Repository navigation
Train node 1: the Node compatibility ledger with its evidence, http client events, http2, Node's error codes, stream, child_process and process fixes - #3353
Conversation
The ledger marked the most-used Node APIs not-implemented because rows lowered through dedicated paths matched no registry entry. Thirteen differential corpus programs, one per API family, now back 220 of those rows as partial: Buffer instance methods, URLSearchParams, the web globals, the stream classes, net, the ChildProcess handle, process basics, the CommonJS module-scope names, the OutgoingMessage header surface, fs.Dirent, the crypto catalogs, EventEmitter's listener events and the error classes and codes. Make verified-absence reachable: a not-implemented features entry that names evidence is a verified gap, and three diagnostics programs pin the refusal for 49 rows that were unverified registry gaps. Generator: a features entry may name several chapters, and a snapshot in which a global and the chapter that owns it disagree as claimed against unmatched is refused. The tls.connect options overload, which the corpus already exercises, is no longer reported as refused. No compiler or runtime change. Co-Authored-By: Claude Code <noreply@anthropic.com>
The manifest maps each chapter's rows under that chapter's own parent heading, so no entry needs to name several chapters. Drop the unused `chapters` form from the generator and say in the twin check's error and the README that both rows are mapped or the difference is classified. Co-Authored-By: Claude Code <noreply@anthropic.com>
The `globals` rows for `PerformanceEntry`, `PerformanceMark` and `PerformanceMeasure` are the global class bindings, which a program cannot read (SC2020, host-global); the evidence only proves the entries `performance.mark()` and `performance.measure()` return, which are the `perf_hooks` rows. The three rows are verified absences again, so the host-global table the compiler derives from the ledger matches (packages/compiler/test/host-globals.test.ts). `TextDecoderStream` and `TextEncoderStream` are implemented in JavaScript sources (web-stream-transform-pipes-untyped.mjs) and `fs.exists` is implemented (2595-fs-arg-ladders.cjs); a TypeScript-only fixture had marked them absent. They are `partial` with those programs as evidence. The global-twin check looked only at direct rows of the owning chapter, so the four web-stream reader, writer and controller classes, which the webstreams chapter nests under "API", were claimed as globals and unmatched in their own chapter. A class row now owns its global at any depth, and the four rows are mapped. Co-Authored-By: Claude Code <noreply@anthropic.com>
A destination never heard 'unpipe' unless the program called unpipe()
itself: `Readable.from(["x", "y"]).pipe(dst)` printed pipe, x, y under
both, and then 'unpipe' only under Node. An explicit unpipe() also paused
the source without 'pause', and unpiping one of two destinations stopped
the flow to the other.
Node's pipe() does more than record the destination. It listens on the
destination for 'unpipe', 'error' (ahead of the program's listeners),
'close' and 'finish', and on a source piped with `end: false` for 'end';
those listeners are what unpipe a destination that finishes, closes or
errors. pipe() now registers the same listeners, as native closures over
one shared set of boxes so that any of them can remove the rest (Node's
cleanup), and unpipe follows Node's order: the destination leaves the
table, the source pauses with 'pause' once it pipes nowhere, then the
destination hears 'unpipe'. A destination error with no listener of the
program's own is thrown, as before; a for-await loop or a finished() or
pipeline() watcher on the destination still takes it.
Every stream program links pipe() and unpipe() (the data path unpipes a
destroyed destination, and an untyped call can reach pipe), so the
lifecycle sits behind a table, scr_stream_pipe_ops, that a new gated unit,
scr_stream_pipe_events.c, installs for programs that can pipe: pipe(),
unpipe(), pipeline() and compose() in every form, a member named pipe or
unpipe (or a computed member) called on an untyped value, or either name
as a string. The implementation stays in scr_stream.c, beside the state it
reads, and is linked only through that unit. There is no new build of the
stream unit.
The pipe table still carries the data and ends an `end: true` destination,
so the source's listenerCount('data') and ('end') do not count pipe()'s
own listeners.
Co-Authored-By: Claude Code <noreply@anthropic.com>
… is killed
maxBuffer was accepted and discarded, and Node's 1 MiB default was not
applied either, so `execSync("head -c 4096 /dev/zero", { maxBuffer: 16 })`
returned normally where Node throws ENOBUFS, and every capture past 1 MiB
returned output Node refuses.
The synchronous forms (execSync, execFileSync, spawnSync) now count stdout
and stderr together, as spawn_sync.cc does: past the limit the child gets
the kill signal, the stdio pipes are closed, and the call reports Node's
"spawnSync <file> ENOBUFS" (thrown by the exec forms, `error` on
spawnSync's result) with errno, syscall, path and spawnargs, the output
read so far, and the child's own exit status or the kill signal. 0 means
no limit.
exec and execFile, callback and promisified, limit each stream in UTF-16
units of the decoded text, as lib/child_process.js does: the chunk that
crosses the limit is cut to what still fits, both streams end, the child is
killed, and the error is a RangeError with code
ERR_CHILD_PROCESS_STDIO_MAXBUFFER and cmd (plus stdout and stderr when
promisified). The callback forms do it the way Node does, with a 'data'
listener on the child's own streams that scr_exec_file installs, so the
stream pump and the settle path are unchanged for every other child.
A program's own maxBuffer reaches the runtime through a new cp.maxBuffer
call that the lowering places after the capturing call's arguments are
evaluated and immediately before the call. A negative or NaN value throws
Node's ERR_OUT_OF_RANGE before anything spawns, for the promisified forms
where they are called. The callback form's maxBuffer no longer has to be a
literal.
Closing the pipes at the kill also fixes the timeout of the synchronous
forms, which kept draining until end of file: a shell killed at the
deadline whose child still held the pipe blocked the call until that child
exited (20 s in the new corpus program, where Node returns at 300 ms).
`subprocess.stdio.length` answered 5 under @types/node, where Node's array
has 3 entries for a default spawn. The member has no lowering (a named
SC2020), and its length escaped the refusal because a tuple's `length`
folds to the declared arity without lowering a receiver that is a plain
chain of property reads. A standard-library member is not such a field, so
the fold now lowers the receiver and the refusal is reported.
spawnSync's own `maxBuffer` option stays a named refusal, and the Windows
arm is ported from the POSIX one without a Windows run. The semantics, the
per-stream cut (scr_exec_limit_chunk) and the Windows loop follow #1379.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Every process.features.<flag> read answered undefined and the object itself was empty, so `typeof process.features.tls` printed "undefined" where Node prints "boolean", and a feature test could not tell "absent" from "false". Node's 13 own keys now answer a value each. A flag that describes a capability the program can use says whether scriptc implements it; a flag that describes Node's own build says what is linked here: - ipv6, tls, tls_alpn, tls_sni and require_module are true, and debug, openssl_is_boringssl and quic are false, as in Node 24.22.1. - inspector, uv and cached_builtins are false: no V8 inspector, no libuv and no JavaScript builtins exist in the binary. - tls_ocsp is false: requestOCSP is a runtime fence, and true would send a program down a path that waits for an 'OCSPResponse'. - typescript is false where Node answers "strip": the binary cannot load TypeScript source at run time, and "strip" tells a loader that import() of a .ts path works. A member read lowers to the constant, so a guard folds. process.features as a value is one stable object (scr_process_features) with the keys in Node's order, typed through NodeJS.ProcessFeatures like process.versions and process.release so an alias keeps its identity. Names Node does not define still read undefined. The object lives in a runtime unit of its own, linked only by programs that read process.features as a value. With the function in scr_lib.c the zero-cost gate reported 299 unrelated executables as changed: new code in that unit changes the code of functions it never calls. The two corpus programs (ambient types and @types/node) print the same text under Node and natively: flags that differ by design are compared through a discriminator, which fixes each native value. Co-Authored-By: Claude Code <noreply@anthropic.com>
…stream handle child.stdout?.pause() had no lowering on the child stream handle, so one such call asked for the general stream representation and the whole program lowered a second time. The compiler's own source has that call (cli/mcp.ts), which made every self-build lower twice. The handle now keeps Node's flowing state. A paused stream fires no 'data', holds what it reads up to the 64 KiB high-water mark and then stops reading, so the child blocks once the pipe is full; resume() drains in order on the next tick; an 'end' that arrives while paused is owed and the child's 'close' waits for it; the child's exit resumes its streams on the tick after the 'exit' listeners, as Node's flushStdio does. The state lives in a build of scr_child.c of its own (SCR_CHILD_STREAM_FLOW), selected by the four new libCalls, so a program that never calls these members links the default object. A stream that is never paused or resumed behaves as before in that build too. Co-Authored-By: Claude Code <noreply@anthropic.com>
A census probed every remaining registry-gap row of the Node compatibility ledger under Node 24.22.1 and as a compiled binary, from a JavaScript source and from a TypeScript one. This maps what a program can prove today and classifies three groups the ledger could not express. Nothing under packages/ changes except test baselines. - Eleven differential programs, tests/corpus/compat-*.mjs, cover 241 registry-gap rows: the WebCrypto algorithm dictionaries, web stream members, node:stream state and hooks, X509Certificate and KeyObject fields, net, dgram and TLS socket members, node:http2 over cleartext, the http and https server tail, error properties and single core members. They are JavaScript modules because most of these members are refused from the same text in TypeScript (the bundled declarations list only what the typed lowering compiles) while a JavaScript source compiles them natively; the public detail of a row whose only evidence is compat-*.mjs programs says so. - 85 Node error codes are mapped to 45 existing corpus programs that print them on stdout under Node and natively. - http2.createServer and http2.connect leave `refused`: a JavaScript source runs both. - 23 prose headings Node writes with a code span are `not-applicable`, declared one by one. - inspector and domain are `by-design` chapters; the compiler refuses both modules by name with an architectural reason. Registry gaps 1,664 to 1,270; stable API rows that apply to native compilation and are test-backed 60.1% to 70.3%. Co-Authored-By: Claude Code <noreply@anthropic.com>
Two cases where the owed 'end' was late or never came: A listener that paused on the last held chunk left the stream paused with nothing held and its 'end' unscheduled, so 'end' and the child's 'close' never fired. Node decides in the read that takes the last chunk, before the chunk's listeners run. The drain now queues the 'end' tick there (and at its start, as Node's read(0) does), whatever the flowing state. A paused stream that reached EOF holding nothing kept its 'end' until a resume or the child's exit. Node's read(0) after the EOF push ends such a stream at once; only a stream that still holds data, or the decoder's tail, owes 'end' until that is read. EOF now defers only then. Co-Authored-By: Claude Code <noreply@anthropic.com>
The compat-http2-basics program registered an 'aborted' listener and read request.aborted on requests that all complete, and the ledger claimed both rows from it. A request the client resets shows why that matters: natively the listener never runs and the flag stays false, where Node emits the event and answers true. The two rows go back to registry gaps, the program stops naming what it does not exercise, and the ledger is regenerated. Co-Authored-By: Claude Code <noreply@anthropic.com>
One 'unpipe' reaches every listener set pipe() left on the destination, and the first one whose source it names cleans up; a thread-local flag recorded that, reset before each emit. An 'unpipe' listener that unpipes another stream runs a second emit inside the first, and that emit left the flag set: when the outer emit went on to pipe()'s own listener, it took the unpipe as already handled and left its 'finish', 'close', 'error' and 'unpipe' listeners on the destination. The flag now belongs to the emit in progress: each emit keeps the outer value and puts it back, which is Node's per-call unpipeInfo. The corpus program gains two nested scenarios (the program's listener ahead of pipe()'s, and after it) and reads the destination's listener counts; with the old flag the first one reported unpipe=2 finish=1 close=1 error=1 where Node reports unpipe=1 finish=0 close=0 error=0. Co-Authored-By: Claude Code <noreply@anthropic.com>
Six declarations a typed source needed and the lowering already served; each matched Node with @types/node and was a type error with the bundled set: - fs.rm(path, [options,] callback) - Http2ServerResponse.setTimeout, and an array value for its setHeader - dgram socket.bind(port, callback) - the 'secureConnection' listener's socket is a TLSSocket - node:crypto's namespace webcrypto carries the WebCrypto type names under the spelling @types/node gives them, each an alias of the existing global interface; the globals stay Co-Authored-By: Claude Code <noreply@anthropic.com>
lower-dgram.ts kept a table of fourteen socket members that restated each member's parameter and result types beside the builtin's signature row. The table moves to handle-members.ts as the first handle member table: a row names the builtin, the types are read from ir/builtin-signatures.ts, and the row adds only how many trailing parameters may be omitted. The lowering emits the same IR. handle-members.test.ts checks the bundled dgram.Socket declaration against the table: every declared member is a row whose builtin has the declared types or one of the members a dedicated lowering compiles, and a new member, a missing row, a differing type and a stale dedicated name all fail. Co-Authored-By: Claude Code <noreply@anthropic.com>
The census behind the evidence programs measured the TypeScript column with its JavaScript probe text, so strictness errors read as refusals. Typed probes show 214 of its 253 JavaScript-only rows work from a TypeScript source and match Node. Each compat-*.mjs program gets a compat-*.ts twin, written against the bundled declarations; a twin's header names what it leaves to the JavaScript program. 220 of the 241 rows now cite both programs and their public detail says both sources exercised them. 21 rows keep the JavaScript program alone: the queuing strategies and the text codec streams, a connect error's address and port, a SystemError's info, buf.parent and StatFs frsize. No row changes status. Co-Authored-By: Claude Code <noreply@anthropic.com>
The flags object lives in a gated runtime unit, scr_process_features.c, and library archives carry only the units their own table lists, selected by the library's own feature plumbing. A library that read process.features as a value therefore compiled and left its archive with an undefined scr_process_features: the embedder's link failed. Library mode now refuses the value form with SC4005, like the other surfaces whose units an archive does not carry, and says to read the flag by name: a member read (process.features.tls) is a constant and links nothing. Carrying the unit instead would mean a new fact through the library archive options, both library build paths and the library cache key, for a read no embedded library needs. Co-Authored-By: Claude Code <noreply@anthropic.com>
…laim CI found two things the Sandbox runs did not. The checker identity job compiles every corpus program with tsgo as well as tsc-ts. Four of the programs call resolve() without an argument inside new Promise((resolve) => ...), which tsc-ts accepts in a JavaScript source and tsgo refuses; they now pass undefined. On the Linux runners a socket connecting to "localhost" emits no 'lookup' event natively, in release and dev builds, where Node emits it; in the Sandboxes, where the name resolves to one address, it does. The section is removed from the net program and the row is a registry gap again until a program controls the resolution. Co-Authored-By: Claude Code <noreply@anthropic.com>
The net evidence program lost its 'lookup' section upstream (the event does not fire natively on the CI runners), so the TypeScript twin drops the same section and the row keeps no claim. The ledger is regenerated from the merged manifest. Co-Authored-By: Claude Code <noreply@anthropic.com>
stream.isDestroyed, isErrored, isDisturbed, isReadable and isWritable (and Readable.isDisturbed), the getters readableAborted, readableDidRead, readableEncoding and writableAborted, stream[Symbol.asyncDispose]() with `await using`, and bytes() from node:stream/consumers were refused by name from TypeScript and JavaScript. They follow Node's internal/streams/utils.js, readable.js and writable.js over the state the stream unit keeps. - A value typed as a stream class answers from the stream. An untyped value, a union and a primitive are judged at run time: a native stream (the five classes, subclasses, file and zlib streams, constructor-function streams) answers the same way, a primitive answers as Node's property reads do, and any other value throws SC2020. - Node decides several answers by prototype: a Writable has no `readable`, a Duplex no `writableAborted`. The runtime answers by the stream's class, so those read null and undefined as in Node. A child-process stream is a net.Socket in Node and a Readable or Writable here; it answers about the side it has and throws SC2020 for the other. - readableDidRead needs one new bit, set where 'data' is emitted. Every stream program links that path, so every stream executable changes once, with identical IR and LLVM. Fixed on the way, all silent or worse on main: - stream/consumers over an untyped argument crashed (the checked-dynamic box was passed as the stream). - `closed` was false after destroy() until 'close'; Node sets it when _destroy calls back. - buffer() from stream/consumers answered bytes that Buffer.isBuffer rejects. - `name in stream` through an untyped value ignored the class. - The four getters read undefined through an untyped value. stream.setDefaultHighWaterMark, stream.duplexPair, readable.wrap, Writable.fromWeb, Duplex.fromWeb and Duplex.toWeb stay refused; tests/diagnostics/compat-stream-absent.ts pins them as verified absences. Co-Authored-By: Claude Code <noreply@anthropic.com>
Node answers a read in two shapes. A read() or next() that finds a chunk already queued takes the fast path and answers { done, value }; one that waits for the chunk, or meets the end, answers through its read request with { value, done }. A program sees the difference through Object.keys, JSON.stringify and spreads: JSON.stringify(await reader.read()) is {"done":false,"value":"a"} for a queued chunk in Node and was {"value":"a","done":false} here.
- The JavaScript streams' reader and async iterator build { done, value } on their fast paths (scr_webstreams.c).
- The typed program's native reader records whether a chunk was queued when read() was called and builds the result accordingly (scr_fetch.c).
tests/corpus/compat-web-stream-read-results.{mjs,ts} pin both shapes for queued and waiting reads, the end, tee() branches, the iterator and a spread.
Co-Authored-By: Claude Code <noreply@anthropic.com>
…ling A census of node:http2 against Node 24.22.1 found members that compile and answer differently. This fixes the ones the http2 unit owns. - A 'connect' listener or connect() callback that declared its socket parameter as unknown read the runtime's socket pointer as a dynamic value: typeof answered "string", the session never closed, and a second listener crashed. Callback parameters are now checked by position, every one of them, and a parameter left dynamic receives the payload across the checked-dynamic boundary. - 'aborted' and the aborted flag follow Node's rule: a stream that closes while its writable side is open aborted, whether the peer reset it, the program called close(code) or destroy(), or the session went away. The compatibility request hears of it, and request.complete answers true afterwards. A session teardown reports NGHTTP2_CANCEL on its streams. - stream.closed is true from the start of a close and stream.destroyed from the stream's 'close' (or destroy()), after a normal end too. A server stream whose request nobody read is dumped so that it can close. - Header fields a peer repeats are combined as Node combines them: ", ", "; " for cookie, the first value for the single-value fields, and set-cookie as an array, also when there is one. This is what made appendHeader look as if it kept one value. - A server's 'session' fires in the connection callback, before a later 'connection' listener on cleartext and after it on TLS, and before the first frame. The compatibility 'request' takes the place among the 'stream' listeners that its registration gives it. The compatibility pair closes with its stream: the request, then the response, after a 'finish' that end() queued, and before stream listeners the handler added. - Settings listeners and the settings() callback receive the settings record from a JavaScript source. A session's own record changes when the peer acknowledges the frame, each frame in turn. A session reports the unlimited header list size Node reports, getDefaultSettings() has Node's keys in Node's order, and customSettings is absent without custom settings. - A server session consumes its socket's error, as Node's does through 'sessionError', instead of ending the process with an unhandled ECONNRESET when a client destroys its session. - stream.headersSent is implemented. On a session or stream that reached dynamic code, ping, goaway, setTimeout, unref and listeners for events this unit never raises throw a named refusal instead of doing nothing, and unmodeled documented properties are refused instead of reading undefined. Server push stays unimplemented: a client still sends ENABLE_PUSH = 0, so its enablePush setting reads false where Node's reads true. The limitations page says so. Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
A session's 'goaway' listeners are called with (errorCode, lastStreamID). A listener declaring a third parameter typed Uint8Array compiled and read an argument nobody passed. It is refused at build time until the opaque data is passed. Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
…he http2 programs 49 node:http2 rows leave the registry gap for evidence, the ten rows that were partial on node:http evidence by a name collision name http2 programs, and three of the thirteen go back to declared gaps: the server timeout property on both server classes, which is refused by name, and the compatibility request.destroy(), which resets the stream where Node's does not. Co-Authored-By: Claude Code <noreply@anthropic.com>
A request with `Expect: 100-continue` never finished: Node's ClientRequest
constructor stores the head when the options carry an Expect header and
sends it without waiting for a body chunk, while the compiled client only
serialized its head at the first write(), end() or flushHeaders(). The
server never saw the request and the caller, which writes the body from
'continue', waited forever.
- The four request constructors send the head of an Expect request once
the request knows its agent, and http.get's automatic end() moved to
the same place: it ran before the agent was attached, so a get through
an agent without keep-alive sent `Connection: keep-alive`.
- A head sent before the body length is known carries no
Transfer-Encoding for GET, HEAD, DELETE, OPTIONS, TRACE and CONNECT
(Node's useChunkedEncodingByDefault).
- 'information' on a ClientRequest: every 1xx response but 101, after
'continue' for a 100, with Node's event object (statusCode,
statusMessage, httpVersion, httpVersionMajor, httpVersionMinor, headers,
rawHeaders). http.InformationEvent maps to the checked-dynamic type, so
typed and untyped listeners receive the same ordinary object.
- A request that went through a function parameter registers 'continue'
and 'information'.
- request.setHeader() and removeHeader() are in the may-throw set: their
ERR_HTTP_HEADERS_SENT escaped a try/catch.
- response.writeContinue(), writeProcessing() and writeEarlyHints() throw
ERR_HTTP_HEADERS_SENT once the head is committed, as Node's
writeInformation() does; they wrote a 1xx after the final head.
tests/corpus/compat-http-interim-responses.{mjs,ts} pin all of it against
Node. The ledger's 'continue', 'information' and 'checkContinue' rows
leave the registry gap.
Co-Authored-By: Claude Code <noreply@anthropic.com>
The previous commit made a head that leaves before the body length is
known carry no Transfer-Encoding for GET, HEAD, DELETE, OPTIONS, TRACE
and CONNECT, which is Node's ClientRequest. fetch() sends its requests
through the same client, and undici frames a body whatever the method:
a DELETE or OPTIONS with a stream body lost its chunked framing. fetch
now marks its requests (scr_http_client_frame_any_method), and the head
serializer applies ClientRequest's rule to the others only.
tests/corpus/compat-http-request-framing.{mjs,ts} print what a raw peer
receives from http.request (GET, DELETE, OPTIONS, POST, PUT with an
early head) and from fetch (DELETE and OPTIONS with a string body,
DELETE and POST with a stream). Order baselines for the new fixtures.
Co-Authored-By: Claude Code <noreply@anthropic.com>
- 'connect' on a ClientRequest: the response to a CONNECT request hands
the socket to the listener whatever its status, with the bytes read
past the head; without a listener the connection is destroyed and the
request closes. The 101 handover path takes CONNECT responses, checked
before the 1xx skip as in Node's parserOnIncomingClient.
- agent: false is Node's throwaway agent: the literal passes `false` in
the agent slot instead of appending a Connection: close pair to the
request's own headers, so the field follows Host on the wire, the
request does not list it, the global agent does not count the request,
and the headers option may be a record.
- A body on a GET, HEAD, DELETE, OPTIONS, TRACE or CONNECT request gets
no framing header through end(data) either (Node's
useChunkedEncodingByDefault); fetch keeps framing every body.
- 'prefinish' on a ServerResponse, inside end() and before 'finish'.
- response.writableHighWaterMark (65536; a response that went through a
function parameter answered 16384) and writableObjectMode.
- http.setMaxIdleHTTPParsers(max) with Node's argument validation.
- Reads that answered undefined are refused by name: the agent's socket
hooks, request.signal; response.setHeaders(), writeInformation() and
writeProcessing() on a dynamic response no longer fail with "is not a
function".
tests/corpus/compat-http-client-connect.{mjs,ts} and
compat-http-message-tail.{mjs,ts} pin the behaviour against Node;
tests/diagnostics/compat-absent-http-members.ts pins 17 refusals. The
ledger maps 9 rows to evidence and records 17 verified absences.
Co-Authored-By: Claude Code <noreply@anthropic.com>
…in what stays refused - session.ping([payload, ]callback) sends a PING and calls back with (null, duration, payload) at the peer's answer. A ping sent while the session is connecting or after close(), and one still open when the session goes away, calls back with ERR_HTTP2_PING_CANCEL alone, as in Node; a payload that is not eight bytes is ERR_HTTP2_PING_LENGTH and a destroyed session ERR_HTTP2_INVALID_SESSION. The peer's 'ping' event carries the eight bytes. - session.connecting is true until a client's transport is up. - http2.getPackedSettings() and http2.getUnpackedSettings() pack and unpack a settings record in Node's order, an unknown id under customSettings. - Settings are validated as Node validates them, for getPackedSettings() and session.settings() alike: a number out of range is a RangeError and a flag that is not a boolean a TypeError, both ERR_HTTP2_INVALID_SETTING_VALUE. A settings record that arrives as a typed record is read too. - A 'goaway' listener on a session that reached dynamic code receives the frame's opaque data as its third argument. Fifteen members stay unimplemented and are now verified absences with a diagnostics program: trailers, additional headers, push, respondWithFile and respondWithFD, priority, stream and session timeouts, unref, goaway(), and the 'ready', 'headers', 'push' and 'timeout' events. goaway() stays refused because what a session does after receiving one differs from Node (recorded in the fleet's follow-ups). Co-Authored-By: Claude Code <noreply@anthropic.com>
…, stream and crypto
An audit of the Node error codes scriptc never produces triggered each one that belongs to an implemented surface under Node 24.22.1 and as a compiled binary. These are the places where Node raised a coded error and the compiled program went on, or raised an error of another shape.
A failed bind, connect, send or lookup carries Node's system error. A dgram 'error' had the message only and now has errno, code, syscall, address and port; a send callback's lookup error and the HTTP client's connect and lookup errors gain errno, syscall and the address and port or the hostname. The parser that rebuilds the error from the message the runtime writes moves from scr_net.c to scr_system_error_event.h, one function for the net, dgram and http units, with the lookup form added.
Calls that went on silently:
- socket.write(data, callback) then destroy() before the connection passed null to the callback; it passes ERR_SOCKET_CLOSED_BEFORE_CONNECTION.
- server.close(callback) on a closed server never ran the callback; it runs with ERR_SERVER_NOT_RUNNING.
- readable.unshift() after 'end' delivered the chunk; the stream reports ERR_STREAM_UNSHIFT_AFTER_END_EVENT.
- socket.setEncoding() on an HTTP server's connection was accepted; it throws ERR_HTTP_SOCKET_ENCODING.
- server.listen() on a listening server was ignored. Compiled code does not check the listen entry points for an exception, so the runtime ends the process with Node's ERR_SERVER_ALREADY_LISTEN as an uncaught error (exit 1); the limitations page says it cannot be caught.
Errors with another code, class or message:
- response.pipe() threw "res.pipe is not a function"; it throws ERR_STREAM_CANNOT_PIPE.
- sign.sign() and crypto.sign() without a key raise ERR_CRYPTO_SIGN_KEY_REQUIRED instead of ERR_INVALID_ARG_TYPE.
- A JWK that does not make a key names its type ("Invalid JWK RSA key").
- Key material no decoder accepts has library, reason and opensslErrorStack: the reported error, preceded by what each decoder reports when the outer DER element of an armored private key decides it (a wrong tag, a length that cannot be read, a length past the data).
- fs.cpSync no longer quotes the paths of ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY and of the symlink form of ERR_FS_CP_EINVAL.
Six programs pin the codes by family, tests/corpus/compat-errors-{net,stream,http,crypto,core,worker}.mjs: each a table of triggers printing class, name, code and message, and the own property names where the shape is the point.
Co-Authored-By: Claude Code <noreply@anthropic.com>
There was a problem hiding this comment.
Additional findings
-
packages/compiler/src/frontend/lowering/node-value-reads.ts:245— Object.defineProperty can replace a stored process object's pid getter, making held.pid disagree with direct process.pid reads. -
packages/compiler/src/frontend/lowering/node-value-reads.ts:213-216— Passing process through an untyped helper drops its IPC members in forked children and mainModule in CommonJS entry points, causing reads and feature checks to incorrectly report these properties as absent.
9872-child-stream-pause-max-buffer.ts printed the length of the output the exec callback gets after the limit ended a child whose stream was paused. Under Node that length depends on how many chunks the paused stream held: the chunks after the cut still reach exec's listener, whose arithmetic then keeps part of a chunk it should drop. CI's sanitized shard 9/20 on d2f2276 saw 59,392 characters from Node where every other run saw 1,024 (an 8,192-byte chunk followed by a 65,536-byte one gives exactly that). The program now prints that the limit was reached, the error, and that the other stream stayed empty (the command after the writer never ran), which hold in every run, and the lengths only where the output is under the limit. Co-Authored-By: Claude Code <noreply@anthropic.com>
A JavaScript source that stores `process` (or `performance`) gets one object with Node's members (#3286), while the spelled members (`process.pid`, `process.nextTick(f)`) are compiled where they are written and never read that object. Its members were configurable and its function members writable, so `Object.defineProperty(held, "pid", { value: 1 })`, `held.nextTick = f` or a new key changed the object and nothing else: `held.pid` and `process.pid` disagreed where Node prints the new value for both. The object is now frozen when it is built. A redefinition, a replaced function member and a new key each throw a TypeError where they are made; a write to a data member is still refused by name by its setter. The members' own corpus programs are unchanged (386 programs around globals and process pass). tests/harness/process-features-frozen.test.ts pins it natively beside the features object (Node accepts the changes, so it is not a differential program); the limitations page says the stored object is frozen. Found by the review bot on #3353. Co-Authored-By: Claude Code <noreply@anthropic.com>
No conflict. Main brings trains perf-2 and cli-2, #3029 and #3380; lowerer.ts keeps main's checkVariadicFunctions call at the start of finishModule beside the train's code. Regenerating the refusal baseline and the compatibility files on the merged tree changes nothing. Co-Authored-By: Claude Code <noreply@anthropic.com>
One conflict, in expressions/typeof.ts: #2567 adds a `typeof require` test for an ES module of any file kind after the __dirname test that the train (#3286) replaced by commonJsBindingInEsModule. Resolved as the integrator recorded: the train's test first, then #2567's require block, which also covers TypeScript ES modules. Regenerating the refusal baseline and the compatibility files on the merged tree changes nothing. Co-Authored-By: Claude Code <noreply@anthropic.com>
|
New head The review bot, review body of 00:57 UTC:
Real, in a narrower form than written. Under Node 24.22.1 and as a binary (Linux x64 Sandbox, on
So the silent case needed a process object that went through an untyped value, and then Fixed in A race under Node in Merged: main Run on the merged tree (Sandbox, Linux x64): on Not run on this head: the corpus lanes outside the programs named, the native bootstraps, macOS, Windows. |
|
One red test to expect in this head's CI that is main's, found in my Sandbox on |
|
New head |
|
Hold: please do not merge yet. A wider sandbox round on this head found a leak that CI's sampled Effect shard does not cover: 🤖 Generated with Claude Code |
… a process object it holds A JavaScript source that holds `process` as a value holds process.getBuiltinModule with it. Building that loader's module inventory lowered worker_threads.SHARE_ENV, which marks the program as one that uses workers, so every program that merely held `process` was compiled in workers mode. The inventory no longer decides that. Its SHARE_ENV entry is built as the refusal every member the loader's module object does not serve already has (`native builtin 'worker_threads.SHARE_ENV' is not supported through process.getBuiltinModule yet [SC2020]`, thrown where the member is read), and once lowering has finished a program that uses workers gets the symbol instead: the value the spelled member reads. A program uses workers when its own source spells a worker API or spells process.getBuiltinModule, as before; the loader reached as a member of a stored process object marks nothing. A program that spells the loader or a worker API lowers to the same IR as before. A program that only holds `process` loses the workers mark. tests/harness/held-process-workers.test.ts pins the module record and the refusal; tests/corpus/held-process-loader-*.mjs pin, against Node, the modules the held loader answers and SHARE_ENV in a program that uses the worker API. Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
… chosen Main's import elision work reads the module a missing named export was requested from to tell whether the name is one of its types. On this branch that module can be a builtin module, named by a string, since the named imports of builtin modules are checked too. The two merge without a conflict and do not build together. The result type now says both, and the hint about types is given only for a source file: a builtin module has none to ask. Co-Authored-By: Claude Code <noreply@anthropic.com>
|
New head What the hold was about. A wider Sandbox round on What changed (
Tests: The leak itself is main's and is not fixed here. It needs no commit of this branch: on main, a program that uses workers and reaches the statics lookup of builtin values leaves one 25-byte string live at exit under the sanitizer (the key string made in The merge of main. One full round in Vercel Sandboxes on this head's tree (against main
Main has two test-only commits newer than the merged one; a trial merge shows no conflict. |
|
The hold of 03:47 UTC is lifted. Its cause is answered on head |
Conflicts, each resolved by keeping both sides: - packages/compiler/src/ir/builtin-never-throw.ts: each side adds rows at one place of the list (main's promise.adopt, this branch's http, http2, stream, process and readable rows). - packages/compiler/src/diagnostics/diagnostic.ts: each side adds diagnostics at one place (main's builtinMemberWriteDiag, this branch's nodeValueDiag and nodeObjectMemberWriteDiag). - internal/compatibility/generated/node-v24-internal.json and docs/src/generated/node-v24-compatibility.json: regenerated from the merged sources (pnpm manifest, generate.mjs --local), not merged by hand. Co-Authored-By: Claude Code <noreply@anthropic.com>
Main now implements module.enableCompileCache and module.flushCompileCache with differential evidence. This branch's fixture of Node APIs that are refused by name still imported and called both, and its compatibility manifest still declared them not implemented on that fixture's evidence. The fixture keeps the module members that are still absent (register, findPackageJSON), its snapshot and order baseline are recorded again, and the stale declaration is removed: the rows keep main's claim, a tested subset. Regenerating the ledger and the public matrix changes neither file. Co-Authored-By: Claude Code <noreply@anthropic.com>
|
New head What main brought in: the landing rollup #3395 (trains lang 2, startup 2, islands 3), train cli 17 (#3402, import elision decided once per load) and train cli 13b (a built dictionary as a generic function's result, The four conflicts, and how each was resolved (
One thing the two sides only get wrong together ( Checked on this head's tree in a Sandbox before the push: the build (typecheck); regeneration leaves the generated files as committed; the builtin member census; Running beside CI, results follow here: the stdio, child_process and stream programs in both lanes, order parity, the docs gate, the island conformance lane and the island value matrix; and one round against the merged main with the zero-cost gate over the whole corpus (the 300-id list) and both native bootstraps, which CI does not run for this change. The full round on the previous head ( Main has two trains newer than the merged commit (perf 4, cli 12); a trial merge shows no conflict. |
|
The Sandbox round on
CI on this head: "lint and format" and all 54 Linux jobs are green. One red job, |
|
The island conformance lane and the island value matrix on |
Main gained train node-1 (vercel-labs#3353) and train cli-18. No conflict with lang-3 (vercel-labs#3405) or lang-4 (vercel-labs#3410), which this branch is cut on. Co-Authored-By: Claude Code <noreply@anthropic.com>
Current head
c4023df963(2026-11-08 07:52 UTC): holdingprocessno longer makes a workers program; mainf11ead56d2mergedprocessas a value holdsprocess.getBuiltinModulewith it (Read an unlowered Node value in a JavaScript source by Node's own kind, never as a string #3286). Building that loader's module inventory loweredworker_threads.SHARE_ENV, which since Support the Worker env option and worker_threads.SHARE_ENV #3023 marks a program as one that uses workers, so a program that merely heldprocesswas compiled in workers mode. Now the inventory decides nothing: itsSHARE_ENVentry is the refusal every unserved member of the loader's module object has (native builtin 'worker_threads.SHARE_ENV' is not supported through process.getBuiltinModule yet [SC2020], thrown where the member is read), and a program that uses workers gets the symbol in that entry once lowering has finished. A program uses workers when its own source spells a worker API or spellsprocess.getBuiltinModule, as on main; the loader as a member of a storedprocessobject marks nothing. Programs that spell the loader or a worker API keep their IR.http-api.ts,http-response.ts) had become workers programs this way and reached a leak that workers programs have on main too: one 25-byte string live at exit under the sanitizer (scr_bv_owner_of,scr_builtin_values.c). The leak is main's own, is tracked as a defect of main with its own owner, and is not fixed here; with this change the fixtures are not workers programs and pass.tests/harness/held-process-workers.test.ts;tests/corpus/held-process-loader-modules.mjsandheld-process-loader-share-env.mjs.6da2be6a1bmerged. No conflict, and one thing that did not build together: main's import elision hint reads a missing export's module as a source file, and Answer five Node value reads that compiled programs got silently wrong (in globalThis, execArgv, typeof exports, zlib namespace) #3337 lets it be a builtin module's name.c0c26b5bf1lets the result say both.6da2be6a1b): typecheck passes; lanes plain 24,419 passed, 0 failed, sanitized 20,996 passed, 0 failed (98 jobs, every Effect shard); both native bootstraps pass; the gate over the whole corpus (5,945 programs): 4,700 identical, 297 on the list, 22 new programs of this branch, 290 that build on neither side, 635 runtime-only (the 632 below plus three programs main gained since, each in a unit already listed; all 635 attributed), and one row for a program main added after the last gate round (9405-global-symbol-state.mjs: it usesin globalThis, Answer five Node value reads that compiled programs got silently wrong (in globalThis, execArgv, typeof exports, zlib namespace) #3337's table; equal to Node in both lanes). Island conformance lane and island value matrix: 666 passed, 0 failed, lists unchanged. Generated files unchanged by regeneration;pnpm node-compat:check, lint, format,pnpm test:ts7 --baselines-only, order parity, docs gate pass.c0c26b5bf1: 298 allowed, 4,700 identical, 635 runtime-only, 22 new programs of this branch, 290 that build on neither side.f11ead56d2was merged (b43afb9be9; the landing rollup of lang 2, startup 2 and islands 3, cli 17, cli 13b). Four conflicts: two lists where each side adds at one place (both kept) and the two generated compatibility files (regenerated, not merged by hand).c4023df963takesmodule.enableCompileCacheandmodule.flushCompileCache, which main now implements, out of this branch's fixture of absent Node APIs and removes the stale "not implemented" declaration. On that tree before the push: build, regeneration unchanged, compat check, lint, format, baselines, 3,040 unit, guard and diagnostics tests.What lands
The compiler builds itself 7 to 8% faster with this train (self-compile -8.15%, -7.18%, -7.03%, -6.88%, -8.60%, -8.28% in six gate rounds, every interval below zero). #3174 makes
pause()on a child's stream compile, the compiler's own source calls it, and so its build now makes one lowering pass where main aborts a pass and lowers again.Twelve open pull requests of the Node.js library area, and the four that were merged into their branches, as one rollup: the compatibility ledger with its differential evidence,
node:httpclient events,node:http2, Node's error codes, stream predicates and the pipe lifecycle,child_processlimits and stream flow,processvalues, and what a JavaScript source reads for a Node value native code does not have. The members are merged, not rebased; their commits are unchanged. Three review-bot findings on members are fixed here, each with a test.Members
asyncDispose).session.ping,connecting, packed settings).'information'on a ClientRequest. Its branch holds the merged Add the client 'connect' event, make agent: false Node's throwaway agent, and close the rest of the http rows #3272 (the client'connect'event,agent: falseas Node's throwaway agent).'unpipe'and'pause'through the pipe lifecycle as Node does.maxBufferin child_process and close the pipes when a capture is killed.process.featureswith booleans that describe the compiled binary.pause(),resume(),isPaused()andreadableFlowingon the child stream handle. It also removes a thrown-away pass from the compiler's own build (the gate's self-compile row below).in globalThis,execArgv,typeof exports, the zlib namespace).NODE_NO_WARNINGS, port validation,process.disconnect.Each member's own proof (zero-cost gate, lanes, bootstraps where it ran them) is in its pull request; the train's round below is on the merged tree.
Not here: #3320 and #3321 were merged to main on their own while the train was being built. #3189 (process state shared with Node islands) changes the island bridge and goes with the island trains. #3340 has two unanswered review threads and waits for the next train.
What the train adds to the members
ping()on an http2 session held as an untyped value answeredtruealways. Node answerstruefor a PING that left,falseover the limit of ten unanswered pings, and nothing while connecting or afterclose().tests/corpus/compat-http2-ping-settings.mjs.request.on("connect", …)on a ClientRequest held as an untyped value threw "not supported yet" where Node runs the listener. It now installs the same tunnel listener as the typed path.tests/corpus/compat-http-client-connect-untyped.mjsand.ts.'prefinish'on an HTTP/2 compatibility response ran on past the registration: the emitter made the call without the check that delivers its error.tests/harness/http2-prefinish-refusal.test.ts(native only: Node registers the listener and never calls it).scr_stream.c(Answer node:stream's state predicates, state getters and asyncDispose #3246's predicates, Emit 'unpipe' and 'pause' through the pipe lifecycle as Node does #3195's pipe lifecycle),builtins/process.ts, two test files, and four import lines against main's shared peel helpers (Peel expression wrappers through one module with a function per set #3254) and record sources.lower-stmts.tsimportedCOMMONJS_MODULE_BINDINGStwice (Read an unlowered Node value in a JavaScript source by Node's own kind, never as a string #3286 and Answer five Node value reads that compiled programs got silently wrong (in globalThis, execArgv, typeof exports, zlib namespace) #3337), and main'snode:seaentry of the builtin member census lacked the two fields Read an unlowered Node value in a JavaScript source by Node's own kind, never as a string #3286 adds to every entry (regenerated withscripts/gen-builtin-module-members.mjs; only that entry changes).stream.asyncDispose, which runs a destroy hook of the program's.pnpm manifestleaves the surface manifest unchanged;generate.mjs --localwrites the internal ledger and the public matrix).Compatibility ledger
pnpm node-compat:checkpasses: 4,996 source rows, 3,782 public matrix rows, Node 24.22.1. Backlog rows (pnpm node-compat:backlog), main before the train and the train:verify-gapimplementaudit-partialreplace-refusalclassifyRows leave
verify-gapbecause they are now proven one way or the other: with differential evidence (audit-partial) or as a verified absence that is ready to implement (implement).Validation
One full round in Vercel Sandboxes on the merged tree (
57826ed72cagainst main23f1b176f3plus #3346's commit), then the merge of main below.packages/compilerandpackages/clion a fresh tree.functional-pipeline+3.5% [+0.5%, +8.9%], whose executable is byte-identical to main's).pnpm lint,pnpm format:check,pnpm test:ts7 --baselines-only,pnpm node-compat:check, the builtin guard withpackages/compiler/src/ir(44 files, 716 tests), and the docs gate (NEXT_DIST_DIR=.next-check pnpm check: 5 unit and 25 route tests) pass.pnpm manifest,generate.mjs --localandscripts/gen-builtin-module-members.mjsreproduce the committed generated files byte for byte.ef8d82ef7f(119 commits after the round's base) merged once, four conflicts: three sites where main now finds a lifted function through an index inside blocks Read an unlowered Node value in a JavaScript source by Node's own kind, never as a string #3286 had already moved into a helper (the train's side kept, and Read an unlowered Node value in a JavaScript source by Node's own kind, never as a string #3286's three searches of that list now ask the index, which main's new guard requires), and one test file where both sides add tests (all kept). The checks in the previous item ran on that merged tree; the full round did not.Zero cost: what changes, and why each row is a member's
The gate ran with
--gate-corpus=allover 5,462 programs: 4,537 identical, 119 fail to build on both sides, and 809 changed. Every changed row is attributed to a member; none is unexplained. The runtime objects that differ are exactly the fourteen units the members edit, plus three new ones.702 rows have identical IR and LLVM and change through a runtime unit a member edits. These are the members' recorded one-time exceptions (
GATE-EXCEPTIONS.md: #3195, #3215, #3246, #3276, #3328) and #3258's allowed set:execArgvThree benchmarks are among them, as the members recorded:
http-api,line-reader,stream-buffered-drain. No fully typed benchmark changes.106 rows and the compiler itself differ in IR or LLVM, each by a member's own lowering change and on that member's allow list: #3286 (50: the global object as a value, globals by a computed name), #3246 (13: an untyped stream's key table), #3272 (15:
agent: false), #3328 (12: a computedlistenport,process.disconnect()), #3215 (9:maxBuffer), #3292 (8:http2), #3191 (2), #3258 (1), andself/cli(#3174, #3215); a row can be on two lists.2577-builtin-global-destructuring.jsgrows 19.5% (#3286 recorded 20.5%); nothing else grows more than 2.6%.What the round's own verdict says: it ran with the 155 ids a first gate-only round had named, and reports FAIL on 90 rows that are not on that list. They are the fix for #3258's
'connect'thread, merged after that first round: the fix adds one function to the HTTP unit's handle dispatch, which 164 http executables link (+416 to +728 bytes each), and in 90 of them it is the first caller ofscr_dyn_new_bytesfrom an unedited unit, which the gate reports asdiffersand not as runtime-only. IR and LLVM are identical on all 90 and none gains a dynamic-tier symbol. A gate-only round with all 245 ids on the pushed tree is running; its result follows in a comment.Allow list: 245 program ids (
results/deepseek-fleet/trains/node-1/gate-allow-programs-2.txt),--gate-max-size-growth=20. Row-by-row attribution:trains/node-1/attr-1/, summary intrains/node-1/VALIDATION.md.Review findings fixed on the train after the first push (2026-11-07 22:40 UTC)
The review bot left two findings in review bodies; both were real, both are fixed, each with a corpus program, and the PR comment of that time quotes them with the evidence.
2e7af381c2: a paused output stream of anexec/execFilechild was read for the callback with no limit (Lower pause(), resume(), isPaused() and readableFlowing on the child stream handle #3174 holds a paused stream's listeners back, Enforce maxBuffer in child_process and close the pipes when a capture is killed #3215 countsmaxBufferin a listener): 766 MB held where Node holds 65 MB. The limit is now counted when a held chunk joins the stream's queue (2 MB), in the flow build of the child unit only.tests/corpus/9872-child-stream-pause-max-buffer.ts.67910bc545: a computedmaxBufferwas evaluated after every other launch value, so{ maxBuffer: f(), cwd: g() }rangfirst, a limit read from a local saw a later member's assignment, and the later member's exception won. The callback, synchronous and promisified launches now evaluate what they read in source order whenevermaxBufferis computed.tests/corpus/9871-child-options-evaluation-order.ts.Known limits, stated here and tracked for an owner:
exec/execFilechild with a callback does not block the child belowmaxBuffer, and the maxBuffer error comes at the limit where Node gives it afterresume()(limitations page, "Child process, workers"). What the parent holds is bounded bymaxBuffer.maxBufferevaluates its other options in a fixed order, not as written (execFile(f, a, { cwd: g() }, make())runsmakefirst).The head also carries
fix/main-red-weak-keys(#3371) as its own commits.🤖 Generated with Claude Code