The sanitized native emitter peaks near 13 GB while it emits the
compiler and then compiles the 348 MB module with clang under
AddressSanitizer, which together exceed the 16 GB runner. Back the
rebuild-emit job with a 24 GiB swap file and lift its stack limit,
since the instrumented emitter's recursion can exceed 8 MiB.
Optional array reads also match arrays whose element is itself the read T | undefined nullable-pointer union; owned copies retain and release through its NULL-tolerant entry points. A local array read borrows when its array operand is rooted at a stable parameter and its own live range preserves heap edges, and a call-argument read borrows when the call preserves them. An optional chain on such a read borrows the inline element instead of taking a second reference.
Emitted ScrCycType descriptors now carry collector statistics indices from ShapeHost.nextCycKind, counted from FIRST_EMITTED_CYC_KIND. The llvm-layouts.ts host lacked the hook, so its class descriptors and vtables stamped kind 0 and the native layout emission diverged from the production emitter's.
Nearly every long-lived object becomes a cycle candidate once, soon after
it is built: the constructing frame's release leaves it held only by the
structure it was stored into. A nursery pass then walks it (about 75 ns per
object), it survives, and it is re-buffered for the mature level, whose
next pass walks it again. In tsc-ts single-threaded, nursery passes walked
6.0M objects on Excalidraw and 36.5M on Sentry and freed 7%; 99% of what
they walked was promoted. Most of that walking goes to types whose objects
never die in a young cycle: SymbolLinks, NodeLinks and Symbol were 2.0M of
Excalidraw's candidates and the collector freed none of them, while young
garbage comes from a few types (inference contexts and mappers, closures,
some arrays). Types with a collector yield under 0.5% are 42-55% of what
nursery passes walk.
So the collector now keeps per-thread statistics per type and level. Each
ScrCycType gains a `kind` index: emitted descriptors number themselves from
32 (FIRST_EMITTED_CYC_KIND / SCR_CYC_KIND_EMITTED), the runtime's own
families have fixed kinds below that, 0 keeps no statistics. For the
nursery and mature levels, a pass counts the candidates of each kind it
walks and the objects of each kind it frees. After every 8192 walked
candidates a kind bypasses that level for the next window if fewer than 1
in 128 of them were freed. A release that buffers an object at a level its
type bypasses promotes it past the level first, and a nursery pass that
re-buffers a survivor into a bypassed mature level moves it to the old
level. One candidate in 16 stays put, so the yield keeps being measured
and a type whose objects start dying young again is walked again after
one window.
Only the generation an object is judged in changes. Any assignment is
sound (a restricted pass counts edges from older objects as external), a
bypassing candidate stays buffered, and full passes walk everything they
did before, so the old level's bounds hold unchanged. Cyclic garbage made
only of objects of a bypassing type waits for the next pass at an older
level. Programs that never complete a window (8192 walked candidates of one
type) are collected exactly as before.
The descriptor layout is part of the emitted-code/runtime ABI, so the
program-object marker moves to scr_runtime_abi_v12.
Pass log (instrumented build, single-threaded, one run each, with the
preceding null-slot change): all collector passes Excalidraw 606 -> 313 ms
(nursery 435 -> 153), TypeORM 629 -> 383 ms, tsc-ts self-check 377 -> 215
ms, Sentry 7.61 -> 5.75 s (nursery 2.92 -> 1.04 s, mature 1.17 -> 1.02 s;
the full pass is unchanged at 3.6 s).
tsc-ts (Linux sandbox, interleaved, medians; 10 rounds, Sentry 5 rounds
pinned to 8 CPUs; both changes against int/best-v6): single-threaded
Excalidraw -8.0%, TypeORM -4.2%, self-check -4.3%, Sentry -5.3%;
--checkers 8 -12.2%, -0.8%, -2.3%, -1.6%. Peak RSS -0.8% to +0.6%.
Diagnostics identical.
Tests: test_cycle gains check_nursery_bypass, check_mature_bypass and
check_rebuffer_past_mature (a test-only entry point, scr_cyc_pass_at under
SCR_CYC_TESTING, runs passes at exact levels); each fails under the
corresponding mutation (no bypass, a sticky decision, no sampling, no freed
or walked counts, a bypassing candidate left unbuffered, survivors
re-buffered into a bypassed level). Corpus 4690 builds a long-lived
declaration graph large enough for both levels to bypass and lets objects
of the same classes die in cycles; under --sanitize the exit RC audit
proves they are reclaimed.
The 16-byte collector header replaced ClassHost.cycleColorOffset with cycleBufferedOffset (6 bytes behind every object on every target); llvm-layouts.ts still set the removed property and lacked the required one.
Every ScrArr carried sixteen words, seven of them (sparse slots, their
length and capacity, noncanonical numeric properties with length and
capacity, and match-result metadata) used only by sparse arrays, arrays
with numeric non-index properties and regex match results. They now live in
a ScrArrExt the array points at, allocated on first use: a dense array
shrinks from 128 to 80 bytes, and a cycle-capable one (with its 16-byte
collector header) from 144 to 96.
Runtime readers go through a shared empty ext; writers allocate it. The
backend mirror loses the seven fields, tagged-template arrays are emitted
with a NULL ext, live stream commits swap the ext pointer (which swapped all
seven fields before), and the cached typed-view refresh swaps sparse slots
and properties through scr_arr_swap_indexed_ext, keeping each array's
metadata as before. The program-object ABI marker moves to
scr_runtime_abi_v11.
A corpus program (4610-array-ext-dyn-refresh.ts, with its order baseline) pins numeric non-index properties of a dynamically typed array through its refreshed typed view, sparse slots written directly and through a live reference, truncation, and regex match metadata read directly, through `any` and after a copy.
Every cycle-capable object carried a 32-byte header: trace and teardown function pointers, a 32-bit color, the buffered flag, the generation and a 64-bit candidate index. The two function pointers are the same for every object of a type, so the header now points at one constant ScrCycType { trace, free_fn } descriptor per type, and the remaining fields pack into the second word: u8 color, u8 gen, u16 buffered, u32 buf_index (candidate buffers stop at 2^31 entries). wasm32 pads before the descriptor, so every object-relative offset is the same on all targets.
Emitted shapes define their descriptor next to the trace and teardown functions; inline allocation stores one header word; the inline release reads buffered at obj-6 and the inline free reads the generation as an i8 at obj-7. Runtime families allocate with static descriptors. The program-object ABI marker moves to scr_runtime_abi_v10.
tsc-ts (interleaved, 5 rounds): peak RSS -8% to -11% on every app and mode; single-threaded wall -0.3% to -5.5% on top of the typed-rest-spread change.
A call with a runtime-length spread went through the checked-dynamic argument pack whenever the callee's parameters could be checked back out of dyn (since 31332427b), even when the spread is trailing and lands in a typed rest parameter of its own array type. Every argument, including class instances, was converted with dynFrom, and every class reaching dynFrom gets a hidden %dynProperties field in its whole hierarchy; the generated property helpers then box the classes its fields reference.
Lower the spread operands first and keep the static completion when every runtime spread fills a typed rest with the same array type, or a T[] into an argument-widened (T | undefined)[] rest, which an interned copy reads element by element the way spread iteration does (holes and present undefined as undefined). In tsc-ts this removes the bag from Node, Symbol, Diagnostic, NodeList and 193 other classes.
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).
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.
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.
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.
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.
Number-keyed maps index small integer keys through a direct table sized
by the largest key ever stored, and bucket tables are sized by the most
entries ever held; neither shrank. scr_map_clear memset both on every
call, even on an empty map, so a collection that was large once paid its
peak size on every later clear. tsc-ts' Relater.reset clears a
Set<number> on every relation check: on Sentry, 36% of the CPU was that
memset.
- clear() resets only the direct-index slots of the entries it removes
(the table is zero at every absent key), resets the bucket table only
when entries pointed into it and it is proportional to them, and frees
storage far larger than the entries removed, returning the map to its
initial state as V8 does. Clearing an empty map does no work.
- delete compacts once fewer than a quarter of at least 64 entries are
live (V8's shrink point), so iteration after mass deletion is O(live).
- Compaction shrinks oversized bucket tables, and after a delete or an
iteration it shrinks the direct index to the surviving keys or moves
keys that became sparse back to hashing. The append path keeps the
current index: it holds a slot of it.
Insertion order and live iteration are unchanged: nothing compacts while
an iteration is active.
The self-hosted compiler's compact IR is ~515-528M characters, a few
million below V8's maximum string length (2^29 - 24). serializeModule
joined it into one string, deserializeModule parsed one string, and the
native bootstrap comparison read cli.ir.json and cli.ll as utf8 strings,
so any compiler growth crashed --emit=ir and the bootstrap tests with
"RangeError: Invalid string length".
- serializeModuleParts returns the compact encoding as ordered parts
(header, one part per function, remainder). serializeModule(mod, true)
is their join, so the on-disk format is byte-identical.
- deserializeModuleBytes splits a UTF-8 Buffer at the root object's
"functions" array with a byte-level scanner and parses the header and
each function separately. Malformed input falls back to the
whole-document parse for its exact error.
- The Node API writes --emit=ir, --emit-ir sidecars and --emit=llvm
through writeTextParts (the former writeLlvmSource batching writer).
- The native bootstrap harness decodes IR per function and compares
LLVM as Buffers, removing ABI-check lines at the byte level.
- The self-hosting serialization fixture gains a "bytes" mode so the
scriptc-compiled serializer exercises both new entry points.
Self-hosted compiler IR (514.6 MB, macOS arm64, Node 24.12):
read old 5.85 s / 4.26 GB max RSS -> new 5.84 s / 1.36 GB
write old 2.05-2.53 s / 4.48 GB -> new 2.30-2.37 s / 2.10 GB
2x module (1.03 GB): old serialize and read throw; new round-trips.
The remaining per-string bound is the largest function (16.3M chars,
~33x headroom) and the header (2.3M chars).
`for (let i = start; i < end; i++)` kept a double counter when the start had
no proven integer range, as in a hash over `[start, end)` whose bounds come
from fields. Such a loop is now versioned like one with an unproven limit:
when the start is an exact integer of at most 2^53 - 1 in magnitude (compared
bit for bit, so -0 takes the original loop), the integer-counter copy runs.
The usual bounds on duplicated bodies apply.
`const DOT = 0x2e` at module scope was a global that every reader loaded (a
thread-local load in worker programs), hiding the value from integer ranges,
switch dispatch and LLVM. foldConstantGlobals rewrites reads of an immutable
number or boolean global that the program writes exactly once, with a literal,
and that has neither a TDZ sentinel nor an initialization flag (globals that
can be read early keep their checked load). The store stays, so namespace
objects still find the value. Library builds keep their globals.
A reference field that only its class family's constructors write, through
their own `this`, never changes outside them. While its object is held, the
field's value is held too, so `this.scanner.scan()` and
`const units = this.units` need no retain and release (and no collector
buffering on the release).
analyzeFinalFields finds such fields over the whole program. It looks through
the derived constructor's guards on `this`, and disqualifies fields that live
dynamic views can store into (the detection int32 slots already uses, now
shared as dynamicFieldStores), fields of runtime-provided or generic classes,
and every field in library builds. canBorrowCallArgument accepts a final field
of a borrowable object, except in constructors, async functions and
generators, and except for nullable-pointer fields, which have no box to lend.
A typed-array, inline ASCII charCodeAt, local array, constant-table or
number-map read whose index is not a proven integer decided validity with
two float compares against the converted length, then an fptoui/uitofp round
trip. On x86-64 both unsigned conversions expand to long sequences, and the
tsc-ts scanner pays them on every character it reads by position.
exactIndexLines replaces them with fptosi, freeze, sitofp, one float equality
and one unsigned integer compare against the length. A frozen out-of-range or
NaN conversion is an arbitrary integer that never converts back to the index
or is not below the length; below the length the round trip is exact, and -0
converts to 0 like ToPropertyKey(-0).
The cycle analysis gave a header to every type that could reach a cycle,
and its construction-only facts gave up on a whole hierarchy as soon as a
constructor let `this` escape or a type could cross into checked-dynamic
code. Four refinements, all derived from the final IR:
- A constructor's stores stay construction-only up to the first top-level
statement in which `this` escapes. Statements run in order, so earlier
stores complete while the object is private; stores from the escaping
statement on, and every store of a constructor whose ancestor let `this`
escape, are writes. Compound-assignment receiver temporaries count as
`this`. An all-immutable cycle is still impossible: each immutable edge
a -> b is stored before a escapes while b has escaped already.
- Crossing into checked-dynamic code only makes types mutable when the
program contains a runtime store into a dynamic value (int32-slots'
audit, now shared as dynamicFieldStores): typed-ref capsules write native
fields only through such a commit. Without one, hidden property bags
stay empty and add no edges. FFI operands and intrinsics other than
publish stay conservative.
- A type is traced iff 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: its references are external counts.
- Reachability is per class: a slot of static type C reaches C's subtree,
not the whole hierarchy; header presence stays uniform per hierarchy.
tsc-ts (e469e33): traced classes 603 -> 469 of 700, records 64 -> 10 of
202, unions 344 -> 93 of 482; scr_cyc_alloc sites 659 -> 471. Mutable
hierarchy units 243 -> 4. Collector candidates on the self-check
(single-threaded) 6.02M -> 5.55M.
A function is edge-preserving when it cannot remove a reference edge; callers then pass field projections borrowed. tsc-ts lost the property for its whole type-ordering family because compareTypeLists reads a module binding behind an import-cycle TDZ check (module.tdzCheck), and for compareSymbols because getSymbolId bumps a shared id counter with Atomics.add (atomics.op). Fresh array literals without spreads and the array operations that only read, copy into a fresh array or append (push and pushSpread retain the new element; moving the slot storage releases nothing) were rejected too. All of these may allocate or throw, which the summary already allows. Removals (pop, shift, splice), truncation, overwrites and Atomics.wait keep the conservative answer.
compareTypes now passes its field arguments borrowed: 42 retains and 118 releases in its body become 4 and 4, and compareSymbols, compareTypeNames and binarySearchTypes need none. Corpus 4621 covers a comparator behind a real import-cycle TDZ error, an atomic id counter, appends that move the storage of the array owning a borrowed argument, loop bindings over appending and draining calls, and callees that shift or truncate the owning array.
An optional chain such as `resolved.members?.get(name)` took three references to its receiver: the owned receiver value, a +1 extraction into the bind slot, and a +1 copy for every chainRecv use, each released out of line (scr_map_release for maps). Now the bind slot borrows the payload whenever another owner outlives the body (the statement frame owns the receiver value), chainRecv reads are borrowable receivers and call arguments, and a receiver projected from a borrowable owner is not retained at all when the body preserves reference edges. Optional chains and chain receivers count as edge-preserving themselves; their children decide. Stack receivers, multi-arm unions and bodies that may remove an edge keep the owned forms.
Corpus 4620 covers chains whose body clears or replaces the receiver field, drops the last other reference, nested and missing links, call and element receivers, self-clearing optional calls, and values that outlive later stores; a mutation that ignores the body check is caught by ASan (heap-use-after-free).