Commit Graph
3704 Commits
Author SHA1 Message Date
Malte Ubl 10e10061a6 runtime: keep a map's storage when clear() finds no entries
An empty map's tables are already empty, so clear() returns at once
instead of freeing oversized storage that the next fill would regrow.
Only a clear that removes entries frees storage far larger than them.
The compaction test in delete is marked unlikely.
2026-10-11 06:39:40 -07:00
Malte Ubl 714afdcfe5 runtime: size a cloned map's direct index for its own keys
new Map(m) and new Set(s) copied the source's direct-index size, which
follows the source's peak. The copy now covers its largest live key, and
keys that are sparse for their count hash instead.
2026-10-11 06:39:40 -07:00
Malte Ubl 175d79ff01 test: record the TS7 preflight/order baseline for 4598-map-set-clear-reuse 2026-10-11 06:39:40 -07:00
Malte Ubl 4831665524 Restore present arms of stale narrowings in typed slots
An assignment narrows a binding to the assigned arm, and TypeScript keeps
that narrowing across a closure or method that stores another arm. After
`if (acc !== null) return acc;` the checker still types the read as the
old arm, the return slot rejects the declared `T | null`, and the checked
extraction threw on the value the closure stored, where Node returns it.
The compiler itself has this shape (lowerDynObjectLiteral), so the native
compiler aborted with "a value of another type is not representable in
the target union" and closure-nullable-union-return.ts diverged.

A slot that accepts the declared type minus null/undefined now reads the
stale reference as NonNullable<declared>, like a binding narrowed only by
the absence test. Elements and properties of array and object literals
are such slots when the literal itself sits in a typed slot (annotated
declaration, argument, assignment, return of a function with a declared
return type). Untyped literals keep the checked extraction.
2026-10-11 06:39:39 -07:00
Malte Ubl 7956559ed1 runtime: make Map/Set clear and delete cost O(live), not O(peak)
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.
2026-10-11 06:39:39 -07:00
Malte Ubl 6b0b502303 Return the shared live adapter from DynHost
DynHost.liveDynRefAdapter was declared to return { snapshot; commit }
while LlEmitter returns its cached LlStreamTypedRefAdapter. Under Node
LlDyn received that same object and bindLiveArrayOps found arrayOps; the
native compiler width-copied the record into the narrower shape, dropped
arrayOps and emitted no scr_dyn_typed_ref_bind_array call, so native and
Node builds of the compiler emitted different LLVM. Declare the real
return type so both pass the shared record.
2026-10-11 06:39:38 -07:00
Malte Ubl 79d6abd42e Pass the shape host to bindLiveArrayOps
bindLiveArrayOps took an anonymous `{ declare(line: string): void }` and
four callers passed the LlEmitter class instance. The self-hosted
compiler projects a class instance into a record only from plain fields,
not methods, so the self-hosting IR check rejected each call with SC2002.
Type the parameter as ShapeHost and pass `host.shapeHost`, as the
neighbouring vAdapters/typedRefConstructor calls already do. The emitted
LLVM is unchanged.
2026-10-11 06:39:38 -07:00
Chris Tate 918389d62a Merge pull request #847 from vercel-labs/fix/stream-ir-serialization
Stream serialized IR so compiler-scale artifacts no longer hit V8's string limit
2026-10-10 23:21:07 -05:00
Malte Ubl a9d294d1c1 Widen the old level's growth fraction two doublings at a time
Full passes are scheduled by heap growth, and the fraction they wait for
widens while growth-triggered passes keep finding nothing. Widening went one
doubling per pass after two passes in a row had asked for it, so a heap that
grows for a whole run never reached the cap of 16 that #833 introduced: on
tsc-ts, runs with seven full passes widened to 9x at the sixth pass, and the
seventh still walked 2.7-3.1M objects (294-393 ms) late in the run.

The old level now widens two doublings per qualifying pass, never past what
the pass's yield asks for, so passes that keep finding nothing reach the cap
on the fourth pass instead of the sixth, while the heap is still small.
Narrowing is unchanged: a pass that finds old garbage moves the fraction
straight to what its yield asks for, and widening still waits for two passes
in a row. The mature level keeps a doubling per pass: a mature pass walks only
what was promoted since the last one, so its total walking hardly depends on
how passes are spaced, and narrower spacing lets less young garbage float.

The memory bound is #833's: once passes stop finding garbage, the heap may
grow to 17 times its size after the last full pass before the next. It is
now reached after four such passes instead of six. Jumping straight to the
cap after two was also measured; it reaches the bound at heaps of a few
thousand objects and is not better on tsc-ts at --checkers 8.

test_cycle pins the schedule from where an old-garbage ring narrows the
fraction: the pass after it does not widen, the second pass in a row that
finds nothing reaches the cap (one doubling per pass fails here at caps 1
and 16), and a capped pass comes exactly at the cap. cycle.test.ts also runs
SCR_CYCLE_GROWTH_CAP=8.

Measured with tsc-ts origin/parallel-check e469e33 (--optimization=speed)
against the parent commit, interleaved in Linux Vercel Sandboxes (8 vCPU),
medians; diagnostics and exit codes identical. The build is the next
commit's with its parallel walk off (SCR_CYCLE_PARALLEL=0), which runs this
commit's collector. RSS in MB:

  single-threaded (10 rounds)   wall              RSS
  Excalidraw                    7.94 -> 7.32  -7.8%   1263 -> 1280
  Playwright                    7.09 -> 6.90  -2.7%   1457 -> 1470
  TypeORM                       6.67 -> 6.39  -4.1%   1014 -> 1046
  tsc-ts self-check             4.33 -> 4.03  -6.8%    626 ->  622

  --checkers 8 (10 rounds, second host)
  Excalidraw                    2.56 -> 2.55  -0.6%   2120 -> 2200
  Playwright                    2.46 -> 2.39  -3.0%   2649 -> 2686
  TypeORM                       1.96 -> 1.85  -5.4%   1270 -> 1315
  tsc-ts self-check             1.04 -> 1.04  +0.5%    742 ->  744

Pass log (instrumented build, one single-threaded run each): full-pass time
337 -> 89 ms (self-check), 426 -> 130 ms (TypeORM), 323 -> 104 ms
(Excalidraw); the largest full pass now walks 1.1-1.3M objects early in the
run, and total collector time drops by 27-35%.
2026-10-10 20:45:39 -07:00
Malte Ubl 0b744838b4 Record TypeScript preflight/order baselines for the reference-write corpus 2026-10-10 20:45:38 -07:00
Malte Ubl 9afbabee3c Keep the reference-write analysis within the self-hosted compiler's subset
The natively compiled compiler rejected compound assignments to typed-array
elements, Uint32Array.some, a chained Set.add and a structural Pick of
ImmutableFields; bit operations now go through small helpers. The dense
reference-read test truncates its array first: a bare element return is
now lent as a borrowed return.
2026-10-10 20:45:38 -07:00
Malte Ubl ec5adcfa18 Lend optional element reads as borrowed returns
Safe index reads of possibly-missing elements lower to lifted helpers
(%arr.idxOr.N) that returned an owned element. Walks now admit required
element reads of walked arrays (an index whose computation removes no
edge), so these helpers qualify as borrowed returns: their body lends the
element its array keeps alive (borrowing the optional read directly), and
receivers consume the call's result without a retain/release pair, as for
any borrowed-return call. Paged lookups such as
`this.pages[id >> 8]?.[id & 255]` no longer retain the page array.

Corpus 4630-4633 renumbered from 4620-4623 (checkerhot uses 4620/4621);
4634 pins paged lookups and a read followed by a removal.
2026-10-10 20:45:38 -07:00
Malte Ubl a8113acb8f Move owned locals into return values
`return x` of a local whose slot is an owned scope entry of the return
type loaded the value, retained it for the caller and released the local
at scope exit. Outside finally regions the return now takes over the
local's reference: no retain, and the local's entry is skipped by the
return's releases.

tsc-ts e469e33 self-check, executed non-null non-immortal RC operations
in emitted code: 320.2M -> 307.6M (-3.9%) on top of the previous commit.
2026-10-10 20:45:38 -07:00
Malte Ubl 7df72c017a Borrow references whose locations nothing in their extent can write
ReferenceEffects answers one bit per function: does anything remove any
heap edge? Any reference store in the transitive callees (including a
constructor's stores into its own fresh `this` behind every `new`, a
closure call, or atomics in symbol-id allocation) disabled borrowing of
field-read and rebound-local arguments, walk locals, borrowed returns and
loop element borrows.

backend/llvm/reference-writes.ts keeps the location instead: per-function
transitive write sets over class fields (keyed by hierarchy root), record
fields, array elements, map entries, boxes, globals and the current
function's own locals, with key 0 as an unknown heap write. Locals stay
exact under unknown writes: no callee can rebind a caller's unboxed local.
Constructor stores into their own `this` drop out of `new`/`super`
summaries (checked: constructors run only through `new` and `super`).
Indirect calls resolve closure targets through function-typed parameters,
captures and adapters, otherwise the union of all closure bodies; runtime
library families that run no user code count as inert.

Uses:
- arguments of borrowed parameters (direct, `new`, virtual) and of
  projection-only array-read parameters borrow when neither the callee
  nor a later argument writes a location they depend on;
- stable locals initialized by a frame-unowned projection borrow when the
  rest of their block writes none of its locations; optional element
  reads into projection-only locals likewise;
- loop element borrows need only an iteration that writes no array
  element (nor the array's binding);
- walk locals and borrowed returns are admitted in bodies whose whole
  write set misses the walked locations.

tsc-ts e469e33 self-check (single-threaded, executed, non-null and
non-immortal RC operations in emitted code, measurement build):
505.3M -> 307.6M (-39%) before return moves (next commit).
Output identical. Build time unchanged (98.5 s vs 99.9 s, Linux).
2026-10-10 20:45:37 -07:00
Malte Ubl 5b8661bf8c Keep native compiler IR and frontend cache entries out of single strings
Scriptc-compiled strings stop at 2^31 - 1 UTF-8 bytes and exceeding the
cap traps ("scriptc: out of memory") instead of throwing. In the native
compiler the self-hosted IR was one 515 MB string, and the frontend
cache entry JSON.stringify'd LLVM and IR together into one ~920 MB
string, about 2.3x below the trap.

- NativeExecutableInput.ir is compact IR as ~16M-char parts; every IR
  output is written with writeTextPartsSync.
- Frontend cache entries move to scriptc.native-frontend.v3: the input
  metadata carries placeholders and the LLVM and IR follow as separate
  members, so the entry is written as parts with an incremental digest
  (NativeCache.writeParts). Entries a native reader could not decode as
  one string are neither published nor read (NativeCache.read maxBytes),
  turning the old trap into a cache miss.
- SCRIPTC_BOOTSTRAP_IR_INPUT decodes the seed IR per function with
  deserializeModuleBytes.

The native LLVM text remains one string (344 MB for the compiler,
~6x below the native cap).
2026-10-10 20:39:35 -07:00
Malte Ubl 20b7da29e7 Stream serialized IR and LLVM instead of building one string
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).
2026-10-10 20:39:35 -07:00
Malte Ubl fe6a4a8503 Check narrowed member reads that a call can invalidate
TypeScript keeps the narrowing of a property reference (and of a binding
that a closure assigns) across calls. After `c.cb = fn` or
`if (c.name !== undefined)`, a callback, method or getter that clears or
replaces the property leaves later reads typed by the old value. Lowering
trusted that type: maybeNarrow extracted the union payload unchecked, and
`?.`, truthiness and `typeof` folded by the narrowed type. A second
`c.cb?.()` after a self-clearing callback threw where Node answers
undefined, and `string | undefined`, `string | number` or record fields
read after such a call crashed or reinterpreted memory.

NarrowingStability decides whether a narrowing is provably current: every
property in the reference is construction-only (no store to its name
outside `this.name = ...` in a constructor body, no accessor, no computed
or reflective store into instances) with a stable root, or nothing between
the earliest occurrence of the reference in the use's function and the use
can run other code or store the property through another reference
(standard library calls without callbacks are inert; a loop around the use
contributes its whole body). Proven reads keep the unchecked extraction.

Otherwise the read observes the stored value. In `?.` receivers, absence,
type and truthiness tests, member reads the declared type supports, and
slots that accept the declared type, the reference reads as declared, so
the consumer tests at runtime exactly as Node does. Everywhere else the
extraction is checked and throws Node's TypeError for the consuming
operation (`Cannot read properties of undefined (reading 'x')`,
`o.f is not a function`). Calls through callback fields use the same
message. A null-only receiver of a restored `?.` short-circuits to
undefined, and `typeof` evaluates an effectful union operand once.

The checked failure path groups arms by message so wide unions stay small:
the self-hosted compiler's IR grows by 0.03%. tsc-ts self-check, Linux,
interleaved in one VM: check time -1.5% (noise), `--checkers 8` -1.2%.

Corpus 4650 pins the shapes; a unit test pins which reads stay unchecked.
2026-10-10 20:39:34 -07:00
Chris Tate 059b0212e3 Merge pull request #845 from vercel-labs/perf/parser-hot-paths
Run counted loops from an unproven start as integer loops
2026-10-10 21:28:34 -05:00
Chris Tate 4ff3a743ec Merge pull request #843 from vercel-labs/perf/final-field-borrows
Borrow final reference fields without a retain
2026-10-10 21:28:01 -05:00
Chris Tate 9b460892a5 Merge pull request #841 from vercel-labs/perf/checker-hot-paths
Count module TDZ checks, atomic counters and array appends as edge-preserving
2026-10-10 21:27:35 -05:00
Chris Tate 3afea88bfb Merge pull request #839 from vercel-labs/perf/length-to-double-nneg
Convert lengths and in-range indices to doubles as non-negative
2026-10-10 21:27:14 -05:00
Chris Tate e44f50b327 Merge pull request #837 from vercel-labs/perf/trace-cycle-candidates
Trace only types that can lie on a cycle
2026-10-10 21:26:29 -05:00
Chris Tate 0b0b7a8319 Merge pull request #835 from vercel-labs/fix/number-undefined-unions
Keep missing reads undefined through number arguments, narrowed slots and lambda results
2026-10-10 21:25:54 -05:00
Chris Tate 43bb23ee0a Merge pull request #833 from vercel-labs/perf/cycle-growth-cap
Let the cycle collector's growth fraction widen to 16
2026-10-10 21:25:20 -05:00
Chris Tate 972557b71b Merge pull request #832 from vercel-labs/fix/array-read-arguments
Keep guarded and in-bounds typed-array reads out of the optional ABI
2026-10-10 21:24:40 -05:00
Chris Tate 3c2eeac1d7 Merge pull request #830 from vercel-labs/perf/alloc-hugepages
Move busy small-object slices to transparent huge pages
2026-10-10 21:24:01 -05:00
Malte Ubl 6ddaa2a140 Drop guards on private constructor stores and borrow construction-only fields
Two uses of the construction-only facts outside the collector
(backend/immutability.ts):

- Publication guards: a store into the constructor's own `this` that lies
  before the constructor's escape point, in a constructor no ancestor of
  which lets `this` escape and that is never invoked directly on an
  existing object, cannot target a published object. It loses its
  rc == SIZE_MAX guard and no longer seeds may-throw, so constructors that
  could only throw through guards lose their pending checks at every `new`.
  Storing `this` into one of its own fields does not expose it.
- Borrowed reads: `x.f` holding an object, record, array or string is
  borrowed as a call argument, receiver or stable alias when `f` is
  construction-only and `x` is itself borrowable (unchanged local,
  borrowed parameter, or such a projection): nothing can replace and
  release it while `x` lives. Not inside constructors, which may still
  write their own fields. Module constants (immutable, not TDZ-checked,
  assigned at most once) are borrowable too.

No object is frozen or marked at run time; publication errors, store order
and reflection are unchanged.

tsc-ts (e469e33), static: published-field guards 6,528 -> 4,462,
sc_release calls 146,056 -> 129,756, sc_retain calls 26,215 -> 22,123,
scr_arr_release 29,865 -> 25,617, binary 23.68 -> 22.96 MB (both commits).
Self-check single-threaded, executed: guards 96.1M -> 60.8M, retains
273.5M -> 247.2M, releases 340.3M -> 314.0M.

Linux sandbox, interleaved medians, both commits against
perf/alloc-hugepages:

  single-threaded (6 runs)   base     new     change
  Excalidraw                 8.62 s   8.34 s  -3.2%
  Playwright                 7.83 s   7.71 s  -1.5%
  tsc-ts self-check          4.36 s   4.22 s  -3.0%
  tRPC                       1.63 s   1.58 s  -3.1%
  TypeORM                    6.68 s   6.50 s  -2.5%

  --checkers 8 (10 runs): -2.3% .. +0.7% (neutral)

Runtime suite (--layouts=4 --runs=16): geomean -1.9%; ast-interp -4.4%,
validate-errors -3.8%, regex-logs -1.7% faster, template-render +1.4%
slower, the rest neutral; sizes unchanged.
2026-10-10 15:42:50 -07:00
Malte Ubl b552bc1354 Read module constants as their literal values
`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.
2026-10-10 15:39:28 -07:00
Malte Ubl 2b056343ee Run counted loops from an unproven start as integer loops
`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.
2026-10-10 15:39:28 -07:00
Malte Ubl bb4b16f026 Borrow final reference fields without a retain
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.
2026-10-10 15:39:27 -07:00
Malte Ubl ca009a0073 Keep the ToUint32 hint of NaN-coded typed-array reads
The hint is ToUint32 of the value, and ToUint32(NaN) is 0, so a read whose
index may be invalid keeps it (the invalid arm already contributed 0); it was
dropped unless the index was proven in bounds. Worker programs also dropped it
at the join with the shared-buffer path, which now converts its integer result
exactly. `hash ^ chars[i]` and similar operators then use the loaded integer
instead of re-deriving ToInt32 from the double.
2026-10-10 15:39:26 -07:00
Malte Ubl 4d3ed5ba81 Check unproven indexes with a signed round trip
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).
2026-10-10 15:39:25 -07:00
Malte Ubl 44f4d0bf19 Prove more fields construction-only and trace only types on a potential cycle
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.
2026-10-10 15:25:49 -07:00
Chris Tate b610c8d6ea Merge pull request #828 from vercel-labs/fix/lowerer-init-order
Keep null distinct from the TDZ in forward-captured nullable bindings
2026-10-10 17:20:25 -05:00
Malte Ubl cc7c6cb730 Decide inline truncation for the host when no triple is given
The Node-hosted CLI emits with an empty target triple (the host), while
the native CLI passes its toolchain's triple. Inline truncation keyed on
the triple text alone, so on x86-64 Linux the Node seed emitted
llvm.trunc and the native compiler emitted the inline sequence, and the
native bootstrap's LLVM comparison with the Node seed failed
(self-hosting-native-driver, rebuild-emit). An empty triple now means
the host architecture, as it already does for executable TLS; WASI keeps
the intrinsic. The DataView emission test accepts either truncation
form, since it runs on hosts of both kinds.
2026-10-10 15:11:19 -07:00
Malte Ubl a854b53e03 Count module TDZ checks, atomic counters and array appends as edge-preserving
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.
2026-10-10 15:05:02 -07:00
Malte Ubl ef8a7171a9 Borrow the bound receiver of optional chains
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).
2026-10-10 14:58:47 -07:00
Malte Ubl 67f9de094c Convert lengths and in-range indices to doubles as non-negative
Array, string and byte lengths, counts, capacities and range-checked indices are size_t values below 2^63, but they were converted with a plain uitofp. On x86-64 without AVX-512 an unsigned 64-bit conversion expands to a five-instruction sequence with two constant-pool loads (unpcklps/subpd/unpckhpd/addsd), and it showed up in the hottest tsc-ts checker loops (compareTypeLists, getPropertyOfTypeEx) between a length load and the conversion back to an integer. `uitofp nneg` states what is already true, so the backend emits one cvtsi2sd. arm64 converts unsigned values in one instruction either way. Byte-number indices, which admit values up to 2^64, keep the unsigned conversion.
2026-10-10 14:47:55 -07:00
Malte Ubl bf17c23af9 Return defaulted and missing reads from TypeScript lambdas
TypeScript types `read ?? "text"` as number when the read is typed number,
and `() => xs[i]` as returning number. Declared functions already widened
their returns for both (the primitive result union and the optional
read), but arrow functions and function expressions kept the checker's
return, so the closure narrowed its result and threw "a 'string' value is
not representable" or "undefined is not representable" (the scanbox
`[string, () => unknown][]` probe table).

Unannotated TypeScript lambdas now get the same widened return as
declared functions. A const or global binding of such a lambda keeps the
checker's parameters and takes the widened result, and calls through the
binding count as optional or primitive-union results for the enclosing
function's return.
2026-10-10 14:47:17 -07:00
Malte Ubl 0027fa3226 Keep undefined in narrowed optional fields and locals
After `obj.field = bytes[i]` on a `number | undefined` field, TypeScript
narrows later reads to number, so the read extracted the number arm
unchecked and an out-of-range read printed 0 instead of undefined. Locals
declared `T | undefined` had the same problem inside functions
(`let x: number | undefined; x = xs[i]; x + 1` gave 1, not NaN).

The optional-read analysis now records instance fields declared exactly
`T | undefined` that receive a possibly missing value; their narrowed
reads keep the stored union, as runtime-optional record fields already
do, and the value propagates into parameters and returns like other
optional reads. Locals declared that way join the runtime-optional
storage tracking, so bare reads keep the union until a guard proves
presence. A member read through such a field throws Node's "Cannot read
properties of undefined" TypeError.
2026-10-10 14:47:12 -07:00
Malte Ubl e52688095d Convert missing number arguments with ToNumber
String.fromCharCode, String.fromCodePoint, the global isNaN and isFinite,
DataView offsets and values, and typed-array slice/subarray bounds took a
`number | undefined` value (an out-of-range read the checker types as
number) through a checked narrow, which threw "undefined is not
representable" or refused to compile. Node converts undefined with
ToNumber: code unit 0, a RangeError for fromCodePoint, isNaN true, offset
0; an undefined slice end is the length.

The Math argument conversion becomes a shared helper that also handles
field reads and any other value lowered as a number-or-undefined union.
2026-10-10 14:46:53 -07:00
Malte Ubl 4b325150fa Support os.availableParallelism() in static programs
The runtime answers like libuv 1.51's uv_available_parallelism, which
Node's os.availableParallelism() returns: on Linux the CPUs in the
process's affinity mask, lowered to a smaller cgroup (v2 cpu.max or v1
CFS) CPU quota; on Darwin hw.activecpu, hw.logicalcpu or hw.ncpu; on
Windows GetSystemInfo's processor count; at least 1. WASI answers 1, as
the dynamic island does.

Parallel programs (tsc-ts --checkers) need it to size their thread pools.
Corpus 4598 pins the value against the host's Node.
2026-10-10 14:45:28 -07:00
Malte Ubl 73a99b2393 Let the cycle collector's growth fraction widen to 16
Mature and old passes are scheduled by heap growth. While growth-triggered
passes at a level keep finding nothing, the fraction they wait for widens by
doublings from 1/4, and it used to stop at 1, so a large live heap that keeps
growing was walked in full once per doubling. On tsc-ts those walks are most
of the collector's time: in single-threaded Excalidraw, 12 growth-triggered
full passes cost 2.0 s of 14.7 s, the last two walking 2.6M and 5.2M objects
to free 1.4%.

The cap is now 16. That is a memory trade-off: once passes stop finding
garbage, the cycle-headered heap may grow to 17 times its size after the last
pass at a level before the next (it was 2 times), so a program that
accumulates dead cyclic garbage while passes find nothing can hold up to 16
times more of it. Yield-driven narrowing is unchanged: a pass that finds old
garbage moves the fraction straight back to what its yield asks for.

SCR_CYCLE_GROWTH_CAP lowers the cap for memory-sensitive programs, read like
SCR_CYCLE_THRESHOLD: a power of two from 1 to 16 (other positive values round
down, larger ones read as 16); 1 restores the previous schedule.

test_cycle pins the cap on a heap at the growth floor: a dead cycle through
the old generation must go exactly when the heap has grown the configured cap
past the floor (not one doubling earlier, not later), and a pass that finds
old garbage must narrow the fraction at once. cycle.test.ts runs it at the
default cap and at SCR_CYCLE_GROWTH_CAP=1, 3 and 64. The large-chain widening
check keeps its walk bound but no longer waits out the cap on a 266k-node
structure, and now runs after the tests that assume the base schedule.

Measured with tsc-ts origin/parallel-check e469e33, built with this runtime
vs the parent commit, interleaved in one Linux Vercel Sandbox (8 vCPU; 5
rounds, medians; --checkers 8 repeated with 10 rounds). Diagnostics identical.
"cap 1" is this build with SCR_CYCLE_GROWTH_CAP=1:

  single-threaded   wall     RSS            cap 1
  Excalidraw        -10.1%   1283 -> 1263   +0.5%
  Playwright        -11.0%   1446 -> 1457   +0.3%

  --checkers 8
  Excalidraw        -8.7% / -4.5%   2110 -> 2132 / 2115 -> 2125   +2.6% (10 rounds)
  Playwright        -7.8% / -7.7%   2616 -> 2634 / 2589 -> 2678   -1.0% (10 rounds)

RSS in MB (max of two runs). The runtime benchmark suite's allocation-heavy
workloads (alloc-trees, word-graph, ast-interp, json-records, records-sort,
functional-pipeline; --layouts=4 --runs=16) are neutral in time and peak RSS
(-0.3% to +1.7%).
2026-10-10 14:44:14 -07:00
Malte Ubl 1c90711572 Record the preflight/order baseline for typed-array-present-reads
One new entry (single-module order, no diagnostics); every existing entry
is unchanged.
2026-10-10 14:35:04 -07:00
Malte Ubl b9eb5254f0 Keep the if-condition bound opt-in for forwarded assertions
indexReadInBounds proves only `i < xs.length`, not that `i` is a
non-negative integer. For `if (f(xs[i]!))` the forwarded-assertion path
used to pass a missing element as undefined; accepting `if` conditions for
every caller turned such reads into the dense bounds trap, which throws for
a negative index where Node passes undefined (tsc-ts: isMatchableRegion and
compareSymbolChains changed ABI). The `if`-condition position is now an
opt-in fact, used by the present-read proof, which also proves the index a
non-negative integer counter.
2026-10-10 14:24:15 -07:00
Malte Ubl 25e3ece9d5 Access live array references one element at a time
A static array passed where a dynamically typed array is expected (for
example a `number[]` given to an `(object | undefined)[]` or `any[]`
parameter) travels as a live typed reference. Every operation on that
reference refreshed its dyn view by rebuilding the entire array, so
`xs[i]`, `xs.length`, `xs[i] = v`, `i in xs`, `Object.hasOwn`, each
`for...of` step, each `for...in` key probe, and `push`/`pop`/`at` all
cost O(n), and any loop over the array was quadratic: 10k elements took
12 s and 20k took 53 s on Linux. Writes also re-converted and swapped the
whole native storage on every store.

The native array is already the source of truth: each refresh rebuilds
the view from it and keeps only the prototype and symbol properties. Live
array capsules now carry per-element hooks (ScrDynTypedArrayOps), emitted
per array type next to the existing snapshot adapter and bound when the
capsule is built. The runtime answers length and presence from the native
array and converts exactly one element per read, writes an in-range or
appended element in place, and steps array iterators without a refresh.
Holes and out-of-range reads, which can reach the prototype chain, keep
using the view. A value the element type rejects, a write that would
grow the array by more than one element, a multi-value push, and `at`
with a coercible argument also keep the snapshot path, so their
diagnostics and atomicity are unchanged.

The capsule payload stays 80 bytes: the key length and the two flags now
share one 32-bit word, which frees the slot for the hooks pointer on both
64- and 32-bit targets.

Linux, 100k elements: the index loop went from beyond a 120 s timeout to
7 ms; Node takes 0.5 ms.

Test: test_json.c live_array_ref_tests. Corpus:
4598-live-array-ref-element-access.
2026-10-10 14:24:13 -07:00
Malte Ubl 8e07b1cffc Pin that an escaping typed array keeps its optional reads
Code holding the array could detach its buffer between the guard and the
read, so the present-read proof must refuse it. With a genuine NaN also
reaching the parameter and a consumer that observes undefined, the
parameter stays `number | undefined`.
2026-10-10 14:14:16 -07:00
Malte Ubl 1c974f015a Keep guarded and in-bounds typed-array reads out of the optional ABI
A typed-array read that may name a missing element lowers to a boxed
`number | undefined`, and the optional-value analysis widens every
parameter it reaches for all callers. Two patterns in tsc-ts's relater
boxed its hottest relation methods this way, failing the build's scalar
ABI check ("Boxed numeric arguments in Checker.isTypeAssignableTo"):

- `const regular = this.regularTypes[id]; const sourceId = regular >= 0
  ? regular : id;` The read is NaN-coded, but its occurrence in the true
  arm was rebuilt as `number | undefined`, so `sourceId` and the
  parameters of isSimpleTypeRelatedTo and structuredTypeRelatedTo were
  boxed. A comparison with a number that is never NaN is false for
  undefined and NaN alike, so an occurrence of a never-reassigned binding
  under such a guard is a plain, non-NaN number. The analysis now treats
  it as one wherever it flows.

- `for (let i = 0; i < sources.length; i++) isTypeAssignableTo(sources[i],
  targets[i])` over two Uint32Arrays allocated with the same length. The
  parameters also receive a JSON field that may be NaN, so NaN-coding
  cannot carry them (`source === target` must tell NaN from undefined).
  The reads are now proven present instead: the index is a non-negative
  integer counter that only its own function writes, a dominating guard
  bounds it by the array's length or by the length of a parallel typed
  array allocated with the same length, and each array is a `const`
  fixed-length typed array referenced only through element accesses and
  `.length`, so no code can detach its buffer. A proven-present read
  lowers to the plain element read and is never optional.

indexReadInBounds also accepts a read inside an `if` condition and takes
an optional same-length fact. Reads the proofs do not cover keep the
established lowering: an inclusive bound, a shorter parallel array, a
decreasing or fractional counter, a counter a closure writes, and an
array that escapes all still read undefined, as the new corpus program
typed-array-present-reads checks against Node.
2026-10-10 14:12:59 -07:00
Malte Ubl a7663551e3 Install Polly in the managed sandbox bootstrap
LLVM 22 CMake exports reference the Polly libraries, so building the LLVM helper in a sandbox started from vercel/sandbox/universal fails without libpolly-22-dev. Custom images that already contain it are unaffected.
2026-10-10 14:04:04 -07:00
Malte Ubl 2d90508bc7 Move busy small-object slices to transparent huge pages
Every class slice used to opt out of transparent huge pages, so each
4 KiB the bump pointers crossed cost a page fault. Those faults (fault
entry, memcg charge, LRU insertion and the page clear) were 6-14% of
tsc-ts samples in the kernel. Backing the whole reservation with huge
pages removes most of them, but makes the 2 MiB page around every bump
pointer resident at once. A worker executable has up to 32 class slices
per script thread, so tRPC at 8 checkers grew from 558 to 955 MB.

A slice now starts on small pages. Once it has carved 8 MiB, the rest
of its range switches to huge pages (MADV_HUGEPAGE). Only slices that
already hold 8 MiB pay for a partial last huge page. The reservation is
huge-page aligned, so the switch point and every slice start on a 2 MiB
boundary. The slow path raises a slice's bump limit in two steps (to the
switch point, then to the slice end), so the inline fast paths are
unchanged. The policy is Linux-only; other targets keep one limit per
slice, as before. The allocator checks now build with _GNU_SOURCE on
Linux like the runtime packs, so the madvise advice is compiled in and
checked (alignment, contiguous carving across the switch, VmFlags nh/hg).

Measured with tsc-ts best-v1 (parallel-check + 6247795 e24057a 64d9af6
1c42c55), built with this runtime vs origin/main, on two Linux Vercel
Sandboxes (8 vCPU; 5-7 interleaved runs, medians). Columns: wall, max
RSS, minor faults; "all" is MADV_HUGEPAGE on the whole reservation:

  single-threaded   wall    RSS     faults         all: wall / RSS
  Excalidraw        -6.1%   +0.8%   331k -> 112k   -10.0% / +3.5%
  Playwright        -1.8%   +1.0%   371k -> 111k    -8.4% / +3.2%
  TypeORM           -7.2%   +0.9%   259k ->  75k   -11.8% / +4.1%
  tRPC              -5.0%   +2.0%   101k ->  47k    -9.0% / +11.3%
  tsc-ts self-check -2.9%   +2.6%   158k ->  64k    -3.1% / +8.4%

  --checkers 8
  Excalidraw        -2.9%   +2.6%  1075k -> 508k    -6.3% / +10.6%
  Playwright       -10.0%   +2.6%   739k -> 381k   -10.3% / +12.2%
  TypeORM           -5.9%   +2.2%   372k -> 232k   -10.1% / +27.7%
  tRPC              -2.5%   +2.5%   159k -> 125k    -4.3% / +71.2%
  tsc-ts self-check -4.0%   +3.9%   213k -> 172k   -12.1% / +46.3%

Switching at 2 or 4 MiB was up to about 1.5 points faster single-threaded
but cost up to +11% RSS at 8 checkers. 16 MiB gave back most of the gain.
Prefaulting small pages with MADV_POPULATE_WRITE did not reduce sys time.
Collapsing the small-page prefix with MADV_COLLAPSE at the switch did not
help either. The runtime benchmark suite (--layouts=4 --runs=16) shows a
geomean change of -1.2%: inventory-report -9.9%, validate-errors -5.3%,
log-summary -2.9%, and regex-logs +2.1%. regex-logs peaks at 6.8 MB and
never reaches the switch point; with one binary the policy measures -0.9%
there, so its delta comes from code layout.
2026-10-10 14:03:22 -07:00