feat: support multi-instance library mode via runtime symbol localization (#136)

* feat: support multi-instance library mode via runtime symbol localization

- Add the abi.localize_runtime profile field: the archive build combines the
  program object with exactly the runtime/vendor members it reaches and
  demotes every external definition except the profile-declared symbols to a
  local symbol (darwin: one ld -r pass with -exported_symbols_list; linux:
  ld -r then objcopy --keep-global-symbols)
- N archives built under pairwise-distinct prefixes now link into one process
  with no symbol collisions and no shared mutable runtime state: each
  instance owns a private copy of the allocator, collector, result arena, and
  panic sink, so sinks register per instance and a trap poisons only the
  instance it fired in
- Document the embedder contract beside the profile spec: one thread per
  instance (an instance is never entered from two threads), and values cross
  instances only through the embedder's own byte/record marshalling
- Refuse cross-target localized builds with SC3002 (the step runs the host
  toolchain's ld/objcopy over host-format objects); absent or false keeps the
  classic single-archive artifact byte-for-byte
- Add the two-instance acceptance probe (two archives, two embedder threads,
  independent collects, a trap delivered to its own sink exactly once while
  the other instance keeps answering) plus symbol-exactness, profile-shape,
  and target-posture suites, and schedule the artifact contracts on the gate
  host

* fix: tighten runtime localization validation

* fix: harden localized library publication
This commit is contained in:
Chris Tate
2026-08-12 02:19:37 -05:00
committed by GitHub
parent e98b2d461d
commit da2ad7e002
11 changed files with 848 additions and 10 deletions
+114 -5
View File
@@ -1121,6 +1121,17 @@ export interface LibArchiveOptions {
* matching compileC's arbitrary-input safety boundary. */
cacheIdentity?: string;
sanitize?: boolean;
/** Multi-instance library mode (the profile's abi.localize_runtime): the
* external symbols to KEEP global — every other scriptc external
* definition in the archive (the runtime's internals, the program TU's
* mangled functions and globals, vendor objects) is demoted to a local
* symbol. Toolchain sanitizer ABI remains external as required,
* so N archives built under pairwise-distinct prefixes link into one
* process with no symbol collisions and no shared mutable runtime state.
* Undefined references (libc/libm, plus sanitizer ABI in instrumented
* builds) keep their global binding. Omitted = the classic archive,
* byte-for-byte. Host-native builds only. */
localizeSymbols?: readonly string[];
/** IR-detected link gates (the compileC precedent, refusal-narrowed). */
regex?: boolean;
assert?: boolean;
@@ -1241,7 +1252,12 @@ export async function compileLibArchive(opts: LibArchiveOptions): Promise<void>
driver,
`${toolchainEnv}\0${implicitToolchain ?? "<uncached>"}`,
);
// Runtime-localized archives skip the completed-archive tier: their bytes
// additionally depend on the host's ld/objcopy identities, which the
// archive key does not fingerprint. The runtime-object tier still serves
// them (localization consumes the same per-flavor objects).
const cacheCompleteArchive =
opts.localizeSymbols === undefined &&
root !== null && await archiverSupportsPersistentCache(arArgv, driver);
let cachedArchive: string | null = null;
let compilerVersion = "";
@@ -1428,18 +1444,39 @@ export async function compileLibArchive(opts: LibArchiveOptions): Promise<void>
}
}
const objects = [programObject, ...runtimeObjects, ...lreObjects, ...zlibObjects];
// A cacheable build owns a private archive from `ar` through publication.
// The caller-visible output can be shared by another invocation without
// letting that invocation's bytes poison this key.
// Multi-instance library mode: the archive's one member becomes the
// combined, symbol-localized object (cached vendor/runtime objects
// are read-only inputs here — the combine step never mutates them).
const archiveMembers =
opts.localizeSymbols === undefined
? objects
: [
await localizeLibraryObjects(
driver,
arArgv,
buildDir,
programObject,
[...runtimeObjects, ...lreObjects, ...zlibObjects],
opts.localizeSymbols,
stem,
),
];
// A cacheable or runtime-localized build owns a private archive from
// `ar` through publication. Localized archives deliberately bypass the
// completed-artifact cache, but still need atomic installation so two
// invocations sharing a caller-visible output cannot race `rm`/`ar` on
// that path.
const archiveOutput =
cachedArchive === null ? opts.outPath : join(buildDir, "artifact.lib.a");
cachedArchive === null && opts.localizeSymbols === undefined
? opts.outPath
: join(buildDir, "artifact.lib.a");
await rm(archiveOutput, { force: true }); // `ar r` would append into a stale archive
await mkdir(dirname(archiveOutput), { recursive: true });
await execFileAsync(arArgv[0] ?? "ar", [
...arArgv.slice(1),
"rcs",
archiveOutput,
...objects,
...archiveMembers,
]);
if (archiveOutput !== opts.outPath) await installArtifact(archiveOutput, opts.outPath);
let runtimeStillMatchesKey = false;
@@ -1502,6 +1539,78 @@ export async function compileLibArchive(opts: LibArchiveOptions): Promise<void>
if (root !== null) await pruneCache(root).catch(() => undefined);
}
/* Multi-instance library mode's localization step: combine the program
* object with exactly the runtime/vendor members it reaches into ONE
* relocatable object, then demote every scriptc external definition except
* the profile-declared symbols to a local symbol. The internals are not
* renamed apart — they stop being visible to the embedder's linker at all,
* so a second archive built under a different prefix brings its own private
* copy of the whole runtime (allocator, collector, arena, panic sink) into
* the same process. Undefined references (libc/libm, plus sanitizer ABI in
* instrumented builds) keep their global binding: the C library and
* sanitizer are the embedder's, shared by design.
*
* Member selection matters: a classic archive's unused members (and their
* undefined references to units library mode excludes, like the
* fs-promises unit's fiber symbols) never reach an embedder's link. A
* blind merge of every object would carry those references into the one
* combined member. Staging the support objects into an intermediate
* archive keeps the linker's own member semantics: `ld -r` pulls only the
* members the program object transitively needs.
*
* darwin — one ld64 invocation: -r merges program + needed members,
* -exported_symbols_list demotes every unlisted global to
* private extern, and -r without -keep_private_externs writes
* private externs out as non-external symbols. Apple ASan's
* image-registration COMMON remains shared so the final Mach-O
* image registers its globals once.
* linux — ld -r merges, then binutils objcopy --keep-global-symbols
* localizes every other DEFINED global (objcopy leaves
* undefined symbols global by its own rule).
*
* Host-native only: compileLibrary refuses localization for cross targets
* before emission (this step runs the host's ld/objcopy over host-format
* objects). */
async function localizeLibraryObjects(
driver: CcDriver,
arArgv: readonly string[],
buildDir: string,
programObject: string,
supportObjects: readonly string[],
keepSymbols: readonly string[],
stem: string,
): Promise<string> {
const platform = targetPlatform(driver);
const combined = join(buildDir, `${stem}.localized.o`);
const staging = join(buildDir, `${stem}.localize-staging.a`);
const keepFile = join(buildDir, "localize-keep.syms");
const run = async (argv: readonly string[]): Promise<void> => {
try {
await execFileAsync(argv[0]!, [...argv.slice(1)]);
} catch (err) {
const stderr = (err as { stderr?: string }).stderr ?? String(err);
throw new Error(
`${argv[0]} failed while localizing the library archive's runtime symbols (abi.localize_runtime).\n` +
`Runtime symbol localization needs the host toolchain's ${platform === "darwin" ? "ld" : "ld and objcopy"} beside the C compiler.\n\n${stderr}`,
);
}
};
await run([arArgv[0] ?? "ar", ...arArgv.slice(1), "rcs", staging, ...supportObjects]);
if (platform === "darwin") {
await writeFile(keepFile, keepSymbols.map((s) => `_${s}\n`).join(""));
await run(["ld", "-r", programObject, staging, "-o", combined, "-exported_symbols_list", keepFile]);
} else if (platform === "linux") {
await writeFile(keepFile, keepSymbols.map((s) => `${s}\n`).join(""));
await run(["ld", "-r", programObject, staging, "-o", combined]);
await run(["objcopy", `--keep-global-symbols=${keepFile}`, combined]);
} else {
throw new Error(
`runtime symbol localization (abi.localize_runtime) supports darwin and linux host builds; this build targets ${platform}`,
);
}
return combined;
}
/* -------------------------- persistent build cache ---------------------------
* Content-addressed caches that let repeat builds of unchanged programs skip
* payload code generation/linking — the test lanes' dominant cost. Executable
+39
View File
@@ -1454,6 +1454,29 @@ export async function compileLibrary(opts: CompileLibraryOptions): Promise<Compi
const entryPath = profile.entry;
const buildPlatform = buildTargetPlatform();
// Multi-instance library mode (abi.localize_runtime) is native on the
// two hosts whose localization toolchains are implemented. Cross-target
// builds and unsupported native hosts refuse before frontend/backend work
// rather than reaching the ld/objcopy step and throwing an unstructured
// toolchain error. WASI retains the general library-mode refusal below.
if (profile.localizeRuntime && buildPlatform !== "wasi") {
const driver = resolveCc();
const platform = targetPlatform(driver);
if (driver.target !== null || (platform !== "darwin" && platform !== "linux")) {
return {
ok: false,
diagnostics: decorateLibraryRefusals([
targetRefusalDiag(
driver.target ?? platform,
"runtime-localized (multi-instance) library archives",
{ file: entryPath, start: 0, end: 0 },
),
], profile),
sourceTexts: new Map(),
};
}
}
// Bare npm specifiers in a library graph take the STATIC-OR-REFUSE
// posture: "lib" runs the same auto-detection and eligibility bar as
// the executable lane's --npm-static (own .d.ts, unminified shipped JS,
@@ -1682,11 +1705,27 @@ export async function compileLibrary(opts: CompileLibraryOptions): Promise<Compi
}
const archivePath = opts.outPath ?? join(opts.outDir, `${stem}.lib.a`);
// Multi-instance library mode: the profile-declared external surface —
// the mode-provided entries, the identity getters, and the export map —
// is exactly the set the localization step keeps global.
const localizeSymbols = profile.localizeRuntime
? [
profile.initSymbol,
profile.sinkRegisterSymbol,
...(profile.collectSymbol !== null ? [profile.collectSymbol] : []),
...(profile.resultResetSymbol !== null ? [profile.resultResetSymbol] : []),
...(profile.sidecar !== null
? [profile.sidecar.buildIdSymbol, profile.sidecar.abiVersionSymbol]
: []),
...profile.exports.map((e) => e.symbol),
]
: undefined;
await compileLibArchive({
cPath,
outPath: archivePath,
cacheIdentity: "scriptc-generated-library-v1",
sanitize: opts.sanitize ?? false,
...(localizeSymbols !== undefined ? { localizeSymbols } : {}),
regex: moduleUsesRegex(mod),
assert: moduleUsesAssert(mod),
inspect: moduleUsesInspect(mod),
+45 -2
View File
@@ -19,7 +19,11 @@
* "init_symbol": "<prefix>_init",
* "sink_register_symbol": "<prefix>_set_panic_sink",
* "collect_symbol": "<prefix>_collect" | null, // session ruling 2
* "result_reset_symbol": "<prefix>_reset" | null // §4.3 two postures
* "result_reset_symbol": "<prefix>_reset" | null, // §4.3 two postures
* "localize_runtime": false // multi-instance library
* // mode (see below); absent
* // = false, the classic
* // single-archive artifact
* },
* "exports": [ { "export": "update", "symbol": "<prefix>_update",
* "params": ["f64", "string"], "returns": "bytes" } ],
@@ -78,6 +82,32 @@
* getters are pure data returns exempt from the poisoned guard and every
* runtime touch (ratified): callable before init and after a trap.
*
* Multi-instance library mode (`abi.localize_runtime: true`): the archive's
* runtime internals — allocator, cycle collector, result arena, panic-sink
* registration, every other piece of runtime state — are demoted to LOCAL
* symbols behind the profile's prefix, so an ordinary archive's only
* external definitions are the profile-declared symbols and its only
* external references are libc/libm. Sanitized artifacts additionally carry
* their sanitizer ABI; Darwin ASan's image-registration COMMON deliberately
* remains shared so the final Mach-O image registers its globals exactly
* once. N such archives, built under pairwise-distinct prefixes, then link
* into ONE process with no scriptc-runtime symbol collisions or shared
* mutable state: each instance owns a private copy of the whole runtime,
* panic sinks register per instance, and a trap poisons only the instance it
* fired in. The embedder contract that makes this sound:
*
* - Each instance keeps the single-threaded execution model. The embedder
* confines each instance to one thread and never enters one instance
* from two threads; different instances may run on different threads
* concurrently.
* - No value crosses instances directly: results are instance-owned
* memory with instance-tied lifetimes, so data moves between instances
* only through the embedder's own byte/record marshalling.
*
* Off by default: an absent or false `localize_runtime` produces the
* classic artifact, byte-for-byte. Localization is host-native (darwin and
* linux); cross-target archive builds refuse it with SC3002.
*
* 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:
@@ -196,6 +226,11 @@ export interface LibraryProfile {
/** §4.3: declared → results accumulate until the host calls it; null →
* every entry prologue resets the result arena. */
resultResetSymbol: string | null;
/** Multi-instance library mode: true localizes every runtime-internal
* symbol so N archives with distinct prefixes link into one process (see
* the header contract). False (the default) produces the classic
* single-archive artifact unchanged. */
localizeRuntime: boolean;
exports: LibraryExportEntry[];
/** The ask-2 contract-sidecar section; null = the profile declares no
* sidecar and the invocation emits none. */
@@ -312,7 +347,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"]);
rejectUnknownKeys(abi, "abi", ["prefix", "init_symbol", "sink_register_symbol", "collect_symbol", "result_reset_symbol", "localize_runtime"]);
const a = abi as Record<string, unknown>;
const prefix = req<string>(a["prefix"], "abi.prefix", "string");
if (!C_IDENT.test(prefix)) {
@@ -322,6 +357,13 @@ export function loadLibraryProfile(
const sinkRegisterSymbol = symbolField(a["sink_register_symbol"], "abi.sink_register_symbol", prefix, false)!;
const collectSymbol = symbolField(a["collect_symbol"], "abi.collect_symbol", prefix, true);
const resultResetSymbol = symbolField(a["result_reset_symbol"], "abi.result_reset_symbol", prefix, true);
// Multi-instance library mode: strictly boolean when present (the field
// gates the artifact's whole link surface, so a truthy non-boolean is a
// refusal, never a coercion).
const localizeRuntime =
a["localize_runtime"] === undefined
? false
: req<boolean>(a["localize_runtime"], "abi.localize_runtime", "boolean");
const exportsRaw = p["exports"];
if (!Array.isArray(exportsRaw)) throw new ProfileError("'exports' must be an array");
@@ -578,6 +620,7 @@ export function loadLibraryProfile(
sinkRegisterSymbol,
collectSymbol,
resultResetSymbol,
localizeRuntime,
exports: entries,
sidecar,
profileBytes: bytes,
+14 -3
View File
@@ -10,6 +10,15 @@
* profile-agnostic and the program TU carries every profile-named symbol —
* one place for the conformance symbol audit to look.
*
* Multi-instance processes (the profile's abi.localize_runtime): the
* archive build demotes this file's externals — and every other runtime
* internal — to LOCAL symbols, so each linked archive carries its own
* private copy of all the state here: its own sink registration, poison
* flag, arena, and entry-symbol slot. Sinks therefore register per
* instance, and a trap poisons only the instance it fired in. Nothing in
* this file is thread-aware; the embedder contract is one thread per
* instance (an instance is never entered from two threads).
*
* State after a sink call: none is legal. The trap fired mid-operation with
* no unwinding — heap, arena, and collector state are unspecified; the library
* is POISONED. Every runtime-touching entry prologue aborts on the flag
@@ -62,9 +71,11 @@ void scr_library_set_sink(ScrLibSinkFn fn, void *ctx) {
/* The current-entry slot: every generated entry's prologue records its
* external symbol before dispatching into core code. A single static slot
* is sound — exactly one core is live per process (the one-live-core rule),
* entries never nest, and a trap can only fire while an entry is on the
* stack. NULL (never entered) renders as the empty symbol field. */
* is sound — exactly one core is live per copy of this state (the sole
* core in a classic process; each archive's own instance under
* abi.localize_runtime, where this slot is a per-instance local), entries
* never nest, and a trap can only fire while an entry is on the stack.
* NULL (never entered) renders as the empty symbol field. */
static const char *scr_library_entry_symbol = NULL;
/* Detected-trap classification: the runtime's trap sites self-classify
+3
View File
@@ -114,6 +114,7 @@ const remoteWorkspaceReset = workspaceResetCommand("/workspace");
const hostLaneContractFiles = [
"tests/harness/ffi.test.ts",
"tests/harness/island.test.ts",
"tests/harness/library-multi.test.ts",
"tests/harness/differential.test.ts",
"tests/harness/server.test.ts",
"tests/harness/dgram.test.ts",
@@ -123,6 +124,8 @@ const hostLaneContractPattern = [
"calls the manifest-bound archive across every v1 ABI class",
"a missing FFI symbol is an SC5004 diagnostic",
"deep island recursion on a fiber is a catchable RangeError",
"M1: external definitions equal the declared set exactly",
"M2: independent state and collects",
"net-echo",
"udp-loopback-pair",
"1564-fs-watch.ts",
+356
View File
@@ -0,0 +1,356 @@
/* Multi-instance library mode (the profile's abi.localize_runtime): N
* library archives built under pairwise-distinct symbol prefixes link into
* ONE process. The archive build combines the program, runtime, and vendor
* objects into one relocatable member and demotes every external
* definition except the profile-declared symbols to a local symbol, so the
* embedder's linker sees no runtime internals at all — no symbol
* collisions, and each instance owns a private copy of the whole runtime
* (allocator, collector, result arena, panic sink, poison flag).
*
* M1 symbols-exact nm over a localized archive: the external defined
* set equals the profile-declared set EXACTLY (plus
* Darwin ASan's one image-registration common in a
* sanitized build), and undefineds stay libc/libm-
* shaped apart from sanitizer ABI references
* M2 two-instance run the acceptance probe: two archives (ma_/mb_), two
* embedder threads (one per instance — the
* documented contract), independent init and
* collect, a deliberate trap in A delivered to A's
* sink exactly once (structured: SC4014, ma_boom,
* A's ctx) while B keeps answering through and after
* the trap window; B's sink never fires. Runs per
* emission and once with mixed emissions (one
* archive per backend).
* M3 profile shape abi.localize_runtime is strictly boolean (SC4001)
* M4 target posture localization is host-native: a cross-target build
* refuses SC3002 before emission
*/
import { execFileSync, spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { delimiter, join } from "node:path";
import { describe, expect, test } from "vitest";
import { compileLibrary, loadLibraryProfile } from "@scriptc/compiler";
const repoRoot = join(import.meta.dirname, "../..");
const fixtureDir = join(repoRoot, "tests/library-mode/multi");
const localizationTest = process.platform === "darwin" || process.platform === "linux" ? test : test.skip;
/* Suite-flavor segment (the library suites' convention): the plain and
* SCRIPTC_SAN=1 suites may run concurrently and must never share build
* dirs. */
const sanitize = process.env["SCRIPTC_SAN"] === "1";
const flavor = sanitize ? "san" : "plain";
const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests/library-multi", flavor);
type Emission = "llvm" | "c";
const EMISSIONS: Emission[] = ["llvm", "c"];
/** Build one instance's localized archive for one emission: the fixture
* profile is patched (emission flipped, entry made absolute) into the
* build dir, then compiled through the real compileLibrary pipeline.
* Memoized per (instance, emission) — the probe pairings reuse builds. */
const built = new Map<string, Promise<string>>();
function buildInstance(instance: "a" | "b", emission: Emission): Promise<string> {
const key = `${instance}-${emission}`;
let archive = built.get(key);
if (archive === undefined) {
archive = (async () => {
const outDir = join(cacheDir, key);
mkdirSync(outDir, { recursive: true });
const profile = JSON.parse(readFileSync(join(fixtureDir, `profile_${instance}.json`), "utf8")) as {
entry: string;
emission: string;
};
profile.emission = emission;
profile.entry = join(fixtureDir, profile.entry);
const profilePath = join(outDir, "profile.json");
writeFileSync(profilePath, JSON.stringify(profile, null, 2));
const result = await compileLibrary({ profilePath, outDir, sanitize });
if (!result.ok) {
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
}
expect(result.backend).toBe(emission);
return result.archivePath;
})();
built.set(key, archive);
}
return archive;
}
/** nm over an archive: [definedExternal, undefined] symbol sets, macOS/
* Linux leading-underscore normalized away. */
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 A_SYMBOLS = ["ma_boom", "ma_bump", "ma_calls_seen", "ma_collect", "ma_init", "ma_set_panic_sink"];
const B_SYMBOLS = ["mb_add", "mb_collect", "mb_init", "mb_set_panic_sink", "mb_sum_to"];
const PROBE_EXPECTED = `multi-a ready
multi-b ready
a: bump(1) x200 -> 201, calls_seen 200, trap fell through 0
a sink: calls=1 ctx_ok=1 fields=3 code=[SC4014] symbol=[ma_boom] text_printable=1 addr_nonzero=1
b: concurrent sums_ok=1 adds_ok=1 reached_200=1
b: post-trap answers ok=1
b sink: calls=0
`;
/* ── M1: the localized archive's exact link surface ─────────────────────── */
describe.each(EMISSIONS)("localized archive symbols, %s emission", (emission) => {
localizationTest("M1: external definitions equal the declared set exactly", async () => {
const [archiveA, archiveB] = await Promise.all([
buildInstance("a", emission),
buildInstance("b", emission),
]);
for (const [archive, declared] of [
[archiveA, A_SYMBOLS],
[archiveB, B_SYMBOLS],
] as const) {
const { defined, undef } = nmSymbols(archive);
// The WHOLE defined set — a classic archive additionally defines
// every runtime internal; a localized one defines nothing else.
// Darwin ASan's image-wide registration guard is the sole sanitized
// exception: keeping its COMMON shared makes the final Mach-O image
// register its ASan globals exactly once when N archives contribute
// module constructors.
const toolchainDefinitions =
sanitize && process.platform === "darwin"
? ["___asan_globals_registered"]
: [];
expect([...defined].sort()).toEqual([...declared, ...toolchainDefinitions].sort());
// Undefineds: no runtime-internal or prefix-carrying reference
// escapes; libc/libm (and sanitizer ABI) references keep their global
// binding.
expect([...undef].filter((s) => s.startsWith("scr_") || s.startsWith("ma_") || s.startsWith("mb_"))).toEqual([]);
// The ambient audit holds through the combine step: no
// process-disposition or threading surface, no atexit teardown.
for (const banned of ["sigaction", "signal", "pthread_create", "atexit", "setvbuf"]) {
expect(undef.has(banned), `undefined reference to ${banned}`).toBe(false);
}
}
});
});
/* ── M2: the two-instance, two-thread acceptance probe ──────────────────── */
function buildProbe(archiveA: string, archiveB: string, outDir: string, tag: string): string {
const bin = join(outDir, `probe-${tag}`);
mkdirSync(outDir, { recursive: true });
execFileSync("clang", [
"-std=c11",
"-pthread",
...(sanitize ? ["-fsanitize=address"] : []),
join(fixtureDir, "probe.c"),
archiveA,
archiveB,
"-lm",
"-o", bin,
]);
return bin;
}
const PAIRINGS: { tag: string; a: Emission; b: Emission }[] = [
{ tag: "llvm-llvm", a: "llvm", b: "llvm" },
{ tag: "c-c", a: "c", b: "c" },
// Two embedder builds need not share a backend: one archive per emission
// links and runs the same.
{ tag: "llvm-c", a: "llvm", b: "c" },
];
describe.each(PAIRINGS)("two instances, one process ($tag)", ({ tag, a, b }) => {
localizationTest("M2: independent state and collects; a trap reaches only its own sink, once", async () => {
const [archiveA, archiveB] = await Promise.all([buildInstance("a", a), buildInstance("b", b)]);
const probe = buildProbe(archiveA, archiveB, join(cacheDir, "probes"), tag);
const run = spawnSync(probe, { encoding: "utf8", timeout: 60_000 });
expect(run.signal).toBeNull();
expect(run.status).toBe(0);
expect(run.stdout).toBe(PROBE_EXPECTED);
});
});
/* ── M3: profile shape ───────────────────────────────────────────────────── */
test("M3: abi.localize_runtime is strictly boolean", () => {
const dir = join(cacheDir, "profile-shape");
mkdirSync(dir, { recursive: true });
const path = join(dir, "profile.json");
const base = {
profile_format: 1,
name: "shape",
entry: "lib.ts",
emission: "llvm",
abi: {
prefix: "sp_",
init_symbol: "sp_init",
sink_register_symbol: "sp_set_panic_sink",
collect_symbol: null,
result_reset_symbol: null,
localize_runtime: "yes",
},
exports: [],
};
for (const invalid of ["yes", null] as const) {
writeFileSync(path, JSON.stringify({
...base,
abi: { ...base.abi, localize_runtime: invalid },
}));
const refused = loadLibraryProfile(path);
expect(refused.ok).toBe(false);
if (!refused.ok) {
expect(refused.diagnostics[0]!.code).toBe("SC4001");
expect(refused.diagnostics[0]!.message).toContain("abi.localize_runtime");
}
}
// The boolean forms load, and absence means false.
for (const [value, expected] of [[true, true], [false, false], [undefined, false]] as const) {
const abi: Record<string, unknown> = { ...base.abi };
if (value === undefined) delete abi["localize_runtime"];
else abi["localize_runtime"] = value;
writeFileSync(path, JSON.stringify({ ...base, abi }));
const loaded = loadLibraryProfile(path);
expect(loaded.ok).toBe(true);
if (loaded.ok) expect(loaded.profile.localizeRuntime).toBe(expected);
}
});
/* ── M4: host-native posture ─────────────────────────────────────────────── */
test("M4: a cross-target build refuses runtime localization with SC3002", async () => {
const outDir = join(cacheDir, "cross-refusal");
mkdirSync(outDir, { recursive: true });
const profile = JSON.parse(readFileSync(join(fixtureDir, "profile_a.json"), "utf8")) as {
entry: string;
};
profile.entry = join(fixtureDir, profile.entry);
const profilePath = join(outDir, "profile.json");
writeFileSync(profilePath, JSON.stringify(profile, null, 2));
// compileLibrary reads SCRIPTC_CC/SCRIPTC_TARGET at call time; the
// refusal fires before any toolchain runs, so zig need not exist here.
const prevCc = process.env["SCRIPTC_CC"];
const prevTarget = process.env["SCRIPTC_TARGET"];
process.env["SCRIPTC_CC"] = "zigcc";
process.env["SCRIPTC_TARGET"] = "x86_64-linux-musl";
try {
const result = await compileLibrary({ profilePath, outDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.diagnostics[0]!.code).toBe("SC3002");
expect(result.diagnostics[0]!.message).toContain("x86_64-linux-musl");
expect(result.diagnostics[0]!.message).toContain("runtime-localized");
}
} finally {
if (prevCc === undefined) delete process.env["SCRIPTC_CC"];
else process.env["SCRIPTC_CC"] = prevCc;
if (prevTarget === undefined) delete process.env["SCRIPTC_TARGET"];
else process.env["SCRIPTC_TARGET"] = prevTarget;
}
});
test("M4: an unsupported native host refuses runtime localization with SC3002", async () => {
const outDir = join(cacheDir, "native-refusal");
mkdirSync(outDir, { recursive: true });
const profile = JSON.parse(readFileSync(join(fixtureDir, "profile_a.json"), "utf8")) as {
entry: string;
};
profile.entry = join(fixtureDir, profile.entry);
const profilePath = join(outDir, "profile.json");
writeFileSync(profilePath, JSON.stringify(profile, null, 2));
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform")!;
const prevCc = process.env["SCRIPTC_CC"];
const prevTarget = process.env["SCRIPTC_TARGET"];
delete process.env["SCRIPTC_CC"];
delete process.env["SCRIPTC_TARGET"];
Object.defineProperty(process, "platform", { ...platformDescriptor, value: "win32" });
try {
const result = await compileLibrary({ profilePath, outDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.diagnostics[0]!.code).toBe("SC3002");
expect(result.diagnostics[0]!.message).toContain("win32");
expect(result.diagnostics[0]!.message).toContain("runtime-localized");
}
} finally {
Object.defineProperty(process, "platform", platformDescriptor);
if (prevCc === undefined) delete process.env["SCRIPTC_CC"];
else process.env["SCRIPTC_CC"] = prevCc;
if (prevTarget === undefined) delete process.env["SCRIPTC_TARGET"];
else process.env["SCRIPTC_TARGET"] = prevTarget;
}
});
/* ── M5: caller-visible archive publication ─────────────────────────────── */
test.skipIf(process.platform !== "darwin" && process.platform !== "linux")(
"M5: localization archives privately before atomically installing the caller-visible output",
async () => {
const outDir = join(cacheDir, "atomic-publication");
const binDir = join(outDir, "bin");
const outPath = join(outDir, "localized.lib.a");
mkdirSync(binDir, { recursive: true });
const profile = JSON.parse(readFileSync(join(fixtureDir, "profile_a.json"), "utf8")) as {
entry: string;
};
profile.entry = join(fixtureDir, profile.entry);
const profilePath = join(outDir, "profile.json");
writeFileSync(profilePath, JSON.stringify(profile, null, 2));
const oldPath = process.env["PATH"];
const oldCc = process.env["SCRIPTC_CC"];
const oldTarget = process.env["SCRIPTC_TARGET"];
const oldRealAr = process.env["SCRIPTC_TEST_REAL_AR"];
const oldForbiddenOutput = process.env["SCRIPTC_TEST_FORBIDDEN_ARCHIVE_OUTPUT"];
const originalAr = (oldPath ?? "")
.split(delimiter)
.map((entry) => join(entry === "" ? process.cwd() : entry, "ar"))
.find((candidate) => existsSync(candidate));
expect(originalAr).toBeDefined();
const wrapper = join(binDir, "ar");
writeFileSync(
wrapper,
`#!/bin/sh
for arg in "$@"; do
if [ "$arg" = "$SCRIPTC_TEST_FORBIDDEN_ARCHIVE_OUTPUT" ]; then
echo "archiver received caller-visible output" >&2
exit 97
fi
done
exec "$SCRIPTC_TEST_REAL_AR" "$@"
`,
);
chmodSync(wrapper, 0o755);
process.env["PATH"] = `${binDir}${delimiter}${oldPath ?? ""}`;
process.env["SCRIPTC_TEST_REAL_AR"] = originalAr!;
process.env["SCRIPTC_TEST_FORBIDDEN_ARCHIVE_OUTPUT"] = outPath;
delete process.env["SCRIPTC_CC"];
delete process.env["SCRIPTC_TARGET"];
try {
const result = await compileLibrary({ profilePath, outDir, outPath, sanitize });
expect(result.ok, result.ok ? undefined : result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")).toBe(true);
expect(existsSync(outPath)).toBe(true);
} finally {
if (oldPath === undefined) delete process.env["PATH"];
else process.env["PATH"] = oldPath;
if (oldCc === undefined) delete process.env["SCRIPTC_CC"];
else process.env["SCRIPTC_CC"] = oldCc;
if (oldTarget === undefined) delete process.env["SCRIPTC_TARGET"];
else process.env["SCRIPTC_TARGET"] = oldTarget;
if (oldRealAr === undefined) delete process.env["SCRIPTC_TEST_REAL_AR"];
else process.env["SCRIPTC_TEST_REAL_AR"] = oldRealAr;
if (oldForbiddenOutput === undefined) delete process.env["SCRIPTC_TEST_FORBIDDEN_ARCHIVE_OUTPUT"];
else process.env["SCRIPTC_TEST_FORBIDDEN_ARCHIVE_OUTPUT"] = oldForbiddenOutput;
}
},
);
+21
View File
@@ -0,0 +1,21 @@
// Multi-instance fixture, instance A (prefix ma_): mutable module state the
// probe advances from A's dedicated thread, plus a deliberately trapping
// export (array index OOB — the runtime's own range trap) that must reach
// ONLY this instance's sink.
let calls = 0;
export function bump(x: number): number {
calls++;
return x + calls;
}
export function callsSeen(): number {
return calls;
}
export function boom(i: number): number {
const xs = [1, 2, 3];
return xs[i]!;
}
console.log("multi-a ready");
+21
View File
@@ -0,0 +1,21 @@
// Multi-instance fixture, instance B (prefix mb_): allocation-heavy work the
// probe drives concurrently with instance A from B's dedicated thread. Every
// call builds and folds a fresh array, so B's allocator and collector churn
// while A runs (and traps) — independence shows up as B's answers staying
// exact throughout.
let total = 0;
export function sumTo(n: number): number {
const xs: number[] = [];
for (let i = 1; i <= n; i++) xs.push(i);
let s = 0;
for (const x of xs) s += x;
return s;
}
export function add(x: number): number {
total += x;
return total;
}
console.log("multi-b ready");
+198
View File
@@ -0,0 +1,198 @@
/* Multi-instance acceptance probe: TWO runtime-localized library archives
* (prefixes ma_ / mb_) linked into ONE process, each instance driven from
* its own dedicated embedder thread — the documented contract: one thread
* per instance, an instance never entered from two threads. Checks:
* - both instances init, run their export families, and run their
* collect entries independently;
* - a deliberate range trap in instance A reaches ONLY A's registered
* sink, exactly once, as the structured message (SC4014, symbol
* ma_boom) with A's registration ctx and a nonzero fault address;
* - instance B keeps answering: its worker loops through allocation-
* heavy calls across A's trap window and still answers afterwards,
* with exact values throughout;
* - B's sink never fires.
* Workers record results into per-thread state; main prints everything
* 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 ma_init(void);
extern void ma_set_panic_sink(void (*fn)(void *, const uint8_t *, size_t, uint64_t), void *ctx);
extern void ma_collect(void);
extern double ma_bump(double x);
extern double ma_calls_seen(void);
extern double ma_boom(double i);
extern void mb_init(void);
extern void mb_set_panic_sink(void (*fn)(void *, const uint8_t *, size_t, uint64_t), void *ctx);
extern void mb_collect(void);
extern double mb_sum_to(double n);
extern double mb_add(double x);
/* Probe stages: 0 start; 1 A initialized; 2 B initialized (both workers'
* concurrent loops run); 3 both fixed loops finished (A traps, B keeps
* looping); 4 A's trap delivered (B answers 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 fixed_loops_done = 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 int stage_get(void) {
pthread_mutex_lock(&mu);
int s = stage;
pthread_mutex_unlock(&mu);
return s;
}
static void stage_wait(int s) {
pthread_mutex_lock(&mu);
while (stage < s) pthread_cond_wait(&cv, &mu);
pthread_mutex_unlock(&mu);
}
static void fixed_loop_done(void) {
pthread_mutex_lock(&mu);
fixed_loops_done++;
if (fixed_loops_done == 2 && stage < 3) {
stage = 3;
pthread_cond_broadcast(&cv);
}
pthread_mutex_unlock(&mu);
}
/* Per-instance sink records. Both instances register the SAME sink
* function with instance-specific ctx: delivery routing is observable as
* which record advances. */
typedef struct {
int calls;
int ctx_ok;
int fields;
int text_printable;
int addr_nonzero;
char code[32];
char symbol[64];
} SinkRec;
static SinkRec rec_a, rec_b;
static jmp_buf trap_jmp_a;
static void copy_field(char *dst, size_t cap, const uint8_t *p, size_t len) {
if (len >= cap) len = cap - 1;
memcpy(dst, p, len);
dst[len] = 0;
}
static void record_structured(SinkRec *r, const uint8_t *msg, size_t len) {
if (len == 0 || msg[0] != 0x01) return; /* fields stays 0: not structured */
r->text_printable = len > 1 && msg[1] >= 0x20;
const uint8_t *p = msg + 1, *end = msg + len;
int i = 0;
for (;;) {
const uint8_t *sep = memchr(p, 0x1f, (size_t)(end - p));
const uint8_t *stop = sep != NULL ? sep : end;
if (i == 1) copy_field(r->code, sizeof r->code, p, (size_t)(stop - p));
if (i == 2) copy_field(r->symbol, sizeof r->symbol, p, (size_t)(stop - p));
i++;
if (sep == NULL) break;
p = sep + 1;
}
r->fields = i;
}
static void sink(void *ctx, const uint8_t *msg, size_t len, uint64_t addr) {
SinkRec *r = ctx;
r->calls++;
r->addr_nonzero = addr != 0;
record_structured(r, msg, len);
if (r == &rec_a) {
r->ctx_ok = 1;
longjmp(trap_jmp_a, 1); /* the conforming survival pattern */
}
/* B's sink must never fire; returning would abort, and main would never
* print (the probe's failure shows as missing output). */
}
/* Instance A's dedicated thread: state-advancing calls, collects, then the
* deliberate trap. */
static double a_last_bump, a_calls;
static int a_trap_fell_through = 0;
static void *worker_a(void *arg) {
(void)arg;
ma_set_panic_sink(sink, &rec_a);
ma_init(); /* prints "multi-a ready" */
stage_set(1);
stage_wait(2);
double last = 0;
for (int i = 0; i < 200; i++) {
last = ma_bump(1);
if ((i + 1) % 50 == 0) ma_collect();
}
a_last_bump = last;
a_calls = ma_calls_seen();
fixed_loop_done();
stage_wait(3);
if (setjmp(trap_jmp_a) == 0) {
ma_boom(9); /* xs[9] of a length-3 array: the runtime's range trap */
a_trap_fell_through = 1;
}
stage_set(4);
return NULL;
}
/* Instance B's dedicated thread: allocation-heavy calls that keep running
* across A's trap window, then post-trap answers. */
static int b_sums_ok = 1, b_adds_ok = 1, b_reached_200 = 0, b_post_ok = 0;
static void *worker_b(void *arg) {
(void)arg;
mb_set_panic_sink(sink, &rec_b);
stage_wait(1);
mb_init(); /* prints "multi-b ready" */
stage_set(2);
double total = 0;
long iters = 0;
for (;;) {
if (mb_sum_to(100) != 5050.0) b_sums_ok = 0;
total = mb_add(1);
iters++;
if (total != (double)iters) b_adds_ok = 0;
if (iters % 25 == 0) mb_collect();
if (iters == 200) fixed_loop_done();
if (iters >= 200 && stage_get() >= 4) break;
}
b_reached_200 = iters >= 200;
/* A's trap has been delivered; B answers again. */
double post_sum = mb_sum_to(10);
double post_add = mb_add(5);
mb_collect();
b_post_ok = post_sum == 55.0 && post_add == total + 5.0;
return NULL;
}
int main(void) {
pthread_t ta, tb;
pthread_create(&ta, NULL, worker_a, NULL);
pthread_create(&tb, NULL, worker_b, NULL);
pthread_join(ta, NULL);
pthread_join(tb, NULL);
printf("a: bump(1) x200 -> %.0f, calls_seen %.0f, trap fell through %d\n",
a_last_bump, a_calls, a_trap_fell_through);
printf("a sink: calls=%d ctx_ok=%d fields=%d code=[%s] symbol=[%s] text_printable=%d addr_nonzero=%d\n",
rec_a.calls, rec_a.ctx_ok, rec_a.fields, rec_a.code, rec_a.symbol,
rec_a.text_printable, rec_a.addr_nonzero);
printf("b: concurrent sums_ok=%d adds_ok=%d reached_200=%d\n", b_sums_ok, b_adds_ok, b_reached_200);
printf("b: post-trap answers ok=%d\n", b_post_ok);
printf("b sink: calls=%d\n", rec_b.calls);
return 0;
}
+19
View File
@@ -0,0 +1,19 @@
{
"profile_format": 1,
"name": "conformance-multi-a",
"entry": "lib_a.ts",
"emission": "llvm",
"abi": {
"prefix": "ma_",
"init_symbol": "ma_init",
"sink_register_symbol": "ma_set_panic_sink",
"collect_symbol": "ma_collect",
"result_reset_symbol": null,
"localize_runtime": true
},
"exports": [
{ "export": "bump", "symbol": "ma_bump", "params": ["f64"], "returns": "f64" },
{ "export": "callsSeen", "symbol": "ma_calls_seen", "params": [], "returns": "f64" },
{ "export": "boom", "symbol": "ma_boom", "params": ["f64"], "returns": "f64" }
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"profile_format": 1,
"name": "conformance-multi-b",
"entry": "lib_b.ts",
"emission": "llvm",
"abi": {
"prefix": "mb_",
"init_symbol": "mb_init",
"sink_register_symbol": "mb_set_panic_sink",
"collect_symbol": "mb_collect",
"result_reset_symbol": null,
"localize_runtime": true
},
"exports": [
{ "export": "sumTo", "symbol": "mb_sum_to", "params": ["f64"], "returns": "f64" },
{ "export": "add", "symbol": "mb_add", "params": ["f64"], "returns": "f64" }
]
}