`require("node:repl")` was ENOENT: the module was never registered, and
registering it was not one line because `lib/repl.js` reaches for machinery
this runtime had no reason to have.
`internalBinding('contextify')` is the piece it genuinely needs, and only for
three things — `makeContextifyScript` and the two SIGINT watchdogs. It is the
same indirect eval `node/vm.ts` already was, so that file now builds its
`Script` on this one rather than keeping a second copy: `repl.js:106` lifts
`vm.Script.prototype.runInThisContext` off the public vm and applies it to a
script the internal binding built, so the two have to be one function over one
set of fields. The constructor compiles eagerly, which it must — an input that
throws there is what makes the REPL offer its `...` continuation instead of
reporting an error.
Everything else is weight. Pulling `repl.js` in unmodified drags node's whole
module system behind it, and the build stopped finishing rather than failing:
`commonjs({ dynamicRequireTargets: ["node_core/**/*.js"] })` means one dynamic
`require()` anywhere in the graph bundles every JavaScript file in the node
checkout — `benchmark/`, `deps/v8/test/`, the lot — and
`internal/modules/helpers.js:404` has one. `internal/util/inspector` was the
other half, reaching `internal/process/execution` and so
`internal/modules/typescript` and a 3.3 MB TypeScript stripper. Seven overrides
cut that back; the worker bundle is 4.6s again, against 4.6s before repl existed
and never-finishing with it.
`lib/vm.js` is an override for a different reason: without it a `require('vm')`
from inside the node tree got upstream's while a script's `require("node:vm")`
got ours. Two different `vm`s, and the REPL is the first module in the tree to
ask for one.
The rest are things only running it could find: `process.addUncaughtException-
CaptureCallback` (node 25's replacement for repl's domain-based error routing —
absent, the constructor throws and there is no prompt at all), `overrideStack-
Trace`, `isErrorStackTraceLimitWritable`, `decorateErrorStack`,
`BuiltinModule.getSchemeOnlyModuleNames`, and `SKIP_SYMBOLS`.
Two known gaps, both from the same absence. There is no V8 inspector in a
Worker, so completing a `const` declared at the prompt — which node does through
`Runtime.globalLexicalScopeNames` — does not work, though completing a property
does. And an evaluation cannot be interrupted: indirect `eval` is not something
V8 can be broken into, so `breakEvalOnSigint` is off and a runaway loop needs
killing from outside.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A server here registers with the signaller under `(credential, port)` and reaches
another Puter app that way -- cross-origin messaging between apps is effectively
impossible, so WebRTC is the transport and the port is the address. The server
side has reflected that since `<code>.peer.puter.com` was phased out. The client
side had not: `connect` handed the string "localhost" to the Wisp relay, which
resolved it on the *relay host*, so the connection did not arrive at all.
So `connect` now resolves a loopback host itself, in the order that costs least:
a server in this same worker, then another app's over WebRTC by port, then
ECONNREFUSED. Never the relay -- a loopback address reaching the public internet
is the bug being fixed.
The same-worker case is two `TransformStream`s wired crosswise into the server's
existing `_onAccept`, so there is no new accept path and backpressure comes free
from the streams. It is a fast path only, and it does not change who is
reachable: `listen` still publishes to the signaller.
`peer.connect` gains `port` beside `code`, and exactly one goes on the wire. A
code exists only for an authenticated server; a port is the address that always
does, and is what browser.js already dials with.
Three smaller things that were wrong in the same area:
- `listen(0)` now allocates a port instead of binding zero, because `address()`
has to report what it picked.
- `address()` reports `127.0.0.1`. Nothing here binds an interface, and the
address that works is the one a client can pass to `connect`.
- failing to *publish* is no longer failing to listen. Without a token the
signaller is simply unavailable, which is an ordinary configuration rather
than an error in the call; the port is still bound as far as this machine is
concerned. Emitting `error` there took down a server that worked.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
From a stack in the deployed app, on the second ^C:
handleKeyDown -> requestExit -> shutdown -> drainStdin
-> readSync -> readStdinSync -> stdioSync -> rawSync -> transportError
A terminal program shutting down drains stdin. In raw mode node answers
`readSync` with EAGAIN when there is nothing to read, and a drain loop is
written against exactly that. `readStdinSync` asked for a *blocking* read
unconditionally, so with nobody typing it waited — and waiting here is a
synchronous transport request held open, which does not stall so much as
eventually fail: the request timed out and threw a TransportError out of
a teardown path that was never written to handle one. The program then
sat neither running nor exited, which is worse than either.
So a raw-mode read is non-blocking and raises EAGAIN when nothing is
available, which is what node does and what the wire already described:
"eof: false with no bytes when that is nothing, which is what a
non-blocking fd does". Only raw mode — a cooked read at a prompt still
blocks, because there the program is waiting for a line and means it.
Measured in a browser with a TTY sibling: before, the drain never
returns and the run is still unsettled at 25s; after, EAGAIN in 2ms and
the program exits cleanly in 14ms.
Also here, from the same investigation but not the same bug: `process.exit`
tells the page *before* running the program's own beforeExit/exit
handlers. Those are synchronous and can block the thread outright, and
then `ctl.exit` is never posted at all — the program is gone and nothing
can say so. The page arms a deadline on that declaration, which is armed
only by a program announcing an exit, so it stays clear of a worker
merely parked in a long blocking call.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A program that exits while its module is still running throws
`ProcessExit` out of the run, and `execute` catches it and reports the
code. A program that exits from a callback — a timer, a keypress, which
is every interactive one — threw it into a handler nobody is watching, so
the message the worker posts to the page is the *only* remaining evidence
the process ended. Three unbounded awaits sat in front of that message,
and any one of them could hold it forever:
- `requestExit` flushed output first, deliberately, so a program's last
line is not lost. But a chunk settles only when whoever is reading
stdout accepts it, so a reader that stalls holds the flush — and with
it the exit — open for good.
- The page awaited every exit listener before terminating. Their one
window to read worker-side state, and it must not become a veto.
- The worker process provider flushed again after the run settled,
before pushing the exit event. Same stall, same result: the child is
gone and nothing says so.
All three are bounded now, and `flushStdio` takes a deadline so the
callers on the teardown path can say what they can afford. Waiting is a
courtesy to the last line; the ending is not optional.
Measured with the page-side flush wedged permanently: before, a
`node -e 'setTimeout(() => process.exit(3), 50)'` never reports and the
run never settles; after, the shell gets rc=3.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
"service worker did not acknowledge the session" was fatal on a single
5s miss, and a miss is not evidence of anything wrong: the worker can be
stopped, replaced, or still starting at the moment the message is posted,
and the page cannot see which. The `rescue` and `controllerchange` paths
already treat exactly these as routine and simply re-attach — only the
first attach had no second chance, which is the difference between a
shell that hiccups and a shell that will not start.
It now tries three times, re-reading `registration.active` each attempt
so a worker replaced between tries is picked up rather than messaged
again in its grave, and a port that timed out is closed so a late
`attached` cannot leave the service worker holding a session pointing at
a channel this page has forgotten. Measured: with every first handshake
dropped, boot recovers in 7.5s instead of failing.
A version mismatch still fails immediately. Retrying a settled
disagreement only spends another ten seconds.
If all three miss, the error now carries the registration's state —
scope, active, waiting, installing, controller — because "did not
acknowledge" names a symptom shared by every cause, and these tell them
apart in a report from someone else's browser.
Separately, `registerOnce` cached a registration for the life of the
page. A registration is not forever: another app on the origin can claim
the scope and clearing site data takes it away, neither of which is
visible from the object — it goes on reporting an `active` worker that
still answers postMessage. So the handshake succeeded and every
synchronous request 404'd, reported as a filesystem fault rather than as
the registration being gone. Reproduced by unregistering mid-session:
before, every later spawn failed with probe-failed; now it re-registers.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
worker_threads.Worker and child_process.fork now work with no setup: a thread
is a second NodeWorker the page creates, joined to the first by a real
MessageChannel, so structured clone, transferables and ordering are the
platform's own and the page is not in the data path. That replaces the
node-core JS shim, which had isMainThread hardcoded false. receiveMessageOnPort
and moveMessagePortToContext still throw — both want a synchronous port drain,
which needs SharedArrayBuffer, and not needing that is what lets this run
without cross-origin isolation.
spawn still needs a ProcessProvider, because "run an arbitrary program" is the
embedder's question. "Run another copy of this runtime" is not, so
createWorkerProcessProvider + nodeCommandLine ship the common case, and
spawnSibling makes the child by inheriting the worker script, service worker,
filesystem, network and TTY of the worker it is called on.
Verified against the phoenix kernel, which drives both halves: 122 passing in
the browser, including five cases that exist only to exercise a node process
spawning bash back into the kernel over this provider.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>