CI's island lanes fall back to the development pack (dev-pack.json: Node
24.22.1, no package.json) until the libnode-packs cache exists, so the
smoke test, which verifies a @scriptc/libnode-<platform> package against
the 24.22.0 pin, failed there. It now runs only when SCRIPTC_LIBNODE names
a package directory. The pack package versions follow the 0.4.2 release
(scripts/sync-versions.mjs), which pin.test.ts checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tsgo rejects a call of `resolve()` without an argument when the
`new Promise()` that produced it has no type, and asks for a JSDoc hint;
tsc-ts accepts it. The two programs compiled under tsc-ts only, so the
checker-identity check failed on them. Each scenario function now
declares `@returns {Promise<void>}`, as corpus 1594 does. Their output is
unchanged.
A checked-dynamic method call looked its name up in the method table on
every call: a strlen, a hash and a byte comparison of the name.
The compiler now gives each method-name constant an i32 slot that starts
at -1 and passes it with the call (scr_dyn_invoke_prepared_id, and the
Buffer method entries, which forward it). The first call stores the
name's id in the slot, and later calls read it. Ids stay the runtime's
own, so the slot holds whatever this runtime's table says. Names are
still matched by content on the first call, and the slot is written with
a relaxed atomic store, since every thread writes the same id.
The runtime ABI is now 28.
The sanitized seed compiles the compiler's 700 MB LLVM module with one clang
process under AddressSanitizer. The compile peaks near 14 GB while the Node
process that emitted the module still holds about 6 GB, which exceeds the
runner's 16 GB of memory. The job now enlarges swap to 24 GB before it builds
the seed, as the sanitized emitter job does.
The cycle collector widens the growth fraction that schedules full passes
up to 16 times the heap left by the last pass while passes find little.
Lowering a large program leaves hundreds of megabytes of cyclic garbage,
and whether a scheduled pass reclaims it before the build's peak depends
on where the growth rule fires: small changes to the compiler moved the
peak of a tsc-ts build between 1.66 and 2.05 GB.
The native compiler now sets the growth cap to 2 at startup, as
SCR_CYCLE_GROWTH_CAP=2 does, so a full pass runs once the heap has grown
to three times its size after the last one. scr_cyc_set_growth_cap sets
the cap for a program from its own code; the environment variable still
takes precedence, and child processes are not affected.
Release builds no longer import runtime bitcode, so program code called scr_map_retain_v for every Map read out of a container and scr_map_size for every size read. A Map retain now uses the emitted increment helper, as string retains do, and Map.size reads the live count through an internal helper.
The LLVM emitter's constructor walks the whole module about 33 times.
Several walks repeat work another already did, or run where the result
is provably empty:
- computePublishedTypes and computeCycleMutability run once and are
shared (may-throw, immutable fields and traced shapes took their own).
- The WebCrypto-asymmetric and Ed25519 runtime-feature conditions join
the feature scan's one walk instead of walking the module again.
- int32 slots: the fixpoint skips the write walk of a function without
a candidate write, dynamicFieldStores stops once every field name is
dynamic, and dynamicallyStoredFamilies returns early when no f64
field can be named by a dynamic store.
- stableGlobals stops once no candidate is left, the second pass of
findAppendedArrayLiterals and the target resolution of
findConstantCallbacks run only when there is something to find,
boxedUnionIds needs unions, and observesStacks walks for call sites
and the "stack" literal only when a dynamic key asks.
- everyModuleNode visits type trees without a closure per node.
The emitted LLVM IR is unchanged.
for-in over a pure index-signature record (`Record<string, number>`) skips
keys deleted during the walk by testing each snapshotted key for presence
before visiting it. The test built the overflow map's whole key list and
searched it with includes() on every visit, so a walk over n keys took
O(n^2) time: 200 walks over 2000 keys took 8.7 s, against 31 ms under
Node. The test is now recordOvfHas, the overflow map's hashed presence
check, which answers the same question in constant time.
The new benchmark workload dictionary-walks walks, prunes and merges
Record<string, number> dictionaries with for-in and Object.keys/values/
entries. It takes 7.1 s before this change and 23 ms after (Node 116 ms).
Corpus program 7011 pins for-in's visit order and its handling of keys
deleted, re-added and added during the walk.
`a[i * n + k]` with int32 operands kept the index in doubles whenever the
product's range could pass 2^53: integer arithmetic needs a proven safe
range, and an int32 product can reach 2^62. Every access then converted
the double back with an exactness test.
Such arithmetic now also carries a wide view: the exact integer as an
i64, the magnitude it is bounded by, and a threshold below which the
double equals it. A product of two exact integers equals its double below
2^53 in magnitude, and a sum or difference of a wide view exact below T
and an exact integer of magnitude at most B equals its double below
min(T - B, 2^53). Every i64 result stays within 2^62 + 2^53, so none
wraps.
An array index below a length or capacity is below 2^32, under every
threshold kept, so the dense array guards and local array reads compare
the wide view, unsigned, with the array's counts and skip the round trip.
Any index that fails those comparisons, including every index whose double
rounds, takes the double's path as before. Typed arrays and strings are
unchanged.
`new Set(x)`, `new Map(x)`, `new WeakSet(x)`, `new WeakMap(x)` and
`Object.fromEntries(x)` now accept any checked-dynamic iterable, as in
Node: a native iterator (`map.keys()`, `set.values()`), a compiled
generator, an object with a Symbol.iterator method, or a Set. Before, a
Set seed that was not an array or a string threw "is not iterable", and a
Map, weak collection or Object.fromEntries seed that was a generator or a
collection other than a Map refused with SC2020.
The runtime drains native iterators, packs arrays, strings and
collections, and steps every other iterable through its iterator
(scr_dyn_seed_values). A program that builds these from an untyped seed
registers the compiled generators' next and return methods with the
runtime, as destructuring rest already did, so a generator seed can be
stepped.
The seed's values are all taken before the first one is added, so when an
entry of a Map seed is not an object the TypeError comes after the
iterable is exhausted; Node closes the iterator at that entry. The
limitations page says so.
Debug metadata is numbered per emitter (debug-info.ts) and the range
journal does not carry it, so a debug build emitted in ranges or on
workers referred to metadata the merged module did not define. Debug
builds now emit every function body in the emitter itself.
wasm-bindgen output constructs a FinalizationRegistry per exported class
when its module loads, so every wasm-bindgen package stopped at load with
SC2020. `new FinalizationRegistry(cleanup)` now creates a native registry:
- register(target, held, token) and unregister(token) check their
arguments and answer as Node does, with Node's TypeError messages;
- the unregister tokens are keys of an internal weak map, so the registry
keeps no token alive;
- the cleanup callback never runs, which ECMAScript allows, so the
registry keeps neither the callback nor the held values.
The global answers typeof "function", instanceof, Object.prototype.toString
and util.inspect as Node does. Symbol targets and tokens are refused with
SC2020.
`"encodeInto" in encoder` on a TextEncoder, which wasm-bindgen's loaders
test at load, answered false because the encoder is a brand record without
fields for its methods. `in` over such a builtin brand record now answers
from the members the standard library declares, plus Object.prototype's.
The limitations page says that memory a package frees only from a cleanup
callback, such as the WebAssembly memory behind wasm-bindgen wrappers the
program drops without free(), is not reclaimed until the program exits.
Fixes#1678.
An async generator kept the context of its creation for its whole life,
and a store its body entered carried into every later step. Node runs
each step in the execution that resumes it: a step starts with the
context of the next(), return() or throw() call, a store it enters
before its first await is the caller's after the call, and after an
await the step continues in its own copy.
Async generators now start each new step from the resumer's context and
hand the prefix's context back at the step's first suspension, as
synchronous generator steps and async function prefixes already do; the
continuation after an await keeps the generator's own. Two requests that
start from the settlement job, which runs from the loop, take Node's
contexts too: a request queued behind a running step starts in that
step's context, and a return() at a yield, whose unwind waits a turn,
runs in the return() call's context.
Corpus 7381 and 7382.
A number parameter that every call site proves an int32 reaches the
function's i32 entry. One that some caller passes as an unproven number,
such as a value read from a worker message or parsed from text, kept its
double, so every loop it bounds compared an integer counter with a double
limit and the values derived from it stayed doubles.
When such a parameter bounds a numeric loop (arithmetic, element and
field reads and writes, Math calls, no allocation or other calls), the
function now gets a copy of its body. Its own body first tests
each such parameter for an int32 that is not -0 and, when all pass, calls
the copy with `p | 0`, the same double. The copy's only caller proves
those parameters int32s, so it gets the i32 entry and integer loops.
Other values run the body as before.
Only plain synchronous functions of at most 2048 IR nodes are versioned:
not class members or generated helpers, not capturing, not the entry,
not dynamic-tier, not calling themselves, and at most 16 per module. A
function holding the only call site of another function is kept whole
unless that callee only computes a number or boolean from scalar reads:
a second call site can stop LLVM from inlining the callee and leave the
records it builds in memory. Library builds and modules that observe
call stacks are left as they are.
A JavaScript object literal's property whose initializer is `undefined` or
`null` (`[{ oldPos: -1, lastComponent: undefined }]`, `{ user: null }`)
mapped to the unit-only union, so a later write of another value threw:
the diff package's diffLines failed with "a '{ added: boolean; count:
number; previousComponent: unknown; removed: boolean }' value is not
representable in the target union" when it stored its first path
component. Nothing checks a JavaScript file's writes, so such a property
is now checked-dynamic and holds whatever the program stores. TypeScript
properties keep the unit-only union, and `__proto__: null` still sets the
prototype.
On hosts that can fork, the native compiler now emits a large module's
function bodies on worker processes (SCRIPTC_EMIT_WORKERS, 4 by default).
The function list splits into contiguous ranges, each emitted by a forked
child of the emitter, and the ranges merge in function order into the text
one emitter gives. A range whose worker fails is emitted in the parent.
Modules under 2,000 functions, Node and Windows keep the serial path.
A range's emitter records an ordered journal (emit-journal.ts):
- every helper it numbers from a counter (the dyn conversions and
matchers, message listeners and readers, JSON writers, publication
walkers, stream adapters and resolve thunks), recorded when its number
is taken, with the entry point and arguments that create it;
- the first request of each key of a module-level table (declarations,
literals, C strings, unit and absent instances, retype guards, regex and
template-strings instances, map descriptors, alias tags) made outside a
helper's definition;
- unkeyed sites (inline caches, speculative loop sites) taken outside a
definition. A speculative loop site's global is now `@sc_spec_bails_N`,
named like the other numbered sites.
The merging emitter replays each range's journal in order. Replay calls
the same entry points, so every helper gets the number and definition the
serial emission gives it, and table entries land in the serial order. The
range's text is then renamed to the merged numbers in one pass. Sets and
flags the ranges set (await callees, function values, RC helper requests)
merge after the replay. LlvmTargetOptions.functionChunks runs the same
merge in one process, for tests.
The fork, exit, wait and lock primitives come from the native host
(host.c, process-fork.ts); the native compiler passes them to the emitter
as LlvmTargetOptions.forkPrimitives.
A method call on a value of unknown type (untyped JavaScript, `any`)
picked its builtin by comparing the method name with strcmp against each
candidate, in long ladders per receiver kind: a string method could test
dozens of names before it matched.
- The name is now looked up once per call in a generated hash table
(scr_dyn_methods.h, from scripts/gen-dyn-methods.mjs), and each ladder
compares the resulting id. The table verifies the name's bytes, so a
name is matched by its content, as before.
- The worker event methods compare the first character before strcmp.
- bench/dynamic-methods calls string, array, Map, Set, number and own
methods from untyped JavaScript.
The PATH lookup for spawns with an `env` option picked the first
executable regular file and stopped there, so a script whose interpreter
is missing ended the search with ENOENT. Node's libuv runs execvp in the
child, which moves on to the next PATH entry when an exec fails with
ENOENT, ENOTDIR or EACCES. Each candidate now runs through posix_spawn in
order with the same rule, and relative entries are resolved by the child
in its cwd.
A checked-dynamic entry's PromiseResolve reads its `then` property,
which can be a getter. The tuple form resolved each entry as the array
was built, so that getter ran before later entries evaluated; Node
evaluates the whole array first. With a checked-dynamic entry, every
entry now evaluates into a hidden local before the entries resolve.
A typed teardown freed each block onto its free list as it went, and the
free lists, which are LIFO, handed a torn-down structure's blocks to the
next allocations in the reverse order. A structure that a loop rebuilds
the same way each time was therefore laid out mirrored every other
generation, and every walk over it, its teardown included, ran through
memory backwards, which the hardware prefetchers handle three to four times
worse. A program that rebuilds a tree of 2^21 - 1 object literals 12
times, summing each generation before dropping it, took 26 to 28 ns per
node to build, 24 to sum and 16 to drop in those generations, against
6.8, 6.3 and 5.3 in the others. With this change, every generation runs
at the second set of times, and the program takes 0.50 s instead of
1.18 s (Node: 0.91 s).
The teardown of a class or record type with children of its own type now
keeps the blocks it frees in a segment, linked through their first words
and built in order through a tail pointer. A node releases its own block
first, places it ahead of its children's when its address is the lower
(object literals allocate before their fields) and behind them otherwise
(constructor arguments build the children first), and the chain's first
teardown frees the whole segment onto the free list in one step. A
teardown past DIRECT_DESTROY_CHAIN appends through scr_rc_destroy_seg,
which keeps scr_rc_destroy's depth budget and queue. Blocks outside the
calling thread's arena are freed as before, and targets without the
inline allocation paths keep the old teardown.
The split tools ran oxfmt in the checkout they rewrite. oxfmt looks up
its configuration from that directory and from the formatted files'
directories, and runs an oxfmt.config.ts it finds there, so a branch that
rehome-split rewrites could run code with the maintainer's permissions.
The tools now format through format.mjs, which passes the tools
checkout's .oxfmtrc.jsonc with --config and disables the nested lookup,
so no configuration file of the rewritten checkout is read or run. With
no files to format it does nothing, where oxfmt would format the whole
directory.
The split-tools tests cover split-methods and move-state on a checkout
whose root and src directory commit an oxfmt.config.ts that records it
ran.
Three cases where a value's key order could differ from Node:
- A write of an optional key moved the key before its value was
evaluated. A value that adds another key to the same record, as in
`o.b = ((o.c = 1), 2)`, then put the keys in the wrong order. The write
now evaluates its value first, then tests and moves the key.
- A value seen as a JavaScript value bound the field hooks of its order
when it was boxed, and a later write that moved a key did not change
them. Shapes whose values can change order after they are built
(IrRecordShape.orderMoves) now read through the value's view, which
follows the current order.
- A literal that spreads a value with orders and then sets a key the
value may lack, as in `{ ...value, tag: "x" }`, put that key where the
source's order has it. Node puts it there only when the value has it,
and after the value's keys otherwise. The literal now picks its order
from the source's order and which of those keys the source has.
Corpus 7136 and 7138 cover each case.