Skip to content

Train islands-5: timers, child processes and the working directory across the Node island boundary - #3429

Merged
cramforce merged 31 commits into
mainfrom
train/islands-5
Nov 8, 2026
Merged

cramforce merged 31 commits into
mainfrom
train/islands-5

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

Three changes at the Node island boundary, each a place where a program with islands answered differently from Node. They are emitted or linked only in a program that uses an island; the bridge protocol does not change (ABI 20).

What lands

  • Clear timers across the Node island boundary #3182: timers are cleared across the boundary. clearTimeout, clearInterval and clearImmediate of an island's timer in compiled code call Node's own function (it did nothing before, so an island interval kept the process alive). Island code that clears a program timer clears it: in a program with islands the program's timer ids start at 2^52, so a number names one timer whichever side made it. A timer an island package declares stays the island's object; storing it in a variable typed as a timer is a compile-time refusal by name. Immediates take their ids from the timer sequence, which also fixes clearTimeout(immediate) cancelling a timeout with the same number in programs without islands.
  • Hold a child process a Node island made in a native ChildProcess slot #3348: a child process an island started can be held at the ChildProcess type. The slot holds a child with no state of its own that names the island's object: listeners are registered on the island's emitter, properties and methods are the island object's, the streams are the island's streams. Adoption happens at the checked conversion. Limits are on the limitations page ("Child processes from Node islands").
  • The working directory is shared (the remaining half of Share process.exitCode and the working directory with Node islands #3189). Node caches process.cwd() until its own process.chdir runs, so after the program changed directory, island code kept resolving relative paths against the old one (conformance entry packages/process-cwd). In a program with islands process.chdir goes through the island unit, like the exit code: the runtime's change, then Node changes to ".", which drops its cached answer. The exit-code half of Share process.exitCode and the working directory with Node islands #3189 is on main since One registry for process events and one process.exitCode across a Node island #3166.

Changes made on the train

  • The open review thread of Hold a child process a Node island made in a native ChildProcess slot #3348 (scr_node_island_child.c, off during an emit) was real and is fixed here (c408bae144). A listener that an earlier listener removes while the event is being emitted was not called in that emit; Node calls every listener registered when the emit started. With Hold a child process a Node island made in a native ChildProcess slot #3348 as it was, tests/corpus/9890-node-island-child-off-during-emit prints a c | a c | a c where Node prints a b c | a c | a c, and a listener removed and registered again during an emit was lost for good (a | a | a against a b | a b | a b); 20 of 20 runs. off now marks the registration as removed and leaves the closure with the function.
  • Two merges that did not build on main (27f0d064e0): the timer clear called the island helper by its old name; the adopted child and main each added nodeIslands to the dyn emitter's host.
  • A throw decision for nodeIsland.clearTimer and nodeIsland.chdir, which the builtin guard asks for.

Validation

Full round on 90063fc94a (this branch before its last merge of main) against main 6642a17b5f:

  • Typecheck passes. Lanes: plain 25,188 passed, 0 failed; sanitized 21,755 passed, 1 failed. The one is the 180 s limit of npm-static.test.ts "commander compiles statically and byte-matches Node" under a full lane; alone on the same tree it passes in 77.7 s, and the same limit was hit in two unrelated validation rounds today.
  • Both native bootstraps ran in that round: plain (commands, rebuild) and sanitized (commands, rebuild-frontend, rebuild-emit).
  • Zero-cost gate over the whole corpus: 5,569 identical, 0 differ, 347 fail on both sides, 246 runtime-only, 1 build-error.
    • The 246 runtime-only rows are the corpus programs that link the setImmediate code: Clear timers across the Node island boundary #3182 removes the separate immediate sequence from scr_async.c. IR and LLVM are identical on all of them, no function is resized, the size changes by -48 to +24 bytes (50 unchanged, median +8), and no benchmark is among them. This is the exception recorded for Clear timers across the Node island boundary #3182, re-measured.
    • The build-error row is node-island-timers-typescript, a program this train adds: the gate has no libnode pack, so the candidate stops there, as every island program does on both sides. It builds and passes in both lanes.
  • Compile time: benchmarks +0.55% [-1.29%, +1.53%], the compiler compiling itself +0.05% [-1.25%, +0.51%], fixtures +0.08% [-0.28%, +0.45%]. Run time: 73 workloads, all neutral. Test262: 0 regressed.
  • Island conformance lane in check mode: 438 passed, 0 failed (the list loses packages/process-cwd, nothing else moves). Island value matrix: 239 of 239, and its update mode changes nothing. The weak-keys fixture passes in both lanes.
  • The twelve child-process island programs against Node, 50 runs each: 600 of 600 plain, 600 of 600 sanitized; under ThreadSanitizer, 20 runs each: 240 clean. The thirteen worker island programs: 650 of 650 in each lane, 260 clean.

The head adds a merge of main 59950fefd3 with no conflict. On that head: the build, typecheck, guard tests, lint, format and the island tests; the gate's identity and the conformance lane on it are posted as a comment when they finish.

Not in this train

#3252 (silent copies), island-native-twins slice 1, #3043, #3124 and the shared globals branch: each needs its owner's restack on current main first.

Supersedes #3182, #3348 and #3189.

🤖 Generated with Claude Code

cramforce and others added 28 commits November 6, 2026 20:29
clearTimeout, clearInterval and clearImmediate did nothing across the
boundary, silently in both directions: an island interval the program
believed it had stopped kept the process alive, and island code clearing a
timer of the program passed Node a number it did not know.

The program clears an island timer: a checked-dynamic handle that is not a
number goes to the island's own clear function when it is an island object
(nodeIsland.clearTimer). The branch is emitted only in a program that can
hold island values, which also takes such a handle in TypeScript.

Island code clears a program timer: the timer stays the number it is in
compiled code. In a program with islands timer and immediate ids start at
2^52, a range Node's own timer ids do not reach, so a number names at most
one timer whichever side made it, and the island's clear functions send such
a number to the runtime's own clear through the hosted loop's run native.
No bridge ABI change.

A timer declared by a package that runs in an island stays the island's
object, like the other builtin classes without a native object form: the
program keeps it in a const, clears it, calls its methods and passes it
back. Storing it in a slot typed as a timer is a compile-time refusal by
name instead of a TypeError at the crossing.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ams' margins

The program-wide fact that a program imports @scriptc/node was cached in a
module-level WeakMap; compiler caches live on their owners
(compiler-weak-collections.test.ts), so the lowerer keeps it, as it keeps
the other facts computed on first use.

The new corpus programs ordered timers of the two sides 5 to 20 ms apart,
which a loaded sanitized runner can reorder against Node. Every pair of
events is now at least 40 ms apart.

Co-Authored-By: Claude Code <noreply@anthropic.com>
main removed the lowerer's namesArrayBufferView cache next to the
importsNodeApi one this branch adds.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ist up to date

scr_node_island_host.h embeds scr_node_island_host.cjs; the comment change
in the last commit left it stale (node scripts/node-island-host.mjs).

tests/harness/island-conformance/known-failures.json, regenerated: eleven
entries had drifted on main through later merges (ten changed their
message or became a build refusal, one program matches Node now), none of
them through this change.

Co-Authored-By: Claude Code <noreply@anthropic.com>
.gitignore: both sides added a diagnostics fixture's node_modules
exception at the same place (#3234's node-island-function-value, this
branch's node-island-timer-native-use); keep both.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Regenerated with SCRIPTC_ISLAND_CONFORMANCE=update on the merged tree.
Two entries move, and both move on main itself with nothing applied:
npm/hono-zod-default-hook with zod in an island prints its TypeError
without brackets, and packages/semver now runs semver in an island by
default, so its forced-island entry leaves. The result is entry for
entry the list main gives when regenerated; this branch adds none.

Co-Authored-By: Claude Code <noreply@anthropic.com>
A ChildProcess that code in a Node island started (a spawn wrapper package)
and that reached a native ChildProcess slot threw
`expected ChildProcess at $, got Object from a Node island`, and a program
that caught it never exited, because the child's stdin was never ended.

The slot now holds a child with no state of its own that names the island's
object. Every entry point that takes a child, one of its output streams or
its input writer hands such a value to the island object's own member: a
listener is registered on the island's emitter, a property is read from the
island's object, a method is the island's. The island's object is adopted
where a dynamic value enters the slot (the checked conversion), and
converting the slot's value back answers the island's object.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…in a native slot

The TypeScript 7 order baselines of the ten corpus programs, the
limitations bullet, and the run-time refusals worded by what the listener
got.

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

# Conflicts:
#	docs/content/docs/limitations.mdx
#	packages/runtime-pack-common/runtime-pack-matrix.mjs
#	tests/harness/node-island-gc.test.ts
…of its own

An object on the link line of every program with Node islands left its file
symbol in each of them (48 bytes), also in programs that never use
child_process. The SCR_NODE_ISLANDS build of scr_child.c now includes the
file, so such a program links nothing of it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
docs/content/docs/limitations.mdx: both sides added an include after the
event-order partial (this branch's timers, #3271's abort listeners); keep
both.
lower-node-island.ts: isTimerClearCallee called stripTypeWrappers, which
#3254 replaced by the shared withoutParenthesesTypeWrappers.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…member table

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

Building the code into scr_child.c kept the size of every other island
program but changed the order of its symbol table: the unit named the
island entry points before the island units did. The unit is its own object
again, on the link line through a link feature, islandChild, that a program
has when it has both Node islands and child_process.

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
…rogram off a native leak

The plateau fixture ends with full collections, so the test reads what is
held (two exports after 400 and after 6,000 children) and not what the last
scheduled collection left. The general-streams program's stdout listener
captured its child, which leaks the child and its stream for a native child
on main too; it captures stdin now. Corpus costs for the ten programs.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Clear timers across the Node island boundary. Conflicts with main: .gitignore, the lowerer's field and journal lists and the island host script keep both sides; the limitations page takes the include line for the new partial; the generated island host header is regenerated; the island conformance list is main's and is regenerated by its update mode on the train.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Hold a child process a Node island made in a native ChildProcess slot. Conflicts with main: the executable feature tables keep both features (processFeatures and islandChild); the limitations page takes the include line for the new partial; the runtime pack matrix and scr_child.c take the branch's additions beside main's.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Two clean merges did not compile. The timer clear called the island helper by the name it had before main renamed it (scr_ni_helper). The adopted child process and main each added a nodeIslands member to the dyn emitter's host, in the interface and in the object literal (TS1117): it is one fact, so main's member stays and the copy goes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
… removed during the emit

A listener of a child process a Node island started that removes a later listener of the same event while the event is being emitted suppressed that listener: off cleared the program's closure at once, and the emit that was under way then called a function with nothing behind it. Node calls every listener that was registered when the emit started. A listener that was removed and registered again during an emit was lost for good.

off now marks the registration as removed, so no later off finds it, and leaves the closure with the function: it is released with the function, or when the child closes. tests/corpus/9890-node-island-child-off-during-emit pins six cases over three emits each.

The unit also sets SCR_NODE_ISLANDS without testing it: it always reads the island layout of ScrChild and has no block under the define, which is what the runtime pack matrix test asks of a unit that has no such build.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Node caches process.cwd() until its own process.chdir runs. A program's process.chdir is a system call Node does not see, so code in an island kept resolving relative paths against the directory the process had left: the conformance lane pinned it (packages/process-cwd, 'false false').

In a program with islands process.chdir goes through the island unit, like the exit code: the runtime's own change, then Node changes to '.', which keeps the directory and drops the cached answer. A Node that is not loaded yet reads the directory when it starts. The other direction already worked: an island's process.chdir is the process's. This is the working-directory half of #3189, on main's island process-state routing; its exit-code half reached main with #3166. tests/corpus/9894-node-island-process-cwd is that PR's program; the conformance entry is removed by the list's update mode.

Also records that nodeIsland.clearTimer can throw (island JavaScript runs in it), which the builtin guard asks of every builtin.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
Measured warm in a Sandbox: the differential plus the llvm-differential case per flavor.

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

vercel Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Nov 8, 2026 3:48pm UTC

@cramforce

Copy link
Copy Markdown
Contributor Author

HOLD: do not merge yet.

The review bot left two findings on this train's child-adoption member (packages/runtime/src/scr_node_island_child.c): at line 260, a close listener dispatched through child.onDyn is released by the cleanup hook before its first invocation, so a rest-parameter close callback never runs; at line 708, pausing or resuming an adopted child's stdout or stderr changes only the native wrapper, leaving the island stream flowing and its reported flow state wrong. Both are being reproduced under Node, on main and on this head. This hold is lifted here when each is fixed or shown not to apply.

🤖 Generated with Claude Code

cramforce and others added 3 commits November 8, 2026 07:26
… the island's stream

Two more places where a child process a Node island started, held in a ChildProcess slot, answered differently from Node.

A 'close' listener that takes its arguments as a rest parameter is an untyped listener. The hook that releases the program's listeners when the child closes runs first in that emit and spared only the typed 'close' listeners, so the untyped one was released before its turn and never ran. It is spared too now and releases itself when it has run. tests/corpus/9895-node-island-child-close-rest-listener.

pause(), resume(), isPaused() and readableFlowing of the child's stdout and stderr came to main after the adoption was written and had no island arm: they changed the native handle only, the island's stream kept flowing and the flow state read wrongly. They go to the island's stream now, like every other member; of the 55 entry points of the child unit that take a child or one of its streams these four were the ones without the arm. tests/corpus/9896-node-island-child-stream-pause.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
Measured warm in a Sandbox: the differential plus the llvm-differential case per flavor.

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

Copy link
Copy Markdown
Contributor Author

Runs on this head (f628514; the gate ran on e78aad6, which differs by two cost files under tests/harness), in Sandboxes:

  • Zero-cost gate over the whole corpus against main 5295319: 5,584 identical, 0 differ, 351 fail on both sides, 246 runtime-only (the programs that link the 'setImmediate code: the exception recorded for #3182), and the one island artifact (node-island-timers-typescript`: no libnode pack in the gate). Compile time: benchmarks -0.10% [-1.35%, +1.22%], self -0.16% [-2.37%, +1.03%], fixtures -0.69% [-1.87%, +0.49%].
  • Island conformance lane, check mode: 442 passed, 0 failed. Island value matrix: 239 of 239. Weak-keys fixture: 3 of 3 plain and sanitized. Island harness tests: 225 plain, 22 sanitized. Unit tests 1,901, diagnostics 408. The four programs this train adds for the child and the working directory: plain and sanitized pass.
  • 14 child-process island programs against Node, 50 runs each: 700 of 700 plain, 700 of 700 sanitized; ThreadSanitizer, 20 runs each: 280 clean. 13 worker island programs: 650 of 650 in each lane, 260 clean.

The full round with both native bootstraps is the one in the description (on 90063fc); since then the branch gained two clean merges of main and the two review changes, which touch only the island child unit and the island arm of four stream entries.

HOLD stays: do not merge yet. Four macOS jobs of this head are red (failed steps "Node islands (Intel)" and "Tests (darwin suite)") and their logs have not been read yet: they may be this train's new child-process or working-directory programs on macOS.

@cramforce

Copy link
Copy Markdown
Contributor Author

Hold lifted on head f628514.

  • Both review findings are answered and resolved: the pause / resume of an adopted child's stream was real and is fixed in a3419c9 (corpus 9896; the previous head differed from Node in 20 of 20 runs in each lane); the rest-parameter close listener did not reproduce (corpus 9895 equals Node on both heads, 20 of 20 in each lane), and the change now spares that listener explicitly.
  • On this head: the zero-cost gate over the whole corpus shows no differing program (246 runtime-only rows under the recorded timer exception, IR and LLVM identical, -48 to +24 bytes); the island conformance lane passes 442 of 442 in check mode; the value matrix is 239 of 239 with no re-pin; 14 child-process and 13 worker island programs equal Node in 50 of 50 runs each, plain and sanitized; ThreadSanitizer is clean over 20 runs each. Both lanes and both native bootstraps passed on the tree before the last three merges of main and the review fixes.
  • CI: lint and every Linux job are green. The four red macOS jobs fail one test that is red on main and on unrelated pull requests today (9811-node-island-load-reads-clock and its harness test node-island-load-reads.test.ts); it is not one of this train's programs.

🤖 Generated with Claude Code

@cramforce
cramforce merged commit 5e91381 into main Nov 8, 2026
87 of 93 checks passed
@cramforce
cramforce deleted the train/islands-5 branch November 8, 2026 16:44

This branch was successfully deployed

1 active deployment
Preview — f628514a 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