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.
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`.
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.
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.
The NaN-coded read analysis memoized lookups whose answer can be null
(symbols, eligible functions, call sites, plain-number returns) and read
them back with `map.get(key)!`. Under Node that returns the cached null,
but in the natively compiled compiler `!` is a checked extraction, which
threw "null is not representable" while lowering programs
(self-hosting-native-frontend, the native CLI bootstrap contracts). Read
the cache through an undefined check instead.
Every two-argument Math.min/Math.max was an out-of-line call to
scr_math_min/scr_math_max (94 sites in tsc-ts, including
compareCodePoints and IdentifierTable.intern). LLVM's minimum/maximum
intrinsics have exactly their semantics: NaN propagates and -0 orders
below +0. On x86-64 they lower to minsd/maxsd with inline NaN and
signed-zero handling, on arm64 to fmin/fmax.
A typed-array element read answers undefined for an index outside
[0, length), so the default lowering returns a heap-boxed
`number | undefined` and widens every binding, parameter and return the
read reaches. In the tsc-ts scanner that boxed every character read and
turned every character predicate (isLineBreak, isIdentifierPart, ...)
into a function taking a boxed union.
Most consumers cannot tell undefined from NaN: relational, arithmetic
and bitwise operators, truthiness, Math functions, String.fromCharCode,
typed-array indexing, switch over number cases, and equality with a
number that is never undefined. The new analysis (nan-coded-reads.ts)
follows reads through local bindings, parameters of directly called
functions and methods (no overrides, no value uses), and their returns.
A slot is NaN-coded when every value it receives is a NaN-coded read or
slot, or any other number (converted with ToNumber on entry), and either:
- it never holds a genuine NaN (integer elements, integer constants,
guarded integer arithmetic), so NaN stands for undefined exactly; any
consumer that takes the value whole (an argument, a stored or returned
value, a literal element, concatenation) gets the exact
`number | undefined` rebuilt, and the optional-read analysis treats
that occurrence as optional, so it lowers as before; or
- every consumer is NaN-insensitive.
Equality of two NaN-coded values that never hold a genuine NaN also
answers true for two NaNs, as undefined === undefined does. Any other
slot keeps the established union lowering; asserted reads (`t[i]!`) are
unchanged.
tsc-ts best-v1 (scriptc main + this commit vs main, Linux sandbox,
Xeon 2.5 GHz, interleaved, medians of 5, diagnostics identical):
| app | parse | total (single-threaded) | wall --checkers 8 |
| --- | --- | --- | --- |
| tRPC | 1.263 -> 0.866 s (-31.4%) | 3.035 -> 2.527 s (-16.7%) | -13.0% |
| Excalidraw | 2.637 -> 1.865 s (-29.3%) | 14.99 -> 14.04 s (-6.3%) | -16.2% |
| Playwright | 3.150 -> 2.202 s (-30.1%) | 12.93 -> 12.14 s (-6.1%) | -12.4% |
| TypeORM | 2.562 -> 2.036 s (-20.5%) | 10.93 -> 10.46 s (-4.3%) | -8.6% |
| self-check | 2.080 -> 1.529 s (-26.5%) | 7.06 -> 6.68 s (-5.4%) | -15.1% |
The tsc-ts IR has 646 instead of 697 scr_union_new_f64 sites (the rest
are cold), 22 instead of 168 typed-read helper calls, and 217 instead of
572 scr_union_get_f64 calls; the scanner's hot functions allocate no
union boxes. Binary size -0.2%.
Baseline x86-64 has no rounding instruction, so every llvm.trunc became
a call to libm's trunc. The typed-array read helper paid that call on
every read: it decided presence with `i >= 0 && i < length &&
Math.trunc(i) === i` before the inline element load.
- Math.trunc (and the DataView offset truncation) now take an integer
round trip on x86-64 targets: values of magnitude below 2^52 go through
fptosi/sitofp with the sign restored by copysign (so -0.5 and -0 give
-0); larger values, NaN and the infinities are already integral and
keep their value. Other targets keep llvm.trunc, which is one
instruction there.
- The ToUint32 slow path is only reached for magnitudes above 2^53 (and
non-finite values), which are integers, so its truncation was a no-op
and is gone.
- For integer element kinds, the read helper performs the element read
that yields NaN for an invalid index (one inline index check) and
answers undefined exactly when that read is NaN: an integer element is
never NaN. Float element kinds keep the range and integrality test.
A closure created above the `let`/`const` it captures holds the binding
in a TDZ box whose NULL payload slot marks "not yet initialized". A
union of one reference arm with `null` and/or `undefined` (`Set<T> |
null`, `string | undefined`, `C | null`) is represented as the bare arm
pointer with NULL standing for the unit arm, so storing that unit value
made the box indistinguishable from the temporal dead zone: every later
read or write threw "Cannot access 'x' before initialization" although
the declaration had run.
The self-hosted compiler trips on exactly this shape in
lowerer.ts (`let dynamicViews: Set<string> | null = null`, read by a
callback created earlier), which made scriptc 0.2.8 fail to build
tsc-ts's relater with SC3004. Node evaluates the source correctly; the
native code was wrong.
TDZ boxes of nullable-union bindings now start at the module's immortal
NULLABLE_ABSENT sentinel and the TDZ test compares against it. No value
of the binding can hold that sentinel, and every RC entry point and the
cycle collector already skip it, so box release, trace and the
initializing store need no other change.
Corpus: 4596-tdz-nullable-forward-capture.
The class method census marks a method name as replaceable when a
function-typed property write uses that name, so a scanner storing its
error callback in `this.onError` made every call of any class's
`onError` method go through the checked property bag. That bag is a
dynamic store naming any property, so the int32 slot analysis also gave up
int32 storage for every class field exposed to dynamic code.
A function-typed write now marks the name only when an instance of a class
that has the method can inhabit the receiver's type: an assignability check
against the instance types of the method's owners, with `this` in an
instance member read as its class. Generic or anonymous owners, `this`
outside instance members, type parameters, `any` and `unknown` receivers
keep the conservative answer. Writes through interfaces or subclasses that
can reach a method still replace it.
Test: class-method-mutations.test.ts. Corpus:
4597-callback-field-method-name.
A class extending Error without its own constructor (or one passing
options to super) initializes the builtin prefix through the
error.ctorOptions helper, whose message and options arguments are dynamic.
The int32 slot analysis did not list that helper among the dynamic helpers
that never store into a live class view, so a single such class anywhere in
a program counted as a dynamic store naming every property: every f64
field of every class exposed to dynamic code lost its int32 storage, and
the integer facts of the functions reading those fields went with it.
error.ctorOptions runs scr_error_init_options, the same initializer
error.newOptions (already listed) runs: it stamps the receiver's
runtime-owned error prefix and only reads `options.cause`. In the native
tsc-ts self-check, two such classes cost 7% total time and 10% check time.
Unit test: int32-slots.test.ts. Corpus: 4596-error-subclass-int-fields
pins Error subclasses forwarding message and options beside integer
fields.
A field shared by every class arm of a union could only be read or
written when all arms declared it with one IR type. Syntax-tree code
commonly narrows a node to several classes with `instanceof` where one
class declares the field optional and another required (`initializer:
Expression | undefined` beside `initializer: Expression`), or as a
subclass beside its base, and both forms were refused.
Reads now dispatch on the union tag, read each arm's own slot, and widen
it to the arm field type every other one converts into without a check
(a union wrap or class upcast); the caller narrows the result to the
checker's flow type as before. Writes evaluate the value once as the arm
field type that every other arm's storage accepts and widen it per arm.
Arms whose field types are unrelated keep the existing refusal.
Corpus: 4594-union-class-field-mixed-types.
A parameter typed as a base class becomes runtime-optional storage
(`Base | undefined`) when a caller can pass an array hole. After
`instanceof` narrowed it to several subclasses and a guard such as
`node.label === undefined` ended its active runtime-optional state, the
next `node.label` lowered the receiver as the storage union, which has no
retag to the checker's class union, and the shared field read or write was
refused. Union property reads and union field writes now convert such a
receiver with the checked extraction and class test that typed slots
already use.
Corpus: 4595-union-class-field-optional-receiver.
- Synchronize published packages and the compiler surface manifest at v0.2.8, and bump the runtime ABI marker to v9.
- Add release notes for worker graph sharing, catchable deep recursion, parallel code generation, runtime performance, and compatibility fixes.
A shorthand property and an object literal passed as an argument no
longer widen the declared record layout of the binding or parameter
that receives them. A widened layout made every existing record that
flowed into that binding or parameter convert to a copy, so later
writes through the copy were lost. Those literals keep a missing
element in their own fields and convert, with a check, into a declared
record.
The debug-variables fixture iterates an array whose elements may be
undefined, so its loop binding keeps the union layout the debugger
check inspects.
sync-versions stamps packages/threads with the CLI version (0.2.7 now),
the release workflow checks that version and publishes the package
before scriptc, and RELEASING.md lists it with the packages that always
release together. npm trusted publishing must be configured for
@scriptc/threads before the next release: a package without it fails
before upload.
A published string can never record facts in its header later, so
publication proves ASCII before marking (every UTF-16 index mapper
answers ASCII strings by identity) and primes the Map key hash only for
short strings: hashing every source text cost more than the rest of the
walk, and the map code already skips caching into immortal strings.
Strings are marked when pushed instead of going through the worklist.
SCRIPTC_PUBLISH_TRACE reports object counts and walk/commit times.
The new @scriptc/threads package exports publish(value) and
sharesPublishedGraphs. Node.js runs its JavaScript: publish validates
the whole graph (plain objects, arrays, class instances, Maps, Sets and
primitives other than symbols), then deep-freezes it, and published Maps
and Sets reject mutation. scriptc builds lower both exports to
intrinsics and never compile the package body.
In scriptc, publish makes the graph immutable and immortal: a
compiler-emitted walker per static type (llvm/publish.ts) marks every
object rc == SIZE_MAX through a runtime worklist (scr_publish.c), so
retains, releases and the collector skip it and nothing frees it, and
the objects leave the collector's buffers. Hierarchy members dispatch on
the concrete class; arrays, maps, sets, strings and dyn subgraphs have
runtime walkers. Static types over-approximate a graph, so an
unpublishable type gets a refusing walker that fails only when a value
is present, and every mark is logged so a refusal restores the whole
graph, exactly like the Node implementation's validate-first order.
A typed-ref capsule over a published object travels through postMessage
and workerData as a pointer (a fresh capsule in the receiver; vtables,
adapters and type keys are process-wide); immortal dyn graphs inside a
message are shared the same way. Iterating a published map no longer
writes its iteration depth.
Stores into the static types a program publishes (computed by
backend/publication.ts, a class with its whole hierarchy) carry a guard
that throws Node's frozen-object TypeErrors, and may-throw counts them.
The corpus program pins the contract against Node: shared and cyclic
class graphs with arrays, maps, sets and records read identically on
both threads, every write form fails with Node's message, unpublished
instances of the same classes stay writable, a refused publish leaves
its graph unchanged, and a worker-published graph reaches the main
thread. packages/threads/README.md documents the contract and the
differences (prototypes and identity across messages).
Each mark records its object kind, so a successful publish takes the
objects out of the RC audit's live counts (immortal objects are never
freed).
Dates (time values) and process streams (descriptors) are scalars in
scriptc, so their walkers refuse a date field or root directly instead
of pushing its bits as a pointer; the refusal corpus program pins the
statically typed refusals (date, function, RegExp, typed array and Error
values in class and record fields, and a date root) against Node.
Main lowers a discarded or union-typed `xs.pop()` to a length
truncation; those stores now carry a `pop` marker so a published array
fails with pop's own TypeError ("Cannot delete property 'N'", or the
length message for an empty array, whose `length = 0` store the emitter
drops unless the array type can be published).
The inline half of a class or record release sent NULL, last and
immortal references to the out-of-line slow release. Static literals
were the only immortal objects then; a published graph is released as
often as ordinary objects (tsc-ts checking a published program spent
more time in the no-op calls of Node releases than in any checker
function). The rarely taken branch now tests for immortal first; the
common decrement path is unchanged.
A postMessage or workerData graph whose objects and arrays are all
frozen, ordinary and hold only primitives, strings and such containers
is now shared by pointer: every node and string becomes immortal
(rc == SIZE_MAX, so retains, releases and the cycle collector skip it
and nothing frees it), strings cache their key hash first, and the
nodes leave the sender's cycle buffers. Posting an already published
root is O(1). Anything else is cloned as before; SCRIPTC_PUBLISH=0
turns publication off and SCRIPTC_PUBLISH_TRACE=1 reports each choice.
With the receiver freezing what it receives (Node's clone is not
frozen) and no identity comparison across messages, the behavior
matches Node's structured clone. Published nodes leave the RC audit's live counts, since they are never
freed, and the small-integer dyn cache becomes process-wide (filled
before main): a published graph can outlive the thread that created its
nodes, and the thread-local table died with its thread.
The new corpus program pins that
contract both ways, including a worker-published graph read after the
worker exited and a shallowly frozen graph that must still be cloned.
Deferred collection diagnostics now stay attached to their declaration until a
copy reaches the final report, so a discarded probe buffer or a successful
nested collection can no longer drop them. Uses of a binding whose type names
a rejected class report that class's diagnostic before the inherited-blocker
cascade.
Positional arguments of Math.hypot now take the same undefined-to-NaN
conversion as Math.max, Math.min and the fixed-arity statics, and a
direct indexed read that can miss converts the same way as a binding
holding one.
A for-of loop over an array whose own facts show a hole or a stored
undefined binds T | undefined, and the typed parameters, locals, record
fields and Math arguments it reaches widen to hold it. Loops over arrays
the element-state analysis proves free of holes read each element
directly, without a per-element state test.
Object literals built from a missing-element read keep the property as
undefined in shorthand and long form, through spreads, nested records,
arrays of records, annotated locals and literal arguments to typed
parameters.
Concurrent builds of different entries with the same file name share
the default .ll path in a common output directory. The helper read that
shared file in place, so one build could compile the other program's
LLVM text. The helper now always reads a private copy written in large
batches, as before partitioning.
The partition debug-information test also matches the Mach-O
__debug_info section name, so it checks the same property on macOS.
Compiler caches keyed by programs and lowering passes move onto the
objects that own their lifetime, and the field-creation pass recognizes
its own writes by their receiver temporaries. A source test keeps weak
collections out of the compiler. Records and arrays that reach an
untyped slot through a union now keep their identity, and empty object
literals are keyed by their native object, so both work as WeakMap and
WeakSet keys instead of failing at runtime.
Large executables were simplified and inlined as one module before the
optimizer split them, so most of a release build ran on a single core,
and any edit recompiled the whole program.
The LLVM helper now divides the program before optimization. It splits
the emitter's assembly text directly, so partitions also parse
concurrently, and falls back to LLVM's module splitter for any input
outside the emitter's grammar. Internal functions referenced by a single
function stay with that function, and globals stay with their first user.
Optimized partitions follow the ThinLTO model: each is simplified and
summarized on its own thread, a thin link imports small functions across
partitions for inlining and returns unexported hidden definitions to
internal linkage, then each partition is optimized and compiled on its
own thread. Unoptimized partitions compile independently, so dev builds
of large programs are parallel too.
A private per-helper cache directory stores simplified partitions under
the hash of their source and objects under the hash of the module after
importing, so a rebuild recompiles only the partitions an edit can
affect. To keep unchanged partitions identical across edits, each
partition numbers its metadata in first-use order, lists only the debug
compile units it uses, and orders declarations by name, and interned
string constants are named by their content rather than first-use order.
Partition counts are powers of two that depend only on the module size,
up to 64, so output stays the same on every host and functions keep
their partition across ordinary edits. LLVM text that arrives in parts is
written in large batches and handed to the helper in place instead of
being written a second time.
Worker runtime variants link only into executables, whose own TLS block
holds every runtime thread-local, but they were compiled with the
default general-dynamic model. The linker relaxes each __tls_get_addr
call to a %fs-relative address, yet the compiler had already treated it
as a call: hot runtime leaves saved and restored extra registers around
every thread-local access (scr_mg_visit pushed five registers instead
of one). With -ftls-model=initial-exec each access is one %fs-relative
operand. Darwin, Windows and wasm variants are unchanged.
tsc-ts self-check, Linux sandbox, Worker build vs non-worker build:
1.04x -> 1.03x (9 interleaved runs).
The collector's thread-locals in worker executables (disposal queue,
candidate buffers, pass vectors, generation limit and scheduling state)
were twenty separate variables. scr_rc_destroy touched four of them, and
the release and walk paths several more; on Darwin each costs a
_tlv_get_addr call per function. Worker builds now keep them in one
ScrCycThread block (macros keep the sources unchanged); ordinary and
library builds keep separate variables.
tsc-ts self-check, macOS, Worker build vs non-worker build: 1.10x ->
1.08x.
In worker executables the small-object allocator state, the collector's
live and old-freed counts and the weak dispose hook were four separate
thread-locals, and every allocation or free touched them all. On
Darwin each distinct thread-local costs a _tlv_get_addr call per
function, so a runtime free made four such calls.
They now share one ScrThreadHot block (macros keep the runtime sources
unchanged; ordinary and library builds keep separate globals). The
emitted inline paths address the fields at fixed offsets from a single
llvm.threadlocal.address, which LLVM shares across a function.
tsc-ts self-check, macOS, Worker build vs non-worker build: 1.12x ->
1.10x.
Worker programs sent every typed-array element read and write through
scr_bytes_get/scr_bytes_set, which take the shared-memory lock guard
even for buffers no other thread can see (4% of a Worker build of the
tsc-ts self-check). A view's `shared` storage is fixed before script
code can see it, so the emitted access now tests it: SharedArrayBuffer
views keep the locked runtime call, every other view takes the ordinary
inline path of non-worker programs.
The new corpus program reads and writes own and shared views through
the same functions (clamping, wrapping, float narrowing, out-of-range
and negative indices) on both threads.
A worker function polls for termination on entry (and so becomes
may-throw, with checks after every call to it) unless all of its
operations are on the bounded list. The list missed operations that run
no script code and cannot block: compiler-resolved Node errors
(error.nodeThrow, in 3,986 tsc-ts functions), runtime fences, array
truncation, typed-array stores and reads, Map and Set construction over
literal entries or static containers, typed-array construction from
static sources, caught-value tests, module TDZ checks, and string and
number library calls whose arguments are primitives (a dynamic argument
could reach a user conversion).
tsc-ts: entry polls 6,903 -> 4,447 of 11,464 functions; the remainder
are almost all members of call cycles.
Every emitted pending check of a worker executable loaded the
thread-local active exception cell and the thread-local stop signal.
On Darwin each thread-local access is a call (_tlv_get_addr), and LLVM
does not share them across a function's checks: compareTypes made 61
such calls, and the single-threaded tsc-ts self-check spent 19% of its
time in them (a Worker build ran 1.67x slower than the same program
without Worker on macOS).
The checks now load one ordinary global, scr_exc_alert, which is
nonzero while any script thread may have an exception pending in its
active cell or must stop. Zero proves nothing is pending anywhere, so
the check falls through; otherwise the out-of-line scr_exc_pending
answers for the calling thread, observes termination and settles the
thread's share. Each thread holds at most one share: raised by throws,
rethrows, the termination sentinel, fiber switches and payload moves
into the active cell, dropped by take, clear, moves out of the active
cell and the slow path. A share is only changed by its own thread, so a
thread's raise is visible to all of its later checks. worker.terminate()
holds one more reference until the worker's script has ended, so the
worker reaches its slow path and stops. An exception in flight in one
thread costs the others slow-path calls only until it is caught.
The stop signal is runtime-private again; the active cell stays
exported for ordinary executables' inline checks.
tsc-ts self-check, macOS, Worker build vs non-worker build: 1.67x ->
1.18x (check phase 1.65x -> 1.09x).
Every thread-local of a worker executable lives in the executable's own
TLS block: the program's internal ones and the runtime's external ones
alike. The emitter declared them with LLVM's default general-dynamic
model, which the linker relaxes to `mov %fs:0` plus an add at each
access. On ELF targets the program's thread-locals are now local-exec
and the runtime's initial-exec, so each access is one %fs-relative
address that LLVM folds into the load or store. Darwin (one model),
Windows, WASI and shared-library thread instances are unchanged.
Linux tsc-ts in a Worker build: 5.01 s -> 4.96 s (7 interleaved runs,
non-worker build 4.55 s); the 16.5k relaxed sequences became direct
%fs accesses.
Worker executables compiled the small-object allocator out and sent
every allocation and free through the system allocator, because
several script threads would race on one set of free lists. Each script
thread now owns an arena: scr_sa is thread-local in worker builds and
describes one of 32 equal slices of a single process-wide reservation,
so the owning arena of any block is address arithmetic. A thread claims
an arena on its first allocation and parks it, free lists and bump
pointers intact, when it exits; the next thread continues from there.
Memory is never unmapped, so blocks a worker leaves behind (published
immortal objects) stay valid after it exits. A block freed by another
thread goes to its owner's lock-free remote list, drained on the owner's
slow path; realloc moves foreign blocks into the caller's arena.
The emitted constructor and release fast paths are inlined for worker
programs too, addressing the thread-local allocator state, live count
and dispose hook through llvm.threadlocal.address.
The allocator unit test gains a worker build that hands blocks across
threads and reclaims a parked arena, also under ThreadSanitizer.
Worker builds skipped the whole-program int32 field analysis, as library
builds do, so a Worker-using tsc-ts kept every flags field a double and
isSimpleTypeRelatedTo tripled in size. Unlike library instances, worker
instances never cross a native boundary: they reach other threads only
through postMessage and workerData, whose dynamic views read and commit
fields like every other dynamic reader the analysis already handles.
worker.new and the ArrayBuffer/SharedArrayBuffer constructors take
dynamic arguments but never store into a live class capsule (none of
their runtime paths reaches scr_dyn_typed_ref_commit), so they join the
read-only helpers instead of disqualifying every field a dynamic store
could name.
Borrowing checked-cast aliases taught canBorrowCallArgument to accept a
checked projection: the always-throwing `error.nodeThrow` call, a
ternary whose failing arm throws, and the narrow of its successful arm.
A borrowed use still evaluates them, but emitDiscarded also used that
predicate to skip a discarded sequence's result as a pure read, so a
statement like `String.prototype.trim.call(undefined)` lost its
TypeError (corpus 3040, 3042, 3043 and 2113 on CI).
Give the discard path its own predicate that only skips effect-free
reads, as before.
Embedded engine calls entered from a fiber get a stack budget documented
as half the fiber stack, leaving the other half as margin. The budget
stayed at 128 KiB when fibers grew to 512 KiB, so it is now 256 KiB,
which restores that ratio.
The stack guard was a tentative definition, which some targets emit as a
common symbol. Localized library archives deliberately leave common
symbols shared, so on macOS the archive defined scr_stack_guard alongside
its declared exports. An explicit initializer makes it an ordinary
definition that localization hides like the rest of the runtime.
Async functions and generators run on their own fiber stacks. The new
stack-overflow guard keeps a quarter of each stack as a reserve for
unwinding and error reporting, which cut the depth they could recurse to
from 256 KiB to 192 KiB of frames. Moderately deep recursion inside an
async body or generator then threw RangeError where Node runs it, for
example 3000 frames of an unoptimized build on arm64 macOS.
Fibers now get 512 KiB, which leaves 384 KiB after the reserve. Pages are
still committed lazily, so idle and shallow fibers cost address space, not
memory. The corpus pins recursion inside async functions and generators
next to deeper recursion on the main stack.
A reference parameter that the body rebinds made the whole parameter
owned: every caller retained the argument and the callee released it on
exit, even when the rebinding never ran. tsc-ts's isTypeRelatedTo starts
with `if (isFreshLiteralType(source)) source = ...regularType` and was the
largest single source of Type releases.
When every write is a plain statement assignment, the parameter now keeps
borrowing the caller's argument; each assigned value moves into a
separate owner slot that the next rebinding releases and replaces and
that the function scope releases on every exit. Reads use the parameter
slot, so whole-value uses still retain. Expression-position writes,
captures, loop and catch bindings, suspending bodies and string
parameters (whose in-place append needs a uniquely owned binding) keep
the owned convention. Unchanged-parameter users (stable call arguments,
aliases, frame-long element borrows) still exclude these parameters.
The call-lifetimes package test now expects `snapshot` to keep its
borrowing body: the alias saved before the rebinding still owns its value.
An edge-preserving function whose every return is a walk of its borrowed
parameters (accessors such as `node.children`, `t.types`, parent walks,
or a call to another such function) now returns its result at +0 from the
borrowing body. The result stays reachable from the arguments, so direct
callers that consume it as a receiver, a walk, or a borrowable argument
use it in place; every other direct caller tests the pending exception
and then retains it, before the argument snapshots that keep it reachable
are released. The owned adapter retains a present result (a throwing body
returns a null dummy, which string and array retains do not accept) before
releasing its parameters. Candidates borrow every reference parameter,
have no try statements and return a plain reference (instances, records,
arrays, strings, nullable-pointer unions).
Implementations of a vtable slot that borrows parameters are excluded:
virtual dispatch reaches their borrowing body (or its virtual adapter)
directly and its callers own the result. Without this, corpus 4545's
virtual accessors hit a heap-use-after-free in the sanitized lane.
The set grows to a fixpoint, since a call to a borrowed-return function
with walk arguments is itself a walk; walk facts are then recomputed with
the final set, admitting only parameters whose convention borrows.
Walk locals now cover every plain reference type, not only instances.
In tsc-ts, compareNodes -> sourceFileOf -> getSourceFileOfNode and
maybeTypeOfKind's loop over typesOf(t) no longer touch reference counts.
Every inline pending-exception check in an executable loaded the
runtime's active-cell pointer and then its kind: two dependent loads
after each call that may throw, because LLVM must assume any call can
change the pointer. Fiber switches restore the active cell before control
returns to a synchronous frame (stack switches, eager spawns and
generator resumes all swap back to the caller's cell), so the pointer is
one value for the whole invocation. Synchronous IR function bodies now
load it once in the entry block and each check reads only the kind.
The flag lives on the body's BlockBuilder, so adapters and runtime
helpers keep the per-check load. Async functions, generators and worker
executables (whose checks also fold in the context stop signal) keep the
existing form; libraries still test through scr_exc_pending.
A parameter that is rebound only by statement assignments of walk
projections (`source = (source as LiteralType).regularType`) used to
become an owned parameter: every caller retained the argument, the callee
released it on exit, and each rebinding retained the new value and
released the old. In an edge-preserving function its incoming value is the
caller's borrow and every later value is reachable from the parameters, so
it can stay borrowed like any walk local.
The walk analysis now runs once per module before call lifetimes, which
merge these parameters into the borrowed set. Unchanged-parameter users
(stable call arguments and aliases, frame-long array element borrows)
still only see parameters that are never rebound.
tsc-ts's isTypeRelatedTo, which normalizes fresh literal types this way
on entry, no longer touches reference counts for its type arguments.