fix(library): reject callback re-entry (#270)

Co-authored-by: mmamedel <23098414+mmamedel@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-08-30 19:30:31 -05:00
committed by GitHub
co-authored by mmamedel
parent 4d9e2a2397
commit 2b6e6524a3
15 changed files with 513 additions and 60 deletions
+4
View File
@@ -4,6 +4,10 @@ All notable changes to scriptc will be documented in this file.
## Unreleased
### Fixes
- **Library host-callback re-entry now traps deterministically.** Entering an export or library control/registration ABI symbol while a synchronous host callback is active delivers the structured `SC4026` diagnostic to the existing panic sink, attributes the attempted inner symbol, and poisons only that library instance.
### Features
- **macOS arm64 executables use release-built runtime packs.** LLVM-tier builds now emit the program object through the bundled helper and link feature-selected, hashed runtime/vendor artifacts without compiling C on the user's machine. Explicit C, LLVM fallback, and sanitizer builds retain the external C-toolchain path.
+4 -1
View File
@@ -1135,6 +1135,7 @@ export class CEmitter {
`}`,
``,
`void ${lib.sinkRegisterSymbol}(void (*fn)(void *ctx, const uint8_t *msg, size_t msg_len, uint64_t address), void *ctx) {`,
` scr_library_callback_entry_guard("${lib.sinkRegisterSymbol}");`,
` scr_library_set_sink(fn, ctx);`,
`}`,
``,
@@ -1142,11 +1143,13 @@ export class CEmitter {
if (lib.callbacks !== undefined && lib.callbacks.length > 0) {
// Host-callback registration: a pure store dispatch (the sink
// registration's rule — no entry prologue, no poison guard, legal
// before init). The channel name selects the slot; an unknown or
// before init) except the first operation rejects callback-time
// re-entry (SC4026). The channel name selects the slot; an unknown or
// NULL name is a defined -1, never a store. Latest registration
// wins; a NULL fn clears the channel.
out.push(
`int32_t ${lib.callbackRegisterSymbol}(const char *name, void (*fn)(void), void *ctx) {`,
` scr_library_callback_entry_guard("${lib.callbackRegisterSymbol}");`,
` if (name == NULL) return -1;`,
);
for (const cb of lib.callbacks) {
+68 -21
View File
@@ -2095,7 +2095,9 @@ function emitCallExpr(
// channel (the library lane loads no native-FFI manifest). The
// dispatch fetches the slot's registered pointer — or delivers the
// channel's unregistered-call trap through the funnel (SC4025) —
// then makes the typed indirect call, opaque context first.
// then brackets only the typed indirect call, opaque context first.
// The bracket makes callback-time ABI re-entry a deterministic
// SC4026 trap before an inner entry can mutate runtime state.
// Marshalling matches the native ffiCall's value classes exactly:
// buffers are borrowed (ptr, len) for the call's duration, the
// u8/u32/i32 plumbing classes ride JS's ToUint32/ToInt32, and a
@@ -2105,35 +2107,56 @@ function emitCallExpr(
if (libCb !== undefined) {
const cbArgs = e.args.map((arg) => emitter.emitExpr(arg));
const natTypes: string[] = ["void *"];
const natArgs: string[] = [`scr_library_cb_ctx(${libCb.slot})`];
const natArgs: string[] = [];
libCb.params.forEach((cls, i) => {
const arg = cbArgs[i]!;
const native = (): string => `sc_t${emitter.tempCounter++}`;
switch (cls) {
case "f64":
case "f64": {
const value = native();
emitter.line(`double ${value} = ${arg.name};`);
natTypes.push("double");
natArgs.push(arg.name);
natArgs.push(value);
break;
case "bool":
}
case "bool": {
const value = native();
emitter.line(`uint8_t ${value} = (uint8_t)(${arg.name} ? 1 : 0);`);
natTypes.push("uint8_t");
natArgs.push(`(uint8_t)(${arg.name} ? 1 : 0)`);
natArgs.push(value);
break;
case "u8":
}
case "u8": {
const value = native();
emitter.line(`uint8_t ${value} = (uint8_t)(uint32_t)scr_bit_ushr(${arg.name}, 0.0);`);
natTypes.push("uint8_t");
natArgs.push(`(uint8_t)(uint32_t)scr_bit_ushr(${arg.name}, 0.0)`);
natArgs.push(value);
break;
case "u32":
}
case "u32": {
const value = native();
emitter.line(`uint32_t ${value} = (uint32_t)scr_bit_ushr(${arg.name}, 0.0);`);
natTypes.push("uint32_t");
natArgs.push(`(uint32_t)scr_bit_ushr(${arg.name}, 0.0)`);
natArgs.push(value);
break;
case "i32":
}
case "i32": {
const value = native();
emitter.line(`int32_t ${value} = (int32_t)scr_bit_or(${arg.name}, 0.0);`);
natTypes.push("int32_t");
natArgs.push(`(int32_t)scr_bit_or(${arg.name}, 0.0)`);
natArgs.push(value);
break;
}
case "string":
case "bytes":
case "bytes": {
const ptr = native();
const len = native();
emitter.line(`const uint8_t *${ptr} = (const uint8_t *)${arg.name}->data;`);
emitter.line(`size_t ${len} = ${arg.name}->len;`);
natTypes.push("const uint8_t *", "size_t");
natArgs.push(`(const uint8_t *)${arg.name}->data`, `${arg.name}->len`);
natArgs.push(ptr, len);
break;
}
}
});
const retC =
@@ -2143,18 +2166,42 @@ function emitCallExpr(
: libCb.returns === "u32" ? "uint32_t"
: "int32_t";
const trapLit = cStringLiteral(Buffer.from(libCb.unregisteredTrap, "utf8"));
const target = `((${retC} (*)(${natTypes.join(", ")}))scr_library_cb_require(${libCb.slot}, ${trapLit}))`;
const call = `${target}(${natArgs.join(", ")})`;
// Materialize the pointer and context before the callback-active
// bracket. This keeps the existing SC4025 fetch path outside the
// bracket and avoids C argument evaluation-order ambiguity.
const fn = `sc_t${emitter.tempCounter++}`;
emitter.line(`${retC} (*${fn})(${natTypes.join(", ")}) = (${retC} (*)(${natTypes.join(", ")}))scr_library_cb_require(${libCb.slot}, ${trapLit});`);
const ctx = `sc_t${emitter.tempCounter++}`;
emitter.line(`void *${ctx} = scr_library_cb_ctx(${libCb.slot});`);
const call = `${fn}(${[ctx, ...natArgs].join(", ")})`;
switch (libCb.returns) {
case "void":
emitter.line(`scr_library_callback_begin();`);
emitter.line(`${call};${emitter.srcComment(e.loc)}`);
emitter.line(`scr_library_callback_end();`);
return { name: "", type: e.type };
case "f64":
return emitter.newTemp(e.type, call);
case "f64": {
const raw = `sc_t${emitter.tempCounter++}`;
emitter.line(`scr_library_callback_begin();`);
emitter.line(`${retC} ${raw} = ${call};${emitter.srcComment(e.loc)}`);
emitter.line(`scr_library_callback_end();`);
return emitter.newTemp(e.type, raw);
}
case "bool":
return emitter.newTemp(e.type, `(${call} != 0)`);
default: // u8/u32/i32 — exact widenings back to f64
return emitter.newTemp(e.type, `(double)${call}`);
{
const raw = `sc_t${emitter.tempCounter++}`;
emitter.line(`scr_library_callback_begin();`);
emitter.line(`${retC} ${raw} = ${call};${emitter.srcComment(e.loc)}`);
emitter.line(`scr_library_callback_end();`);
return emitter.newTemp(e.type, `(${raw} != 0)`);
}
default: { // u8/u32/i32 — exact widenings back to f64
const raw = `sc_t${emitter.tempCounter++}`;
emitter.line(`scr_library_callback_begin();`);
emitter.line(`${retC} ${raw} = ${call};${emitter.srcComment(e.loc)}`);
emitter.line(`scr_library_callback_end();`);
return emitter.newTemp(e.type, `(double)${raw}`);
}
}
}
const entry = emitter.ffiByName.get(e.import);
@@ -998,6 +998,7 @@ class LlEmitter {
this.declare(`declare void @scr_library_reset()`);
this.declare(`declare void @scr_library_check_exc()`);
this.declare(`declare void @scr_library_set_sink(ptr, ptr)`);
this.declare(`declare void @scr_library_callback_entry_guard(ptr)`);
this.declare(`declare void @scr_library_arena_reset()`);
this.declare(`declare void @scr_library_collect()`);
if ((this.mod.lib.callbacks?.length ?? 0) > 0) {
@@ -1429,6 +1430,8 @@ class LlEmitter {
out.push(`${symConst(sym)} = internal constant [${Buffer.byteLength(sym, "utf8") + 1} x i8] c"${llStrBytes(sym)}"`);
};
emitSymConst(lib.initSymbol);
emitSymConst(lib.sinkRegisterSymbol);
if (lib.callbackRegisterSymbol !== null && lib.callbackRegisterSymbol !== undefined) emitSymConst(lib.callbackRegisterSymbol);
if (lib.resultResetSymbol !== null) emitSymConst(lib.resultResetSymbol);
if (lib.collectSymbol !== null) emitSymConst(lib.collectSymbol);
for (const e of lib.exports) emitSymConst(e.symbol);
@@ -1485,6 +1488,7 @@ class LlEmitter {
``,
`define void @${lib.sinkRegisterSymbol}(ptr %fn, ptr %ctx) ${FN_ATTRS} {`,
`entry:`,
` call void @scr_library_callback_entry_guard(ptr ${symConst(lib.sinkRegisterSymbol)})`,
` call void @scr_library_set_sink(ptr %fn, ptr %ctx)`,
` ret void`,
`}`,
@@ -1497,7 +1501,8 @@ class LlEmitter {
// scr_library_cb_require operands — same bytes as the C emission by
// construction), and the registration define: a pure store dispatch
// (the sink registration's rule — no entry prologue, no poison
// guard). An unknown or NULL name is a defined -1, never a store.
// guard) whose first operation rejects callback-time re-entry
// (SC4026). An unknown or NULL name is a defined -1, never a store.
for (const cb of lib.callbacks) {
out.push(
`@sc_lib_cb_name_${cb.slot} = internal constant [${Buffer.byteLength(cb.name, "utf8") + 1} x i8] c"${llStrBytes(cb.name)}"`,
@@ -1508,6 +1513,7 @@ class LlEmitter {
``,
`define i32 @${lib.callbackRegisterSymbol}(ptr %name, ptr %fn, ptr %ctx) ${FN_ATTRS} {`,
`entry:`,
` call void @scr_library_callback_entry_guard(ptr ${symConst(lib.callbackRegisterSymbol!)})`,
` %isnull = icmp eq ptr %name, null`,
` br i1 %isnull, label %miss, label %try0`,
);
@@ -42,8 +42,9 @@ export function emitCallExpr(host: LlvmEmitterContext, e: ExprOf<"call" | "ffiCa
// channel (the library lane loads no native-FFI manifest). Fetch
// the slot's registered pointer — scr_library_cb_require delivers
// the channel's trap constant through the funnel (SC4025) when the
// host never registered — then the typed indirect call, opaque
// context first. Marshalling matches the native ffiCall's value
// host never registered — then brackets the typed indirect call,
// opaque context first. The bracket makes callback-time ABI re-entry
// a deterministic SC4026 trap. Marshalling matches the native ffiCall's value
// classes exactly; the host cannot raise a scriptc exception, so
// no pending check follows.
const libCb = host.mod.lib?.callbacks?.find((c) => c.name === e.import);
@@ -121,6 +122,8 @@ export function emitCallExpr(host: LlvmEmitterContext, e: ExprOf<"call" | "ffiCa
});
host.declare(`declare ptr @scr_library_cb_require(${host.sizeType}, ptr)`);
host.declare(`declare ptr @scr_library_cb_ctx(${host.sizeType})`);
host.declare(`declare void @scr_library_callback_begin()`);
host.declare(`declare void @scr_library_callback_end()`);
const fn = B.tmp();
B.line(`${fn} = call ptr @scr_library_cb_require(${host.sizeType} ${libCb.slot}, ptr @sc_lib_cb_trap_${libCb.slot})`);
const ctx = B.tmp();
@@ -128,11 +131,15 @@ export function emitCallExpr(host: LlvmEmitterContext, e: ExprOf<"call" | "ffiCa
const retTy = ffiNativeTypeLl(libCb.returns);
const call = `call ${retTy} ${fn}(${[`ptr ${ctx}`, ...natArgs].join(", ")})`;
if (libCb.returns === "void") {
B.line(`call void @scr_library_callback_begin()`);
B.line(call);
B.line(`call void @scr_library_callback_end()`);
return { name: "", type: e.type };
}
const raw = B.tmp();
B.line(`call void @scr_library_callback_begin()`);
B.line(`${raw} = ${call}`);
B.line(`call void @scr_library_callback_end()`);
if (libCb.returns === "f64") return { name: raw, type: e.type };
if (libCb.returns === "bool") {
const value = B.tmp();
@@ -42,9 +42,11 @@
* not fit ±(2^53 − 1), or is negative at a u64 slot), the
* host-callback surface (SC4024 — a signature-only ambient
* function reference the profile's callbacks section cannot
* serve), and the unregistered-callback runtime trap (SC4025 —
* a structured trap-teaching code in the funnel-classified
* family, not a refusal)
* serve), the unregistered-callback runtime trap (SC4025 — a
* structured trap-teaching code in the funnel-classified family,
* not a refusal), and callback-time library re-entry (SC4026 —
* the distinct detected trap for an attempted ABI entry while a
* host callback handler is active)
* SC5xxx native FFI: malformed manifests (SC5001), a configured
* binding that is not an ambient function declaration
* (SC5002), and a TypeScript signature that does not match its
@@ -1002,6 +1004,10 @@ export const LIB_INBOUND_BYTES_TRAP_CODE = "SC4012";
* like SC4012, but the trap site is inside compiled code, so
* the funnel assembles it and field 2 names the entry the
* host called)
* SC4026 library ABI entry invoked from a host callback ("scriptc:
* library entry ..."): the callback-time guard poisons the
* affected instance and the funnel assembles the attempted inner
* ABI symbol in field 2
*
* There is no arithmetic/div-by-zero kind: JS division never traps, so the
* runtime has no such site. The list here is the compile-time face of the
@@ -1016,6 +1022,7 @@ export const LIB_RUNTIME_TRAP_CODES = [
"SC4018",
"SC4019",
"SC4025",
"SC4026",
] as const;
/** SC4024 — a host-callback reference the profile cannot serve. Library
@@ -206,6 +206,9 @@
* refusal, never a store). Latest registration wins; a NULL fn clears the
* channel; registration is a pure store — no entry prologue, no poison
* guard, legal before init — and registrations persist across init/reset.
* Its first operation still rejects an active host callback, before even a
* NULL-name check or dispatch, so callback-time registration cannot mutate
* a slot or hide a re-entry error.
* Calling a channel the host never registered is the SC4025 runtime trap
* through the panic sink (structured, naming the channel in the text and
* the entry the host called in the symbol field): register every channel
@@ -220,11 +223,17 @@
*
* Reentrancy is pinned like the sink's rule: a callback runs on the
* calling thread, inside the entry's dynamic extent, and must NOT call
* back into any library entry (the registration symbols included) or
* unwind/longjmp across library frames — read the borrowed buffers, hand
* the bytes to the embedder's own structures, return. The async_free
* posture is unchanged: a channel adds no event loop, no threads, and no
* reentry into the archive.
* back into any library entry (exports, init, result reset, collect, panic
* sink registration, or callback registration) or unwind/longjmp across
* library frames — read the borrowed buffers, hand the bytes to the
* embedder's own structures, return. An attempted entry is SC4026: it
* poisons only the affected instance, delivers once through the registered
* sink, and places the attempted inner ABI symbol in structured field 2;
* a sink that returns aborts. A later host-loop turn may enter after the
* callback returns. The sidecar identity getters are the explicit pure-data
* exception: they touch no mutable runtime state and remain callable before
* init and after poison. The async_free posture is unchanged: a channel
* adds no event loop, no threads, and no reentry into the archive.
*
* Marshalling classes (design §4.2 + session ruling 3 + ask 4): f64, bool,
* string, bytes for params and returns; u8/u32/i32 are PARAM-ONLY plumbing
+36 -4
View File
@@ -64,6 +64,10 @@ void scr_library_set_sink(ScrLibSinkFn fn, void *ctx) {
static SCR_TL ScrLibCbFn scr_library_cb_fns[SCR_LIB_MAX_CALLBACKS];
static SCR_TL void *scr_library_cb_ctxs[SCR_LIB_MAX_CALLBACKS];
/* A typed host callback runs synchronously inside one ABI entry. Like the
* sink, slots, poison, arena, and current-entry symbol, this belongs to the
* localized / thread-instanced library copy rather than the process. */
static SCR_TL size_t scr_library_callback_depth = 0;
void scr_library_cb_set(size_t slot, ScrLibCbFn fn, void *ctx) {
/* A pure store, the sink registration's rule: latest wins, NULL clears,
@@ -82,6 +86,15 @@ ScrLibCbFn scr_library_cb_require(size_t slot, const char *trap_msg) {
void *scr_library_cb_ctx(size_t slot) { return scr_library_cb_ctxs[slot]; }
void scr_library_callback_begin(void) { scr_library_callback_depth++; }
void scr_library_callback_end(void) {
/* Generated calls pair this only with a normally returned host callback.
* A callback that illegally unwinds leaves the depth set, so a later ABI
* entry fails deterministically instead of silently reusing the instance. */
if (scr_library_callback_depth != 0) scr_library_callback_depth--;
}
/* ── the trap funnel, library expansion ───────────────────────────────────
* Poison first (the sink may longjmp to a host frame below the entry — the
* conforming survival pattern), then deliver exactly once, then abort:
@@ -111,8 +124,9 @@ void *scr_library_cb_ctx(size_t slot) { return scr_library_cb_ctxs[slot]; }
* external symbol before dispatching into core code. A single static slot
* is sound — exactly one core is live per copy of this state (the sole
* core in a classic process; each archive's own instance under
* abi.localize_runtime, where this slot is a per-instance local), entries
* never nest, and a trap can only fire while an entry is on the stack.
* abi.localize_runtime, where this slot is a per-instance local), re-entry
* from a host callback is rejected before core work begins, and a trap can
* only fire while an entry is on the stack.
* NULL (never entered) renders as the empty symbol field. */
static SCR_TL const char *scr_library_entry_symbol = NULL;
@@ -134,6 +148,7 @@ static const struct {
{"scriptc: out of memory", "SC4017"}, /* allocation failure */
{"scriptc: internal error: ", "SC4018"}, /* internal invariant failure */
{"scriptc: library callback ", "SC4025"}, /* unregistered host callback */
{"scriptc: library entry ", "SC4026"}, /* host callback re-entry */
};
static const char *scr_library_trap_code(const char *msg, size_t len) {
@@ -229,11 +244,28 @@ __attribute__((noinline)) _Noreturn void scr_trap_fmt(const char *fmt, ...) {
/* ── entry prologues ──────────────────────────────────────────────────── */
static _Noreturn void scr_library_callback_reentry(const char *entry_symbol) {
/* Attribute the detected trap to the attempted INNER ABI symbol. Clear
* depth before delivery: a conforming sink may longjmp to a host recovery
* frame, and its poisoned instance must not retain stale callback-active
* state that would change the established pure-registration behavior. */
scr_library_entry_symbol = entry_symbol;
scr_library_callback_depth = 0;
scr_trap_fmt("scriptc: library entry '%s' invoked from a host callback\n", entry_symbol);
}
void scr_library_callback_entry_guard(const char *entry_symbol) {
if (scr_library_callback_depth != 0) scr_library_callback_reentry(entry_symbol);
}
void scr_library_entry(bool reset_arena, const char *entry_symbol) {
/* Record the entry symbol FIRST so even a poisoned-abort's core dump
* names the entry; a trap anywhere below (the arena reset's OOM
* included) then reports the right symbol. */
* names the entry; a trap anywhere below (the arena reset's OOM
* included) then reports the right symbol. */
scr_library_entry_symbol = entry_symbol;
/* Re-entry must win over poison and reset: no inner ABI entry may mutate
* globals, release arena data, collect, or replace state from a callback. */
if (scr_library_callback_depth != 0) scr_library_callback_reentry(entry_symbol);
/* A poisoned library's entries abort deterministically — never through the
* sink again (it received its exactly-once message when the trap fired),
* never into a heap whose invariants already failed. */
+30 -11
View File
@@ -108,7 +108,8 @@ typedef struct ScrBytes ScrBytes;
* Every trap the runtime DETECTS arrives structured: the funnel assembles
* the baseline human line into field 0 unchanged, a stable code for the
* trap kind (the compiler registry's runtime family — SC4013–SC4019 plus
* the SC4025 unregistered-callback trap, classified in scr_library.c), the
* the SC4025 unregistered-callback and SC4026 callback-re-entry traps,
* classified in scr_library.c), the
* entry symbol recorded by the trapping
* entry's prologue, and the profile's remediation for that code when the
* program TU's overlay table declares one (the whole fourth field is
@@ -133,15 +134,22 @@ void scr_library_set_sink(ScrLibSinkFn fn, void *ctx); /* latest wins */
*
* Registration is a pure store like the sink's (no entry prologue, no
* poison guard, legal before init); latest wins, NULL clears, and
* registrations persist across init/reset. Slots are per-copy of this
* registrations persist across init/reset. While a host callback is active,
* its registration entry is rejected before name dispatch or a store, just
* like every runtime-touching ABI entry. Slots are per-copy of this
* state, exactly the sink's story: per-archive under abi.localize_runtime,
* per-thread instance under abi.instance_per_thread (SCR_TL) — a callback
* registered on thread T fires only for T's instance. The host's callback
* runs on the calling thread inside the entry's dynamic extent and must
* NOT call back into any library entry (registration symbols included) or
* unwind/longjmp across library frames: read the borrowed buffers, copy
* what outlives the call, return. Buffer parameters are borrowed for the
* duration of the call only. */
* NOT call back into any library entry (exports, init, reset, collect, sink
* registration, or callback registration) or unwind/longjmp across library
* frames: read the borrowed buffers, copy what outlives the call, return.
* A re-entry is a detected SC4026 trap: it poisons only this library
* instance, delivers exactly once to the already-registered sink, names the
* attempted inner ABI symbol in structured field 2, then aborts if the sink
* returns. A later host-loop turn may enter normally after the callback has
* returned. Buffer parameters are borrowed for the duration of the call
* only. */
#define SCR_LIB_MAX_CALLBACKS 32 /* keep in step with LIB_MAX_CALLBACKS (library/library-profile.ts) */
/* The stored shape: generated call sites cast a slot's pointer to the
* channel's typed shape before calling. */
@@ -151,6 +159,15 @@ void scr_library_cb_set(size_t slot, ScrLibCbFn fn, void *ctx);
* trap_msg (never returns NULL). */
ScrLibCbFn scr_library_cb_require(size_t slot, const char *trap_msg);
void *scr_library_cb_ctx(size_t slot);
/* Generated typed call sites bracket only the actual host-function call.
* End is reached only after a normal return; an illegal unwind deliberately
* leaves the depth active so the next ABI entry is rejected. */
void scr_library_callback_begin(void);
void scr_library_callback_end(void);
/* Registration wrappers bypass scr_library_entry because their normal path
* is a pure store. They call this first so callback-time registration is
* rejected before dispatch, NULL handling, or mutation. */
void scr_library_callback_entry_guard(const char *entry_symbol);
/* Entry prologue: aborts deterministically when the library is poisoned (a
* trap already fired — no profile entry may run again; recovery is process
@@ -159,10 +176,11 @@ void *scr_library_cb_ctx(size_t slot);
* entry_symbol is the generated entry's external symbol exactly as the
* host linked it (a static string in the program TU): the prologue records
* it in the funnel's current-entry slot so a detected trap's structured
* message can name the trapping entry — sound as a single static slot
* because exactly one core is ever live and entries never nest. Init and
* the mode entries (reset, collect) record theirs too; the identity
* getters and sink registration touch no runtime and never trap. */
* message can name the trapping entry. A host callback's attempted nested
* entry is rejected first and replaces this slot with that inner symbol.
* Init and the mode entries (reset, collect) record theirs too. The two
* profile identity getters are the explicit pure-data exception: they touch
* no mutable runtime state and remain callable before init and after poison. */
void scr_library_entry(bool reset_arena, const char *entry_symbol);
void scr_library_arena_reset(void);
/* The mode-provided collect entry's body: arena reset + a full cycle
@@ -200,7 +218,8 @@ _Noreturn void scr_trap_len(const char *msg, size_t len);
* (both emissions emit identical data) and consumed by the funnel when it
* assembles a detected trap's structured message: flat triples of
* (code, teaching-or-NULL, remediation-or-NULL), one per runtime trap code
* (the SC4013–SC4019 family plus SC4025) the profile declares text for;
* (the SC4013–SC4019 family plus SC4025 and SC4026) the profile declares
* text for;
* _len counts triples. A declared teaching replaces the baseline human line as field 0;
* a declared remediation becomes the optional fourth field. */
extern const char *const scr_library_trap_overlays[];
+94 -7
View File
@@ -56,7 +56,12 @@
* 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
* CB8 callback re-entry every ABI entry is rejected as SC4026 while a
* host callback is active; the attempted INNER
* symbol wins, the original sink sees it, pure
* registration retains its post-poison behavior,
* and an independent thread instance survives
* CB9 sanitized lane CB1, CB2, and callback re-entry re-run under ASan
*/
import { execFileSync, spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -88,6 +93,8 @@ interface BuildOpts {
entry?: string;
/** Drop the callbacks surface entirely (CB6's callback-free posture). */
stripCallbacks?: boolean;
/** Keep only exports the alternate callback fixture implements. */
stripBufferedExport?: boolean;
tag?: string;
}
@@ -112,6 +119,9 @@ async function buildLibrary(
delete profile.callbacks;
delete profile.abi["callback_register_symbol"];
}
if (opts.stripBufferedExport === true) {
profile.exports = (profile.exports as { export: string }[]).filter((e) => e.export !== "buffered");
}
const profilePath = join(outDir, "profile.json");
writeFileSync(profilePath, JSON.stringify(profile, null, 2));
const result = await compileLibrary({ profilePath, outDir, sanitize: opts.sanitize ?? false });
@@ -163,7 +173,16 @@ function buildProbe(
}
function runProbe(bin: string, args: string[] = []): { stdout: string; status: number | null; signal: string | null } {
const r = spawnSync(bin, args, { encoding: "utf8", timeout: 60_000 });
// Library poison survival intentionally uses sink longjmp, which abandons
// the active outer operation by contract. LeakSanitizer cannot model that
// non-local recovery, while ASan still checks the memory-safety paths.
const r = spawnSync(bin, args, {
encoding: "utf8",
timeout: 60_000,
env: process.env["SCRIPTC_SAN"] === "1" || bin.includes("-san/")
? { ...process.env, ASAN_OPTIONS: "detect_leaks=0" }
: undefined,
});
return { stdout: r.stdout ?? "", status: r.status, signal: r.signal };
}
@@ -230,10 +249,35 @@ 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",
"cb_init", "cb_set_panic_sink", "cb_collect", "cb_reset_results", "cb_set_callback",
"cb_stream", "cb_buffered", "cb_ask_host", "cb_poke_orphan",
];
const REENTRY_SYMBOLS: Record<string, string> = {
"reenter-export": "cb_stream",
"reenter-init": "cb_init",
"reenter-reset": "cb_reset_results",
"reenter-collect": "cb_collect",
"reenter-sink": "cb_set_panic_sink",
"reenter-callback-unknown": "cb_set_callback",
};
function reentryExpected(symbol: string, overlay = false): string {
const text = overlay ? "return from the callback before calling the library" : `scriptc: library entry '${symbol}' invoked from a host callback\n`;
const remediation = overlay ? "schedule the operation for a later host-loop turn" : undefined;
return `callbacks ready
sink[1]:
text=[${text}]
code=[SC4026]
symbol=[${symbol}]
${remediation === undefined ? "" : `remediation=[${remediation}]\n`}fields=${overlay ? 4 : 3} text_printable=1
addr: nonzero
post-poison register: 0
replacement-sink-calls=0
saved-result=[buffer 7]
`;
}
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);
@@ -296,6 +340,31 @@ survived, sink_calls=1
`);
});
platformTest("CB8: callback-time entries trap SC4026 before mutation and poison the instance", async () => {
const { archive, outDir } = await buildLibrary(emission, { tag: "reentry" });
const probe = buildProbe("probe.c", archive, outDir, { pthread: true });
for (const [mode, symbol] of Object.entries(REENTRY_SYMBOLS)) {
const run = runProbe(probe, [mode]);
expect(run.signal, mode).toBe("SIGABRT");
expect(run.stdout, mode).toBe(reentryExpected(symbol));
expect(run.stdout.includes("UNREACHABLE"), mode).toBe(false);
}
});
platformTest("CB8: SC4026 rides the teaching overlay table with its inner symbol", async () => {
const { archive, outDir } = await buildLibrary(emission, {
tag: "reentry-teach",
determinism: {
teachings: { SC4026: "return from the callback before calling the library" },
remediations: { SC4026: "schedule the operation for a later host-loop turn" },
},
});
const probe = buildProbe("probe.c", archive, outDir, { pthread: true });
const run = runProbe(probe, ["reenter-reset"]);
expect(run.signal).toBe("SIGABRT");
expect(run.stdout).toBe(reentryExpected("cb_reset_results", true));
});
test("CB6: an undeclared host-callback reference refuses SC4024 with the profile teaching", async () => {
const diags = await buildRefusal(emission, {
tag: "undeclared",
@@ -357,6 +426,7 @@ survived, sink_calls=1
tag: "no-callbacks",
entry: "lib_undeclared.ts",
stripCallbacks: true,
stripBufferedExport: true,
});
const probe = buildProbe("probe_referror.c", archive, outDir);
const run = runProbe(probe);
@@ -374,7 +444,7 @@ survived, sink_calls=1
// 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 { archive, outDir } = await buildLibrary(emission, { tag: "unused", entry: "lib_unused.ts", stripBufferedExport: true });
const probe = buildProbe("probe_unused.c", archive, outDir);
const run = runProbe(probe);
expect(run.signal).toBeNull();
@@ -405,6 +475,7 @@ keyword: 7.5
const { archive, outDir } = await buildLibrary(emission, {
tag: "project-dts",
entry: "lib_project_dts.ts",
stripBufferedExport: true,
});
const probe = buildProbe("probe_project_dts.c", archive, outDir);
const run = runProbe(probe);
@@ -445,9 +516,22 @@ B: r1=31 r2=6 chunks=4 thread_ok=1 sink_calls=0
expect([...undef].filter((s) => s.startsWith("cbt_"))).toEqual([]);
});
/* ── CB8: the sanitized lane ─────────────────────────────────────────── */
localizationTest("CB8: callback re-entry poisons only the active thread instance", async () => {
const { archive, outDir } = await buildLibrary(emission, { tag: "threads-reentry", profileFile: "profile_t.json" });
const probe = buildProbe("probe_threads_reentry.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: result=0 chunks=0 thread_ok=1 sink_calls=1 code=SC4026 symbol=cbt_stream ctx_ok=1
B: result=5 chunks=1 thread_ok=1 sink_calls=0
`);
});
platformTest("CB8: CB1/CB2 under ASan", async () => {
/* ── CB9: the sanitized lane ─────────────────────────────────────────── */
platformTest("CB9: CB1/CB2/CB8 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"]);
@@ -457,5 +541,8 @@ B: r1=31 r2=6 chunks=4 thread_ok=1 sink_calls=0
const orphan = runProbe(probe, ["orphan"]);
expect(orphan.signal).toBe("SIGABRT");
expect(orphan.stdout).toBe(ORPHAN_EXPECTED);
const reentry = runProbe(probe, ["reenter-reset"]);
expect(reentry.signal).toBe("SIGABRT");
expect(reentry.stdout).toBe(reentryExpected("cb_reset_results"));
});
});
+19 -4
View File
@@ -17,10 +17,7 @@ export function stream(n: number, base: number): number {
sessions++;
let acc = 0;
for (let i = 0; i < n; i++) {
const chunk = new Uint8Array(3);
chunk[0] = 65 + i; // 'A' + i
chunk[1] = 48 + ((base + i) % 10); // a digit tied to the arguments
chunk[2] = 33; // '!'
const chunk = chunkFor(i, base);
emitChunk(chunk, i);
acc += (i + 1) * base; // computation between emits
note(`chunk ${i} away`, i === n - 1);
@@ -28,10 +25,28 @@ export function stream(n: number, base: number): number {
return acc + sessions;
}
function chunkFor(i: number, base: number): Uint8Array {
const chunk = new Uint8Array(3);
chunk[0] = 65 + i; // 'A' + i
chunk[1] = 48 + ((base + i) % 10); // a digit tied to the arguments
chunk[2] = 33; // '!'
return chunk;
}
export function askHost(x: number): number {
return progress(x, 10) * 2 + mix(x + 300, 0 - x);
}
// The re-entry probe keeps one result borrowed, then enters here with a
// callback that tries result reset/collect. Its longjmp means this outer
// buffer can never be returned, while the earlier result proves the inner
// control entry did not touch the arena before SC4026.
export function buffered(n: number): string {
const chunk = chunkFor(n, n);
emitChunk(chunk, n);
return `buffer ${n}`;
}
export function pokeOrphan(): number {
orphan(7);
return -1;
+74
View File
@@ -16,6 +16,10 @@
* library aborts the next entry deterministically
* preregister — an unregistered-channel call BEFORE sink registration
* aborts (the funnel's last resort)
* reenter-* — a handler attempts the named ABI entry. SC4026 reaches
* the original sink exactly once, names the INNER symbol,
* leaves post-poison pure registration available, then the
* next runtime-touching entry aborts.
*/
#include <pthread.h>
#include <setjmp.h>
@@ -26,8 +30,10 @@
extern void cb_init(void);
extern void cb_set_panic_sink(void (*fn)(void *, const uint8_t *, size_t, uint64_t), void *ctx);
extern void cb_collect(void);
extern void cb_reset_results(void);
extern int32_t cb_set_callback(const char *name, void (*fn)(void), void *ctx);
extern double cb_stream(double n, double base);
extern void cb_buffered(double n, const uint8_t **out, size_t *out_len);
extern double cb_ask_host(double x);
extern double cb_poke_orphan(void);
@@ -94,6 +100,42 @@ static uint32_t on_mix(void *ctx, uint8_t a, int32_t b) {
return (uint32_t)a + (uint32_t)(-b);
}
/* ── callback-time re-entry probes ───────────────────────────────────── */
enum ReentryAction {
REENTER_EXPORT,
REENTER_INIT,
REENTER_RESET,
REENTER_COLLECT,
REENTER_SINK,
REENTER_CALLBACK_UNKNOWN,
};
static enum ReentryAction reentry_action;
static int replacement_sink_calls = 0;
static void replacement_sink(void *ctx, const uint8_t *msg, size_t len, uint64_t addr) {
(void)ctx; (void)msg; (void)len; (void)addr;
replacement_sink_calls++;
printf("REPLACEMENT SINK\n");
}
static void on_reenter(void *ctx, const uint8_t *p, size_t len, uint32_t seq) {
(void)ctx; (void)p; (void)len; (void)seq;
switch (reentry_action) {
case REENTER_EXPORT: cb_stream(1, 1); break;
case REENTER_INIT: cb_init(); break;
case REENTER_RESET: cb_reset_results(); break;
case REENTER_COLLECT: cb_collect(); break;
case REENTER_SINK: cb_set_panic_sink(replacement_sink, NULL); break;
case REENTER_CALLBACK_UNKNOWN:
/* The guard must precede even this unknown-name dispatch. */
cb_set_callback("not-a-channel", (cb_fn)on_chunk, NULL);
break;
}
printf("UNREACHABLE callback return\n");
}
/* ── the panic sink (the traps probe's parse rule) ───────────────────── */
static jmp_buf trap_jmp;
@@ -164,6 +206,38 @@ int main(int argc, char **argv) {
return 0;
}
if (strncmp(mode, "reenter-", 8) == 0) {
const char *which = mode + 8;
if (strcmp(which, "export") == 0) reentry_action = REENTER_EXPORT;
else if (strcmp(which, "init") == 0) reentry_action = REENTER_INIT;
else if (strcmp(which, "reset") == 0) reentry_action = REENTER_RESET;
else if (strcmp(which, "collect") == 0) reentry_action = REENTER_COLLECT;
else if (strcmp(which, "sink") == 0) reentry_action = REENTER_SINK;
else if (strcmp(which, "callback-unknown") == 0) reentry_action = REENTER_CALLBACK_UNKNOWN;
else return 64;
cb_set_panic_sink(sink, NULL);
cb_set_callback("emitChunk", (cb_fn)on_chunk, &log_a);
cb_init();
const uint8_t *saved = NULL;
size_t saved_len = 0;
cb_buffered(7, &saved, &saved_len); /* held by the explicit-reset arena */
cb_set_callback("emitChunk", (cb_fn)on_reenter, NULL);
if (setjmp(trap_jmp) == 0) {
cb_buffered(8, NULL, NULL); /* callback attempts an ABI entry; no result returns */
printf("UNREACHABLE outer return\n");
} else {
/* The re-entry path cleared callback-active before delivery. Pure
* registration therefore retains its established post-poison rule. */
printf("post-poison register: %d\n", (int)cb_set_callback("emitChunk", (cb_fn)on_chunk, &log_a));
printf("replacement-sink-calls=%d\n", replacement_sink_calls);
printf("saved-result=[%.*s]\n", (int)saved_len, (const char *)saved);
fflush(stdout);
cb_stream(1, 1); /* poisoned runtime-touching entry must abort */
printf("UNREACHABLE poisoned entry\n");
}
return 0;
}
/* mode "run" */
/* Registration is a pure store, legal before init; return codes are the
@@ -137,6 +137,7 @@ static void *worker_a(void *arg) {
cbt_set_callback("note", (cb_fn)on_note, NULL);
cbt_set_callback("mix", (cb_fn)on_mix, NULL);
cbt_init();
fflush(stdout); /* keep the two independent init logs line-separated */
arrive(&inited, 2, 1);
stage_wait(1);
w->r1 = cbt_stream(3, 2); /* concurrent with B's stream */
@@ -160,6 +161,7 @@ static void *worker_b(void *arg) {
cbt_set_callback("note", (cb_fn)on_note, NULL);
cbt_set_callback("mix", (cb_fn)on_mix, NULL);
cbt_init();
fflush(stdout); /* keep the two independent init logs line-separated */
arrive(&inited, 2, 1);
stage_wait(1);
w->r1 = cbt_stream(3, 5); /* concurrent with A's stream */
@@ -0,0 +1,140 @@
/* Localized + thread-instanced SC4026 probe. Thread A re-enters from its
* callback and is poisoned; thread B's independent instance still calls a
* callback and completes an entry. The ordinary SC4025 isolation fixture
* remains in probe_threads.c. */
#include <pthread.h>
#include <setjmp.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
extern void cbt_init(void);
extern void cbt_set_panic_sink(void (*fn)(void *, const uint8_t *, size_t, uint64_t), void *ctx);
extern int32_t cbt_set_callback(const char *name, void (*fn)(void), void *ctx);
extern double cbt_stream(double n, double base);
typedef void (*cb_fn)(void);
static pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t cv = PTHREAD_COND_INITIALIZER;
static int inited = 0, a_trapped = 0;
typedef struct {
pthread_t self;
int reenter;
int chunks;
int thread_ok;
int sink_calls;
int sink_ctx_ok;
char code[16];
char symbol[64];
double result;
jmp_buf trap_jmp;
} Worker;
static Worker workers[2];
static void parse_field(char *out, size_t cap, const uint8_t *msg, size_t len, int wanted) {
if (len == 0 || msg[0] != 0x01) return;
const uint8_t *p = msg + 1, *end = msg + len;
int field = 0;
for (;;) {
const uint8_t *sep = memchr(p, 0x1f, (size_t)(end - p));
const uint8_t *stop = sep != NULL ? sep : end;
if (field == wanted) {
snprintf(out, cap, "%.*s", (int)(stop - p), (const char *)p);
return;
}
field++;
if (sep == NULL) return;
p = sep + 1;
}
}
static void sink(void *ctx, const uint8_t *msg, size_t len, uint64_t addr) {
(void)addr;
Worker *w = (Worker *)ctx;
w->sink_calls++;
w->sink_ctx_ok = pthread_equal(pthread_self(), w->self) ? 1 : 0;
parse_field(w->code, sizeof w->code, msg, len, 1);
parse_field(w->symbol, sizeof w->symbol, msg, len, 2);
longjmp(w->trap_jmp, 1);
}
static void on_chunk(void *ctx, const uint8_t *p, size_t len, uint32_t seq) {
(void)p; (void)len; (void)seq;
Worker *w = (Worker *)ctx;
if (!pthread_equal(pthread_self(), w->self)) w->thread_ok = 0;
if (w->reenter) {
cbt_stream(1, 1); /* rejected SC4026; never returns */
printf("UNREACHABLE callback return\n");
}
w->chunks++;
}
static void on_note(void *ctx, const uint8_t *p, size_t len, uint8_t last) {
(void)ctx; (void)p; (void)len; (void)last;
}
static void wait_for(int *value, int target) {
pthread_mutex_lock(&mu);
while (*value < target) pthread_cond_wait(&cv, &mu);
pthread_mutex_unlock(&mu);
}
static void signal_value(int *value) {
pthread_mutex_lock(&mu);
(*value)++;
pthread_cond_broadcast(&cv);
pthread_mutex_unlock(&mu);
}
static void *worker_a(void *arg) {
Worker *w = (Worker *)arg;
w->self = pthread_self();
w->thread_ok = 1;
w->reenter = 1;
cbt_set_panic_sink(sink, w);
cbt_set_callback("emitChunk", (cb_fn)on_chunk, w);
cbt_set_callback("note", (cb_fn)on_note, NULL);
cbt_init();
fflush(stdout); /* keep the two independent init logs line-separated */
signal_value(&inited);
wait_for(&inited, 2);
if (setjmp(w->trap_jmp) == 0) {
cbt_stream(1, 2);
printf("UNREACHABLE outer return\n");
}
signal_value(&a_trapped);
return NULL;
}
static void *worker_b(void *arg) {
Worker *w = (Worker *)arg;
w->self = pthread_self();
w->thread_ok = 1;
cbt_set_panic_sink(sink, w);
cbt_set_callback("emitChunk", (cb_fn)on_chunk, w);
cbt_set_callback("note", (cb_fn)on_note, NULL);
cbt_init();
fflush(stdout); /* keep the two independent init logs line-separated */
signal_value(&inited);
wait_for(&inited, 2);
wait_for(&a_trapped, 1);
w->result = cbt_stream(1, 4);
return NULL;
}
int main(void) {
pthread_t a, b;
pthread_create(&a, NULL, worker_a, &workers[0]);
pthread_create(&b, NULL, worker_b, &workers[1]);
pthread_join(a, NULL);
pthread_join(b, NULL);
printf("A: result=%g chunks=%d thread_ok=%d sink_calls=%d code=%s symbol=%s ctx_ok=%d\n",
workers[0].result, workers[0].chunks, workers[0].thread_ok, workers[0].sink_calls,
workers[0].code, workers[0].symbol, workers[0].sink_ctx_ok);
printf("B: result=%g chunks=%d thread_ok=%d sink_calls=%d\n",
workers[1].result, workers[1].chunks, workers[1].thread_ok, workers[1].sink_calls);
return 0;
}
+2 -1
View File
@@ -8,7 +8,7 @@
"init_symbol": "cb_init",
"sink_register_symbol": "cb_set_panic_sink",
"collect_symbol": "cb_collect",
"result_reset_symbol": null,
"result_reset_symbol": "cb_reset_results",
"callback_register_symbol": "cb_set_callback"
},
"callbacks": [
@@ -20,6 +20,7 @@
],
"exports": [
{ "export": "stream", "symbol": "cb_stream", "params": ["f64", "f64"], "returns": "f64" },
{ "export": "buffered", "symbol": "cb_buffered", "params": ["f64"], "returns": "string" },
{ "export": "askHost", "symbol": "cb_ask_host", "params": ["f64"], "returns": "f64" },
{ "export": "pokeOrphan", "symbol": "cb_poke_orphan", "params": [], "returns": "f64" }
]