mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-22 05:59:31 +08:00
* 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
462 lines
20 KiB
TypeScript
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);
|
|
});
|
|
});
|