String storage can hold bytes that are not well-formed UTF-8: an HTTP
header value keeps the raw bytes a client sent. Encoding such a string
read past its end.
- scr_bytes_next_cp decoded any high byte as the lead of a sequence and
read up to three bytes after it without a bound. It now takes the
storage length, decodes only complete, well-formed sequences, and
returns any other byte as U+0000..U+00FF, advancing by one. That is
each such byte's value in Node, which decodes header values as latin1.
This covers latin1, ascii, binary and utf16le encoding, hex and base64
of non-ASCII text (which narrow through latin1), Buffer#write and
TextEncoder#encodeInto.
- base64 decoding read one byte past its input when the eight-character
groups ended exactly at the end.
The runtime's bytes unit test (built with ASan) encodes ill-formed
storage at every offset within a word and random high bytes, and
compares latin1, ascii and utf16le with a scalar reference. A corpus
program sends such header bytes to a server and encodes them as Node
does.
An intersection that restates an optional member as required with a
separately written element type (`Opts & { files: Array<{ path: string }> }`
over `files?: Array<{ path: string }>`) gives the member the type
`A[] & B[]`. The checker keeps both element literals, and no intersection
rule mapped an intersection of arrays, so the record was refused (SC2009).
When every constituent maps to one IR type that is not the dynamic value,
the intersection now maps to it. The rule runs only after every other
intersection rule declined, so intersections that already mapped keep their
mapping.
pi's NormalizedBuildSystemPromptOptions is such an intersection; it sits on
the chain from main()'s options to the extension API and refused main().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`length` and `byteLength` loaded the typed array's header with
`!invariant.load` (#2016). LLVM may move such a load down to its use,
past the release that frees the view, so `const slots = this.slots;
this.slots = next; return slots.length` returned a garbage number in
release builds. These reads are now ordinary loads that keep the
header's alias tag. Header loads that only bound or address an element
access stay invariant: their values reach only that access, which runs
while the view is alive, so typed-array loops still hoist the length
and data pointer.
Corpus typed-array-length-after-release covers a replaced field, a
deleted map entry and a rebound global.
`f!()`, `t[k]!()` and `o.cb!()` on a value that holds undefined threw
scriptc's checked-extraction message ("undefined is not representable in
the target union …"). The non-null assertion is erased in JavaScript, so
Node throws the call's own "<callee> is not a function". The checked
extraction behind an asserted callee now carries that message, with
V8's spelling of the callee: parentheses and non-null assertions dropped
and call results printed as `f(...)`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The close step drops a socket's listener lists so that a listener which
captures its socket does not keep it alive; the 'drain' list was not
among them. A once('drain') listener still pending when the peer
disconnected, or an on('drain') listener, held its socket and the
socket's buffers after 'close'. The close step now drops the 'drain'
listeners with the others.
A loop to a typed array's length reads the length again on every
iteration, so a body that assigns the array's binding changes the limit
for the next iteration. The strip-mined form took the length once per
block of 64 iterations, and after the body assigned a shorter array it
ran on to the block's end, reading past the new length.
The loop to a typed array's length is now strip-mined only when the
array is a local of the function that the body never assigns
(stableReceiver on the match); others keep the budget and test the
length on every iteration.
New corpus program: native-worker-length-loop-reassign.ts.
RSA_PSS_SALTLEN_AUTO recovers the salt length from the signature and
does not enforce the key's minimum salt length, as in Node (OpenSSL).
The runtime's exemption now says so, and corpus 8178's header, which
said the minimum was still enforced, describes what it checks.
Untyped functions built each member of an object literal with
scr_dyn_key_set: a lookup of the key in the new object, a walk of its
empty prototype chain, a second lookup before the append, and a hash of
the key to share its bytes. The first cached read of the object then
computed its shape from its keys, hashing each key again.
A member whose key is a constant string other than __proto__ now owns a
store cache site and calls scr_js_store_probe, which appends the member
through the cached shape transition, takes the key bytes from the shape,
and leaves the object shaped. Members the probe does not take (Error
field names, members past the 64th, a site's first object of a shape)
keep scr_dyn_key_set, as do computed keys and literals in module
initializers.
push() of an empty Buffer outside object mode put a zero-length entry
into the read buffer, and the chunks pushed after it stalled behind it:
a 'data' listener saw nothing more and a readline interface never
closed. Node's readableAddChunk adds no empty chunk outside object mode;
it clears the reading flag and may read more. The runtime now does the
same, as it already did for an encoded push that decodes to nothing.
A typed-array loop version in a worker program tested its bounds and
its views' ownership together: every view had to have no shared
storage. A loop over views of a SharedArrayBuffer always failed that
guard and ran the checked copy, testing every element's index.
The guard now tests the bounds first and the ownership after. When the
bounds hold and every view is unshared, the loop runs the owned copy as
before. When a view is shared, it runs a new third copy whose accesses
skip their bounds checks and keep the shared-or-local path, without
alias scopes. Versions with a UTF-16 units copy, and programs without
workers, are unchanged.
Corpus 8350 runs such loops over shared, unshared and mixed views, to
counts within and past the views and over subarrays, with a worker
writing through shared views. The emission test checks the split guard
and the shared copy's accesses.
Once a program creates a weak collection, every object freed afterwards
called into the weak-key table to remove entries keyed by it, although
almost none are keys: zod's validation freed 16.6 million objects against
23 keys. A per-thread bit filter indexed by the object's address now marks
every address that entered the table (cleared when the table empties),
and the free paths test it inline, so an object whose bit is clear makes
no call.
The fixture's loop iterated `[6, 7] as (number | undefined)[]` so that
its binding kept the union layout the LLDB check reads through
`item->tag` and `item->slot.arm0`. Since (T | undefined)[] stores T
elements, an array that never holds undefined gives a `number` binding,
and the check failed with "item is not a pointer".
The loop now iterates `[6, 7, undefined]`, whose binding needs the
union, and the check also reads the undefined arm's tag.
parseFloat and the text of parseInt take a string, and the radix of parseInt
takes a number. An unannotated offset passed as their text, or a capture passed
as the radix, is no longer bound to its value's type; it stays checked-dynamic,
so the call compiles as it did before the binding.
TypeScript types the parameters of an inline replace callback after the
match `any`. An unannotated parameter that is never reassigned, whose
every read means the same for a string or number as for `any`, and that
receives a capture every match sets, the offset or the subject now takes
that value's type. String-search callbacks get the same treatment for the
offset and the subject.
scr_regex_replace_fn calls the closure directly in more cases:
- checked-dynamic parameters, which receive the boxed value Node passes at
their position (a capture or undefined, the offset, the subject, the
groups object, then undefined);
- `string | undefined` capture parameters, which receive NULL when the
match leaves the capture unset;
- a checked-dynamic result, converted with ToString;
- up to six parameters.
Callbacks of the earlier typed form compile to the same call, with a
kinds argument of 0, and run the same per-match work.
- fill with a string or Buffer value longer than one byte: writes the
value once, then doubles the filled prefix with memcpy, as Node does.
The value is copied with memmove, so a value that is a view of the
buffer being filled gives Node's result; the previous per-byte loop
read such a view while overwriting it. The loop also computed a
division per byte.
- swap16, swap32 and swap64: one load, byte swap and store per group,
through a local data pointer.
- toString("utf16le" | "ucs2"): four units below 0x80 copy as four bytes,
and other units outside the surrogate range are written inline (a
three-byte sequence as one four-byte store). Surrogates keep the
previous path. The result records its UTF-16 length (one unit per
input unit), or is marked ASCII, so `.length` does not walk it.
A number-keyed Map or Set with keys the direct index allows now moves to it
at its second entry rather than its fifth, so a map over a few small
integer keys (a histogram over 0..3) reads through the inline direct index
instead of the out-of-line linear scan.
In a worker program, every loop without a pending test on each iteration
decremented a cancellation budget in its body and polled the exception
flag every 64 iterations. The poll's early return made each loop an
early-exit loop, which LLVM's loop vectorizer does not vectorize.
A counted for loop (integer counter, invariant limit), or a loop to a
typed array's length, whose body reads or writes a typed-array element
outside nested loops and closures, now runs as blocks of at most 64
iterations: the outer header tests the counter against the limit, the
inner loop runs to the nearer of the limit and 64 steps on, and the poll
follows each block. A typed array's length is fixed, so each block takes
it as its limit. The loop polls as often as the budget did, and the
inner loop's only exit besides break and return is its bound. The block
bound lives in one slot shared by the loop's speculative copies, which
resume iterations in each other's bodies. Other loops keep the budget,
which costs less in loops of a few iterations.
An elementwise Float64Array loop in a worker-enabled program vectorizes
again: 949 ms to 691 ms. A byte-classifying loop over a 100M-byte
Uint8Array takes 13 ms instead of 141 ms.
New corpus program: native-worker-terminate-counted-loops.ts.
The native compiler ran four large module initializers at every startup:
the builtin call signatures (LIB_FN_SIGS), the Node 24 fetch
compatibility profile, and the builtin module function and ambient
surface tables. Together their init functions are about 1.3 MB of code,
and --version and warm cache-hit builds paged them in and built the
tables without reading them.
Each table is now built by a function on its first call and kept in a
module variable:
- libFnSignatures() replaces LIB_FN_SIGS
- node24FetchCompatProfile() and staticFetchMembers() replace
NODE24_FETCH_COMPAT_PROFILE and the STATIC_* member sets
- builtinModuleFns() and ambientSurfaceFns() replace BUILTIN_MODULE_FNS
and AMBIENT_SURFACE_FNS
The @scriptc/compiler entry point keeps LIB_FN_SIGS and
NODE24_FETCH_COMPAT_PROFILE as deprecated constants built from the new
functions when the entry module loads. The native compiler does not load
that module.
The surface manifest's coverage notes read the fetch target through the
profile function, so building the manifest module no longer builds the
profile. The table contents are unchanged.
A spread tuple's first field carries the statements that evaluate the
tuple into a hidden local, and its later fields read that local. When
lowerArraySpreadItems stored the first field as one item's expression,
the local's frame ended with that store, so a tuple returned by a call
was released before its later fields were read (a use-after-free under
SCRIPTC_SAN=1). The field's statements now join the packing sequence at
the item's position, whose frame lasts until every item is stored.
A socket's write() answer and writableLength counted the bytes left
unsent. Node counts each write that is not completely sent in full, so a
large write the kernel takes in part still counts its whole length. The
socket now keeps its buffered writes in order with their lengths and
completes each one when its last byte is sent; write() answers false when
those reach the 65536-byte high-water mark.
write() now checks whether the write half is open after the send, so a
send that fails with EPIPE or ECONNRESET and closes the socket answers
false. writableNeedDrain also reads true while a custom lookup or a
deferred dial is pending, since writes then buffer until the socket
connects.
Untyped code reads writableLength and writableNeedDrain, and
writableHighWaterMark and readableHighWaterMark read 65536, Node's
default, instead of 16384.
A call that spread a fixed tuple and also an array, a Set or another
iterable into a `(T | undefined)[]` rest parameter took the general rest
path, which converts every value to T, so an undefined tuple field threw
("undefined is not representable in the target union"). Such calls now
pack every item in argument order through lowerArraySpreadItems, which
takes values already lowered (the tuple's fields) beside argument nodes
and keeps undefined values as present undefined elements.
A tuple element typed only `undefined`, as in `...["b", undefined]`, has
a `null | undefined` field. Stored into an array that keeps undefined
elements it is now evaluated and stored as undefined, where it was an
internal compiler error; the checker admits it there only as undefined.
A typed array whose constructor property is set to another built-in
typed-array constructor now creates that kind from map, filter, slice
and subarray when the program has not evaluated Symbol.species.
In a speculative integer loop, `v - Math.floor(v / 2^n) * 2^n` computed
the floor division as an arithmetic shift, multiplied it back by 2^n with
overflow and -0 checks, and subtracted with range checks. For a safe
integer v and n in [0, 52] the result is the floor-mod, which is v's low
n bits: the fast copy now computes it as `v & (2^n - 1)`, which lies in
[0, 2^n) and needs no check. A product with a power of two (a literal or
`Math.pow(2, n)`) becomes a shift, with one test that the other factor's
magnitude is at most (2^53 - 1) >> n; its zero result is +0.
A TCP socket's write() answered nothing in typed code and true through
untyped values, and a 'drain' listener never fired. Node 24 answers
false when the bytes the write leaves unsent reach the socket's
65536-byte high-water mark (writeOrBuffer computes it after the socket
writes), sets needDrain, and emits 'drain' when the write buffer
empties; writes to an ended or destroyed socket answer false.
The runtime's write entry points now return that answer, record
needDrain, and fire the socket's 'drain' listeners from the flush that
empties the buffer, unless the write half is ending or the socket is
gone. writableLength reads the unsent bytes and writableNeedDrain the
flag. on('drain') and once('drain') register on the typed path and
through untyped values, and socket.write() may be used as a value.
The fallback node:net declarations type write() as boolean, add the
'drain' event and both properties.
RUNTIME_ABI_VERSION goes from 43 to 44: the socket write entry points
return a boolean.
A getter or proxy trap that runs during a trusted build outside any
visit session now runs with the build's state set aside, as inside a
session. A conversion the getter starts, such as a recursive union
that opens and ends its own session, no longer clears the mark that
keeps the rest of the outer build from trusting its earlier match.