An array's length is at most 2^32 - 1 (the runtime's SCR_ARR_MAX_LENGTH).
On 64-bit targets the length loads now carry that range (`!range`), so
LLVM folds a length converted to double and back to an integer, and
simplifies the comparisons that use it. A length stored into an int32
local or used as an index no longer goes through double: tsc-ts has 18%
fewer int/double conversion instructions.
`"p" + n` and other string-plus-number concatenations whose left string
cannot grow in place (a literal or a shared string) went through the
general mixed concatenation, which sizes and copies every part through a
loop over a scratch table. The result is now the head's bytes followed by
the number's text in one exactly sized string. A number's text completes
no surrogate pair, so no seam needs joining. A uniquely owned head still
appends in place.
A const binding of promisify(exec) now lowers like promisify(execFile):
each call runs the command line with /bin/sh -c, as execSync does, and
settles with { stdout, stderr }. A non-zero exit, a missing command (the
shell's 127) or a timeout rejects with Node's "Command failed: <command>\n
<stderr>" message. The options are the execFile slice: encoding utf8,
cwd, env (inline spread included), timeout, and maxBuffer accepted.
Three gaps closed for both targets along the way:
- the { stdout, stderr } result maps as a record when it is held in a
variable, passed or returned, instead of only when destructured at the
await (it is an anonymous literal in @types/node and in the fallback);
- a namespace member is a recognized target (promisify(cp.exec));
- util.promisify(...) through a namespace or default import is
recognized like the named import.
A Map or Set header held six adapter pointers, the union key table and
size_t counts: 168 bytes, 176 in its size class. The adapters and union
key kinds now live in an immutable per-type ScrMapOps descriptor that the
compiler emits once per collection type, a traced byte replaces the two
trace-pointer tests, and the counts and capacities are 32-bit. The header
is 88 bytes.
The entries array starts with one slot and grows to four, then doubles,
and maps of up to 8 entries scan their cached hashes instead of keeping a
bucket table. Number-key maps still move to the direct index from four
entries.
One million maps held at once (RSS per map): empty 188 -> 100 bytes,
1 entry 286 -> 125, 4 entries 286 -> 198, 8 entries 450 -> 295.
Every release of a LifetimeRegion, and every retry at region creation, ran
scr_collect_cycles(): full cycle-collector passes over the thread's heap, to
a fixpoint, before checking any region, in case dead cycles of ordinary
objects held counted references into it. Freeing a region ran them again
whenever the owned ordinary objects left counts behind after the region
released its fields. Next to a large heap of candidates (a type checker
beside per-file emit regions) those passes cost far more than the regions.
A retry now checks the due regions first. A region whose references are all
explained frees without a pass; only when one would be kept does the heap
get the full pass, after which every due region is judged as before. The
first check leaves a region it cannot free as it was, so the backoff, the
kept report and SCRIPTC_REGION_DEBUG see only the check after the pass. It
keeps what its walk found: a region whose counted references the pass did not
change is judged from that, without walking it again. A pass that freed a dead
cycle referring only to an owned object leaves the count unchanged; such a
region waits for its next attempt.
After a region releases its instances' fields, what can still hold counts
is the owned ordinary objects' cycles. Each has a member that lost a
reference in those releases and is buffered as a candidate, and the release
walk entered every cycle member. The cycle collector gains a pass limited to
a set of candidates (scr_cyc_collect_set): it takes the set's members out of
the buffers up to a given generation, reading only the buffers' pointers, so
the candidates it leaves are not touched, and it walks at full depth without
moving the schedule's baselines. A dead object such a pass reaches through a
candidate it did not take leaves its buffer before it is freed. Freeing a
region runs these passes over the walk's objects from the youngest buffer
out, and the whole heap only if counts remain.
SCRIPTC_REGION_STATS also prints, per thread at exit, how many full passes
over the heap regions needed.
The inline push named the incremented stack length `%len1<id>`, which for
push 0 is the same name as push 10's loaded length `%len10`. Walkers of
classes with more than ten pushed fields were invalid IR. The incremented
length is now `%lennext<id>`.
Every class whose instances reach unknown got a hidden property bag
(%dynProperties) throughout its hierarchy: one more pointer in every
instance. The bag holds the properties that dynamic stores add to an
instance. It is stored by the lazy initializers of the storing
property-bag accessors and by the commit of a live view after a runtime
store.
dropUnwrittenPropertyBags runs with the other whole-program passes of
release builds. When the program has no dynamic store and no called
function stores a hierarchy's bag, the hierarchy's classes keep no slot
for it, reads of it are undefined, and the uncalled storing accessors are
removed. Field presence tracking (JavaScript classes) and library builds
keep their bags.
The for-in presence recheck now reads the instance's bag through the
read-only accessor, so for-in over an instance no longer creates its bag.
A class or record instance seen as unknown is a live view: reads
materialize it, and a runtime store into the view commits the result
back into the native object. Every class and record reachable from a
value passed to unknown got a commit adapter, and each adapter a checked
conversion for every field type.
A view commits only after a runtime store into it. When the program has
no such store (dynamicFieldStores, as the int32 and cycle analyses use),
class and record views are created without a commit. Array, bytes and
collection views keep theirs: a mutating array method called on an
unknown value commits without a store helper. Library builds keep every
commit.
dyn.objCreate (Object.create) and dyn.getPrototype
(Object.getPrototypeOf), which the property bags and prototype data of
class hierarchies use, are added to DYN_READ_ONLY: they create an object
or read a prototype and reach no commit.
JSON.parse appends each property and element, so object and array
storage grows by doubling and keeps up to half of its capacity unused,
and a parsed tree is usually kept as it is. When an object or array
closes, its storage now moves to a block of exactly its length (shrinking
in place would keep the block's size class). A table recycled from the
free list may hold keys past its length; they are released first.
Properties and elements added later grow the storage again as before.
`x instanceof C` on a union whose arms include arrays, such as the
`Entry<V> | V` values of a generic Map instantiated with an array V,
answered false for every value: the union test accepted only class,
record, scalar and unit arms, and any other union fell through to the
statically decided false for values that are not class instances. A
class-arm value then took the other branch, and code reading it as the
array arm crashed.
Array arms now take the union test, which checks the tag of each class
arm and answers false for the others; no program class extends Array.
A union that has a class arm and still cannot be tested is refused with
SC1090 instead of answering false.
Each object a publish walks cost two calls out of its emitted walker:
scr_pub_mark to mark and log it, and scr_pub_push for each reference it
holds (a string field went through scr_pub_push to scr_pub_str_fn).
The walkers now mark through ScrPub's leading fields: when the publish is
not refused, the log has room, and the count fits the log's 32 bits, they
write the log entry and SIZE_MAX themselves, and call scr_cyc_pub_take for
a buffered candidate. Anything else, and every mark of an RC-audit build,
still calls scr_pub_mark. References go onto the stack directly, with
scr_pub_push only when it is full, and string fields call scr_pub_str_fn.
ScrPub's fields are reordered so the walkers' fields come first; static
assertions in scr_publish.c pin the offsets the walkers use.
A plain socket with no 'data' listener, pipe or 'readable' consumer was
never read, so a peer that ended or closed the connection was never
noticed and the process stayed up. Node's stream reads ahead up to its
high-water mark (64 KiB) even without a consumer: a peer that ends
without sending data ends and closes the socket, unread bytes stay
buffered (bytesRead counts them) and keep the socket open, and a
listener added later receives them before 'end'. The runtime now does
the same, holding EOF back while unread bytes remain.
Corpus 5653 covers each case.
The global-effects summary typed each library-call argument as a
structural `{ kind: string }`. The native compiler extracted that one
record arm from the IrType union and threw on bytes, array and other
arms with more fields, so `scriptc build --lib` failed with SC3004 for
integer-slot profiles that call, for example, `Buffer.alloc` or
`String.fromCharCode`. The helper now takes `IrType`, and the bootstrap
test compares native and Node sidecars for such a profile.
publish() logs each object it marks, with the object's count before the
mark, so that a refused publish can restore the graph. At 24 bytes per
object the log was most of the walk's memory traffic. Each publish also
allocated the log anew, so a large one faulted in fresh pages every time,
and a thread that publishes one graph per file paid that on each file.
A log entry is now 16 bytes on 64-bit targets. The count is stored in 32
bits, with escapes for the program-lifetime count and for any other count
of 32 bits or more, which goes to a side array restored in order on
refusal. The dyn-container flag moves into the entry's pointer, and the
RC audit's object kinds into a side array of audit builds. The stack and
log of a thread's last publish are kept for its next one while they take
16 MiB or less, and freed at thread exit.
Adds corpus program 5704.
When the main thread's script ends, the executable released every module
global and ran a full cycle collection before exiting, which takes time
proportional to everything the program still holds (about 15 ms for
1.5 million live objects) and frees memory the operating system reclaims
anyway. The main thread now skips those releases, the exit-time
collection and the collector's buffer cleanup. Exit listeners and output
flushing run as before. Worker threads still free their heaps when they
exit, since later threads reuse their arenas, and builds that check for
leaks (the RC audit, AddressSanitizer) free everything as before, as does
any process run with SCRIPTC_EXIT_FREE=1 for external leak checkers.
SCRIPTC_WORKER_STATS=1 reports, as each worker thread exits, how long it
took to start, to run its script and to exit.
The macOS lane of pull requests and pushes to main runs the darwin suite in
three slices. Each slice runs differential.test.ts over a third of the
corpus while the darwin contracts run beside it. vitest runs one file's
cases in sequence, so a slice's corpus time is the sum of its case times:
in three green main runs, 587-891 s (plain) and 800-1,731 s (sanitized),
on 3-core runners whose contracts task ends after 46-180 s.
ciTestPlan now runs slice i of n as case shards 2i-1 and 2i of 2n in two
vitest processes: the first beside the side queue, the second in the side
queue after the darwin contracts. The halves of all slices are the case
shards of 2n slices, so every program still runs once.
The comment above darwinFiles now says which macOS suite each CI event
runs: pull requests and pushes to main run the darwin suite, and the
nightly schedule and manual runs with macos_full run the full suite.
Buffer.from of strings, arrays and Uint8Arrays, Buffer.allocUnsafe and
Buffer.concat now return views into a per-thread pool slab of
Buffer.poolSize bytes (64 KiB by default) when the size is below
Buffer.poolSize >>> 1, following Node's allocate, fromStringFast and
fromArrayLike: 8-byte aligned offsets, a new slab when a request does not
fit, and the four-bytes-per-unit reservation for strings. Buffer.poolSize
can be read and set, and Buffer.allocUnsafeSlow returns an unpooled Buffer.
A slab lives while any of its views does. Its ArrayBuffer cannot be
transferred, and structuredClone of a typed array view copies the whole
ArrayBuffer and keeps the view's offset. Buffer.alloc, allocUnsafe and
allocUnsafeSlow validate sizes with Node's ERR_OUT_OF_RANGE error.
The first pooled allocation finds the startup slab, which has the
default 64 KiB size. A request it cannot hold gets a slab of the current
Buffer.poolSize, as in Node, and the room is checked after every new
slab, so a Buffer.poolSize raised before the first pooled allocation
cannot place a reservation past the end of a 64 KiB slab. A reservation
larger than a fresh slab traps instead of writing past it.
Corpus 5531 (typed) and 5532 (untyped) raise Buffer.poolSize before the
first pooled allocation and allocate just under the new threshold
through each pooled path, each case in a fresh child process, and also
shrink Buffer.poolSize and change it between allocations. The
node-types fenced fixture no longer lists Buffer.poolSize, which now
compiles.
Int32Slots keeps a class field as a double when its class reaches
checked-dynamic code and some dynamic store in the program can name the
field. A runtime helper that takes a dynamic value counts as a store of
every property unless it is in DYN_READ_ONLY. Object.keys, Object.values,
Object.entries, for-in, Object.getOwnPropertyNames, Reflect.ownKeys,
Object.hasOwn, Object.prototype.toString.call, toString, typeof and
console.log/util.inspect of an unknown value were not in that list, so one
of them anywhere in a program, reached or not, stored every int32 field of
every class reachable from a value passed to unknown as a double.
The helpers they lower to are added to DYN_READ_ONLY. None of their
runtime functions reaches scr_dyn_typed_ref_commit, the only caller of a
live view's commit, except through scr_dyn_typed_ref_materialize, which
defines properties only on its fresh snapshot (as for dyn.hasKey).
Checking or converting a dyn value whose objects or arrays refer back to
each other against a recursive interface (`raw.next = raw; raw as Node`)
recursed without end and overflowed the stack. Matchers and builders of
record and array types that reach themselves now track the dyn values
they visit:
- a matcher answers true for a (value, type) pair it is already checking
further up the stack, so the outer check decides;
- a builder records the record or array it allocated for a dyn value
before converting its members, and a later visit of the same value in
the conversion answers it, so `n.next === n` holds after the cast and a
value reached twice converts once.
Only values with more than one reference are tracked, since a value
reached twice has at least two; the reference a member read holds is not
counted. Capsules materialized during a conversion keep their first view,
so a nested visit cannot refresh a view an outer visit is reading, and
getters and proxy traps that a member read calls run outside the visit. The
cycle analysis treats record shapes that reach themselves and are targets
of a dynCheck as mutable, so the cyclic records a conversion builds are
collected.
tests/harness/effect-costs.json and effect4-costs.json were recorded on
2026-10-01 and give the fixtures' relative cost for balancedShardSelect
across the 16 Effect shards of each flavor. Against the fixture times of
three green main runs (38851650148, 38851726594, 38852266290), the old
tables put 1,171 s of plain and 1,295 s of sanitized fixture time on the
longest shard, against a mean of 1,027 and 1,111 s.
scripts/ci-effect-costs.mjs refreshes both tables from Effect job logs:
each fixture's plain and sanitized medians, summed in seconds, as the
tables recorded before. Fixtures without a measurement keep their value,
and fixtures that no longer exist are removed. With the refreshed tables,
the longest shard of the same measurements carries 1,082 s (plain) and
1,144 s (sanitized).
A released region that something outside still refers to is kept, and
SCRIPTC_REGION_STATS only said how many references the walk could not
explain. In a build with --optimization=dev (or --sanitize), setting
SCRIPTC_REGION_DEBUG=1 (or a larger number, the line limit) makes each
attempt that keeps a region also print what keeps it:
- per chunk, the references to its objects that the walk did not find
(from variables, other regions or objects it does not walk), with the
classes the chunk holds, since counts are kept per chunk;
- each ordinary object the region owns that is referenced from outside,
named by the region class and field it was reached through, with its
reference count and the references the walk found.
The emitted region class descriptors gain the names of the fields their
visits report, in visit order. Debug walks give every owned object an
entry, so that each one records where the walk first reached it. The
bookkeeping is compiled only into unoptimized and RC-audit runtimes:
compiled in, it made the release walk 6.5% slower even with the variable
unset. Optimized runtimes walk as before and, when the variable is set,
say once that the reports need a dev build. Retains, releases and
allocation are unchanged.
Adds corpus program 5703.
Atomics.load, store, exchange, compareExchange, add, sub, and, or and xor
on an integer typed array compile to one sequentially consistent LLVM
atomic instruction on the element. An index that is not an integer in
range takes the runtime operation, which applies ToIndex and raises its
errors.