feat: support thread-instanced library state (#137)

* feat: support thread-instanced library state

- Add the abi.instance_per_thread profile field: the archive's TUs compile
  with -DSCR_THREAD_INSTANCES, and the SCR_TL qualifier in scr_runtime.h
  moves every runtime unit's mutable statics into thread-local storage;
  both backends emit the program TU's module globals, run-once guards, and
  lazily-compiled regex literal caches thread-local to match
- ONE linked archive then serves one independent instance per embedder
  thread through the unchanged entry family: a thread registers its sink
  and calls the init entry, and owns its own collector, result arena,
  poison flag, and program state — a trap poisons only the instance it
  fired in while sibling threads' instances keep answering
- Document the contract beside the profile spec: one instance per thread,
  selected implicitly by the calling thread; instance lifetime is the
  thread's lifetime; the never-entered-from-two-threads rule is unchanged;
  independent of and composable with abi.localize_runtime
- Immutable interned data (string literals, unit arms, template arrays,
  vtables) stays shared — Darwin ASan's image-registration common
  included, keeping the one-registration-per-image discipline; the
  exception cell's current pointer resolves a NULL sentinel in this mode
  because a thread-local address is not a constant initializer
- Add the four-thread acceptance probe (distinct per-thread workloads,
  concurrent instance-local inits, per-instance collects, a trap delivered
  to its own thread's sink exactly once), the composition probe pairing a
  thread-instanced localized archive with a second different-prefix
  localized archive, an explicit ASan rerun beside the suite-flavor
  sanitized builds, and profile-shape coverage; schedule the new lane
  contracts beside M1/M2
- Non-opted builds are byte-for-byte unchanged: the qualifier expands to
  nothing outside -DSCR_LIB -DSCR_THREAD_INSTANCES (verified object-level
  over every touched runtime TU in both default and library flavors)

* fix: keep sanitized runtime-localized archives linkable on ELF

ASan's instrumented globals ride ELF section groups; archives built from
shared runtime objects carry groups with REPEATED signatures, so a process
linking two runtime-localized sanitized archives kept one archive's group
and discarded the other's — whose now-local references then dangled at the
embedder's link. Resolving the groups into the combined relocatable member
(ld -r --force-group-allocation) keeps every archive's copies; the
localization step then demotes them per archive exactly like unsanitized
state. Plain builds carry no section groups, so the flag is inert there.

With the groups resolved, ELF surfaces the same deliberate exception
Mach-O already documented: the image-wide registration guard COMMON stays
shared so the final image registers its ASan globals exactly once — M1/M7
now pin that spelling on both platforms. The explicit ASan pairing (M8)
additionally points Linux LSan away from contractually thread-lifetime
instance state, exactly as the sanitized suite lanes do.

* fix: isolate inspect state per thread

* fix(runtime): share uptime anchor across threads
This commit is contained in:
Chris Tate
2026-08-12 09:49:43 -05:00
committed by GitHub
parent da2ad7e002
commit 6f7a1a08fe
33 changed files with 1021 additions and 126 deletions
+18 -4
View File
@@ -1132,6 +1132,14 @@ export interface LibArchiveOptions {
* builds) keep their global binding. Omitted = the classic archive,
* byte-for-byte. Host-native builds only. */
localizeSymbols?: readonly string[];
/** Thread-instanced state (the profile's abi.instance_per_thread): every
* TU of the archive compiles with -DSCR_THREAD_INSTANCES, moving the
* runtime units' mutable statics into thread-local storage (SCR_TL in
* scr_runtime.h) to match the program TU's thread-local globals — one
* complete instance per embedder thread. The define rides cflags, so
* every cache tier keys it automatically; omitted = the classic
* archive, byte-for-byte. */
threadInstances?: boolean;
/** IR-detected link gates (the compileC precedent, refusal-narrowed). */
regex?: boolean;
assert?: boolean;
@@ -1180,6 +1188,7 @@ export async function compileLibArchive(opts: LibArchiveOptions): Promise<void>
"-fno-strict-aliasing", // the emitted object model type-puns — see compileC's buildArgs
"-Wno-deprecated-declarations",
"-DSCR_LIB",
...(opts.threadInstances ? ["-DSCR_THREAD_INSTANCES"] : []),
...(opts.textDecoderLegacy ? ["-DSCR_TEXT_DECODER_LEGACY"] : []),
"-I", rtDir,
...(regex ? ["-I", vendorEngineDir()] : []),
@@ -1564,9 +1573,14 @@ export async function compileLibArchive(opts: LibArchiveOptions): Promise<void>
* 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).
* linux — ld -r merges with --force-group-allocation (ASan's
* instrumented globals ride ELF section groups whose signatures
* repeat across archives sharing runtime objects; resolving the
* groups into the combined member keeps a later multi-archive
* link from discarding one archive's copies), 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
@@ -1601,7 +1615,7 @@ async function localizeLibraryObjects(
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(["ld", "-r", "--force-group-allocation", programObject, staging, "-o", combined]);
await run(["objcopy", `--keep-global-symbols=${keepFile}`, combined]);
} else {
throw new Error(
@@ -662,7 +662,7 @@ export class CEmitter {
// "*/" inside the pattern would close the trailing comment.
const safe = `/${pattern}/${flags}`.split("*/").join("* /");
out.push(
`static ScrRegex ${sym} = { .rc = SIZE_MAX, .source = (ScrStr *)&${src}, ` +
`static ${this.mod.lib?.threadInstances === true ? "_Thread_local " : ""}ScrRegex ${sym} = { .rc = SIZE_MAX, .source = (ScrStr *)&${src}, ` +
`.flags = (ScrStr *)&${fl}, .bc = NULL }; /* ${safe} */`,
);
}
@@ -685,10 +685,17 @@ export class CEmitter {
const embedded = this.mod.embedded;
emitNpmEmbedding(this, out);
const globals = this.mod.globals ?? [];
// Thread-instanced library state (abi.instance_per_thread): module
// globals, run-once guards, and the regex literal caches below live in
// thread-local storage — one full instance per embedder thread,
// matching the runtime objects compiled with -DSCR_THREAD_INSTANCES.
// Immutable interned data (string literals, unit arms, template
// arrays, vtables) stays shared.
const tl = this.mod.lib?.threadInstances === true ? "_Thread_local " : "";
for (const g of globals) {
// File-scope statics: zero/NULL-initialized, assigned by %init
// functions, released (refcounted ones) before the RC audit runs.
out.push(`static ${cDecl(g.type, mangleGlobal(g.id))}; /* ${g.name} */`);
out.push(`static ${tl}${cDecl(g.type, mangleGlobal(g.id))}; /* ${g.name} */`);
}
if (globals.length > 0) out.push("");
// Outbound native FFI declarations. string/bytes each expand from one
+12 -4
View File
@@ -1689,9 +1689,17 @@ class LlEmitter {
];
out.push(...shapes.typeDefs);
out.push(...classShapes.typeDefs);
// Thread-instanced library state (abi.instance_per_thread): the
// program TU's mutable globals — module globals, run-once guards, the
// lazily-compiled regex literal caches — and the runtime globals its
// init stamps live in thread-local storage, matching the runtime
// objects compiled with -DSCR_THREAD_INSTANCES. Immutable interned
// data (string literals, unit arms, template arrays, vtables) stays
// shared.
const tl = this.mod.lib?.threadInstances === true ? "thread_local " : "";
out.push(
``,
`@scr_error_vts = external global [5 x %ScrVt]`,
`@scr_error_vts = external ${tl}global [5 x %ScrVt]`,
`declare void @scr_init()`,
`declare void @scr_lib_init(i32, ptr)`,
);
@@ -1721,7 +1729,7 @@ class LlEmitter {
// the interned source/flags strings. The bc slot starts null (lazy
// compile, cached by the runtime) — a mutable global, not constant.
out.push(
`@${re.sym} = internal global %ScrRegex { ${this.sizeType} -1, ptr ${re.src}, ptr ${re.fl}, ptr null } ; ${key.replace(/\n/g, "\\n")}`,
`@${re.sym} = internal ${tl}global %ScrRegex { ${this.sizeType} -1, ptr ${re.src}, ptr ${re.fl}, ptr null } ; ${key.replace(/\n/g, "\\n")}`,
);
}
if (this.regexInstances.size > 0) out.push(``);
@@ -1793,7 +1801,7 @@ class LlEmitter {
for (const g of globals) {
const ty = this.llType(g.type);
const zero = ty === "double" ? f64Lit(0) : ty === "ptr" ? "null" : "false";
out.push(`@${mangleGlobal(g.id)} = internal global ${ty} ${zero} ; ${g.name}`);
out.push(`@${mangleGlobal(g.id)} = internal ${tl}global ${ty} ${zero} ; ${g.name}`);
}
if (globals.length > 0) out.push(``);
out.push(...helpers);
@@ -1825,7 +1833,7 @@ class LlEmitter {
// The runtime emitter vtable's interval (emitterVtStampLines): bare
// EventEmitter instances answer instanceof and dispatch dynamic
// teardown under THIS module's preorder numbering.
out.push(`@scr_emitter_vt = external global %ScrVt`, ``);
out.push(`@scr_emitter_vt = external ${tl}global %ScrVt`, ``);
stamps.push(
` store ${this.sizeType} ${this.emitterInterval.pre}, ptr getelementptr inbounds (%ScrVt, ptr @scr_emitter_vt, i64 0, i32 0)`,
` store ${this.sizeType} ${this.emitterInterval.post}, ptr getelementptr inbounds (%ScrVt, ptr @scr_emitter_vt, i64 0, i32 1) ; EventEmitter`,
+2
View File
@@ -1247,6 +1247,7 @@ function resolveLibrarySection(
sinkRegisterSymbol: profile.sinkRegisterSymbol,
collectSymbol: profile.collectSymbol,
resultResetSymbol: profile.resultResetSymbol,
threadInstances: profile.instancePerThread,
exports,
trapOverlays,
},
@@ -1726,6 +1727,7 @@ export async function compileLibrary(opts: CompileLibraryOptions): Promise<Compi
cacheIdentity: "scriptc-generated-library-v1",
sanitize: opts.sanitize ?? false,
...(localizeSymbols !== undefined ? { localizeSymbols } : {}),
...(profile.instancePerThread ? { threadInstances: true } : {}),
regex: moduleUsesRegex(mod),
assert: moduleUsesAssert(mod),
inspect: moduleUsesInspect(mod),
+6
View File
@@ -898,6 +898,12 @@ export interface IrLibSection {
/** The declared result-arena reset entry; null selects the auto-reset
* posture (every entry prologue resets the arena). */
resultResetSymbol: string | null;
/** Thread-instanced state (the profile's abi.instance_per_thread): both
* backends emit the program TU's mutable statics — module globals,
* run-once guards, and the lazily-compiled regex literal caches — as
* thread-local storage, matching the runtime objects compiled with
* -DSCR_THREAD_INSTANCES: one full instance per embedder thread. */
threadInstances: boolean;
exports: IrLibExport[];
/** Profile-declared teaching/remediation overlays for the runtime
* detected-trap code family (SC4013–SC4019, diagnostics registry): both
+44 -2
View File
@@ -20,10 +20,14 @@
* "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
* "localize_runtime": false // multi-instance library
* "localize_runtime": false, // multi-instance library
* // mode (see below); absent
* // = false, the classic
* // single-archive artifact
* "instance_per_thread": false // thread-instanced state
* // (see below); absent =
* // false, one instance per
* // linked archive
* },
* "exports": [ { "export": "update", "symbol": "<prefix>_update",
* "params": ["f64", "string"], "returns": "bytes" } ],
@@ -108,6 +112,31 @@
* classic artifact, byte-for-byte. Localization is host-native (darwin and
* linux); cross-target archive builds refuse it with SC3002.
*
* Thread-instanced state (`abi.instance_per_thread: true`): every mutable
* piece of the archive's state — the runtime units' internals AND the
* program's globals, run-once guards, and lazily-compiled regex literal
* caches — compiles as thread-local storage, so ONE linked archive serves
* N instances: a thread that calls the init entry owns a complete,
* independent instance through the unchanged entry family (no handle
* parameter exists; the calling thread IS the instance selector). Sinks,
* the poison flag, the result arena, and the collector are all
* per instance, so a trap poisons only the instance it fired in. The
* contract extends the multi-instance one:
*
* - One instance per thread, selected implicitly by the calling thread;
* each thread that will serve requests calls the init entry first.
* - An instance's lifetime is its thread's lifetime; entering it from
* any other thread is undefined (unchanged rule). Ending a thread ends
* its instance without teardown — memory owned by the instance is
* reclaimed at process exit, so long-lived pools should reuse threads
* rather than cycle them.
* - No value crosses instances (the marshalling rule above, unchanged).
*
* Independent of `localize_runtime`, and composable with it: a thread-
* instanced archive that must coexist with OTHER archives in one process
* declares both. Off by default; non-opted builds are byte-for-byte
* unchanged.
*
* 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:
@@ -231,6 +260,12 @@ export interface LibraryProfile {
* the header contract). False (the default) produces the classic
* single-archive artifact unchanged. */
localizeRuntime: boolean;
/** Thread-instanced state: true compiles every mutable static — runtime
* internals and program globals — as thread-local storage, so one linked
* archive serves one independent instance per embedder thread through
* the unchanged entry family (see the header contract). False (the
* default) keeps one instance per linked archive. */
instancePerThread: boolean;
exports: LibraryExportEntry[];
/** The ask-2 contract-sidecar section; null = the profile declares no
* sidecar and the invocation emits none. */
@@ -347,7 +382,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"]);
rejectUnknownKeys(abi, "abi", ["prefix", "init_symbol", "sink_register_symbol", "collect_symbol", "result_reset_symbol", "localize_runtime", "instance_per_thread"]);
const a = abi as Record<string, unknown>;
const prefix = req<string>(a["prefix"], "abi.prefix", "string");
if (!C_IDENT.test(prefix)) {
@@ -364,6 +399,12 @@ export function loadLibraryProfile(
a["localize_runtime"] === undefined
? false
: req<boolean>(a["localize_runtime"], "abi.localize_runtime", "boolean");
// Thread-instanced state: same strict-boolean rule — the field gates
// where every mutable static of the artifact lives.
const instancePerThread =
a["instance_per_thread"] === undefined
? false
: req<boolean>(a["instance_per_thread"], "abi.instance_per_thread", "boolean");
const exportsRaw = p["exports"];
if (!Array.isArray(exportsRaw)) throw new ProfileError("'exports' must be an array");
@@ -621,6 +662,7 @@ export function loadLibraryProfile(
collectSymbol,
resultResetSymbol,
localizeRuntime,
instancePerThread,
exports: entries,
sidecar,
profileBytes: bytes,
+1 -1
View File
@@ -8,7 +8,7 @@
/* Live heap-array count for the RC audit lane (-DSCR_RC_AUDIT); same
* contract as scr_str_live_count in scr_string.c. */
#ifdef SCR_RC_AUDIT
static long scr_live_arrays = 0;
static SCR_TL long scr_live_arrays = 0;
long scr_arr_live_count(void) { return scr_live_arrays; }
#endif
+4 -4
View File
@@ -158,9 +158,9 @@ void scr_assert_ok(bool pass, ScrStr *message) {
* PAIR already being compared answers equal (the coinductive step —
* Node's memo behavior exactly). The stack is global (comparisons never
* interleave; the emitted walks cannot throw mid-compare). */
static struct { const void *a, *b; } *g_deq_stack;
static size_t g_deq_len;
static size_t g_deq_cap;
static SCR_TL struct { const void *a, *b; } *g_deq_stack;
static SCR_TL size_t g_deq_len;
static SCR_TL size_t g_deq_cap;
bool scr_assert_deq_enter(const void *a, const void *b) {
for (size_t i = 0; i < g_deq_len; i++) {
@@ -1228,7 +1228,7 @@ typedef struct {
ScrStr *val; /* owned: the string value, or the regex's /source/flags */
} ScrShapeSlot;
static struct {
static SCR_TL struct {
ScrError *err; /* borrowed — alive across the straight-line sequence */
ScrShapeSlot slots[3];
} scr_shape;
+1 -1
View File
@@ -17,7 +17,7 @@
#include <string.h>
#ifdef SCR_RC_AUDIT
static long scr_live_bytes = 0;
static SCR_TL long scr_live_bytes = 0;
long scr_bytes_live_count(void) { return scr_live_bytes; }
#endif
+2 -2
View File
@@ -5,8 +5,8 @@
#include <string.h>
#ifdef SCR_RC_AUDIT
static long scr_live_boxes = 0;
static long scr_live_closures = 0;
static SCR_TL long scr_live_boxes = 0;
static SCR_TL long scr_live_closures = 0;
long scr_box_live_count(void) { return scr_live_boxes; }
long scr_closure_live_count(void) { return scr_live_closures; }
#endif
+1 -1
View File
@@ -11,7 +11,7 @@
/* Set by scr_async.c at loop exhaustion; lives here (unconditionally) so
* binaries that link the console without the async runtime still link, and
* plain builds satisfy scr_async's reference. */
static long scr_abandoned_fibers = 0;
static SCR_TL long scr_abandoned_fibers = 0;
void scr_note_abandoned_fibers(long n) { scr_abandoned_fibers = n; }
#ifndef SCR_LIB
+14 -14
View File
@@ -95,7 +95,7 @@ static void scr_cyc_oom(void) {
}
/* Live cycle-headered objects — what the full-pass trigger watches. */
static size_t scr_cyc_live = 0;
static SCR_TL size_t scr_cyc_live = 0;
void *scr_cyc_alloc(size_t size, ScrTraceFn trace, ScrCycFreeFn free_fn) {
ScrCycHdr *h = calloc(1, sizeof(ScrCycHdr) + size);
@@ -137,25 +137,25 @@ static void scr_cyc_push(ScrVec *vec, void *obj) {
/* ── the candidate-root buffers (one per generation) ──────────────────── */
static ScrVec scr_roots[SCR_CYC_NGENS];
static bool scr_collecting = false;
static SCR_TL ScrVec scr_roots[SCR_CYC_NGENS];
static SCR_TL bool scr_collecting = false;
/* The candidates of the pass in flight, gathered across the generations it
* collects so the phases can walk them without re-filtering the buffers. */
static ScrVec scr_cands;
static SCR_TL ScrVec scr_cands;
/* Nursery survivors awaiting promotion (see scr_scan_black). */
static ScrVec scr_promote;
static SCR_TL ScrVec scr_promote;
/* The gathered white set (freed after the walk completes). */
static ScrVec scr_white;
static SCR_TL ScrVec scr_white;
/* Cross-generation targets pinned by the white set (see scr_xg_pin_visit),
* one entry per skipped edge. Drained after the teardowns. */
static ScrVec scr_xgen;
static SCR_TL ScrVec scr_xgen;
/* Objects above this generation are invisible to the pass in flight. */
static unsigned scr_gen_limit = SCR_CYC_MATURE;
static SCR_TL unsigned scr_gen_limit = SCR_CYC_MATURE;
static size_t scr_cyc_pass(unsigned gen_limit);
@@ -166,10 +166,10 @@ static size_t scr_cyc_pass(unsigned gen_limit);
#define SCR_CYC_FULL_FLOOR 4096 /* ...but not below this many objects */
/* Live count as of the end of the last full pass. */
static size_t scr_cyc_live_after_full = 0;
static SCR_TL size_t scr_cyc_live_after_full = 0;
static size_t scr_cyc_nursery_threshold(void) {
static size_t cached = 0;
static SCR_TL size_t cached = 0;
if (cached == 0) {
const char *env = getenv("SCR_CYCLE_THRESHOLD");
long v = env ? strtol(env, NULL, 10) : 0;
@@ -186,12 +186,12 @@ static size_t scr_cyc_nursery_threshold(void) {
* hysteresis a large mature buffer — which a nursery pass cannot drain —
* would re-arm on every single release. Both start at zero, so the first
* release runs one trivial pass that arms them properly. */
static size_t scr_cyc_nbuffered = 0;
static size_t scr_cyc_trigger = 0;
static SCR_TL size_t scr_cyc_nbuffered = 0;
static SCR_TL size_t scr_cyc_trigger = 0;
/* Scheduled nursery passes for which at least one mature root was waiting.
* A full pass resets the age; with no mature backlog there is nothing to age. */
static size_t scr_cyc_scheduled_mature_age = 0;
static SCR_TL size_t scr_cyc_scheduled_mature_age = 0;
static size_t scr_cyc_buffered(void) {
return scr_roots[SCR_CYC_NURSERY].n + scr_roots[SCR_CYC_MATURE].n;
@@ -404,7 +404,7 @@ static void scr_xg_pin_visit(void *child, void *ctx) {
}
/* Freed by the cross-generation drain, for the caller's fixpoint. */
static size_t scr_xg_freed = 0;
static SCR_TL size_t scr_xg_freed = 0;
static void scr_xg_release(void *obj);
static void scr_xg_child_visit(void *child, void *ctx) {
+3 -3
View File
@@ -17,7 +17,7 @@ static void scr_error_oom(void) {
scr_trap("scriptc: out of memory\n");
}
static bool scr_error_traced = false;
static SCR_TL bool scr_error_traced = false;
void scr_error_set_traced(void) { scr_error_traced = true; }
@@ -49,7 +49,7 @@ static void scr_error_gcfree(void *obj) {
* must stay linkable WITHOUT the checked-dynamic tree (the runtime C-unit tests link
* subsets), so the drop goes through a hook scr_json.c installs before
* any cause can exist (scr_domex_new is the only producer). */
static void (*scr_domex_cause_drop)(void *obj) = NULL;
static SCR_TL void (*scr_domex_cause_drop)(void *obj) = NULL;
void scr_domex_install_cause_drop(void (*fn)(void *obj)) {
scr_domex_cause_drop = fn;
@@ -90,7 +90,7 @@ static void scr_domex_reld(void *obj) {
/* Defaults cover only the instants before main() stamps the program's real
* preorder intervals; release is permanent. */
ScrVt scr_error_vts[5] = {
SCR_TL ScrVt scr_error_vts[5] = {
{0, 4, &scr_error_reld}, /* Error */
{1, 1, &scr_error_reld}, /* TypeError */
{2, 2, &scr_error_reld}, /* RangeError */
+3 -3
View File
@@ -97,11 +97,11 @@ struct ScrEeReg {
* stamps pre/post from the program's preorder numbering (the
* scr_error_vts precedent). release is the direct teardown below. */
static void scr_emitter_release_direct(void *obj);
ScrVt scr_emitter_vt = {0, 0, &scr_emitter_release_direct};
SCR_TL ScrVt scr_emitter_vt = {0, 0, &scr_emitter_release_direct};
double scr_emitter_default_max = 10; /* EventEmitter.defaultMaxListeners */
SCR_TL double scr_emitter_default_max = 10; /* EventEmitter.defaultMaxListeners */
static bool scr_ee_trace_hint_shown = false;
static SCR_TL bool scr_ee_trace_hint_shown = false;
/* Post-registration hook (scr_runtime.h): the stream unit's flow kick.
* NULL in stream-free builds — behavior byte-identical. */
+22 -12
View File
@@ -35,28 +35,38 @@ _Noreturn void scr_trap_fmt(const char *fmt, ...) {
}
#endif /* !SCR_LIB */
static ScrExcCell scr_main_exc; /* fiber zero (the main stack) */
static SCR_TL ScrExcCell scr_main_exc; /* fiber zero (the main stack) */
#if defined(SCR_LIB) && defined(SCR_THREAD_INSTANCES)
/* A thread-local pointer cannot be initialized with &scr_main_exc — the
* address of a thread-local is not a constant expression — so NULL stands
* for the main cell and every read resolves it. Fibers never exist in
* library artifacts, so the cell only ever IS the main cell here. */
static SCR_TL ScrExcCell *scr_cur;
#define SCR_EXC_CUR() (scr_cur ? scr_cur : &scr_main_exc)
#else
static ScrExcCell *scr_cur = &scr_main_exc;
#define SCR_EXC_CUR() (scr_cur)
#endif
/* Fiber switching (scr_async.c) points the exception machinery at the
* incoming fiber's cell; returns the previous cell. */
ScrExcCell *scr_exc_swap_cell(ScrExcCell *cell) {
ScrExcCell *prev = scr_cur;
ScrExcCell *prev = SCR_EXC_CUR();
scr_cur = cell ? cell : &scr_main_exc;
return prev;
}
/* The ACTIVE cell, for runtime code that moves a pending payload out of it
* (a new-Promise executor throw rejecting the promise). */
ScrExcCell *scr_exc_current_cell(void) { return scr_cur; }
ScrExcCell *scr_exc_current_cell(void) { return SCR_EXC_CUR(); }
#define scr_exc_kind (scr_cur->kind)
#define scr_exc_f64 (scr_cur->f64)
#define scr_exc_bool (scr_cur->b)
#define scr_exc_payload (scr_cur->payload)
#define scr_exc_retain_fn (scr_cur->retain_fn)
#define scr_exc_release_fn (scr_cur->release_fn)
#define scr_exc_trace_fn (scr_cur->trace_fn)
#define scr_exc_kind (SCR_EXC_CUR()->kind)
#define scr_exc_f64 (SCR_EXC_CUR()->f64)
#define scr_exc_bool (SCR_EXC_CUR()->b)
#define scr_exc_payload (SCR_EXC_CUR()->payload)
#define scr_exc_retain_fn (SCR_EXC_CUR()->retain_fn)
#define scr_exc_release_fn (SCR_EXC_CUR()->release_fn)
#define scr_exc_trace_fn (SCR_EXC_CUR()->trace_fn)
bool scr_exc_pending(void) { return scr_exc_kind != SCR_EXC_NONE; }
@@ -187,7 +197,7 @@ ScrStr *scr_caught_to_string(const ScrCaught *c) {
* delivery; only the 0x01-led verbatim path below bypasses assembly. */
void scr_library_check_exc(void) {
if (!scr_exc_pending()) return;
static char buf[1024]; /* the message is copied out before the payload dies */
static SCR_TL char buf[1024]; /* the message is copied out before the payload dies */
/* The ratified verbatim rule: a thrown message that ALREADY begins with
* the structured trap-teaching marker (0x01) is delivered exactly as
* authored — no "Uncaught " prefix, no added newline — which is how a
@@ -301,7 +311,7 @@ void scr_throw_obj(void *v, void *(*retain)(void *), void (*release)(void *),
* events unit's atexit, when linked) must see that code, like Node's.
* Lives HERE so the unit is self-contained (the reporters below and in
* scr_async.c both note it; scr_events.c reads it). */
static int scr_exit_code_hint = 0;
static SCR_TL int scr_exit_code_hint = 0;
void scr_exit_code_note(int code) { scr_exit_code_hint = code; }
int scr_exit_code_hint_get(void) { return scr_exit_code_hint; }
+10 -10
View File
@@ -237,11 +237,11 @@ typedef struct {
bool all_num; /* every entry so far flagged numeric (grouping order) */
} InspFrame;
static InspFrame *g_frames;
static size_t g_nframes;
static size_t g_frames_cap;
static size_t g_indent; /* ctx.indentationLvl */
static double g_cur_depth; /* ctx.currentDepth (last-entered composite) */
static SCR_TL InspFrame *g_frames;
static SCR_TL size_t g_nframes;
static SCR_TL size_t g_frames_cap;
static SCR_TL size_t g_indent; /* ctx.indentationLvl */
static SCR_TL double g_cur_depth; /* ctx.currentDepth (last-entered composite) */
static InspFrame *insp_top(void) { return &g_frames[g_nframes - 1]; }
@@ -297,12 +297,12 @@ typedef struct {
int id; /* circular id (0 while only on the stack) */
} InspSeenEnt;
static InspSeenEnt *g_seen;
static size_t g_nseen;
static size_t g_seen_cap;
static SCR_TL InspSeenEnt *g_seen;
static SCR_TL size_t g_nseen;
static SCR_TL size_t g_seen_cap;
/* Detection-numbered circular targets (persist across the call). */
static const void *g_circ[64];
static int g_ncirc;
static SCR_TL const void *g_circ[64];
static SCR_TL int g_ncirc;
static void insp_circ_reset(void) {
g_nseen = 0;
+14 -14
View File
@@ -31,7 +31,7 @@
/* Live dyn-node count for the RC audit lane (-DSCR_RC_AUDIT); same contract
* as scr_str_live_count in scr_string.c. */
#ifdef SCR_RC_AUDIT
static long scr_live_dyns = 0;
static SCR_TL long scr_live_dyns = 0;
long scr_dyn_live_count(void) { return scr_live_dyns; }
#endif
@@ -49,7 +49,7 @@ static void scr_json_oom(void) {
/* The ScrStr block behind a non-empty buffer. */
#define SCR_JB_STR(b) ((ScrStr *)((char *)(b)->data - offsetof(ScrStr, data)))
static size_t scr_jb_hint = 64;
static SCR_TL size_t scr_jb_hint = 64;
void scr_jb_init(ScrJsonBuf *b) {
b->data = NULL;
@@ -280,9 +280,9 @@ void scr_jb_edge_idx(ScrJsonBuf *b, size_t i) {
* (SEMANTICS.md documents the divergence). The emitted converters over
* cycle-capable containers bracket their walks with enter/leave; the
* stack is global (conversions never interleave). */
static const void **g_td_seen;
static size_t g_td_nseen;
static size_t g_td_cap;
static SCR_TL const void **g_td_seen;
static SCR_TL size_t g_td_nseen;
static SCR_TL size_t g_td_cap;
void scr_dyn_from_enter(const void *v) {
for (size_t i = 0; i < g_td_nseen; i++) {
@@ -315,8 +315,8 @@ void scr_dyn_from_leave(void) {
* strict alloc/free balance.
*/
#ifndef SCR_RC_AUDIT
static ScrDyn *scr_dyn_free_arr, *scr_dyn_free_obj, *scr_dyn_free_misc;
static size_t scr_dyn_free_count;
static SCR_TL ScrDyn *scr_dyn_free_arr, *scr_dyn_free_obj, *scr_dyn_free_misc;
static SCR_TL size_t scr_dyn_free_count;
#define SCR_DYN_FREE_MAX 8192
#endif
@@ -818,7 +818,7 @@ ScrBytes *scr_dyn_bytes_copy_out(const ScrDyn *d) {
* Per-chunk utf8 decode: a multibyte character split across chunks does
* not re-join (Node's StringDecoder holds the partial byte); ASCII-clean
* bodies — the overwhelming test shape — are exact. */
static bool scr_dyn_chunk_utf8;
static SCR_TL bool scr_dyn_chunk_utf8;
void scr_dyn_chunk_enc(bool utf8) { scr_dyn_chunk_utf8 = utf8; }
/* One 'data' payload as the dyn value the current window dictates:
@@ -1122,7 +1122,7 @@ ScrDyn *scr_dyn_alloc_promise(void (*release_fn)(ScrPromise *p)) {
* this allocator view and installs the engine-routing ops — the
* scr_dyn_alloc_promise story: a dynamic-free link never references
* engine symbols, and JSVAL nodes exist only after an install. */
static const ScrDynJsvalOps *scr_dynjs_ops = NULL;
static SCR_TL const ScrDynJsvalOps *scr_dynjs_ops = NULL;
ScrDyn *scr_dyn_alloc_jsval(ScrJsval *cell, const ScrDynJsvalOps *ops) {
scr_dynjs_ops = ops;
@@ -1256,8 +1256,8 @@ typedef struct {
ScrDyn *dv; /* dyn entry (+1); NULL for handle/undefined entries */
} ScrDynThisEnt;
static ScrDynThisEnt *scr_dyn_this_stack;
static size_t scr_dyn_this_n, scr_dyn_this_cap;
static SCR_TL ScrDynThisEnt *scr_dyn_this_stack;
static SCR_TL size_t scr_dyn_this_n, scr_dyn_this_cap;
static ScrDynThisEnt *scr_dyn_this_grow(void) {
if (scr_dyn_this_n == scr_dyn_this_cap) {
@@ -1526,9 +1526,9 @@ typedef struct {
ScrDyn *dyn; /* retained */
} ScrErrDynEnt;
static ScrErrDynEnt *scr_errdyn_cache = NULL;
static size_t scr_errdyn_n = 0, scr_errdyn_cap = 0;
static bool scr_errdyn_teardown_registered = false;
static SCR_TL ScrErrDynEnt *scr_errdyn_cache = NULL;
static SCR_TL size_t scr_errdyn_n = 0, scr_errdyn_cap = 0;
static SCR_TL bool scr_errdyn_teardown_registered = false;
static void scr_errdyn_teardown(void) {
for (size_t i = 0; i < scr_errdyn_n; i++) {
+44 -17
View File
@@ -104,14 +104,14 @@ extern char **environ; /* env snapshot (scr_env_pairs) */
/* ── process ─────────────────────────────────────────────────────────── */
static int scr_lib_argc = 0;
static char **scr_lib_argv = NULL;
static ScrArr *scr_argv_arr = NULL; /* interned process.argv */
static ScrStr *scr_platform_str = NULL; /* interned process.platform */
static ScrStr *scr_exec_path_str = NULL; /* interned process.execPath */
static ScrStr *scr_arch_str = NULL; /* interned process.arch */
static ScrStr *scr_versions_node_str = NULL; /* interned process.versions.node */
static ScrStr *scr_versions_openssl_str = NULL; /* interned process.versions.openssl */
static SCR_TL int scr_lib_argc = 0;
static SCR_TL char **scr_lib_argv = NULL;
static SCR_TL ScrArr *scr_argv_arr = NULL; /* interned process.argv */
static SCR_TL ScrStr *scr_platform_str = NULL; /* interned process.platform */
static SCR_TL ScrStr *scr_exec_path_str = NULL; /* interned process.execPath */
static SCR_TL ScrStr *scr_arch_str = NULL; /* interned process.arch */
static SCR_TL ScrStr *scr_versions_node_str = NULL; /* interned process.versions.node */
static SCR_TL ScrStr *scr_versions_openssl_str = NULL; /* interned process.versions.openssl */
static void scr_lib_cleanup(void) {
scr_arr_release(scr_argv_arr);
@@ -1103,8 +1103,27 @@ void scr_process_exit(double code) {
* attribute monotonic stamp — the binary's own start, which is what
* "the current Node.js process" means for a compiled program). */
#ifdef _WIN32
#if defined(SCR_LIB) && defined(SCR_THREAD_INSTANCES)
/* The uptime anchor belongs to the host PROCESS, not to a library
* instance. InitOnce keeps the lazy Windows spelling race-free when
* several thread instances ask for the clock concurrently. */
static INIT_ONCE scr_uptime_once = INIT_ONCE_STATIC_INIT;
static double scr_uptime_t0_ms;
static BOOL CALLBACK scr_uptime_anchor_once(PINIT_ONCE once, PVOID param,
PVOID *ctx) {
(void)once;
(void)param;
(void)ctx;
scr_uptime_t0_ms = (double)GetTickCount64();
return TRUE;
}
static void scr_uptime_anchor_init(void) {
InitOnceExecuteOnce(&scr_uptime_once, scr_uptime_anchor_once, NULL, NULL);
}
#else
static SCR_TL double scr_uptime_t0_ms;
static void scr_uptime_anchor_init(void) { scr_uptime_t0_ms = (double)GetTickCount64(); }
#endif
static double scr_uptime_now_ms(void) { return (double)GetTickCount64(); }
#else
#include <sys/resource.h>
@@ -1112,6 +1131,10 @@ static double scr_uptime_now_ms(void) { return (double)GetTickCount64(); }
#ifdef __APPLE__
#include <mach/mach.h>
#endif
/* A load-time, process-wide anchor: the constructor runs on only the
* initial thread, while thread-instanced archives are entered from worker
* threads whose TLS slots would otherwise stay zero. It is immutable once
* main starts, so sharing it adds no cross-instance mutable state. */
static double scr_uptime_t0_ms;
static double scr_uptime_now_ms(void) {
struct timespec ts;
@@ -1124,7 +1147,9 @@ __attribute__((constructor)) static void scr_uptime_anchor_init(void) {
#endif
double scr_process_uptime(void) {
#ifdef _WIN32
#if defined(_WIN32) && defined(SCR_LIB) && defined(SCR_THREAD_INSTANCES)
scr_uptime_anchor_init();
#elif defined(_WIN32)
if (scr_uptime_t0_ms == 0) scr_uptime_anchor_init();
#endif
return (scr_uptime_now_ms() - scr_uptime_t0_ms) / 1000.0;
@@ -1134,7 +1159,9 @@ double scr_process_uptime(void) {
* start (Node's timeOrigin anchor), fractional — the same monotonic
* clock and anchor as uptime, in Node's performance.now units. */
double scr_perf_now(void) {
#ifdef _WIN32
#if defined(_WIN32) && defined(SCR_LIB) && defined(SCR_THREAD_INSTANCES)
scr_uptime_anchor_init();
#elif defined(_WIN32)
if (scr_uptime_t0_ms == 0) scr_uptime_anchor_init();
#endif
return scr_uptime_now_ms() - scr_uptime_t0_ms;
@@ -1353,7 +1380,7 @@ double scr_available_memory(void) {
/* process._exiting — true once the exit sequence began (set above and by
* scr_run_exit_listeners in scr_events.c; the flag lives HERE so reading
* it never forces the events unit into the link). */
bool scr_process_in_exit = false;
SCR_TL bool scr_process_in_exit = false;
bool scr_process_exiting(void) { return scr_process_in_exit; }
@@ -1372,7 +1399,7 @@ double scr_process_umask(double mask) {
}
return (double)prev;
#elif defined(__wasi__)
static mode_t current = 022;
static SCR_TL mode_t current = 022;
mode_t prev = current;
if (mask >= 0) current = (mode_t)mask;
return (double)prev;
@@ -1399,7 +1426,7 @@ void scr_process_chdir(ScrStr *dir) {
/* net's process-wide happy-eyeballs attempt budget (Node's
* getDefaultAutoSelectFamilyAttemptTimeout pair, default 250ms). Lives in
* the core unit so the knob never forces scr_net.c into the link. */
static double scr_net_autosel_timeout_ms = 250;
static SCR_TL double scr_net_autosel_timeout_ms = 250;
double scr_net_get_autosel_timeout(void) { return scr_net_autosel_timeout_ms; }
@@ -2745,8 +2772,8 @@ double scr_process_columns(double fd) {
* NON-TTY stdin: Node's process.stdin is a Socket with no setRawMode
* member at all, so the call throws Node's exact catchable TypeError. */
#ifdef _WIN32
static DWORD scr_stdin_cooked;
static bool scr_stdin_cooked_saved = false;
static SCR_TL DWORD scr_stdin_cooked;
static SCR_TL bool scr_stdin_cooked_saved = false;
void scr_process_stdin_set_raw_mode(bool raw) {
if (!isatty(0)) {
@@ -2778,8 +2805,8 @@ void scr_process_stdin_set_raw_mode(bool raw) {
scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1);
}
#else
static struct termios scr_stdin_cooked;
static bool scr_stdin_cooked_saved = false;
static SCR_TL struct termios scr_stdin_cooked;
static SCR_TL bool scr_stdin_cooked_saved = false;
void scr_process_stdin_set_raw_mode(bool raw) {
if (!isatty(0)) {
+19 -10
View File
@@ -19,6 +19,15 @@
* this file is thread-aware; the embedder contract is one thread per
* instance (an instance is never entered from two threads).
*
* Thread-instanced archives (the profile's abi.instance_per_thread) take
* the same story one level down: SCR_TL moves this file's state — and
* every other mutable static in the archive — into thread-local storage,
* so ONE linked archive serves one independent instance per embedder
* thread. A thread registers its own sink and calls the init entry before
* serving; its trap poisons only its own instance while sibling threads'
* instances keep answering. The one-thread-per-instance contract is
* unchanged — the calling thread IS the instance selector.
*
* 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
@@ -32,9 +41,9 @@
/* ── sink registration + poison ───────────────────────────────────────── */
static ScrLibSinkFn scr_library_sink = NULL;
static void *scr_library_sink_ctx = NULL;
static bool scr_library_poisoned = false;
static SCR_TL ScrLibSinkFn scr_library_sink = NULL;
static SCR_TL void *scr_library_sink_ctx = NULL;
static SCR_TL bool scr_library_poisoned = false;
void scr_library_set_sink(ScrLibSinkFn fn, void *ctx) {
/* Latest registration wins; re-registration is permitted before a trap.
@@ -76,7 +85,7 @@ void scr_library_set_sink(ScrLibSinkFn fn, void *ctx) {
* 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;
static SCR_TL const char *scr_library_entry_symbol = NULL;
/* Detected-trap classification: the runtime's trap sites self-classify
* through their message conventions (the exact bytes the executable lane
@@ -114,7 +123,7 @@ static _Noreturn void scr_library_trap_deliver(const char *msg, size_t len, uint
* no malloc on the failure path; text truncates before structure ever
* would (codes and symbols are short; an oversized remediation drops
* whole, never split). */
static char buf[2048];
static SCR_TL char buf[2048];
const char *code = scr_library_trap_code(msg, len);
const char *text = msg;
size_t text_len = len;
@@ -179,7 +188,7 @@ __attribute__((noinline)) _Noreturn void scr_trap_len(const char *msg, size_t le
}
__attribute__((noinline)) _Noreturn void scr_trap_fmt(const char *fmt, ...) {
static char buf[512]; /* no malloc on the invariant-failure path */
static SCR_TL char buf[512]; /* no malloc on the invariant-failure path */
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(buf, sizeof buf, fmt, ap);
@@ -213,8 +222,8 @@ typedef struct {
bool is_str; /* ScrStr vs ScrBytes — picks the release */
} ScrCoreArenaEnt;
static ScrCoreArenaEnt *scr_library_arena = NULL;
static size_t scr_library_arena_n = 0, scr_library_arena_cap = 0;
static SCR_TL ScrCoreArenaEnt *scr_library_arena = NULL;
static SCR_TL size_t scr_library_arena_n = 0, scr_library_arena_cap = 0;
static void scr_library_arena_keep(void *v, bool is_str) {
if (scr_library_arena_n == scr_library_arena_cap) {
@@ -296,8 +305,8 @@ void scr_library_bytes_out(ScrBytes *b, const uint8_t **out, size_t *out_len) {
* satisfied because the entry persists. */
#define SCR_LIB_MAX_RESETS 32
static void (*scr_library_resets[SCR_LIB_MAX_RESETS])(void);
static size_t scr_library_nresets = 0;
static SCR_TL void (*scr_library_resets[SCR_LIB_MAX_RESETS])(void);
static SCR_TL size_t scr_library_nresets = 0;
void scr_library_register_reset(void (*fn)(void)) {
for (size_t i = 0; i < scr_library_nresets; i++) {
+1 -1
View File
@@ -24,7 +24,7 @@
/* Live heap-map count for the RC audit lane (-DSCR_RC_AUDIT); same contract
* as scr_str_live_count in scr_string.c. */
#ifdef SCR_RC_AUDIT
static long scr_live_maps = 0;
static SCR_TL long scr_live_maps = 0;
long scr_map_live_count(void) { return scr_live_maps; }
#endif
+1 -1
View File
@@ -33,7 +33,7 @@ void scr_record_key_miss(ScrStr *k) {
}
#ifdef SCR_RC_AUDIT
static long scr_live_objects = 0;
static SCR_TL long scr_live_objects = 0;
long scr_obj_live_count(void) { return scr_live_objects; }
void scr_obj_alloc_note(void) { scr_live_objects++; }
void scr_obj_free_note(void) { scr_live_objects--; }
+2 -2
View File
@@ -82,8 +82,8 @@ void *lre_realloc(void *opaque, void *ptr, size_t size) {
* allocation audit sees the bytecode gone).
*/
static ScrRegex **scr_compiled = NULL;
static size_t scr_compiled_len = 0, scr_compiled_cap = 0;
static SCR_TL ScrRegex **scr_compiled = NULL;
static SCR_TL size_t scr_compiled_len = 0, scr_compiled_cap = 0;
static void scr_regex_free_bytecodes(void) {
for (size_t i = 0; i < scr_compiled_len; i++) {
+22 -4
View File
@@ -29,6 +29,24 @@ char *strcasestr(const char *hay, const char *needle);
void arc4random_buf(void *buf, size_t n);
#endif
/* ── thread-instanced library state ─────────────────────────────────────
* Archives built under the profile's abi.instance_per_thread compile every
* TU with -DSCR_THREAD_INSTANCES, and SCR_TL moves each unit's mutable
* state — and the emitted program's globals — into thread-local storage.
* A thread that calls the profile's init entry then owns a complete,
* independent instance: its own collector, result arena, panic-sink
* registration, poison flag, and program state. The instance's lifetime is
* the thread's; the one-thread-per-instance contract (an instance is never
* entered from two threads) is unchanged — this mode adds instances, not
* thread awareness. Expands to nothing everywhere else, so executable
* builds and classic library builds carry the exact bytes they always
* carried. Truly immutable tables (static const data) stay shared. */
#if defined(SCR_LIB) && defined(SCR_THREAD_INSTANCES)
#define SCR_TL _Thread_local
#else
#define SCR_TL
#endif
/* ── process ──────────────────────────────────────────────────────────── */
/* Called once at the top of main: private stdout formatter buffer,
@@ -445,7 +463,7 @@ enum {
SCR_ERR_DOMEX = 4, /* DOMException — ScrDomException, the wider layout */
};
extern ScrVt scr_error_vts[5]; /* indexed by SCR_ERR_*; main() stamps pre/post */
extern SCR_TL ScrVt scr_error_vts[5]; /* indexed by SCR_ERR_*; main() stamps pre/post */
struct ScrDyn; /* full declaration below (the checked-dynamic tree section) */
@@ -1373,8 +1391,8 @@ typedef struct ScrEmitter {
const char *cls; /* display name for the leak warning ("EventEmitter") */
} ScrEmitter;
extern ScrVt scr_emitter_vt; /* main() stamps pre/post */
extern double scr_emitter_default_max;
extern SCR_TL ScrVt scr_emitter_vt; /* main() stamps pre/post */
extern SCR_TL double scr_emitter_default_max;
ScrEmitter *scr_emitter_new(void); /* +1, collector-headered */
void scr_emitter_init(void *obj); /* super() into the prefix (no-op today) */
@@ -1976,7 +1994,7 @@ bool scr_process_kill_named(double pid, const ScrStr *signal);
void scr_process_exit(double code);
/* process._exiting: true once the exit sequence began (process.exit or
* the exit-listener runner set the flag). Never throws. */
extern bool scr_process_in_exit;
extern SCR_TL bool scr_process_in_exit;
bool scr_process_exiting(void);
/* umask(2): mask < 0 reads without setting; otherwise sets and answers
* the previous mask. Never throws. */
+4 -4
View File
@@ -10,7 +10,7 @@
* leaks nothing and frees nothing twice (double-free shows up as ASan
* use-after-free on the rc field or a negative count here). */
#ifdef SCR_RC_AUDIT
static long scr_live_strings = 0;
static SCR_TL long scr_live_strings = 0;
long scr_str_live_count(void) { return scr_live_strings; }
#endif
@@ -41,8 +41,8 @@ typedef struct {
size_t u16len; /* SCR_U16_UNKNOWN until computed */
size_t cu, cb; /* cursor: byte offset cb starts the char at unit cu */
} ScrSidx;
static ScrSidx scr_sidx_tab[SCR_SIDX_N];
static unsigned scr_sidx_clock;
static SCR_TL ScrSidx scr_sidx_tab[SCR_SIDX_N];
static SCR_TL unsigned scr_sidx_clock;
static void scr_sidx_purge(const ScrStr *s) {
for (int i = 0; i < SCR_SIDX_N; i++) {
@@ -90,7 +90,7 @@ ScrStr *scr_str_new(const char *bytes, size_t len) {
* blocks instead of paging in fresh zero-filled memory 40k times. Disabled
* in the audit lane so ASan sees every logical free as a real free. */
#ifndef SCR_RC_AUDIT
static ScrStr *scr_str_spare;
static SCR_TL ScrStr *scr_str_spare;
#endif
/* A spare-block reuse must not waste grossly (cap <= 4x the need) and only
+1 -1
View File
@@ -55,7 +55,7 @@ ScrSym *scr_sym_new(ScrStr *desc) {
/* ── the Symbol.for global registry ──────────────────────────────────── */
static ScrSym *g_sym_registry = NULL;
static SCR_TL ScrSym *g_sym_registry = NULL;
static void scr_sym_registry_cleanup(void) {
ScrSym *s = g_sym_registry;
+1 -1
View File
@@ -15,7 +15,7 @@
/* Live union count for the RC audit lane (-DSCR_RC_AUDIT); same contract
* as scr_str_live_count in scr_string.c. */
#ifdef SCR_RC_AUDIT
static long scr_live_unions = 0;
static SCR_TL long scr_live_unions = 0;
long scr_union_live_count(void) { return scr_live_unions; }
#endif
+3
View File
@@ -126,6 +126,9 @@ const hostLaneContractPattern = [
"deep island recursion on a fiber is a catchable RangeError",
"M1: external definitions equal the declared set exactly",
"M2: independent state and collects",
"M6: four threads, one archive",
"M7: thread-instanced and runtime-localized archives compose",
"M8: M6 under ASan",
"net-echo",
"udp-loopback-pair",
"1564-fs-watch.ts",
+240 -8
View File
@@ -9,7 +9,7 @@
*
* 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
* 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
@@ -24,6 +24,35 @@
* 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
*
* Thread-instanced state (the profile's abi.instance_per_thread): every
* mutable static in the archive — runtime internals, module globals,
* run-once guards, regex literal caches — compiles as thread-local
* storage, so ONE linked archive serves one independent instance per
* embedder thread through the unchanged entry family (the calling thread
* is the instance selector).
*
* M6 four threads one archive, four embedder threads with distinct
* workloads: concurrent instance-local inits,
* independent state and collects, a deliberate trap
* on thread 0 delivered to ITS sink exactly once
* (SC4014, mt_boom, its ctx) poisoning only its
* instance while the other three keep answering
* through and after the trap window; sanitized in
* the SCRIPTC_SAN=1 flavor like M1/M2
* M6 inspect TLS deterministic two-thread runtime seam proving
* circular-target pointers cannot cross instances
* M7 composition a thread-instanced AND runtime-localized archive
* coexists with a second different-prefix localized
* archive in one process (both mechanisms at once);
* the localized link surface stays exactly the
* declared set, with M1's one ASan
* image-registration common in a sanitized build
* M8 sanitized rerun M6 re-run explicitly under ASan (the K10
* precedent: the plain flavor carries an
* instrumented pairing too)
* M9 profile shape abi.instance_per_thread is strictly boolean
* (SC4001)
*/
import { execFileSync, spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -120,14 +149,14 @@ describe.each(EMISSIONS)("localized archive symbols, %s emission", (emission) =>
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
// ASan's image-wide registration guard is the sole sanitized
// exception: keeping its COMMON shared makes the final image
// register its ASan globals exactly once when N archives contribute
// module constructors.
const toolchainDefinitions =
sanitize && process.platform === "darwin"
? ["___asan_globals_registered"]
: [];
// module constructors — Mach-O and ELF spell the same discipline
// with one underscore of decoration between them.
const toolchainDefinitions = sanitize
? [process.platform === "darwin" ? "___asan_globals_registered" : "__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
@@ -354,3 +383,206 @@ exec "$SCRIPTC_TEST_REAL_AR" "$@"
}
},
);
/* ── M6/M7/M8: thread-instanced state (abi.instance_per_thread) ──────────── */
const threadFixtureDir = join(repoRoot, "tests/library-mode/thread-instances");
/** Build the thread-instances fixture's archive for one emission: same
* patch-and-compile shape as buildInstance, plus abi overrides (M7 turns
* localize_runtime on) and an explicit sanitize override (M8's ASan
* pairing inside the plain flavor). Memoized like buildInstance. */
function buildThreaded(
emission: Emission,
opts: { localize?: boolean; sanitize?: boolean } = {},
): Promise<string> {
const sanitized = opts.sanitize ?? sanitize;
const key = `t-${emission}${opts.localize === true ? "-loc" : ""}${sanitized ? "-san" : ""}`;
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(threadFixtureDir, "profile_t.json"), "utf8")) as {
entry: string;
emission: string;
abi: Record<string, unknown>;
};
profile.emission = emission;
profile.entry = join(threadFixtureDir, profile.entry);
if (opts.localize === true) profile.abi["localize_runtime"] = true;
const profilePath = join(outDir, "profile.json");
writeFileSync(profilePath, JSON.stringify(profile, null, 2));
const result = await compileLibrary({ profilePath, outDir, sanitize: sanitized });
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;
}
function buildThreadProbe(
source: string,
archives: string[],
tag: string,
opts: { sanitize?: boolean } = {},
): string {
const outDir = join(cacheDir, "probes");
const bin = join(outDir, `probe-${tag}`);
mkdirSync(outDir, { recursive: true });
execFileSync("clang", [
"-std=c11",
"-pthread",
...((opts.sanitize ?? sanitize) ? ["-fsanitize=address"] : []),
source,
...archives,
"-lm",
"-o", bin,
]);
return bin;
}
const THREADED_EXPECTED = `t0: bump x100 -> 101, calls_seen 100, sums_ok=1, clocks_ok=1, trap fell through 0
t0 sink: calls=1 ctx_ok=1 fields=3 code=[SC4014] symbol=[mt_boom] addr_nonzero=1
t1: bump x150 -> 151, calls_seen 150, sums_ok=1, clocks_ok=1, post_ok=1
t2: bump x200 -> 201, calls_seen 200, sums_ok=1, clocks_ok=1, post_ok=1
t3: bump x250 -> 251, calls_seen 250, sums_ok=1, clocks_ok=1, post_ok=1
survivor sinks: 0 0 0
`;
describe.each(EMISSIONS)("thread-instanced archive, %s emission", (emission) => {
localizationTest("M6: four threads, one archive: independent instances; a trap reaches only its own thread's sink, once", async () => {
const archive = await buildThreaded(emission);
const probe = buildThreadProbe(join(threadFixtureDir, "probe.c"), [archive], `t-${emission}`);
const run = spawnSync(probe, { encoding: "utf8", timeout: 60_000 });
expect(run.signal).toBeNull();
expect(run.status).toBe(0);
expect(run.stdout).toBe(THREADED_EXPECTED);
});
});
localizationTest("M6: util.inspect circular-reference state is thread-local", () => {
const outDir = join(cacheDir, "inspect-tls-probe");
const bin = join(outDir, "probe");
mkdirSync(outDir, { recursive: true });
execFileSync("clang", [
"-std=c11",
"-pthread",
"-DSCR_LIB",
"-DSCR_THREAD_INSTANCES",
"-ffunction-sections",
"-fdata-sections",
"-Wno-comment",
...(sanitize ? ["-fsanitize=address"] : []),
"-I", join(repoRoot, "packages/runtime/src"),
join(threadFixtureDir, "probe_inspect.c"),
join(repoRoot, "packages/runtime/src/scr_inspect.c"),
process.platform === "darwin" ? "-Wl,-dead_strip" : "-Wl,--gc-sections",
"-o", bin,
]);
const env =
sanitize && process.platform === "linux"
? { ...process.env, ASAN_OPTIONS: "detect_leaks=0" }
: process.env;
const run = spawnSync(bin, { encoding: "utf8", timeout: 60_000, env });
expect(run.signal).toBeNull();
expect(run.status).toBe(0);
expect(run.stdout).toBe("1 1 1\n");
});
localizationTest("M7: thread-instanced and runtime-localized archives compose in one process", async () => {
const [archiveT, archiveB] = await Promise.all([
buildThreaded("llvm", { localize: true }),
buildInstance("b", "c"),
]);
// The composed archive's link surface stays exactly the declared set:
// thread-local storage adds no external definitions (M1's one Darwin
// ASan image-registration common included in a sanitized build), and the
// TLS access machinery undefineds are the platform runtime's, never
// scriptc's.
const { defined, undef } = nmSymbols(archiveT);
const toolchainDefinitions = sanitize
? [process.platform === "darwin" ? "___asan_globals_registered" : "__asan_globals_registered"]
: [];
expect([...defined].sort()).toEqual(
[
"mt_boom", "mt_bump", "mt_calls_seen", "mt_collect", "mt_init", "mt_perf_now", "mt_set_panic_sink", "mt_sum_to", "mt_uptime",
...toolchainDefinitions,
].sort(),
);
expect([...undef].filter((s) => s.startsWith("scr_") || s.startsWith("mt_") || s.startsWith("mb_"))).toEqual([]);
const probe = buildThreadProbe(join(threadFixtureDir, "probe_pair.c"), [archiveT, archiveB], "t-pair");
const run = spawnSync(probe, { encoding: "utf8", timeout: 60_000 });
expect(run.signal).toBeNull();
expect(run.status).toBe(0);
expect(run.stdout).toBe(`multi-b ready
t0: bump x100 -> 101, calls_seen 100, sums_ok=1, trap fell through 0
t0 sink: calls=1 ctx_ok=1 code=[SC4014] symbol=[mt_boom]
t1: bump x200 -> 201, calls_seen 200, sums_ok=1, post_ok=1
b: sums_ok=1 adds_ok=1 post_ok=1
other sinks: t1=0 b=0
`);
});
localizationTest("M8: M6 under ASan", async () => {
const archive = await buildThreaded("llvm", { sanitize: true });
const probe = buildThreadProbe(join(threadFixtureDir, "probe.c"), [archive], "t-asan", { sanitize: true });
// An instance's lifetime is its thread's, with no teardown at thread
// exit (the documented contract) — once the worker threads end, their
// thread-local roots are gone and Linux LSan's unreachable-at-exit
// accounting would flag contractually-held state. Point it away exactly
// as the sanitized suite lanes do; Apple ASan carries no leak checker.
const env =
process.platform === "linux" ? { ...process.env, ASAN_OPTIONS: "detect_leaks=0" } : process.env;
const run = spawnSync(probe, { encoding: "utf8", timeout: 120_000, env });
expect(run.signal).toBeNull();
expect(run.status).toBe(0);
expect(run.stdout).toBe(THREADED_EXPECTED);
});
/* ── M9: profile shape ───────────────────────────────────────────────────── */
test("M9: abi.instance_per_thread is strictly boolean", () => {
const dir = join(cacheDir, "profile-shape-thread");
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,
},
exports: [],
};
for (const invalid of [1, "yes", null] as const) {
writeFileSync(path, JSON.stringify({
...base,
abi: { ...base.abi, instance_per_thread: 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.instance_per_thread");
}
}
// 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) abi["instance_per_thread"] = value;
writeFileSync(path, JSON.stringify({ ...base, abi }));
const loaded = loadLibraryProfile(path);
expect(loaded.ok).toBe(true);
if (loaded.ok) expect(loaded.profile.instancePerThread).toBe(expected);
}
});
@@ -0,0 +1,39 @@
// Thread-instanced fixture (prefix mt_): mutable module state each embedder
// thread's instance advances independently, allocation-heavy work that
// churns each instance's own collector, and a deliberately trapping export
// (array index OOB — the runtime's own range trap) that must reach only the
// calling thread's sink. No top-level output: four instances init
// concurrently in the probe.
import { performance } from "node:perf_hooks";
let calls = 0;
export function bump(x: number): number {
calls++;
return x + calls;
}
export function callsSeen(): number {
return calls;
}
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 boom(i: number): number {
const xs = [1, 2, 3];
return xs[i]!;
}
export function uptime(): number {
return process.uptime();
}
export function perfNow(): number {
return performance.now();
}
+183
View File
@@ -0,0 +1,183 @@
/* Thread-instanced acceptance probe: ONE archive (prefix mt_, built with
* abi.instance_per_thread), FOUR embedder threads. Each thread registers
* its own sink, calls the init entry (concurrently — init touches only the
* calling thread's instance), and runs an allocation-heavy loop of a
* DIFFERENT length, so per-thread call counters prove instance
* independence numerically. Collects run per instance. After every fixed
* loop finishes, thread 0 takes a deliberate range trap: its sink fires
* exactly once with the structured message (SC4014, mt_boom) and its ctx,
* poisoning only ITS instance, while threads 1..3 keep looping through
* the trap window and answer again afterwards with exact values. Workers
* record results; main prints after all 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 mt_init(void);
extern void mt_set_panic_sink(void (*fn)(void *, const uint8_t *, size_t, uint64_t), void *ctx);
extern void mt_collect(void);
extern double mt_bump(double x);
extern double mt_calls_seen(void);
extern double mt_sum_to(double n);
extern double mt_boom(double i);
extern double mt_uptime(void);
extern double mt_perf_now(void);
#define NTHREADS 4
/* Stages: 0 running fixed loops; 1 all fixed loops done (thread 0 traps,
* the rest keep looping); 2 trap delivered (survivors answer once more and
* exit). */
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 int stage_get(void) {
pthread_mutex_lock(&mu);
int s = stage;
pthread_mutex_unlock(&mu);
return s;
}
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 fixed_loop_done(void) {
pthread_mutex_lock(&mu);
fixed_loops_done++;
if (fixed_loops_done == NTHREADS && stage < 1) {
stage = 1;
pthread_cond_broadcast(&cv);
}
pthread_mutex_unlock(&mu);
}
typedef struct {
int calls;
int ctx_ok;
int fields;
int addr_nonzero;
char code[32];
char symbol[64];
} SinkRec;
typedef struct {
int id;
int iters; /* fixed-loop length: distinct per thread */
SinkRec sink;
double last_bump;
double calls_seen;
int sums_ok;
int clocks_ok;
int trap_fell_through; /* thread 0 only */
int post_ok; /* threads 1..3 only */
} Worker;
static Worker workers[NTHREADS];
static jmp_buf trap_jmp; /* thread 0 is the only trapping thread */
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 sink(void *ctx, const uint8_t *msg, size_t len, uint64_t addr) {
SinkRec *r = ctx;
r->calls++;
r->addr_nonzero = addr != 0;
r->ctx_ok = (r == &workers[0].sink);
if (len > 0 && msg[0] == 0x01) {
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;
}
if (r == &workers[0].sink) longjmp(trap_jmp, 1); /* the conforming survival pattern */
/* Any other sink firing is a routing failure; returning aborts, and the
* missing output shows it. */
}
static void *worker(void *arg) {
Worker *w = arg;
mt_set_panic_sink(sink, &w->sink);
mt_init(); /* concurrent across all four threads: each init is instance-local */
double uptime = mt_uptime();
double perf_now = mt_perf_now();
w->clocks_ok = uptime >= 0 && uptime < 60 && perf_now >= 0 && perf_now < 60000;
w->sums_ok = 1;
double last = 0;
for (int i = 0; i < w->iters; i++) {
last = mt_bump(1);
if (mt_sum_to(100) != 5050.0) w->sums_ok = 0;
if ((i + 1) % 25 == 0) mt_collect();
}
w->last_bump = last;
w->calls_seen = mt_calls_seen();
fixed_loop_done();
stage_wait(1);
if (w->id == 0) {
if (setjmp(trap_jmp) == 0) {
mt_boom(9); /* xs[9] of a length-3 array: the runtime's range trap */
w->trap_fell_through = 1;
}
stage_set(2);
return NULL; /* this instance is poisoned; no further entries on this thread */
}
/* Survivors keep answering through the trap window... */
while (stage_get() < 2) {
if (mt_sum_to(100) != 5050.0) w->sums_ok = 0;
}
/* ...and after it: exact per-instance state advances. */
double post_sum = mt_sum_to(10);
double post_bump = mt_bump(1);
double post_calls = mt_calls_seen();
mt_collect();
w->post_ok = post_sum == 55.0 && post_bump == 1.0 + (w->iters + 1) && post_calls == (double)(w->iters + 1);
return NULL;
}
int main(void) {
pthread_t threads[NTHREADS];
for (int i = 0; i < NTHREADS; i++) {
workers[i].id = i;
workers[i].iters = 100 + 50 * i; /* 100 / 150 / 200 / 250 */
pthread_create(&threads[i], NULL, worker, &workers[i]);
}
for (int i = 0; i < NTHREADS; i++) pthread_join(threads[i], NULL);
for (int i = 0; i < NTHREADS; i++) {
Worker *w = &workers[i];
printf("t%d: bump x%d -> %.0f, calls_seen %.0f, sums_ok=%d, clocks_ok=%d", i, w->iters, w->last_bump, w->calls_seen, w->sums_ok, w->clocks_ok);
if (i == 0) {
printf(", trap fell through %d\n", w->trap_fell_through);
printf("t0 sink: calls=%d ctx_ok=%d fields=%d code=[%s] symbol=[%s] addr_nonzero=%d\n",
w->sink.calls, w->sink.ctx_ok, w->sink.fields, w->sink.code, w->sink.symbol, w->sink.addr_nonzero);
} else {
printf(", post_ok=%d\n", w->post_ok);
}
}
printf("survivor sinks: %d %d %d\n", workers[1].sink.calls, workers[2].sink.calls, workers[3].sink.calls);
return 0;
}
@@ -0,0 +1,67 @@
/* Deterministic regression for util.inspect's thread-instanced circular
* target map. The inspect stack and target count were already TLS; the
* target pointer array must be TLS too. Thread A records its first target,
* thread B then records a different target in its own slot zero, and A
* must still resolve its target as circular id 1. A shared backing array
* makes A lose that entry and assign id 2 instead. */
#include <pthread.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
extern void scr_insp_seen_push(const void *v);
extern double scr_insp_circ_check(const void *v);
/* The retained inspect sections only need the OOM trap path. */
_Noreturn void scr_trap(const char *msg) {
fputs(msg, stderr);
abort();
}
static pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t cv = PTHREAD_COND_INITIALIZER;
static int stage;
static int targets[2];
static double ids[3];
static void advance(int next) {
pthread_mutex_lock(&mu);
stage = next;
pthread_cond_broadcast(&cv);
pthread_mutex_unlock(&mu);
}
static void await(int expected) {
pthread_mutex_lock(&mu);
while (stage < expected) pthread_cond_wait(&cv, &mu);
pthread_mutex_unlock(&mu);
}
static void *worker_a(void *unused) {
(void)unused;
scr_insp_seen_push(&targets[0]);
ids[0] = scr_insp_circ_check(&targets[0]);
advance(1);
await(2);
ids[2] = scr_insp_circ_check(&targets[0]);
return NULL;
}
static void *worker_b(void *unused) {
(void)unused;
await(1);
scr_insp_seen_push(&targets[1]);
ids[1] = scr_insp_circ_check(&targets[1]);
advance(2);
return NULL;
}
int main(void) {
pthread_t a, b;
pthread_create(&a, NULL, worker_a, NULL);
pthread_create(&b, NULL, worker_b, NULL);
pthread_join(a, NULL);
pthread_join(b, NULL);
printf("%.0f %.0f %.0f\n", ids[0], ids[1], ids[2]);
return ids[0] == 1 && ids[1] == 1 && ids[2] == 1 ? 0 : 1;
}
@@ -0,0 +1,205 @@
/* Composition probe: a thread-instanced AND runtime-localized archive
* (prefix mt_) coexists with a second, different-prefix runtime-localized
* archive (mb_) in ONE process. Two threads drive two mt_ instances; a
* third drives the mb_ instance. Thread t0's deliberate trap reaches only
* t0's sink, exactly once; the sibling mt_ instance AND the mb_ archive
* keep answering through and after the trap window. Workers record
* results; main prints after all joins. */
#include <pthread.h>
#include <setjmp.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
extern void mt_init(void);
extern void mt_set_panic_sink(void (*fn)(void *, const uint8_t *, size_t, uint64_t), void *ctx);
extern void mt_collect(void);
extern double mt_bump(double x);
extern double mt_calls_seen(void);
extern double mt_sum_to(double n);
extern double mt_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);
/* Stages: 0 fixed loops; 1 all three done (t0 traps, others keep looping);
* 2 trap delivered (survivors answer once more and exit). The mb_ archive's
* init prints "multi-b ready", sequenced FIRST so stdout stays
* deterministic (the mt_ fixture's init prints nothing). */
static pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t cv = PTHREAD_COND_INITIALIZER;
static int stage = 0;
static int b_ready = 0;
static int fixed_loops_done = 0;
static int stage_get(void) {
pthread_mutex_lock(&mu);
int s = stage;
pthread_mutex_unlock(&mu);
return s;
}
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 b_ready_set(void) {
pthread_mutex_lock(&mu);
b_ready = 1;
pthread_cond_broadcast(&cv);
pthread_mutex_unlock(&mu);
}
static void b_ready_wait(void) {
pthread_mutex_lock(&mu);
while (!b_ready) 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 == 3 && stage < 1) {
stage = 1;
pthread_cond_broadcast(&cv);
}
pthread_mutex_unlock(&mu);
}
typedef struct {
int calls;
int ctx_ok;
char code[32];
char symbol[64];
} SinkRec;
static SinkRec sink_t0, sink_t1, sink_b;
static jmp_buf trap_jmp;
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 sink(void *ctx, const uint8_t *msg, size_t len, uint64_t addr) {
SinkRec *r = ctx;
(void)addr;
r->calls++;
r->ctx_ok = (r == &sink_t0);
if (len > 0 && msg[0] == 0x01) {
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;
}
}
if (r == &sink_t0) longjmp(trap_jmp, 1);
}
typedef struct {
int id; /* 0 = trapping mt_ thread, 1 = surviving mt_ thread */
int iters;
double last_bump;
double calls_seen;
int sums_ok;
int trap_fell_through;
int post_ok;
} TWorker;
static TWorker t0 = { 0, 100, 0, 0, 0, 0, 0 };
static TWorker t1 = { 1, 200, 0, 0, 0, 0, 0 };
static void *worker_t(void *arg) {
TWorker *w = arg;
mt_set_panic_sink(sink, w->id == 0 ? &sink_t0 : &sink_t1);
b_ready_wait(); /* mb_'s init already printed; mt_ inits print nothing */
mt_init();
w->sums_ok = 1;
double last = 0;
for (int i = 0; i < w->iters; i++) {
last = mt_bump(1);
if (mt_sum_to(100) != 5050.0) w->sums_ok = 0;
if ((i + 1) % 25 == 0) mt_collect();
}
w->last_bump = last;
w->calls_seen = mt_calls_seen();
fixed_loop_done();
stage_wait(1);
if (w->id == 0) {
if (setjmp(trap_jmp) == 0) {
mt_boom(9);
w->trap_fell_through = 1;
}
stage_set(2);
return NULL; /* poisoned instance: no further entries on this thread */
}
while (stage_get() < 2) {
if (mt_sum_to(100) != 5050.0) w->sums_ok = 0;
}
double post_sum = mt_sum_to(10);
double post_bump = mt_bump(1);
w->post_ok = post_sum == 55.0 && post_bump == 1.0 + (w->iters + 1) && mt_calls_seen() == (double)(w->iters + 1);
return NULL;
}
static int b_sums_ok = 1, b_adds_ok = 1, b_post_ok = 0;
static void *worker_b(void *arg) {
(void)arg;
mb_set_panic_sink(sink, &sink_b);
mb_init(); /* prints "multi-b ready" before anything else runs */
b_ready_set();
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 == 150) fixed_loop_done();
if (iters >= 150 && stage_get() >= 2) break;
}
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, tc;
pthread_create(&ta, NULL, worker_t, &t0);
pthread_create(&tb, NULL, worker_t, &t1);
pthread_create(&tc, NULL, worker_b, NULL);
pthread_join(ta, NULL);
pthread_join(tb, NULL);
pthread_join(tc, NULL);
printf("t0: bump x%d -> %.0f, calls_seen %.0f, sums_ok=%d, trap fell through %d\n",
t0.iters, t0.last_bump, t0.calls_seen, t0.sums_ok, t0.trap_fell_through);
printf("t0 sink: calls=%d ctx_ok=%d code=[%s] symbol=[%s]\n", sink_t0.calls, sink_t0.ctx_ok, sink_t0.code, sink_t0.symbol);
printf("t1: bump x%d -> %.0f, calls_seen %.0f, sums_ok=%d, post_ok=%d\n",
t1.iters, t1.last_bump, t1.calls_seen, t1.sums_ok, t1.post_ok);
printf("b: sums_ok=%d adds_ok=%d post_ok=%d\n", b_sums_ok, b_adds_ok, b_post_ok);
printf("other sinks: t1=%d b=%d\n", sink_t1.calls, sink_b.calls);
return 0;
}
@@ -0,0 +1,23 @@
{
"profile_format": 1,
"name": "conformance-thread-instances",
"entry": "lib.ts",
"emission": "llvm",
"abi": {
"prefix": "mt_",
"init_symbol": "mt_init",
"sink_register_symbol": "mt_set_panic_sink",
"collect_symbol": "mt_collect",
"result_reset_symbol": null,
"localize_runtime": false,
"instance_per_thread": true
},
"exports": [
{ "export": "bump", "symbol": "mt_bump", "params": ["f64"], "returns": "f64" },
{ "export": "callsSeen", "symbol": "mt_calls_seen", "params": [], "returns": "f64" },
{ "export": "sumTo", "symbol": "mt_sum_to", "params": ["f64"], "returns": "f64" },
{ "export": "boom", "symbol": "mt_boom", "params": ["f64"], "returns": "f64" },
{ "export": "uptime", "symbol": "mt_uptime", "params": [], "returns": "f64" },
{ "export": "perfNow", "symbol": "mt_perf_now", "params": [], "returns": "f64" }
]
}