Commit Graph
901 Commits
Author SHA1 Message Date
Malte Ubl fa6c6988b2 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	packages/compiler/src/backend/llvm/emitter.ts
#	packages/compiler/test/ts7/baselines/order-parity.json
2026-10-11 12:22:24 -07:00
Chris Tate baeaf9c7e0 Merge pull request #854 from vercel-labs/perf/exception-flag-main
Exact per-thread pending word for worker executables, and a call-free macOS stack guard
2026-10-11 14:06:38 -05:00
Chris Tate b2bb6fa173 Merge main into perf/exception-checks-main 2026-10-11 13:38:06 -05:00
Malte Ubl a34d018497 Merge remote-tracking branch 'origin/perf/exception-checks-main' into HEAD 2026-10-11 10:16:45 -07:00
Malte Ubl 445730ee38 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
2026-10-11 10:16:37 -07:00
Malte Ubl 9931ea30de Merge remote-tracking branch 'origin/perf/static-immutability' into HEAD 2026-10-11 10:15:24 -07:00
Malte Ubl 8f8c48d598 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	packages/compiler/src/backend/llvm/emitter.ts
#	packages/compiler/test/ts7/baselines/order-parity.json
2026-10-11 10:15:17 -07:00
Malte Ubl fe79aea42e Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
2026-10-11 10:15:10 -07:00
Chris Tate e503d26b17 Merge pull request #842 from vercel-labs/perf/exact-index-checks
Cheaper checks for unproven typed-array and string indexes
2026-10-11 12:06:42 -05:00
Chris Tate c0cedd769c Merge pull request #840 from vercel-labs/perf/borrow-optional-chain-receivers
Borrow the bound receiver of optional chains
2026-10-11 12:06:17 -05:00
Malte Ubl ba7c1ed2b3 Merge remote-tracking branch 'origin/perf/exception-checks-main' into HEAD
# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
2026-10-11 09:36:39 -07:00
Malte Ubl 0e4beb15a2 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
2026-10-11 09:36:31 -07:00
Malte Ubl d56348effd Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
2026-10-11 09:36:22 -07:00
Malte Ubl 98dc0f1314 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-11 09:36:04 -07:00
Malte Ubl e52c7dbbbd Merge remote-tracking branch 'origin/perf/fold-constant-globals' into HEAD 2026-10-11 09:36:03 -07:00
Malte Ubl c4cb109c38 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
2026-10-11 09:35:55 -07:00
Malte Ubl 70c443e694 Merge remote-tracking branch 'origin/perf/static-immutability' into HEAD
# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
2026-10-11 09:35:31 -07:00
Malte Ubl ef2c5b6278 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
2026-10-11 09:35:21 -07:00
Malte Ubl cc13a7eff9 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-11 09:35:13 -07:00
Malte Ubl 5c3f6f80e0 Land the approved performance stack (#829, #830, #832, #833, #835, #837, #839)
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).
2026-10-11 08:57:42 -07:00
Chris Tate c6822dbbf6 Merge pull request #858 from vercel-labs/perf/worker-exit-trim
runtime: return an exiting worker thread's free pages to the system
2026-10-11 10:51:46 -05:00
Malte Ubl 9a90bd8ac6 Merge the fixed perf/trace-cycle-candidates into perf/static-immutability 2026-10-11 08:28:13 -07:00
Malte Ubl 74029bada4 Expect only cycle members to be traced in the self-hosting analysis cases
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`.
2026-10-11 08:28:03 -07:00
Malte Ubl d2deedbf45 Merge perf/typed-array-reads into perf/trace-cycle-candidates
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.
2026-10-11 08:28:02 -07:00
Malte Ubl 81f8502387 Merge the fixed perf/alloc-hugepages into perf/exact-index-checks
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.
2026-10-11 08:25:48 -07:00
Malte Ubl a310e02f73 Merge the fixed perf/alloc-hugepages into perf/parmem 2026-10-11 08:17:24 -07:00
Malte Ubl fb291122f5 Expect only cycle members to be traced in the self-hosting analysis cases
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`.
2026-10-11 08:13:08 -07:00
Malte Ubl 5ed8842d43 Merge perf/typed-array-reads into perf/alloc-hugepages
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.
2026-10-11 08:12:14 -07:00
Chris Tate 003265a1df Merge pull request #852 from vercel-labs/fix/publish-leaks
Tell LeakSanitizer that published objects are immortal
2026-10-11 09:10:50 -05:00
Malte Ubl 4f89dfd19d tests: workers that drop tens of megabytes and later workers reusing their arenas
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.
2026-10-11 07:08:22 -07:00
Malte Ubl bdba4a7a1c Test an exact per-thread pending word in ELF worker executables
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.
2026-10-11 06:39:42 -07:00
Malte Ubl 63c45c99ee Tell LeakSanitizer that published objects are immortal
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).
2026-10-11 06:39:41 -07:00
Malte Ubl ad99adc1a0 Skip worker loop polls when every iteration tests the pending state
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.
2026-10-11 06:39:41 -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 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 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 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
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
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 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 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 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 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
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 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