mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-02 08:35:07 +08:00
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
This commit is contained in:
@@ -4,6 +4,10 @@ All notable changes to scriptc will be documented in this file.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Features
|
||||
|
||||
- **Library archives call back into their host.** A profile's `callbacks` array declares named channels over the marshalling classes (bytes, string, f64, bool, and the u8/u32/i32 plumbing classes; scalar returns), and `abi.callback_register_symbol` names the registration entry point: the host supplies a function pointer and an opaque context per channel, and compiled code reaches a channel as a signature-only ambient function whose direct calls deliver synchronously on the calling thread, buffers borrowed for the duration of the call. Registrations are per instance, matching the panic sink under `abi.localize_runtime` and `abi.instance_per_thread`. Calling an unregistered channel is a structured `SC4025` trap through the sink naming the channel and the entry; a call the profile's channels cannot serve refuses at compile time with `SC4024`. Profiles without a `callbacks` section produce unchanged output.
|
||||
|
||||
<!-- release:start -->
|
||||
|
||||
## 0.0.29
|
||||
|
||||
@@ -53,7 +53,7 @@ $ SCRIPTC_CC=zigcc SCRIPTC_TARGET=aarch64-apple-ios scriptc build --lib --profil
|
||||
$ SCRIPTC_CC=zigcc SCRIPTC_TARGET=aarch64-linux-android scriptc build --lib --profile app.profile.json
|
||||
```
|
||||
|
||||
The full library-mode feature set applies: profile-declared exports and ABI entry points, contract sidecars, determinism fences, `abi.localize_runtime` (multi-instance archives — Mach-O localization runs the macOS host linker for both iOS platforms; Android rides the same in-process ELF localization as the Linux cross targets), and `abi.instance_per_thread` (thread-instanced state). The archive's external-symbol contract is unchanged: undefined references only to the target's C/math runtime and system APIs, resolved by Xcode's link against the selected SDK or the NDK clang link at API 26+. Simulator and emulator execution of the archive probes is part of the test matrix; device-architecture archives are build- and link-verified.
|
||||
The full library-mode feature set applies: profile-declared exports and ABI entry points, host-callback channels (`callbacks` + `abi.callback_register_symbol`), contract sidecars, determinism fences, `abi.localize_runtime` (multi-instance archives — Mach-O localization runs the macOS host linker for both iOS platforms; Android rides the same in-process ELF localization as the Linux cross targets), and `abi.instance_per_thread` (thread-instanced state). The archive's external-symbol contract is unchanged: undefined references only to the target's C/math runtime and system APIs, resolved by Xcode's link against the selected SDK or the NDK clang link at API 26+. Simulator and emulator execution of the archive probes is part of the test matrix; device-architecture archives are build- and link-verified.
|
||||
|
||||
### WebAssembly (WASI Preview 1)
|
||||
|
||||
|
||||
@@ -2054,6 +2054,72 @@ export function emitExpr(E: CEmitter, e: IrExpr): Temp {
|
||||
return t;
|
||||
}
|
||||
case "ffiCall": {
|
||||
// LIBRARY mode: every ffiCall is a profile-declared host-callback
|
||||
// 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.
|
||||
// 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
|
||||
// scalar return converts to f64 exactly. The host cannot raise a
|
||||
// scriptc exception, so no pending check follows.
|
||||
const libCb = E.mod.lib?.callbacks?.find((c) => c.name === e.import);
|
||||
if (libCb !== undefined) {
|
||||
const cbArgs = e.args.map((arg) => E.emitExpr(arg));
|
||||
const natTypes: string[] = ["void *"];
|
||||
const natArgs: string[] = [`scr_library_cb_ctx(${libCb.slot})`];
|
||||
libCb.params.forEach((cls, i) => {
|
||||
const arg = cbArgs[i]!;
|
||||
switch (cls) {
|
||||
case "f64":
|
||||
natTypes.push("double");
|
||||
natArgs.push(arg.name);
|
||||
break;
|
||||
case "bool":
|
||||
natTypes.push("uint8_t");
|
||||
natArgs.push(`(uint8_t)(${arg.name} ? 1 : 0)`);
|
||||
break;
|
||||
case "u8":
|
||||
natTypes.push("uint8_t");
|
||||
natArgs.push(`(uint8_t)(uint32_t)scr_bit_ushr(${arg.name}, 0.0)`);
|
||||
break;
|
||||
case "u32":
|
||||
natTypes.push("uint32_t");
|
||||
natArgs.push(`(uint32_t)scr_bit_ushr(${arg.name}, 0.0)`);
|
||||
break;
|
||||
case "i32":
|
||||
natTypes.push("int32_t");
|
||||
natArgs.push(`(int32_t)scr_bit_or(${arg.name}, 0.0)`);
|
||||
break;
|
||||
case "string":
|
||||
case "bytes":
|
||||
natTypes.push("const uint8_t *", "size_t");
|
||||
natArgs.push(`(const uint8_t *)${arg.name}->data`, `${arg.name}->len`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
const retC =
|
||||
libCb.returns === "void" ? "void"
|
||||
: libCb.returns === "f64" ? "double"
|
||||
: libCb.returns === "bool" || libCb.returns === "u8" ? "uint8_t"
|
||||
: 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(", ")})`;
|
||||
switch (libCb.returns) {
|
||||
case "void":
|
||||
E.line(`${call};${E.srcComment(e.loc)}`);
|
||||
return { name: "", type: e.type };
|
||||
case "f64":
|
||||
return E.newTemp(e.type, call);
|
||||
case "bool":
|
||||
return E.newTemp(e.type, `(${call} != 0)`);
|
||||
default: // u8/u32/i32 — exact widenings back to f64
|
||||
return E.newTemp(e.type, `(double)${call}`);
|
||||
}
|
||||
}
|
||||
const entry = E.ffiByName.get(e.import);
|
||||
if (!entry) throw new Error(`emitter bug: unknown FFI import ${e.import}`);
|
||||
const args = e.args.map((arg) => E.emitExpr(arg));
|
||||
|
||||
@@ -701,7 +701,16 @@ export class CEmitter {
|
||||
// Outbound native FFI declarations. string/bytes each expand from one
|
||||
// scriptc value to a borrowed pointer+length pair. Format-2 callbacks
|
||||
// and their independently positioned contexts are exact pointer slots.
|
||||
for (const entry of this.mod.ffiImports ?? []) {
|
||||
// Library callback channels reuse ffiCall IR but are NOT direct symbol
|
||||
// imports: their profile names are registration keys and TypeScript
|
||||
// bindings only, and call sites dispatch through the runtime slot. Do
|
||||
// not emit extern declarations for them (besides inventing undefined
|
||||
// symbols, a valid TS channel name such as `int` is a C keyword).
|
||||
const libraryCallbackNames = new Set(this.mod.lib?.callbacks?.map((cb) => cb.name) ?? []);
|
||||
const directFfiImports = (this.mod.ffiImports ?? []).filter(
|
||||
(entry) => !libraryCallbackNames.has(entry.name),
|
||||
);
|
||||
for (const entry of directFfiImports) {
|
||||
const params = entry.params.flatMap((param): string[] => {
|
||||
if (isFfiCallbackParam(param)) return [ffiCallbackPointerTypeC(param.callback)];
|
||||
if (isFfiContextParam(param)) return ["void *"];
|
||||
@@ -723,7 +732,7 @@ export class CEmitter {
|
||||
const ret = ffiNativeTypeC(entry.returns);
|
||||
out.push(`extern ${ret} ${entry.symbol}(${params.length > 0 ? params.join(", ") : "void"});`);
|
||||
}
|
||||
if ((this.mod.ffiImports?.length ?? 0) > 0) out.push("");
|
||||
if (directFfiImports.length > 0) out.push("");
|
||||
for (const fn of this.mod.functions) out.push(this.signature(fn) + ";");
|
||||
this.emitFfiCallbackDefs(out);
|
||||
// Class objects (classes as values): construct-thunk prototypes plus
|
||||
@@ -1076,6 +1085,26 @@ 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
|
||||
// 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) {`,
|
||||
` if (name == NULL) return -1;`,
|
||||
);
|
||||
for (const cb of lib.callbacks) {
|
||||
out.push(
|
||||
` if (strcmp(name, ${cStringLiteral(Buffer.from(cb.name, "utf8"))}) == 0) {`,
|
||||
` scr_library_cb_set(${cb.slot}, fn, ctx); /* channel '${cb.name}' */`,
|
||||
` return 0;`,
|
||||
` }`,
|
||||
);
|
||||
}
|
||||
out.push(` return -1;`, `}`, ``);
|
||||
}
|
||||
if (lib.identity !== undefined) {
|
||||
// Profile-declared identity getters (the ask-2 sidecar's boot-time
|
||||
// pairing fence): pure data returns with NO entry prologue — exempt
|
||||
|
||||
@@ -1611,6 +1611,15 @@ class LlEmitter {
|
||||
this.declare(`declare void @scr_library_set_sink(ptr, ptr)`);
|
||||
this.declare(`declare void @scr_library_arena_reset()`);
|
||||
this.declare(`declare void @scr_library_collect()`);
|
||||
if ((this.mod.lib.callbacks?.length ?? 0) > 0) {
|
||||
// Host-callback channels: the registration define's dispatch
|
||||
// (strcmp over the declared names + the runtime slot store).
|
||||
// The call-site fetch pair (scr_library_cb_require/_ctx) is
|
||||
// declared at ffiCall emission like every body-driven runtime
|
||||
// symbol.
|
||||
this.declare(`declare void @scr_library_cb_set(${this.sizeType}, ptr, ptr)`);
|
||||
this.declare(`declare i32 @strcmp(ptr, ptr)`);
|
||||
}
|
||||
if (this.mod.lib.exports.some((e) => e.params.includes("string"))) {
|
||||
this.declare(`declare ptr @scr_library_str_in(ptr, ${this.sizeType})`);
|
||||
}
|
||||
@@ -2084,6 +2093,41 @@ class LlEmitter {
|
||||
`}`,
|
||||
``,
|
||||
);
|
||||
if (lib.callbacks !== undefined && lib.callbacks.length > 0) {
|
||||
// Host-callback channels: the per-channel name constants (the
|
||||
// registration dispatch's strcmp operands), the per-channel
|
||||
// unregistered-call trap constants (the ffiCall sites'
|
||||
// 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.
|
||||
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)}"`,
|
||||
`@sc_lib_cb_trap_${cb.slot} = internal constant [${Buffer.byteLength(cb.unregisteredTrap, "utf8") + 1} x i8] c"${llStrBytes(cb.unregisteredTrap)}"`,
|
||||
);
|
||||
}
|
||||
out.push(
|
||||
``,
|
||||
`define i32 @${lib.callbackRegisterSymbol}(ptr %name, ptr %fn, ptr %ctx) ${FN_ATTRS} {`,
|
||||
`entry:`,
|
||||
` %isnull = icmp eq ptr %name, null`,
|
||||
` br i1 %isnull, label %miss, label %try0`,
|
||||
);
|
||||
lib.callbacks.forEach((cb, i) => {
|
||||
const next = i + 1 < lib.callbacks!.length ? `try${i + 1}` : "miss";
|
||||
out.push(
|
||||
`try${i}: ; channel '${cb.name}'`,
|
||||
` %cmp${i} = call i32 @strcmp(ptr %name, ptr @sc_lib_cb_name_${cb.slot})`,
|
||||
` %eq${i} = icmp eq i32 %cmp${i}, 0`,
|
||||
` br i1 %eq${i}, label %set${i}, label %${next}`,
|
||||
`set${i}:`,
|
||||
` call void @scr_library_cb_set(${this.sizeType} ${cb.slot}, ptr %fn, ptr %ctx)`,
|
||||
` ret i32 0`,
|
||||
);
|
||||
});
|
||||
out.push(`miss:`, ` ret i32 -1`, `}`, ``);
|
||||
}
|
||||
if (lib.identity !== undefined) {
|
||||
// Profile-declared identity getters (the ask-2 sidecar's boot-time
|
||||
// pairing fence): pure data returns with NO entry prologue — exempt
|
||||
@@ -5792,6 +5836,112 @@ class LlEmitter {
|
||||
return out;
|
||||
}
|
||||
case "ffiCall": {
|
||||
// LIBRARY mode: every ffiCall is a profile-declared host-callback
|
||||
// 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
|
||||
// classes exactly; the host cannot raise a scriptc exception, so
|
||||
// no pending check follows.
|
||||
const libCb = this.mod.lib?.callbacks?.find((c) => c.name === e.import);
|
||||
if (libCb !== undefined) {
|
||||
const cbArgs = e.args.map((arg) => this.emitExpr(arg));
|
||||
const natTypes: string[] = ["ptr"];
|
||||
const natArgs: string[] = [];
|
||||
libCb.params.forEach((cls, i) => {
|
||||
const arg = cbArgs[i]!;
|
||||
switch (cls) {
|
||||
case "f64":
|
||||
natTypes.push("double");
|
||||
natArgs.push(`double ${arg.name}`);
|
||||
break;
|
||||
case "bool": {
|
||||
const widened = B.tmp();
|
||||
B.line(`${widened} = zext i1 ${arg.name} to i8`);
|
||||
natTypes.push("i8");
|
||||
natArgs.push(`i8 ${widened}`);
|
||||
break;
|
||||
}
|
||||
case "u8":
|
||||
case "u32": {
|
||||
this.declare(`declare double @scr_bit_ushr(double, double)`);
|
||||
const asDouble = B.tmp();
|
||||
const asU32 = B.tmp();
|
||||
B.line(`${asDouble} = call double @scr_bit_ushr(double ${arg.name}, double ${f64Lit(0)})`);
|
||||
B.line(`${asU32} = fptoui double ${asDouble} to i32`);
|
||||
if (cls === "u8") {
|
||||
const asU8 = B.tmp();
|
||||
B.line(`${asU8} = trunc i32 ${asU32} to i8`);
|
||||
natTypes.push("i8");
|
||||
natArgs.push(`i8 ${asU8}`);
|
||||
} else {
|
||||
natTypes.push("i32");
|
||||
natArgs.push(`i32 ${asU32}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "i32": {
|
||||
this.declare(`declare double @scr_bit_or(double, double)`);
|
||||
const asDouble = B.tmp();
|
||||
const asI32 = B.tmp();
|
||||
B.line(`${asDouble} = call double @scr_bit_or(double ${arg.name}, double ${f64Lit(0)})`);
|
||||
B.line(`${asI32} = fptosi double ${asDouble} to i32`);
|
||||
natTypes.push("i32");
|
||||
natArgs.push(`i32 ${asI32}`);
|
||||
break;
|
||||
}
|
||||
case "string": {
|
||||
const lenPtr = B.tmp();
|
||||
const len = B.tmp();
|
||||
const data = B.tmp();
|
||||
B.line(`${lenPtr} = getelementptr inbounds %ScrStr, ptr ${arg.name}, i64 0, i32 1`);
|
||||
B.line(`${len} = load i64, ptr ${lenPtr}`);
|
||||
B.line(`${data} = getelementptr inbounds i8, ptr ${arg.name}, i64 24`);
|
||||
natTypes.push("ptr", "i64");
|
||||
natArgs.push(`ptr ${data}`, `i64 ${len}`);
|
||||
break;
|
||||
}
|
||||
case "bytes": {
|
||||
const lenPtr = B.tmp();
|
||||
const len = B.tmp();
|
||||
const dataPtr = B.tmp();
|
||||
const data = B.tmp();
|
||||
B.line(`${lenPtr} = getelementptr inbounds i8, ptr ${arg.name}, i64 8`);
|
||||
B.line(`${len} = load i64, ptr ${lenPtr}`);
|
||||
B.line(`${dataPtr} = getelementptr inbounds i8, ptr ${arg.name}, i64 24`);
|
||||
B.line(`${data} = load ptr, ptr ${dataPtr}`);
|
||||
natTypes.push("ptr", "i64");
|
||||
natArgs.push(`ptr ${data}`, `i64 ${len}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
this.declare(`declare ptr @scr_library_cb_require(${this.sizeType}, ptr)`);
|
||||
this.declare(`declare ptr @scr_library_cb_ctx(${this.sizeType})`);
|
||||
const fn = B.tmp();
|
||||
B.line(`${fn} = call ptr @scr_library_cb_require(${this.sizeType} ${libCb.slot}, ptr @sc_lib_cb_trap_${libCb.slot})`);
|
||||
const ctx = B.tmp();
|
||||
B.line(`${ctx} = call ptr @scr_library_cb_ctx(${this.sizeType} ${libCb.slot})`);
|
||||
const retTy = ffiNativeTypeLl(libCb.returns);
|
||||
const call = `call ${retTy} ${fn}(${[`ptr ${ctx}`, ...natArgs].join(", ")})`;
|
||||
if (libCb.returns === "void") {
|
||||
B.line(call);
|
||||
return { name: "", type: e.type };
|
||||
}
|
||||
const raw = B.tmp();
|
||||
B.line(`${raw} = ${call}`);
|
||||
if (libCb.returns === "f64") return { name: raw, type: e.type };
|
||||
if (libCb.returns === "bool") {
|
||||
const value = B.tmp();
|
||||
B.line(`${value} = icmp ne i8 ${raw}, 0`);
|
||||
return { name: value, type: e.type };
|
||||
}
|
||||
const value = B.tmp();
|
||||
const op = libCb.returns === "i32" ? "sitofp" : "uitofp";
|
||||
B.line(`${value} = ${op} ${retTy} ${raw} to double`);
|
||||
return { name: value, type: e.type };
|
||||
}
|
||||
const entry = this.ffiByName.get(e.import);
|
||||
if (!entry) throw new Error(`llvm emitter bug: unknown FFI import ${e.import}`);
|
||||
const args = e.args.map((arg) => this.emitExpr(arg));
|
||||
|
||||
@@ -38,7 +38,12 @@
|
||||
* source spelling does not round-trip f64), wholeness
|
||||
* (SC4022 — may-be-NaN or may-be-fractional at a declared
|
||||
* i64/u64 slot), and range (SC4023 — the proven interval does
|
||||
* not fit ±(2^53 − 1), or is negative at a u64 slot)
|
||||
* 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)
|
||||
* 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
|
||||
@@ -982,6 +987,13 @@ export const LIB_INBOUND_BYTES_TRAP_CODE = "SC4012";
|
||||
* SC4019 other detected trap (the family's residual: environment
|
||||
* failures like getcwd/os lookups, unsupported regex
|
||||
* operations, circular-structure conversion, the RC audit)
|
||||
* SC4025 host callback invoked before registration ("scriptc: library
|
||||
* callback ...": compiled code reached a profile-declared
|
||||
* callback channel the host never registered through the
|
||||
* profile's callback_register_symbol — a host-contract story
|
||||
* like SC4012, but the trap site is inside compiled code, so
|
||||
* the funnel assembles it and field 2 names the entry the
|
||||
* host called)
|
||||
*
|
||||
* 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
|
||||
@@ -995,7 +1007,37 @@ export const LIB_RUNTIME_TRAP_CODES = [
|
||||
"SC4017",
|
||||
"SC4018",
|
||||
"SC4019",
|
||||
"SC4025",
|
||||
] as const;
|
||||
|
||||
/** SC4025 — the unregistered host-callback trap's code (the runtime
|
||||
* family entry above; exported for the resolver that assembles the trap
|
||||
* message and the tests that pin it). */
|
||||
export const LIB_CALLBACK_UNREGISTERED_TRAP_CODE = "SC4025";
|
||||
|
||||
/** SC4024 — a host-callback reference the profile cannot serve. Library
|
||||
* mode maps signature-only ambient function declarations onto the
|
||||
* profile's declared callback channels (the outbound seam: compiled code
|
||||
* delivers bytes and scalars to the embedder synchronously). When the
|
||||
* profile declares ANY callback, a call of a program-authored
|
||||
* signature-only declaration that names no channel — or whose TypeScript
|
||||
* signature does not fit the channel's declared classes — is a refusal,
|
||||
* never an inert ReferenceError lowering: the author reached for the
|
||||
* host seam and the profile does not provide it. Decorated with the
|
||||
* profile's SC4024 teaching text like every library refusal. */
|
||||
export function libCallbackDiag(name: string, detail: string, loc: SrcLoc): ScrDiagnostic {
|
||||
return {
|
||||
code: "SC4024",
|
||||
message: `library callback '${name}' cannot be compiled: ${detail}`,
|
||||
loc,
|
||||
hint:
|
||||
"profile-declared callbacks are the library's outbound seam: declare the channel in the profile's " +
|
||||
"'callbacks' array (name, params, returns) and keep the ambient TypeScript signature on the callback " +
|
||||
"marshalling classes — params f64/bool/string/bytes and the u8/u32/i32 plumbing classes, " +
|
||||
"returns f64/bool/u8/u32/i32/void; the host registers an implementation through the profile's " +
|
||||
"abi.callback_register_symbol before calling any entry that can reach the channel",
|
||||
};
|
||||
}
|
||||
/** SC4020 — a bare npm specifier in a library graph naming a package that
|
||||
* fails the npm-static eligibility bar (own .d.ts, unminified shipped JS,
|
||||
* no build-transform markers) or whose static compilation the preflight
|
||||
|
||||
@@ -12,7 +12,7 @@ import { isGenericCallableMemberType, typeKey } from "../types.js";
|
||||
import { PoisonError, dynFallbackType, dynUndefinedExpr, importCallHandleType, jsFuncNameOf, newFnCtx, nodeThrowExpr } from "./lowerer.js";
|
||||
import { enforceLibBoundary } from "./lib-boundary.js";
|
||||
import { NARROW_FIRST, builtinFenceHintOf, builtinModuleFnOf } from "./surfaces.js";
|
||||
import { ffiBindingDiag, ffiSignatureDiag, requiresDynamicDiag } from "../../diagnostics/diagnostic.js";
|
||||
import { ffiBindingDiag, ffiSignatureDiag, libCallbackDiag, requiresDynamicDiag } from "../../diagnostics/diagnostic.js";
|
||||
import type { ScrDiagnostic } from "../../diagnostics/diagnostic.js";
|
||||
import { mixinFnShapeOf } from "./lower-mixins.js";
|
||||
import { bufEncoding, dynStringReceiver, lowerArrayFromCall, lowerDynArrayFilterCall, lowerDynArrayFlatMapCall, lowerGroupByStaticCall, lowerIteratorHelperCall, lowerObjectAssignIndexShape, lowerObjectFromEntriesCall, lowerObjectIterOverIndexShape, lowerRegexMethodCall, lowerStringMethodCall, lowerTupleReadMethodCall } from "./lower-containers.js";
|
||||
@@ -2530,6 +2530,43 @@ function ffiCallbackInputDiagnostic(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Values returned by native code flow into TypeScript, so a declaration
|
||||
* must admit the whole scalar domain the ABI class can produce. `mapType`
|
||||
* intentionally widens literal and enum types to their storage type; that
|
||||
* is sound for script-owned values but not for an external return contract
|
||||
* (`(): true` cannot describe a bool callback that is free to return false). */
|
||||
function ffiReturnDomainDiagnostic(
|
||||
L: Lowerer,
|
||||
nativeClass: IrFfiImport["returns"],
|
||||
returnType: ts.Type,
|
||||
): string | null {
|
||||
if (nativeClass === "void") return null;
|
||||
const domain = nativeClass === "bool" ? "boolean" : "number";
|
||||
const coversDomain = nativeClass === "bool"
|
||||
? (returnType.flags & ts.TypeFlags.Boolean) !== 0
|
||||
: (returnType.flags & ts.TypeFlags.Number) !== 0;
|
||||
return coversDomain
|
||||
? null
|
||||
: `the return type is '${L.checker.typeToString(returnType)}', but native class '${nativeClass}' may supply ` +
|
||||
`any ${domain}; declare it as '${domain}'`;
|
||||
}
|
||||
|
||||
/** The binding surface's diagnostic flavor: native-manifest bindings
|
||||
* speak the SC5002/SC5003 FFI codes; library-mode host callbacks are the
|
||||
* same recognition machinery under the profile's vocabulary — SC4024 for
|
||||
* both the binding and signature halves, with "manifest" respelled
|
||||
* "profile" in the shared detail strings so the teaching names the
|
||||
* document the author actually edits. */
|
||||
function ffiFlavor(L: Lowerer): {
|
||||
binding: (name: string, detail: string, loc: SrcLoc) => ScrDiagnostic;
|
||||
signature: (name: string, detail: string, loc: SrcLoc) => ScrDiagnostic;
|
||||
} {
|
||||
if (!L.libraryCallbacks) return { binding: ffiBindingDiag, signature: ffiSignatureDiag };
|
||||
const lib = (name: string, detail: string, loc: SrcLoc): ScrDiagnostic =>
|
||||
libCallbackDiag(name, detail.replaceAll("manifest", "profile"), loc);
|
||||
return { binding: lib, signature: lib };
|
||||
}
|
||||
|
||||
/** The declaration half of an outbound FFI binding. Kept independent of
|
||||
* call-site argument checks so the whole manifest can be validated even
|
||||
* when a configured function is never called. */
|
||||
@@ -2539,6 +2576,7 @@ function ffiDeclarationDiagnostic(
|
||||
symbol: ts.Symbol,
|
||||
loc: SrcLoc,
|
||||
): ScrDiagnostic | null {
|
||||
const { binding: bindingDiag, signature: signatureDiag } = ffiFlavor(L);
|
||||
const declarations = L.checker.declarationsOf(symbol);
|
||||
const functionDecls = declarations.filter(ts.isFunctionDeclaration);
|
||||
if (
|
||||
@@ -2546,14 +2584,14 @@ function ffiDeclarationDiagnostic(
|
||||
declarations.some((decl) => !ts.isFunctionDeclaration(decl)) ||
|
||||
functionDecls.some((decl) => decl.body !== undefined)
|
||||
) {
|
||||
return ffiBindingDiag(
|
||||
return bindingDiag(
|
||||
binding.name,
|
||||
"the configured name does not resolve exclusively to signature-only function declarations",
|
||||
loc,
|
||||
);
|
||||
}
|
||||
if (functionDecls.some((decl) => (decl.typeParameters?.length ?? 0) > 0)) {
|
||||
return ffiSignatureDiag(
|
||||
return signatureDiag(
|
||||
binding.name,
|
||||
"generic ambient declarations cannot describe one fixed C ABI",
|
||||
loc,
|
||||
@@ -2561,7 +2599,7 @@ function ffiDeclarationDiagnostic(
|
||||
}
|
||||
const signatures = L.checker.getCallSignatures(L.checker.getTypeOfSymbol(symbol));
|
||||
if (signatures.length !== 1) {
|
||||
return ffiSignatureDiag(
|
||||
return signatureDiag(
|
||||
binding.name,
|
||||
`the ambient binding has ${signatures.length} call signatures; exactly one non-overloaded signature is required`,
|
||||
loc,
|
||||
@@ -2571,7 +2609,7 @@ function ffiDeclarationDiagnostic(
|
||||
const params = signature.getParameters();
|
||||
const sourceParams = ffiSourceParams(binding);
|
||||
if (params.length !== sourceParams.length) {
|
||||
return ffiSignatureDiag(
|
||||
return signatureDiag(
|
||||
binding.name,
|
||||
`the TypeScript declaration has ${params.length} parameter(s), but the manifest declares ${sourceParams.length} source parameter(s) ` +
|
||||
`(${binding.params.length - sourceParams.length} additional native context slot(s) are compiler-supplied)`,
|
||||
@@ -2587,7 +2625,7 @@ function ffiDeclarationDiagnostic(
|
||||
// declaration is different: it is a callable external contract, so a
|
||||
// `never` slot cannot truthfully describe any native parameter.
|
||||
if ((paramType.flags & ts.TypeFlags.Never) !== 0) {
|
||||
return ffiSignatureDiag(
|
||||
return signatureDiag(
|
||||
binding.name,
|
||||
`parameter ${i + 1} is 'never', an uninhabited TypeScript type that cannot describe a native ABI parameter`,
|
||||
loc,
|
||||
@@ -2596,13 +2634,13 @@ function ffiDeclarationDiagnostic(
|
||||
if (isFfiCallbackParam(sourceParam)) {
|
||||
const callbackDiagnostic = ffiCallbackInputDiagnostic(L, sourceParam, paramType);
|
||||
if (callbackDiagnostic !== null) {
|
||||
return ffiSignatureDiag(binding.name, callbackDiagnostic, loc);
|
||||
return signatureDiag(binding.name, callbackDiagnostic, loc);
|
||||
}
|
||||
}
|
||||
const mapped = L.mapTypeOf(paramType);
|
||||
const expected = expectedParams[i]!;
|
||||
if (mapped === null || !typeEquals(mapped, expected)) {
|
||||
return ffiSignatureDiag(
|
||||
return signatureDiag(
|
||||
binding.name,
|
||||
`parameter ${i + 1} maps to '${mapped === null ? L.checker.typeToString(paramType) : L.fmt(mapped)}', ` +
|
||||
`which does not fit manifest ${ffiParamDisplay(sourceParam)}`,
|
||||
@@ -2615,7 +2653,7 @@ function ffiDeclarationDiagnostic(
|
||||
// let tsc erase all control flow after the call while the linked function
|
||||
// continues, making the generated program disagree with TypeScript.
|
||||
if ((returnType.flags & ts.TypeFlags.Never) !== 0) {
|
||||
return ffiSignatureDiag(
|
||||
return signatureDiag(
|
||||
binding.name,
|
||||
"the return type is 'never', but a native ABI return cannot uphold TypeScript's non-returning contract",
|
||||
loc,
|
||||
@@ -2624,13 +2662,17 @@ function ffiDeclarationDiagnostic(
|
||||
const declaredReturn = L.mapTypeOf(returnType);
|
||||
const expectedReturn = ffiClassType(binding.returns);
|
||||
if (declaredReturn === null || !typeEquals(declaredReturn, expectedReturn)) {
|
||||
return ffiSignatureDiag(
|
||||
return signatureDiag(
|
||||
binding.name,
|
||||
`the return maps to '${declaredReturn === null ? L.checker.typeToString(returnType) : L.fmt(declaredReturn)}', ` +
|
||||
`which does not fit manifest class '${binding.returns}'`,
|
||||
loc,
|
||||
);
|
||||
}
|
||||
const returnDomainDiagnostic = ffiReturnDomainDiagnostic(L, binding.returns, returnType);
|
||||
if (returnDomainDiagnostic !== null) {
|
||||
return signatureDiag(binding.name, returnDomainDiagnostic, loc);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2639,11 +2681,23 @@ export interface FfiValidationResult {
|
||||
symbolsByName: ReadonlyMap<string, ReadonlySet<ts.Symbol>>;
|
||||
}
|
||||
|
||||
/** True when a library callback may claim declarations from this file.
|
||||
* Ordinary program source is always eligible. Declaration files are
|
||||
* eligible only when they are project-owned: the standard library and
|
||||
* installed/workspace package declarations are existing ambient surfaces,
|
||||
* never callback declarations merely because a profile channel shares their
|
||||
* spelling. */
|
||||
function libraryCallbackOwnsFile(L: Lowerer, file: ts.SourceFile): boolean {
|
||||
return !file.isDeclarationFile || (!L.isStdlibFile(file) && !L.isNpmFile(file));
|
||||
}
|
||||
|
||||
/** Resolve and validate every configured outbound binding before emit.
|
||||
* Candidate declarations are signature-only functions bearing the manifest
|
||||
* name anywhere in the program. Multiple scoped declarations are all native
|
||||
* bindings under the existing name-based call surface, so every candidate
|
||||
* must fit the one manifest ABI. */
|
||||
* must fit the one manifest ABI. Library callbacks additionally exclude
|
||||
* standard-library and package declaration files while retaining project
|
||||
* declaration files as authored callback surface. */
|
||||
export function validateFfiImports(L: Lowerer): FfiValidationResult {
|
||||
const diagnostics: ScrDiagnostic[] = [];
|
||||
const symbolsByName = new Map<string, ReadonlySet<ts.Symbol>>();
|
||||
@@ -2653,6 +2707,7 @@ export function validateFfiImports(L: Lowerer): FfiValidationResult {
|
||||
if (configuredNames.size === 0) return { diagnostics, symbolsByName };
|
||||
|
||||
for (const file of L.program.getSourceFiles()) {
|
||||
if (L.libraryCallbacks && !libraryCallbackOwnsFile(L, file)) continue;
|
||||
ts.walkPreorder(file, (node) => {
|
||||
if (ts.isFunctionDeclaration(node)) {
|
||||
if (
|
||||
@@ -2679,13 +2734,28 @@ export function validateFfiImports(L: Lowerer): FfiValidationResult {
|
||||
for (const binding of L.ffiImports) {
|
||||
const bySymbol = candidates.get(binding.name);
|
||||
if (bySymbol === undefined || bySymbol.size === 0) {
|
||||
diagnostics.push(
|
||||
ffiBindingDiag(
|
||||
binding.name,
|
||||
"the program has no signature-only function declaration with this name",
|
||||
{ file: L.entry.fileName, start: 0, end: 0 },
|
||||
),
|
||||
);
|
||||
// A native-manifest binding with no declaration is a broken build
|
||||
// input. A library callback channel with no declaration is unused
|
||||
// CAPACITY: the registration symbol still dispatches it, nothing
|
||||
// calls it, and a later program revision may (a program that calls
|
||||
// the name without declaring it fails ordinary typechecking first).
|
||||
if (!L.libraryCallbacks) {
|
||||
diagnostics.push(
|
||||
ffiBindingDiag(
|
||||
binding.name,
|
||||
"the program has no signature-only function declaration with this name",
|
||||
{ file: L.entry.fileName, start: 0, end: 0 },
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Preserve the distinction between unused capacity and a binding
|
||||
// whose program declaration was found but failed validation. The
|
||||
// latter deliberately leaves no map entry so calls poison without
|
||||
// duplicating the program-level diagnostic; the empty set lets call
|
||||
// lowering inspect same-named builtins, implementations, and
|
||||
// unsupported ambient declaration shapes individually.
|
||||
symbolsByName.set(binding.name, new Set());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const validSymbols = new Set<ts.Symbol>();
|
||||
@@ -2718,14 +2788,47 @@ export function validateFfiImports(L: Lowerer): FfiValidationResult {
|
||||
export function lowerFfiCall(L: Lowerer, expr: ts.CallExpression): IrExpr | null {
|
||||
if (!ts.isIdentifier(expr.expression)) return null;
|
||||
const binding = L.ffiImportsByName.get(expr.expression.text);
|
||||
if (binding === undefined) return null;
|
||||
if (binding === undefined) {
|
||||
// LIBRARY mode with a declared callback surface: a CALL of a
|
||||
// program-authored signature-only ambient function that names no
|
||||
// channel is the author reaching for the host seam the profile does
|
||||
// not provide — refuse with the callback teaching instead of the
|
||||
// ambient ReferenceError lowering. Scoped to project-owned source and
|
||||
// declaration files so lib.d.ts/@types/package ambients (parseInt,
|
||||
// setTimeout, …) keep every existing lowering; callback-free profiles
|
||||
// are untouched.
|
||||
if (L.libraryCallbacks) {
|
||||
const symbol = L.resolveValueSymbol(expr.expression);
|
||||
const decls = symbol === null ? [] : L.checker.declarationsOf(symbol);
|
||||
const callbackShaped =
|
||||
decls.length > 0 &&
|
||||
decls.every(
|
||||
(decl) =>
|
||||
ts.isFunctionDeclaration(decl) &&
|
||||
decl.body === undefined &&
|
||||
libraryCallbackOwnsFile(L, decl.getSourceFile()),
|
||||
);
|
||||
if (callbackShaped) {
|
||||
L.pushDiag(
|
||||
libCallbackDiag(
|
||||
expr.expression.text,
|
||||
"the profile declares no callback channel with this name",
|
||||
locOf(expr),
|
||||
),
|
||||
);
|
||||
throw new PoisonError();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const loc = locOf(expr);
|
||||
const { binding: bindingFlavor, signature: signatureFlavor } = ffiFlavor(L);
|
||||
const bindingError = (detail: string): never => {
|
||||
L.pushDiag(ffiBindingDiag(binding.name, detail, loc));
|
||||
L.pushDiag(bindingFlavor(binding.name, detail, loc));
|
||||
throw new PoisonError();
|
||||
};
|
||||
const signatureError = (detail: string): never => {
|
||||
L.pushDiag(ffiSignatureDiag(binding.name, detail, loc));
|
||||
L.pushDiag(signatureFlavor(binding.name, detail, loc));
|
||||
throw new PoisonError();
|
||||
};
|
||||
const symbol =
|
||||
@@ -2737,10 +2840,43 @@ export function lowerFfiCall(L: Lowerer, expr: ts.CallExpression): IrExpr | null
|
||||
// binding. Poison the statement without duplicating that diagnostic.
|
||||
if (validSymbols === undefined) throw new PoisonError();
|
||||
if (!validSymbols.has(symbol)) {
|
||||
// TypeScript resolved this call to a distinct local declaration.
|
||||
// The manifest owns only the exact validated ambient binding; a
|
||||
// same-named function with a body remains ordinary scriptc code.
|
||||
return null;
|
||||
if (L.libraryCallbacks) {
|
||||
const declarations = L.checker.declarationsOf(symbol);
|
||||
const programDeclarations = declarations.filter((decl) =>
|
||||
libraryCallbackOwnsFile(L, decl.getSourceFile())
|
||||
);
|
||||
const programAmbient =
|
||||
programDeclarations.length > 0 &&
|
||||
programDeclarations.every(
|
||||
(decl) =>
|
||||
decl.getSourceFile().isDeclarationFile ||
|
||||
(ts.getCombinedModifierFlags(decl as ts.Declaration) &
|
||||
ts.ModifierFlags.Ambient) !== 0,
|
||||
);
|
||||
if (programAmbient) {
|
||||
// A called, program-authored ambient with a configured channel
|
||||
// name is not unused capacity. Validate the resolved symbol now
|
||||
// so unsupported declaration forms (`declare const cb: ...`)
|
||||
// refuse SC4024 instead of silently dropping the call. A valid
|
||||
// declaration missed by the up-front syntax walk may proceed as
|
||||
// the callback binding after this exact-symbol check.
|
||||
const diagnostic = ffiDeclarationDiagnostic(L, binding, symbol, loc);
|
||||
if (diagnostic !== null) {
|
||||
L.pushDiag(diagnostic);
|
||||
throw new PoisonError();
|
||||
}
|
||||
} else {
|
||||
// Standard-library/package ambients and same-named program
|
||||
// implementations remain their ordinary TypeScript bindings;
|
||||
// the profile does not claim them.
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// TypeScript resolved this call to a distinct local declaration.
|
||||
// The manifest owns only the exact validated ambient binding; a
|
||||
// same-named function with a body remains ordinary scriptc code.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const diagnostic = ffiDeclarationDiagnostic(L, binding, symbol, loc);
|
||||
|
||||
@@ -352,6 +352,14 @@ export interface LowerOptions {
|
||||
* imports; without this option ambient declarations keep Node's ordinary
|
||||
* ReferenceError behavior. */
|
||||
ffiImports?: readonly IrFfiImport[];
|
||||
/** LIBRARY mode's host-callback surface marker: the `ffiImports` above
|
||||
* are profile-declared callback channels, not native-manifest bindings.
|
||||
* Flips the binding diagnostics to the library flavor (SC4024), keeps a
|
||||
* channel legal when no program declaration references it (an unused
|
||||
* channel is capacity, not an error), and refuses a CALL of any other
|
||||
* program-authored signature-only ambient function with the callback
|
||||
* teaching instead of the ambient ReferenceError lowering. */
|
||||
libraryCallbacks?: boolean;
|
||||
/** Coverage-only external host type surfaces. Their declarations inform
|
||||
* the checker, while every runtime value use remains an SC1010 fence. */
|
||||
externalTypes?: ReadonlyMap<string, string>;
|
||||
@@ -381,6 +389,8 @@ export interface LowererMode {
|
||||
startupCrash?: StartupCrash | null;
|
||||
/** The build's outbound native FFI declarations. */
|
||||
ffiImports?: readonly IrFfiImport[];
|
||||
/** LowerOptions.libraryCallbacks (see there). */
|
||||
libraryCallbacks?: boolean;
|
||||
/** Program-validated ambient declaration symbols for each FFI name.
|
||||
* Undefined in discovery's legacy call-local validation path. */
|
||||
ffiBindingSymbols?: ReadonlyMap<string, ReadonlySet<ts.Symbol>>;
|
||||
@@ -448,12 +458,14 @@ export function lowerToIr(
|
||||
});
|
||||
}
|
||||
const ffiImports = options.ffiImports ?? [];
|
||||
const libraryCallbacks = options.libraryCallbacks ?? false;
|
||||
const externalTypes = options.externalTypes ?? new Map<string, string>();
|
||||
const externalTypeSpecifiersByFile = options.externalTypeSpecifiersByFile ??
|
||||
directExternalTypeSpecifiersByFile(externalTypes);
|
||||
const validation = new Lowerer(program, entry, moduleOrder, dynamic, {
|
||||
targetPlatform,
|
||||
ffiImports,
|
||||
libraryCallbacks,
|
||||
externalTypes,
|
||||
externalTypeSpecifiersByFile,
|
||||
});
|
||||
@@ -469,6 +481,7 @@ export function lowerToIr(
|
||||
: new Lowerer(program, entry, moduleOrder, dynamic, {
|
||||
targetPlatform,
|
||||
ffiImports,
|
||||
libraryCallbacks,
|
||||
externalTypes,
|
||||
externalTypeSpecifiersByFile,
|
||||
ffiBindingSymbols: ffiValidation.symbolsByName,
|
||||
@@ -479,6 +492,7 @@ export function lowerToIr(
|
||||
targetPlatform,
|
||||
startupCrash,
|
||||
ffiImports,
|
||||
libraryCallbacks,
|
||||
ffiBindingSymbols: ffiValidation.symbolsByName,
|
||||
externalTypes,
|
||||
externalTypeSpecifiersByFile,
|
||||
@@ -493,6 +507,7 @@ export function lowerToIr(
|
||||
alreadyFlushed: emit.flushedSymbols,
|
||||
targetPlatform,
|
||||
ffiImports,
|
||||
libraryCallbacks,
|
||||
ffiBindingSymbols: ffiValidation.symbolsByName,
|
||||
externalTypes,
|
||||
externalTypeSpecifiersByFile,
|
||||
@@ -1221,6 +1236,7 @@ export class Lowerer {
|
||||
readonly startupCrash: StartupCrash | null;
|
||||
/** Outbound native bindings by their source-level ambient name. */
|
||||
readonly ffiImports: readonly IrFfiImport[];
|
||||
readonly libraryCallbacks: boolean;
|
||||
readonly ffiImportsByName: ReadonlyMap<string, IrFfiImport>;
|
||||
/** Non-null after whole-program FFI declaration validation. */
|
||||
readonly ffiBindingSymbols: ReadonlyMap<string, ReadonlySet<ts.Symbol>> | null;
|
||||
@@ -1280,6 +1296,7 @@ export class Lowerer {
|
||||
this.targetPlatform = mode.targetPlatform ?? process.platform;
|
||||
this.startupCrash = mode.startupCrash ?? null;
|
||||
this.ffiImports = mode.ffiImports ?? [];
|
||||
this.libraryCallbacks = mode.libraryCallbacks ?? false;
|
||||
this.ffiImportsByName = new Map(this.ffiImports.map((entry) => [entry.name, entry]));
|
||||
this.ffiBindingSymbols = mode.ffiBindingSymbols ?? null;
|
||||
this.externalTypes = mode.externalTypes ?? new Map();
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { validateSidecar } from "./library/sidecar-validate.js";
|
||||
import { entryFunctionExports, type EntryExportInfo } from "./frontend/lib-exports.js";
|
||||
import { entryContractFacts, type ContractFacts } from "./frontend/lib-contract.js";
|
||||
import { moduleLibAsyncSurface, moduleLibNondeterministicSurface, moduleEmbedsBuiltin, moduleEmbedsCompressedNpm, moduleUsesAssert, moduleUsesCopying, moduleUsesDc, moduleUsesDgram, moduleUsesDynAsync, moduleUsesDynInvoke, moduleUsesEmitter, moduleUsesFetch, moduleUsesFileHandle, moduleUsesFsWatch, moduleUsesHttp2, moduleUsesHttpServer, moduleUsesInspect, moduleUsesLegacyTextDecoder, moduleUsesNet, moduleUsesNodeTest, moduleUsesParseArgs, moduleUsesProcessEvents, moduleUsesQs, moduleUsesRegex, moduleUsesSearchParams, moduleUsesStream, moduleUsesSymbol, moduleUsesTls, moduleUsesTlsCa, moduleUsesZlib, type IrLibSection, type IrModule, type IrRecordShape, type IrType, type SrcLoc } from "./ir/nodes.js";
|
||||
import { moduleLibAsyncSurface, moduleLibNondeterministicSurface, moduleEmbedsBuiltin, moduleEmbedsCompressedNpm, moduleUsesAssert, moduleUsesCopying, moduleUsesDc, moduleUsesDgram, moduleUsesDynAsync, moduleUsesDynInvoke, moduleUsesEmitter, moduleUsesFetch, moduleUsesFileHandle, moduleUsesFsWatch, moduleUsesHttp2, moduleUsesHttpServer, moduleUsesInspect, moduleUsesLegacyTextDecoder, moduleUsesNet, moduleUsesNodeTest, moduleUsesParseArgs, moduleUsesProcessEvents, moduleUsesQs, moduleUsesRegex, moduleUsesSearchParams, moduleUsesStream, moduleUsesSymbol, moduleUsesTls, moduleUsesTlsCa, moduleUsesZlib, type IrFfiImport, type IrLibSection, type IrModule, type IrRecordShape, type IrType, type SrcLoc } from "./ir/nodes.js";
|
||||
import { serializeModule } from "./ir/serialize.js";
|
||||
import { validateModule } from "./ir/validate.js";
|
||||
import { canonicalBuiltinModule, checkPreflight, isNodeTypesPath, loadProgram, locOf, requiresOf, resolveNpmImport, type LoadResult } from "./frontend/program.js";
|
||||
@@ -1279,6 +1279,26 @@ function resolveLibrarySection(
|
||||
collectSymbol: profile.collectSymbol,
|
||||
resultResetSymbol: profile.resultResetSymbol,
|
||||
threadInstances: profile.instancePerThread,
|
||||
// Host-callback channels: declaration order is the runtime slot
|
||||
// assignment, and the unregistered-call trap text is assembled HERE,
|
||||
// once, so both backends emit identical constant bytes (a DETECTED
|
||||
// trap: the funnel classifies the "scriptc: library callback "
|
||||
// prefix as SC4025 and names the entry the host called — the entry
|
||||
// is runtime knowledge, so no compile-time SC4012-style assembly
|
||||
// can carry it). Both fields stay absent on callback-free profiles
|
||||
// (the byte-identity guarantee).
|
||||
...(profile.callbacks.length > 0
|
||||
? {
|
||||
callbackRegisterSymbol: profile.callbackRegisterSymbol!,
|
||||
callbacks: profile.callbacks.map((cb, i) => ({
|
||||
name: cb.name,
|
||||
slot: i,
|
||||
params: [...cb.params],
|
||||
returns: cb.returns,
|
||||
unregisteredTrap: `scriptc: library callback '${cb.name}' invoked before registration\n`,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
exports,
|
||||
trapOverlays,
|
||||
},
|
||||
@@ -1635,10 +1655,24 @@ export async function compileLibrary(opts: CompileLibraryOptions): Promise<Compi
|
||||
}
|
||||
}
|
||||
}
|
||||
// The profile's host-callback channels ride the FFI import machinery:
|
||||
// each channel is a signature-only ambient binding whose direct calls
|
||||
// lower to ffiCall nodes (the classes are a subset of the FFI's), and
|
||||
// `libraryCallbacks` flips the recognition to the library flavor —
|
||||
// SC4024 diagnostics, unused channels legal, undeclared references
|
||||
// refused with the callback teaching. The library lane never loads a
|
||||
// native-FFI manifest, so the channel set owns the surface outright.
|
||||
const cbImports: IrFfiImport[] = profile.callbacks.map((cb) => ({
|
||||
name: cb.name,
|
||||
symbol: cb.name,
|
||||
params: [...cb.params],
|
||||
returns: cb.returns,
|
||||
}));
|
||||
try {
|
||||
lowered = fe.lower({
|
||||
dynamic: false,
|
||||
targetPlatform: buildPlatform,
|
||||
...(cbImports.length > 0 ? { ffiImports: cbImports, libraryCallbacks: true } : {}),
|
||||
// The profile-mapped exports are called from OUTSIDE the graph:
|
||||
// they seed reachability beside the entry's top level (an
|
||||
// executable build would dead-strip an uncalled export). A helper
|
||||
@@ -1790,6 +1824,7 @@ export async function compileLibrary(opts: CompileLibraryOptions): Promise<Compi
|
||||
profile.sinkRegisterSymbol,
|
||||
...(profile.collectSymbol !== null ? [profile.collectSymbol] : []),
|
||||
...(profile.resultResetSymbol !== null ? [profile.resultResetSymbol] : []),
|
||||
...(profile.callbackRegisterSymbol !== null ? [profile.callbackRegisterSymbol] : []),
|
||||
...(profile.sidecar !== null
|
||||
? [profile.sidecar.buildIdSymbol, profile.sidecar.abiVersionSymbol]
|
||||
: []),
|
||||
|
||||
@@ -885,6 +885,29 @@ export interface IrLibTrapOverlay {
|
||||
remediation?: string;
|
||||
}
|
||||
|
||||
/** One resolved host-callback channel (the profile's `callbacks` entry
|
||||
* landed on the IR): compiled call sites of the channel's ambient binding
|
||||
* lower as ffiCall nodes carrying the channel name, and both backends emit
|
||||
* the same dispatch — fetch the slot's registered pointer through
|
||||
* scr_library_cb_require (which delivers `unregisteredTrap` through the
|
||||
* library funnel when the host never registered), then the typed indirect
|
||||
* call with the slot's opaque context first. */
|
||||
export interface IrLibCallback {
|
||||
/** The channel name — the registration name string, the ambient
|
||||
* TypeScript binding, and the matching IrFfiImport's `name`. */
|
||||
name: string;
|
||||
/** The runtime channel slot (profile declaration order). */
|
||||
slot: number;
|
||||
params: ("f64" | "bool" | "string" | "bytes" | "u8" | "u32" | "i32")[];
|
||||
returns: "f64" | "bool" | "u8" | "u32" | "i32" | "void";
|
||||
/** The unregistered-call trap text (a DETECTED trap: plain bytes the
|
||||
* library funnel classifies SC4025 and assembles with the current
|
||||
* entry's symbol — unlike the SC4012 wrapper traps, the entry is only
|
||||
* known at runtime). Built once at export resolution so both backends
|
||||
* emit identical constants by construction. */
|
||||
unregisteredTrap: string;
|
||||
}
|
||||
|
||||
export interface IrLibSection {
|
||||
/** The profile's identity string (artifact header comments only). */
|
||||
profileName: string;
|
||||
@@ -904,6 +927,13 @@ export interface IrLibSection {
|
||||
* thread-local storage, matching the runtime objects compiled with
|
||||
* -DSCR_THREAD_INSTANCES: one full instance per embedder thread. */
|
||||
threadInstances: boolean;
|
||||
/** The host-callback registration symbol; present exactly when
|
||||
* `callbacks` is (the profile pairs them — SC4001 otherwise). Both
|
||||
* fields stay ABSENT on callback-free profiles so their serialized IR
|
||||
* and emitted TU are unchanged byte-for-byte. */
|
||||
callbackRegisterSymbol?: string;
|
||||
/** The resolved host-callback channels, slot-ordered. */
|
||||
callbacks?: IrLibCallback[];
|
||||
exports: IrLibExport[];
|
||||
/** Profile-declared teaching/remediation overlays for the runtime
|
||||
* detected-trap code family (SC4013–SC4019, diagnostics registry): both
|
||||
|
||||
@@ -1233,6 +1233,27 @@ export function validateModule(mod: IrModule): IrValidationError[] {
|
||||
if (!functionsByName.has(mod.entry)) {
|
||||
errors.push({ message: `library module missing its entry function "${mod.entry}"`, loc: entryLoc });
|
||||
}
|
||||
// Host-callback channels: the register symbol and the channel list are
|
||||
// paired (the profile loader refuses otherwise, so a miss here is a
|
||||
// compiler bug), slots are the declaration order, and every channel
|
||||
// has its matching ffiImport (the lowering recognizes calls by it).
|
||||
const cbs = mod.lib.callbacks ?? [];
|
||||
if ((cbs.length > 0) !== (mod.lib.callbackRegisterSymbol !== undefined)) {
|
||||
errors.push({ message: "library callbacks and callbackRegisterSymbol must be present together", loc: entryLoc });
|
||||
}
|
||||
const cbNames = new Set<string>();
|
||||
cbs.forEach((cb, i) => {
|
||||
if (cb.slot !== i) {
|
||||
errors.push({ message: `library callback "${cb.name}": slot ${cb.slot} out of declaration order (expected ${i})`, loc: entryLoc });
|
||||
}
|
||||
if (cbNames.has(cb.name)) {
|
||||
errors.push({ message: `duplicate library callback channel "${cb.name}"`, loc: entryLoc });
|
||||
}
|
||||
cbNames.add(cb.name);
|
||||
if (!(mod.ffiImports ?? []).some((f) => f.name === cb.name)) {
|
||||
errors.push({ message: `library callback "${cb.name}" has no matching ffiImport`, loc: entryLoc });
|
||||
}
|
||||
});
|
||||
}
|
||||
const classesByName = new Map<string, IrClassDef>();
|
||||
for (const cls of mod.classes ?? []) {
|
||||
|
||||
@@ -24,13 +24,22 @@
|
||||
* // mode (see below); absent
|
||||
* // = false, the classic
|
||||
* // single-archive artifact
|
||||
* "instance_per_thread": false // thread-instanced state
|
||||
* "instance_per_thread": false, // thread-instanced state
|
||||
* // (see below); absent =
|
||||
* // false, one instance per
|
||||
* // linked archive
|
||||
* "callback_register_symbol": // host-callback channel
|
||||
* "<prefix>_set_callback" // registration (see below);
|
||||
* // required exactly when
|
||||
* // `callbacks` declares
|
||||
* // channels
|
||||
* },
|
||||
* "exports": [ { "export": "update", "symbol": "<prefix>_update",
|
||||
* "params": ["f64", "string"], "returns": "bytes" } ],
|
||||
* "callbacks": [ { "name": "emitChunk", // host-callback channels
|
||||
* "params": ["bytes", "u32"], // (see below); absent =
|
||||
* "returns": "void" } ], // no callback surface
|
||||
|
||||
* "sidecar": { ... }, // ask-2 contract sidecar
|
||||
* // (see below); absent =
|
||||
* // no sidecar is emitted
|
||||
@@ -158,6 +167,64 @@
|
||||
* declares both. Off by default; non-opted builds are byte-for-byte
|
||||
* unchanged.
|
||||
*
|
||||
* Host-callback channels (`callbacks` + `abi.callback_register_symbol`):
|
||||
* the mode's outbound seam. A library's default outbound channels are
|
||||
* entry return values and the panic sink; a declared callback channel adds
|
||||
* a synchronous byte/scalar path from compiled code to the embedder — the
|
||||
* sink's pattern generalized: a profile-named registration symbol, a
|
||||
* host-supplied function pointer plus opaque context, calls arriving on
|
||||
* the calling thread. Each channel entry declares:
|
||||
*
|
||||
* - `name`: the channel's identity — the registration name string AND
|
||||
* the TypeScript binding compiled code calls. Program code reaches the
|
||||
* channel as a signature-only ambient declaration whose signature must
|
||||
* fit the declared classes (`declare function emitChunk(chunk:
|
||||
* Uint8Array, seq: number): void`); a call of such a declaration that
|
||||
* names no channel refuses SC4024 (only when the profile declares at
|
||||
* least one channel — a callback-free profile keeps the ordinary
|
||||
* ambient ReferenceError semantics, and its artifact is unchanged).
|
||||
* Direct calls only, the FFI binding rule.
|
||||
* - `params`: f64, bool, string, bytes, and the u8/u32/i32 plumbing
|
||||
* classes (outbound plumbing rides JS's own ToUint32/ToInt32, the
|
||||
* executable FFI lane's rule). string/bytes arrive as (ptr, len)
|
||||
* pairs BORROWED for the duration of the call only.
|
||||
* - `returns`: f64, bool, u8, u32, i32, or void — scalars only (a
|
||||
* buffer return needs an ownership contract the mode does not define,
|
||||
* the FFI format-1 ruling).
|
||||
*
|
||||
* The registration symbol's C shape, one per profile:
|
||||
*
|
||||
* int32_t <sym>(const char *name, void (*fn)(void), void *ctx);
|
||||
*
|
||||
* name selects the channel by its declared `name` (NUL-terminated); fn is
|
||||
* stored as shown and cast to the channel's typed shape at the call site:
|
||||
*
|
||||
* <ret> (*)(void *ctx, <params...>) // ctx first, the sink's layout
|
||||
*
|
||||
* Returns 0 on success, -1 for an unknown or NULL name (a defined
|
||||
* 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.
|
||||
* 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
|
||||
* an operation can reach before invoking that entry.
|
||||
*
|
||||
* Instance semantics compose exactly like the sink's: under
|
||||
* `localize_runtime` the channel slots are per-archive (each instance owns
|
||||
* a private registration set); under `instance_per_thread` they are
|
||||
* per-instance THREAD-LOCAL state — a callback registered on thread T
|
||||
* fires only for T's instance, and each serving thread registers its own
|
||||
* channels before calling the init entry.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* classes; i64/u64 are ask-4's declared integer boundary classes (params:
|
||||
@@ -199,6 +266,35 @@ export type LibIntClass = (typeof LIB_INT_CLASSES)[number];
|
||||
export type LibParamClass = (typeof LIB_PARAM_CLASSES)[number];
|
||||
export type LibReturnClass = (typeof LIB_RETURN_CLASSES)[number];
|
||||
|
||||
/** Marshalling classes legal in a CALLBACK PARAMETER position (outbound:
|
||||
* compiled code → host). The value classes plus the u8/u32/i32 plumbing
|
||||
* classes — outbound plumbing rides JS's own ToUint32/ToInt32 conversions
|
||||
* (the executable FFI lane's rule), so no new coercion story exists. The
|
||||
* declared integer classes stay export-map surface: an outbound i64/u64
|
||||
* demands the prove-or-refuse machinery, which is scoped to export
|
||||
* returns today. */
|
||||
export const LIB_CALLBACK_PARAM_CLASSES = ["f64", "bool", "string", "bytes", "u8", "u32", "i32"] as const;
|
||||
/** Marshalling classes legal in a CALLBACK RETURN position (inbound: host
|
||||
* → compiled code). Scalars only — every conversion to f64 is exact by
|
||||
* construction; a buffer return would need an ownership contract the mode
|
||||
* does not define (the FFI format-1 ruling). */
|
||||
export const LIB_CALLBACK_RETURN_CLASSES = ["f64", "bool", "u8", "u32", "i32", "void"] as const;
|
||||
/** The runtime's fixed channel-slot capacity (scr_library.c's
|
||||
* SCR_LIB_MAX_CALLBACKS — keep the two in step). */
|
||||
export const LIB_MAX_CALLBACKS = 32;
|
||||
|
||||
export type LibCallbackParamClass = (typeof LIB_CALLBACK_PARAM_CLASSES)[number];
|
||||
export type LibCallbackReturnClass = (typeof LIB_CALLBACK_RETURN_CLASSES)[number];
|
||||
|
||||
/** One declared host-callback channel (see the header contract). */
|
||||
export interface LibraryCallbackEntry {
|
||||
/** The channel's identity: the registration name string and the
|
||||
* TypeScript binding compiled code calls. */
|
||||
name: string;
|
||||
params: LibCallbackParamClass[];
|
||||
returns: LibCallbackReturnClass;
|
||||
}
|
||||
|
||||
export interface LibraryExportEntry {
|
||||
/** The entry module's export name. */
|
||||
export: string;
|
||||
@@ -287,6 +383,13 @@ export interface LibraryProfile {
|
||||
* the unchanged entry family (see the header contract). False (the
|
||||
* default) keeps one instance per linked archive. */
|
||||
instancePerThread: boolean;
|
||||
/** The host-callback registration symbol (see the header contract):
|
||||
* non-null exactly when `callbacks` declares channels. */
|
||||
callbackRegisterSymbol: string | null;
|
||||
/** The declared host-callback channels, in declaration order (the order
|
||||
* IS the runtime slot assignment). Empty when the profile declares
|
||||
* none — the callback-free artifact is unchanged. */
|
||||
callbacks: LibraryCallbackEntry[];
|
||||
exports: LibraryExportEntry[];
|
||||
/** The ask-2 contract-sidecar section; null = the profile declares no
|
||||
* sidecar and the invocation emits none. */
|
||||
@@ -383,7 +486,7 @@ export function loadLibraryProfile(
|
||||
// the root would otherwise be silently inert — the exact footgun the
|
||||
// fence machinery refuses everywhere else.
|
||||
for (const k of Object.keys(p)) {
|
||||
if (["profile_format", "name", "entry", "emission", "abi", "exports", "sidecar", "determinism"].includes(k)) continue;
|
||||
if (["profile_format", "name", "entry", "emission", "abi", "exports", "callbacks", "sidecar", "determinism"].includes(k)) continue;
|
||||
if (k === "fences" || k === "teachings" || k === "remediations") {
|
||||
throw new ProfileError(
|
||||
`'${k}' at the profile root does nothing — the ask-5 determinism surface lives under 'determinism.${k}'; move it there`,
|
||||
@@ -403,7 +506,7 @@ export function loadLibraryProfile(
|
||||
if (abi === null || typeof abi !== "object" || Array.isArray(abi)) {
|
||||
throw new ProfileError("'abi' must be an object");
|
||||
}
|
||||
rejectUnknownKeys(abi, "abi", ["prefix", "init_symbol", "sink_register_symbol", "collect_symbol", "result_reset_symbol", "localize_runtime", "instance_per_thread"]);
|
||||
rejectUnknownKeys(abi, "abi", ["prefix", "init_symbol", "sink_register_symbol", "collect_symbol", "result_reset_symbol", "localize_runtime", "instance_per_thread", "callback_register_symbol"]);
|
||||
const a = abi as Record<string, unknown>;
|
||||
const prefix = req<string>(a["prefix"], "abi.prefix", "string");
|
||||
if (!C_IDENT.test(prefix)) {
|
||||
@@ -426,6 +529,7 @@ export function loadLibraryProfile(
|
||||
a["instance_per_thread"] === undefined
|
||||
? false
|
||||
: req<boolean>(a["instance_per_thread"], "abi.instance_per_thread", "boolean");
|
||||
const callbackRegisterSymbol = symbolField(a["callback_register_symbol"], "abi.callback_register_symbol", prefix, true);
|
||||
|
||||
const exportsRaw = p["exports"];
|
||||
if (!Array.isArray(exportsRaw)) throw new ProfileError("'exports' must be an array");
|
||||
@@ -461,6 +565,90 @@ export function loadLibraryProfile(
|
||||
entries.push({ export: exportName, symbol, params, returns: returns as LibReturnClass });
|
||||
});
|
||||
|
||||
// Host-callback channels: the outbound seam's declaration (see the
|
||||
// header contract). The section is strict like exports — a typo'd
|
||||
// class or key would change the C shape the host implements with no
|
||||
// signal.
|
||||
const callbacks: LibraryCallbackEntry[] = [];
|
||||
const cbRaw = p["callbacks"];
|
||||
if (cbRaw !== undefined && cbRaw !== null) {
|
||||
if (!Array.isArray(cbRaw)) throw new ProfileError("'callbacks' must be an array");
|
||||
cbRaw.forEach((c, i) => {
|
||||
const path = `callbacks[${i}]`;
|
||||
if (c === null || typeof c !== "object" || Array.isArray(c)) {
|
||||
throw new ProfileError(`'${path}' must be an object`);
|
||||
}
|
||||
rejectUnknownKeys(c, path, ["name", "params", "returns"]);
|
||||
const cc = c as Record<string, unknown>;
|
||||
const cbName = req<string>(cc["name"], `${path}.name`, "string");
|
||||
// The name is BOTH the registration name string and the ambient
|
||||
// TypeScript binding — one grammar serves both sides (and stays a
|
||||
// clean C string literal in the generated dispatch).
|
||||
if (!C_IDENT.test(cbName)) {
|
||||
throw new ProfileError(
|
||||
`'${path}.name' is not a valid channel name: '${cbName}' (a channel name is the TypeScript binding compiled code calls and the registration name string — letters, digits, underscore, not starting with a digit)`,
|
||||
);
|
||||
}
|
||||
const paramsRaw = cc["params"];
|
||||
if (!Array.isArray(paramsRaw)) throw new ProfileError(`'${path}.params' must be an array`);
|
||||
const cbParams = paramsRaw.map((cls, j) => {
|
||||
if (typeof cls === "string" && (cls === "i64" || cls === "u64")) {
|
||||
throw new ProfileError(
|
||||
`'${path}.params[${j}]': the declared integer classes are export-map surface — an outbound callback scalar is f64 (whole values ride exactly to ±(2^53 − 1)) or one of the u8/u32/i32 plumbing classes (JS ToUint32/ToInt32 semantics)`,
|
||||
);
|
||||
}
|
||||
if (typeof cls !== "string" || !(LIB_CALLBACK_PARAM_CLASSES as readonly string[]).includes(cls)) {
|
||||
throw new ProfileError(
|
||||
`'${path}.params[${j}]' must be one of ${LIB_CALLBACK_PARAM_CLASSES.join("/")}, got ${JSON.stringify(cls)}`,
|
||||
);
|
||||
}
|
||||
return cls as LibCallbackParamClass;
|
||||
});
|
||||
const cbReturns = req<string>(cc["returns"], `${path}.returns`, "string");
|
||||
if (!(LIB_CALLBACK_RETURN_CLASSES as readonly string[]).includes(cbReturns)) {
|
||||
const detail =
|
||||
cbReturns === "string" || cbReturns === "bytes"
|
||||
? `'${path}.returns': a callback return stays scalar — a buffer flowing host → library needs an ownership contract the mode does not define; pass buffers as callback parameters (library → host, borrowed for the call) or entry parameters instead`
|
||||
: `'${path}.returns' must be one of ${LIB_CALLBACK_RETURN_CLASSES.join("/")}, got '${cbReturns}'`;
|
||||
throw new ProfileError(detail);
|
||||
}
|
||||
callbacks.push({ name: cbName, params: cbParams, returns: cbReturns as LibCallbackReturnClass });
|
||||
});
|
||||
if (callbacks.length > LIB_MAX_CALLBACKS) {
|
||||
throw new ProfileError(
|
||||
`'callbacks' declares ${callbacks.length} channels — the runtime's slot capacity is ${LIB_MAX_CALLBACKS}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Presence pairing (the anti-inert posture, both directions): channels
|
||||
// without a registration symbol are unreachable by any host, and a
|
||||
// registration symbol without channels is a symbol that can only
|
||||
// answer -1.
|
||||
if (callbacks.length > 0 && callbackRegisterSymbol === null) {
|
||||
throw new ProfileError(
|
||||
"'callbacks' declares channels but 'abi.callback_register_symbol' is missing — the host registers channel implementations through that symbol",
|
||||
);
|
||||
}
|
||||
if (callbacks.length === 0 && callbackRegisterSymbol !== null) {
|
||||
throw new ProfileError(
|
||||
"'abi.callback_register_symbol' is declared but 'callbacks' declares no channels — remove the symbol or declare the channels it registers",
|
||||
);
|
||||
}
|
||||
{
|
||||
const seen = new Set<string>();
|
||||
for (const cb of callbacks) {
|
||||
if (seen.has(cb.name)) throw new ProfileError(`callback channel '${cb.name}' is declared twice`);
|
||||
seen.add(cb.name);
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (seen.has(e.export)) {
|
||||
throw new ProfileError(
|
||||
`'${e.export}' is both an export-map export and a callback channel name — a channel is a signature-only ambient declaration, an export is an entry function; one binding cannot be both`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The ask-2 sidecar section: presence turns contract emission on.
|
||||
// Unknown fields inside it are refused like abi's (a typo here would
|
||||
// silently change the emitted contract).
|
||||
@@ -567,6 +755,7 @@ export function loadLibraryProfile(
|
||||
claim(sinkRegisterSymbol, "abi.sink_register_symbol");
|
||||
claim(collectSymbol, "abi.collect_symbol");
|
||||
claim(resultResetSymbol, "abi.result_reset_symbol");
|
||||
claim(callbackRegisterSymbol, "abi.callback_register_symbol");
|
||||
if (sidecar !== null) {
|
||||
claim(sidecar.buildIdSymbol, "sidecar.build_id_symbol");
|
||||
claim(sidecar.abiVersionSymbol, "sidecar.abi_version_symbol");
|
||||
@@ -684,6 +873,8 @@ export function loadLibraryProfile(
|
||||
resultResetSymbol,
|
||||
localizeRuntime,
|
||||
instancePerThread,
|
||||
callbackRegisterSymbol,
|
||||
callbacks,
|
||||
exports: entries,
|
||||
sidecar,
|
||||
profileBytes: bytes,
|
||||
|
||||
@@ -216,15 +216,18 @@ export function libraryIdentityHashes(
|
||||
|
||||
/** The profile's canonical `abi.exports` suffix order: the identity
|
||||
* getters first (abi_version, then build_id), the mode-provided entries
|
||||
* (sink registration, init, collect, reset — declared ones only), then
|
||||
* the export map in profile order. Suffix = symbol minus prefix. */
|
||||
* (sink registration, callback registration, init, collect, reset —
|
||||
* declared ones only), then the export map in profile order. Suffix =
|
||||
* symbol minus prefix. */
|
||||
export function abiExportSuffixes(profile: LibraryProfile): string[] {
|
||||
const strip = (sym: string): string => sym.slice(profile.prefix.length);
|
||||
const out: string[] = [];
|
||||
if (profile.sidecar !== null) {
|
||||
out.push(strip(profile.sidecar.abiVersionSymbol), strip(profile.sidecar.buildIdSymbol));
|
||||
}
|
||||
out.push(strip(profile.sinkRegisterSymbol), strip(profile.initSymbol));
|
||||
out.push(strip(profile.sinkRegisterSymbol));
|
||||
if (profile.callbackRegisterSymbol !== null) out.push(strip(profile.callbackRegisterSymbol));
|
||||
out.push(strip(profile.initSymbol));
|
||||
if (profile.collectSymbol !== null) out.push(strip(profile.collectSymbol));
|
||||
if (profile.resultResetSymbol !== null) out.push(strip(profile.resultResetSymbol));
|
||||
for (const e of profile.exports) out.push(strip(e.symbol));
|
||||
|
||||
@@ -53,6 +53,35 @@ void scr_library_set_sink(ScrLibSinkFn fn, void *ctx) {
|
||||
scr_library_sink_ctx = ctx;
|
||||
}
|
||||
|
||||
/* ── host-callback channels (scr_runtime.h's contract) ────────────────────
|
||||
* Fixed slot storage, one pair per profile-declared channel (declaration
|
||||
* order = slot index, assigned by the generated registration symbol's
|
||||
* dispatch). SCR_TL gives thread-instanced archives per-instance
|
||||
* registration; localization gives multi-instance processes per-archive
|
||||
* slots — exactly the sink's story. The generated program TU is the only
|
||||
* caller, with slot indices proven in range at compile time (the profile
|
||||
* caps channels at SCR_LIB_MAX_CALLBACKS). */
|
||||
|
||||
static SCR_TL ScrLibCbFn scr_library_cb_fns[SCR_LIB_MAX_CALLBACKS];
|
||||
static SCR_TL void *scr_library_cb_ctxs[SCR_LIB_MAX_CALLBACKS];
|
||||
|
||||
void scr_library_cb_set(size_t slot, ScrLibCbFn fn, void *ctx) {
|
||||
/* A pure store, the sink registration's rule: latest wins, NULL clears,
|
||||
* not poison-guarded, persists across init/reset. */
|
||||
scr_library_cb_fns[slot] = fn;
|
||||
scr_library_cb_ctxs[slot] = ctx;
|
||||
}
|
||||
|
||||
ScrLibCbFn scr_library_cb_require(size_t slot, const char *trap_msg) {
|
||||
/* trap_msg is the call site's constant ("scriptc: library callback
|
||||
* '<name>' invoked before registration\n") — a DETECTED trap the funnel
|
||||
* classifies SC4025 and assembles with the entry the host called. */
|
||||
if (scr_library_cb_fns[slot] == NULL) scr_trap(trap_msg);
|
||||
return scr_library_cb_fns[slot];
|
||||
}
|
||||
|
||||
void *scr_library_cb_ctx(size_t slot) { return scr_library_cb_ctxs[slot]; }
|
||||
|
||||
/* ── 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:
|
||||
@@ -104,6 +133,7 @@ static const struct {
|
||||
{"scriptc: SyntaxError: ", "SC4016"}, /* syntax trap (regex compile) */
|
||||
{"scriptc: out of memory", "SC4017"}, /* allocation failure */
|
||||
{"scriptc: internal error: ", "SC4018"}, /* internal invariant failure */
|
||||
{"scriptc: library callback ", "SC4025"}, /* unregistered host callback */
|
||||
};
|
||||
|
||||
static const char *scr_library_trap_code(const char *msg, size_t len) {
|
||||
|
||||
@@ -103,8 +103,9 @@ 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 SC4013–SC4019 runtime family,
|
||||
* classified in scr_library.c), the entry symbol recorded by the trapping
|
||||
* trap kind (the compiler registry's runtime family — SC4013–SC4019 plus
|
||||
* the SC4025 unregistered-callback trap, 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
|
||||
* absent otherwise). A message that already begins with the marker — a
|
||||
@@ -114,6 +115,39 @@ typedef void (*ScrLibSinkFn)(void *ctx, const uint8_t *msg, size_t msg_len,
|
||||
uint64_t address);
|
||||
void scr_library_set_sink(ScrLibSinkFn fn, void *ctx); /* latest wins */
|
||||
|
||||
/* ── host-callback channels ───────────────────────────────────────────────
|
||||
* The panic sink's registration pattern generalized into a synchronous
|
||||
* outbound seam: the profile declares named channels (bytes/scalar
|
||||
* signatures), the generated registration symbol maps a channel name to a
|
||||
* slot index here, and compiled call sites fetch the slot through
|
||||
* scr_library_cb_require — which returns the host's pointer or delivers
|
||||
* the call site's trap message through the funnel (the SC4025 detected
|
||||
* trap) when the host never registered. The typed shape of each stored
|
||||
* pointer is the channel's, with the opaque context first:
|
||||
*
|
||||
* <ret> (*)(void *ctx, <params...>)
|
||||
*
|
||||
* 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
|
||||
* 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. */
|
||||
#define SCR_LIB_MAX_CALLBACKS 32 /* keep in step with LIB_MAX_CALLBACKS (library/profile.ts) */
|
||||
/* The stored shape: generated call sites cast a slot's pointer to the
|
||||
* channel's typed shape before calling. */
|
||||
typedef void (*ScrLibCbFn)(void);
|
||||
void scr_library_cb_set(size_t slot, ScrLibCbFn fn, void *ctx);
|
||||
/* The call-site fetch: the registered pointer, or the funnel trap with
|
||||
* 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);
|
||||
|
||||
/* Entry prologue: aborts deterministically when the library is poisoned (a
|
||||
* trap already fired — no profile entry may run again; recovery is process
|
||||
* restart). reset_arena additionally drops the result arena (the
|
||||
@@ -162,8 +196,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
|
||||
* (SC4013–SC4019 family) the profile declares text for; _len counts
|
||||
* triples. A declared teaching replaces the baseline human line as field 0;
|
||||
* (the SC4013–SC4019 family plus SC4025) 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[];
|
||||
extern const size_t scr_library_trap_overlays_len;
|
||||
|
||||
@@ -265,6 +265,17 @@ test.each([
|
||||
code: "SC5003",
|
||||
message: "return type is 'never'",
|
||||
},
|
||||
{
|
||||
id: "narrow-return",
|
||||
name: "a narrowed TypeScript return that does not cover the native ABI domain",
|
||||
source: [
|
||||
"declare function nativeScale(value: number): 0;",
|
||||
"console.log('ok');",
|
||||
"",
|
||||
].join("\n"),
|
||||
code: "SC5003",
|
||||
message: "may supply any number",
|
||||
},
|
||||
])("rejects $name", async ({ id, source, code, message }) => {
|
||||
const outDir = join(cacheRoot, `reject-${id}`);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
/* 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);
|
||||
});
|
||||
});
|
||||
@@ -231,7 +231,7 @@ describe.each(EMISSIONS)("contract sidecar, %s emission", (emission) => {
|
||||
// exactly prefix + suffix over abi.exports — no extras, no misses.
|
||||
expect(doc.abi).toEqual({
|
||||
prefix: "kc_",
|
||||
exports: ["abi_version", "build_id", "set_panic_sink", "init", "boot", "send", "command_msg", "title", "helper_probe", "boom"],
|
||||
exports: ["abi_version", "build_id", "set_panic_sink", "set_callback", "init", "boot", "send", "command_msg", "title", "helper_probe", "boom"],
|
||||
snapshot_format: 2,
|
||||
});
|
||||
expect(nmDefined(archive, "kc_")).toEqual(doc.abi.exports.map((s) => `kc_${s}`).sort());
|
||||
|
||||
@@ -559,3 +559,107 @@ describe("library profile fences", () => {
|
||||
expect(r.profile.fences).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("library profile host-callback channels", () => {
|
||||
const withCallbacks = {
|
||||
...good,
|
||||
abi: { ...good.abi, callback_register_symbol: "kx_set_callback" },
|
||||
callbacks: [
|
||||
{ name: "emitChunk", params: ["bytes", "u32"], returns: "void" },
|
||||
{ name: "progress", params: ["f64", "f64"], returns: "i32" },
|
||||
],
|
||||
};
|
||||
|
||||
test("well-formed channels resolve in declaration order", () => {
|
||||
const r = loadLibraryProfile(writeProfile(withCallbacks));
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.profile.callbackRegisterSymbol).toBe("kx_set_callback");
|
||||
expect(r.profile.callbacks).toEqual([
|
||||
{ name: "emitChunk", params: ["bytes", "u32"], returns: "void" },
|
||||
{ name: "progress", params: ["f64", "f64"], returns: "i32" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("a callback-free profile carries the empty surface", () => {
|
||||
const r = loadLibraryProfile(writeProfile(good));
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.profile.callbackRegisterSymbol).toBeNull();
|
||||
expect(r.profile.callbacks).toEqual([]);
|
||||
});
|
||||
|
||||
test("channels without a registration symbol refuse (unreachable by any host)", () =>
|
||||
expectSc4001(
|
||||
{ ...good, callbacks: [{ name: "emitChunk", params: ["bytes"], returns: "void" }] },
|
||||
"abi.callback_register_symbol",
|
||||
));
|
||||
|
||||
test("a registration symbol without channels refuses (the anti-inert posture)", () =>
|
||||
expectSc4001(
|
||||
{ ...good, abi: { ...good.abi, callback_register_symbol: "kx_set_callback" } },
|
||||
"declares no channels",
|
||||
));
|
||||
|
||||
test("the registration symbol keeps the prefix and pairwise-distinct rules", () => {
|
||||
expectSc4001(
|
||||
{ ...withCallbacks, abi: { ...withCallbacks.abi, callback_register_symbol: "other_set" } },
|
||||
"must start with the profile prefix",
|
||||
);
|
||||
expectSc4001(
|
||||
{ ...withCallbacks, abi: { ...withCallbacks.abi, callback_register_symbol: "kx_init" } },
|
||||
"declared twice",
|
||||
);
|
||||
});
|
||||
|
||||
test("declared integer classes refuse in callback parameter position", () =>
|
||||
expectSc4001(
|
||||
{ ...withCallbacks, callbacks: [{ name: "x", params: ["i64"], returns: "void" }] },
|
||||
"export-map surface",
|
||||
));
|
||||
|
||||
test("buffer returns refuse with the ownership teaching", () =>
|
||||
expectSc4001(
|
||||
{ ...withCallbacks, callbacks: [{ name: "x", params: [], returns: "bytes" }] },
|
||||
"ownership contract",
|
||||
));
|
||||
|
||||
test("duplicate channel names refuse", () =>
|
||||
expectSc4001(
|
||||
{
|
||||
...withCallbacks,
|
||||
callbacks: [
|
||||
{ name: "x", params: [], returns: "void" },
|
||||
{ name: "x", params: [], returns: "void" },
|
||||
],
|
||||
},
|
||||
"declared twice",
|
||||
));
|
||||
|
||||
test("a channel name colliding with an export-map export refuses", () =>
|
||||
expectSc4001(
|
||||
{ ...withCallbacks, callbacks: [{ name: "update", params: [], returns: "void" }] },
|
||||
"both an export-map export and a callback channel name",
|
||||
));
|
||||
|
||||
test("unknown fields inside a channel entry refuse", () =>
|
||||
expectSc4001(
|
||||
{ ...withCallbacks, callbacks: [{ name: "x", params: [], returns: "void", lifetime: "call" }] },
|
||||
"callbacks[0].lifetime",
|
||||
));
|
||||
|
||||
test("a channel name must be a plain identifier (the TS binding and the C name string)", () =>
|
||||
expectSc4001(
|
||||
{ ...withCallbacks, callbacks: [{ name: "emit-chunk", params: [], returns: "void" }] },
|
||||
"not a valid channel name",
|
||||
));
|
||||
|
||||
test("the runtime's slot capacity caps the channel count", () =>
|
||||
expectSc4001(
|
||||
{
|
||||
...withCallbacks,
|
||||
callbacks: Array.from({ length: 33 }, (_, i) => ({ name: `c${i}`, params: [], returns: "void" })),
|
||||
},
|
||||
"slot capacity is 32",
|
||||
));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// A project-owned declaration file is authored callback surface, unlike
|
||||
// lib.d.ts/@types/package declarations. The profile's emitChunk channel must
|
||||
// claim this exact ambient binding when lib_project_dts.ts calls it.
|
||||
declare function emitChunk(chunk: Uint8Array, seq: number): void;
|
||||
@@ -0,0 +1,3 @@
|
||||
// Project-owned declaration files are callback-authored surface. This name
|
||||
// is deliberately absent from the profile so its call must refuse SC4024.
|
||||
declare function sneaky(x: number): void;
|
||||
@@ -0,0 +1,40 @@
|
||||
// Host-callback conformance fixture: profile-declared channels a
|
||||
// service-shaped export streams through — synchronously, on the calling
|
||||
// thread. `stream` emits N chunks interleaved with computation (the return
|
||||
// value depends on work done between emits, so ordering is observable);
|
||||
// `askHost` round-trips scalars through host RETURNS (i32/u32 inbound);
|
||||
// `pokeOrphan` calls the one channel the probe deliberately never
|
||||
// registers (the SC4025 unregistered-call trap path).
|
||||
declare function emitChunk(chunk: Uint8Array, seq: number): void;
|
||||
declare function progress(done: number, total: number): number;
|
||||
declare function note(text: string, last: boolean): void;
|
||||
declare function mix(a: number, b: number): number;
|
||||
declare function orphan(x: number): void;
|
||||
|
||||
let sessions = 0;
|
||||
|
||||
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; // '!'
|
||||
emitChunk(chunk, i);
|
||||
acc += (i + 1) * base; // computation between emits
|
||||
note(`chunk ${i} away`, i === n - 1);
|
||||
}
|
||||
return acc + sessions;
|
||||
}
|
||||
|
||||
export function askHost(x: number): number {
|
||||
return progress(x, 10) * 2 + mix(x + 300, 0 - x);
|
||||
}
|
||||
|
||||
export function pokeOrphan(): number {
|
||||
orphan(7);
|
||||
return -1;
|
||||
}
|
||||
|
||||
console.log("callbacks ready");
|
||||
@@ -0,0 +1,18 @@
|
||||
// CB6 refusal fixture: the configured channel name resolves to a
|
||||
// program-authored ambient function VALUE, not the supported signature-only
|
||||
// function-declaration form. The call must refuse SC4024; it must never be
|
||||
// mistaken for unused capacity and silently erased.
|
||||
declare const orphan: (x: number) => void;
|
||||
|
||||
export function stream(n: number, base: number): number {
|
||||
return n + base;
|
||||
}
|
||||
|
||||
export function askHost(x: number): number {
|
||||
return x;
|
||||
}
|
||||
|
||||
export function pokeOrphan(): number {
|
||||
orphan(7);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// CB6 builtin-name collision fixture: profile channels may share names with
|
||||
// declaration-file ambients without claiming those bindings. isNaN must keep
|
||||
// its standard-library lowering; parseInt is unreferenced channel capacity and
|
||||
// must not be validated against lib.d.ts's optional-radix signature.
|
||||
// `int` is a valid TypeScript identifier but a C keyword: C emission must
|
||||
// dispatch it indirectly without inventing an `extern ... int(...)` symbol.
|
||||
declare function int(x: number): number;
|
||||
|
||||
export function checkBuiltin(x: number): boolean {
|
||||
return isNaN(x);
|
||||
}
|
||||
|
||||
export function callKeyword(x: number): number {
|
||||
return int(x);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// CB6 refusal fixture: the ambient declaration's first parameter is
|
||||
// `string` where the profile's emitChunk channel declares bytes — the
|
||||
// TypeScript signature and the channel's classes must agree (SC4024).
|
||||
declare function emitChunk(chunk: string, seq: number): void;
|
||||
|
||||
export function stream(n: number, base: number): number {
|
||||
emitChunk("hi", base);
|
||||
return n;
|
||||
}
|
||||
|
||||
export function askHost(x: number): number {
|
||||
return x;
|
||||
}
|
||||
|
||||
export function pokeOrphan(): number {
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// External callback returns must cover the whole ABI domain. Literal return
|
||||
// declarations map to the same IR storage types, but cannot promise what an
|
||||
// arbitrary host implementation returns.
|
||||
declare function answerBool(): true;
|
||||
declare function answerNumber(): 0;
|
||||
|
||||
export function run(): number {
|
||||
return (answerBool() ? 1 : 0) + answerNumber();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/// <reference path="./host_project.d.ts" />
|
||||
|
||||
// The callback binding intentionally lives in the referenced project .d.ts.
|
||||
// The remaining profile channels are unused capacity for this fixture.
|
||||
export function stream(n: number, base: number): number {
|
||||
const chunk = new Uint8Array(2);
|
||||
chunk[0] = 65 + n;
|
||||
chunk[1] = 48 + base;
|
||||
emitChunk(chunk, n);
|
||||
return n + base;
|
||||
}
|
||||
|
||||
export function askHost(x: number): number {
|
||||
return x;
|
||||
}
|
||||
|
||||
export function pokeOrphan(): number {
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// CB6 refusal fixture: `sneaky` is a program-authored signature-only
|
||||
// ambient function the profile's channels do not declare — with a declared
|
||||
// callback surface, calling it refuses SC4024 (the author reached for the
|
||||
// host seam the profile does not provide). With the callbacks section
|
||||
// stripped, the same source keeps Node's ambient ReferenceError semantics
|
||||
// (the standing guarantee's behavioral half).
|
||||
declare function emitChunk(chunk: Uint8Array, seq: number): void;
|
||||
declare function sneaky(x: number): void;
|
||||
|
||||
export function stream(n: number, base: number): number {
|
||||
emitChunk(new Uint8Array(1), n);
|
||||
sneaky(base);
|
||||
return n;
|
||||
}
|
||||
|
||||
export function askHost(x: number): number {
|
||||
return x;
|
||||
}
|
||||
|
||||
export function pokeOrphan(): number {
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/// <reference path="./host_undeclared_project.d.ts" />
|
||||
|
||||
// The profile declares callbacks, but not `sneaky`. Keeping the declaration
|
||||
// in a project .d.ts pins the same SC4024 path as an in-source ambient.
|
||||
export function stream(n: number, base: number): number {
|
||||
sneaky(base);
|
||||
return n;
|
||||
}
|
||||
|
||||
export function askHost(x: number): number {
|
||||
return x;
|
||||
}
|
||||
|
||||
export function pokeOrphan(): number {
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// CB6 capacity fixture: the profile declares five channels; this program
|
||||
// references none of them. A declared-but-unreferenced channel is legal
|
||||
// capacity — the build succeeds and the registration symbol still answers
|
||||
// for every declared name.
|
||||
export function stream(n: number, base: number): number {
|
||||
return n * base * 2;
|
||||
}
|
||||
|
||||
export function askHost(x: number): number {
|
||||
return x + 1;
|
||||
}
|
||||
|
||||
export function pokeOrphan(): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log("unused ready");
|
||||
@@ -0,0 +1,219 @@
|
||||
/* Host-callback acceptance probe, mode-selected by argv[1]:
|
||||
* run — the full happy path: registration return codes (0 known,
|
||||
* -1 unknown/NULL name), a service-shaped export streaming
|
||||
* chunks through the bytes+u32 channel interleaved with
|
||||
* computation (contents, order, and thread identity all
|
||||
* recorded by the callback and asserted after the entry
|
||||
* returns — the calls are synchronous, on the calling
|
||||
* thread), string/bool and scalar channels, host RETURNS
|
||||
* riding i32/u32 back into compiled code, re-registration
|
||||
* routing to the new context, and finally a NULL-fn clear
|
||||
* followed by a call: the SC4025 structured trap through
|
||||
* the sink, exactly once, naming the entry the host called
|
||||
* orphan — a channel the host never registered: the first call
|
||||
* traps SC4025 (symbol cb_poke_orphan), the sink longjmps
|
||||
* out (the conforming survival pattern), and the poisoned
|
||||
* library aborts the next entry deterministically
|
||||
* preregister — an unregistered-channel call BEFORE sink registration
|
||||
* aborts (the funnel's last resort)
|
||||
*/
|
||||
#include <pthread.h>
|
||||
#include <setjmp.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
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 int32_t cb_set_callback(const char *name, void (*fn)(void), void *ctx);
|
||||
extern double cb_stream(double n, double base);
|
||||
extern double cb_ask_host(double x);
|
||||
extern double cb_poke_orphan(void);
|
||||
|
||||
typedef void (*cb_fn)(void);
|
||||
|
||||
/* ── chunk recording (the bytes+u32 channel) ─────────────────────────── */
|
||||
|
||||
typedef struct {
|
||||
char bytes[8];
|
||||
size_t len;
|
||||
uint32_t seq;
|
||||
} ChunkRec;
|
||||
|
||||
typedef struct {
|
||||
const char *tag;
|
||||
ChunkRec chunks[16];
|
||||
int count;
|
||||
int thread_ok; /* every delivery arrived on the registering thread */
|
||||
} ChunkLog;
|
||||
|
||||
static void on_chunk(void *ctx, const uint8_t *p, size_t len, uint32_t seq) {
|
||||
ChunkLog *log = (ChunkLog *)ctx;
|
||||
ChunkRec *rec = &log->chunks[log->count];
|
||||
/* Borrowed for the duration of the call only: copy out. */
|
||||
memcpy(rec->bytes, p, len < sizeof rec->bytes ? len : sizeof rec->bytes);
|
||||
rec->len = len;
|
||||
rec->seq = seq;
|
||||
log->count++;
|
||||
}
|
||||
|
||||
static pthread_t main_thread;
|
||||
|
||||
static void on_chunk_thread_check(void *ctx, const uint8_t *p, size_t len, uint32_t seq) {
|
||||
ChunkLog *log = (ChunkLog *)ctx;
|
||||
on_chunk(ctx, p, len, seq);
|
||||
if (!pthread_equal(pthread_self(), main_thread)) log->thread_ok = 0;
|
||||
}
|
||||
|
||||
static void dump_chunks(const ChunkLog *log) {
|
||||
printf("%s: %d chunk(s), thread_ok=%d\n", log->tag, log->count, log->thread_ok);
|
||||
for (int i = 0; i < log->count; i++) {
|
||||
printf(" seq=%u len=%zu bytes=%.*s\n", log->chunks[i].seq, log->chunks[i].len,
|
||||
(int)log->chunks[i].len, log->chunks[i].bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── the other channels ──────────────────────────────────────────────── */
|
||||
|
||||
static char note_log[512];
|
||||
|
||||
static void on_note(void *ctx, const uint8_t *p, size_t len, uint8_t last) {
|
||||
(void)ctx;
|
||||
size_t used = strlen(note_log);
|
||||
snprintf(note_log + used, sizeof note_log - used, "[%.*s last=%d]", (int)len, (const char *)p, last != 0);
|
||||
}
|
||||
|
||||
static int32_t on_progress(void *ctx, double done, double total) {
|
||||
(void)ctx;
|
||||
return (int32_t)(total - done);
|
||||
}
|
||||
|
||||
static uint32_t on_mix(void *ctx, uint8_t a, int32_t b) {
|
||||
(void)ctx;
|
||||
return (uint32_t)a + (uint32_t)(-b);
|
||||
}
|
||||
|
||||
/* ── the panic sink (the traps probe's parse rule) ───────────────────── */
|
||||
|
||||
static jmp_buf trap_jmp;
|
||||
static int sink_calls = 0;
|
||||
|
||||
static void show(const uint8_t *msg, size_t len) {
|
||||
if (len == 0 || msg[0] != 0x01) {
|
||||
printf("baseline printable=%d text=%.*s", len > 0 && msg[0] >= 0x20, (int)len, (const char *)msg);
|
||||
return;
|
||||
}
|
||||
static const char *names[4] = {"text", "code", "symbol", "remediation"};
|
||||
const uint8_t *p = msg + 1, *end = msg + len;
|
||||
int fields = 0;
|
||||
for (;;) {
|
||||
const uint8_t *sep = memchr(p, 0x1f, (size_t)(end - p));
|
||||
const uint8_t *stop = sep != NULL ? sep : end;
|
||||
if (fields < 4) printf("%s=[%.*s]\n", names[fields], (int)(stop - p), (const char *)p);
|
||||
fields++;
|
||||
if (sep == NULL) break;
|
||||
p = sep + 1;
|
||||
}
|
||||
printf("fields=%d text_printable=%d\n", fields, len > 1 && msg[1] >= 0x20);
|
||||
}
|
||||
|
||||
static void sink(void *ctx, const uint8_t *msg, size_t len, uint64_t addr) {
|
||||
(void)ctx;
|
||||
sink_calls++;
|
||||
printf("sink[%d]:\n", sink_calls);
|
||||
show(msg, len);
|
||||
printf("addr: %s\n", addr != 0 ? "nonzero" : "zero");
|
||||
longjmp(trap_jmp, 1);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
const char *mode = argc > 1 ? argv[1] : "run";
|
||||
main_thread = pthread_self();
|
||||
|
||||
static ChunkLog log_a = {.tag = "log_a", .thread_ok = 1};
|
||||
static ChunkLog log_b = {.tag = "log_b", .thread_ok = 1};
|
||||
|
||||
if (strcmp(mode, "preregister") == 0) {
|
||||
/* No sink registered: the unregistered-channel trap must abort.
|
||||
* Nothing after the call may print. */
|
||||
cb_init();
|
||||
cb_poke_orphan();
|
||||
printf("UNREACHABLE\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (strcmp(mode, "orphan") == 0) {
|
||||
cb_set_panic_sink(sink, NULL);
|
||||
/* Everything BUT orphan registered: reaching the one unregistered
|
||||
* channel is the defined trap, whatever else is wired. */
|
||||
cb_set_callback("emitChunk", (cb_fn)on_chunk_thread_check, &log_a);
|
||||
cb_set_callback("progress", (cb_fn)on_progress, NULL);
|
||||
cb_set_callback("note", (cb_fn)on_note, NULL);
|
||||
cb_set_callback("mix", (cb_fn)on_mix, NULL);
|
||||
cb_init();
|
||||
if (setjmp(trap_jmp) == 0) {
|
||||
cb_poke_orphan();
|
||||
printf("UNREACHABLE\n");
|
||||
} else {
|
||||
printf("survived, sink_calls=%d\n", sink_calls);
|
||||
fflush(stdout);
|
||||
cb_stream(1, 1); /* must abort — the library is poisoned */
|
||||
printf("UNREACHABLE\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* mode "run" */
|
||||
|
||||
/* Registration is a pure store, legal before init; return codes are the
|
||||
* defined refusal surface (-1 unknown or NULL name, 0 stored). */
|
||||
printf("reg unknown: %d\n", (int)cb_set_callback("nope", (cb_fn)on_chunk, &log_a));
|
||||
printf("reg null-name: %d\n", (int)cb_set_callback(NULL, (cb_fn)on_chunk, &log_a));
|
||||
printf("reg emitChunk: %d\n", (int)cb_set_callback("emitChunk", (cb_fn)on_chunk_thread_check, &log_a));
|
||||
printf("reg progress: %d\n", (int)cb_set_callback("progress", (cb_fn)on_progress, NULL));
|
||||
printf("reg note: %d\n", (int)cb_set_callback("note", (cb_fn)on_note, NULL));
|
||||
printf("reg mix: %d\n", (int)cb_set_callback("mix", (cb_fn)on_mix, NULL));
|
||||
|
||||
cb_set_panic_sink(sink, NULL);
|
||||
cb_init();
|
||||
|
||||
/* The service-shaped stream: chunks arrive synchronously, in order, on
|
||||
* this thread, fully delivered by the time the entry returns. */
|
||||
double r1 = cb_stream(4, 3);
|
||||
printf("stream(4,3) = %g\n", r1);
|
||||
dump_chunks(&log_a);
|
||||
printf("notes: %s\n", note_log);
|
||||
|
||||
log_a.count = 0;
|
||||
note_log[0] = '\0';
|
||||
double r2 = cb_stream(2, 7);
|
||||
printf("stream(2,7) = %g\n", r2);
|
||||
dump_chunks(&log_a);
|
||||
printf("notes: %s\n", note_log);
|
||||
|
||||
/* Host returns ride i32/u32 back into compiled code. */
|
||||
printf("askHost(5) = %g\n", cb_ask_host(5));
|
||||
|
||||
/* Re-registration: latest wins — chunks route to the NEW context. */
|
||||
printf("reg emitChunk again: %d\n", (int)cb_set_callback("emitChunk", (cb_fn)on_chunk_thread_check, &log_b));
|
||||
log_a.count = 0;
|
||||
double r3 = cb_stream(1, 9);
|
||||
printf("stream(1,9) = %g\n", r3);
|
||||
printf("log_a after reroute: %d chunk(s)\n", log_a.count);
|
||||
dump_chunks(&log_b);
|
||||
|
||||
/* Buffer results and callback channels coexist: collect between calls. */
|
||||
cb_collect();
|
||||
|
||||
/* NULL clears the channel; the next call through it is the SC4025
|
||||
* structured trap — exactly once, naming the entry the host called. */
|
||||
printf("reg emitChunk clear: %d\n", (int)cb_set_callback("emitChunk", NULL, NULL));
|
||||
if (setjmp(trap_jmp) == 0) {
|
||||
cb_stream(1, 1);
|
||||
printf("UNREACHABLE\n");
|
||||
} else {
|
||||
printf("survived, sink_calls=%d\n", sink_calls);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/* CB6 builtin-name collision probe: neither channel is registered. If the
|
||||
* profile incorrectly claims lib.d.ts's isNaN binding, this call traps;
|
||||
* ordinary builtin lowering returns false/true and reaches both prints. */
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
||||
extern void cbb_init(void);
|
||||
extern int32_t cbb_set_callback(const char *name, void (*fn)(void), void *ctx);
|
||||
extern uint8_t cbb_check_builtin(double x);
|
||||
extern double cbb_call_keyword(double x);
|
||||
|
||||
typedef void (*cb_fn)(void);
|
||||
|
||||
static double on_int(void *ctx, double x) {
|
||||
return x + *(const double *)ctx;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
static const double add = 0.5;
|
||||
if (cbb_set_callback("int", (cb_fn)on_int, (void *)&add) != 0) return 2;
|
||||
cbb_init();
|
||||
printf("finite: %u\n", (unsigned)cbb_check_builtin(123));
|
||||
printf("nan: %u\n", (unsigned)cbb_check_builtin(0.0 / 0.0));
|
||||
printf("keyword: %.1f\n", cbb_call_keyword(7));
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/* Project-.d.ts callback probe: registration must route the call through the
|
||||
* profile channel. If the declaration file is incorrectly treated like
|
||||
* lib.d.ts/@types, compilation fails before this probe can link. */
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
||||
extern void cb_init(void);
|
||||
extern int32_t cb_set_callback(const char *name, void (*fn)(void), void *ctx);
|
||||
extern double cb_stream(double n, double base);
|
||||
|
||||
typedef void (*cb_fn)(void);
|
||||
|
||||
static void on_chunk(void *ctx, const uint8_t *p, size_t len, uint32_t seq) {
|
||||
(void)ctx;
|
||||
printf("seq=%u len=%zu bytes=%.*s\n", seq, len, (int)len, (const char *)p);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
if (cb_set_callback("emitChunk", (cb_fn)on_chunk, NULL) != 0) return 2;
|
||||
cb_init();
|
||||
printf("result=%g\n", cb_stream(2, 7));
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/* CB6 (callback-free posture): the same signature-only ambient call under
|
||||
* a profile with NO callbacks section keeps Node's ReferenceError
|
||||
* semantics — the entry throws, and the escaped exception reaches the
|
||||
* sink as the SC4013 structured message naming the entry. */
|
||||
#include <setjmp.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
extern void cb_init(void);
|
||||
extern void cb_set_panic_sink(void (*fn)(void *, const uint8_t *, size_t, uint64_t), void *ctx);
|
||||
extern double cb_stream(double n, double base);
|
||||
|
||||
static jmp_buf trap_jmp;
|
||||
static int sink_calls = 0;
|
||||
|
||||
static void sink(void *ctx, const uint8_t *msg, size_t len, uint64_t addr) {
|
||||
(void)ctx; (void)addr;
|
||||
sink_calls++;
|
||||
printf("sink[%d]:\n", sink_calls);
|
||||
int has_referror = 0;
|
||||
if (len > 0 && msg[0] == 0x01) {
|
||||
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 == 0) {
|
||||
for (const uint8_t *q = p; q + 14 <= stop; q++) {
|
||||
if (memcmp(q, "ReferenceError", 14) == 0) { has_referror = 1; break; }
|
||||
}
|
||||
}
|
||||
if (field == 1) printf("code=[%.*s]\n", (int)(stop - p), (const char *)p);
|
||||
if (field == 2) printf("symbol=[%.*s]\n", (int)(stop - p), (const char *)p);
|
||||
field++;
|
||||
if (sep == NULL) break;
|
||||
p = sep + 1;
|
||||
}
|
||||
}
|
||||
printf("text has ReferenceError: %d\n", has_referror);
|
||||
longjmp(trap_jmp, 1);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
cb_set_panic_sink(sink, NULL);
|
||||
cb_init();
|
||||
if (setjmp(trap_jmp) == 0) {
|
||||
cb_stream(1, 1);
|
||||
printf("UNREACHABLE\n");
|
||||
} else {
|
||||
printf("survived, sink_calls=%d\n", sink_calls);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/* Composition probe: ONE runtime-localized (abi.localize_runtime),
|
||||
* thread-instanced (abi.instance_per_thread) archive with host-callback
|
||||
* channels, driven from two embedder threads — the documented contract:
|
||||
* the calling thread IS the instance selector, and channel registrations
|
||||
* are per-instance state exactly like the sink's. Checks:
|
||||
* - each thread registers its OWN emitChunk context and sink, inits its
|
||||
* own instance, and streams concurrently (a barrier forces overlap);
|
||||
* chunks route to the registering thread's log with exact contents,
|
||||
* order, and thread identity — nothing crosses instances;
|
||||
* - thread A then reaches the channel it never registered: the SC4025
|
||||
* structured trap arrives at A's sink exactly once (A's ctx, symbol
|
||||
* cbt_poke_orphan) and poisons only A's instance;
|
||||
* - thread B keeps streaming through and after A's trap window with
|
||||
* exact values; B's sink never fires.
|
||||
* Workers record into per-thread state; main prints after both joins, so
|
||||
* stdout is deterministic under any interleaving. */
|
||||
#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);
|
||||
extern double cbt_ask_host(double x);
|
||||
extern double cbt_poke_orphan(void);
|
||||
|
||||
typedef void (*cb_fn)(void);
|
||||
|
||||
/* Stages: 0 start; 1 both instances initialized (concurrent streams run);
|
||||
* 2 both first streams done (A traps); 3 A's trap delivered (B streams
|
||||
* once more and exits). */
|
||||
static pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER;
|
||||
static pthread_cond_t cv = PTHREAD_COND_INITIALIZER;
|
||||
static int stage = 0;
|
||||
static int inited = 0, streamed = 0;
|
||||
|
||||
static void stage_set(int s) {
|
||||
pthread_mutex_lock(&mu);
|
||||
if (stage < s) stage = s;
|
||||
pthread_cond_broadcast(&cv);
|
||||
pthread_mutex_unlock(&mu);
|
||||
}
|
||||
|
||||
static void stage_wait(int s) {
|
||||
pthread_mutex_lock(&mu);
|
||||
while (stage < s) pthread_cond_wait(&cv, &mu);
|
||||
pthread_mutex_unlock(&mu);
|
||||
}
|
||||
|
||||
static void arrive(int *counter, int total, int next_stage) {
|
||||
pthread_mutex_lock(&mu);
|
||||
(*counter)++;
|
||||
if (*counter == total && stage < next_stage) {
|
||||
stage = next_stage;
|
||||
pthread_cond_broadcast(&cv);
|
||||
}
|
||||
pthread_mutex_unlock(&mu);
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
char bytes[8];
|
||||
size_t len;
|
||||
uint32_t seq;
|
||||
} ChunkRec;
|
||||
|
||||
typedef struct {
|
||||
pthread_t self;
|
||||
ChunkRec chunks[16];
|
||||
int count;
|
||||
int thread_ok;
|
||||
int sink_calls;
|
||||
int sink_ctx_ok;
|
||||
char sink_code[16];
|
||||
char sink_symbol[64];
|
||||
double r1, r2;
|
||||
jmp_buf trap_jmp;
|
||||
} Worker;
|
||||
|
||||
static void on_chunk(void *ctx, const uint8_t *p, size_t len, uint32_t seq) {
|
||||
Worker *w = (Worker *)ctx;
|
||||
ChunkRec *rec = &w->chunks[w->count];
|
||||
memcpy(rec->bytes, p, len < sizeof rec->bytes ? len : sizeof rec->bytes);
|
||||
rec->len = len;
|
||||
rec->seq = seq;
|
||||
w->count++;
|
||||
if (!pthread_equal(pthread_self(), w->self)) w->thread_ok = 0;
|
||||
}
|
||||
|
||||
static int32_t on_progress(void *ctx, double done, double total) {
|
||||
(void)ctx;
|
||||
return (int32_t)(total - done);
|
||||
}
|
||||
|
||||
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 uint32_t on_mix(void *ctx, uint8_t a, int32_t b) {
|
||||
(void)ctx;
|
||||
return (uint32_t)a + (uint32_t)(-b);
|
||||
}
|
||||
|
||||
static Worker workers[2];
|
||||
|
||||
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;
|
||||
/* Structured parse: fields 1 (code) and 2 (symbol). */
|
||||
if (len > 0 && msg[0] == 0x01) {
|
||||
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 == 1) snprintf(w->sink_code, sizeof w->sink_code, "%.*s", (int)(stop - p), (const char *)p);
|
||||
if (field == 2) snprintf(w->sink_symbol, sizeof w->sink_symbol, "%.*s", (int)(stop - p), (const char *)p);
|
||||
field++;
|
||||
if (sep == NULL) break;
|
||||
p = sep + 1;
|
||||
}
|
||||
}
|
||||
longjmp(w->trap_jmp, 1);
|
||||
}
|
||||
|
||||
static void *worker_a(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("progress", (cb_fn)on_progress, NULL);
|
||||
cbt_set_callback("note", (cb_fn)on_note, NULL);
|
||||
cbt_set_callback("mix", (cb_fn)on_mix, NULL);
|
||||
cbt_init();
|
||||
arrive(&inited, 2, 1);
|
||||
stage_wait(1);
|
||||
w->r1 = cbt_stream(3, 2); /* concurrent with B's stream */
|
||||
arrive(&streamed, 2, 2);
|
||||
stage_wait(2);
|
||||
if (setjmp(w->trap_jmp) == 0) {
|
||||
cbt_poke_orphan(); /* A never registered 'orphan': SC4025 to A's sink */
|
||||
printf("UNREACHABLE A\n");
|
||||
}
|
||||
stage_set(3);
|
||||
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("progress", (cb_fn)on_progress, NULL);
|
||||
cbt_set_callback("note", (cb_fn)on_note, NULL);
|
||||
cbt_set_callback("mix", (cb_fn)on_mix, NULL);
|
||||
cbt_init();
|
||||
arrive(&inited, 2, 1);
|
||||
stage_wait(1);
|
||||
w->r1 = cbt_stream(3, 5); /* concurrent with A's stream */
|
||||
arrive(&streamed, 2, 2);
|
||||
stage_wait(3); /* A's instance is poisoned now; B's keeps answering */
|
||||
w->r2 = cbt_stream(1, 4);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void dump(const char *tag, const Worker *w) {
|
||||
printf("%s: r1=%g r2=%g chunks=%d thread_ok=%d sink_calls=%d\n",
|
||||
tag, w->r1, w->r2, w->count, w->thread_ok, w->sink_calls);
|
||||
for (int i = 0; i < w->count; i++) {
|
||||
printf(" seq=%u len=%zu bytes=%.*s\n", w->chunks[i].seq, w->chunks[i].len,
|
||||
(int)w->chunks[i].len, w->chunks[i].bytes);
|
||||
}
|
||||
if (w->sink_calls > 0) {
|
||||
printf(" sink code=%s symbol=%s ctx_ok=%d\n", w->sink_code, w->sink_symbol, w->sink_ctx_ok);
|
||||
}
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
pthread_t ta, tb;
|
||||
pthread_create(&ta, NULL, worker_a, &workers[0]);
|
||||
pthread_create(&tb, NULL, worker_b, &workers[1]);
|
||||
pthread_join(ta, NULL);
|
||||
pthread_join(tb, NULL);
|
||||
dump("A", &workers[0]);
|
||||
dump("B", &workers[1]);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/* CB6 (capacity posture): no compiled code references any channel, but the
|
||||
* registration symbol still dispatches every declared name — an embedder
|
||||
* can wire callbacks ahead of the program revision that uses them. */
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
||||
extern void cb_init(void);
|
||||
extern int32_t cb_set_callback(const char *name, void (*fn)(void), void *ctx);
|
||||
extern double cb_stream(double n, double base);
|
||||
|
||||
static void on_orphan(void *ctx, double x) { (void)ctx; (void)x; }
|
||||
|
||||
int main(void) {
|
||||
printf("reg orphan: %d\n", (int)cb_set_callback("orphan", (void (*)(void))on_orphan, NULL));
|
||||
printf("reg emitChunk: %d\n", (int)cb_set_callback("emitChunk", (void (*)(void))on_orphan, NULL));
|
||||
cb_init();
|
||||
printf("stream(3,7) = %g\n", cb_stream(3, 7));
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"profile_format": 1,
|
||||
"name": "conformance-callbacks",
|
||||
"entry": "lib.ts",
|
||||
"emission": "llvm",
|
||||
"abi": {
|
||||
"prefix": "cb_",
|
||||
"init_symbol": "cb_init",
|
||||
"sink_register_symbol": "cb_set_panic_sink",
|
||||
"collect_symbol": "cb_collect",
|
||||
"result_reset_symbol": null,
|
||||
"callback_register_symbol": "cb_set_callback"
|
||||
},
|
||||
"callbacks": [
|
||||
{ "name": "emitChunk", "params": ["bytes", "u32"], "returns": "void" },
|
||||
{ "name": "progress", "params": ["f64", "f64"], "returns": "i32" },
|
||||
{ "name": "note", "params": ["string", "bool"], "returns": "void" },
|
||||
{ "name": "mix", "params": ["u8", "i32"], "returns": "u32" },
|
||||
{ "name": "orphan", "params": ["f64"], "returns": "void" }
|
||||
],
|
||||
"exports": [
|
||||
{ "export": "stream", "symbol": "cb_stream", "params": ["f64", "f64"], "returns": "f64" },
|
||||
{ "export": "askHost", "symbol": "cb_ask_host", "params": ["f64"], "returns": "f64" },
|
||||
{ "export": "pokeOrphan", "symbol": "cb_poke_orphan", "params": [], "returns": "f64" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"profile_format": 1,
|
||||
"name": "conformance-callback-builtins",
|
||||
"entry": "lib_builtins.ts",
|
||||
"emission": "llvm",
|
||||
"abi": {
|
||||
"prefix": "cbb_",
|
||||
"init_symbol": "cbb_init",
|
||||
"sink_register_symbol": "cbb_set_panic_sink",
|
||||
"collect_symbol": null,
|
||||
"result_reset_symbol": null,
|
||||
"callback_register_symbol": "cbb_set_callback"
|
||||
},
|
||||
"callbacks": [
|
||||
{ "name": "isNaN", "params": ["f64"], "returns": "bool" },
|
||||
{ "name": "parseInt", "params": ["string", "u32"], "returns": "f64" },
|
||||
{ "name": "int", "params": ["f64"], "returns": "f64" }
|
||||
],
|
||||
"exports": [
|
||||
{ "export": "checkBuiltin", "symbol": "cbb_check_builtin", "params": ["f64"], "returns": "bool" },
|
||||
{ "export": "callKeyword", "symbol": "cbb_call_keyword", "params": ["f64"], "returns": "f64" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"profile_format": 1,
|
||||
"name": "conformance-callback-narrow-returns",
|
||||
"entry": "lib_narrow_returns.ts",
|
||||
"emission": "llvm",
|
||||
"abi": {
|
||||
"prefix": "cbn_",
|
||||
"init_symbol": "cbn_init",
|
||||
"sink_register_symbol": "cbn_set_panic_sink",
|
||||
"collect_symbol": null,
|
||||
"result_reset_symbol": null,
|
||||
"callback_register_symbol": "cbn_set_callback"
|
||||
},
|
||||
"callbacks": [
|
||||
{ "name": "answerBool", "params": [], "returns": "bool" },
|
||||
{ "name": "answerNumber", "params": [], "returns": "i32" }
|
||||
],
|
||||
"exports": [
|
||||
{ "export": "run", "symbol": "cbn_run", "params": [], "returns": "f64" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"profile_format": 1,
|
||||
"name": "conformance-callbacks-threads",
|
||||
"entry": "lib.ts",
|
||||
"emission": "llvm",
|
||||
"abi": {
|
||||
"prefix": "cbt_",
|
||||
"init_symbol": "cbt_init",
|
||||
"sink_register_symbol": "cbt_set_panic_sink",
|
||||
"collect_symbol": null,
|
||||
"result_reset_symbol": null,
|
||||
"localize_runtime": true,
|
||||
"instance_per_thread": true,
|
||||
"callback_register_symbol": "cbt_set_callback"
|
||||
},
|
||||
"callbacks": [
|
||||
{ "name": "emitChunk", "params": ["bytes", "u32"], "returns": "void" },
|
||||
{ "name": "progress", "params": ["f64", "f64"], "returns": "i32" },
|
||||
{ "name": "note", "params": ["string", "bool"], "returns": "void" },
|
||||
{ "name": "mix", "params": ["u8", "i32"], "returns": "u32" },
|
||||
{ "name": "orphan", "params": ["f64"], "returns": "void" }
|
||||
],
|
||||
"exports": [
|
||||
{ "export": "stream", "symbol": "cbt_stream", "params": ["f64", "f64"], "returns": "f64" },
|
||||
{ "export": "askHost", "symbol": "cbt_ask_host", "params": ["f64"], "returns": "f64" },
|
||||
{ "export": "pokeOrphan", "symbol": "cbt_poke_orphan", "params": [], "returns": "f64" }
|
||||
]
|
||||
}
|
||||
@@ -8,8 +8,12 @@
|
||||
"init_symbol": "kc_init",
|
||||
"sink_register_symbol": "kc_set_panic_sink",
|
||||
"collect_symbol": null,
|
||||
"result_reset_symbol": null
|
||||
"result_reset_symbol": null,
|
||||
"callback_register_symbol": "kc_set_callback"
|
||||
},
|
||||
"callbacks": [
|
||||
{ "name": "contractNotice", "params": ["string"], "returns": "void" }
|
||||
],
|
||||
"exports": [
|
||||
{ "export": "boot", "symbol": "kc_boot", "params": [], "returns": "void" },
|
||||
{ "export": "send", "symbol": "kc_send", "params": ["f64", "f64"], "returns": "void" },
|
||||
|
||||
Reference in New Issue
Block a user