Commit Graph
850 Commits
Author SHA1 Message Date
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 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 5d0b38ef87 Keep null distinct from the TDZ in forward-captured nullable bindings
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.
2026-10-10 14:03:19 -07:00
Malte Ubl 3eeea34ce5 Keep native method calls when only an unrelated callback field shares the name
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.
2026-10-10 09:01:04 -07:00
Malte Ubl 8d8d478f80 Keep int32 fields when an Error subclass forwards its constructor arguments
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.
2026-10-10 09:01:03 -07:00
Malte Ubl 0d679c1b98 Read and write union class fields declared with different types
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.
2026-10-10 09:01:03 -07:00
Malte Ubl b901ec78eb Narrow optional base-class receivers before shared union field access
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.
2026-10-10 09:01:02 -07:00
Chris Tate c67e08a9cf Prepare v0.2.8
- 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.
2026-10-10 10:08:39 -05:00
Chris Tate 9e0c6564d4 Format the LLVM emitter bootstrap test 2026-10-10 08:36:39 -05:00
Chris Tate 8e2197fe10 Merge main into ctate/report-rejected-declaration-a704c5 2026-10-10 08:07:08 -05:00
Chris Tate da2517a976 Keep record layouts stable for shorthand and literal-argument fields (#819) 2026-10-10 07:59:01 -05:00
Chris Tate e8e644c7e0 Give the sanitized LLVM emitter bootstrap room to emit itself
The natively built emitter records an allocation stack for every object
under ASan, so emitting its own module takes several times longer than
the plain build and now runs close to the 300 s per-stage limit on shared
runners. Sanitized runs get a 900 s per-stage limit and a matching test
budget; plain runs keep the existing limits.
2026-10-10 03:58:05 -05:00
Chris Tate 2ff5711a54 Keep record layouts stable for shorthand and literal-argument fields
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.
2026-10-10 03:40:36 -05:00
Chris Tate 9302fc9c93 Merge main into ctate/report-rejected-declaration-a704c5 2026-10-10 01:54:50 -05:00
Chris Tate 6cf3d4744b Read holes in for-of loops and missing-element shorthand properties as undefined (#817) 2026-10-10 01:08:12 -05:00
Malte Ubl bf508eb70f Publish typed object graphs to workers by reference (@scriptc/threads)
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).
2026-10-10 01:00:36 -05:00
Malte Ubl 3487d17348 Publish deeply frozen plain-data graphs to workers without copying
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.
2026-10-10 00:57:58 -05:00
Chris Tate 1767fe7fa0 Merge main into perf/workers/01-worker-tax 2026-10-10 00:55:35 -05:00
Chris Tate 69b1523466 Merge main into perf/tscts/10-checker-hot-paths 2026-10-10 00:24:43 -05:00
Chris Tate f992727487 Report a rejected declaration's own diagnostic when a discarded probe flushed it first
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.
2026-10-09 23:07:57 -05:00
Chris Tate 2d15471497 Read missing elements as NaN in every static Math argument
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.
2026-10-09 22:45:10 -05:00
Chris Tate 92642d775e Read holes in for-of loops and missing-element shorthand properties as undefined
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.
2026-10-09 22:05:36 -05:00
Chris Tate f388612dcd Make the worker stack-overflow fixture independent of event order
A worker's messages and its uncaught 'error' event reach the parent on
separate channels in Node, so their relative order is not specified and
can differ under load. The fixture now records both and reports them when
the worker exits, which Node and native executables deliver after both.
2026-10-09 21:19:20 -05:00
Chris Tate 34cb02d6d1 Keep the native compiler independent of weak keys
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.
2026-10-09 20:26:57 -05:00
Malte Ubl b4a961b36d Read and write thread-owned typed arrays inline in worker programs
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.
2026-10-09 17:43:51 -07:00
Chris Tate 2606fadf31 Give the sanitized self-hosting compiles room on loaded runners
Two self-hosting tests compile large parts of the compiler under the
sanitizer, and on loaded macOS runners they regularly come close to their
limits. Backend analysis ranged from 165 s to over 300 s against a 300 s
limit with no code change behind the spread (134 s locally, base and
current alike), and the native-frontend compile ranged from 643 s to over
900 s against a 900 s limit. Backend analysis now allows 600 s, and the
native-frontend compile 1500 s within an 1800 s test limit.
2026-10-09 18:32:36 -05:00
Chris Tate d1e7f9088c Give async bodies and generators 512 KiB stacks
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.
2026-10-09 17:33:59 -05:00
Malte Ubl d54f705540 Keep the borrowed convention for rebound parameters with an owner slot
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.
2026-10-09 15:03:24 -07:00
Malte Ubl 5133738a83 Return walks of borrowed parameters without a reference
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.
2026-10-09 15:03:23 -07:00
Malte Ubl 294b443b32 Keep the borrowed convention for parameters rebound to walks
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.
2026-10-09 15:03:13 -07:00
Malte Ubl 713f947024 Borrow locals that walk pointers in edge-preserving functions
A local whose every definition projects an unwritten parameter or another
such local through plain field reads, casts, nullable narrows/wraps and
checked ternaries (the parent walk `p = p.parent`) now holds a borrowed
pointer: no retain at the definition, no release when it is rebound or
leaves scope. This is sound only in functions whose whole body preserves
heap edges (ReferenceEffects.functions): nothing they run removes a field,
element or global reference, so every object reachable from a parameter
at entry stays alive until the function returns. Whole-value uses still
retain their own copies.

The unreachable-completion fence after a `for (;;)` loop throws a fresh
constant error and preserves edges like `throw`; it used to make every
such function look mutating.

tsc-ts's getSourceFileOfNode loop did two increments and two full
decrements (with cycle-candidate checks) per parent step; it now only
loads and tests the parent pointer.
2026-10-09 15:03:13 -07:00
Malte Ubl 12f1966f06 Borrow constant aliases created by checked casts
`const x = a as LiteralType` lowers to a ternary whose failing arm always
throws and whose successful arm narrows and casts the unchanged source.
The alias projects the same stable owner, so it can borrow like a plain
copy of the binding instead of retaining at the declaration and releasing
at scope exit. Call arguments of the same shape borrow too.

tsc-ts's sameLiteralValue (two casts per call, on the relation hot path)
loses all of its reference counting.
2026-10-09 15:03:12 -07:00
Malte Ubl a07b97f798 Borrow nullable wraps, element reads and preserving virtual calls
A wrap into a nullable-pointer union is the payload pointer itself (or an
immortal unit constant), so it can borrow exactly when its payload can,
as a receiver and as a call argument. tsc-ts widens many `Type` and
`Node` parameters to `T | undefined` (they may receive unchecked array
reads), so `compareTypes(c, t1, t2)` wrapped `t1` for every identity test
and call and retained and released it each time.

Required array element reads may borrow as receivers while the borrowed
array owns the element and the index preserves heap edges (array
operands already borrow; this borrows the element itself).

Reference-effect analysis now sees through switch statements, class
references, primitive formatting and virtual calls: a virtual call
preserves edges when every implementation reachable from its static class
(the inherited one plus every override below it) does. Runtime classes
keep dispatch opaque.
2026-10-09 15:03:12 -07:00
Chris Tate 3bcf761f42 Throw a catchable RangeError on deep recursion
Native executables used to terminate with a signal when recursion
exhausted the stack. Functions that can take part in unbounded recursion
now compare the stack pointer against a per-stack guard on entry and
throw Node's `RangeError: Maximum call stack size exceeded` through the
ordinary exception path, so catch handlers, finally blocks, worker error
events and the uncaught-error exit code behave as they do under Node.

- The checked set is every function on a cycle of the call graph, where
  operations that can reenter compiled code indirectly (indirect calls,
  callbacks, dynamic and library operations) reach closure targets and
  class members. Non-recursive functions, including leaf callbacks, stay
  check-free, and the may-throw analysis accounts for the new throw.
- The runtime installs the guard per context (main thread from the stack
  rlimit, workers from their thread attributes) and per async fiber, with
  a reserve for the throw, unwinding and error reporting. Worker threads
  now get an explicit 8 MiB stack.
- Scalar call results pass through the unwind path as its dummy value, so
  calls in tail position keep tail-call and recursion elimination.
2026-10-09 16:55:35 -05:00
Chris Tate 1254caaba9 Read unassigned class fields as undefined during construction (#803) 2026-10-09 14:34:32 -05:00
Chris Tate dd6b8d4ad9 Read unassigned class fields as undefined during construction
Class-typed fields that construction may read before assigning them now
observe undefined instead of reading an empty slot. A small analysis walks
field initializers and the constructor in evaluation order and stops at the
first point that can hand the instance to other code (a method call, an
argument, a callback, a base constructor that does); private helpers that
only assign fields are followed. Fields not assigned by then take the
deferred-init representation:

- the slot starts empty and reads check it inline, so assigned reads stay
  borrowed and only an empty slot reaches the throw;
- equality, `typeof`, truthiness, `== null`, optional chaining, `as`
  casts, undefined-admitting destinations and console output observe the
  undefined itself;
- member reads, method calls, updates and writes through an unassigned
  field throw Node's TypeErrors in Node's evaluation order.

Fields assigned before any exposure keep their plain representation.
Definite-assignment-asserted fields assigned through such private helpers
no longer need the deferred form either.

Corpus 4471 pins inheritance, callbacks, initializer order, private helpers
and member updates; unit tests cover the analysis and the emitted checks.
2026-10-09 14:27:41 -05:00
Chris TateandClaude Opus 5.5 6ffcdf96ff Accept borrowing vtable entries in inheritance codegen tests
Virtual slots whose implementations all borrow a parameter now store the
borrowing body (`@sc_bf_...`), or a virtual adapter, instead of the owned
entry (`@sc_f_...`). Three inheritance codegen tests still required the
owned entry. They now accept either entry for the same implementation,
so they keep checking which method fills each slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:40:02 -05:00
Malte Ubl fcdbe88494 Share virtual-slot borrowing with the self-hosted layout stage
Vtables store the borrowing body of a method whose slot borrows its
parameters, but that decision lived in private emitter state, so the
self-hosting layout fixture kept emitting owned entries and no longer
matched production (`@sc_f_...` vs `@sc_bf_...`).

Move the decision into VirtualBorrows (virtual-borrows.ts), used by the
emitter and by the fixture, which now runs the same call-lifetimes
analysis. The slot lookup narrows explicitly instead of combining a
union with `&&`/`||`, which the self-hosted subset rejects (SC1090). The
abstract-slot emission case now expects the borrowing body, since its
implementation borrows its receiver.
2026-10-09 10:10:26 -07:00
Malte Ubl a360250b38 Treat a choice between string literals as an interned literal
A ternary whose arms are string literals (`i === 0 ? '' : ','`) produced
an owned frame temp, so every evaluation ended with a scr_str_release call
on an immortal string. tsc-ts made 3.3M such calls (2.1M in typeListKey).

The result is an interned literal like a strLit operand: it is now marked
immortal instead of owned, so it is neither retained nor released wherever
it goes. Immortal temps are recorded per block builder, since temp names
are only unique within one.

Corpus 4564 stores, returns, captures, compares and concatenates such
choices, nested ones included.

tsc-ts self-check (two 11-run interleaved benches): 1.864 -> 1.803 s and
1.736 -> 1.706 s (-1.7..-3.3%), check -0.6% and -2.4%.
2026-10-09 10:10:26 -07:00
Malte Ubl 5d687b9d1d Borrow projection receivers of array element reads
An element read (`c.nodes[i]`, `i in c.nodes`, hole tests) borrowed its
array only when it was an unchanged local; an array held in a parameter's
or local's field was retained and released around every read. tsc-ts's
getTypeAtFlowNode scans `c.sharedFlowNodes[i]` and reads the matching
`c.sharedFlowIncomplete[i]` / `c.sharedFlowTypes[i]`: 3.3M retain/release
pairs of 13.5M array releases in the self-check.

Element reads run no user code, so they now take their receiver through
emitStableReceiver like the scalar array intrinsics: a projection rooted in
a counted owner is borrowed while the index cannot remove a reference
edge; an effectful index (a call that may replace the array) keeps the
owned snapshot, so the read still sees the array named first.

Corpus 4563 covers reads through parameter and nested fields, holes and
`in`, and an index that replaces the array mid-expression.

tsc-ts self-check (median of 11, interleaved): 1.757 -> 1.715 s (-2.4%),
check 1.403 -> 1.369 s; binary -16 KB.
2026-10-09 10:10:25 -07:00
Malte Ubl 031adc005a Move the old value into string self-concatenation
`s += x` (canonically `s = s + x`) on a string binding took a retained
snapshot of `s`, evaluated the suffix, released the binding's value so the
snapshot became unique, concatenated (appending in place), and released
the snapshot: a retain and two releases around every append. tsc-ts's
typeListKey alone made 3.4M such releases (plus 2.1M on literal temps).

When the binding is an unboxed local that the suffix does not mention,
nothing but this function's own expressions can read or write it, so
reading it after the suffix gives the left side's value. The old value now
moves into scr_str_concat_move / scr_str_concat_mixed_move, which consume
their head (appending in place when it is unique), and the result takes
its place in the binding: one runtime call instead of four. Boxed
bindings, suffixes that read the local, and expression-position updates
keep the general path.

Corpus 4562 covers aliases that must keep their value, literal heads,
number suffixes, throwing suffixes, self-reading suffixes, expression
position, captured bindings, and long ASCII and non-ASCII appends.

tsc-ts self-check (median of 9, interleaved): 1.854 -> 1.804 s (-2.7%),
check 1.471 -> 1.428 s.
2026-10-09 10:10:25 -07:00
Malte Ubl 48d91cd06f Borrow parameters that every implementation of a virtual method borrows
Virtual dispatch kept the owned ABI: the caller retained every argument
and the method's owned entry released what its borrowing body did not
consume. For a visitor-style forEachChild that meant a retain and a
release of the receiver per call, and the release buffered every node the
first time a visitor reached it (1.3M cycle candidates on the tsc-ts
self-check came from forEachChild alone).

call-lifetimes proves per function which parameters its body borrows,
independent of the caller. When every implementation of a vtable slot has
a borrowing body for a parameter, the slot now borrows it: call sites pass
it like a borrowed argument of a direct call (an unchanged local as is,
anything else as an owned snapshot released after the call), and the
vtable stores the borrowing body. An implementation that borrows more
parameters than its slot gets a `.virtual` adapter that releases the
difference; closures, async methods and generators keep the owned slot.

Corpus 4561 covers visitors with early exits, a holder that drops its
own reference to the child it is visiting, slots narrowed by a captured
receiver and by a reassigned parameter (both adapters), results and
exceptions through borrowed virtual calls.

tsc-ts self-check (median of 9, interleaved): 1.788 -> 1.744 s (-2.5%),
check 1.438 -> 1.407 s; binary -55 KB.
2026-10-09 10:10:25 -07:00
Malte Ubl e42dc158b9 Borrow field-store receivers, including this in derived constructors
A store of a reference-counted value always retained its receiver for the
duration of the store and released it afterwards, while scalar stores
already borrowed a stable receiver. The old value's release cannot drop a
receiver rooted in a counted owner: the owner's path to the receiver never
uses an edge leaving the receiver, and destructors run no user code. So
ref-valued (and nullable-pointer) stores now use emitStableReceiver too.

In a derived constructor `this` lowers to a sequence that throws unless
super() has returned, which no borrow check recognized: every field store,
the super() call (an upcast of `this`) and the discarded `this` result of
the super() statement retained and released the new object, and each
release left it buffered as a cycle candidate. Such throwing guards over
scalar conditions now read through to their result (guardedValue), class
casts of an unchanged local are borrowable call arguments, and a
statement-position sequence whose result is such a read drops it without a
retain (the sequence's locals still move to the statement frame).

Corpus 4560 covers released subtrees that point back at the receiver,
receivers reached through parameter fields and reassigned locals, base
constructors calling overridden methods, and super() chains.

tsc-ts self-check (median of 7, vs /tmp/tscts/tsc-int3): 1.697 -> 1.702 s
(neutral), binary 17,218,200 -> 16,768,696 bytes (-2.6%).
2026-10-09 10:10:24 -07:00
Malte Ubl 512282fa92 Size state-built array literals for their elements
A literal holding a value that may be missing at run time (and the item
arrays of push/unshift/toSpliced with such values) starts from an empty
array and stores each element with a state test. The empty array had no
dense storage, so the first store always took the runtime path and grew
the array. arrayLit gains an optional capacity hint (validated: an
integer between the element count and 1024, no observable effect) that
the emitter passes to the allocation; these builders set it to the
number of plain elements, so the stores stay on the inline dense path.
tsc-ts hits this in getOptionalType's `[t, missingOrUndefined]`.
2026-10-09 10:10:24 -07:00
Malte Ubl c7018c48b3 Borrow array elements reached through a parameter's fields
A call argument or local read from an array may skip its retain when the
enclosing function preserves every heap edge and the array is a stable
borrowed parameter. Accept pure projections of such a parameter as well
(field and record reads, narrowing, casts): the caller-owned root keeps
each projected field, the array, and its elements alive for the frame.

The edge-preservation summary rejected `??` and Math library calls,
neither of which can remove a reference edge, which disqualified
tsc-ts's compareNodes and compareCodePoints and with them the symbol and
type sort comparators. compareSymbols now passes `declarations[0]` and
the symbol names to its callees without retaining and releasing them.
2026-10-09 10:10:24 -07:00
Malte Ubl b4134160f4 Splice runtime-optional items without a temporary item array
`a.splice(i, n, v)` where v may hold undefined at run time (a for-of
binding or unchecked read, as in reorderCandidates) always built a
present-undefined item array. It now evaluates receiver, start, count,
and v once, in order, and branches: a present v is spliced as a plain
element literal, a missing one keeps the item array. In statement
position the emitter now also looks through such sequences and
conditionals, so the present arm drops its removed elements with
scr_arr_splice_drop and allocates nothing.
2026-10-09 10:10:23 -07:00
Malte Ubl 622bacc914 Skip the runtime call when an array length assignment is unchanged
`arr.length = n` always called scr_arr_set_len, even when n already is
the length. tsc-ts resets three shared flow-cache arrays to their saved
start after every flow walk, which is usually exactly that no-op. The
emitted code now compares n with the current length first: equality is
precisely the case the runtime accepts and leaves unchanged (-0 included,
NaN never), so only real resizes and invalid lengths reach the runtime.
2026-10-09 10:10:23 -07:00
Malte Ubl de2100515a Splice in place when the removed elements are discarded
A statement-position `a.splice(start, deleteCount, ...items)` built a
one-element (or n-element) items array, then scr_arr_splice_insert built
the removed-elements array that the caller released straight away: three
or more allocations per sorted insert in tsc-ts's insertType. When the
result is discarded and the items are plain evaluated values, the emitter
now evaluates receiver, start, count, and items in order and calls
scr_arr_splice_drop with the owned value slots. It applies the same
clamping and state shifts on dense receivers, releases removed values
only after the receiver is consistent again, and keeps the general
implementation for sparse receivers and sparse-sized results.
2026-10-09 10:10:23 -07:00
Malte Ubl 0a9d322f2d Test runtime-optional instanceof against the static class object
The runtime-optional `x instanceof C` lowering copied C into an owned
temporary before testing. For a capture-free class reference that value
is the immortal static class object, and the copy cost an out-of-line
scr_classobj_retain_v/release_v pair on every test (2,513 sites in
tsc-ts, mostly AST accessors such as getName and getText). The class
reference is now used directly.
2026-10-09 10:10:23 -07:00
Malte Ubl c75cc167dd Append runtime-optional push values in place
`a.push(v)` where v may hold undefined at run time (an unchecked array
read or a hole-observing for-of binding, as in tsc-ts's filterType loop)
built a temporary one-element array, stored v or present undefined into
it, and spread it onto the receiver: an allocation, a growth, a copy and
two releases per append. Such pushes now evaluate every argument first,
then append each present value through the ordinary push (and its inline
dense fast path) and each missing one as a present-undefined slot at the
current length, which is what Node stores. Pure receivers and arguments
are re-read instead of copied into owned temporaries.

Call arguments read from arrays of nullable references now take the
inline dense lookup like other projections (the value is the element
pointer itself, so the path is a few loads), and scr_arr_peek_ref answers
dense in-range indices before the general lookup. compareSymbols'
`declarations[0]!` arguments no longer leave compiled code.
2026-10-09 10:10:22 -07:00