Skip to content

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

Merged
cramforce merged 139 commits into
mainfrom
train/node-1
Nov 8, 2026
Merged

cramforce merged 139 commits into
mainfrom
train/node-1

Conversation

@cramforce

@cramforce cramforce commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Current head c4023df963 (2026-11-08 07:52 UTC): holding process no longer makes a workers program; main f11ead56d2 merged

  • The change. A JavaScript source that holds process as a value holds process.getBuiltinModule with 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 lowered worker_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 held process was compiled in workers mode. Now the inventory decides nothing: its SHARE_ENV entry 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 spells process.getBuiltinModule, as on main; the loader as a member of a stored process object marks nothing. Programs that spell the loader or a worker API keep their IR.
  • Why. Two sanitized Effect fixtures (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. tests/harness/held-process-workers.test.ts; tests/corpus/held-process-loader-modules.mjs and held-process-loader-share-env.mjs.
  • Main 6da2be6a1b merged. 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. c0c26b5bf1 lets the result say both.
  • One full round on this head's tree (Vercel Sandboxes, against main 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 uses in 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.
  • The gate-only round with that program on the list passed on c0c26b5bf1: 298 allowed, 4,700 identical, 635 runtime-only, 22 new programs of this branch, 290 that build on neither side.
  • Then main f11ead56d2 was 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). c4023df963 takes module.enableCompileCache and module.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.
  • Still running when this was written: CI on this head; in Sandboxes, the touched programs, order parity, docs gate, island conformance lane and value matrix, and one round against the merged main with the gate and both native bootstraps (results are posted as comments).

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:http client events, node:http2, Node's error codes, stream predicates and the pipe lifecycle, child_process limits and stream flow, process values, 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

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

Compatibility ledger

pnpm node-compat:check passes: 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:

action main train
verify-gap 1,937 799
implement 85 361
audit-partial 1,397 2,070
replace-refusal 12 9
classify 2 2
total 3,433 3,241

Rows leave verify-gap because 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 (57826ed72c against main 23f1b176f3 plus #3346's commit), then the merge of main below.

  • Typecheck: tsc passes in packages/compiler and packages/cli on a fresh tree.
  • Lanes: plain 22,918 passed, 0 failed; sanitized 19,656 passed, 0 failed (92 jobs).
  • Native bootstraps: plain passes (in the plain lane); sanitized passes (three phase jobs in the sanitized lane).
  • bench-runtime: 66 workloads, geomean +0.3%; 64 neutral, 1 faster, 1 slower (functional-pipeline +3.5% [+0.5%, +8.9%], whose executable is byte-identical to main's).
  • Compile time (gate): benchmarks +0.35% [95% CI -0.19%, +1.11%], within tolerance; the compiler's own source -7.18% [-8.58%, -6.25%] (Lower pause(), resume(), isPaused() and readableFlowing on the child stream handle #3174 removes a thrown-away pass); fixtures +1.50% [+0.29%, +2.70%], which the gate calls inconclusive at one pair per fixture (a gate-only round on the same members an hour earlier measured +0.24% [-1.42%, +1.88%]).
  • On the tree as pushed, in a Sandbox: pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only, pnpm node-compat:check, the builtin guard with packages/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 --local and scripts/gen-builtin-module-members.mjs reproduce the committed generated files byte for byte.
  • Merge of main after the round: main 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.
  • Not run: Test262 (no member touches it); the island conformance lane (no member here changes island code); a second full round after the merge of main.

Zero cost: what changes, and why each row is a member's

The gate ran with --gate-corpus=all over 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:

family rows size members
stream unit 284 0 to +6,544 B, at most +1.26% #3246, #3276, #3195
http unit 169 +144 to +10,008 B, at most +1.41% #3258, #3272, #3276, #3292
process warnings and uncaught dispatch 71 0 to +192 B #3328
child_process 50 +280 to +3,448 B, at most +0.77% #3215, #3174
net and dgram 39 -8 to +8,648 B, at most +1.63% #3276
fs copy helper 31 -128 to -64 B #3276
two families at once, pki, execArgv 58 at most +1.40% as above, #3337

Three 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 computed listen port, process.disconnect()), #3215 (9: maxBuffer), #3292 (8: http2), #3191 (2), #3258 (1), and self/cli (#3174, #3215); a row can be on two lists. 2577-builtin-global-destructuring.js grows 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 of scr_dyn_new_bytes from an unedited unit, which the gate reports as differs and 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 in trains/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 an exec/execFile child 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 counts maxBuffer in 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 computed maxBuffer was evaluated after every other launch value, so { maxBuffer: f(), cwd: g() } ran g first, 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 whenever maxBuffer is computed. tests/corpus/9871-child-options-evaluation-order.ts.

Known limits, stated here and tracked for an owner:

  • A paused stream of an exec/execFile child with a callback does not block the child below maxBuffer, and the maxBuffer error comes at the limit where Node gives it after resume() (limitations page, "Child process, workers"). What the parent holds is bounded by maxBuffer.
  • On main, and unchanged here: a launch with no computed maxBuffer evaluates its other options in a fixed order, not as written (execFile(f, a, { cwd: g() }, make()) runs make first).

The head also carries fix/main-red-weak-keys (#3371) as its own commits.

🤖 Generated with Claude Code

cramforce and others added 30 commits November 6, 2026 19:49
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>

@vercel vercel Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings

  1. 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.

    Fix on Vercel

  2. 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.

    Fix on Vercel

cramforce and others added 3 commits November 7, 2026 17:05
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>
@cramforce

Copy link
Copy Markdown
Contributor Author

New head 90473aaa20: main 793159ec8a merged, the review bot's finding on a held process object answered and fixed, and a race under Node in one of the train's own corpus programs removed.

The review bot, review body of 00:57 UTC:

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.

Real, in a narrower form than written. Under Node 24.22.1 and as a binary (Linux x64 Sandbox, on 9343fcafec):

program Node binary before
const p = process; Object.defineProperty(p, "pid", { value: 1 }) changes it refused at run time, by name: SC2020 (loud)
held.title = "x" on a held object (held = pass(process)) assigns refused by name: SC2020 (loud)
Object.defineProperty(held, "pid", { value: … }) on that held object, also ppid, platform, arch, version, execPath, title the object and process.pid both change the object changed, process.pid did not (silent)

So the silent case needed a process object that went through an untyped value, and then Object.defineProperty, a replaced function member or a new key. An ordinary program does not do that; a test helper that mocks process.platform through a parameter does.

Fixed in d85dc85615, by the rule used for process.features, applied to the whole object: the process (and performance) object a JavaScript source holds is frozen when it is built, because the spelled members (process.pid, process.nextTick(f)) are compiled where they are written and never read that object. 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. Pinned natively, plain and sanitized, by a second test in tests/harness/process-features-frozen.test.ts; the limitations page says the stored object is frozen. The members' own programs are untouched: 386 corpus programs around globals and process pass. The full behaviour (the spelled reads follow the object in a program that redefines a member) has an owner-needed row in the fleet queue, one rule for this and for process.features.

A race under Node in tests/corpus/9872-child-stream-pause-max-buffer.ts, the program added with the paused-stream fix (67006efe25). CI's test (Linux, san, 9/20) on d2f227676a failed on it, and the difference was Node's: the callback's output was 59,392 characters where every other run gave 1,024. When a paused stream holds more than one chunk, Node hands the later ones to exec's listener after the cut, and the listener's arithmetic then keeps part of a chunk it should drop (an 8,192-byte chunk followed by a 65,536-byte one gives exactly 59,392). The program now prints what Node does not vary: the error, that the limit was reached, and that the command after the writer never ran. The binary's behaviour is unchanged.

Merged: main d4c82a5c39 (trains perf-2 and cli-2, #3029, #3380 and others; no conflict; lowerer.ts keeps main's new checkVariadicFunctions call at the start of finishModule), then main 793159ec8a (#2567 and #3381). 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 #3286 replaced by commonJsBindingInEsModule; the train's test stays first and #2567's require block follows it (it also covers TypeScript ES modules). Regenerating the refusal baseline and the compatibility files on each merged tree changes nothing.

Run on the merged tree (Sandbox, Linux x64): on 90473aaa20: the build; the regeneration checks, node-compat:check, the member census; pnpm lint and pnpm format:check; the three generated-source checks; baselines. On the tree one merge earlier (91eb0c75ea): 2,935 tests of the compiler's unit tests, the guards, the diagnostics suite, the island weak-keys tests and the two new native-only tests, none failed. On the tree before that (1755d46723, with islands-1): 866 stdio, child_process, stream and exit programs plain and 288 sanitized, the island conformance lane and the island value matrix (656 passed), none failed. The unit tests and the typeof, require and import programs on 90473aaa20 were still running in the Sandbox when this was posted; I post here only if one fails.

Not run on this head: the corpus lanes outside the programs named, the native bootstraps, macOS, Windows.

@cramforce

Copy link
Copy Markdown
Contributor Author

One red test to expect in this head's CI that is main's, found in my Sandbox on 90473aaa20: packages/compiler/src/frontend/outer-expressions.test.ts, "only outer-expressions.ts defines a function that peels wrappers off an expression", lists frontend/binding-writes.ts#stripValueWrappers. #2567 added that function on main (551639137b); the file is identical on main 793159ec8a and in this head, and the train does not touch it. Not fixed here. The rest of that stage passed: 2,937 of 2,938 unit, guard and diagnostics tests.

No conflict. Main brings #3383 and #3385: the peel guard that #2567 turned red on main is green again, and binding-analysis.ts is formatted.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@cramforce

Copy link
Copy Markdown
Contributor Author

New head 085706f145: main 659a1caaa5 merged (no conflict). It brings #3383 and #3385, which fix the peel guard that #2567 had turned red on main; that guard was the one red test of the previous head's CI (outer-expressions.test.ts:251, in Linux san 11/20 and plain 13/20), and with a lane shard red CI skips both native bootstraps, so this head exists to let them run. On this tree in the Sandbox: the build, pnpm lint, pnpm format:check, and the guard tests (peel helpers, throw decisions, lifted-function index, unit journal, option cache keys, pack matrix, define sites, code quality, CI policy: 142 of 142). On the tree before this merge (90473aaa20): 2,937 unit, guard and diagnostics tests with only that guard failing, 1,250 typeof, require, import and module programs in both differential suites, order parity 138 of 138, docs gate. The island conformance lane and the value matrix are running in the Sandbox on this tree; I post here only if one fails.

@cramforce

Copy link
Copy Markdown
Contributor Author

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: tests/harness/effect4.test.ts, fixtures http-api.ts and http-response.ts, sanitized lane only: stdout equals Node's, but the reference-count audit ends the program with status 99 ("1 heap string(s) live at exit"). The same fixture passes on main at the train's base and fails on the train, so it is the train's. It is being bisected and fixed in the train; one push follows, and this hold is lifted with it.

🤖 Generated with Claude Code

cramforce and others added 3 commits November 7, 2026 21:39
… 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>
@cramforce

Copy link
Copy Markdown
Contributor Author

New head c0c26b5bf1: the hold is answered. A program that only holds process is no longer compiled as one that uses workers; main 6da2be6a1b merged.

What the hold was about. A wider Sandbox round on 085706f145 found two sanitized Effect fixtures (tests/harness/effect4.test.ts: http-api.ts, http-response.ts) ending with one heap string live at exit. The cause on this branch: a JavaScript source that holds process as a value holds process.getBuiltinModule with it (#3286), building that loader's module inventory lowered worker_threads.SHARE_ENV, and since #3023 that member marks the program as one that uses workers. So every program that merely held process was compiled in workers mode, and the two fixtures reached a leak that workers programs have on main as well.

What changed (9f9283a1ed). Whether a program uses workers is decided by what its own source does, not by an object it holds.

  • The inventory's 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. When lowering has finished, a program that uses workers gets the symbol in that entry instead, the value the spelled member reads.
  • A program uses workers when its source spells a worker API or spells process.getBuiltinModule, as on main. The loader reached as a member of a stored process object marks nothing.
  • No new builtin, diagnostic code or runtime code. A program that spells the loader or a worker API lowers to the IR it had: the three programs on main that spell the loader are byte-identical to main in the gate.
  • Nothing else in that inventory selects a whole-program mode. Read: every site that sets one of the lowerer's mode flags. Measured: the IR of a program that only holds process has no optional module key at all.

Tests: tests/harness/held-process-workers.test.ts (the module record of a holder; the refusal by name where SHARE_ENV is read through a held object, caught and uncaught; the spelled loader with a computed id), and two differential programs, tests/corpus/held-process-loader-modules.mjs and held-process-loader-share-env.mjs.

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 scr_bv_owner_of, scr_builtin_values.c). It is tracked as a defect of main with its own owner.

The merge of main. 6da2be6a1b merges without a conflict and did not build: main's import elision work (9f03224b17) reads the module a missing named export was requested from as a source file, and on this branch it can be a builtin module's name (#3337). c0c26b5bf1 lets the result say both and gives the hint about types only for a source file.

One full round in Vercel Sandboxes on this head's tree (against main 6da2be6a1b, 53.5 minutes):

  • Typecheck: passes.
  • Lanes: plain 24,419 passed, 0 failed; sanitized 20,996 passed, 0 failed (98 jobs). That is every Effect shard, where CI samples one of sixteen; the two fixtures pass and their IR has no workers key again.
  • Native bootstraps: both pass (plain: commands and rebuild; sanitized: commands, rebuild-emit, rebuild-frontend). CI runs them only for changes to the bootstrap paths, so this round is their evidence for this head.
  • Zero-cost gate over the whole corpus, 5,945 programs: 4,700 identical; 297 on the list of programs that use what the members change; 22 that are this branch's own new programs; 290 that fail to build on both sides; 635 runtime-only; 1 differs.
    • Runtime-only: 632 in the round on e311ea7979, 635 now. None of the 632 goes away with this change: those rows have identical IR, and the workers mark is part of the IR. The three new rows are programs main gained since (8859-fsp-access-box.ts, 9643-variadic-builtin-callbacks.ts, 8786-stdout-write-bind-stream-slots), each linking a changed function of a unit already listed in the comment of 00:08 UTC (the fs copy helper, stream, child_process with stream). Every one of the 635 is attributed to a unit a member edits.
    • The one differs row is corpus/9405-global-symbol-state.mjs, added to main by Focus the test programs for tuple unions, global symbol state and project declarations #3393 after this branch's last gate round. It uses in globalThis; Answer five Node value reads that compiled programs got silently wrong (in globalThis, execArgv, typeof exports, zlib namespace) #3337 takes the names Node's global object does not have out of that table (two string literals fewer, 208 bytes smaller), and the program equals Node in both lanes. It is on the list from now; a gate-only round with it is running and its verdict follows here.
    • Compile time: the compiler's own source -7.62% [95% CI -9.58%, -6.34%]; benchmarks +0.75% [-0.54%, +2.13%] and fixtures +0.09% [-1.86%, +2.05%], both inside their noise at one pair.
  • On the same tree in a second Sandbox: the island conformance lane and the island value matrix, 666 passed, 0 failed, both lists unchanged; regenerating leaves every generated file as committed; pnpm node-compat:check, lint, format, the generated IR checks, pnpm test:ts7 --baselines-only, order parity 142 of 142, the docs gate.

Main has two test-only commits newer than the merged one; a trial merge shows no conflict.

@cramforce

Copy link
Copy Markdown
Contributor Author

The hold of 03:47 UTC is lifted. Its cause is answered on head c0c26b5bf1 (details in the comment above): a program that only holds process is no longer compiled as one that uses workers, both Effect fixtures (http-api.ts, http-response.ts) pass in the sanitized lane with the workers mark off, and the full Sandbox round ran on this head's tree: both lanes with every Effect shard, both native bootstraps, the gate over the whole corpus, the island conformance lane and the value matrix.

cramforce and others added 2 commits November 7, 2026 23:21
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>
@cramforce

Copy link
Copy Markdown
Contributor Author

New head c4023df963: main f11ead56d2 merged (the branch had gone conflicting 19 minutes after the last push).

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, node:module's compile cache calls).

The four conflicts, and how each was resolved (b43afb9be9):

  • packages/compiler/src/ir/builtin-never-throw.ts: each side adds rows at one place of the list. Both kept: 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. Both kept: 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. A second regeneration on the committed tree changes neither file.

One thing the two sides only get wrong together (c4023df963). This branch's fixture of Node APIs that are refused by name (tests/diagnostics/compat-absent-node-apis.ts, from #3140) imported and called module.enableCompileCache and module.flushCompileCache, and the branch's compatibility manifest declared both not implemented on that fixture's evidence. Train cli 13b implements them with differential programs. The merge built, and two tests failed on it: the fixture's snapshot and its order baseline (18 diagnostics where 20 were recorded). The two names are out of the fixture, which keeps the node:module members that are still absent (register, findPackageJSON); the snapshot and the baseline are recorded again; the stale declaration is removed, so the rows carry main's claim, a tested subset. The claim got narrower in no row and wider in none: the ledger and the public matrix are byte-identical before and after.

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; pnpm node-compat:check; lint; format; the generated IR checks; pnpm test:ts7 --baselines-only; 3,040 unit, guard and diagnostics tests in 347 files, 0 failed.

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 (c0c26b5bf1: both lanes with every Effect shard, both bootstraps, gate) is in the comment of 06:57 UTC; the gate-only round that followed it with 9405-global-symbol-state.mjs on the list passed: 298 allowed, 4,700 identical, 635 runtime-only, 22 new programs of this branch, 290 that build on neither side; benchmarks' compile time +0.25% [-1.07%, +1.58%], the compiler's own source -7.49% [-8.91%, -5.04%].

Main has two trains newer than the merged commit (perf 4, cli 12); a trial merge shows no conflict.

@cramforce

Copy link
Copy Markdown
Contributor Author

The Sandbox round on c4023df963 against the merged main f11ead56d2 (53 minutes), and CI.

  • Typecheck: passes.
  • Lanes: plain 24,707 passed, 0 failed; sanitized 21,279 passed, 0 failed (88 jobs, every Effect shard).
  • Native bootstraps: both pass on this head (plain: commands and rebuild; sanitized: commands, rebuild-emit, rebuild-frontend).
  • Zero-cost gate over the whole corpus: 4,742 identical; 298 on the list; 22 new programs of this branch; 315 that build on neither side; 635 runtime-only, the same rows as on the previous head; and one row for a program the merged main added, corpus/builtin-member-writes-unread.cjs. It has the same cause as 9405-global-symbol-state.mjs before it: its code reads the table of global names, from which Answer five Node value reads that compiled programs got silently wrong (in globalThis, execArgv, typeof exports, zlib namespace) #3337 takes the names Node's global object does not have (the literal localStorage and one more are gone, 272 bytes smaller); it equals Node in both lanes. It is on the list from here.
  • Compile time: benchmarks +0.29% [95% CI -0.96%, +1.94%] and fixtures +0.22% [-0.69%, +1.12%], both within tolerance; the compiler's own source -8.49% [-9.29%, -6.33%].
  • In a second Sandbox on the same tree: the stdio, child_process and stream programs (1,206 plain, 288 sanitized), order parity 145 of 145, the docs gate: pass. The island conformance lane and value matrix are still running there; their result follows.

CI on this head: "lint and format" and all 54 Linux jobs are green. One red job, test (macOS x64 LLVM artifacts, no clang): tests/harness/node-island-load-reads.test.ts, a timing assertion that is failing on main's own macOS runs as well and is not touched by this branch. The review bot left no finding on this head.

@cramforce

Copy link
Copy Markdown
Contributor Author

The island conformance lane and the island value matrix on c4023df963 (a Sandbox with a libnode pack built from this tree, check mode): 666 passed, 0 failed; neither list changes. With that, every check named in the comments above has reported for this head.

@cramforce
cramforce merged commit 2cea97f into main Nov 8, 2026
80 of 81 checks passed
@cramforce
cramforce deleted the train/node-1 branch November 8, 2026 08:48
yowainwright pushed a commit to yowainwright/scriptc that referenced this pull request Nov 9, 2026
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>

This branch was successfully deployed

1 active deployment
Preview — c4023df9 Deployed Nov 8, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants