Counting array appends as edge-preserving makes `keep(item, items) { items.push(item); return item; }` a borrowed-return function: it preserves every heap edge, borrows every reference parameter and returns one of them. Its borrowing body now retains only the appended element and returns the parameter +0, and the owned adapter retains the result before releasing its parameters. An append never releases an element (the slow path stores past the old length), so the caller's owner keeps the result alive.
These PRs were merged into their stack base branches instead of main. This merges perf/alloc-hugepages (with the CI fixes below) into main. Conflicts: order-parity.json (union of entries); scr_alloc.c/scr_alloc.test.ts, where main already has the worker exit trim (#858, adapted to the pre-hugepages allocator): resolved by taking the hugepages allocator and re-applying the original trim commits written on top of it (ea88b87c + 4816696d, clean apply on an identical base).
Since #837 a type is traced only if it lies in a cycle-capable component or reaches an intrinsic reference; a type that merely holds a cycle cannot be reached back from it. In both backend-analysis cases `record:outer` only holds `record:cycle`, so the Node oracle and the native compiler agree on ["record:cycle"] and the expectations still listed `record:outer`.
Brings in cc7c6cb7 (inline truncation decided for the host when no triple is given), which this branch predates; without it the native bootstrap's LLVM comparison with the Node seed fails on x86-64 Linux.
Brings in cc7c6cb7 (inline truncation decided for the host when no triple is given), which this branch predates; without it the native bootstrap's LLVM comparison with the Node seed fails on x86-64 Linux.
os.availableParallelism() follows libuv's uv_available_parallelism, whose uv__slurp reads each /proc and cgroup file with a single read and fails on a read error; the helper looped until the buffer filled and ignored read errors, so a long /proc/self/cgroup could resolve a controller Node does not see. A cgroup CPU quota below one CPU truncates to 0 and stays ignored rather than clamped to 1: libuv 1.51 (Node 24) through 1.52 applies a quota only when it is positive, and Node then answers the affinity count.
Conflicts, all resolved to this branch's side:
- emitter.ts (numeric-table get, map index probe), expr-bytes.ts, local-array-reads.ts, string-reads.ts: the base only respelled the size-to-double conversions in the old range checks (uitofp nneg, now sitofp); exactIndexLines replaces those checks entirely. The base's other hunks in these files (host inline truncation, the remaining sitofp conversions) merge as they are.
- int32-slots.ts: #837 and #843 both extracted dynamicFieldStores with identical bodies; keep this branch's signature, whose optional records/unions maps also serve #837's single-argument callers.
A stale narrowing read in a call argument whose parameter accepts the declared type read as declared, so the callee's lowering tests the stored value. For standard-library callees that lowering is often specialized to the argument types programs pass: after `combo.p = {...}; combo()` the argument of `JSON.stringify(combo.p)` became `T | undefined` (SC1090), and after `ci(slow)` the handle of `timers.clearInterval(fast)` became `number | null` (SC2020), so 2072-expando-function-members.ts and 1804-timers-module.ts stopped compiling.
Call and construction arguments (directly or inside literal arguments) now read as declared only where the callee lowers any value of the parameter type: user callees, console methods, and standard-library parameters typed by a type parameter (`push(...items: T[])`, `Map.set`). Other standard-library arguments keep the checker's narrowing and the checked extraction.
The reference-write analysis borrows an argument when neither the callee nor a later argument writes a location it depends on. A later argument that only throws a fresh Error writes no array element, so `read(items[0], fail())` now borrows the element from the borrowed parameter's array and has nothing to release on any edge; the test pins that. The exception-edge release is now exercised by a later argument that pops the array before throwing, which still retains the snapshot and releases it before the exceptional return.
Two expectations predate the reference-write analysis and return moves on this branch. `keep(item, items) { items.push(item); return item; }` now qualifies as a borrowed-return function: its write set (array elements) misses the walked parameter, so the body retains only the appended element, returns the parameter +0 while the caller still owns it, and the owned adapter retains the result before releasing its parameters. In `snapshot`, `return saved` moves the saved alias's owned scope entry into the result rather than retaining it again and releasing the local at scope exit, leaving one retain.
The `nneg` flag on `uitofp` is LLVM 19 syntax. Sanitized builds hand the textual module to the host clang, and Ubuntu 24.04's clang 18 rejects it ("expected type"), which failed every Linux sanitized Effect shard. These operands are size_t values below 2^63, so `sitofp` converts them identically, is accepted by every supported LLVM, and still lowers to one cvtsi2sd on x86-64 (LLVM 19+ canonicalizes it to `uitofp nneg` where it can prove the sign).
The emitter defines @scr_detached_exit for --detached-exit executables, and the runtime ABI test requires every scr_* symbol the backend can emit to have a prototype or extern data declaration in the header.
scr_runtime.h declares uintptr_t scr_stack_guard_get(void), an i64 in the C ABI, but the Darwin worker stack check's cold fallback declared and called it returning ptr, which the runtime ABI test rejects. Declare i64 and convert the result with inttoptr.
Since #837 a type is traced only if it lies in a cycle-capable component or reaches an intrinsic reference; a type that merely holds a cycle cannot be reached back from it. In both backend-analysis cases `record:outer` only holds `record:cycle`, so the Node oracle and the native compiler agree on ["record:cycle"] and the expectations still listed `record:outer`.
Brings in cc7c6cb7 (inline truncation decided for the host when no triple is given), which this branch predates; without it the native bootstrap's LLVM comparison with the Node seed fails on x86-64 Linux.
The custom Dockerfile.sandbox image builds the LLVM helper against the same LLVM 22 CMake exports as the managed bootstrap, which reference the Polly libraries; without libpolly-22-dev CMake configure fails.
Three workers each hold and drop about 80 MB of small objects and exit;
three more then allocate as much again in the arenas the first ones
returned (on Linux the first ones hand those pages back when they exit).
Records the fixture's preflight/order baseline.
A script thread that exits parks its allocator arena with every freed
block still resident, so the memory of a finished worker stays with the
process until another thread claims the arena. On Linux, a parking thread
that carved at least 16 MiB now hands back every page of its arena that
holds only free blocks (MADV_DONTNEED), relinks the remaining free blocks
in address order, and keeps the returned page runs as zeroed regions that
the next owner carves before the slice frontier. It also trims glibc
malloc. SCR_SA_STATS=1 reports what each park returned; SCR_SA_TRIM=0
turns it off.
`scriptc build|run --detached-exit` opts an executable into handing the address-space teardown after process.exit to a helper process on Linux. The helper shares the address space (CLONE_VM), blocks signals, closes every descriptor but the read end of a pipe and waits for EOF on it. The kernel releases an exiting process's memory (exit_mm) before its files (exit_files), so EOF means the helper holds the last reference; it then exits and the kernel frees the pages there instead of before the parent sees the exit (30-40 ms per GB of 4 KiB pages; tsc-ts 20-110 ms). The same latency mold hides with --fork.
It is a build-time choice, not a run-time knob, because the helper keeps the executable mapped (overwriting the binary right after it exits can fail with ETXTBSY) and the memory charged to the cgroup for up to ~100 ms, and the teardown CPU time leaves the parent's rusage. The program object defines `scr_detached_exit = 1`, overriding the runtime's weak default of 0, so no runtime flavor or linker flag is added. The option is part of the early executable cache and route keys; executables only, no effect on other platforms.
Map.get of a reference value under a string key called scr_map_get_str_ref,
which retained the hit through the collection's value adapter: an indirect
tail call into an out-of-line retain function on every hit (in tsc-ts the
symbol tables' Symbol and Type retains, 0.4% of samples, were only reached
that way). Number keys already read the entry and retain with the value
type's own retain. String keys now do the same: scr_map_entry_str returns the
entry, and the generated code loads the value slot and retains it inline.
Both the ordinary and the stack-boxed (local) reads take the entry path.
Corpus 4651 keeps string-keyed results alive past delete, overwrite, clear
and the map itself for instances, records, arrays, nested maps, cycles and
union values; dropping the inline retain makes it (and 523) fail under ASan.
Every worker mailbox is a pipe. Once a process passes 64 descriptors the kernel grows its descriptor table, and while other threads share the table expand_fdtable first waits a full RCU grace period. tsc-ts on a 16-vCPU host stalled 11-25 ms on the thread starting its 14th checker (16 parse threads plus 13 checkers), on the critical path. Worker builds now grow the table to 256 entries at load, while the process has one thread, which never waits; the table never shrinks.
ELF worker executables gave the program's own thread-locals the
local-exec model and the runtime's external ones initial-exec. The linker
relaxes initial-exec to `mov $offset, reg` plus an indexed %fs access, so
every inline allocation (scr_thread_hot) and every stack check
(scr_stack_guard) paid an extra instruction and register.
The runtime is always linked statically into the executable, so its
thread-locals are in the executable's own TLS block too and local-exec is
valid for them: the access becomes one %fs-relative operand that LLVM
folds into loads and compares, as the pending word already is.
In worker executables scr_stack_guard is thread-local, and on Darwin every
thread-local access calls the variable's TLV thunk. Every stack-checked
function paid that call on entry; in tsc-ts the checks were about 12% of
the instructions retired on macOS.
The runtime now also keeps the running stack's guard in a pthread key's
thread-specific-data slot, written wherever scr_stack_guard changes
(thread start, fiber switches). libpthread stores a thread's values in an
array at its thread pointer, indexed by key; scr_stack_guard_init probes
that layout once and exports the slot's byte offset. Emitted checks in
Darwin worker executables load the guard from TPIDRRO_EL0 (masked) plus
the offset on arm64, or %gs plus the offset on x86-64: no call. If the
probe fails the offset stays zero and the checks call
scr_stack_guard_get from a cold block. Other targets are unchanged.
Executables link the runtime statically, so scr_exc_alert can be dso_local and addressed without the GOT. macOS tsc-ts self-check: 24.07G -> 23.92G instructions; the Linux x86-64 binary is unchanged.
The exact pending word was read with monotonic atomic loads. LLVM's
memory analyses treat an ordered load as a write: it clobbers every
other location in MemorySSA, so loads around a pending test could not be
forwarded after inlining, and x86 selection never folds it into a compare.
Ordered loads are only needed for liveness, where a cross-thread stop must
be observed:
- termination polls (function entry and loop budgets);
- tests inside source loops: the loop-poll rule drops a loop's poll
because these tests run every iteration, so LLVM must not hoist them;
- tail-position tests whose unwind returns the call's own result: an
unordered test there is dead, which lets tail-recursion elimination turn
the recursion into a loop that neither grows the stack nor tests again.
Every other test is now an unordered load of the same word. Same-thread
changes only happen inside runtime calls, which clobber memory, so the
unwind decision is unchanged.
Worker programs on Linux tested a process-wide alert word after every
call that may throw and called scr_exc_pending on its slow path to answer
for the calling thread. Each of the ~55k slow-path calls in tsc-ts counted
against LLVM's inline cost, and while any thread had an exception in
flight every other thread took the slow path.
ELF worker executables (the emitter's executableTls targets) now give
every script thread an exact pending word in the executable's static TLS
block, and the emitted check is one thread-pointer-relative load and a
branch to the unwind, with no out-of-line call:
- bit 0 mirrors "active cell kind != NONE". Only the owner thread writes
it, at every change of its active cell (throws, rethrows, take/clear,
fiber switches, payload moves, the termination sentinel). The uncaught
printer now settles it after resetting the cell.
- bit 1 requests the context's stop. worker.terminate() sets it through a
pointer the worker registers under a mutex while its script runs (a
terminate before registration is found through the cancel flag), and
scr_context_stop sets it for process.exit() and fatal paths. The stop
bit counts as the stop signal in scr_context_quiet/checkpoint.
A set stop bit unwinds while the active cell may still be clear, so every
consumer of an unwind installs the TERMINATE sentinel before inspecting
the cell: emitted try regions test the bit before entering a handler and
call scr_context_checkpoint, and fiber completion, the uncaught handlers
and exit listeners checkpoint instead of only testing
scr_context_stopping. The runtime's out-of-line scr_exc_pending already
checkpoints. Darwin and Windows keep the alert word (a Darwin thread-local
access is a call).
New corpus programs pin terminate() inside catch and finally bodies, with
exceptions stashed or propagating, inside generator and async bodies and
runtime callbacks, and before the worker starts, plus every way an
exception is raised, moved, consumed or discarded on the main thread and
in a worker, each followed by a may-throw call that must not unwind.
Publication (@scriptc/threads publish() and the zero-copy delivery of
deeply frozen plain-data graphs) makes objects immortal: nothing frees
them, and the RC audit already stops counting them. LeakSanitizer did not
know, so a program that drops its last reference to a published graph
reported every such object as a direct leak whenever leak detection was
on (SCRIPTC_SAN=1 runs on Linux without ASAN_OPTIONS=detect_leaks=0, such
as the documented local fallback). Both publication paths now mark each
object they make immortal with the weak __lsan_ignore_object hook in ASan
builds; ignored chunks are leak-scan roots, so the verdict on the rest of
the heap is unchanged. Other builds compile the hook away.
The sanitized lanes run with detect_leaks=0, so a harness test pins the
contract with LeakSanitizer on (Linux, SCRIPTC_SAN=1 lane).
Worker loops poll the exception alert every 64 iterations so that a loop calling nothing that tests still observes a terminate request. A loop whose condition, update or do-while condition evaluates a call followed by a pending test, or whose body evaluates one before any statement that can continue the loop, observes the request through that test. Corpus native-worker-terminate-tested-loops terminates workers spinning in each shape, including a continue path that keeps its poll.
A stack-checked synchronous function may throw, so every caller tests the pending state after calling it, and a descent that never returns ends at the stack guard, whose RangeError unwinds through those tests. A terminate request keeps the alert word raised, so the next test stops the thread; the entry poll only added a rejoining slow path that kept arguments live across a call in every recursive function. Async and generator bodies and the module entry keep their polls.