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:
Chris Tate
2026-08-13 21:44:55 -05:00
committed by GitHub
parent a86888873d
commit 81b70b0ee6
41 changed files with 2222 additions and 42 deletions
+4
View File
@@ -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
+1 -1
View File
@@ -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();
+36 -1
View File
@@ -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]
: []),
+30
View File
@@ -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
+21
View File
@@ -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 ?? []) {
+194 -3
View File
@@ -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,
+6 -3
View File
@@ -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));
+30
View File
@@ -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) {
+38 -4
View File
@@ -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;
+11
View File
@@ -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 });
+461
View File
@@ -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);
});
});
+1 -1
View File
@@ -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());
+104
View File
@@ -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",
));
});
+4
View File
@@ -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;
+40
View File
@@ -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");
+219
View File
@@ -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;
}
+26
View File
@@ -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" }
]
}
+5 -1
View File
@@ -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" },