Files
scriptc/tests/harness/library-callbacks.test.ts
Chris Tate 81b70b0ee6 feat: add host-callback channels to library mode (#148)
* feat: add host-callback channels to library mode

- the profile's `callbacks` array declares named channels and
  `abi.callback_register_symbol` names the one registration entry point:
  `int32_t <sym>(const char *name, void (*fn)(void), void *ctx)` — 0 on
  success, -1 for an unknown or NULL name, latest registration wins, a
  NULL fn clears, registrations persist across init/reset. The stored
  pointer's typed shape is the channel's with the opaque context first,
  the panic sink's layout
- channel signatures ride the existing marshalling classes: params are
  f64/bool/string/bytes plus the u8/u32/i32 plumbing classes (outbound
  plumbing keeps JS's ToUint32/ToInt32, the executable FFI rule);
  returns are scalar only (f64/bool/u8/u32/i32/void — a buffer return
  needs an ownership contract the mode does not define). string/bytes
  parameters arrive as (ptr, len) pairs borrowed for the call's duration
- compiled code reaches a channel as a signature-only ambient function
  declaration whose direct calls deliver synchronously on the calling
  thread; the recognition rides the FFI import machinery under a library
  flavor: SC4024 refuses a call the channels cannot serve (undeclared
  name or off-class signature), unused channels are legal capacity, and
  callback-free profiles keep the ambient ReferenceError semantics
- calling an unregistered channel is the SC4025 runtime trap: a detected
  trap through the library funnel, so the structured sink message names
  the channel in its text and the entry the host called in its symbol
  field, and profile teachings/remediations overlay it like the rest of
  the runtime family
- registration slots are per copy of the runtime state, the sink's story:
  per-archive under abi.localize_runtime (the register symbol joins the
  localization keep-list), per-thread instance under
  abi.instance_per_thread. The host callback must not reenter any library
  entry or unwind across library frames
- both emissions produce identical behavior by construction: the slot
  store/fetch lives in scr_library.c (scr_library_cb_set/_require/_ctx),
  the generated TU emits the registration dispatch and typed indirect
  calls, and the trap text is assembled once at export resolution
- callback-free profiles emit byte-identical program TUs and serialized
  IR, and executable builds are byte-identical end to end
- conformance: the CB suite (tests/harness/library-callbacks.test.ts)
  covers the acceptance stream, unregistered and pre-registration traps,
  symbol exactness, teaching overlays, SC4024 refusals, capacity and
  callback-free postures, the localized+thread-instanced composition
  probe, and ASan reruns; profile-shape refusals join
  library-profile.test.ts

* fix: preserve callback binding identity

* fix: harden library callback contracts

* fix: recognize project callback declarations

* fix: enforce host callback declarations
2026-08-13 21:44:55 -05:00

462 lines
20 KiB
TypeScript

/* Host-callback channels — the library mode's outbound seam (the profile's
* `callbacks` array + `abi.callback_register_symbol`): the panic sink's
* registration pattern generalized so compiled library code delivers bytes
* and scalars to the embedder synchronously, on the calling thread,
* replacing file-based relay between a long-running operation and its
* host. Every fixture runs per emission and outputs must be identical
* across the two (the library suites' reference/differential posture).
*
* CB1 acceptance registration return codes (0 known, -1
* unknown/NULL), a service-shaped export
* streaming N chunks through the bytes+u32
* channel interleaved with computation — chunk
* contents, order, and thread identity recorded
* by the callback and asserted after the entry
* returns — string/bool and u8/i32→u32 channels,
* host returns riding i32/u32 back into compiled
* code, re-registration routing to the new
* context, and a NULL-fn clear followed by a
* call: the SC4025 structured trap, exactly
* once, naming the entry the host called
* CB2 unregistered call a channel the host never registered traps
* SC4025 through the sink (default text names
* the channel; fields=3 — no remediation on a
* teachings-free profile) and poisons the
* instance: the next entry aborts
* CB3 pre-registration the same trap before sink registration aborts
* CB4 symbols + audit prefix-carrying external definitions equal the
* declared set exactly (the register symbol
* included); the ambient audit holds
* CB5 teaching overlay SC4025 joins the runtime detected-trap family:
* a teachings-declared profile overlays its text
* and remediation (fields=4) through the
* existing overlay table
* CB6 refusals SC4024 — a call of a program-authored
* signature-only ambient function the profile's
* channels cannot serve (undeclared name, or a
* TypeScript signature off the declared
* classes), decorated with the profile's SC4024
* teaching; a callback-free profile keeps the
* ambient ReferenceError lowering (the standing
* guarantee), and an unused declared channel is
* legal capacity; standard/package declaration-file
* ambient names stay builtins rather than becoming
* channels, project-owned .d.ts declarations remain
* authored callback surface (including undeclared
* channel refusals), narrowed scalar return types
* refuse because they cannot cover the host ABI's
* whole domain, and unsupported program-authored
* ambient shapes refuse instead of disappearing
* (SC4001 profile shapes live in library-profile.test.ts)
* CB7 composition a runtime-localized AND thread-instanced
* archive with channels: two embedder threads
* register different contexts, chunks route to
* the registering thread's instance only, a
* trap on thread A reaches A's sink exactly
* once while B streams through and after the
* window; the localized external surface is
* exactly the declared set
* CB8 sanitized lane CB1 and CB2 re-run under ASan
*/
import { execFileSync, spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { compileLibrary } from "@scriptc/compiler";
const repoRoot = join(import.meta.dirname, "../..");
const fixtureDir = join(repoRoot, "tests/library-mode/callbacks");
const platformTest = process.env["SCRIPTC_PORTABLE_ONLY"] === "1" ? test.skip : test;
const localizationTest =
process.env["SCRIPTC_PORTABLE_ONLY"] === "1" ||
!(process.platform === "darwin" || process.platform === "linux" || process.platform === "win32")
? test.skip
: platformTest;
const flavor = process.env["SCRIPTC_SAN"] === "1" ? "san" : "plain";
const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests/library-callbacks", flavor);
type Emission = "llvm" | "c";
const EMISSIONS: Emission[] = ["llvm", "c"];
interface BuildOpts {
sanitize?: boolean;
/** Base profile file inside the fixture dir (default profile.json). */
profileFile?: string;
/** Patched into the profile before compiling (CB5's overlay build). */
determinism?: Record<string, unknown>;
/** Entry override relative to the fixture dir (CB6's refusal sources). */
entry?: string;
/** Drop the callbacks surface entirely (CB6's callback-free posture). */
stripCallbacks?: boolean;
tag?: string;
}
async function buildLibrary(
emission: Emission,
opts: BuildOpts = {},
): Promise<{ archive: string; outDir: string }> {
const tag = `${opts.tag ?? "callbacks"}-${emission}${opts.sanitize ? "-san" : ""}`;
const outDir = join(cacheDir, tag);
mkdirSync(outDir, { recursive: true });
const profile = JSON.parse(readFileSync(join(fixtureDir, opts.profileFile ?? "profile.json"), "utf8")) as {
entry: string;
emission: string;
abi: Record<string, unknown>;
callbacks?: unknown;
determinism?: unknown;
};
profile.emission = emission;
profile.entry = join(fixtureDir, opts.entry ?? profile.entry);
if (opts.determinism !== undefined) profile.determinism = opts.determinism;
if (opts.stripCallbacks === true) {
delete profile.callbacks;
delete profile.abi["callback_register_symbol"];
}
const profilePath = join(outDir, "profile.json");
writeFileSync(profilePath, JSON.stringify(profile, null, 2));
const result = await compileLibrary({ profilePath, outDir, sanitize: opts.sanitize ?? false });
if (!result.ok) {
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
}
expect(result.backend).toBe(emission);
return { archive: result.archivePath, outDir };
}
/** Compile expecting refusal; returns the diagnostics. */
async function buildRefusal(emission: Emission, opts: BuildOpts): Promise<{ code: string; message: string; note?: string }[]> {
const tag = `${opts.tag ?? "refusal"}-${emission}`;
const outDir = join(cacheDir, tag);
mkdirSync(outDir, { recursive: true });
const profile = JSON.parse(readFileSync(join(fixtureDir, opts.profileFile ?? "profile.json"), "utf8")) as {
entry: string;
emission: string;
determinism?: unknown;
};
profile.emission = emission;
profile.entry = join(fixtureDir, opts.entry ?? profile.entry);
if (opts.determinism !== undefined) profile.determinism = opts.determinism;
const profilePath = join(outDir, "profile.json");
writeFileSync(profilePath, JSON.stringify(profile, null, 2));
const result = await compileLibrary({ profilePath, outDir });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("unreachable");
return result.diagnostics.map((d) => ({ code: d.code, message: d.message, ...(d.note !== undefined ? { note: d.note } : {}) }));
}
function buildProbe(
source: string,
archive: string,
outDir: string,
opts: { sanitize?: boolean; pthread?: boolean } = {},
): string {
const bin = join(outDir, "probe");
execFileSync("clang", [
"-std=c11",
...(opts.sanitize ? ["-fsanitize=address"] : []),
...(opts.pthread ? ["-pthread"] : []),
join(fixtureDir, source),
archive,
"-lm",
"-o", bin,
]);
return bin;
}
function runProbe(bin: string, args: string[] = []): { stdout: string; status: number | null; signal: string | null } {
const r = spawnSync(bin, args, { encoding: "utf8", timeout: 60_000 });
return { stdout: r.stdout ?? "", status: r.status, signal: r.signal };
}
function nmSymbols(archive: string): { defined: Set<string>; undef: Set<string> } {
const parse = (out: string): Set<string> => {
const set = new Set<string>();
for (const line of out.split("\n")) {
const sym = line.trim().split(/\s+/).pop();
if (sym === undefined || sym === "" || sym.endsWith(":")) continue;
set.add(sym.replace(/^_/, ""));
}
return set;
};
const defined = parse(execFileSync("nm", ["-gU", archive], { encoding: "utf8" }));
const undef = parse(execFileSync("nm", ["-u", archive], { encoding: "utf8" }));
return { defined, undef };
}
const RUN_EXPECTED = `reg unknown: -1
reg null-name: -1
reg emitChunk: 0
reg progress: 0
reg note: 0
reg mix: 0
callbacks ready
stream(4,3) = 31
log_a: 4 chunk(s), thread_ok=1
seq=0 len=3 bytes=A3!
seq=1 len=3 bytes=B4!
seq=2 len=3 bytes=C5!
seq=3 len=3 bytes=D6!
notes: [chunk 0 away last=0][chunk 1 away last=0][chunk 2 away last=0][chunk 3 away last=1]
stream(2,7) = 23
log_a: 2 chunk(s), thread_ok=1
seq=0 len=3 bytes=A7!
seq=1 len=3 bytes=B8!
notes: [chunk 0 away last=0][chunk 1 away last=1]
askHost(5) = 64
reg emitChunk again: 0
stream(1,9) = 12
log_a after reroute: 0 chunk(s)
log_b: 1 chunk(s), thread_ok=1
seq=0 len=3 bytes=A9!
reg emitChunk clear: 0
sink[1]:
text=[scriptc: library callback 'emitChunk' invoked before registration
]
code=[SC4025]
symbol=[cb_stream]
fields=3 text_printable=1
addr: nonzero
survived, sink_calls=1
`;
const ORPHAN_EXPECTED = `callbacks ready
sink[1]:
text=[scriptc: library callback 'orphan' invoked before registration
]
code=[SC4025]
symbol=[cb_poke_orphan]
fields=3 text_printable=1
addr: nonzero
survived, sink_calls=1
`;
const CALLBACK_SYMBOLS = [
"cb_init", "cb_set_panic_sink", "cb_collect", "cb_set_callback",
"cb_stream", "cb_ask_host", "cb_poke_orphan",
];
describe.each(EMISSIONS)("library host callbacks, %s emission", (emission) => {
platformTest("CB1/CB4: the acceptance run, symbol exactness, ambient audit", async () => {
const { archive, outDir } = await buildLibrary(emission);
const probe = buildProbe("probe.c", archive, outDir, { pthread: true });
const run = runProbe(probe, ["run"]);
expect(run.signal).toBeNull();
expect(run.status).toBe(0);
expect(run.stdout).toBe(RUN_EXPECTED);
// CB4: prefix-carrying external definitions equal the declared set
// (the register symbol included), no prefix-carrying undefineds, and
// the ambient audit holds with the callback machinery linked.
const { defined, undef } = nmSymbols(archive);
expect([...defined].filter((s) => s.startsWith("cb_")).sort()).toEqual([...CALLBACK_SYMBOLS].sort());
expect([...undef].filter((s) => s.startsWith("cb_"))).toEqual([]);
for (const banned of ["sigaction", "signal", "pthread_create", "atexit", "setvbuf"]) {
expect(undef.has(banned), `undefined reference to ${banned}`).toBe(false);
}
});
platformTest("CB2: an unregistered channel traps SC4025 and poisons the instance", async () => {
const { archive, outDir } = await buildLibrary(emission);
const probe = buildProbe("probe.c", archive, outDir, { pthread: true });
// The sink's longjmp survives the trap; the poisoned instance then
// aborts the next entry deterministically.
const run = runProbe(probe, ["orphan"]);
expect(run.signal).toBe("SIGABRT");
expect(run.stdout).toBe(ORPHAN_EXPECTED);
expect(run.stdout.includes("UNREACHABLE")).toBe(false);
});
platformTest("CB3: the unregistered-channel trap before sink registration aborts", async () => {
const { archive, outDir } = await buildLibrary(emission);
const probe = buildProbe("probe.c", archive, outDir, { pthread: true });
const run = runProbe(probe, ["preregister"]);
expect(run.signal).toBe("SIGABRT");
expect(run.stdout).toBe("callbacks ready\n");
});
platformTest("CB5: SC4025 rides the teaching overlay table", async () => {
const { archive, outDir } = await buildLibrary(emission, {
tag: "teach",
determinism: {
teachings: { SC4025: "register every channel before starting an operation" },
remediations: { SC4025: "call cb_set_callback for the named channel" },
},
});
const probe = buildProbe("probe.c", archive, outDir, { pthread: true });
const run = runProbe(probe, ["orphan"]);
expect(run.signal).toBe("SIGABRT");
expect(run.stdout).toBe(`callbacks ready
sink[1]:
text=[register every channel before starting an operation]
code=[SC4025]
symbol=[cb_poke_orphan]
remediation=[call cb_set_callback for the named channel]
fields=4 text_printable=1
addr: nonzero
survived, sink_calls=1
`);
});
test("CB6: an undeclared host-callback reference refuses SC4024 with the profile teaching", async () => {
const diags = await buildRefusal(emission, {
tag: "undeclared",
entry: "lib_undeclared.ts",
determinism: { teachings: { SC4024: "channels are declared in the embedder profile" } },
});
expect(diags.map((d) => d.code)).toEqual(["SC4024"]);
expect(diags[0]!.message).toContain("library callback 'sneaky'");
expect(diags[0]!.message).toContain("declares no callback channel");
expect(diags[0]!.note).toContain("channels are declared in the embedder profile");
});
test("CB6: an undeclared callback in a project .d.ts also refuses SC4024", async () => {
const diags = await buildRefusal(emission, {
tag: "undeclared-project-dts",
entry: "lib_undeclared_project_dts.ts",
determinism: { teachings: { SC4024: "channels are declared in the embedder profile" } },
});
expect(diags.map((d) => d.code)).toEqual(["SC4024"]);
expect(diags[0]!.message).toContain("library callback 'sneaky'");
expect(diags[0]!.message).toContain("declares no callback channel");
expect(diags[0]!.note).toContain("channels are declared in the embedder profile");
});
test("CB6: callback returns must cover the full scalar ABI domain", async () => {
const diags = await buildRefusal(emission, {
tag: "narrow-returns",
profileFile: "profile_narrow_returns.json",
});
expect(diags.map((d) => d.code)).toEqual(["SC4024", "SC4024"]);
expect(diags[0]!.message).toContain("library callback 'answerBool'");
expect(diags[0]!.message).toContain("return type is 'true'");
expect(diags[0]!.message).toContain("may supply any boolean");
expect(diags[1]!.message).toContain("library callback 'answerNumber'");
expect(diags[1]!.message).toContain("return type is '0'");
expect(diags[1]!.message).toContain("may supply any number");
});
test("CB6: a declaration off the channel's classes refuses SC4024", async () => {
const diags = await buildRefusal(emission, { tag: "mismatch", entry: "lib_mismatch.ts" });
expect(diags.map((d) => d.code)).toEqual(["SC4024"]);
expect(diags[0]!.message).toContain("library callback 'emitChunk'");
expect(diags[0]!.message).toContain("does not fit profile class 'bytes'");
});
test("CB6: a called function-valued ambient refuses SC4024 instead of disappearing", async () => {
const diags = await buildRefusal(emission, { tag: "ambient-const", entry: "lib_ambient_const.ts" });
expect(diags.map((d) => d.code)).toEqual(["SC4024"]);
expect(diags[0]!.message).toContain("library callback 'orphan'");
expect(diags[0]!.message).toContain("does not resolve exclusively to signature-only function declarations");
});
platformTest("CB6: a callback-free profile keeps the ambient ReferenceError lowering", async () => {
// The standing guarantee's behavioral half: without a callbacks
// section the same signature-only declaration keeps Node's
// ReferenceError semantics — the call throws, and the escaped
// exception reaches the sink as SC4013, never SC4024/SC4025.
const { archive, outDir } = await buildLibrary(emission, {
tag: "no-callbacks",
entry: "lib_undeclared.ts",
stripCallbacks: true,
});
const probe = buildProbe("probe_referror.c", archive, outDir);
const run = runProbe(probe);
expect(run.signal).toBeNull();
expect(run.status).toBe(0);
expect(run.stdout).toBe(`sink[1]:
code=[SC4013]
symbol=[cb_stream]
text has ReferenceError: 1
survived, sink_calls=1
`);
});
platformTest("CB6: a declared channel no code references is legal capacity", async () => {
// lib_unused.ts never mentions 'orphan' (or the other channels); the
// build succeeds and the registration symbol still answers for every
// declared name.
const { archive, outDir } = await buildLibrary(emission, { tag: "unused", entry: "lib_unused.ts" });
const probe = buildProbe("probe_unused.c", archive, outDir);
const run = runProbe(probe);
expect(run.signal).toBeNull();
expect(run.status).toBe(0);
expect(run.stdout).toBe(`reg orphan: 0
reg emitChunk: 0
unused ready
stream(3,7) = 42
`);
});
platformTest("CB6: declaration-file ambient names remain builtins and C-keyword channels stay indirect", async () => {
const { archive, outDir } = await buildLibrary(emission, {
tag: "builtin-names",
profileFile: "profile_builtins.json",
});
const probe = buildProbe("probe_builtins.c", archive, outDir);
const run = runProbe(probe);
expect(run.signal).toBeNull();
expect(run.status).toBe(0);
expect(run.stdout).toBe(`finite: 0
nan: 1
keyword: 7.5
`);
});
platformTest("CB6: a callback declared in a project .d.ts remains authored surface", async () => {
const { archive, outDir } = await buildLibrary(emission, {
tag: "project-dts",
entry: "lib_project_dts.ts",
});
const probe = buildProbe("probe_project_dts.c", archive, outDir);
const run = runProbe(probe);
expect(run.signal).toBeNull();
expect(run.status).toBe(0);
expect(run.stdout).toBe(`seq=2 len=2 bytes=C7
result=9
`);
});
localizationTest("CB7: localized + thread-instanced channels route per instance", async () => {
const { archive, outDir } = await buildLibrary(emission, { tag: "threads", profileFile: "profile_t.json" });
const probe = buildProbe("probe_threads.c", archive, outDir, { pthread: true });
const run = runProbe(probe);
expect(run.signal).toBeNull();
expect(run.status).toBe(0);
expect(run.stdout).toBe(`callbacks ready
callbacks ready
A: r1=13 r2=0 chunks=3 thread_ok=1 sink_calls=1
seq=0 len=3 bytes=A2!
seq=1 len=3 bytes=B3!
seq=2 len=3 bytes=C4!
sink code=SC4025 symbol=cbt_poke_orphan ctx_ok=1
B: r1=31 r2=6 chunks=4 thread_ok=1 sink_calls=0
seq=0 len=3 bytes=A5!
seq=1 len=3 bytes=B6!
seq=2 len=3 bytes=C7!
seq=0 len=3 bytes=A4!
`);
// The localized external surface is exactly the declared set — the
// register symbol rides the localization keep-list.
const { defined, undef } = nmSymbols(archive);
const prefixDefined = [...defined].filter((s) => s.startsWith("cbt_")).sort();
expect(prefixDefined).toEqual([
"cbt_ask_host", "cbt_init", "cbt_poke_orphan",
"cbt_set_callback", "cbt_set_panic_sink", "cbt_stream",
]);
expect([...undef].filter((s) => s.startsWith("cbt_"))).toEqual([]);
});
/* ── CB8: the sanitized lane ─────────────────────────────────────────── */
platformTest("CB8: CB1/CB2 under ASan", async () => {
const { archive, outDir } = await buildLibrary(emission, { sanitize: true });
const probe = buildProbe("probe.c", archive, outDir, { sanitize: true, pthread: true });
const run = runProbe(probe, ["run"]);
expect(run.signal).toBeNull();
expect(run.status).toBe(0);
expect(run.stdout).toBe(RUN_EXPECTED);
const orphan = runProbe(probe, ["orphan"]);
expect(orphan.signal).toBe("SIGABRT");
expect(orphan.stdout).toBe(ORPHAN_EXPECTED);
});
});