From 55a69232b765ea4470dd111a9502478e54c11349 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 18:49:55 -0700 Subject: [PATCH 01/18] Exit cleanly with Node loaded and keep nested entries on Node's stack (NI-7c, NI-3b) Node islands stack, part 8 of 11 (scriptc-perf/results/islands-prs/PLAN.md). Co-Authored-By: Claude Opus 5.5 --- .../main.ts.json | 6 + .../libnode-common/bridge/scr_node_bridge.cc | 69 ++++++++++- packages/runtime/src/scr_async.c | 20 ++++ packages/runtime/src/scr_node_island.c | 108 +++++++++++++++++- packages/runtime/src/scr_node_island.h | 5 + packages/runtime/src/scr_node_island_handle.c | 4 + .../6870-node-island-server-fibers/main.ts | 91 +++++++++++++++ .../node_modules/server-kit/index.d.ts | 14 +++ .../node_modules/server-kit/index.js | 53 +++++++++ .../node_modules/server-kit/package.json | 1 + .../package.json | 1 + tests/fixtures/node-island/exit-tls.cjs | 16 +++ tests/fixtures/node-island/exit-tls.mjs | 20 ++++ tests/harness/lsan.supp | 21 ++++ tests/harness/node-island-exit.test.ts | 87 ++++++++++++++ 15 files changed, 508 insertions(+), 8 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6870-node-island-server-fibers/main.ts.json create mode 100644 tests/corpus/6870-node-island-server-fibers/main.ts create mode 100644 tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/index.d.ts create mode 100644 tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/index.js create mode 100644 tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/package.json create mode 100644 tests/corpus/6870-node-island-server-fibers/package.json create mode 100644 tests/fixtures/node-island/exit-tls.cjs create mode 100644 tests/fixtures/node-island/exit-tls.mjs create mode 100644 tests/harness/node-island-exit.test.ts diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6870-node-island-server-fibers/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6870-node-island-server-fibers/main.ts.json new file mode 100644 index 000000000..0d9124d48 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6870-node-island-server-fibers/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6870-node-island-server-fibers/main.ts" + ], + "diags": [] +} diff --git a/packages/libnode-common/bridge/scr_node_bridge.cc b/packages/libnode-common/bridge/scr_node_bridge.cc index 584325d50..d63a3b6f1 100644 --- a/packages/libnode-common/bridge/scr_node_bridge.cc +++ b/packages/libnode-common/bridge/scr_node_bridge.cc @@ -183,6 +183,26 @@ bool TakeFunction(Isolate *isolate, Local context, Local from, return true; } +// OpenSSL registers OPENSSL_cleanup with atexit on its first +// initialization. In a scriptc program that loaded Node, threads Node owns +// can still be inside OpenSSL when the process's exit handlers run: the +// threads node:tls starts to parse the root certificates (long with +// NODE_USE_SYSTEM_CA=1; RunLoop waits for them, but an exit that does not +// end the loop does not) are joined only by Node's per-process teardown, +// which cannot run here (see Stop). OPENSSL_cleanup +// freed OpenSSL's locks and method stores under them (SIGSEGV/SIGABRT at +// exit in up to half the runs, NI-7c). Initializing OpenSSL first with +// OPENSSL_INIT_NO_ATEXIT keeps that handler from being registered: OpenSSL's +// process-lifetime state is left to process exit, like the rest of Node's +// per-process state. Packs whose libnode does not export the symbol keep +// OpenSSL's default. +void KeepOpenSSLAtExit() { + using InitCrypto = int (*)(uint64_t, const void *); + constexpr uint64_t kNoAtExit = 0x00080000L; // OPENSSL_INIT_NO_ATEXIT (OpenSSL 3) + auto init = reinterpret_cast(dlsym(RTLD_DEFAULT, "OPENSSL_init_crypto")); + if (init != nullptr) init(kNoAtExit, nullptr); +} + char *Start(const ScrNodeBridgeHost *host, const char *bootstrap, const char *argv0, uintptr_t stack_limit) { if (g_setup) return nullptr; @@ -192,6 +212,7 @@ char *Start(const ScrNodeBridgeHost *host, const char *bootstrap, const char *ar Dl_info info; if (dladdr(reinterpret_cast(&napi_create_function), &info) && info.dli_fname) dlopen(info.dli_fname, RTLD_NOW | RTLD_NOLOAD | RTLD_GLOBAL); + KeepOpenSSLAtExit(); std::vector args = {argv0}; auto result = node::InitializeOncePerProcess( args, {node::ProcessInitializationFlags::kNoInitializeV8, @@ -390,10 +411,18 @@ void OnCap(uv_timer_t *) { SyncIo(SCR_NODE_IO_JOBS); } -void OnPrepare(uv_prepare_t *) { SyncIo(SCR_NODE_IO_PREPARE); } +// Before each poll phase, while the runtime has live work: one pass over +// its dispatch stations, as the runtime's own loop makes one per turn. +// Some of their work no fd announces (a server's deferred 'listening' or +// 'close', a socket's queued writes), so waiting for an fd could block the +// loop forever with that work pending. +void OnPrepare(uv_prepare_t *handle) { + if (uv_has_ref(reinterpret_cast(handle))) Dispatch(); + SyncIo(SCR_NODE_IO_PREPARE); +} // The end of an iteration: the next one may dispatch again (from its idle -// phase when work is ready, else from its poll phase). +// or prepare phase, else from its poll phase). void OnCheck(uv_check_t *) { g_dispatched = false; SyncIo(SCR_NODE_IO_CHECK); @@ -573,6 +602,29 @@ void Checkpoint(int microtasks_first) { node::CallbackScope scope(e.isolate, Object::New(e.isolate), node::async_context{0, 0}); } +// Loading node:tls starts threads that parse the root certificates into +// function-local statics (bundled, NODE_EXTRA_CA_CERTS, and the system +// store with NODE_USE_SYSTEM_CA=1, which takes long). Only Node's +// CleanupCachedRootCertificates joins them. Once exit() begins, the +// statics' destructors (registered when the threads initialize them, so +// they run before any handler the program registered earlier) free the +// vectors under a thread that is still parsing, and OpenSSL's state goes +// away under it: heap corruption and SIGSEGV/SIGABRT at exit (NI-7c). So +// when the loop is done, before the program returns, the bridge waits for +// them: building a root store reads the same statics under the same +// conditions, and initializing a static blocks until the thread that runs +// it is done. A store built earlier means the statics were initialized +// then. Programs that never loaded node:tls do nothing here. +void AwaitCertificateLoading(Enter &e) { + TryCatch try_catch(e.isolate); + static const char kSource[] = + "(() => { try { if (process.moduleLoadList.includes('NativeModule tls'))" + " process.getBuiltinModule('node:tls').createSecureContext(); } catch {} })()"; + Local script; + if (v8::Script::Compile(e.context, Utf8(e.isolate, kSource)).ToLocal(&script)) + (void)script->Run(e.context); +} + int RunLoop(int continue_turn) { Enter e; // The rest of the turn the switch interrupted: libuv's UV_RUN_ONCE skips @@ -581,7 +633,9 @@ int RunLoop(int continue_turn) { uv_run(g_setup->event_loop(), UV_RUN_ONCE); g_platform->DrainTasks(e.isolate); } - return node::SpinEventLoop(g_setup->env()).FromMaybe(0); + int code = node::SpinEventLoop(g_setup->env()).FromMaybe(0); + AwaitCertificateLoading(e); + return code; } // An island listener of a process event: through MakeCallback, so a throw @@ -610,9 +664,16 @@ void Stop() { delete g_locker; g_locker = nullptr; g_setup.reset(); + // As Node's own exit does (DefaultProcessExitHandlerInternal): join + // libuv's thread pool, whose work (pbkdf2, key generation, ...) may be + // inside OpenSSL, before the process's exit handlers clean OpenSSL up. + uv_library_shutdown(); v8::V8::Dispose(); v8::V8::DisposePlatform(); - node::TearDownOncePerProcess(); + // Node's per-process teardown is left to process exit: Stop runs from an + // exit handler registered when Node started, after the destructors of + // the certificate statics (registered later) already ran, so its + // certificate cleanup would free them a second time. } diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 816ad8f7d..921c73648 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -1598,9 +1598,26 @@ static void *scr_win_self(void) { } #endif +#ifdef SCR_NODE_ISLANDS +/* The lowest frame of Node's stack that is still live while a fiber runs: + * where runtime code on Node's stack (a job or host callback, however deep + * it went) last switched to a fiber. A call into Node from that fiber runs + * on Node's stack below it (scr_node_island.c), never over those frames. + * 0: none. scr_node_stack_has (set when Node starts) tells Node's stack. */ +uintptr_t scr_node_stack_low = 0; +bool (*scr_node_stack_has)(uintptr_t sp) = NULL; +#endif + static void scr_switch(ScrCtx *from, ScrCtx *to, ScrFiber *to_fiber) { #ifdef SCR_ASAN_FIBERS ScrFiber *from_fiber = scr_current; +#endif +#ifdef SCR_NODE_ISLANDS + uintptr_t node_low = scr_node_stack_low; + if (to_fiber != NULL && scr_node_stack_has != NULL) { + uintptr_t here = (uintptr_t)__builtin_frame_address(0); + if (scr_node_stack_has(here)) scr_node_stack_low = here; + } #endif scr_current = to_fiber; scr_exc_swap_cell(to_fiber ? &to_fiber->exc : NULL); @@ -1642,6 +1659,9 @@ static void scr_switch(ScrCtx *from, ScrCtx *to, ScrFiber *to_fiber) { #if !defined(_WIN32) && !defined(__wasi__) SCR_STACK_GUARD_SET(guard); #endif +#ifdef SCR_NODE_ISLANDS + scr_node_stack_low = node_low; /* back on the stack that switched away */ +#endif } static void scr_promise_settle_wake(ScrPromise *p); diff --git a/packages/runtime/src/scr_node_island.c b/packages/runtime/src/scr_node_island.c index f63d74959..1c6bfbea3 100644 --- a/packages/runtime/src/scr_node_island.c +++ b/packages/runtime/src/scr_node_island.c @@ -313,8 +313,15 @@ static void ni_nested_trampoline(void) { static void ni_on_node_stack_below_mark(void (*job)(void *), void *arg) { ucontext_t nested, caller; char *lo = ni_stack + NI_GUARD; - /* Below the red zone and 16-byte aligned, as makecontext expects. */ - uintptr_t hi = (ni_mark - 512) & ~(uintptr_t)15; + /* Below every live frame of Node's stack: the mark the host callback + * recorded, or lower where runtime code it ran went deeper before it + * switched to this fiber (an async call's eager start, a job: the + * spawner's frame and its saved context are still in use there). Below + * the red zone and the swap's own frame, 16-byte aligned, as makecontext + * expects. */ + uintptr_t top = ni_mark; + if (scr_node_stack_low != 0 && scr_node_stack_low < top) top = scr_node_stack_low; + uintptr_t hi = (top - 1024) & ~(uintptr_t)15; if (getcontext(&nested) != 0) scr_trap("scriptc: getcontext failed\n"); nested.uc_stack.ss_sp = lo; nested.uc_stack.ss_size = hi - (uintptr_t)lo; @@ -354,6 +361,7 @@ static void ni_on_node_stack(void (*job)(void *), void *arg) { ni_stack = mmap(NULL, NI_STACK, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0); if (ni_stack == MAP_FAILED) ni_oom(); (void)mprotect(ni_stack, NI_GUARD, PROT_NONE); + scr_node_stack_has = &ni_stack_has; #ifdef NI_ASAN if (__lsan_register_root_region) __lsan_register_root_region(ni_stack, NI_STACK); #endif @@ -500,13 +508,27 @@ static const ScrNodeBridgeHost ni_host = { &scr_ni_host_export_op, }; +typedef struct { + uint32_t event, listener; + const char *name; + int arg; +} NiEmit; + +static void ni_emit_job(void *p) { + NiEmit *e = p; + ni_bridge->emit(e->event, e->listener, e->name, e->arg); +} + +/* An island listener of a process event, on Node's stack (an exit listener + * runs from the runtime's exit sequence, on whatever stack called it). */ void scr_node_island_emit(uint32_t event, uint32_t listener, ScrStr *name, int arg) { if (ni_bridge == NULL) return; char *n = malloc(name->len + 1); if (n == NULL) ni_oom(); memcpy(n, name->data, name->len); n[name->len] = '\0'; - ni_bridge->emit(event, listener, n, arg); + NiEmit e = {event, listener, n, arg}; + ni_on_node_stack(ni_emit_job, &e); free(n); } @@ -549,7 +571,75 @@ static int ni_run_loop(int continue_turn) { return a.code; } -/* The bridge as the hosted loop sees it: Node's loop runs on Node's stack. */ +/* The bridge as the hosted loop sees it. Every entry that runs JavaScript + * (scheduling a tick, timer or immediate goes through the bootstrap's + * helpers; a checkpoint drains queues; Node's loop) runs on Node's stack, + * below the mark when it comes from an async fiber that a host callback + * resumed, like every other entry: V8 scans only Node's stack, so no frame + * of Node may live on a fiber's stack or the main thread's. */ +typedef struct { + int op; + uint32_t a, b; + double d0, d1; + int i0; + uint32_t result; +} NiHosted; + +enum { + NI_H_MICROTASK, + NI_H_TICK, + NI_H_TIMER, + NI_H_TIMER_CLEAR, + NI_H_TIMER_REF, + NI_H_TIMER_REFRESH, + NI_H_IMMEDIATE, + NI_H_IMMEDIATE_CLEAR, + NI_H_IMMEDIATE_REF, + NI_H_CHECKPOINT, +}; + +static void ni_hosted_job(void *p) { + NiHosted *h = p; + switch (h->op) { + case NI_H_MICROTASK: ni_bridge->microtask(h->a); break; + case NI_H_TICK: ni_bridge->tick(h->a); break; + case NI_H_TIMER: h->result = ni_bridge->timer(h->d0, h->d1, h->i0, h->a); break; + case NI_H_TIMER_CLEAR: ni_bridge->timer_clear(h->a); break; + case NI_H_TIMER_REF: ni_bridge->timer_ref(h->a, h->i0); break; + case NI_H_TIMER_REFRESH: ni_bridge->timer_refresh(h->a); break; + case NI_H_IMMEDIATE: h->result = ni_bridge->immediate(h->i0, h->a); break; + case NI_H_IMMEDIATE_CLEAR: ni_bridge->immediate_clear(h->a); break; + case NI_H_IMMEDIATE_REF: ni_bridge->immediate_ref(h->a, h->i0); break; + case NI_H_CHECKPOINT: ni_bridge->checkpoint(h->i0); break; + default: break; + } +} + +static uint32_t ni_hosted_run(int op, uint32_t a, double d0, double d1, int i0) { + NiHosted h = {op, a, 0, d0, d1, i0, 0}; + ni_on_node_stack(ni_hosted_job, &h); + return h.result; +} + +static void ni_h_microtask(uint32_t token) { ni_hosted_run(NI_H_MICROTASK, token, 0, 0, 0); } +static void ni_h_tick(uint32_t token) { ni_hosted_run(NI_H_TICK, token, 0, 0, 0); } +static uint32_t ni_h_timer(double delay, double repeat, int ref, uint32_t token) { + return ni_hosted_run(NI_H_TIMER, token, delay, repeat, ref); +} +static void ni_h_timer_clear(uint32_t h) { ni_hosted_run(NI_H_TIMER_CLEAR, h, 0, 0, 0); } +static void ni_h_timer_ref(uint32_t h, int ref) { ni_hosted_run(NI_H_TIMER_REF, h, 0, 0, ref); } +static void ni_h_timer_refresh(uint32_t h) { ni_hosted_run(NI_H_TIMER_REFRESH, h, 0, 0, 0); } +static uint32_t ni_h_immediate(int ref, uint32_t token) { + return ni_hosted_run(NI_H_IMMEDIATE, token, 0, 0, ref); +} +static void ni_h_immediate_clear(uint32_t h) { ni_hosted_run(NI_H_IMMEDIATE_CLEAR, h, 0, 0, 0); } +static void ni_h_immediate_ref(uint32_t h, int ref) { + ni_hosted_run(NI_H_IMMEDIATE_REF, h, 0, 0, ref); +} +static void ni_h_checkpoint(int microtasks_first) { + ni_hosted_run(NI_H_CHECKPOINT, 0, 0, 0, microtasks_first); +} + static ScrNodeBridge ni_hosted; /* ── loading Node ─────────────────────────────────────────────────────── */ @@ -623,6 +713,16 @@ static void ni_start(void) { scr_atexit(ni_stop_atexit); ni_hosted = *bridge; ni_hosted.run_loop = &ni_run_loop; + ni_hosted.microtask = &ni_h_microtask; + ni_hosted.tick = &ni_h_tick; + ni_hosted.timer = &ni_h_timer; + ni_hosted.timer_clear = &ni_h_timer_clear; + ni_hosted.timer_ref = &ni_h_timer_ref; + ni_hosted.timer_refresh = &ni_h_timer_refresh; + ni_hosted.immediate = &ni_h_immediate; + ni_hosted.immediate_clear = &ni_h_immediate_clear; + ni_hosted.immediate_ref = &ni_h_immediate_ref; + ni_hosted.checkpoint = &ni_h_checkpoint; scr_loop_host(&ni_hosted, since); /* NI-7: deferred island modules evaluate now, before the operation that * needed Node, in their import order. */ diff --git a/packages/runtime/src/scr_node_island.h b/packages/runtime/src/scr_node_island.h index 926128222..2241f3fc2 100644 --- a/packages/runtime/src/scr_node_island.h +++ b/packages/runtime/src/scr_node_island.h @@ -34,6 +34,11 @@ ScrPromise *scr_node_island_call(ScrStr *file, ScrStr *specifier, ScrStr *name, struct ScrNodeBridge; struct ScrNodeIoState; void scr_loop_host(const struct ScrNodeBridge *bridge, double since_ms); +/* The lowest live frame of Node's stack while a fiber runs (the SCR_NODE_ISLANDS + * build of scr_async.c records it at each switch from Node's stack to a + * fiber; 0: none), and the test for Node's stack it uses. */ +extern uintptr_t scr_node_stack_low; +extern bool (*scr_node_stack_has)(uintptr_t sp); bool scr_loop_is_hosted(void); void scr_loop_hosted_run(uint32_t kind, uint32_t token); void scr_loop_hosted_io_state(struct ScrNodeIoState *state); diff --git a/packages/runtime/src/scr_node_island_handle.c b/packages/runtime/src/scr_node_island_handle.c index 4d03a4898..bb3bcbd7d 100644 --- a/packages/runtime/src/scr_node_island_handle.c +++ b/packages/runtime/src/scr_node_island_handle.c @@ -1369,6 +1369,10 @@ static const ScrDynHandleOps nh_ops = { * own objects. */ static void nh_cleanup(void) { nh_drain_freed(); + /* What the last conversion for the export unit kept alive. */ + NhKeep last = nh_out_keep; + memset(&nh_out_keep, 0, sizeof nh_out_keep); + nh_keep_done(&last); if (nh_inspect_fn != NULL) { scr_dyn_release(nh_inspect_fn); nh_inspect_fn = NULL; diff --git a/tests/corpus/6870-node-island-server-fibers/main.ts b/tests/corpus/6870-node-island-server-fibers/main.ts new file mode 100644 index 000000000..829f796c1 --- /dev/null +++ b/tests/corpus/6870-node-island-server-fibers/main.ts @@ -0,0 +1,91 @@ +// @requires-libnode +// An HTTP server whose async request handler awaits island promises, calls +// island functions, extends an island class and arms timers, while an +// island client drives it (a long-running service's shape): the handler's +// continuations run as runtime jobs that Node's loop +// dispatches, possibly on an async fiber's stack, and every entry into Node +// from there must run on Node's stack, where V8 scans it during the +// collections the island's allocations cause. +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { churn, delay, depth, get, Handler } from "server-kit"; + +class CountingHandler extends Handler { + served = 0; + async handle(path: string): Promise { + this.served++; + return `${await super.handle(path)}#${this.served}`; + } +} + +const handler = new CountingHandler("kit"); +const timers: string[] = []; + +// Called from a continuation (a runtime job on Node's stack): its eager +// start runs on a fiber and calls into the island, deep, before its first +// await returns to the caller's frame on Node's stack. +async function listFiles(path: string, wait: (n: number) => Promise): Promise { + const out: string[] = []; + try { + for (let i = 0; i < 3; i++) { + out.push(`${path}:${depth(300 + i)}`); + } + const more = await wait(out.length); + out.push(String(more)); + } finally { + out.push("done"); + } + return out; +} + +// Starts listFiles a few native frames deeper on the current stack. +function startDeep(levels: number, path: string): Promise { + const parts = [path, String(levels), path.length.toString(16)]; + if (levels > 0) return startDeep(levels - 1, parts[0]!); + return listFiles(parts.join("").slice(0, path.length), (n) => delay(1, n)); +} + +async function respond(path: string): Promise { + const waited = await delay(1, path); + const files = (await startDeep(24, waited)) as string[]; + let total = 0; + for (let i = 0; i < 3; i++) { + total += churn(i) > 0 ? 1 : 0; + await delay(0); + } + const handled = await handler.handle(waited); + await new Promise((resolve) => + setTimeout(() => { + timers.push(path); + resolve(); + }, 1), + ); + return `${handled} churn=${total} files=${files.length}`; +} + +const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const path = req.url ?? "/"; + respond(path).then( + (text) => { + res.writeHead(200, { "content-type": "text/plain" }); + res.end(text); + }, + (error: unknown) => { + res.writeHead(500); + res.end(String(error)); + }, + ); +}); + +async function main(): Promise { + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const address = server.address(); + const port = typeof address === "object" && address !== null ? address.port : 0; + for (let i = 0; i < 40; i++) { + const reply = await get(port, `/r${i}`); + if (i % 10 === 0 || reply.status !== 200) console.log(reply.status, reply.body); + } + console.log("timers", timers.length, "served", handler.served, "count", handler.count); + server.close(() => console.log("closed")); +} + +void main(); diff --git a/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/index.d.ts b/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/index.d.ts new file mode 100644 index 000000000..d4ae40527 --- /dev/null +++ b/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/index.d.ts @@ -0,0 +1,14 @@ +export interface Reply { + status: number; + body: string; +} +export declare function get(port: number, path: string): Promise; +export declare function delay(ms: number, value?: T): Promise; +export declare function churn(seed: number): number; +export declare function depth(n: number): number; +export declare class Handler { + name: string; + count: number; + constructor(name: string); + handle(path: string): Promise; +} diff --git a/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/index.js b/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/index.js new file mode 100644 index 000000000..31ba6ed3b --- /dev/null +++ b/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/index.js @@ -0,0 +1,53 @@ +"use strict"; +// An island package shaped like a service's HTTP helpers: a +// client that requests over HTTP and answers promises, a base class that +// native code extends, and work that allocates enough to collect garbage. +const http = require("node:http"); + +function get(port, path) { + return new Promise((resolve, reject) => { + http + .get({ host: "127.0.0.1", port, path, agent: false }, (res) => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => resolve({ status: res.statusCode, body })); + }) + .on("error", reject); + }); +} + +function delay(ms, value) { + return new Promise((resolve) => setTimeout(() => resolve(value), ms)); +} + +// Allocates and drops a few megabytes, so collections run while native +// callers are on the stack. +function churn(seed) { + let keep = 0; + for (let i = 0; i < 2000; i++) { + const a = new Array(64).fill(seed + i); + keep += a.length + JSON.stringify({ i, a: a.slice(0, 4) }).length; + } + return keep; +} + +// Recursion deep enough to use tens of kilobytes of the stack it runs on. +function depth(n) { + const pad = [n, n + 1, n + 2, n + 3]; + return n <= 0 ? pad.length : depth(n - 1) + (pad[n % 4] > 0 ? 1 : 0); +} + +class Handler { + constructor(name) { + this.name = name; + this.count = 0; + } + async handle(path) { + this.count++; + await delay(1); + return `${this.name}:${path}`; + } +} + +module.exports = { get, delay, churn, depth, Handler }; diff --git a/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/package.json b/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/package.json new file mode 100644 index 000000000..234acb589 --- /dev/null +++ b/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/package.json @@ -0,0 +1 @@ +{ "name": "server-kit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6870-node-island-server-fibers/package.json b/tests/corpus/6870-node-island-server-fibers/package.json new file mode 100644 index 000000000..26859cb33 --- /dev/null +++ b/tests/corpus/6870-node-island-server-fibers/package.json @@ -0,0 +1 @@ +{ "name": "island-server-fibers", "private": true, "type": "module", "scriptc": { "packages": { "server-kit": "node" } } } diff --git a/tests/fixtures/node-island/exit-tls.cjs b/tests/fixtures/node-island/exit-tls.cjs new file mode 100644 index 000000000..230902e81 --- /dev/null +++ b/tests/fixtures/node-island/exit-tls.cjs @@ -0,0 +1,16 @@ +// Island work that keeps OpenSSL busy while the program exits +// (exit-tls.mjs): loading node:tls starts Node's off-thread loading of the +// root certificates (system ones with NODE_USE_SYSTEM_CA=1, extra ones with +// NODE_EXTRA_CA_CERTS); pbkdf2 and key generation run on libuv's pool. +"use strict"; +const crypto = require("node:crypto"); +const tls = require("node:tls"); + +exports.warm = function warm(mode) { + if (mode === "load") return tls.DEFAULT_MIN_VERSION.length > 0; + tls.createSecureContext(); + const roots = tls.rootCertificates.length > 0; + for (let i = 0; i < 8; i++) crypto.pbkdf2("password", "salt", 20000, 64, "sha512", () => {}); + if (mode === "keys") crypto.generateKeyPair("ec", { namedCurve: "P-256" }, () => {}); + return roots; +}; diff --git a/tests/fixtures/node-island/exit-tls.mjs b/tests/fixtures/node-island/exit-tls.mjs new file mode 100644 index 000000000..29dfd69e1 --- /dev/null +++ b/tests/fixtures/node-island/exit-tls.mjs @@ -0,0 +1,20 @@ +// Exit while Node has OpenSSL work in flight (node-island-exit.test.ts): +// Node must stop its threads before the process's exit handlers tear +// OpenSSL down. argv[2]: "early" (exit right after Node starts, while it +// loads the system root certificates on a thread of its own with +// NODE_USE_SYSTEM_CA=1), "load" (node:tls loaded, extra certificates +// loading off-thread), "exit" (process.exit with pool work in flight), +// "keys", or "return" (fall off the end). A "-return" suffix ends the +// program by returning instead of process.exit (C exit and its handlers). +import { requireModule } from "@scriptc/node"; + +const arg = process.argv[2] ?? "exit"; +const returns = arg.endsWith("-return") || arg === "return"; +const mode = arg.replace(/-return$/, ""); +if (mode === "early") { + console.log("roots", requireModule("./helper.cjs") !== null); +} else { + const lib = requireModule("./exit-tls.cjs"); + console.log("roots", lib.warm(mode)); +} +if (!returns) process.exit(0); diff --git a/tests/harness/lsan.supp b/tests/harness/lsan.supp index 41125589f..a0fe1c5ae 100644 --- a/tests/harness/lsan.supp +++ b/tests/harness/lsan.supp @@ -2,3 +2,24 @@ # (scripts/ci-test-plan.mjs). One `leak:` line per intentional # leak, under a comment that gives the reason. Empty: no corpus program # leaks. + +# Node islands (scope-B NI-7c): OpenSSL inside libnode is initialized with +# OPENSSL_INIT_NO_ATEXIT (scr_node_bridge.cc, KeepOpenSSLAtExit), so its +# process-lifetime state is never freed: OPENSSL_cleanup at exit raced the +# threads Node owns that can still be inside OpenSSL. +leak:OPENSSL_init_crypto +# Node islands (scope-B NI-7c): Node's root certificates (bundled, system +# store, NODE_EXTRA_CA_CERTS) live in function-local statics that Node's +# per-process teardown frees; a scriptc program leaves that teardown to +# process exit (scr_node_bridge.cc, Stop), and the vectors' own destructors +# drop the last pointers at exit. +leak:node::crypto::LoadCACertificates +leak:node::crypto::LoadSystemCACertificates +leak:node::crypto::GetBundledRootCertificates +leak:node::crypto::GetSystemStoreCACertificates +leak:node::crypto::GetExtraCACertificates +leak:node::crypto::GetOpenSSLSystemCertificates +leak:node::crypto::LoadCertsFromBIO +# The certificates' lazily decoded keys, cached in them when a root store +# is built. +leak:node::crypto::NewRootCertStore diff --git a/tests/harness/node-island-exit.test.ts b/tests/harness/node-island-exit.test.ts new file mode 100644 index 000000000..eb69623f4 --- /dev/null +++ b/tests/harness/node-island-exit.test.ts @@ -0,0 +1,87 @@ +/* Exit with Node loaded (scope-B NI-7c): island code loads node:tls (Node + * then loads the root certificates on threads of its own) or leaves OpenSSL + * work on libuv's thread pool, and the program exits at once. The bridge's + * stop runs Node's own exit sequence (join the pool, dispose the platform, + * join the certificate threads) before the process's exit handlers run + * OpenSSL's cleanup; without it a certificate thread inside OpenSSL crashed + * the exit (SIGSEGV/SIGABRT in up to half the runs). + * Many runs per mode; SCRIPTC_NODE_ISLAND_EXIT_RUNS scales them. Needs a + * libnode pack in SCRIPTC_LIBNODE. */ +import { execFile } from "node:child_process"; +import { existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { beforeAll, describe, expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; +import { libnodePack } from "./libnode.js"; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../.."); +const fixture = join(repoRoot, "tests/fixtures/node-island/exit-tls.mjs"); +const outDir = join(repoRoot, "node_modules/.cache/scriptc-tests/node-island-exit"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; +const pack = libnodePack(); +const runs = Number(process.env["SCRIPTC_NODE_ISLAND_EXIT_RUNS"] ?? (sanitize ? "10" : "40")); + +let binary = ""; +// Node loads the system and extra root certificates on their own threads +// when node:tls loads, with these set: the threads the exit must wait for. +const caFile = ["/etc/ssl/certs/ca-certificates.crt", "/etc/ssl/cert.pem"].find((f) => + existsSync(f), +); +const env: NodeJS.ProcessEnv = { + ...process.env, + NODE_USE_SYSTEM_CA: "1", + ...(caFile === undefined ? {} : { NODE_EXTRA_CA_CERTS: caFile }), +}; + +async function runOnce(mode: string): Promise<{ code: number | string; stdout: string }> { + try { + const { stdout } = await execFileAsync(binary, [mode], { encoding: "utf8", env }); + return { code: 0, stdout }; + } catch (err) { + const e = err as { code?: number; signal?: string; stdout?: string }; + return { code: e.signal ?? e.code ?? "?", stdout: e.stdout ?? "" }; + } +} + +describe.skipIf(pack === null)("Node islands: exit with OpenSSL work in flight", () => { + beforeAll(async () => { + mkdirSync(outDir, { recursive: true }); + const result = await compile(fixture, { + outPath: join(outDir, "exit-tls"), + outDir, + sanitize, + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + binary = result.binaryPath; + }, 300_000); + + test.each([ + "early", + "load", + "exit", + "keys", + "return", + "early-return", + "load-return", + "keys-return", + ])( + "%s: every run exits 0", + async (mode) => { + const outcomes: (number | string)[] = []; + for (let i = 0; i < runs; i += 4) { + const batch = await Promise.all( + Array.from({ length: Math.min(4, runs - i) }, () => runOnce(mode)), + ); + for (const r of batch) { + outcomes.push(r.code); + expect(r.stdout).toBe(`roots true\n`); + } + } + expect(outcomes.filter((c) => c !== 0)).toEqual([]); + }, + 600_000, + ); +}); From 3b2d5f202228b102c46a6a1f1434204973724ebb Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 19:13:46 -0700 Subject: [PATCH 02/18] Node islands: give a nested entry its own scheduling state A call into Node from an async fiber runs in a context of its own on Node's stack while the calling fiber stays suspended in it, but runtime code that Node called back there still ran as that fiber: scr_current named it, and a fiber resumed from there saved its resumer in scr_loop_ctx and, under ASan, its fake stack in the calling fiber's slot. The calling fiber's next park then returned to a context that no longer existed, or under ASan resumed with a fake stack the nested context had destroyed (6870-node-island-server-fibers in dev mode, sanitized: SEGV in nh_keep_done). The nested entry now runs host callbacks as the loop's code does (no current fiber, the main exception cell and ALS slot) and puts the calling fiber's state and the loop context back when it returns. Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/scr_async.c | 48 ++++++++++++++++++++++++++ packages/runtime/src/scr_node_island.c | 2 ++ packages/runtime/src/scr_node_island.h | 5 +++ 3 files changed, 55 insertions(+) diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 921c73648..64a3712a3 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -1664,6 +1664,54 @@ static void scr_switch(ScrCtx *from, ScrCtx *to, ScrFiber *to_fiber) { #endif } +#ifdef SCR_NODE_ISLANDS +/* A nested entry into Node (scr_node_island.c) runs on Node's stack in a + * context of its own while the fiber that made the call stays suspended in + * it. Runtime code that Node calls back there runs as the loop's code does: + * no current fiber, the main exception cell and ALS slot. It may resume + * other fibers, which saves the resumer's context in scr_loop_ctx (and, + * under ASan, its fake stack in the main slot); those belong to whoever + * resumed the calling fiber, so they are saved here and put back when the + * nested entry returns, or the calling fiber's next park would return to a + * context that no longer exists. */ +typedef struct { + ScrFiber *current; + ScrAlsCtx **als; + ScrCtx loop_ctx; +#ifdef SCR_ASAN_FIBERS + void *main_fake; +#endif +} ScrNestedSave; + +void *scr_loop_nested_enter(void) { + ScrNestedSave *s = malloc(sizeof *s); + if (s == NULL) scr_oom(); + s->current = scr_current; + s->als = scr_als_active; + s->loop_ctx = scr_loop_ctx; +#ifdef SCR_ASAN_FIBERS + s->main_fake = scr_main_fake_stack; + scr_main_fake_stack = NULL; +#endif + scr_current = NULL; + scr_exc_swap_cell(NULL); + scr_als_active = &scr_als_main_slot; + return s; +} + +void scr_loop_nested_leave(void *save) { + ScrNestedSave *s = save; + scr_loop_ctx = s->loop_ctx; +#ifdef SCR_ASAN_FIBERS + scr_main_fake_stack = s->main_fake; +#endif + scr_current = s->current; + scr_exc_swap_cell(s->current ? &s->current->exc : NULL); + scr_als_active = s->als; + free(s); +} +#endif + static void scr_promise_settle_wake(ScrPromise *p); /* Copies src's settlement into a still-pending dst (payload RETAINED — diff --git a/packages/runtime/src/scr_node_island.c b/packages/runtime/src/scr_node_island.c index 1c6bfbea3..c297a3630 100644 --- a/packages/runtime/src/scr_node_island.c +++ b/packages/runtime/src/scr_node_island.c @@ -332,6 +332,7 @@ static void ni_on_node_stack_below_mark(void (*job)(void *), void *arg) { ni_nested_return = &caller; uintptr_t guard = scr_stack_guard; SCR_STACK_GUARD_SET(scr_stack_guard_for((uintptr_t)lo, hi - (uintptr_t)lo)); + void *loop_save = scr_loop_nested_enter(); #ifdef NI_ASAN void *caller_fake = NULL; __sanitizer_start_switch_fiber(&caller_fake, lo, hi - (uintptr_t)lo); @@ -342,6 +343,7 @@ static void ni_on_node_stack_below_mark(void (*job)(void *), void *arg) { size_t size; __sanitizer_finish_switch_fiber(caller_fake, &bottom, &size); #endif + scr_loop_nested_leave(loop_save); SCR_STACK_GUARD_SET(guard); } diff --git a/packages/runtime/src/scr_node_island.h b/packages/runtime/src/scr_node_island.h index 2241f3fc2..320328fde 100644 --- a/packages/runtime/src/scr_node_island.h +++ b/packages/runtime/src/scr_node_island.h @@ -39,6 +39,11 @@ void scr_loop_host(const struct ScrNodeBridge *bridge, double since_ms); * fiber; 0: none), and the test for Node's stack it uses. */ extern uintptr_t scr_node_stack_low; extern bool (*scr_node_stack_has)(uintptr_t sp); +/* Around a nested entry: the runtime's scheduling state of the suspended + * calling fiber, saved and put back (the SCR_NODE_ISLANDS build of + * scr_async.c). */ +void *scr_loop_nested_enter(void); +void scr_loop_nested_leave(void *save); bool scr_loop_is_hosted(void); void scr_loop_hosted_run(uint32_t kind, uint32_t token); void scr_loop_hosted_io_state(struct ScrNodeIoState *state); From f0b6e362a4a4fe50e713fad1866dd31fa29c3e72 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 26 Oct 2026 02:05:06 -0500 Subject: [PATCH 03/18] Expect the word-segments pilot to segment in its Node island --- tests/harness/npm-static.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index 076733739..fb469ec22 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -15,6 +15,7 @@ import { promisify } from "node:util"; import { beforeEach, describe, expect, test } from "vitest"; import { analyze, compile } from "@scriptc/compiler"; import { skipUnownedTest } from "./shard.js"; +import { libnodePack } from "./libnode.js"; const execFileAsync = promisify(execFile); const repoRoot = join(import.meta.dirname, "../.."); @@ -586,7 +587,10 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { } }); - test("a word segmenter in a package constructs, and its segment() throws SC2020", async () => { + // The package builds its segmenter at module load, a run-time fence that + // surely fires, so the package runs in its Node island and segments as + // Node does. + test("a word segmenter a package builds at load runs in its Node island", async () => { const entry = join(pilotRoot, "word-segments-cli.ts"); const { coverage } = analyze(entry, { npmStatic: "auto" }); expect(coverage.npmStatic).toEqual([{ package: "word-segments", status: "static" }]); @@ -599,12 +603,15 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { outDir, outPath: join(outDir, "program"), }); + if (libnodePack() === null) { + expect(result.ok, "no libnode pack: the compile stops").toBe(false); + if (!result.ok) expect(result.diagnostics.map((d) => d.code)).toContain("SC8005"); + return; + } if (!result.ok) throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); const nativeRes = await runBinary(result.binaryPath, []); - expect(nativeRes.stdout.toString()).toMatch( - /^SC2020: Intl\.Segmenter segmentation with granularity 'word' is not supported yet \[SC2020 at .*index\.js:1\]\n$/, - ); + expect(nativeRes.stdout.toString()).toBe("two| |words\n"); expect(nativeRes.exitCode).toBe(0); }); From 500dd3e9b4a80124260244044d22c7cd60f4e78f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 18:52:05 -0500 Subject: [PATCH 04/18] Run short constant-count loops together when their inner exits saturate A loop of two to eight iterations with a constant count, whose body is an inner loop that exits once an accumulator reaches the value its ORs saturate at (mandelbrot's pixel pairs: `b |= 2`, `b |= 1`, `if (b === 3) break`), now runs its iterations in one inner loop: every iteration's declarations, then one loop running each iteration's body in turn that exits once every accumulator is full, then every iteration's trailing assignments in order. Once an accumulator is full its ORs leave it full, so an iteration that runs past its own exit leaves its accumulator as its own loop did, and every other value it computes is dead. The bodies only compute numbers, read typed-array elements that answer NaN out of range and write their own locals; the declarations and the inner loop read nothing the trailing assignments write. The independent iterations then overlap their dependence chains. --- packages/compiler/src/backend/llvm/emitter.ts | 23 +- .../src/ir/saturating-loop-jams.test.ts | 188 ++++++++ .../compiler/src/ir/saturating-loop-jams.ts | 424 ++++++++++++++++++ .../tests/corpus/saturating-loop-jams.ts.json | 6 + tests/corpus/saturating-loop-jams.ts | 85 ++++ .../corpus-costs/saturating-loop-jams.ts.json | 1 + 6 files changed, 717 insertions(+), 10 deletions(-) create mode 100644 packages/compiler/src/ir/saturating-loop-jams.test.ts create mode 100644 packages/compiler/src/ir/saturating-loop-jams.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/saturating-loop-jams.ts.json create mode 100644 tests/corpus/saturating-loop-jams.ts create mode 100644 tests/harness/corpus-costs/saturating-loop-jams.ts.json diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 9d85bb8da..eeb418512 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -96,6 +96,7 @@ import { } from "../../ir/inline-arrays.js"; import { inlineArraysApply, programLifetimeClassOf } from "./inline-arrays.js"; import { pairRowSums } from "../../ir/row-pairs.js"; +import { jamSaturatingLoops } from "../../ir/saturating-loop-jams.js"; import { allocatedLiteral, fillAllocationLength, @@ -759,16 +760,18 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter { specializeCallbackCalls( specializeNumericCalls( pairRowSums( - inlineArraysPass( - options, - inlineExpressionFunctions( - foldConstantGlobals( - copyStableCaptures( - resolveSelfCaptures( - hoistFinalFieldReads( - freezeInitOnlyGlobals( - holdCharLocalsAsUnits( - dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)), + jamSaturatingLoops( + inlineArraysPass( + options, + inlineExpressionFunctions( + foldConstantGlobals( + copyStableCaptures( + resolveSelfCaptures( + hoistFinalFieldReads( + freezeInitOnlyGlobals( + holdCharLocalsAsUnits( + dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)), + ), ), ), ), diff --git a/packages/compiler/src/ir/saturating-loop-jams.test.ts b/packages/compiler/src/ir/saturating-loop-jams.test.ts new file mode 100644 index 000000000..b3d1d0fe9 --- /dev/null +++ b/packages/compiler/src/ir/saturating-loop-jams.test.ts @@ -0,0 +1,188 @@ +import { expect, test } from "vitest"; +import { BOOL, F64, VOID, type IrExpr, type IrFunction, type IrModule, type IrStmt } from "./ir.js"; +import { everyStmtList } from "./traverse.js"; +import { jamSaturatingLoops } from "./saturating-loop-jams.js"; + +const loc = { file: "saturating-loop-jams.ts", start: 0, end: 0 }; +const BYTES = { kind: "bytes", elem: "f64" } as const; +const num = (value: number): IrExpr => ({ loc, kind: "numLit", value, type: F64 }); +const ref = (localId: string): IrExpr => ({ loc, kind: "varRef", localId, type: F64 }); +type Op = "+" | "*" | "-" | "<" | ">" | "|" | "===" | "<<"; +const bin = (op: Op, left: IrExpr, right: IrExpr): IrExpr => ({ + loc, + kind: "bin", + op, + left, + right, + type: op === "<" || op === ">" || op === "===" ? BOOL : F64, +}); +const decl = (id: string, init: IrExpr): IrStmt => ({ loc, kind: "varDecl", localId: id, init }); +const set = (id: string, value: IrExpr): IrStmt => ({ loc, kind: "assign", localId: id, value }); +const stepBy = (id: string, by: number): IrStmt => set(id, bin("+", ref(id), num(by))); +const loop = (id: string, end: IrExpr, by: number, body: IrStmt[]): IrStmt & { kind: "for" } => ({ + loc, + kind: "for", + init: decl(id, num(0)), + cond: bin("<", ref(id), end), + update: stepBy(id, by), + body, +}); +const exitWhen = (acc: string, full: number): IrStmt => ({ + loc, + kind: "if", + cond: bin("===", ref(acc), num(full)), + then: [{ loc, kind: "break" }], + else_: null, +}); +const get = (index: IrExpr): IrExpr => ({ + loc, + kind: "bytesIntrinsic", + method: "get", + receiver: { loc, kind: "varRef", localId: "cr", type: BYTES }, + args: [index], + type: F64, + invalidNaN: true, +}); + +/** One lane of mandelbrot's escape test: `z = z * z + c`, `b |= bit` and + * an exit once `b` is full. */ +function lane(z: string, c: string, bit: number, full = 3, extra: IrStmt[] = []): IrStmt[] { + return [ + decl(`${z}.t`, bin("*", ref(z), ref(z))), + set(z, bin("+", ref(`${z}.t`), ref(c))), + { + loc, + kind: "if", + cond: bin(">", ref(`${z}.t`), num(4)), + then: [set("b", bin("|", ref("b"), num(bit))), exitWhen("b", full), ...extra], + else_: null, + }, + ]; +} + +function nest( + options: { full?: number; post?: IrStmt[]; preExtra?: IrStmt[]; copies?: number } = {}, +): IrStmt[] { + const post = options.post ?? [set("res", bin("|", bin("<<", ref("res"), num(2)), ref("b")))]; + return [ + decl("res", num(0)), + loop("i", num(2 * (options.copies ?? 4)), 2, [ + decl("c1", get(ref("i"))), + decl("c2", get(bin("+", ref("i"), num(1)))), + decl("z1", ref("c1")), + decl("z2", ref("c2")), + decl("b", num(0)), + ...(options.preExtra ?? []), + loop("j", num(50), 1, [ + ...lane("z1", "c1", 2, options.full), + ...lane("z2", "c2", 1, options.full), + ]), + ...post, + ]), + ]; +} + +function moduleOf(body: IrStmt[]): IrModule { + const ids = new Set(); + everyStmtList(body, { + expr: (e) => { + if (e.kind === "varRef" && e.localId !== "cr") ids.add(e.localId); + return true; + }, + stmt: (s) => { + if (s.kind === "varDecl" || s.kind === "assign") ids.add(s.localId); + return true; + }, + }); + const fn: IrFunction = { + name: "render", + params: [{ localId: "cr", name: "cr", type: BYTES }], + locals: [ + { id: "cr", name: "cr", type: BYTES, mutable: false }, + ...[...ids].map((id) => ({ id, name: id, type: F64, mutable: true })), + ], + body, + returnType: VOID, + loc, + }; + return { irVersion: 17, sourceFile: loc.file, entry: "render", functions: [fn] } as IrModule; +} + +function loops(body: IrStmt[]): (IrStmt & { kind: "for" })[] { + const found: (IrStmt & { kind: "for" })[] = []; + everyStmtList(body, { + expr: () => true, + stmt: (s) => { + if (s.kind === "for") found.push(s); + return true; + }, + }); + return found; +} + +test("jams a constant-count loop whose inner loop exits once its accumulator is full", () => { + const mod = moduleOf(nest()); + const out = jamSaturatingLoops(mod); + expect(out).not.toBe(mod); + const body = out.functions[0]!.body; + // The outer loop is gone: one inner loop runs the four iterations. + const remaining = loops(body); + expect(remaining.length).toBe(1); + const inner = remaining[0]!; + // Its only exit tests every copy's accumulator, at the end of the body. + let breaks = 0; + everyStmtList(inner.body, { + expr: () => true, + stmt: (s) => { + if (s.kind === "break") breaks++; + return true; + }, + }); + expect(breaks).toBe(1); + const last = inner.body[inner.body.length - 1]!; + expect(last.kind).toBe("if"); + // Each copy keeps its own accumulator and lanes; `post` runs in order. + const added = out.functions[0]!.locals.filter((local) => local.id.startsWith("%jam.")); + expect(added.filter((local) => local.name === "%jam.b").length).toBe(4); + const block = body[1]!; + expect(block.kind).toBe("block"); + if (block.kind !== "block") return; + const posts = block.body.slice(-4); + expect(posts.every((s) => s.kind === "assign" && s.localId === "res")).toBe(true); +}); + +test("keeps loops whose exits or values do not allow running iterations together", () => { + const reject = (body: IrStmt[]): void => { + const mod = moduleOf(body); + expect(jamSaturatingLoops(mod)).toBe(mod); + }; + // An exit before the accumulator is full: later ORs would change it. + reject(nest({ full: 2 })); + // `post` reads a lane value the copies keep computing past their exit. + reject(nest({ post: [set("res", bin("+", ref("res"), ref("z1")))] })); + // `pre` reads what `post` writes. + reject(nest({ preExtra: [decl("seed", ref("res"))] })); + // One iteration, or more than eight. + reject(nest({ copies: 1 })); + reject(nest({ copies: 9 })); + // A store in the inner loop is observable past an exit. + reject([ + decl("res", num(0)), + loop("i", num(8), 2, [ + decl("b", num(0)), + decl("z1", num(0)), + loop("j", num(50), 1, [ + ...lane("z1", "z1", 2, 3, [ + { + loc, + kind: "bytesSet", + arr: { loc, kind: "varRef", localId: "cr", type: BYTES }, + index: num(0), + value: num(1), + }, + ]), + ]), + set("res", bin("|", ref("res"), ref("b"))), + ]), + ]); +}); diff --git a/packages/compiler/src/ir/saturating-loop-jams.ts b/packages/compiler/src/ir/saturating-loop-jams.ts new file mode 100644 index 000000000..ae4bd38c8 --- /dev/null +++ b/packages/compiler/src/ir/saturating-loop-jams.ts @@ -0,0 +1,424 @@ +import { + BOOL, + F64, + type IrExpr, + type IrFunction, + type IrLocal, + type IrModule, + type IrStmt, +} from "./ir.js"; +import { everyStmtList, transformStmtList } from "./traverse.js"; + +/** The most iterations of an outer loop that run jammed. */ +const MAX_COPIES = 8; + +/** The most statement and expression nodes of one outer iteration: every + * copy duplicates it. */ +const MAX_BODY_NODES = 160; + +/** Binary operators of numbers and booleans that run no other code and + * cannot throw. */ +const PURE_BINARY = new Set([ + "+", + "-", + "*", + "/", + "%", + "&", + "|", + "^", + "<<", + ">>", + ">>>", + "<", + "<=", + ">", + ">=", + "===", + "!==", +]); + +const PURE_UNARY = new Set(["-", "+", "~", "!"]); + +interface Jam { + counter: string; + /** The counter's value on each iteration. */ + values: number[]; + pre: IrStmt[]; + inner: IrStmt & { kind: "for" }; + post: IrStmt[]; + /** The accumulator the inner loop's exits test, and its full value. */ + acc: string; + full: number; +} + +function isRef(e: IrExpr | null | undefined, id: string): boolean { + return e?.kind === "varRef" && e.localId === id; +} + +function intLit(e: IrExpr | null | undefined): number | null { + return e?.kind === "numLit" && Number.isSafeInteger(e.value) && !Object.is(e.value, -0) + ? e.value + : null; +} + +/** `id = id + step` with a positive integer literal step. */ +function constantStep(update: IrStmt | null, id: string): number | null { + if ( + update?.kind !== "assign" || + update.localId !== id || + update.value.kind !== "bin" || + update.value.op !== "+" || + !isRef(update.value.left, id) + ) + return null; + const step = intLit(update.value.right); + return step !== null && step > 0 ? step : null; +} + +/** A number or boolean expression that runs no other code and cannot throw: + * literals, reads of locals and module bindings, arithmetic, comparisons + * and typed-array element reads that answer NaN out of range. */ +function pureExpr(e: IrExpr, locals: ReadonlyMap): boolean { + if (e.type.kind !== "f64" && e.type.kind !== "bool") return false; + switch (e.kind) { + case "numLit": + case "boolLit": + return true; + case "varRef": { + const local = locals.get(e.localId); + if (local) return !local.boxed && !local.tdz; + return e.localId.startsWith("%g."); + } + case "bin": + return PURE_BINARY.has(e.op) && pureExpr(e.left, locals) && pureExpr(e.right, locals); + case "unary": + return PURE_UNARY.has(e.op) && pureExpr(e.operand, locals); + case "bytesIntrinsic": + return ( + e.method === "get" && + e.invalidNaN === true && + e.receiver.kind === "varRef" && + e.args.length === 1 && + pureExpr(e.args[0]!, locals) + ); + default: + return false; + } +} + +/** A local written by `s` (a declaration or assignment), or null. */ +function writtenLocal(s: IrStmt): string | null { + return s.kind === "varDecl" || s.kind === "assign" ? s.localId : null; +} + +/** Straight-line declarations and assignments of pure values. */ +function pureStatements(list: readonly IrStmt[], locals: ReadonlyMap): boolean { + return list.every( + (s) => + (s.kind === "varDecl" && s.init !== null && pureExpr(s.init, locals)) || + (s.kind === "assign" && locals.has(s.localId) && pureExpr(s.value, locals)), + ); +} + +/** The statements of the inner loop's body: pure declarations and + * assignments, `if` statements over them, `acc = acc | C`, and + * `if (acc === full) break` (collected into `breaks`). */ +function innerStatements( + list: readonly IrStmt[], + locals: ReadonlyMap, + acc: { id: string | null; bits: number; breaks: number[] }, +): boolean { + return list.every((s) => { + if (s.kind === "if") { + // `if (acc === full) break` + if ( + s.then.length === 1 && + s.then[0]!.kind === "break" && + s.then[0]!.label === undefined && + (s.else_ === null || s.else_.length === 0) && + s.cond.kind === "bin" && + s.cond.op === "===" && + s.cond.left.kind === "varRef" && + intLit(s.cond.right) !== null + ) { + if (acc.id !== null && acc.id !== s.cond.left.localId) return false; + acc.id = s.cond.left.localId; + acc.breaks.push(intLit(s.cond.right)!); + return true; + } + return ( + pureExpr(s.cond, locals) && + innerStatements(s.then, locals, acc) && + innerStatements(s.else_ ?? [], locals, acc) + ); + } + if ( + s.kind === "assign" && + s.value.kind === "bin" && + s.value.op === "|" && + isRef(s.value.left, s.localId) + ) { + const bits = intLit(s.value.right); + if (bits !== null && bits >= 0 && bits <= 0x7fffffff) { + if (acc.id !== null && acc.id !== s.localId) return false; + acc.id = s.localId; + acc.bits |= bits; + return true; + } + } + return pureStatements([s], locals); + }); +} + +function countNodes(list: readonly IrStmt[]): number { + let n = 0; + everyStmtList(list as IrStmt[], { + expr: () => ++n <= MAX_BODY_NODES, + stmt: () => ++n <= MAX_BODY_NODES, + }); + return n; +} + +/** Reads of local `id` anywhere in `list`. */ +function reads(list: readonly IrStmt[], id: string): boolean { + return !everyStmtList(list as IrStmt[], { expr: (e) => !isRef(e, id), stmt: () => true }); +} + +/** Locals written anywhere in `list`. */ +function writes(list: readonly IrStmt[]): Set { + const out = new Set(); + everyStmtList(list as IrStmt[], { + expr: () => true, + stmt: (s) => { + const id = writtenLocal(s); + if (id !== null) out.add(id); + return true; + }, + }); + return out; +} + +/** Locals declared anywhere in `list`. */ +function declared(list: readonly IrStmt[]): Set { + const out = new Set(); + everyStmtList(list as IrStmt[], { + expr: () => true, + stmt: (s) => { + if (s.kind === "varDecl") out.add(s.localId); + return true; + }, + }); + return out; +} + +/** The jam of `loop`, or null. */ +function matchJam( + loop: IrStmt & { kind: "for" }, + locals: ReadonlyMap, +): Jam | null { + if (loop.labels?.length || loop.init?.kind !== "varDecl") return null; + const counter = loop.init.localId; + const local = locals.get(counter); + if (local?.type.kind !== "f64" || local.boxed || local.tdz) return null; + const start = intLit(loop.init.init); + const step = constantStep(loop.update, counter); + const cond = loop.cond; + if ( + start === null || + step === null || + cond?.kind !== "bin" || + cond.op !== "<" || + !isRef(cond.left, counter) + ) + return null; + const end = intLit(cond.right); + if (end === null) return null; + const values: number[] = []; + for (let v = start; v < end && values.length <= MAX_COPIES; v += step) values.push(v); + if (values.length < 2 || values.length > MAX_COPIES) return null; + // One inner loop, with straight-line code before and after it. + const at = loop.body.findIndex((s) => s.kind === "for"); + if (at < 0) return null; + const inner = loop.body[at] as IrStmt & { kind: "for" }; + const pre = loop.body.slice(0, at), + post = loop.body.slice(at + 1); + if (!pureStatements(pre, locals) || !pureStatements(post, locals)) return null; + if (countNodes(loop.body) > MAX_BODY_NODES) return null; + if (inner.labels?.length || inner.init?.kind !== "varDecl") return null; + const j = inner.init.localId; + const jLocal = locals.get(j); + if (jLocal?.type.kind !== "f64" || jLocal.boxed || jLocal.tdz) return null; + if (inner.init.init === null || !pureExpr(inner.init.init, locals)) return null; + if (inner.cond === null || !pureExpr(inner.cond, locals) || constantStep(inner.update, j) !== 1) + return null; + const acc = { id: null as string | null, bits: 0, breaks: [] as number[] }; + if (!innerStatements(inner.body, locals, acc) || acc.id === null) return null; + const full = acc.bits; + // Every exit tests the accumulator against the value every OR leaves + // unchanged once it is reached. + if (acc.breaks.length === 0 || acc.breaks.some((value) => value !== full)) return null; + const accId = acc.id; + // The accumulator starts at a literal in `pre`, and the inner loop changes + // it only by its ORs. + const accDecl = pre.find((s) => s.kind === "varDecl" && s.localId === accId); + if (accDecl?.kind !== "varDecl" || intLit(accDecl.init) === null) return null; + // The counter, the inner bound and every value the inner loop reads from + // outside the iteration are the same for every copy: only `pre` and the + // counter vary. + const own = declared([...pre, inner]); + own.delete(counter); + const innerWrites = writes([inner]); + innerWrites.delete(j); + for (const id of innerWrites) if (!own.has(id) && id !== accId) return null; + if (writes([inner]).has(counter) || writes(pre).has(counter) || writes(post).has(counter)) + return null; + // Copies run `pre` and the inner loop of later iterations before `post` + // of earlier ones: `pre` and the inner loop must not read what `post` + // writes, and `pre` writes only its own declarations. + const postWrites = writes(post); + for (const id of postWrites) if (reads([...pre, inner], id) || own.has(id)) return null; + for (const id of writes(pre)) if (!own.has(id)) return null; + // After the inner loop, iterations past a copy's exit are dead: `post` + // reads no value the inner loop writes besides the accumulator, and the + // inner bound reads nothing the copies write. + for (const id of innerWrites) if (id !== accId && reads(post, id)) return null; + for (const id of own) + if (id !== j && reads([{ kind: "exprStmt", expr: inner.cond, loc: inner.loc }], id)) + return null; + return { counter, values, pre, inner, post, acc: accId, full }; +} + +/** Runs the iterations of a short constant-count loop together when its body + * is an inner loop that exits once an accumulator reaches the value its ORs + * saturate at (mandelbrot's pairs of pixels per output byte): + * + * for (let i = 0; i < 8; i += 2) { + * + * for (let j = 0; j < M; j++) { + * ; if (c) { b |= 2; if (b === 3) break; } ... + * } + * + * } + * + * becomes every iteration's `pre` (with `i` a literal), one inner loop + * running every iteration's body in turn that exits when every accumulator + * is full, and every iteration's `post` in order. Once an accumulator is + * full its ORs leave it full, so the iterations a copy runs past its own + * exit leave its accumulator as its own loop left it, and every other value + * they compute is dead: the bodies only compute numbers, read typed-array + * elements that answer NaN out of range, and write their own locals. `pre` + * and the inner loop read nothing `post` writes, so running later `pre`s + * and inner loops first changes no value. Independent iterations then + * overlap their dependence chains. */ +export function jamSaturatingLoops(mod: IrModule): IrModule { + let changed = false; + const functions = mod.functions.map((fn): IrFunction => { + if (fn.async || fn.generator) return fn; + const locals = new Map(fn.locals.map((local) => [local.id, local])); + let found = false; + everyStmtList(fn.body, { + expr: () => !found, + stmt: (s) => { + if (s.kind === "for" && matchJam(s, locals) !== null) found = true; + return !found; + }, + }); + if (!found) return fn; + const added: IrLocal[] = []; + const ids = new Set(fn.locals.map((local) => local.id)); + const fresh = (of: IrLocal, copy: number): string => { + let id = `%jam.${copy}.${of.id}`; + while (ids.has(id)) id += "_"; + ids.add(id); + const { source: _source, ...rest } = of; + added.push({ ...rest, id, name: `%jam.${of.name}` }); + return id; + }; + const body = transformStmtList(fn.body, { + expr: (e) => e, + stmt: (s) => { + if (s.kind !== "for") return s; + const jam = matchJam(s, locals); + return jam === null ? s : emitJam(jam, locals, fresh); + }, + }); + changed = true; + return { ...fn, locals: [...fn.locals, ...added], body }; + }); + return changed ? { ...mod, functions } : mod; +} + +function emitJam( + jam: Jam, + locals: ReadonlyMap, + fresh: (of: IrLocal, copy: number) => string, +): IrStmt { + const { inner, pre, post, values, counter } = jam; + const loc = inner.loc; + const own = declared([...pre, ...inner.body]); + const j = (inner.init as IrStmt & { kind: "varDecl" }).localId; + own.delete(j); + const copies = values.map((value, copy) => { + const rename = new Map(); + for (const id of own) rename.set(id, fresh(locals.get(id)!, copy)); + const rewrite = (list: readonly IrStmt[]): IrStmt[] => + transformStmtList(list as IrStmt[], { + expr: (e) => { + if (e.kind !== "varRef") return e; + if (e.localId === counter) return { kind: "numLit", value, type: F64, loc: e.loc }; + const to = rename.get(e.localId); + return to === undefined ? e : { ...e, localId: to }; + }, + stmt: (s) => { + if (s.kind === "varDecl" || s.kind === "assign") { + const to = rename.get(s.localId); + if (to !== undefined) return { ...s, localId: to }; + } + return s; + }, + }); + // The copy's exits become the shared test at the end of the iteration. + const withoutBreaks = (list: readonly IrStmt[]): IrStmt[] => + list.flatMap((s): IrStmt[] => { + if (s.kind !== "if") return [s]; + if (s.then.length === 1 && s.then[0]!.kind === "break") return []; + return [ + { ...s, then: withoutBreaks(s.then), else_: s.else_ ? withoutBreaks(s.else_) : null }, + ]; + }); + return { + pre: rewrite(pre), + body: rewrite(withoutBreaks(inner.body)), + post: rewrite(post), + acc: rename.get(jam.acc)!, + }; + }); + let allFull: IrExpr | null = null; + for (const copy of copies) { + const full: IrExpr = { + kind: "bin", + op: "===", + left: { kind: "varRef", localId: copy.acc, type: F64, loc }, + right: { kind: "numLit", value: jam.full, type: F64, loc }, + type: BOOL, + loc, + }; + allFull = + allFull === null + ? full + : { kind: "logical", op: "&&", left: allFull, right: full, type: BOOL, loc }; + } + const jammed: IrStmt = { + ...inner, + body: [ + ...copies.flatMap((copy) => copy.body), + { kind: "if", cond: allFull!, then: [{ kind: "break", loc }], else_: null, loc }, + ], + }; + return { + kind: "block", + body: [...copies.flatMap((copy) => copy.pre), jammed, ...copies.flatMap((copy) => copy.post)], + loc, + }; +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/saturating-loop-jams.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/saturating-loop-jams.ts.json new file mode 100644 index 000000000..34cd32574 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/saturating-loop-jams.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/saturating-loop-jams.ts" + ], + "diags": [] +} diff --git a/tests/corpus/saturating-loop-jams.ts b/tests/corpus/saturating-loop-jams.ts new file mode 100644 index 000000000..fdc066eb8 --- /dev/null +++ b/tests/corpus/saturating-loop-jams.ts @@ -0,0 +1,85 @@ +// Escape-time loops in the shape of mandelbrot's rows: a short constant-count +// loop over pixel pairs whose inner loop exits once both pixels of the pair +// have escaped (`b |= 2`, `b |= 1`, `if (b === 3) break`). Pixels escape at +// different iterations or never; each byte packs the pairs' bits in order. +const LIMIT = 4; + +function row(initial: Float64Array, ci: number, bytes: number, iterations: number): number[] { + const out: number[] = []; + for (let xByte = 0; xByte < bytes; xByte++) { + let res = 0; + for (let i = 0; i < 8; i += 2) { + const x = xByte << 3; + const cr1 = initial[x + i]!; + const cr2 = initial[x + i + 1]!; + let zr1 = cr1; + let zi1 = ci; + let zr2 = cr2; + let zi2 = ci; + let b = 0; + for (let j = 0; j < iterations; j++) { + const tr1 = zr1 * zr1; + const ti1 = zi1 * zi1; + zi1 = 2 * zr1 * zi1 + ci; + zr1 = tr1 - ti1 + cr1; + if (tr1 + ti1 > LIMIT) { + b |= 2; + if (b === 3) break; + } + const tr2 = zr2 * zr2; + const ti2 = zi2 * zi2; + zi2 = 2 * zr2 * zi2 + ci; + zr2 = tr2 - ti2 + cr2; + if (tr2 + ti2 > LIMIT) { + b |= 1; + if (b === 3) break; + } + } + res = (res << 2) | b; + } + out.push(~res & 255); + } + return out; +} + +/** Two pairs per value: the loop runs two iterations. */ +function quads(initial: Float64Array, ci: number): number { + let res = 0; + for (let i = 0; i < 4; i += 2) { + let zr1 = initial[i]!; + let zr2 = initial[i + 1]!; + let b = 0; + for (let j = 0; j < 30; j++) { + zr1 = zr1 * zr1 + ci; + if (zr1 > 2) { + b |= 2; + if (b === 3) break; + } + zr2 = zr2 * zr2 + ci; + if (zr2 > 2) { + b |= 1; + if (b === 3) break; + } + } + res = res * 4 + b; + } + return res; +} + +const size = 64; +const initial = new Float64Array(size); +for (let x = 0; x < size; x++) initial[x] = (2 * x) / size - 1.5; +let checksum = 0; +const lines: string[] = []; +for (let y = 0; y < size; y += 8) { + const ci = (2 * y) / size - 1; + const bytes = row(initial, ci, size >> 3, y % 16 === 0 ? 50 : 3); + lines.push(bytes.map((v) => v.toString(16).padStart(2, "0")).join("")); + for (const v of bytes) checksum = (checksum * 31 + v) % 1000003; +} +console.log(lines.join("\n")); +console.log(checksum); +const q = new Float64Array([0.1, 1.5, -0.3, 3]); +console.log(quads(q, 0.2), quads(q, -1.9), quads(new Float64Array(4), 0)); +// A row past the end of the coordinates reads NaN, which never escapes. +console.log(row(initial, 0.5, (size >> 3) + 1, 50).slice(-2)); diff --git a/tests/harness/corpus-costs/saturating-loop-jams.ts.json b/tests/harness/corpus-costs/saturating-loop-jams.ts.json new file mode 100644 index 000000000..e615532ed --- /dev/null +++ b/tests/harness/corpus-costs/saturating-loop-jams.ts.json @@ -0,0 +1 @@ +{ "plain": 5955, "san": 72997 } From 9eaa210e1fffce3f630e96039dff4b75b9e869fe Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:54:21 -0700 Subject: [PATCH 05/18] Narrow filtered union elements into a record with an arm's fields A written type predicate whose target is no arm of the element union but the same fields as one, with a nested union field narrowed (Model<"a"> from the Model arm), was refused. The kept elements of that arm now convert field by field, the nested union by its tag; any other kept element still throws the predicate TypeError. Co-Authored-By: Claude Opus 5.5 --- .../lowering/containers/filter-predicates.ts | 36 +++++++++++++-- .../filter-predicate-model-narrow.ts.json | 6 +++ tests/corpus/filter-predicate-model-narrow.ts | 44 +++++++++++++++++++ .../filter-predicate-model-narrow.ts.json | 1 + 4 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/filter-predicate-model-narrow.ts.json create mode 100644 tests/corpus/filter-predicate-model-narrow.ts create mode 100644 tests/harness/corpus-costs/filter-predicate-model-narrow.ts.json diff --git a/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts b/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts index 3934edbf0..18e6ac984 100644 --- a/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts +++ b/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts @@ -7,6 +7,8 @@ * * - a union element to one of its arms, or to a union of some of its * arms, by its tag; + * - a union element to a record with the same fields as one of its arms, + * through that arm; * - a checked-dynamic element by dynCheck; * - a class instance to a subclass by its class; * - a tuple or record to one with the same fields, field by field. @@ -229,10 +231,35 @@ function unionConversion( if (targetArms === null) return null; const body: IrStmt[] = []; let kept = 0; + let recordArmConversions = 0; for (let tag = 0; tag < source.arms.length; tag++) { const arm = source.arms[tag]!; const targetTag = targetArms.findIndex((t) => typeEquals(t, arm)); - if (targetTag < 0) continue; + if (targetTag < 0) { + // A record target that is no arm itself but the same fields as one + // (`Model<"a">` from the `Model` arm, whose nested union field + // the predicate narrows): that arm converts field by field. + if (to.kind !== "record" || arm.kind !== "record") continue; + const narrowedArm: IrExpr = { + kind: "unionNarrow", + unionId, + tag, + value: input, + type: arm, + loc, + }; + const converted = conversionBody(lowerer, narrowedArm, arm, to, typeText, loc); + if (converted === null) continue; + recordArmConversions++; + body.push({ + kind: "if", + cond: { kind: "unionIsTag", unionId, tag, negated: false, value: input, type: BOOL, loc }, + then: converted, + else_: null, + loc, + }); + continue; + } kept++; const payload: IrExpr = isUnitType(arm) ? { kind: "unitLit", unit: arm.kind === "undefinedT" ? "undefined" : "null", type: arm, loc } @@ -249,8 +276,11 @@ function unionConversion( loc, }); } - // Every arm the target names must be one of the union's own arms. - if (kept === 0 || kept !== targetArms.length) return null; + // Every arm the target names must be one of the union's own arms, or a + // record target converts from at least one arm. + if (recordArmConversions > 0) { + if (kept !== 0) return null; + } else if (kept === 0 || kept !== targetArms.length) return null; body.push( typeError( `a kept element is not a '${typeText}' (a type predicate returned true for a value of another type)`, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/filter-predicate-model-narrow.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/filter-predicate-model-narrow.ts.json new file mode 100644 index 000000000..c74a7d818 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/filter-predicate-model-narrow.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/filter-predicate-model-narrow.ts" + ], + "diags": [] +} diff --git a/tests/corpus/filter-predicate-model-narrow.ts b/tests/corpus/filter-predicate-model-narrow.ts new file mode 100644 index 000000000..4c97445d0 --- /dev/null +++ b/tests/corpus/filter-predicate-model-narrow.ts @@ -0,0 +1,44 @@ +// `.filter` with a written type predicate whose target is no arm of the +// element union but the same fields as one, with a nested union field the +// predicate narrows (`Model<"a">` from the `Model` arm). Kept elements +// convert field by field; a kept element of another shape would throw. + +interface CompatA { + strict?: boolean; +} +interface CompatB { + effort?: string; +} +interface CompatMap { + a: CompatA; + b: CompatB; +} +type Api = keyof CompatMap; +interface Model { + kind: "chat"; + api: A; + id: string; + window: number; + compat?: CompatMap[A]; +} +interface Image { + kind: "image"; + id: string; +} +type AnyModel = Model | Image; + +function restore(stored: AnyModel[]): Model<"a">[] { + return stored.filter((model): model is Model<"a"> => model.kind === "chat" && model.api === "a"); +} + +const stored: AnyModel[] = [ + { kind: "chat", api: "a", id: "1", window: 8, compat: { strict: true } }, + { kind: "image", id: "2" }, + { kind: "chat", api: "b", id: "3", window: 4, compat: { effort: "hi" } }, + { kind: "chat", api: "a", id: "4", window: 2 }, +]; +const restored = restore(stored); +for (const model of restored) console.log(model.id, model.api, model.window, model.compat?.strict, "compat" in model); +console.log(restored.length, restored.map((model) => model.window).reduce((a, b) => a + b, 0)); + +export {}; diff --git a/tests/harness/corpus-costs/filter-predicate-model-narrow.ts.json b/tests/harness/corpus-costs/filter-predicate-model-narrow.ts.json new file mode 100644 index 000000000..2440b7ddb --- /dev/null +++ b/tests/harness/corpus-costs/filter-predicate-model-narrow.ts.json @@ -0,0 +1 @@ +{ "plain": 4307, "san": 81017 } From 2acd199fbebf6e29ccef02ec1383f85526ae359f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:54:26 -0700 Subject: [PATCH 06/18] Give an async object-literal method its contextual return type An async method in an object literal with no return annotation and a contextual signature inferred its own (narrower) literal type, while its `return` literal was built at the contextually typed record: the return statement then had to convert the wider record into the narrower literal, which fails with SC2002. The method now takes its return type from the contextual property's call signature, as the arrow form does. Sync methods are unchanged; they already adopt their slot. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/function-returns.ts | 28 ++++++++++++++++++- ...nc-object-method-contextual-return.ts.json | 6 ++++ .../async-object-method-contextual-return.ts | 14 ++++++++++ ...nc-object-method-contextual-return.ts.json | 1 + 4 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/async-object-method-contextual-return.ts.json create mode 100644 tests/corpus/async-object-method-contextual-return.ts create mode 100644 tests/harness/corpus-costs/async-object-method-contextual-return.ts.json diff --git a/packages/compiler/src/frontend/lowering/function-returns.ts b/packages/compiler/src/frontend/lowering/function-returns.ts index c692742a8..4ab067fc9 100644 --- a/packages/compiler/src/frontend/lowering/function-returns.ts +++ b/packages/compiler/src/frontend/lowering/function-returns.ts @@ -164,7 +164,33 @@ export function declaredReturnType( if (isJsSourceFile(ts.getSourceFileOfNode(decl)) && ts.isGetAccessorDeclaration(decl)) return DYN; const sig = lowerer.checker.getSignatureFromDeclaration(decl); if (!sig) lowerer.unsupported("SC1090", decl, "this function form"); - const retTsType = lowerer.checker.getReturnTypeOfSignature(sig); + let retTsType = lowerer.checker.getReturnTypeOfSignature(sig); + // An ASYNC object-literal method with no return annotation and a + // contextual signature takes its return from that signature, as an async + // arrow does: the body's `return` builds at the contextually typed + // literal, so the checker's own inferred literal (narrower than the + // contextual record) could never hold it. + if ( + ts.isMethodDeclaration(decl) && + ts.isObjectLiteralExpression(decl.parent) && + decl.type === undefined && + decl.modifiers?.nodes.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) === true + ) { + const contextual = lowerer.checker.getContextualType(decl.parent); + const slot = + contextual === undefined || (!ts.isIdentifier(decl.name) && !ts.isStringLiteral(decl.name)) + ? undefined + : lowerer.checker.getPropertyOfType( + lowerer.checker.getNonNullableType(contextual), + decl.name.text, + ); + const signatures = + slot === undefined + ? [] + : lowerer.checker.getCallSignatures(lowerer.checker.getTypeOfSymbol(slot)); + if (signatures.length === 1) + retTsType = lowerer.checker.getReturnTypeOfSignature(signatures[0]!); + } // An inferred null/undefined return in JS can read a mutable checked-value // field. Keep the actual value instead of restricting it to the initializer. if ( diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/async-object-method-contextual-return.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/async-object-method-contextual-return.ts.json new file mode 100644 index 000000000..5338193d9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/async-object-method-contextual-return.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/async-object-method-contextual-return.ts" + ], + "diags": [] +} diff --git a/tests/corpus/async-object-method-contextual-return.ts b/tests/corpus/async-object-method-contextual-return.ts new file mode 100644 index 000000000..223063079 --- /dev/null +++ b/tests/corpus/async-object-method-contextual-return.ts @@ -0,0 +1,14 @@ +type Text = { type: "text"; text: string; textSignature?: string }; +type Image = { type: "image"; data: string; mimeType: string }; +interface ToolResult { content: (Text | Image)[]; details: D; isError?: boolean } +interface ToolDefinition { name: string; execute(id: string, q: string): Promise> } +function define(): ToolDefinition<{ loaded: string[] }> { + return { + name: "search", + async execute(_id, q) { + const text = `found ${q}`; + return { content: [{ type: "text", text }], details: { loaded: [q] } }; + }, + }; +} +define().execute("1", "x").then((r) => console.log(JSON.stringify(r))); diff --git a/tests/harness/corpus-costs/async-object-method-contextual-return.ts.json b/tests/harness/corpus-costs/async-object-method-contextual-return.ts.json new file mode 100644 index 000000000..b0f0df482 --- /dev/null +++ b/tests/harness/corpus-costs/async-object-method-contextual-return.ts.json @@ -0,0 +1 @@ +{ "plain": 3268, "san": 6404 } From 8e74d0518d20dcebad64fbbe8591f209102a4bab Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:05:21 -0700 Subject: [PATCH 07/18] Give an untyped empty Map the layout of the value it joins `x ?? { errors: new Map() }` left the empty map typed Map, which spread through the checker into `new Map(result.errors)` and the binding holding it, and every use failed with SC2020/SC1090. The fallback literal now builds as x's record, `new Map(source)` over such a join copies into the source's layout, an unannotated binding whose checker type differs only by that residue takes its initializer's layout, and Map methods on it convert into the receiver's layout. No map is converted between layouts: each value keeps the one layout it has. Co-Authored-By: Claude Opus 5.5 --- .../lowering/any-collection-residue.ts | 44 +++++++++++++++++++ .../lowering/containers/collection-methods.ts | 11 +++++ .../frontend/lowering/expressions/binary.ts | 43 ++++++++++++++++++ .../src/frontend/lowering/lower-classes.ts | 35 ++++++++++++++- .../src/frontend/lowering/lower-stmts.ts | 14 ++++++ .../corpus/map-untyped-empty-fallback.ts.json | 6 +++ tests/corpus/map-untyped-empty-fallback.ts | 41 +++++++++++++++++ .../map-untyped-empty-fallback.ts.json | 1 + 8 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/src/frontend/lowering/any-collection-residue.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/map-untyped-empty-fallback.ts.json create mode 100644 tests/corpus/map-untyped-empty-fallback.ts create mode 100644 tests/harness/corpus-costs/map-untyped-empty-fallback.ts.json diff --git a/packages/compiler/src/frontend/lowering/any-collection-residue.ts b/packages/compiler/src/frontend/lowering/any-collection-residue.ts new file mode 100644 index 000000000..7581c0fed --- /dev/null +++ b/packages/compiler/src/frontend/lowering/any-collection-residue.ts @@ -0,0 +1,44 @@ +import { type IrType, typeKey } from "../../ir/ir.js"; +import type { Lowerer } from "./lowerer.js"; + +/** Whether `actual` is `declared` with every `Map` or + * `Set` (TypeScript's `any` type arguments of an untyped empty + * collection, mapped to unknown) replaced by a map or set of a concrete + * layout, and with nothing else different. A union `declared` matches when + * `actual` is one of its record arms and every other arm is the residue + * form of that arm. */ +export function anyResidueReplacedBy(lowerer: Lowerer, declared: IrType, actual: IrType): boolean { + const residue = (t: IrType): boolean => + (t.kind === "map" && t.key.kind === "dyn" && t.value.kind === "dyn") || + (t.kind === "set" && t.elem.kind === "dyn"); + const replaces = (d: IrType, a: IrType, seen: Set): boolean => { + if (typeKey(d) === typeKey(a)) return true; + if (residue(d)) return a.kind === d.kind && !residue(a); + if (d.kind === "record" && a.kind === "record") { + const pair = `${d.shapeId}>${a.shapeId}`; + if (seen.has(pair)) return true; + seen.add(pair); + const from = lowerer.shapes.get(d.shapeId); + const to = lowerer.shapes.get(a.shapeId); + if (!from || !to || from.tuple || to.tuple || from.indexValue || to.indexValue) return false; + if (from.fields.length !== to.fields.length) return false; + return from.fields.every((field) => { + const other = to.fields.find((f) => f.name === field.name); + return other !== undefined && replaces(field.type, other.type, seen); + }); + } + return false; + }; + if (declared.kind === "union" && actual.kind === "record") { + const arms = lowerer.unions.get(declared.unionId)?.arms ?? []; + return ( + arms.some((arm) => typeKey(arm) === typeKey(actual)) && + arms.every( + (arm) => + typeKey(arm) === typeKey(actual) || + (arm.kind === "record" && replaces(arm, actual, new Set())), + ) + ); + } + return replaces(declared, actual, new Set()); +} diff --git a/packages/compiler/src/frontend/lowering/containers/collection-methods.ts b/packages/compiler/src/frontend/lowering/containers/collection-methods.ts index 21cc36016..c2fae508d 100644 --- a/packages/compiler/src/frontend/lowering/containers/collection-methods.ts +++ b/packages/compiler/src/frontend/lowering/containers/collection-methods.ts @@ -76,6 +76,17 @@ export function lowerMapMethodCall( if (!probedUntyped && !lowerer.isStdlibMember(access) && name !== "has") return null; const loc = locOf(call); const value = lowerer.lowerExpr(access.expression); + // A receiver the checker types `Map` (inference residue of an + // untyped `new Map()`) whose value has a concrete layout: its keys and + // values convert into that layout. + if ( + value.type.kind === "map" && + receiverIr.key.kind === "dyn" && + receiverIr.value.kind === "dyn" && + value.type.key.kind !== "dyn" && + value.type.value.kind !== "dyn" + ) + receiverIr = value.type; const receiver = lowerer.runtimeOptionalPropertyReceiver(access.expression, value, receiverIr, name) ?? value; if (receiver.type.kind !== "map") { diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index 58ae3e7bd..e20604f92 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -354,6 +354,31 @@ function literalDefault(node: ts.Expression): boolean { ); } +/** A fresh object literal (parentheses allowed) one of whose property + * values, at any literal depth, is `new Map()` or `new Set()` of the + * standard library with neither arguments nor type arguments. */ +function untypedCollectionFallback(lowerer: Lowerer, node: ts.Expression): boolean { + let value = node; + while (ts.isParenthesizedExpression(value)) value = value.expression; + if (!ts.isObjectLiteralExpression(value)) return false; + return value.properties.nodes.some((property) => { + if (!ts.isPropertyAssignment(property)) return false; + let inner = property.initializer; + while (ts.isParenthesizedExpression(inner)) inner = inner.expression; + if (ts.isObjectLiteralExpression(inner)) return untypedCollectionFallback(lowerer, inner); + if ( + !ts.isNewExpression(inner) || + !ts.isIdentifier(inner.expression) || + (inner.arguments?.nodes.length ?? 0) !== 0 || + (inner.typeArguments?.nodes.length ?? 0) !== 0 + ) + return false; + const symbol = lowerer.resolveValueSymbol(inner.expression); + const name = symbol ? ts.symbolName(symbol) : ""; + return symbol !== null && lowerer.isStdlibSymbol(symbol) && (name === "Map" || name === "Set"); + }); +} + export function lowerNullishPair( lowerer: Lowerer, expr: ts.BinaryExpression, @@ -387,6 +412,24 @@ export function lowerNullishPair( // arm that has no first-class union representation. const fresh = rest.length === 1 ? lowerer.emptyCollectionFor(expr.right, rest[0]!) : null; if (fresh) return { kind: "nullish", left, right: fresh, type: fresh.type, loc }; + // `optionalRecord ?? { a: 1, items: new Map() }`: a fresh fallback literal + // whose untyped empty collections (`new Map()`, `new Set()` without type + // arguments, which the checker types `Map`) leave the checker's + // join as a second record arm. When the literal is assignable to the + // left's record, it builds as that record, so the collections take its + // element types and the result keeps one layout. + if ( + rest.length === 1 && + rest[0]!.kind === "record" && + untypedCollectionFallback(lowerer, expr.right) && + lowerer.checker.isTypeAssignableTo( + lowerer.typeOf(expr.right), + lowerer.checker.getNonNullableType(lowerer.typeOf(expr.left)), + ) + ) { + const right = lowerer.lowerExprExpecting(expr.right, rest[0]!); + return { kind: "nullish", left, right, type: rest[0]!, loc }; + } let type = lowerer.irTypeOf(expr); // A runtime-optional read can reach the default even when declarations // call the left side present. Preserve a differently typed primitive diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 61de74d83..95364441a 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -133,7 +133,11 @@ import { isAssertClassNew } from "./assert-instances.js"; import { isCjsRequireGlobal } from "./lower-node-module.js"; import { lowerArrayConstructor } from "./containers/array-construction.js"; import { initializedCollectionType } from "./js-collection-types.js"; -import { lowerMapSeedNew, lowerSetSeedNew } from "./containers/collection-methods.js"; +import { + lowerMapSeedNew, + lowerSetSeedNew, + mapFromSeedValue, +} from "./containers/collection-methods.js"; import { lowerObjectConstructor } from "./containers/indexed-objects.js"; import { knownBufEncoding, lowerBufferConstruct } from "./containers/bytes.js"; import { lowerSearchParamsNew } from "./builtins/url.js"; @@ -11062,6 +11066,35 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { mapped = ctxMapped; } } + // `new Map(source)` the checker types `Map` because the + // source's checker type joins an untyped `new Map()` with a typed map + // (a union of map types): the copy takes the layout the source value + // actually has. + if ( + seedArg && + !entriesLit && + mapped?.kind === "map" && + mapped.key.kind === "dyn" && + mapped.value.kind === "dyn" && + (expr.typeArguments?.nodes.length ?? 0) === 0 && + !isJsSourceFile(ts.getSourceFileOfNode(expr)) && + lowerer.mapTypeOf(lowerer.typeOf(seedArg))?.kind === "union" + ) { + const source = lowerer.lowerExpr(seedArg); + if ( + source.type.kind === "map" && + source.type.key.kind !== "dyn" && + source.type.value.kind !== "dyn" + ) { + const copied = mapFromSeedValue(lowerer, source, source.type); + if (copied) return copied; + } + lowerer.noLowering( + "new Map(entries)", + expr, + "a source whose map layouts differ (give the empty `new Map()` its type arguments)", + ); + } if (seedArg && !entriesLit && mapped?.kind === "map") { const seeded = lowerMapSeedNew(lowerer, seedArg, mapped); if (seeded) return seeded; diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index ee991091f..5f8236fa8 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -32,6 +32,7 @@ import { lowerCollectionSpread } from "./containers/collection-methods.js"; import { lowerEventObjectPropertyWrite } from "./lower-event-target.js"; import { lowerDynObjectLiteral } from "./expressions/object-literals.js"; import { importMetaBindingSource } from "./import-meta.js"; +import { anyResidueReplacedBy } from "./any-collection-residue.js"; import { iteratorCanStep, iteratorValue } from "./iterator-consumption.js"; import { notIterableSpelling } from "./iterable-spelling.js"; import { isPrivateSplitConsumer } from "./split-consumers.js"; @@ -6426,6 +6427,19 @@ export function lowerVarDecl( typeInvolvesWebStream(lowerer, lowerer.typeOf(decl.name)))) ) type = DYN; + // An unannotated TypeScript binding whose checker type carries `any` + // inference residue (`Map` from an untyped `new Map()` joined + // into the initializer's type) takes the initializer's own layout when + // that layout replaces only the residue: `any` admits every use, and the + // value has that layout. + if ( + !decl.type && + type !== null && + !isJsSourceFile(ts.getSourceFileOfNode(decl)) && + !typeEquals(type, init.type) && + anyResidueReplacedBy(lowerer, type, init.type) + ) + type = init.type; // A const whose initializer has another record or array layout than the // binding's type keeps the initializer's layout: converting would copy the // value, so the binding would not be the value its initializer names. diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/map-untyped-empty-fallback.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/map-untyped-empty-fallback.ts.json new file mode 100644 index 000000000..364970fee --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/map-untyped-empty-fallback.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/map-untyped-empty-fallback.ts" + ], + "diags": [] +} diff --git a/tests/corpus/map-untyped-empty-fallback.ts b/tests/corpus/map-untyped-empty-fallback.ts new file mode 100644 index 000000000..e9506dac1 --- /dev/null +++ b/tests/corpus/map-untyped-empty-fallback.ts @@ -0,0 +1,41 @@ +// An untyped `new Map()` in a `??` fallback literal: the fallback builds +// as the left operand's record, so the empty map takes its key and value +// types. `new Map(result.errors)` then copies a typed map, and writes to +// the copy (directly and from a callback) keep that layout. + +interface RefreshResult { + aborted: boolean; + errors: ReadonlyMap; +} + +async function refresh(fail: boolean): Promise { + if (fail) return undefined; + const errors = new Map(); + errors.set("openai", new Error("boom")); + return { aborted: false, errors }; +} + +async function run(fail: boolean, providers: string[]): Promise<{ aborted: boolean; errors: Map }> { + const result = (await refresh(fail)) ?? { aborted: fail, errors: new Map() }; + const errors = new Map(result.errors); + errors.set("local", new Error("extra")); + await Promise.all( + providers.map(async (provider) => { + if (provider.startsWith("bad")) errors.set(provider, new Error(`${provider} failed`)); + }), + ); + return { aborted: result.aborted, errors }; +} + +async function main(): Promise { + for (const fail of [false, true]) { + const r = await run(fail, ["ok", "bad-a", "bad-b"]); + const names = [...r.errors.keys()].sort().join(","); + console.log(r.aborted, names, r.errors.get("local")?.message, r.errors.get("bad-b")?.message); + } + const source = new Map([["a", 1]]); + const copy = new Map(source); + copy.set("b", 2); + console.log(source.size, copy.size, copy !== source); +} +void main(); diff --git a/tests/harness/corpus-costs/map-untyped-empty-fallback.ts.json b/tests/harness/corpus-costs/map-untyped-empty-fallback.ts.json new file mode 100644 index 000000000..ac2bc9413 --- /dev/null +++ b/tests/harness/corpus-costs/map-untyped-empty-fallback.ts.json @@ -0,0 +1 @@ +{ "plain": 3815, "san": 6037 } From c3474cfa7ae6d0ef1e6620040aa5237ecce641f8 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 22:30:01 -0500 Subject: [PATCH 08/18] Test that receivers are evaluated once and before effectful keys Adds the corpus program receiver-before-effectful-key.ts, with its corpus cost and order baseline: - a tuple read with a runtime index that rebinds the tuple or replaces the field holding it; - a keyed read through a finite union of field names whose key rebinds the record or replaces the field holding it; - an inherited-key `in` test on a computed null-prototype receiver, which runs the receiver once. Each case reads the receiver as evaluated before the key, as in Node. --- .../receiver-before-effectful-key.ts.json | 6 ++ tests/corpus/receiver-before-effectful-key.ts | 59 +++++++++++++++++++ .../receiver-before-effectful-key.ts.json | 1 + 3 files changed, 66 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/receiver-before-effectful-key.ts.json create mode 100644 tests/corpus/receiver-before-effectful-key.ts create mode 100644 tests/harness/corpus-costs/receiver-before-effectful-key.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/receiver-before-effectful-key.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/receiver-before-effectful-key.ts.json new file mode 100644 index 000000000..b44d5a127 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/receiver-before-effectful-key.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/receiver-before-effectful-key.ts" + ], + "diags": [] +} diff --git a/tests/corpus/receiver-before-effectful-key.ts b/tests/corpus/receiver-before-effectful-key.ts new file mode 100644 index 000000000..496c9c4c3 --- /dev/null +++ b/tests/corpus/receiver-before-effectful-key.ts @@ -0,0 +1,59 @@ +// A receiver is evaluated once, before its key, even when the key's +// evaluation rebinds the receiver's binding or has other effects: a tuple +// read with a runtime index, a keyed read through a finite union of field +// names, and an inherited-key `in` test on a computed index-signature +// receiver. + +const log: string[] = []; + +// Tuple read with a runtime index that rebinds the tuple. +let pair: [string, number] = ["first", 1]; +function rebindPair(i: number): number { + log.push(`rebind pair ${i}`); + pair = ["second", 2]; + return i; +} +const viaIndex = pair[rebindPair(0)]; +console.log("tuple", viaIndex, pair[0]); + +// A field holding a tuple, replaced by the index. +const holder: { t: [number, number, number] } = { t: [10, 20, 30] }; +function replaceHeld(i: number): number { + holder.t = [-1, -2, -3]; + return i; +} +console.log("tuple field", holder.t[replaceHeld(2)], holder.t[2]); + +// Keyed read through a finite union of keys naming a subset of the fields, +// with a key that rebinds the local or replaces the field holding the record. +type Row = { width?: number; label: string; tags: string[] }; +function finiteKeys(): void { + let row: Row = { width: 640, label: "old", tags: [] }; + const pick = (): "width" | "label" => { + log.push("pick key"); + row = { width: 1, label: "new", tags: ["x"] }; + return "label"; + }; + const picked = row[pick()]; + console.log("keyed", picked, row.label); + const box: { row: Row } = { row: { label: "boxed", tags: [] } }; + const replace = (): "width" | "label" => { + box.row = { width: 7, label: "replaced", tags: [] }; + return "width"; + }; + const width = box.row[replace()]; + console.log("keyed field", width, box.row.width); +} +finiteKeys(); + +// `in` on an inherited key of a computed null-prototype record: the +// receiver call runs once. +let groups = 0; +function grouped(): Partial> { + groups++; + log.push("grouped"); + return Object.groupBy([1, 2, 3], (n) => (n % 2 === 0 ? "even" : "odd")); +} +console.log("in", "toString" in grouped(), "hasOwnProperty" in grouped(), groups); + +console.log(log.join(" | ")); diff --git a/tests/harness/corpus-costs/receiver-before-effectful-key.ts.json b/tests/harness/corpus-costs/receiver-before-effectful-key.ts.json new file mode 100644 index 000000000..bb380e241 --- /dev/null +++ b/tests/harness/corpus-costs/receiver-before-effectful-key.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 3000 } From 269ef9f0bc99d3469529737ed1b86518fe90491a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 22:43:27 -0500 Subject: [PATCH 09/18] Read each layout training profile when its build finishes refresh-cli-layout.mjs kept the raw profile of every cold training build until the end of the run, about 23 MB per build and 8.7 GB for the 399 builds. When the disk filled, the profile runtime wrote partial files, the compiler still exited with 0, and reading a partial file threw a RangeError past the end of its counters. Each cold profile is now read and deleted when its build finishes, so the work directory stays under 200 MB. The script throws when a training run reports `LLVM Profile Error`, naming the build and the write error, and when a profile is shorter than its header describes. --- scripts/refresh-cli-layout.mjs | 47 +++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/scripts/refresh-cli-layout.mjs b/scripts/refresh-cli-layout.mjs index cb396015f..4b7437a0d 100644 --- a/scripts/refresh-cli-layout.mjs +++ b/scripts/refresh-cli-layout.mjs @@ -92,9 +92,11 @@ const scriptc = join(cli, "bin/scriptc"); const baseEnv = { ...process.env, SCRIPTC_TIMING: "1" }; delete baseEnv["SCRIPTC_LLVM_HELPER"]; -/** Runs the instrumented compiler, writing its profile to `options.profile`. */ +/** Runs the instrumented compiler, writing its profile to `options.profile`. + * Throws when the profile runtime reports that it could not write the + * profile (a full disk, for example): the compiler still exits with 0. */ function run(args, options) { - return new Promise((done) => { + return new Promise((done, fail) => { const child = spawn(scriptc, args, { cwd: options.cwd, stdio: ["ignore", "pipe", "pipe"], @@ -109,7 +111,9 @@ function run(args, options) { child.stderr.on("data", (chunk) => (text += chunk)); child.on("close", (code) => { appendFileSync(log, `$ scriptc ${args.join(" ")}\n${text}exit ${code}\n`); - done({ ok: code === 0, text }); + const profileError = /^LLVM Profile Error: .*$/m.exec(text)?.[0]; + if (profileError !== undefined) fail(new Error(`scriptc ${args.join(" ")}: ${profileError}`)); + else done({ ok: code === 0, text }); }); }); } @@ -142,8 +146,24 @@ const training = [ // rebuild that missed a cache (it shows no `frontend-cache-hit` or // `executable-cache-hit` phase) is left out. The first builds each write // their own profile (cold--*), merged across the build's processes. +// Each profile is read and deleted when its build finishes: a profile is +// about 23 MB, and keeping every build's until the end takes about 9 GB. const warm = new Set(); let skipped = 0; +// For each function, how many of the first builds ran it. +const runs = new Map(); +let coldBuilds = 0; +function countColdRun(index) { + const profiles = readdirSync(raw).filter((name) => name.startsWith(`cold-${index}-`)); + if (profiles.length === 0) return; + const ran = new Set(); + for (const name of profiles) { + for (const fn of executedFunctions(join(raw, name))) ran.add(fn); + rmSync(join(raw, name)); + } + for (const fn of ran) runs.set(fn, (runs.get(fn) ?? 0) + 1); + coldBuilds++; +} async function warmRun(args, cache, name, cacheHits) { const profile = join(raw, `warm-${name}.profraw`); const { ok, text } = await run(args, { cwd: root, cache, profile }); @@ -166,6 +186,7 @@ const passed = await pool( cache, profile: join(raw, `cold-${index}-%1m.profraw`), }); + countColdRun(index); if (ok) await warmRun(args, cache, String(index), ["frontend-cache-hit", "executable-cache-hit"]); rmSync(cache, { recursive: true, force: true }); @@ -196,6 +217,12 @@ function executedFunctions(path) { const dataStart = 16 * 8 + binaryIdsBytes; const countersStart = dataStart + records * recordBytes + beforeCounters; const namesStart = countersStart + counters * 8 + afterCounters + bitmapBytes + afterBitmap; + // The profile runtime reports a failed write (a full disk, for example) on + // stderr, and the compiler still exits with 0, leaving a partial file. + if (buf.length < namesStart + namesBytes) + throw new Error( + `${path} is ${buf.length} bytes, shorter than the ${namesStart + namesBytes} bytes its header describes`, + ); const countersDelta = buf.readBigInt64LE(10 * 8); const names = new Map(); let at = namesStart; @@ -241,18 +268,8 @@ function executedFunctions(path) { // tier 2 for functions at least 90% of the builds run, tier 3 for 60%, and so // on; tier 7 holds the functions fewer than 3% of the builds run. const COLD_TIERS = [0.9, 0.6, 0.3, 0.1, 0.03]; -const coldRuns = new Map(); -for (const name of readdirSync(raw)) { - const program = /^cold-(\d+)-/.exec(name)?.[1]; - if (program === undefined) continue; - const ran = coldRuns.get(program) ?? new Set(); - for (const fn of executedFunctions(join(raw, name))) ran.add(fn); - coldRuns.set(program, ran); -} -const runs = new Map(); -for (const ran of coldRuns.values()) for (const fn of ran) runs.set(fn, (runs.get(fn) ?? 0) + 1); const coldTier = (count) => { - const share = count / coldRuns.size; + const share = count / coldBuilds; const index = COLD_TIERS.findIndex((least) => share >= least); return 2 + (index < 0 ? COLD_TIERS.length : index); }; @@ -269,7 +286,7 @@ const perTier = new Map(); for (const line of lines) perTier.set(line.split(" ")[0], (perTier.get(line.split(" ")[0]) ?? 0) + 1); console.log( - `wrote ${output}: ${[...perTier].map(([tier, count]) => `tier ${tier}: ${count}`).join(", ")} functions, from ${coldRuns.size} builds (${statSync(output).size} bytes)`, + `wrote ${output}: ${[...perTier].map(([tier, count]) => `tier ${tier}: ${count}`).join(", ")} functions, from ${coldBuilds} builds (${statSync(output).size} bytes)`, ); rmSync(builds, { recursive: true, force: true }); if (values.work === undefined) rmSync(work, { recursive: true, force: true }); From 01a9cce36d933e8cde6de300469fe6112a48b020 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 22:50:15 -0500 Subject: [PATCH 10/18] Name alias type nodes by category The emitter named each TBAA type node `field.`, numbered in the order the module's functions first use the categories, so an edit that adds a field or memory category renumbered every later one. Every function that loads or stores those fields then changed text, and its program partition was compiled again. Each type node is now named by its category (`field:`, `array:header`, `global:`). The set of nodes and their structure are unchanged, so the generated code is the same. --- .../compiler/src/backend/llvm/receivers.ts | 12 ++- .../test/alias-metadata-names.test.ts | 75 +++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/alias-metadata-names.test.ts diff --git a/packages/compiler/src/backend/llvm/receivers.ts b/packages/compiler/src/backend/llvm/receivers.ts index e2a362bf7..240e1946a 100644 --- a/packages/compiler/src/backend/llvm/receivers.ts +++ b/packages/compiler/src/backend/llvm/receivers.ts @@ -11,6 +11,7 @@ import { import type { LlValue } from "./expr-context.js"; import { guardedValue } from "./borrowed-receivers.js"; import { InternalCompilerError } from "../../errors.js"; +import { llvmQuoted } from "../literals.js"; import { emitBorrowedArrayRead, emitProjectedArrayRead } from "./local-array-reads.js"; import { emitBorrowedMapGet, @@ -459,12 +460,19 @@ export function analyzeBorrowedReturns(emitter: LlEmitter): void { } } +/** The alias metadata of the module's field and memory categories. Each type + * node is named by its category (`field:`, `array:header`, ...), not + * by its number: numbers follow the order in which the module's functions + * first use the categories, so an edit that adds one renumbers the rest. The + * LLVM helper renumbers each program partition's metadata, so a partition + * whose functions are unchanged keeps the same text and its cached + * artifacts. */ export function fieldAliasMetadata(emitter: LlEmitter): string[] { if (emitter.fieldAliasTags.size === 0) return []; const lines = ['!0 = !{!"scriptc native fields"}']; - for (const tag of emitter.fieldAliasTags.values()) { + for (const [category, tag] of emitter.fieldAliasTags) { lines.push( - `!${tag - 1} = !{!"field.${tag}", !0, i64 0}`, + `!${tag - 1} = !{!${llvmQuoted(category)}, !0, i64 0}`, `!${tag} = !{!${tag - 1}, !${tag - 1}, i64 0}`, ); } diff --git a/packages/compiler/test/alias-metadata-names.test.ts b/packages/compiler/test/alias-metadata-names.test.ts new file mode 100644 index 000000000..3674ce50b --- /dev/null +++ b/packages/compiler/test/alias-metadata-names.test.ts @@ -0,0 +1,75 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; + +async function emit(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-alias-names-")); + try { + const entry = join(dir, "main.ts"), + output = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath: output, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n")); + return emitLlvmModule(deserializeModule(await readFile(output, "utf8"))); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** The alias type names that a function's tagged loads and stores use, in order. */ +function aliasNames(llvm: string, symbol: string): string[] { + const found = new RegExp(`^define [^\\n]*@${symbol}\\([^]*?^}`, "m").exec(llvm); + expect(found, symbol).not.toBeNull(); + const node = (id: string) => new RegExp(`^!${id} = !\\{(.*)\\}$`, "m").exec(llvm)?.[1] ?? ""; + return [...found![0].matchAll(/!tbaa !(\d+)/g)].map((tag) => { + const type = /^!(\d+),/.exec(node(tag[1]!))?.[1] ?? ""; + return /^!"([^"]*)"/.exec(node(type))?.[1] ?? ""; + }); +} + +const points = ` +class Point { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } +} +function sum(points: Point[]): number { + let total = 0; + for (const p of points) total += p.x * p.y; + return total; +} +`; + +test("alias type nodes are named by category, so a field added earlier leaves the others' names", async () => { + const before = await emit( + `${points}const all = [new Point(1, 2), new Point(3, 4)];\nconsole.log(sum(all));\n`, + ); + // A class whose field the module uses before Point's. + const after = await emit(` +class Weight { + grams: number; + constructor(grams: number) { + this.grams = grams; + } +} +function heaviest(weights: Weight[]): number { + let most = 0; + for (const w of weights) most = Math.max(most, w.grams); + return most; +} +console.log(heaviest([new Weight(5), new Weight(7)])); +${points}const all = [new Point(1, 2), new Point(3, 4)]; +console.log(sum(all)); +`); + const names = aliasNames(before, "sc_b?f_sum"); + expect(names.length).toBeGreaterThan(0); + expect(names.every((name) => name !== "" && !/^field\.\d+$/.test(name))).toBe(true); + expect(aliasNames(after, "sc_b?f_sum")).toEqual(names); +}); From a64b5b082050b6879bbd0dc1b218c21122490838 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 23:01:16 -0500 Subject: [PATCH 11/18] Keep saturating loops whose copies cannot share the inner loop The jam of a short constant-count loop now leaves the loop as it is when: - the inner loop's body assigns the accumulator other than by `acc = acc | C`. An iteration that runs past its exit could clear bits after the accumulator was full. - the inner loop's body assigns the inner counter. The copies share the counter, so one copy's write changed the iterations of the others. - the inner loop's start or bound reads the outer counter or a local of the outer iteration. The copies share the inner loop's start and bound, which read neither the counter's value nor the copies' renamed locals. The unit test rejects each shape, and the corpus program adds a function for each that matches Node's output. --- .../src/ir/saturating-loop-jams.test.ts | 46 +++++++++-- .../compiler/src/ir/saturating-loop-jams.ts | 55 ++++++++----- tests/corpus/saturating-loop-jams.ts | 79 +++++++++++++++++++ 3 files changed, 155 insertions(+), 25 deletions(-) diff --git a/packages/compiler/src/ir/saturating-loop-jams.test.ts b/packages/compiler/src/ir/saturating-loop-jams.test.ts index b3d1d0fe9..e25b17e80 100644 --- a/packages/compiler/src/ir/saturating-loop-jams.test.ts +++ b/packages/compiler/src/ir/saturating-loop-jams.test.ts @@ -19,10 +19,16 @@ const bin = (op: Op, left: IrExpr, right: IrExpr): IrExpr => ({ const decl = (id: string, init: IrExpr): IrStmt => ({ loc, kind: "varDecl", localId: id, init }); const set = (id: string, value: IrExpr): IrStmt => ({ loc, kind: "assign", localId: id, value }); const stepBy = (id: string, by: number): IrStmt => set(id, bin("+", ref(id), num(by))); -const loop = (id: string, end: IrExpr, by: number, body: IrStmt[]): IrStmt & { kind: "for" } => ({ +const loop = ( + id: string, + end: IrExpr, + by: number, + body: IrStmt[], + start: IrExpr = num(0), +): IrStmt & { kind: "for" } => ({ loc, kind: "for", - init: decl(id, num(0)), + init: decl(id, start), cond: bin("<", ref(id), end), update: stepBy(id, by), body, @@ -61,7 +67,15 @@ function lane(z: string, c: string, bit: number, full = 3, extra: IrStmt[] = []) } function nest( - options: { full?: number; post?: IrStmt[]; preExtra?: IrStmt[]; copies?: number } = {}, + options: { + full?: number; + post?: IrStmt[]; + preExtra?: IrStmt[]; + innerExtra?: IrStmt[]; + innerStart?: IrExpr; + innerEnd?: IrExpr; + copies?: number; + } = {}, ): IrStmt[] { const post = options.post ?? [set("res", bin("|", bin("<<", ref("res"), num(2)), ref("b")))]; return [ @@ -73,10 +87,17 @@ function nest( decl("z2", ref("c2")), decl("b", num(0)), ...(options.preExtra ?? []), - loop("j", num(50), 1, [ - ...lane("z1", "c1", 2, options.full), - ...lane("z2", "c2", 1, options.full), - ]), + loop( + "j", + options.innerEnd ?? num(50), + 1, + [ + ...lane("z1", "c1", 2, options.full), + ...lane("z2", "c2", 1, options.full), + ...(options.innerExtra ?? []), + ], + options.innerStart, + ), ...post, ]), ]; @@ -162,6 +183,17 @@ test("keeps loops whose exits or values do not allow running iterations together reject(nest({ post: [set("res", bin("+", ref("res"), ref("z1")))] })); // `pre` reads what `post` writes. reject(nest({ preExtra: [decl("seed", ref("res"))] })); + // The inner loop writes the accumulator other than by an OR: a later + // iteration can clear bits after an exit. + reject(nest({ innerExtra: [set("b", num(0))] })); + reject(nest({ innerExtra: [set("b", bin("+", ref("b"), num(4)))] })); + // The inner loop's body advances its counter, which the copies share. + reject(nest({ innerExtra: [stepBy("j", 1)] })); + // The inner loop's start or bound reads the counter or a local of the + // iteration. + reject(nest({ innerStart: ref("i") })); + reject(nest({ innerEnd: bin("+", num(10), ref("i")) })); + reject(nest({ preExtra: [decl("start", num(3))], innerStart: ref("start") })); // One iteration, or more than eight. reject(nest({ copies: 1 })); reject(nest({ copies: 9 })); diff --git a/packages/compiler/src/ir/saturating-loop-jams.ts b/packages/compiler/src/ir/saturating-loop-jams.ts index ae4bd38c8..96973a483 100644 --- a/packages/compiler/src/ir/saturating-loop-jams.ts +++ b/packages/compiler/src/ir/saturating-loop-jams.ts @@ -121,6 +121,20 @@ function pureStatements(list: readonly IrStmt[], locals: ReadonlyMap= 0 && bits <= 0x7fffffff ? bits : null; +} + /** The statements of the inner loop's body: pure declarations and * assignments, `if` statements over them, `acc = acc | C`, and * `if (acc === full) break` (collected into `breaks`). */ @@ -153,19 +167,12 @@ function innerStatements( innerStatements(s.else_ ?? [], locals, acc) ); } - if ( - s.kind === "assign" && - s.value.kind === "bin" && - s.value.op === "|" && - isRef(s.value.left, s.localId) - ) { - const bits = intLit(s.value.right); - if (bits !== null && bits >= 0 && bits <= 0x7fffffff) { - if (acc.id !== null && acc.id !== s.localId) return false; - acc.id = s.localId; - acc.bits |= bits; - return true; - } + const bits = orBits(s); + if (s.kind === "assign" && bits !== null) { + if (acc.id !== null && acc.id !== s.localId) return false; + acc.id = s.localId; + acc.bits |= bits; + return true; } return pureStatements([s], locals); }); @@ -263,6 +270,14 @@ function matchJam( // it only by its ORs. const accDecl = pre.find((s) => s.kind === "varDecl" && s.localId === accId); if (accDecl?.kind !== "varDecl" || intLit(accDecl.init) === null) return null; + const onlyOrs = everyStmtList(inner.body, { + expr: () => true, + stmt: (s) => writtenLocal(s) !== accId || orBits(s) !== null, + }); + if (!onlyOrs) return null; + // Every copy shares the inner counter: only the inner loop's update writes + // it. + if (writes(inner.body).has(j)) return null; // The counter, the inner bound and every value the inner loop reads from // outside the iteration are the same for every copy: only `pre` and the // counter vary. @@ -280,12 +295,16 @@ function matchJam( for (const id of postWrites) if (reads([...pre, inner], id) || own.has(id)) return null; for (const id of writes(pre)) if (!own.has(id)) return null; // After the inner loop, iterations past a copy's exit are dead: `post` - // reads no value the inner loop writes besides the accumulator, and the - // inner bound reads nothing the copies write. + // reads no value the inner loop writes besides the accumulator. for (const id of innerWrites) if (id !== accId && reads(post, id)) return null; - for (const id of own) - if (id !== j && reads([{ kind: "exprStmt", expr: inner.cond, loc: inner.loc }], id)) - return null; + // The copies share the inner loop's start and bound: they read neither the + // counter nor a local of the iteration. + const controls: IrStmt[] = [ + { kind: "exprStmt", expr: inner.init.init, loc: inner.loc }, + { kind: "exprStmt", expr: inner.cond, loc: inner.loc }, + ]; + if (reads(controls, counter)) return null; + for (const id of own) if (id !== j && reads(controls, id)) return null; return { counter, values, pre, inner, post, acc: accId, full }; } diff --git a/tests/corpus/saturating-loop-jams.ts b/tests/corpus/saturating-loop-jams.ts index fdc066eb8..fb0fd5bb3 100644 --- a/tests/corpus/saturating-loop-jams.ts +++ b/tests/corpus/saturating-loop-jams.ts @@ -66,6 +66,82 @@ function quads(initial: Float64Array, ci: number): number { return res; } +// Loops the jam keeps as they are. The inner loop clears the accumulator. +function resets(initial: Float64Array): number { + let res = 0; + for (let i = 0; i < 4; i += 2) { + let zr1 = initial[i]!; + let zr2 = initial[i + 1]!; + let b = 0; + for (let j = 0; j < 30; j++) { + zr1 = zr1 * zr1 + 0.2; + if (zr1 > 2) { + b |= 2; + if (b === 3) break; + } + zr2 = zr2 * zr2 + 0.2; + if (zr2 > 2) { + b |= 1; + if (b === 3) break; + } + if (j > 20) b = 0; + } + res = res * 4 + b; + } + return res; +} + +// The inner loop's body advances its counter. +function skips(initial: Float64Array): number { + let res = 0; + for (let i = 0; i < 4; i += 2) { + let zr1 = initial[i]!; + let zr2 = initial[i + 1]!; + let b = 0; + let steps = 0; + for (let j = 0; j < 16; j++) { + zr1 = zr1 * zr1 + 0.2; + if (zr1 > 2) { + b |= 2; + if (b === 3) break; + } + zr2 = zr2 * zr2 + 0.2; + if (zr2 > 2) { + b |= 1; + if (b === 3) break; + } + if (zr1 < 0.5) j = j + 1; + } + res = res * 4 + b; + } + return res; +} + +// The inner loop's start and bound depend on the outer iteration. +function shifted(initial: Float64Array): number { + let res = 0; + for (let i = 0; i < 4; i += 2) { + let zr1 = initial[i]!; + let zr2 = initial[i + 1]!; + let b = 0; + const start = i * 3; + for (let j = start; j < 10 + i; j++) { + zr1 = zr1 * zr1 + 0.2; + if (zr1 > 2) { + b |= 2; + if (b === 3) break; + } + zr2 = zr2 * zr2 + 0.2; + if (zr2 > 2) { + b |= 1; + if (b === 3) break; + } + } + res = res * 4 + b; + } + return res; +} + const size = 64; const initial = new Float64Array(size); for (let x = 0; x < size; x++) initial[x] = (2 * x) / size - 1.5; @@ -83,3 +159,6 @@ const q = new Float64Array([0.1, 1.5, -0.3, 3]); console.log(quads(q, 0.2), quads(q, -1.9), quads(new Float64Array(4), 0)); // A row past the end of the coordinates reads NaN, which never escapes. console.log(row(initial, 0.5, (size >> 3) + 1, 50).slice(-2)); +const r = new Float64Array([1.2, 0.5, 1.3, 0.9]); +const s = new Float64Array([0.1, 0.1, 0.73, 0.74]); +console.log(resets(q), resets(r), skips(q), skips(s), shifted(q), shifted(r)); From 5f7a5dc8835af61f0be6fdb021f3ed524b65e69e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 23:10:05 -0500 Subject: [PATCH 12/18] Keep an async method's promise in non-promise slots; convert filtered arms beside an exact one - An async object-literal method whose contextual slot returns a non-promise (`() => void`) keeps its own promise return, and one whose slot returns a union takes only the union's promise arm (`Promise | void`): adopting the whole return type gave the method a non-promise type, refused with SC2001. - A type predicate whose record target is itself an arm of the element union keeps that arm's elements as they are and converts the other arms with its fields, instead of refusing the filter with SC1090. --- .../lowering/containers/filter-predicates.ts | 8 +++--- .../src/frontend/lowering/function-returns.ts | 26 +++++++++++++++++-- .../async-object-method-contextual-return.ts | 26 +++++++++++++++++++ tests/corpus/filter-predicate-model-narrow.ts | 10 +++++++ ...nc-object-method-contextual-return.ts.json | 2 +- .../filter-predicate-model-narrow.ts.json | 2 +- 6 files changed, 66 insertions(+), 8 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts b/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts index 18e6ac984..ba8fb9d53 100644 --- a/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts +++ b/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts @@ -277,10 +277,10 @@ function unionConversion( }); } // Every arm the target names must be one of the union's own arms, or a - // record target converts from at least one arm. - if (recordArmConversions > 0) { - if (kept !== 0) return null; - } else if (kept === 0 || kept !== targetArms.length) return null; + // record target converts from at least one arm. A record target that is + // itself an arm keeps that arm's elements as they are, and the arms with + // its fields convert. + if (kept === 0 ? recordArmConversions === 0 : kept !== targetArms.length) return null; body.push( typeError( `a kept element is not a '${typeText}' (a type predicate returned true for a value of another type)`, diff --git a/packages/compiler/src/frontend/lowering/function-returns.ts b/packages/compiler/src/frontend/lowering/function-returns.ts index 4ab067fc9..7378f26a7 100644 --- a/packages/compiler/src/frontend/lowering/function-returns.ts +++ b/packages/compiler/src/frontend/lowering/function-returns.ts @@ -154,6 +154,22 @@ function returnsOpenJsBinding(lowerer: Lowerer, decl: ts.SignatureDeclaration): return open; } +/** `type` when it is the ambient Promise, or its one Promise arm when it is + * a union with exactly one; undefined for anything else (`void`, `unknown`, + * a PromiseLike). */ +function promiseArm(lowerer: Lowerer, type: ts.Type): ts.Type | undefined { + const isPromise = (t: ts.Type): boolean => { + const symbol = t.getSymbol(); + return ( + symbol !== undefined && ts.symbolName(symbol) === "Promise" && lowerer.isStdlibSymbol(symbol) + ); + }; + if (isPromise(type)) return type; + if (!type.isUnionType()) return undefined; + const arms = ts.constituentTypes(type).filter(isPromise); + return arms.length === 1 ? arms[0] : undefined; +} + /** Map a declaration's return contract while retaining JavaScript * inference fallbacks. Diagnostics preserve the caller's blame node. */ export function declaredReturnType( @@ -188,8 +204,14 @@ export function declaredReturnType( slot === undefined ? [] : lowerer.checker.getCallSignatures(lowerer.checker.getTypeOfSymbol(slot)); - if (signatures.length === 1) - retTsType = lowerer.checker.getReturnTypeOfSignature(signatures[0]!); + // A slot whose return is not a promise (`() => void`, or a union such + // as `Promise | void`) contributes only its promise: an async + // method returns one whatever the slot says. + const promise = + signatures.length === 1 + ? promiseArm(lowerer, lowerer.checker.getReturnTypeOfSignature(signatures[0]!)) + : undefined; + if (promise !== undefined) retTsType = promise; } // An inferred null/undefined return in JS can read a mutable checked-value // field. Keep the actual value instead of restricting it to the initializer. diff --git a/tests/corpus/async-object-method-contextual-return.ts b/tests/corpus/async-object-method-contextual-return.ts index 223063079..6c3a93758 100644 --- a/tests/corpus/async-object-method-contextual-return.ts +++ b/tests/corpus/async-object-method-contextual-return.ts @@ -12,3 +12,29 @@ function define(): ToolDefinition<{ loaded: string[] }> { }; } define().execute("1", "x").then((r) => console.log(JSON.stringify(r))); + +// Slots whose return is not a promise: an async method there still returns +// its own promise, and a union slot contributes only its promise arm. +interface Hooks { + start(): void; + stop(): Promise | void; + load(q: string): Promise<{ n: number; q: string }> | undefined; +} +const hooks: Hooks = { + async start() { + console.log("start"); + }, + async stop() { + console.log("stop"); + }, + async load(q) { + return { n: q.length, q }; + }, +}; +async function run(): Promise { + await hooks.start(); + await hooks.stop(); + const loaded = await hooks.load("abc"); + console.log(loaded?.n, loaded?.q); +} +run(); diff --git a/tests/corpus/filter-predicate-model-narrow.ts b/tests/corpus/filter-predicate-model-narrow.ts index 4c97445d0..c881fbdef 100644 --- a/tests/corpus/filter-predicate-model-narrow.ts +++ b/tests/corpus/filter-predicate-model-narrow.ts @@ -41,4 +41,14 @@ const restored = restore(stored); for (const model of restored) console.log(model.id, model.api, model.window, model.compat?.strict, "compat" in model); console.log(restored.length, restored.map((model) => model.window).reduce((a, b) => a + b, 0)); +// A target that is itself an arm of the union keeps that arm's elements as +// they are, while another arm with its fields converts. +type Mixed = Model<"a"> | Model | Image; +function pick(stored: Mixed[]): Model<"a">[] { + return stored.filter((model): model is Model<"a"> => model.kind === "chat" && model.api === "a"); +} +const exact: Model<"a"> = { kind: "chat", api: "a", id: "5", window: 3, compat: { strict: false } }; +const picked = pick([exact, ...stored]); +console.log(picked.map((model) => `${model.id}:${model.window}:${model.compat?.strict}`).join(" "), picked[0] === exact); + export {}; diff --git a/tests/harness/corpus-costs/async-object-method-contextual-return.ts.json b/tests/harness/corpus-costs/async-object-method-contextual-return.ts.json index b0f0df482..0b84e3c8a 100644 --- a/tests/harness/corpus-costs/async-object-method-contextual-return.ts.json +++ b/tests/harness/corpus-costs/async-object-method-contextual-return.ts.json @@ -1 +1 @@ -{ "plain": 3268, "san": 6404 } +{ "plain": 12154, "san": 118497 } diff --git a/tests/harness/corpus-costs/filter-predicate-model-narrow.ts.json b/tests/harness/corpus-costs/filter-predicate-model-narrow.ts.json index 2440b7ddb..11d5632f1 100644 --- a/tests/harness/corpus-costs/filter-predicate-model-narrow.ts.json +++ b/tests/harness/corpus-costs/filter-predicate-model-narrow.ts.json @@ -1 +1 @@ -{ "plain": 4307, "san": 81017 } +{ "plain": 12080, "san": 11582 } From a35bf813db18d4f5abea1b0a4480f3a0bf007fb2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 23:23:07 -0500 Subject: [PATCH 13/18] Keep the pointer when a narrowing drops a pointer union's unit arm A union retype between two shape unions (or two class unions) shares the pointer when both encode their unit arms the same way. Narrowing a field read past `=== undefined` or `=== null` retypes a union with a unit arm to one without it, so the encodings differ and each listed arm was rebuilt: a retain, a tag switch, a second retain and a release, for the same pointer. The unit encodings only matter when the value may hold a unit arm. When the retype lists none of the source's unit tags, every value is a record or class pointer whose arm the target derives from the record or class itself, so the pointer is the target value. The rebuild's release also made the pointer a nursery cycle candidate, and in the native compiler those candidates kept the nursery bypass's probes of closures and boxes succeeding. Without them, a probe window can open while young cycles of the type are held by older garbage that the bypass promoted earlier. The window then frees nothing, the type stays bypassed, and the garbage waits for a mature pass, which on a cold tsc-ts build raised the compiler's peak RSS by about 100 MB. A nursery probe window that opens now makes the next release-triggered pass that would run at the nursery run at the mature level, which reclaims that garbage before the window is judged. --- .../compiler/src/backend/llvm/expr-records.ts | 21 +++--- .../src/backend/llvm/union-retype.test.ts | 48 +++++++++++++ packages/runtime/src/scr_cycle.c | 28 +++++++- packages/runtime/test/test_cycle.c | 70 +++++++++++++++++++ 4 files changed, 155 insertions(+), 12 deletions(-) diff --git a/packages/compiler/src/backend/llvm/expr-records.ts b/packages/compiler/src/backend/llvm/expr-records.ts index e65a356eb..9e3a5e9b0 100644 --- a/packages/compiler/src/backend/llvm/expr-records.ts +++ b/packages/compiler/src/backend/llvm/expr-records.ts @@ -61,9 +61,11 @@ export function emitUnionWiden( /** The identity union conversion (IR unionRetype). Two tagged unions * share the operand's box: its +1 becomes the result, so no box is built * and no payload is retained. The operand is a whole-value use in every - * lifetime analysis, so it is never a stack box. A nullable-pointer union - * on either side, or a source union whose boxes a checked-dynamic cast has - * cached (and refreshes in place), rebuilds each listed arm instead. */ + * lifetime analysis, so it is never a stack box. Two pointer unions of one + * encoding share the pointer the same way. A pointer union on only one + * side, pointer unions whose encodings differ for an arm the value may + * hold, or a source union whose boxes a checked-dynamic cast has cached (and + * refreshes in place), rebuild each listed arm instead. */ export function emitUnionRetype( host: LlvmEmitterContext, e: IrExpr & { kind: "unionRetype" }, @@ -75,14 +77,15 @@ export function emitUnionRetype( if (from && to) { // Equal encodings make the pointer itself the target value. A shape // union derives its record arms from the record and a class union its - // class arms from the class id, so only the unit encodings must agree. + // class arms from the class id, so only the unit encodings must agree, + // and only when the value may hold one of the source's unit arms: a + // narrowing that drops `undefined` or `null` keeps the very pointer. + const holdsUnit = e.tags.some((tag) => tag === from.unitTag || tag === from.nullTag); + const units = !holdsUnit || (from.unitTag === to.unitTag && from.nullTag === to.nullTag); const same = from.repr === "single" && to.repr === "single" - ? from.refTag === to.refTag && from.unitTag === to.unitTag && from.nullTag === to.nullTag - : from.repr !== "single" && - from.repr === to.repr && - from.unitTag === to.unitTag && - from.nullTag === to.nullTag; + ? from.refTag === to.refTag && units + : from.repr !== "single" && from.repr === to.repr && units; if (same) return host.own({ name: value.name, type: e.type }); return host.own({ name: rebuildRetype(host, value.name, e), type: e.type }); } diff --git a/packages/compiler/src/backend/llvm/union-retype.test.ts b/packages/compiler/src/backend/llvm/union-retype.test.ts index 502940e71..bc82e7e44 100644 --- a/packages/compiler/src/backend/llvm/union-retype.test.ts +++ b/packages/compiler/src/backend/llvm/union-retype.test.ts @@ -268,3 +268,51 @@ test("record unions retype as the record pointer itself", async () => { // No record of the visitor is ever boxed. expect(ir).not.toMatch(/call ptr @scr_union_new_ref/); }); + +// Children that may be absent, as IR nodes have: reading one after its +// `=== undefined` or `=== null` test narrows a shape union with a unit arm +// to the shape union without it. +const OPTIONAL_CHILDREN = ` +type Num = { kind: "num"; value: number }; +type Ref = { kind: "ref"; id: string }; +type Call = { kind: "call"; callee: Expr; receiver?: Expr; args: Expr[] }; +type Init = { kind: "init"; value: Expr | null }; +type Expr = Num | Ref | Call | Init; +function count(e: Expr): number { + switch (e.kind) { + case "num": + case "ref": + return 1; + case "call": + return ( + 1 + + count(e.callee) + + (e.receiver === undefined ? 0 : count(e.receiver)) + + e.args.reduce((n, a) => n + count(a), 0) + ); + case "init": + return 1 + (e.value === null ? 0 : count(e.value)); + } +} +const tree: Expr = { + kind: "call", + callee: { kind: "ref", id: "f" }, + receiver: { kind: "init", value: { kind: "num", value: 1 } }, + args: [{ kind: "init", value: null }], +}; +console.log(count(tree)); +`; + +test("dropping a shape union's unit arm keeps the record pointer", async () => { + const { module, ir } = await llvmFor(OPTIONAL_CHILDREN, false); + const identities = module.functions.filter( + (fn) => + fn.name.startsWith("%union.retag.") && + JSON.stringify(fn.body).includes('"kind":"unionRetype"'), + ); + expect(identities.length).toBeGreaterThan(0); + for (const helper of identities) { + const symbol = `sc_bf__x25_union_retag_${helper.name.slice("%union.retag.".length)}`; + expect(body(ir, symbol)).not.toMatch(/retype\.built/); + } +}); diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index a7157f9af..02ad86aa1 100644 --- a/packages/runtime/src/scr_cycle.c +++ b/packages/runtime/src/scr_cycle.c @@ -235,6 +235,7 @@ typedef struct { bool falling; bool idle_points; size_t death_due_live; + bool probe_due; bool keep_base; size_t trigger; size_t alloc_trigger; @@ -286,6 +287,7 @@ static _Thread_local ScrCycThread scr_cyc_thread = {.gen_limit = SCR_CYC_OLD}; #define scr_cyc_falling (scr_cyc_thread.falling) #define scr_cyc_idle_points (scr_cyc_thread.idle_points) #define scr_cyc_death_due_live (scr_cyc_thread.death_due_live) +#define scr_cyc_probe_due (scr_cyc_thread.probe_due) #define scr_cyc_keep_base (scr_cyc_thread.keep_base) #define scr_cyc_kinds (scr_cyc_thread.kinds) #define scr_cyc_kinds_cap (scr_cyc_thread.kinds_cap) @@ -640,6 +642,10 @@ static SCR_TL bool scr_cyc_falling = false, scr_cyc_keep_base = false; #ifndef SCR_WORKERS static SCR_TL bool scr_cyc_idle_points = false; static SCR_TL size_t scr_cyc_death_due_live = 0; +/* A nursery probe window opened since the last scheduled pass (see "level + * bypass"): the next pass the release path would run at the nursery runs at + * the mature level instead. */ +static SCR_TL bool scr_cyc_probe_due = false; #endif /* The nursery trigger a program sets for itself @@ -976,9 +982,11 @@ static SCR_CYC_SCHEDULING void scr_cyc_collect_due(void) { if (!by_growth && !by_death && scr_roots[SCR_CYC_OLD].n < scr_cyc_backlog_threshold(SCR_CYC_OLD)) { gen = SCR_CYC_MATURE; by_growth = scr_cyc_grown(SCR_CYC_MATURE); - if (!by_growth && scr_roots[SCR_CYC_MATURE].n < scr_cyc_backlog_threshold(SCR_CYC_MATURE)) + if (!by_growth && scr_roots[SCR_CYC_MATURE].n < scr_cyc_backlog_threshold(SCR_CYC_MATURE) && + !scr_cyc_probe_due) gen = SCR_CYC_NURSERY; } + scr_cyc_probe_due = false; scr_cyc_scheduled_pass(gen, by_growth, by_death); } @@ -1059,6 +1067,16 @@ bool scr_cyc_collect_idle(void) { * a probe window every candidate of the type stays at the level, so the * cycles it builds during the window die there. * + * Those young cycles can still be held by older garbage: a dead structure + * the bypass promoted before the window that points at objects built in it. + * A restricted pass reads that edge as external, so the window frees none + * of them, judges the type long-lived again, and the garbage on both sides + * waits for a mature pass, which the growth schedule may not run until the + * heap has grown a multiple of its size. So a nursery window that opens + * makes the next release-triggered pass that would run at the nursery run + * at the mature level: it reclaims the promoted garbage once, and the window + * judges the young cycles on their own. + * * This changes only which generation an object is judged in. Any * generation assignment is sound (a restricted pass treats edges from older * objects as external), and a bypassing candidate is buffered like any @@ -1110,7 +1128,8 @@ static inline ScrCycKind *scr_cyc_kind(uint32_t kind) { /* Whether a candidate of this type at level `gen` (nursery or mature) * skips that level's passes (see above). The last candidate a bypass sends - * past the level opens a probe window, with fresh counts. */ + * past the level opens a probe window, with fresh counts, and a nursery + * window asks for a mature pass first. */ static inline __attribute__((always_inline)) bool scr_cyc_bypassed(const ScrCycType *type, unsigned gen) { uint32_t kind = type->kind; @@ -1119,9 +1138,12 @@ static inline __attribute__((always_inline)) bool scr_cyc_bypassed(const ScrCycT if (k->bypass == 0) return false; if (--k->bypass == 0) { k->walked = k->freed = 0; + if (gen == SCR_CYC_NURSERY) { + scr_cyc_probe_due = true; #if SCR_CYC_INLINE_BUFFERING - if (gen == SCR_CYC_NURSERY) scr_cyc_nursery_bypassing--; + scr_cyc_nursery_bypassing--; #endif + } } return true; } diff --git a/packages/runtime/test/test_cycle.c b/packages/runtime/test/test_cycle.c index 961d43586..f18d29ff8 100644 --- a/packages/runtime/test/test_cycle.c +++ b/packages/runtime/test/test_cycle.c @@ -1039,6 +1039,75 @@ static void check_nursery_bypass(void) { scr_collect_cycles(); } +/* A nursery probe window that opens makes the next release-triggered pass + * that would run at the nursery run at the mature level, which reclaims + * dead promoted rings a nursery pass stops at. */ +static size_t window_kind_freed, window_ring_freed; +static void window_kind_free(void *obj) { + window_kind_freed++; + node_free(obj); +} +static void window_ring_free(void *obj) { + window_ring_freed++; + node_free(obj); +} +static ScrCycType window_kind_type = { &node_trace, &window_kind_free, 9 }; +static ScrCycType window_ring_type = { &node_trace, &window_ring_free, 0 }; + +/* A candidate of the window kind that dies once it has been routed, as the + * next pass would route it: it counts down a bypass without growing the + * heap. */ +static void release_window_candidate(void) { + Node *node = scr_cyc_alloc(sizeof(*node), &window_kind_type); + node->rc = 2; + release_live(node); + scr_cyc_route_bypassed_at(); + node->rc = 0; + scr_cyc_on_dead(node); + window_kind_free(node); +} + +static void check_probe_window_runs_mature_pass(void) { + /* Every release collects at a threshold of 1, and passes with only a few + * candidates walk too few of them to decide a window. */ + if (configured_nursery_threshold() < 8) return; + enum { WINDOW = 8192, INTERVAL = 16, LIVE = WINDOW, RING = 4 }; + Node **live = calloc(LIVE, sizeof(*live)); + if (!live) scr_trap("cycle test: out of memory\n"); + for (size_t i = 0; i < LIVE; i++) { + live[i] = scr_cyc_alloc(sizeof(*live[i]), &window_kind_type); + live[i]->rc = 1; + read_live(live[i]); + scr_cyc_pass_at(SCR_CYC_NURSERY); + } + scr_collect_cycles(); + /* A pass that an earlier window asked for runs now. */ + run_release_passes(); + + /* A dead ring promoted to the mature level: nursery passes stop at it. */ + Node *ring = make_ring_of(RING, &window_ring_type); + ring->rc++; + release_live(ring); + scr_cyc_pass_at(SCR_CYC_NURSERY); + release_live(ring); + run_release_passes(); + check(window_ring_freed == 0, "a nursery pass freed a dead mature ring"); + + /* The window kind's candidates go past the nursery until a probe window + * opens; the next release-triggered pass runs at the mature level. */ + for (size_t i = 0; i < (size_t)WINDOW * INTERVAL; i++) release_window_candidate(); + run_release_passes(); + check(window_ring_freed == RING, "the pass after a probe window opened missed a dead mature ring"); + + for (size_t i = 0; i < LIVE; i++) { + live[i]->rc = 0; + scr_cyc_on_dead(live[i]); + window_kind_free(live[i]); + } + free(live); + scr_collect_cycles(); +} + /* The mature level's bypass: a type whose mature candidates keep surviving * goes straight to the old generation, its dead cycles are found by full * passes, and once its objects die at the mature level again it is judged @@ -1719,6 +1788,7 @@ int main(void) { check_death_brings_full_pass(); check_nursery_bypass(); check_mature_bypass(); + check_probe_window_runs_mature_pass(); check_probe_sees_candidate_rings(); check_rebuffer_past_mature(); check_allocation_growth_runs_passes(); From f909bdf6c85d6cf26ed440bcd87e1c063d56b8b2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 23:34:34 -0500 Subject: [PATCH 14/18] Make fewer syscalls per fs thread-pool completion and pool-thread write - A worker writes the completion wake byte only when the pipe has none. The loop reads it, with the queue lock held, when it takes the last waiting completion. The pipe holds a byte exactly while completions wait, so a burst of completions costs one write and one read instead of one write per completion and a read loop per dispatch. - The fs pool's threads keep SIGPIPE and SIGXFSZ blocked for their lifetime. scr_fs_write_once checks once per thread whether both are blocked; on such a thread a write makes no signal-mask calls, and a write that fails with EPIPE or EFBIG consumes the signal it raised. Other threads keep the per-write guard. fs-write-sync.test.ts covers EPIPE and EFBIG from write streams, whose writes run on the pool's threads, and matches Node's errors. --- packages/runtime/src/scr_async.c | 42 +++++++++++++++++------- packages/runtime/src/scr_lib.c | 43 +++++++++++++++++++++++++ tests/fixtures/fs-write-sync-sigpipe.ts | 20 ++++++++++-- tests/harness/fs-write-sync.test.ts | 18 ++++++++--- 4 files changed, 105 insertions(+), 18 deletions(-) diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 0225269f4..92e95b8b7 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -3423,20 +3423,25 @@ ScrPromise *scr_fsp_rename(ScrStr *oldpath, ScrStr *newpath) { * platform mutex publishes each result before the loop removes it from the * completion queue. */ #if !defined(SCR_LIB) && !defined(__wasi__) -/* The completion wake: a worker signals it once per published completion, +/* The completion wake: a worker signals it when it publishes a completion, * so the loop's sleep ends for the completion — a pipe the poll(2) sleep - * watches on POSIX, an auto-reset event the Windows sleep waits on. */ + * watches on POSIX, an auto-reset event the Windows sleep waits on. On + * POSIX, `posted` (guarded by the queue lock) records that the pipe holds + * a byte: a worker writes one only when none is there, and the loop reads + * it when it takes the last completion, so the pipe holds a byte exactly + * while completions wait. */ typedef struct ScrFsRenameWake { #ifdef _WIN32 HANDLE event; /* NULL until created */ #else int fds[2]; /* -1 until created */ + bool posted; #endif } ScrFsRenameWake; #ifdef _WIN32 #define SCR_FS_RENAME_WAKE_NONE {NULL} #else -#define SCR_FS_RENAME_WAKE_NONE {{-1, -1}} +#define SCR_FS_RENAME_WAKE_NONE {{-1, -1}, false} #endif #ifdef SCR_WORKERS typedef struct ScrFsRenameOwner ScrFsRenameOwner; @@ -3530,28 +3535,29 @@ static void scr_fs_rename_wake_open(ScrFsRenameWake *wake) { #endif } +/* Called with the queue lock held. */ static void scr_fs_rename_wake_post(ScrFsRenameWake *wake) { #ifdef _WIN32 if (wake->event != NULL) (void)SetEvent(wake->event); #else - if (wake->fds[1] >= 0) { + if (wake->fds[1] >= 0 && !wake->posted) { char byte = 1; - ssize_t n = write(wake->fds[1], &byte, 1); /* a full pipe already wakes the loop */ - (void)n; + wake->posted = write(wake->fds[1], &byte, 1) == 1; } #endif } -/* POSIX: empty the pipe before taking the queue, so a completion published - * afterwards leaves its byte for the next poll. The Windows event resets - * when a wait consumes it. */ +/* POSIX, with the queue lock held: the loop took the last completion, so + * the pipe's byte is read and the next completion writes a new one. The + * Windows event resets when a wait consumes it. */ static void scr_fs_rename_wake_drain(ScrFsRenameWake *wake) { #ifdef _WIN32 (void)wake; #else - if (wake->fds[0] < 0) return; + if (wake->fds[0] < 0 || !wake->posted) return; + wake->posted = false; char buf[64]; - while (read(wake->fds[0], buf, sizeof buf) > 0) { + while (read(wake->fds[0], buf, sizeof buf) == (ssize_t)sizeof buf) { } #endif } @@ -3661,6 +3667,18 @@ static DWORD WINAPI scr_fs_rename_worker(LPVOID payload) { #else static void *scr_fs_rename_worker(void *payload) { (void)payload; + /* A pool thread keeps SIGPIPE and SIGXFSZ blocked: its file writes then + * report EPIPE and EFBIG without changing the signal mask per write + * (scr_fs_write_once). */ + sigset_t write_set; + sigemptyset(&write_set); +#ifdef SIGPIPE + sigaddset(&write_set, SIGPIPE); +#endif +#ifdef SIGXFSZ + sigaddset(&write_set, SIGXFSZ); +#endif + (void)pthread_sigmask(SIG_BLOCK, &write_set, NULL); scr_fs_rename_worker_loop(); return NULL; } @@ -3759,13 +3777,13 @@ static bool scr_fs_renames_dispatch(void) { #ifdef SCR_WORKERS if (!scr_fs_rename_owner) return false; #endif - scr_fs_rename_wake_drain(SCR_FS_RENAME_WAKE(scr_fs_rename_owner)); scr_fs_rename_lock_enter(); ScrFsRenameOp *op = scr_fs_rename_done; if (op != NULL) { scr_fs_rename_done = op->next; if (scr_fs_rename_done == NULL) scr_fs_rename_done_tail = &scr_fs_rename_done; } + if (scr_fs_rename_done == NULL) scr_fs_rename_wake_drain(SCR_FS_RENAME_WAKE(scr_fs_rename_owner)); scr_fs_rename_lock_leave(); if (op == NULL) return false; scr_fs_rename_pending_count--; diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index ad0c613ee..edc2849c7 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -4101,6 +4101,49 @@ static ssize_t scr_fs_write_once(int fd, const void *data, size_t length, #ifdef SIGXFSZ sigaddset(&write_set, SIGXFSZ); #endif + /* A thread that keeps both signals blocked (the fs pool's threads) writes + * without changing its mask; a write that fails with EPIPE or EFBIG then + * consumes the signal it left pending. The answer is per thread and + * computed once. */ + static SCR_TL signed char thread_blocks = -1; + if (thread_blocks < 0) { + sigset_t current; + bool blocks = pthread_sigmask(SIG_BLOCK, NULL, ¤t) == 0; +#ifdef SIGPIPE + blocks = blocks && sigismember(¤t, SIGPIPE) == 1; +#endif +#ifdef SIGXFSZ + blocks = blocks && sigismember(¤t, SIGXFSZ) == 1; +#endif + thread_blocks = blocks ? 1 : 0; + } + if (thread_blocks == 1) { + ssize_t n = positioned + ? scr_fs_pwrite(fd, data, length, position) + : write(fd, data, length); + if (n < 0) { + int write_errno = errno; + int raised = 0; +#ifdef SIGPIPE + if (write_errno == EPIPE) raised = SIGPIPE; +#endif +#ifdef SIGXFSZ + if (write_errno == EFBIG) raised = SIGXFSZ; +#endif + if (raised != 0 && sigpending(&pending) == 0 && sigismember(&pending, raised) == 1) { + sigset_t raised_set; + sigemptyset(&raised_set); + sigaddset(&raised_set, raised); + int caught = 0; + int wait_err; + do { + wait_err = sigwait(&raised_set, &caught); + } while (wait_err == EINTR); + } + errno = write_errno; + } + return n; + } if (pthread_sigmask(SIG_BLOCK, &write_set, &old_set) != 0) { return positioned ? scr_fs_pwrite(fd, data, length, position) diff --git a/tests/fixtures/fs-write-sync-sigpipe.ts b/tests/fixtures/fs-write-sync-sigpipe.ts index e5fca1d71..1cbafb8b3 100644 --- a/tests/fixtures/fs-write-sync-sigpipe.ts +++ b/tests/fixtures/fs-write-sync-sigpipe.ts @@ -5,12 +5,28 @@ import * as os from "node:os"; import * as path from "node:path"; const mode = process.argv.length > 2 ? process.argv[2] : "sigpipe"; -if (mode === "sigpipe") { +if (mode === "sigpipe" || mode === "stream-sigpipe") { const sleeper = new Int32Array(new SharedArrayBuffer(4)); Atomics.wait(sleeper, 0, 0, 100); } -if (mode === "eagain") { +if (mode === "stream-sigpipe") { + // a write stream's writes run on the fs pool's threads + const out = fs.createWriteStream("", { fd: 1 }); + out.on("error", (err: NodeJS.ErrnoException) => { + console.error("stream caught:", err.name, err.code, err.message); + }); + out.write("probe\n"); +} else if (mode === "stream-efbig") { + const scratch = path.join(os.tmpdir(), `scr-write-stream-${process.pid}`); + const out = fs.createWriteStream(scratch); + out.on("error", (err: NodeJS.ErrnoException) => { + console.error("stream caught:", err.name, err.code, err.message); + fs.unlinkSync(scratch); + }); + out.write(Buffer.alloc(512)); + out.write(Buffer.alloc(4096)); +} else if (mode === "eagain") { const data = Buffer.alloc(65536); for (;;) { try { diff --git a/tests/harness/fs-write-sync.test.ts b/tests/harness/fs-write-sync.test.ts index bd277cd1c..a9ed08d98 100644 --- a/tests/harness/fs-write-sync.test.ts +++ b/tests/harness/fs-write-sync.test.ts @@ -1,7 +1,9 @@ -/* fs.writeSync contracts that need harness control over the native process's - * descriptors. The ordinary byte/error behavior stays in differential corpus - * 2685; this test controls stdout's pipe state, nonblocking mode, and the - * process file-size limit to pin the signal-backed descriptor errors. */ +/* fs.writeSync and write-stream contracts that need harness control over the + * native process's descriptors. The ordinary byte/error behavior stays in + * differential corpus 2685; this test controls stdout's pipe state, + * nonblocking mode, and the process file-size limit to pin the signal-backed + * descriptor errors, on the calling thread (writeSync) and on the fs pool's + * threads (write streams). */ import { execFileSync, spawn } from "node:child_process"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; @@ -80,6 +82,14 @@ describe.skipIf(process.platform === "win32")( "positioned first: 512\n" + "positioned caught: Error EFBIG EFBIG: file too large, write\n", ); + + const streamPipe = await run(result.binaryPath, ["stream-sigpipe"], true); + expect(streamPipe.outcome).toEqual({ code: 0, signal: null }); + expect(streamPipe.stderr).toBe("stream caught: Error EPIPE EPIPE: broken pipe, write\n"); + + const streamLimit = await run(launcher, ["rlimit", result.binaryPath, "stream-efbig"]); + expect(streamLimit.outcome).toEqual({ code: 0, signal: null }); + expect(streamLimit.stderr).toBe("stream caught: Error EFBIG EFBIG: file too large, write\n"); }); }, ); From 26e458932eeec0379db9b40730b2a37cb8b5f73c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 23:39:17 -0500 Subject: [PATCH 15/18] Poll the checker server's socket before a reply read blocks The native compiler sends most checker requests one at a time and blocks in recv for each reply. The server answers most of them in a few microseconds, and a reader the reply wakes takes tens of microseconds more per round trip, over about 130,000 round trips on a large program. scriptc_ts7_read now polls the socket with MSG_DONTWAIT for up to 200 us before it blocks. Windows keeps its blocking pipe read. --- .../compiler/native/ts7-process-child.test.c | 13 ++++++- packages/compiler/native/ts7-process.c | 38 ++++++++++++++++++- packages/compiler/native/ts7-process.test.c | 15 ++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/compiler/native/ts7-process-child.test.c b/packages/compiler/native/ts7-process-child.test.c index d387f735a..c89fcbafb 100644 --- a/packages/compiler/native/ts7-process-child.test.c +++ b/packages/compiler/native/ts7-process-child.test.c @@ -13,6 +13,7 @@ #include #else #include +#include #include #include #include @@ -102,7 +103,9 @@ static int child_main(int argc, char **argv) { #endif } } - if (strcmp(mode, "echo") != 0) return 73; + // "slow-echo" answers each read 20 ms later: after the client's poll. + int slow = strcmp(mode, "slow-echo") == 0; + if (!slow && strcmp(mode, "echo") != 0) return 73; // Unbuffered reads let short writes make progress without waiting for a // complete stdio buffer. Fragment each echoed reply independently. uint8_t bytes[17]; @@ -113,6 +116,14 @@ static int child_main(int argc, char **argv) { ssize_t count = read(STDIN_FILENO, bytes, sizeof(bytes)); #endif if (count <= 0) return count < 0 ? 74 : 0; + if (slow) { +#ifdef _WIN32 + Sleep(20); +#else + struct timespec delay = {0, 20000000L}; + nanosleep(&delay, NULL); +#endif + } if (fwrite(bytes, 1, (size_t)count, stdout) != (size_t)count || fflush(stdout) != 0) return 75; } } diff --git a/packages/compiler/native/ts7-process.c b/packages/compiler/native/ts7-process.c index bf486c87d..e570fa98c 100644 --- a/packages/compiler/native/ts7-process.c +++ b/packages/compiler/native/ts7-process.c @@ -552,6 +552,41 @@ static int valid_range(const uint8_t *bytes, size_t size, uint32_t offset, uint3 return 1; } +#ifndef _WIN32 +/* How long a read polls the socket before it blocks. The server answers + * most requests within a few microseconds; a blocked reader that the reply + * wakes takes tens of microseconds more per round trip. */ +#define TS7_REPLY_POLL_NS 200000L + +static long monotonic_ns(void) { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return now.tv_sec * 1000000000L + now.tv_nsec; +} + +/* recv on the server's socket: polls it for TS7_REPLY_POLL_NS, then waits. */ +static ssize_t ts7_recv(int socket, uint8_t *bytes, size_t length) { + long started = -1; + for (;;) { + ssize_t count = recv(socket, bytes, length, MSG_DONTWAIT); + if (count >= 0) return count; + if (errno == EINTR) continue; + if (errno != EAGAIN && errno != EWOULDBLOCK) return -1; + long now = monotonic_ns(); + if (started < 0) started = now; + else if (now - started >= TS7_REPLY_POLL_NS) break; +#if defined(__x86_64__) || defined(__i386__) + __builtin_ia32_pause(); +#elif defined(__aarch64__) + __asm__ volatile("yield"); +#endif + } + ssize_t count; + do { count = recv(socket, bytes, length, 0); } while (count < 0 && errno == EINTR); + return count; +} +#endif + int32_t scriptc_ts7_read(uint32_t handle, uint8_t *bytes, size_t size, uint32_t offset, uint32_t length) { Ts7Process *p = find_process(handle); @@ -565,8 +600,7 @@ int32_t scriptc_ts7_read(uint32_t handle, uint8_t *bytes, size_t size, if (code == ERROR_BROKEN_PIPE) return 0; system_error(code); return -1; #else - ssize_t count; - do { count = recv(p->socket, bytes + offset, length, 0); } while (count < 0 && errno == EINTR); + ssize_t count = ts7_recv(p->socket, bytes + offset, length); if (count < 0) { system_error(errno); return -1; } return (int32_t)count; #endif diff --git a/packages/compiler/native/ts7-process.test.c b/packages/compiler/native/ts7-process.test.c index e07a7d10c..c51541772 100644 --- a/packages/compiler/native/ts7-process.test.c +++ b/packages/compiler/native/ts7-process.test.c @@ -149,6 +149,20 @@ static void exchange_cases(void) { scriptc_ts7_close(newer); } +/* A reply that arrives after the read's poll window: the read waits for it. */ +static void slow_reply_cases(void) { + uint32_t id = start("slow-echo", 0); + assert(id != 0); + const uint8_t bytes[3] = {1, 2, 3}; + uint8_t result[3] = {0}; + for (unsigned round = 0; round < 3; round++) { + write_exact(id, bytes, sizeof(bytes), 0, sizeof(bytes)); + read_exact(id, result, sizeof(result)); + assert(memcmp(bytes, result, sizeof(bytes)) == 0); + } + scriptc_ts7_close(id); +} + static void failure_cases(void) { uint8_t bytes[1] = {42}; const char *saved = executable; @@ -289,6 +303,7 @@ static int process_main(int argc, char **argv) { unsigned before = resource_count(); argument_cases(); exchange_cases(); + slow_reply_cases(); failure_cases(); #ifndef _WIN32 attach_cases(); From 50aa42883f303e53343ac177211f0068a00e7a44 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 00:18:11 -0500 Subject: [PATCH 16/18] Cache the pool-thread signal check per OS thread scr_fs_write_once cached whether the calling thread blocks SIGPIPE and SIGXFSZ in an SCR_TL variable. SCR_TL is empty in builds without thread instances, so a pool thread's answer applied to the main thread too, and a later writeSync to a closed pipe ended the process with SIGPIPE instead of throwing EPIPE. The cache is _Thread_local now. The fs-write-sync fixture covers a write stream followed by writeSync on a closed stdout. --- packages/runtime/src/scr_lib.c | 7 ++++--- tests/fixtures/fs-write-sync-sigpipe.ts | 16 +++++++++++++++- tests/harness/fs-write-sync.test.ts | 4 ++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index edc2849c7..9ef71a223 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -4103,9 +4103,10 @@ static ssize_t scr_fs_write_once(int fd, const void *data, size_t length, #endif /* A thread that keeps both signals blocked (the fs pool's threads) writes * without changing its mask; a write that fails with EPIPE or EFBIG then - * consumes the signal it left pending. The answer is per thread and - * computed once. */ - static SCR_TL signed char thread_blocks = -1; + * consumes the signal it left pending. The answer is per OS thread and + * computed once: _Thread_local, not SCR_TL, which is empty in builds + * without thread instances while the pool threads still exist. */ + static _Thread_local signed char thread_blocks = -1; if (thread_blocks < 0) { sigset_t current; bool blocks = pthread_sigmask(SIG_BLOCK, NULL, ¤t) == 0; diff --git a/tests/fixtures/fs-write-sync-sigpipe.ts b/tests/fixtures/fs-write-sync-sigpipe.ts index 1cbafb8b3..24c0012b4 100644 --- a/tests/fixtures/fs-write-sync-sigpipe.ts +++ b/tests/fixtures/fs-write-sync-sigpipe.ts @@ -5,7 +5,7 @@ import * as os from "node:os"; import * as path from "node:path"; const mode = process.argv.length > 2 ? process.argv[2] : "sigpipe"; -if (mode === "sigpipe" || mode === "stream-sigpipe") { +if (mode === "sigpipe" || mode === "stream-sigpipe" || mode === "stream-then-sync-sigpipe") { const sleeper = new Int32Array(new SharedArrayBuffer(4)); Atomics.wait(sleeper, 0, 0, 100); } @@ -17,6 +17,20 @@ if (mode === "stream-sigpipe") { console.error("stream caught:", err.name, err.code, err.message); }); out.write("probe\n"); +} else if (mode === "stream-then-sync-sigpipe") { + // a pool thread writes first; the calling thread's writeSync after it + // keeps its own guard + const scratch = path.join(os.tmpdir(), `scr-write-then-sync-${process.pid}`); + const file = fs.createWriteStream(scratch); + file.end("pool\n", () => { + fs.unlinkSync(scratch); + try { + fs.writeSync(1, "probe\n"); + } catch (e) { + const err = e as NodeJS.ErrnoException; + console.error("sync caught:", err.name, err.code); + } + }); } else if (mode === "stream-efbig") { const scratch = path.join(os.tmpdir(), `scr-write-stream-${process.pid}`); const out = fs.createWriteStream(scratch); diff --git a/tests/harness/fs-write-sync.test.ts b/tests/harness/fs-write-sync.test.ts index a9ed08d98..1c8b6c41e 100644 --- a/tests/harness/fs-write-sync.test.ts +++ b/tests/harness/fs-write-sync.test.ts @@ -87,6 +87,10 @@ describe.skipIf(process.platform === "win32")( expect(streamPipe.outcome).toEqual({ code: 0, signal: null }); expect(streamPipe.stderr).toBe("stream caught: Error EPIPE EPIPE: broken pipe, write\n"); + const thenSync = await run(result.binaryPath, ["stream-then-sync-sigpipe"], true); + expect(thenSync.outcome).toEqual({ code: 0, signal: null }); + expect(thenSync.stderr).toBe("sync caught: Error EPIPE\n"); + const streamLimit = await run(launcher, ["rlimit", result.binaryPath, "stream-efbig"]); expect(streamLimit.outcome).toEqual({ code: 0, signal: null }); expect(streamLimit.stderr).toBe("stream caught: Error EFBIG EFBIG: file too large, write\n"); From 10479ce2e0bdcd9033e97c9a0816c7524f6f9581 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 01:01:21 -0500 Subject: [PATCH 17/18] Link the scriptc CLI without huge text An executable with at least 4 MiB of program code is linked with 2 MiB segment alignment and published with one write, so the page cache holds its text in 2 MiB folios and the runtime advises huge pages for it. A run then maps every 2 MiB text window it touches whole. Most runs of the compiler are short: `scriptc --version` peaked at 76 MB and an unchanged rebuild at 78 MB, against 20 MB and 26 MB with 4 KiB alignment, and cold and edited builds of tsc-ts took the same time either way. compile() takes an internal `hugeText: false`, which links with 4 KiB alignment whatever the code size and is part of the executable cache key. scripts/build-native-cli.mts passes it. Executables the compiler builds keep huge text. --- packages/compiler/src/backend/link-plan.ts | 12 ++++---- .../compiler/src/backend/runtime-pack.test.ts | 29 +++++++++++++++++++ packages/compiler/src/compile-types.ts | 6 ++++ .../src/executable/executable-cache.ts | 4 +++ packages/compiler/src/index.ts | 5 ++++ scripts/build-native-cli.mts | 6 ++++ 6 files changed, 56 insertions(+), 6 deletions(-) diff --git a/packages/compiler/src/backend/link-plan.ts b/packages/compiler/src/backend/link-plan.ts index 19cff2d5f..c59095a98 100644 --- a/packages/compiler/src/backend/link-plan.ts +++ b/packages/compiler/src/backend/link-plan.ts @@ -48,6 +48,8 @@ export async function createNativeLinkPlan(options: { windowsSubsystem?: WindowsSubsystem; profileGenerate?: boolean; profileLayout?: boolean; + /** `false` keeps 4 KiB segment alignment for large program code. */ + hugeText?: boolean; programObjectDependencies?: readonly NativeArtifactDependency[]; env?: NodeJS.ProcessEnv; resolver?: (specifier: string) => string; @@ -55,6 +57,9 @@ export async function createNativeLinkPlan(options: { if (options.ffi?.frameworks?.length && options.target.platform !== "darwin") throw new Error("FFI frameworks require a Darwin target"); const runtimePack = await loadRuntimePack(options); + const hugeText = + options.hugeText !== false && + hugeTextProgram(options.target, [options.programObject, ...(options.programPartitions ?? [])]); return { target: options.target, darwinDebugSymbols: needsDarwinDebugSymbols( @@ -88,11 +93,6 @@ export async function createNativeLinkPlan(options: { programObjectDependencies: [...(options.programObjectDependencies ?? [])], runtimePack, ...(options.staticLink === true ? { staticLink: true } : {}), - ...(hugeTextProgram(options.target, [ - options.programObject, - ...(options.programPartitions ?? []), - ]) - ? { hugeText: true } - : {}), + ...(hugeText ? { hugeText: true } : {}), }; } diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts index a12c137b2..457f96749 100644 --- a/packages/compiler/src/backend/runtime-pack.test.ts +++ b/packages/compiler/src/backend/runtime-pack.test.ts @@ -14,6 +14,7 @@ import { RuntimePackError, type RuntimePackManifest, } from "./runtime-pack.js"; +import { HUGE_TEXT_MIN_CODE_BYTES } from "./huge-text.js"; import { createNativeLinkPlan } from "./link-plan.js"; import { executableLinkerEnvironmentFingerprint, @@ -266,6 +267,34 @@ describe("runtime pack manifests", () => { expect(staticPlan.staticLink).toBe(true); }); + test("Linux links of large program code use huge text unless it is turned off", async () => { + const { packagePath, root } = await fixture(LINUX_X64_GNU_TARGET); + // An ELF64 relocatable object's headers with one executable section of + // HUGE_TEXT_MIN_CODE_BYTES; only the headers are read. + const object = Buffer.alloc(64 + 2 * 64); + object.writeUInt32BE(0x7f454c46, 0); + object[4] = 2; + object[5] = 1; + object.writeBigUInt64LE(64n, 40); + object.writeUInt16LE(64, 58); + object.writeUInt16LE(2, 60); + object.writeUInt32LE(1, 128 + 4); // SHT_PROGBITS + object.writeBigUInt64LE(0x6n, 128 + 8); // SHF_ALLOC | SHF_EXECINSTR + object.writeBigUInt64LE(BigInt(HUGE_TEXT_MIN_CODE_BYTES), 128 + 32); + await writeFile(join(root, "program.o"), object); + const options = { + target: LINUX_X64_GNU_TARGET, + programObject: join(root, "program.o"), + outPath: join(root, "program"), + features: BASE, + ffi: null, + optimization: "release" as const, + resolver: () => packagePath, + }; + expect((await createNativeLinkPlan(options)).hugeText).toBe(true); + expect((await createNativeLinkPlan({ ...options, hugeText: false })).hugeText).toBeUndefined(); + }); + test("selection chooses the most-specific variant and feature archive", async () => { const { packagePath } = await fixture(); const resolver = () => packagePath; diff --git a/packages/compiler/src/compile-types.ts b/packages/compiler/src/compile-types.ts index 5a382d9c6..404648051 100644 --- a/packages/compiler/src/compile-types.ts +++ b/packages/compiler/src/compile-types.ts @@ -57,6 +57,12 @@ export interface CompileBaseOptions { * at startup when the system allows them. The moved code stays resident. * Executables only; no effect on other platforms. */ largePages?: boolean; + /** Internal: `false` links a Linux executable with 4 KiB segment alignment + * even when its program code is large enough for text on huge pages + * (backend/huge-text.ts). The scriptc CLI is linked this way + * (scripts/build-native-cli.mts): most of its runs are short, and a short + * run maps every 2 MiB text window it touches whole. */ + hugeText?: false; /** How often the program throws (`--exceptions`). `rare` puts recursive * functions and those near a `throw` on the unwinding exception ABI too: * calls to them need no pending-flag test, and an exception that crosses diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 91fa7d791..a6dcc69d8 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -172,6 +172,8 @@ export interface EarlyExecutableCacheOptions { libnodeIdentity?: () => string | undefined; /** Omitted unless --large-pages is set. */ largePages?: true; + /** Omitted unless the build links without huge text (CompileOptions.hugeText). */ + noHugeText?: true; /** Omitted unless --exceptions=rare is set. */ exceptions?: "rare"; /** Omitted for the x86-64 baseline (`--target-cpu`). */ @@ -343,6 +345,7 @@ function cacheKey(options: EarlyExecutableCacheOptions): string { ...(options.windowsSubsystem === "gui" ? ["windows-subsystem-gui"] : []), ...(options.detachedExit ? ["detached-exit"] : []), ...(options.largePages ? ["large-pages"] : []), + ...(options.noHugeText ? ["no-huge-text"] : []), ...(options.exceptions === "rare" ? ["exceptions-rare"] : []), ...targetCpuKeyParts(options.targetCpu), ...cpuDispatchKeyParts(options.cpuDispatch), @@ -393,6 +396,7 @@ function routeKey(options: Omit { const programIsObject = /\.(?:o|obj)$/.test(llvmPath); const runtimePackTarget = programIsObject && !sanitize ? nativeCodegenTarget() : null; @@ -650,6 +651,7 @@ async function compileExecutableNative( ...(windowsSubsystem === undefined ? {} : { windowsSubsystem }), ...(profileGenerate ? { profileGenerate: true } : {}), ...(profileLayout ? { profileLayout: true } : {}), + ...(hugeText ? {} : { hugeText: false }), programObjectDependencies, }); const cacheableLinker = @@ -1823,6 +1825,7 @@ async function compileTracked( // identity (node-islands/libnode.ts) and a hit requires it unchanged. libnodeIdentity: () => libnodeCacheKey(opts.libnode, process.env, dirname(entryPath)), ...(opts.largePages === true ? { largePages: true as const } : {}), + ...(opts.hugeText === false ? { noHugeText: true as const } : {}), ...(opts.exceptions === "rare" ? { exceptions: "rare" as const } : {}), ...targetCpuField(opts.targetCpu), ...(opts.cpuDispatch === true ? { cpuDispatch: true as const } : {}), @@ -2003,6 +2006,7 @@ async function compileTracked( profile?.generate !== undefined, hitSources.llvmPath, nativeProfileLayout(profile), + opts.hugeText !== false, ); if (nativeProgramObject !== null && opts.nativeProgramObject === true) { await rename(nativeProgramObject.linkPath, nativeProgramObject.artifactPath); @@ -2151,6 +2155,7 @@ async function compileTracked( profile?.generate !== undefined, llvmPath, nativeProfileLayout(profile), + opts.hugeText !== false, ); timing("native-link"); if (nativeProgramObject !== null && opts.nativeProgramObject === true) { diff --git a/scripts/build-native-cli.mts b/scripts/build-native-cli.mts index 6a9334e4a..6c17b829b 100644 --- a/scripts/build-native-cli.mts +++ b/scripts/build-native-cli.mts @@ -164,6 +164,12 @@ const result = await compile(join(root, "packages/compiler/src/native/cli.ts"), ...(profileGenerate === undefined ? {} : { profileGenerate }), ...(profileUse === undefined ? {} : { profileUse }), ...(functionLayout === undefined ? {} : { functionLayout }), + // Most of the compiler's runs are short (an unchanged rebuild takes tens of + // milliseconds), and a run maps every 2 MiB text window it touches whole. Text + // on 4 KiB pages keeps an unchanged rebuild at about a third of the peak RSS, + // and builds of tsc-ts take the same time. Executables the compiler builds + // keep huge text. + hugeText: false, emitIr: process.env["SCRIPTC_NATIVE_EMIT_IR"] === "1", }); if (!result.ok) From a2e12517592070e1914444b72c2fcb406a188dcd Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 06:02:00 -0500 Subject: [PATCH 18/18] Prepare v0.4.6 release --- CHANGELOG.md | 42 ++++++++++++++++++- packages/cli-darwin-arm64/package.json | 2 +- packages/cli-darwin-x64/package.json | 2 +- packages/cli-linux-arm64-gnu/package.json | 2 +- packages/cli-linux-arm64-musl/package.json | 2 +- packages/cli-linux-x64-gnu/package.json | 2 +- packages/cli-linux-x64-musl/package.json | 2 +- packages/cli-win32-x64-msvc/package.json | 2 +- packages/cli/package.json | 2 +- packages/compiler/package.json | 2 +- packages/compiler/surface-manifest.json | 2 +- packages/experimental/package.json | 2 +- packages/libnode-darwin-arm64/package.json | 2 +- packages/libnode-darwin-x64/package.json | 2 +- packages/libnode-linux-arm64-gnu/package.json | 2 +- packages/libnode-linux-x64-gnu/package.json | 2 +- packages/llvm-darwin-arm64/package.json | 2 +- packages/llvm-darwin-x64/package.json | 2 +- packages/llvm-linux-arm64-gnu/package.json | 2 +- packages/llvm-linux-arm64-musl/package.json | 2 +- packages/llvm-linux-x64-gnu/package.json | 2 +- packages/llvm-linux-x64-musl/package.json | 2 +- packages/llvm-win32-x64-msvc/package.json | 2 +- packages/node/package.json | 2 +- packages/runtime-android-arm64/package.json | 2 +- packages/runtime-darwin-arm64/package.json | 2 +- packages/runtime-darwin-x64/package.json | 2 +- packages/runtime-ios-arm64/package.json | 2 +- .../runtime-ios-simulator-arm64/package.json | 2 +- packages/runtime-linux-arm64-gnu/package.json | 2 +- .../runtime-linux-arm64-musl/package.json | 2 +- packages/runtime-linux-x64-gnu/package.json | 2 +- packages/runtime-linux-x64-musl/package.json | 2 +- packages/runtime-wasm32-wasi/package.json | 2 +- packages/runtime-win32-x64-msvc/package.json | 2 +- packages/runtime/package.json | 2 +- packages/threads/package.json | 2 +- packages/types/package.json | 2 +- 38 files changed, 77 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8ab725f0..8116c5d08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,46 @@ All notable changes to scriptc will be documented in this file. +## 0.4.6 + +### Highlights + +- **Node islands:** programs can load Node.js in-process on first use and run npm packages that scriptc doesn't compile in Node islands. + - Native values, closures and promises pass into islands by reference, and island handles, classes and collections can be used from compiled code. + - Modules are shared between a program and its islands, and island modules are evaluated only when the program uses them. + - Once Node loads, the runtime's event loop runs on Node's libuv, and programs exit cleanly with Node loaded. +- **New options:** + - `--exceptions=rare` unwinds through recursive functions instead of checking a status after each call. + - `--static` builds static Linux executables (opt-in). + - `--large-pages` moves executable code onto huge pages; `--cpu-dispatch` adds x86-64-v3 copies of hot functions. + - Type patches replace a package's declaration files or supply missing declarations. + - The shape overlay types JavaScript from facts the compiler proves (opt-in). +- **Faster programs:** + - Exceptions raise through cached unwind plans on x86-64 Linux, and every native function has an unwind table entry. + - Ordinary objects are stored as a layout plus value slots; unions of classes are the instance pointer itself; class instances that stay in their frame use frame storage. + - Object groups that die together are allocated in arenas, and the cycle collector does cheaper trial deletion and adapts its nursery trigger. + - The speed runtime is optimized with a committed runtime profile, and Math functions are computed as V8 does, with libm linked only when needed. + - Loops: short constant-count loops run together, for and while loops are versioned on the ranges their locals enter with, counted loops unroll by the period of a guard, and small leaf functions called from loops are inlined. + - Typed arrays: header fields are read once at a speculative loop's entry, products under bitwise operators stay in 32 bits, and shared typed-array elements are read and written with plain accesses at a counted loop's counter. + - Strings: UTF-16 indexes of long non-ASCII strings map in constant time, ASCII case converts eight bytes at a time, template literals allocate at their exact length, and strings iterate over their bytes. + - Untyped code: class instance fields are read inline at dynamic property sites with two inline-cache entries, native arrays iterate without type-name scans, and numbers pass to directly called untyped functions as JavaScript values. + - I/O: fs thread-pool completions and pool-thread writes make fewer syscalls; sockets, clients and agent entries register in constant time; fetch() reuses keep-alive connections; web streams, WritableStream and TransformStream run natively. + - Large executables map their text with 2 MiB pages, and freed heap pages are returned to the system after the frontend is disposed. +- **Faster builds:** + - Small release and speed programs compile in balanced partitions, the entry module's top-level code gets its own partition, and unchanged partitions stay cached across edits. + - Optimized executables that stay one partition generate code concurrently, and emission's workers stay busy. + - The native compiler is laid out by context-sensitive profiles collected in two phases. + - Type checking uses a shared tsc-ts server by default on Linux and macOS, and lib diagnostics are reused across options and module sets that cannot change them. + - Rebuilds skip the helper version probe and the digests of unchanged files, and the CLI links without huge text pages, so cold builds use less memory. +- **Node.js parity:** + - `Error.captureStackTrace`, Node's navigator, builtin globals read through the global object, and unhandled rejections reported in programs without async work. + - node:zlib is built on the zlib Node bundles; `Response.clone` and `Request.clone` work on typed values; fetch bodies have the members of a web ReadableStream; redirects whose chunked body has not ended are followed. + - Classes that extend Array compile; strings spread into their code points; frozen checked records stay frozen; sloppy-mode `this` is the global object. + - `util.format` runtime format strings, `inspect.custom` and colors, and untyped `promisify`. +- **TypeScript:** scriptc reads TypeScript 7.1's binary AST format and API server beside 7.0's, and tsc-ts is updated to 0.0.10. + + + ## 0.4.5 ### Highlights @@ -40,8 +80,6 @@ All notable changes to scriptc will be documented in this file. - `util.inspect` of dynamic classes, functions and `%o` arguments, and `Symbol.toStringTag` fields and getters, match Node. - **npm packages:** more npm and CommonJS dependencies compile statically, including namespace-style packages with guarded declarations and `var` exports, and untyped Maps, Sets and arrays are typed from their uses. - - ## 0.4.4 ### Highlights diff --git a/packages/cli-darwin-arm64/package.json b/packages/cli-darwin-arm64/package.json index 1d4921f48..ad846da82 100644 --- a/packages/cli-darwin-arm64/package.json +++ b/packages/cli-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-darwin-arm64", - "version": "0.4.5", + "version": "0.4.6", "description": "Native scriptc compiler for darwin-arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-darwin-x64/package.json b/packages/cli-darwin-x64/package.json index dca236b32..7ecacacd1 100644 --- a/packages/cli-darwin-x64/package.json +++ b/packages/cli-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-darwin-x64", - "version": "0.4.5", + "version": "0.4.6", "description": "Native scriptc compiler for darwin-x64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-arm64-gnu/package.json b/packages/cli-linux-arm64-gnu/package.json index 1459caa1d..b74fffebe 100644 --- a/packages/cli-linux-arm64-gnu/package.json +++ b/packages/cli-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-arm64-gnu", - "version": "0.4.5", + "version": "0.4.6", "description": "Native scriptc compiler for linux-arm64-gnu", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-arm64-musl/package.json b/packages/cli-linux-arm64-musl/package.json index 3703c0059..a5a49e9ba 100644 --- a/packages/cli-linux-arm64-musl/package.json +++ b/packages/cli-linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-arm64-musl", - "version": "0.4.5", + "version": "0.4.6", "description": "Native scriptc compiler for linux-arm64-musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-x64-gnu/package.json b/packages/cli-linux-x64-gnu/package.json index 6d70361f9..4cc72ac73 100644 --- a/packages/cli-linux-x64-gnu/package.json +++ b/packages/cli-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-x64-gnu", - "version": "0.4.5", + "version": "0.4.6", "description": "Native scriptc compiler for linux-x64-gnu", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-x64-musl/package.json b/packages/cli-linux-x64-musl/package.json index 0c12fc60d..14db344e4 100644 --- a/packages/cli-linux-x64-musl/package.json +++ b/packages/cli-linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-x64-musl", - "version": "0.4.5", + "version": "0.4.6", "description": "Native scriptc compiler for linux-x64-musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-win32-x64-msvc/package.json b/packages/cli-win32-x64-msvc/package.json index 8872d2f15..827b76ad1 100644 --- a/packages/cli-win32-x64-msvc/package.json +++ b/packages/cli-win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-win32-x64-msvc", - "version": "0.4.5", + "version": "0.4.6", "description": "Native scriptc compiler for win32-x64-msvc", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli/package.json b/packages/cli/package.json index 59f9db90a..0ab88f222 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "scriptc", - "version": "0.4.5", + "version": "0.4.6", "description": "Compile ordinary TypeScript and JavaScript to small, fast native executables and WebAssembly modules that run without Node.js", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 3237def0a..a6b52a42f 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/compiler", - "version": "0.4.5", + "version": "0.4.6", "description": "The scriptc compiler — TypeScript/JavaScript frontend, typed IR, LLVM backend", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index ea8295406..7139fed28 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": 2, - "compilerVersion": "0.4.5", + "compilerVersion": "0.4.6", "coverage": [ "Entries are projected mechanically from the compiler's own decision tables: the diagnostics registry, the unsupported-syntax dispatch tables, the stdlib and node-builtin lowering tables, and the supported-builtin-module list. Nothing is hand-maintained; the manifest regenerates byte-identically from the source tree at this version.", "Absence from this manifest means 'not projected', never 'unsupported'. Surfaces lowered through dedicated code paths rather than tables are not yet projected: console, JSON, Promise/async and the timer surface, the net/http/tls/https/dgram/dns/assert/test/stream/readline module member surfaces, template literals, the regex slice, global functions (parseInt, parseFloat, isNaN, isFinite), and the process surface outside its ambient slice.", diff --git a/packages/experimental/package.json b/packages/experimental/package.json index 74e213448..55004c082 100644 --- a/packages/experimental/package.json +++ b/packages/experimental/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/experimental", - "version": "0.4.5", + "version": "0.4.6", "description": "Experimental scriptc APIs, with Node.js implementations: lifetime regions for ProgramLifetime objects, intern tables shared between threads, per-thread slots on shared objects, and array fields kept in their instances", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/libnode-darwin-arm64/package.json b/packages/libnode-darwin-arm64/package.json index 22d1e1889..9e9113a68 100644 --- a/packages/libnode-darwin-arm64/package.json +++ b/packages/libnode-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/libnode-darwin-arm64", - "version": "0.4.5", + "version": "0.4.6", "private": true, "description": "Prebuilt Node.js shared library and scriptc bridge for Node islands on macOS arm64", "license": "SEE LICENSE IN LICENSE", diff --git a/packages/libnode-darwin-x64/package.json b/packages/libnode-darwin-x64/package.json index dc9f3e06f..9a1e43bc6 100644 --- a/packages/libnode-darwin-x64/package.json +++ b/packages/libnode-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/libnode-darwin-x64", - "version": "0.4.5", + "version": "0.4.6", "private": true, "description": "Prebuilt Node.js shared library and scriptc bridge for Node islands on macOS x64", "license": "SEE LICENSE IN LICENSE", diff --git a/packages/libnode-linux-arm64-gnu/package.json b/packages/libnode-linux-arm64-gnu/package.json index 108e179cd..1ba37111f 100644 --- a/packages/libnode-linux-arm64-gnu/package.json +++ b/packages/libnode-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/libnode-linux-arm64-gnu", - "version": "0.4.5", + "version": "0.4.6", "private": true, "description": "Prebuilt Node.js shared library and scriptc bridge for Node islands on Linux arm64 glibc", "license": "SEE LICENSE IN LICENSE", diff --git a/packages/libnode-linux-x64-gnu/package.json b/packages/libnode-linux-x64-gnu/package.json index af2c3bace..e361aa369 100644 --- a/packages/libnode-linux-x64-gnu/package.json +++ b/packages/libnode-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/libnode-linux-x64-gnu", - "version": "0.4.5", + "version": "0.4.6", "private": true, "description": "Prebuilt Node.js shared library and scriptc bridge for Node islands on Linux x64 glibc", "license": "SEE LICENSE IN LICENSE", diff --git a/packages/llvm-darwin-arm64/package.json b/packages/llvm-darwin-arm64/package.json index d1ac54f3e..78198ec25 100644 --- a/packages/llvm-darwin-arm64/package.json +++ b/packages/llvm-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-darwin-arm64", - "version": "0.4.5", + "version": "0.4.6", "description": "Pinned LLVM code-generation helper for scriptc on macOS arm64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-darwin-x64/package.json b/packages/llvm-darwin-x64/package.json index b96a90cae..1a9ed9d2c 100644 --- a/packages/llvm-darwin-x64/package.json +++ b/packages/llvm-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-darwin-x64", - "version": "0.4.5", + "version": "0.4.6", "description": "Pinned LLVM code-generation helper for scriptc on macOS x64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-arm64-gnu/package.json b/packages/llvm-linux-arm64-gnu/package.json index 49796f522..c72277e4a 100644 --- a/packages/llvm-linux-arm64-gnu/package.json +++ b/packages/llvm-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-arm64-gnu", - "version": "0.4.5", + "version": "0.4.6", "description": "Pinned LLVM code-generation helper for scriptc on Linux arm64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-arm64-musl/package.json b/packages/llvm-linux-arm64-musl/package.json index 3001949c6..a9489efdd 100644 --- a/packages/llvm-linux-arm64-musl/package.json +++ b/packages/llvm-linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-arm64-musl", - "version": "0.4.5", + "version": "0.4.6", "description": "Statically linked LLVM code-generation helper for scriptc on Linux arm64 musl", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-x64-gnu/package.json b/packages/llvm-linux-x64-gnu/package.json index 11b375ba4..6949973db 100644 --- a/packages/llvm-linux-x64-gnu/package.json +++ b/packages/llvm-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-x64-gnu", - "version": "0.4.5", + "version": "0.4.6", "description": "Pinned LLVM code-generation helper for scriptc on Linux x64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-x64-musl/package.json b/packages/llvm-linux-x64-musl/package.json index ffe1ca7ae..d1ab5309a 100644 --- a/packages/llvm-linux-x64-musl/package.json +++ b/packages/llvm-linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-x64-musl", - "version": "0.4.5", + "version": "0.4.6", "description": "Statically linked LLVM code-generation helper for scriptc on Linux x64 musl", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-win32-x64-msvc/package.json b/packages/llvm-win32-x64-msvc/package.json index 77e6fe687..9d41b3df1 100644 --- a/packages/llvm-win32-x64-msvc/package.json +++ b/packages/llvm-win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-win32-x64-msvc", - "version": "0.4.5", + "version": "0.4.6", "description": "Pinned LLVM code-generation helper for scriptc on Windows x64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/node/package.json b/packages/node/package.json index 6a080da99..bb82fd4d8 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/node", - "version": "0.4.5", + "version": "0.4.6", "description": "Call into Node.js from scriptc programs: run an export of a JavaScript module in a Node.js helper process and copy JSON data both ways. Under Node.js the calls run in-process.", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-android-arm64/package.json b/packages/runtime-android-arm64/package.json index 7aea1da1e..405d2e8b1 100644 --- a/packages/runtime-android-arm64/package.json +++ b/packages/runtime-android-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-android-arm64", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc library runtime pack for Android arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-darwin-arm64/package.json b/packages/runtime-darwin-arm64/package.json index ae068a00a..06e7033d3 100644 --- a/packages/runtime-darwin-arm64/package.json +++ b/packages/runtime-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-darwin-arm64", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for macOS arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-darwin-x64/package.json b/packages/runtime-darwin-x64/package.json index 7dbdaf6b9..02ed6b3af 100644 --- a/packages/runtime-darwin-x64/package.json +++ b/packages/runtime-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-darwin-x64", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for macOS x64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-ios-arm64/package.json b/packages/runtime-ios-arm64/package.json index 8a868a606..17af72e9e 100644 --- a/packages/runtime-ios-arm64/package.json +++ b/packages/runtime-ios-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-ios-arm64", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc library runtime pack for iOS arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-ios-simulator-arm64/package.json b/packages/runtime-ios-simulator-arm64/package.json index 011d41b39..807ef48c8 100644 --- a/packages/runtime-ios-simulator-arm64/package.json +++ b/packages/runtime-ios-simulator-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-ios-simulator-arm64", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc library runtime pack for iOS simulator arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-arm64-gnu/package.json b/packages/runtime-linux-arm64-gnu/package.json index 92a3192e7..a00679bab 100644 --- a/packages/runtime-linux-arm64-gnu/package.json +++ b/packages/runtime-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-arm64-gnu", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for Linux arm64 glibc", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-arm64-musl/package.json b/packages/runtime-linux-arm64-musl/package.json index 5e751d829..c7f91ba98 100644 --- a/packages/runtime-linux-arm64-musl/package.json +++ b/packages/runtime-linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-arm64-musl", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for Linux arm64 musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-x64-gnu/package.json b/packages/runtime-linux-x64-gnu/package.json index 63c045385..edfd142c6 100644 --- a/packages/runtime-linux-x64-gnu/package.json +++ b/packages/runtime-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-x64-gnu", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for Linux x64 glibc", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-x64-musl/package.json b/packages/runtime-linux-x64-musl/package.json index a8602b395..e1e1d6955 100644 --- a/packages/runtime-linux-x64-musl/package.json +++ b/packages/runtime-linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-x64-musl", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for Linux x64 musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-wasm32-wasi/package.json b/packages/runtime-wasm32-wasi/package.json index 799863cf0..4aad3dbd4 100644 --- a/packages/runtime-wasm32-wasi/package.json +++ b/packages/runtime-wasm32-wasi/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-wasm32-wasi", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for WASI Preview 1", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-win32-x64-msvc/package.json b/packages/runtime-win32-x64-msvc/package.json index 421f1f71f..8409e7d25 100644 --- a/packages/runtime-win32-x64-msvc/package.json +++ b/packages/runtime-win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-win32-x64-msvc", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for Windows x64 MSVC", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 90140ab15..97a5b3344 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime", - "version": "0.4.5", + "version": "0.4.6", "description": "scriptc native runtime sources and vendored dependencies", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/threads/package.json b/packages/threads/package.json index 6ebcf5b8b..56811f3e4 100644 --- a/packages/threads/package.json +++ b/packages/threads/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/threads", - "version": "0.4.5", + "version": "0.4.6", "description": "Zero-copy publication of immutable object graphs to worker threads (scriptc), with a deep-freezing Node.js implementation", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/types/package.json b/packages/types/package.json index b2aa805be..547161cb9 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/types", - "version": "0.4.5", + "version": "0.4.6", "description": "Contract types for scriptc: type-only annotations (Int32, NoThrow, Immutable) that scriptc checks and optimizes, with no runtime semantics", "license": "Apache-2.0", "homepage": "https://scriptc.dev",