`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.
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).
The plain writer was inlined into scr_json_replaced_write, enlarging the
frame every level of the protocol walk recurses through. It is now out of
line and recurses into itself for member containers that prove plain, so
plain nesting costs one small frame per level and only members that are
not plain take the protocol walk. Pins a 900-level parsed document.
value[i] with a number i formatted i as a property name on every read
(snprintf and a string allocation) before the generated dispatch helper
parsed it back. Numeric-key read helpers now take the number and return
the element directly when it is an integer below the checked array's
length (dyn.arrLen answers 0 for every other kind; -0 names "0"), and
format the name only otherwise.
The plain JSON.stringify path proved only containers of primitive
members, so an array of parsed objects took the full protocol walk per
element: a formatted index key, a retained member read, a toJSON lookup
and the replacer callback. Inside an intact parse region, container
members are admitted too: everything such a container reaches was built
by the parser and holds JSON values only, so writing a member subtree
runs no program code that could mutate the container mid-walk and no
cycle passes through it. Each member container is still written through
scr_json_replaced_write, which proves it afresh.
Every heap dyn node carries a cycle header, so a member read of a parsed
document (parsed[i], JSON.stringify's walk) left each container it touched
in the candidate buffer and the collector traced the document over and
over. JSON.parse now tags the containers it builds with one shared region
that stays intact until the first store into any of them that could close
a cycle (a reference store; primitive and immortal stores, removals and
permutations keep it). Releases of intact containers skip buffering.
The flag is per region, not per container: a cycle can run through a
container that was never written (child.parent = root), and the last
outside reference to it may be dropped from that container. The write
barrier sits in the checked-dynamic writers (arr_push, obj_put, key_set,
define_property, reflect_define, set_prototype, symbol and named-property
tables, the reviver walk, unshift/splice/fill). A missed barrier would
leak a cycle, which the RC audit reports; walks and teardowns are
unchanged, so it cannot free live data.
Also corrects the collector header comment, which listed dyn trees as
headerless.
createInterface checks historySize before completer, as Node's
InterfaceConstructor does: setupHistoryManager runs before the completer
check, and its validateNumber names the option "size". Corpus 5781 now
passes an out-of-range historySize together with an invalid completer
and prints the historySize RangeError, as Node v24.22.0 does.
dgram.Socket's setBroadcast, setTTL, setMulticastTTL,
setMulticastLoopback, setMulticastInterface, addMembership,
dropMembership, get/setRecvBufferSize, get/setSendBufferSize,
getSendQueueSize, getSendQueueCount and disconnect were refused at
compile time. They now lower to scr_dgram.c and match Node:
- The TTL setters and setMulticastLoopback return their argument. The
value checks run first: a TTL outside 1-255 (0-255 for multicast,
after ToInt32) throws "setTTL EINVAL" with code, errno and syscall.
- A socket that has not been bound has no descriptor yet, so the
options throw EBADF, and the buffer size methods throw Node's
ERR_SOCKET_BUFFER_SIZE SystemError.
- After close(), the members that read Node's null handle throw its
TypeError ("Cannot read properties of null (reading 'setTTL')"), and
setMulticastInterface and the membership calls throw "Not running".
- Buffer sizes must be uint32 values (ERR_SOCKET_BAD_BUFFER_SIZE), 0
reads the current size, and sizes past the int32 range fail as EINVAL.
- IPv6 interfaces and groups go to the IPv6 socket options, so a udp4
socket answers ENOPROTOOPT as in Node.
- disconnect() throws ERR_SOCKET_DGRAM_NOT_CONNECTED until 'connect' was
emitted, and dissolves the association with an AF_UNSPEC connect.
addMembership and dropMembership before bind() throw SC2020, where
Node binds the socket to an ephemeral port first; the limitations page
documents it, along with the missing info object on the buffer size
SystemError. Corpus 5655 and 5656 and the node-types fixture
dgram-options.ts cover each method.
Hash and Hmac values are Transforms, as in Node: they can be written
and ended, piped into, used as pipeline() and stream/promises pipeline
stages, read, and listened to, and setEncoding() applies to the digest
they push. Each value gets one native Transform on its first stream use.
The stream refers to its value without a reference; a pipe destination
or pipeline stage holds the value until the stream flushes or is
destroyed, and a destroyed stream is released so that listeners that
capture the value do not keep it alive.
After the stream flushes, a Hash answers one more digest() with the
same digest and then throws ERR_CRYPTO_HASH_FINALIZED, and update()
throws ERR_CRYPTO_HASH_UPDATE_FAILED; an Hmac answers digest() with an
empty digest. A stream write after digest() reports
ERR_CRYPTO_HASH_UPDATE_FAILED through 'error'. copy() of a Hash whose
stream flushed, and stream use of a value after its stream closed,
throw a coded SC2020 error.
Writing a checked-dynamic value that is not null, a string or a view to
any stream now throws ERR_INVALID_ARG_TYPE; it used to report the
null-values error.
Corpus 5760-5762 cover the piping, pipeline, setEncoding and event
forms, the states after a flush, and the JavaScript forms, and
errors.test.ts covers the two SC2020 errors.
`"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.
A collection pass copied every buffered candidate from the generation
root buffers into its candidate list, so it held two buffers of every
candidate, and each work vector kept its largest size for the rest of
the program.
A pass now takes the fullest root buffer as its candidate list and
appends the others; that generation gets the previous candidate buffer,
empty. The vectors a pass fills and leaves empty (pending, white,
promote, restore and cross-generation lists) record how many entries the
pass held at most. When a pass ends, any of them that holds more than
65536 entries is freed if the pass used less than a quarter of it, and so
is the candidate list's buffer when this pass's candidates filled less
than a quarter of it. Passes of a steady size keep reusing their
buffers; a spike in one pass no longer keeps its buffers for the rest of
the program.
When an older generation's buffer is the fullest, candidates are
visited, and a pass's garbage is freed, in a different order; the set
of objects a pass frees does not change.
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>`.
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.
new MessageChannel() from node:worker_threads makes two entangled
MessagePorts. They share the handle model of parentPort: postMessage
with the existing message clone, 'message' and 'close' events,
on/once, start, close, ref, unref and hasRef. A port's 'close' reaches
the other port after its queued messages. receiveMessageOnPort(port)
answers the next queued message as { message }, or undefined.
A channel is a thread-neutral record (scr_mailbox.c): each end has its
queue and the inbox of the thread that owns its port. A port listed in a
transfer list, given as postMessage's array or { transfer }, or as the
Worker constructor's transferList, travels with the message, and the
decoding thread adopts it. The sending port becomes unusable. A port in a
message without that listing throws DataCloneError, and so does a port
listing itself.
Port events follow Node's loop phases. Each port's queued messages
arrive as one batch, ports in creation order, before setImmediate
callbacks, with nextTicks and microtasks after each message (a new loop
hook keeps the pass going until the batch is delivered). A port's own
'close' runs in the following close phase, the latest close() first. The
other port's 'close' arrives on a later poll, in the order the notices
were sent, and only while the loop keeps running.
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.