socket.address(), socket.resetAndDestroy() and server.dropMaxConnection
were refused at compile time, and address() and resetAndDestroy() also
through untyped socket handles. They now match Node:
- address() answers { address, family, port } while the socket has a
local address, and {} while it connects or once it is destroyed. The
declared AddressInfo | {} result is a checked-dynamic value, so a type
assertion to AddressInfo narrows it.
- resetAndDestroy() closes the connection with an RST (SO_LINGER with a
zero timeout), so the peer reads ECONNRESET, and 'close' reports
hadError false. A connecting socket resets once it connects: the reset
is a once('connect') listener, so it runs at that place among the
'connect' listeners. A destroyed socket is left alone, a TLS socket
throws ERR_INVALID_HANDLE_TYPE, and the call answers the socket.
- dropMaxConnection is a stored value (undefined until set), since Node
reads it only in cluster mode.
Corpus 5657 (typed) and 5658 (untyped handles and the TLS throw) and the
node-types fixture net-reset-address.ts cover each member.
promisify(fs.X) over a named or namespace import of node:fs lowers as
the fs/promises member of the same name for access, appendFile, chmod,
chown, copyFile, lchown, link, lstat, lutimes, mkdir, mkdtemp, readFile,
readdir, readlink, realpath, rename, rm, rmdir, stat, statfs, symlink,
truncate, unlink, utimes and writeFile. For these, Node's promisified
callback form settles with the callback's single result and rejects
with the same errors as the fs/promises call. Calls through the bound
function accept the forms the fs/promises lowering accepts and refuse
the others by name.
fs.open (an fd number where fs/promises.open gives a FileHandle),
fs.exists (a custom promisify) and fs.read/fs.write (multi-value
callbacks) remain refused.
The program reads the same files through readFileSync with "utf8",
"utf-8" and an options object, the fd form, fs.promises.readFile and
FileHandle.readFile, and compares each with Buffer.toString("utf8"):
valid files (ASCII, 2- to 4-byte characters, a BOM, empty) and invalid
ones (stray continuation and lead bytes, encoded surrogates, overlong
forms, sequences cut at the end of the file).
"Append to strings in place for fields, concat chains and character
codes" made `obj.f += s` linear by handing the field's reference to a
local before the concat. Two neighbouring forms still copied the whole
string on every append, so their loops stayed quadratic:
- `parts[i] += s` on a `string[]`: the old element reached the concat
through a retained snapshot (the hidden %compoundOld local) while the
slot still held it. 1.6M appends spread over four slots took 11 s.
- `o.f = o.f + s` written out: the field read retains a snapshot and
the field keeps its reference until the store.
A statement `a[i] += rhs` on a `string[]` now lowers to
`a[i] = (hole ? "undefined" : a[i]) + rhs` with the array and index saved
first (evaluation order unchanged). The backend recognizes that store and
the direct `recv.f = recv.f + suffix` on a plain string class or record
field. After the snapshot and suffix are evaluated and the store guard
ran, a field unlinks and releases its current value; an array slot in
the hole-free prefix that still holds the snapshot is swapped for the
immortal empty string and released. The concat then appends in place
when nothing else holds the string, and the result is stored as before.
Both paths unlink only when the snapshot is frame-owned (or immortal).
Aliases, interned literals, throwing suffixes, suffixes that read or
replace the field or element, rebound receivers and indices, holes,
appends past the end and appends that grow the array keep their
semantics (tests/corpus/string-field-append.ts, which also pins the
field `+=` forms).
A for-of over a user iterable calls next() once per step, and a next()
that builds its { value, done } record with `this.i++` (or a compound or
nullish assignment) lowers a field through a seqExpr. Record result
entries rejected any seqExpr, so every step allocated and released a
heap record. The result entry splits a returned literal into one store
per field; that is safe for a field's seqExpr as long as no other field
names a hidden local declared inside it, which is now checked. Other
seqExpr placements keep the original rejection.
A 30M-step class-iterator loop: 200.7M -> 63.2M instructions per 3M
steps (callgrind), 150 ms -> 40 ms.
A native synchronous generator body whose yield channel is a number or a
boolean caches its ScrGen's OUT slot at entry and stores each yield into
it directly instead of calling scr_coro_yield_f64/bool; for-of, spread
and yield* drives read such values back inline instead of calling
scr_gen_take_out_f64/bool. The IR validator types every yield operand as
the channel, so OUT never holds a reference at a yield and the store
needs no release. The emitter mirrors the slot as %ScrGenHead and
%ScrGenSlot; scr_async.c pins both layouts and the tags with static
assertions. WASI keeps the call paths.
scr_gen_step, the compiler's own for-of/spread/yield* drive, now enters a
suspended or unstarted stackless generator directly: one function with
the hop inlined instead of resume -> switch_in -> coro_in calls. Its
emitted callers own the generator or lend it from a binding nothing can
rebind during the step, so that hop skips the generator retain/release
(the user-facing next/return/throw paths keep it). The IN slot is reset
only when it holds a value, the stackless hop skips the async-generator
state test (stackless bodies are always synchronous), and the resumer is
parked in the fiber so fewer values stay live across the body.
In lanes without exception alert state (no workers, no thread-instanced
libraries) scr_exc_cell_changed is empty, so runtime units now inline the
fiber switch's exception-cell swap as two pointer moves, mirroring the
existing inline pending test.
A generator frame owns its parameters until its final exit, across every
suspension, so an unchanged, unboxed reference parameter is a stable
owner that reads may borrow (CallLifetimes.generatorParameters). Calling
a callback parameter on every step no longer retains and releases it.
Generator declarations entered only through direct (spawn) calls are now
constant-callback consumers: when every call passes the same function,
the body enters it through the guarded direct path, which inlines it and
drops the dynamic-`this` bracket for callees that cannot observe it.
At -O0 LLVM inlines none of the guarded bodies, so guarded dispatch only
adds call sites and temporaries to every virtual call, and unoptimized
frames (sanitized builds use --optimization=dev) grow with them. Emit the
slot tests only for optimized builds; dev builds keep the single
indirect call through the vtable.
Both hierarchies construct the base and the subclass, so the base-typed
call sites are closed sets now: they still load the vtable slot, then
call each implementation directly behind a slot test.
A module-level number binding that only its guarded, synchronous module
initializer reads or writes (findInitializerBindings) is invisible to
every other function, and reentry returns before reaching it. Bindings
proven int32 already move into an i32 local of the initializer
(initializerLocalCandidates); the initializer now also addresses the
remaining double candidates through a function-local slot seeded from the
global, so LLVM keeps top-level double accumulators in registers across
calls and loops instead of storing and reloading the global around every
opaque call. The global's storage is never read again.
Bindings a loop updates only conditionally stay in memory: in a register
LLVM turns `if (c) count++` into a select every iteration waits on, which
made 30-string-build's character scan slower.
`const cur = m.get(k); m.set(k, f(cur))` and `m.set(k, (m.get(k) ?? 0) + 1)`
probed a string-keyed map's hash index twice. When only inert code (reads,
scalar arithmetic, compiler-resolved throws) runs between the read and the
write, the read parks the map, the key and the resolved entry in local
slots and the write overwrites that entry's value in place if the map and
key are identical at run time and the entry was found. Misses and every
other case take the ordinary set, so insertion order and SameValueZero
semantics are unchanged. Only string-keyed typed maps with scalar values
fuse; number-key writes after a read already store into the found entry
through findReadNumberMapWrites.
A virtual call whose receivers can only be a few known classes (every
class that may be instantiated below the static class resolves the method
to one of at most four declarations) now records that closed set on the
virtualCall node. The backend compares the loaded vtable slot against
each implementation but the last and calls the match directly, so LLVM
can inline the bodies and keep loop-carried values in registers. Open
subtrees (decorators, dynamic bases, exported library classes, runtime
classes) and larger sets keep the indirect call.
On x86-64 CPUs with the SHA extensions (and SSSE3 and SSE4.1), SHA-1 and
SHA-256 process their blocks with SHA1RNDS4/SHA256RNDS2 and the
message-schedule instructions, several blocks per call. The CPU check
runs once and is cached; other CPUs, other architectures and wasm32 keep
the portable blocks, and both give the same digests. createHash,
crypto.hash, createHmac, pbkdf2, hkdf and every other caller of the two
digests use the new path.
sha-kernels.test.ts runs both block implementations over known vectors
and every length from 0 to 1100 bytes and 1 MiB, whole and in 1-, 7-,
64- and 100-byte updates. Corpus 5763 compares the digests, HMAC, PBKDF2
and Hash.copy() with Node across block-boundary lengths and update
sizes.
Outside constructors a final field cannot change: the constructors that
store it are finished or suspended below every other running frame. A loop
reading p.f through a never-rebound parameter (usually this) still reloaded
it after every store or call that might alias the object and hid bounds and
strides such as y < this.h or i - this.w from loop-invariant reasoning. Such
reads now go through an immutable local declared before the outermost loop
containing them. analyzeFinalFields gains a scalar mode, which also treats
in-place numeric updates (fieldIncDec) as writes.
A source module binding whose one write in the program is an unconditional
statement of the guarded synchronous initializer holds that value for every
later read, like a const; early reads already carry the frontend's init flag,
TDZ sentinel or undefined-admitting type. Marking it immutable hands it to
constant folding, borrowed reads and constant tables, so top-level `let N =
1024` no longer keeps index math in doubles behind global loads.
Once every use of an environment-free closure body is a direct call, the
original body is unreferenced and is dropped. When other uses still take
the closure as a value, its body becomes a call of the capture-free copy,
so the module carries each body once instead of twice.
A nested helper called only directly still created its closure value,
which kept the body a closure target: a callback-taking helper could then
recurse through callbacks, needed a stack check and made every indirect
call may-throw (w41-closures paid an exception check per callback call).
The binding's definitions only create that closure, so they go.
Helpers declared above the consts they read force those consts into TDZ
boxes with a checked read per access. When every environment created
before the initializing assignment is the direct value of an unshared
local whose reads all follow that assignment (and so do the binding's own
reads), no code can observe the empty box: the binding splits at its
initialization and closures copy its value once, including numeric
constants. Calls in the initializer, escapes before it and helpers
reached through other captured helpers keep their checks.
Captured bindings live in shared boxes, and every analysis skips boxed
locals: a function-wrapped program's const N turned all index math into
checked f64 arithmetic and every captured array read into a retained box
load. copyStableCaptures keeps the boxes (environments, ownership and
cycle tracing are unchanged) but reads bindings that nothing writes after
their only initialization through plain immutable locals: split
declarations in the declaring function, one entry copy per closure body,
and numeric constants propagated into the copy. Hoisted function
bindings qualify through their single assignment, and calls through a
binding of an environment-free function become direct calls of a
capture-free copy (int32 ABI clones and inlining apply).
Constant callbacks resolve hoisted function bindings, copies of a
scalar capture keep double storage, and AmbientReceiverReaders moves to
ir/ so the pass can use it.
The typed 31-json benchmark spent most of its time in the kept-source
table: every record was hashed to a random slot of a multi-megabyte
table, every native object free probed it, and backward-shift deletion
rescanned probe runs.
- Buckets keep keys of one 1 KiB region in order within a 64-slot run,
so records converted, serialized and freed together touch neighboring
slots.
- Deletion leaves a marker that insertion reuses; the amortized rehash
drops markers (in place unless live entries fill a quarter).
- A bit filter over the keys answers the dispose hook for untracked
objects without probing; large rehashes rebuild it.
- Pooled dyn nodes notify weak collections only.
- Inserting a fresh record skips the presence probe, and acyclic shapes
serialize through one scr_jb_try_record_source lookup.
Sandbox (8 vCPU, median of 5): typed variant 10.8 s -> 2.5 s.
`const parsed: { score: number }[] = JSON.parse(doc)` printed a different
checksum from Node: the checked conversion (and the schema-directed
JSON.parse fast path) stored only the declared fields of each object in
its native record, so JSON.stringify(parsed) silently dropped `id`,
`name` and `active`, and every other reflective surface agreed.
A closed record converted from an ordinary object now keeps the source's
own properties beside it when the object has undeclared keys or holds the
declared ones out of declaration order. The runtime keeps a per-thread
table from the native record to a view of the source's own keys
(placeholders for declared keys, which the record still owns), released
through the native dispose hook when the record is freed:
- the checked record builder notes its source (scr_record_source_note);
- the schema parser parses undeclared members into the view instead of
declining, so typed parses keep their one-pass fast path;
- typed JSON.stringify writes such records in source key order, declared
fields through a generated per-shape field writer and the others through
the dyn writer;
- record capsules adopt the view and refresh it in place, so untyped reads
and writes see and persist undeclared properties;
- util.inspect and Object.keys/values/entries answer through the live view
(values/entries check the extra values into their static result type and
throw the checked-conversion TypeError when one does not fit).
Copies made by object spread, Object.assign, rest destructuring or
conversion to another record type still hold only the declared fields;
the limitations page documents this.
`checksum += parsed[i].score` (a number local plus an `any` value) failed
with SC1043 "comparing non-number, non-string values" in TypeScript, while
the expanded `checksum = checksum + parsed[i].score` compiled: the
compound lowering only routed checked-dynamic operands through the
runtime operators in JavaScript files.
Locals, record fields and array elements now lower `op=` with an untyped
right operand (or an untyped local target) exactly like `x = x op rhs`:
dyn.add / dyn.arithmetic / dyn.bitwise, then the checked conversion into
the target's declared type. Numeric operators keep Node's ToNumber
semantics; a `+=` that produces a string for a `number` target throws the
same catchable TypeError as the expanded assignment and leaves the target
unchanged (pinned in tests/harness/dyncheck.test.ts).
Remaining SC1043 refusals for arithmetic, unary, update and compound
operators now name the operator and operand types instead of the
comparison wording.
The retired-engine-763 diagnostics fixture compiles now and matches Node,
so it moves to the corpus as 5343.
Main now inlines dyn releases in compiled code (shapes.ts). Its fast path
buffered every surviving array and object, so compiled releases of a
parsed document's containers (the `parsed[i]` temporaries) bypassed the
intact-region check in scr_dyn_release. Arrays and objects now keep the
inline already-buffered check and otherwise call
scr_dyn_release_survivor, which skips containers of intact documents
(ScrDynRegion) like scr_dyn_release does. Functions and proxies keep the
inline enqueue.
Two writers that main added install tables or prototypes on existing
objects directly: scr_dyn_symbol_init_own and scr_dyn_dirent_finish. Both
now clear the object's region first, keeping the invariant that intact
containers have no symbol tables or prototypes.
scr_fss_new takes ownership of the FileHandle owner record (and its
reference to the handle), but its first two refusal paths returned before
the shared cleanup, so a FileHandle stream refused by its options, such
as fh.createReadStream({ encoding: "nope" }), leaked both. Those paths
now release the owner as the later ones do.
Corpus 5554 refuses a FileHandle stream and then closes the handle, and
tests/harness/fs-stream-leaks.test.ts runs the fs stream programs under
LeakSanitizer in the sanitized lane: 5554 reports the leak without this
change.
structuredClone of checked-dynamic values builds a fresh tree (shared
inputs are copied per path, cycles throw), so its containers join one
ScrDynRegion as they complete, exactly like JSON.parse; a BigInt or bytes
leaf clears the region, because intact containers promise JSON.stringify
inert leaves only.
scr_message_decode (worker, MessagePort and BroadcastChannel payloads)
tags its containers before dropping the decoder's references when every
node is a JSON primitive, undefined, array or object, arrays carry no
named properties, and every container edge leads to a higher encoder id.
Ids are assigned parents first, so that proves the graph acyclic; shared
subtrees reached again from a later node and cycles stay untagged.
Region API for other whole-document producers: scr_dyn_region_open,
scr_dyn_region_tag (on each completed container) and
scr_dyn_region_close. Node layout is unchanged: arrays and objects keep
the region pointer added for parse regions (arr.region / obj.region in
the capsule payload space).