Skip to content

Packages: Run Bun-compiled executables on Kandelo via spidermonkey-node (bun-run) - #1371

Open
brandonpayton wants to merge 44 commits into
mainfrom
brandonpayton/run-claude-code-bun-exe
Open

brandonpayton wants to merge 44 commits into
mainfrom
brandonpayton/run-claude-code-bun-exe

Conversation

@brandonpayton

@brandonpayton brandonpayton commented Sep 3, 2026

Copy link
Copy Markdown
Member

Why

Modern JavaScript CLIs increasingly ship as Bun single-file
executables
(bun build --compile): a native launcher (Mach-O on
macOS, ELF on Linux, PE on Windows) with the application's JavaScript
embedded as a serialized "module graph." Claude Code is the motivating
example — since v2.1.113 its npm package ships only the Bun
executable; the last plain-Node cli.js was v2.1.112 (2026-04-16) and
is now months stale. Codex and others are moving the same way.

Kandelo cannot run the native binary itself — it is compiled machine
code, and the app inside targets Bun's runtime. But Kandelo does have
a Node-compatible JavaScript runtime (spidermonkey-node). This PR turns
that into a real capability: run a Bun-compiled executable by
extracting its embedded JS, caching it, and executing it natively on
spidermonkey-node
— no separate transpile/bundler step.

End-to-end proof: the real ~207 MB Claude Code Linux ELF prints
2.1.259 (Claude Code) on spidermonkey-node inside the kernel,
natively.

What changed

The pipeline is bun-run <binary> → extract (cached) → run the app's
own ESM graph via native import():

  • programs/bun-extract.c--prepare mode. Container-agnostic
    extractor (anchors on the Bun trailer + Offsets struct; one code
    path for Mach-O/ELF/PE). --prepare <binary> <cache-root> content-
    hashes the embedded module-graph (FNV-1a-64), extracts once into
    <cache-root>/<hash>/, rewrites the app's baked-in /$bunfs/root/
    specifiers to the cache dir, writes package.json {"type":"module"}
    and a .mjs entry so the modules load as ESM, and prints
    CACHE=/ENTRY=. Reads are positioned (pread) so peak memory is
    one module, not the whole ~200 MB binary. The parser is hardened
    against malformed/hostile input (entry-id range, content-length
    integer overflow, .. path traversal, OOM, UTF-16 size overflow,
    JSON-unsafe module names).

  • runtime/bun-run/bun-run.js + programs/bun-run.c. The bootstrap
    (run on spidermonkey-node) invokes bun-extract --prepare, installs
    a thin Bun global shim (unimplemented members throw named errors —
    never silent), sets process.argv to the app's view, and
    import()s the cached entry in-process. bun-run.c is the thin
    entrypoint.

  • Three spidermonkey-node platform fixes (each a build-wired
    SpiderMonkey patch that survives a node.wasm rebuild — the enabling,
    reusable work):

    • 0015 — native ESM loader resolves bare specifiers (Node
      builtins + node_modules) via the existing node-compat require
      resolver, instead of treating them as file paths.
    • 0016 — populate import.meta.{url,dirname,require} on native ES
      modules.
    • 0017 — enable JavaScript using (Explicit Resource Management):
      the --enable-explicit-resource-management compile flag and
      defaulting the runtime pref on (honoring --disable-...). ESR 140
      already contains the feature; this only turns it on.
  • docs/superpowers/{specs,plans}/2026-09-03-bun-run-kandelo*.md
    the design and implementation plan.

How it was validated

host/test/ in-kernel tests (real kernel via runCentralizedProgram),
all green on the tip:

  • esm-probe-guest (5 cases, self-contained inlined fixtures) — the
    durable regression guard for patches 0015/0016/0017: dynamic-import
    of a minified ESM graph, bare-specifier import … from "fs",
    import.meta population, .mjs main, and a using declaration.
  • bun-extract-guest (2) — extraction + --prepare cache/remap.
  • bun-run-guest (1) — the bootstrap runs a synthetic Bun app.
  • bun-extract-real-guest (1) — extracts the real 207 MB ELF in-kernel
    (1819 modules).
  • claude-run-native-guest (1) — the end-to-end 2.1.259 (Claude Code) run (gated on CLAUDE_BUN_ELF, skips in CI without the
    binary).

The three runtime patches live in the guest node.wasm + shared
node-compat/bootstrap.js, so Node and browser hosts run the same
artifact; browser-host validation is a tracked follow-up. No crates/
or abi/ changes — the runtime and extractor are guest programs, so no
ABI_VERSION bump.

Not included / follow-ups

Scoped to getting a program to boot and run (--version). A full
interactive claude session (headless -p and beyond) needs the
deeper runtime gaps recorded in docs/posix-status.md: async
child_process, a host-yielding TLS egress loop, a CSPRNG, and
setRawMode/PTY. Also deferred: binfmt exec-path integration (so a
bare ./claude is transparently handled), Codex, and cache GC. One
cosmetic residual: with the stderr-surfacing stdio option,
node-compat spawnSync returns status: 0, so bun-run.js's
r.status check is redundant — failure is still caught deterministically
(a failed extract never emits ENTRY=) and exits non-zero.

🤖 Generated with Claude Code


Scope update & merge plan

This branch is the accumulating line of work for running Claude Code on
Kandelo
. Beyond the bun-run capability described above, it now also
includes Milestone 2 Phase A — node-compat builtin-export completeness
(docs/superpowers/{specs,plans}/2026-09-03-node-compat-builtin-completeness*):
the 40 Node builtin named-exports Claude Code statically imports, added to
node-compat so its 1819-module ESM graph instantiates on spidermonkey-node.

That change is a standalone node-compat improvement in its own right, but it
depends at runtime on the spidermonkey-node patches introduced in this PR
(notably 0015 bare-specifier ESM resolution — Claude imports the builtins
as native-ESM bare specifiers), so it is stacked in this PR rather than
split into a separate PR against main.

Merge plan (not squash): when this is ready to land, we will
rebase-merge a curated set of commits — a clean, reviewable sequence
(extractor, bun-run bootstrap + entrypoint, each spidermonkey-node platform
patch, node-compat completeness) — so main receives a tidy history rather
than the exploratory and fix-wave commits accumulated during development.
Further milestones (dynamic-import interop, then the runtime gaps behind a
full headless claude -p) will continue on this branch and be curated the
same way.

brandonpayton and others added 30 commits September 3, 2026 14:43
Add the approved design for a Kandelo capability that runs Bun-compiled
standalone executables (e.g. the Claude Code native binary) by extracting
their embedded JS, caching it in a volatile tmpfs region, and executing it
natively on spidermonkey-node. Ships as a userland launcher, factored so
the detect/extract/cache/run handler can later be hoisted into the exec
path (binfmt). Captures the enabling spidermonkey-node platform fixes
(bare-specifier ESM resolution, import.meta population, the `using` build
flag) and the cache-is-never-persisted invariant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task-by-task plan implementing the approved bun-run design: bun-extract
--prepare (hash+cache+remap), the bun-run.js bootstrap + Bun shim, the
spidermonkey-node native-ESM fixes (bare-specifier resolution, import.meta
population, the `using` build flag), and the end-to-end native run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ns + node_modules)

The SpiderMonkey shell module loader resolves every ESM import specifier
as a file path, so a bare specifier such as `import ... from "fs"` in a
natively-loaded ES module became `//fs` and failed with "can't open
//fs". That blocked real Node ESM apps (e.g. the bun-run app) from
loading natively even though relative/absolute specifiers already worked.

Route bare specifiers (not starting with "./", "../", "/", or
"javascript:") through the existing node-compat resolver:

- js/src/shell/ModuleLoader.cpp (new patch 0015): ModuleLoader::resolve
  calls globalThis.__kandeloResolveBare(specifier, referrerPath) for bare
  specifiers; when it returns a synthetic token path, fetchSource serves
  the generated source from globalThis.__kandeloBareSources. Unresolved
  bare specifiers fall through to the default file-path behavior so the
  failure stays visible (truthful failure).
- node-compat/bootstrap.js: __kandeloResolveBare wraps a Node builtin
  (via _builtinModules, incl. `node:` prefix) or a node_modules package
  (resolved with _resolveFile, loaded with _makeRequire) as an ES module
  namespace — `default` plus own enumerable keys (valid identifiers, no
  reserved words) as named exports — reusing the object require() returns.
  All namespace generation stays in JS.

Decision: a C shell patch was required because the native module loader
is pure C++ with no JS-level resolve hook; the C seam is minimal and all
resolution/namespace logic lives in bootstrap.js.

Test: host/test/esm-probe-guest.test.ts gains a bare-builtin case
(BARE true) and an assertion guarding the existing path-specifier case
(ESMOK 43 hi!). Verified RED (BAREERR can't open //fs) before the fix and
GREEN after rebuilding node.wasm.

Browser-host parity validation: follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e native modules

The SpiderMonkey shell's native ES-module metadata hook only set
import.meta.url to the raw filesystem path and defined no dirname or
require. Extracted Bun apps read import.meta.require and
import.meta.dirname, so they broke on native modules without source
rewriting.

Add a C seam (patches/0016-kandelo-import-meta.patch) in
ModuleLoader::populateImportMeta that calls globalThis.__kandeloModuleMeta
with the resolved module path and copies url/dirname/require onto
import.meta; falls back to the shell default (url = raw path) when the
helper is absent so an unresolvable path stays a visible boundary.
Implement __kandeloModuleMeta in node-compat/bootstrap.js reusing
url.pathToFileURL, path.dirname, and _makeRequire. Same C-seam shape as
the Task 3 bare-specifier patch (0015); needs no new includes.

This is the native import() module system's import.meta, distinct from
the _runEsmMain regex import.meta.url substitution, which is untouched.

Browser-host parity validation: follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-node build

ESR 140.11 ships the Explicit Resource Management implementation (`using`
/ `await using`) behind the compile-time macro
ENABLE_EXPLICIT_RESOURCE_MANAGEMENT plus the runtime pref
javascript.options.experimental.explicit_resource_management, which the
StaticPrefList defaults to false on the ESR (non-Nightly) channel. Enable
the macro via --enable-explicit-resource-management in the mozconfig and
default the pref on in the Kandelo shell so node.wasm parses and runs
`using` declarations without any source-lowering transform.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ermonkey-node

Adds programs/bun-run.c, a thin exec-passthrough entrypoint that hands a
Bun standalone executable to the bun-run.js bootstrap on
spidermonkey-node (future binfmt call target).

Rewrites host/test/claude-run-native-guest.test.ts into the milestone-1
end-to-end proof: stage the real ~207MB Claude Code Bun ELF, bun-extract,
bun-run.js, and /bin/sh into a big-capacity rootfs image and drive
'node /usr/lib/kandelo/bun-run.js /usr/bin/claude --version' through the
whole pipeline (bun-extract --prepare -> Bun shim -> native import() of
the ~1819-module ESM graph). Confirms real output: 2.1.259 (Claude Code).
…oud patch apply, surfaced extract errors)

## Why

The whole-branch review of the bun-run feature (running Bun standalone
executables, including the real Claude Code CLI, on spidermonkey-node
inside Kandelo) found one gap serious enough to undermine future
confidence in the platform patches it depends on, plus a handful of
smaller correctness and defense-in-depth issues:

- `host/test/esm-probe-guest.test.ts` is the only regression test for
  three SpiderMonkey source patches (bare-specifier ESM resolution,
  `import.meta` population, and `using`/Explicit Resource Management
  support). Its fixtures lived only at `/tmp/cc-inspect/esm_probe`,
  which is not committed and does not exist on a fresh checkout. Every
  test case was gated on that directory existing, so on CI or a fresh
  clone all five cases silently skipped instead of running — the test
  file looked like a safety net but proved nothing.
- `build-spidermonkey.sh` applied each of its SpiderMonkey source
  patches only when a dry-run succeeded, with no `else` branch. If the
  pinned Firefox ESR source ever drifted enough that a patch stopped
  applying cleanly, the build would silently skip that patch and
  finish green, quietly dropping a platform fix with no signal to
  whoever ran the build.
- `bun-run.js` (the bootstrap that extracts and runs a Bun executable)
  built its own failure message from `bun-extract`'s captured stderr,
  but the spidermonkey-node child_process shim never actually captures
  child stderr into a JS string. A malformed or non-Bun input binary
  therefore surfaced only "exit 1" instead of `bun-extract`'s real
  diagnostic (e.g. "not a Bun standalone executable...").

## What changed

- `host/test/esm-probe-guest.test.ts`: inlined all 10 fixture file
  contents as string constants, written to a fresh
  `mkdtempSync`-created temp directory at test load time instead of
  reading from `/tmp/cc-inspect/esm_probe`. The `ready` gate no longer
  depends on that path existing, so all 5 cases now run (and pass) on
  a bare checkout. Removed the "Throwaway" framing from the file
  docstring since this is the durable regression guard for the 0015
  (bare specifiers), 0016 (`import.meta`), and 0017 (`using`) patches.
- `packages/registry/spidermonkey/build-spidermonkey.sh`: the patch
  loop now also checks a reverse dry-run to recognize an
  already-applied patch (prints a note and continues), and fails the
  build loudly, naming the offending patch file, when a patch applies
  neither forward nor in reverse — so source drift can no longer
  silently drop a platform fix.
- `runtime/bun-run/bun-run.js`: passes `stdio: ["ignore", "pipe",
  "inherit"]` to the `bun-extract --prepare` spawn so `bun-extract`'s
  own stderr diagnostics reach the real process stderr instead of
  being redirected to `/dev/null`. The failure message no longer
  claims to include stderr text the shim never actually captures.
- `host/test/claude-run-native-guest.test.ts`: the end-to-end
  assertion now also checks `result.exitCode === 0`, not just that
  stdout matches the version string.
- `programs/bun-extract.c`: `utf16le_to_utf8` now rejects an input
  length that would overflow the `inlen * 3 + 4` capacity calculation
  before computing it, so a maliciously large UTF-16 module length in
  an untrusted Bun executable can't wrap `size_t` into a too-small
  heap allocation.
- `docs/posix-status.md`: documented two node-compat platform
  boundaries surfaced by this work — `spawnSync`/`execSync` always
  shelling via `popen()` (requires `/bin/sh`, ignores `encoding`) and
  the milestone-2 interactive-runtime gaps (`setRawMode` no-op,
  unproven TLS keep-alive egress, non-CSPRNG `Math.random`-backed
  `crypto.randomBytes`) that a full interactive `claude` session will
  hit.

## Validation

- `scripts/dev-shell.sh bash -c 'cd host && npx vitest run
  test/esm-probe-guest.test.ts'` — 5/5 cases run (not skipped) and
  pass: ESMOK, BARE true, META, `.mjs` main import, USING 1.
- `scripts/dev-shell.sh bash -c 'cd host && npx vitest run
  test/bun-run-guest.test.ts'` — 1/1 pass (no regression from the
  stderr-routing change).
- `scripts/dev-shell.sh scripts/build-programs.sh` rebuilt
  `bun-extract.wasm` with the overflow guard; `scripts/dev-shell.sh
  bash -c 'cd host && npx vitest run test/bun-extract-guest.test.ts'`
  — 2/2 pass.
- `scripts/dev-shell.sh bash -c 'cd host && npx vitest run
  test/claude-run-native-guest.test.ts'` — 1/1 pass against the real
  Claude Code ELF present in this environment, including the new
  `exitCode === 0` assertion.
- `build-spidermonkey.sh`'s new `else` branch was verified by code
  inspection (rebuilding SpiderMonkey end-to-end is a multi-minute
  build not needed to prove the shell logic is correct); a full
  rebuild was performed anyway as part of unblocking the above test
  runs and completed successfully with all patches applying cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… Phase A)

Approved design to make Claude Code's module graph instantiate on
spidermonkey-node by providing the 40 builtin named-exports it statically
imports (real where trivial, honest throwing stubs where hard, node-compat
JS only). Prerequisite (module link-time) before the Phase B runtime gaps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ase A)

Two-task plan (batched around one node.wasm rebuild) to add the 40 missing
builtin named-exports Claude Code imports, plus a kept link-surface guard and
a throwaway -p acceptance run that seeds Phase B.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…urface

Claude Code's extracted ESM app graph named-imports 40 Node builtin
exports that packages/registry/node-compat/bootstrap.js did not
provide. A named import of a name a module doesn't export fails at
ESM link time, before any code runs, so this graph could not
instantiate on spidermonkey-node at all.

Adds all 40: real implementations where the qjs:os/qjs:node native
bindings make them trivial (os.availableParallelism, util.stripVTControlCharacters, crypto.timingSafeEqual, zlib.deflate/inflate, dns.promises.lookup, path/posix, events.setMaxListeners,
child_process.ChildProcess, url.domainToASCII, util/types.isProxy,
tls.rootCertificates/checkServerIdentity), honest throwing stubs
everywhere else (fs.fsyncSync/ftruncateSync — no fsync/ftruncate
primitive in the qjs:os native module; fs/promises.link/lutimes/opendir/statfs; crypto.randomFillSync/createCipheriv/createDecipheriv/createPrivateKey/createPublicKey/generateKeyPairSync/sign/verify; zlib.inflateRawSync/createZstdDecompress; tls.createSecureContext), and two constructable
no-op/throw-on-construct class stubs (net.BlockList,
crypto.X509Certificate) since npm/CLI code commonly instantiates
these at module scope.

host/test/node-compat-builtin-exports.test.ts stages a minimal ESM
fixture that imports the exact 40-name surface and asserts it links
on spidermonkey-node.wasm (LINKED40), with spot checks on a handful
of the real implementations and on a stub actually throwing when
called. Confirmed RED beforehand against the unmodified bootstrap.js
(LINKERR can't open //dns/promises), then GREEN after rebuilding
spidermonkey-node with these changes; the existing 0015/0016/0017
ESM regression guard (test/esm-probe-guest.test.ts) still passes 5/5.

docs/posix-status.md records the throwing/class stubs as a tracked
node-compat gap; Phase B graduates whichever the app actually calls
at runtime to a real implementation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Phase B)

Approved design: make spidermonkey-node load ESM targets as native modules
through both the dynamic import()/bare-hook path (bootstrap.js) and a new
synchronous require()-of-ESM capability (a C seam that compiles/links/
evaluates via the native loader+registry, throwing ERR_REQUIRE_ASYNC_MODULE
on top-level await), deduping all routes through the native per-path
registry. Unblocks headless `claude -p` past the dynamic-import SyntaxError
and readies ESM npm-package require().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e B)

Two tasks: (1) bare-hook ESM routing in bootstrap.js (fixes the -p dynamic
import SyntaxError) + acceptance; (2) synchronous require()-of-ESM via new C
seam patch 0018 + require() change, with dedup and ERR_REQUIRE_ASYNC_MODULE.
One rebuild per task; kept guard extends esm-probe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (dynamic import fix)

When a dynamic import() resolves a bare specifier (e.g. import("epkg")) to
a file whose nearest package.json says "type": "module" (or that ends in
.mjs), hand the real resolved path back to the native SpiderMonkey module
loader instead of routing it through node-compat require()'s CJS wrapper.
The CJS wrapper evaluates the file body as a plain function, so a top-level
import declaration inside it threw "import declarations may only appear at
top level of a module" -- this was the current claude -p blocker on
spidermonkey-node.

Returning an absolute path from __kandeloResolveBare is handled natively by
ModuleLoader.cpp (0015-kandelo-esm-bare-specifier.patch): it bypasses the
synthetic bare-token/namespace path entirely and is read + CompileModule'd
by the native per-path module registry, so dedup stays correct. The CJS
(non-ESM) bare path is unchanged.

Adds a regression case to esm-probe-guest.test.ts. The bare dynamic import
in the fixture happens from a genuine native ES module referrer
(dynhost.mjs), not directly from the CJS main: only a referrer compiled by
the native module loader carries the script-path private data
__kandeloResolveBare needs to walk node_modules from the right directory.
A classic/CJS script executed via the shell's evalScriptAsFunction helper
never registers that private data -- only the shell's own top-level RunFile
path does (js.cpp's RegisterScriptPathWithModuleLoader) -- so a bare
specifier dynamically imported directly from CJS always resolves with a
null referrer today. This mirrors the real Claude Code shape: a
lazily-loaded ESM chunk performing import() of a bare specifier from
within already-native ESM code.
…e load + dedup, ERR_REQUIRE_ASYNC_MODULE on TLA)

Add a SpiderMonkey shell C seam, __kandeloRequireModule(path), that loads
an ES module through the shell ModuleLoader's per-path registry, links and
evaluates it, and returns its namespace -- so node-compat require() (and the
Bun __breq / import.meta.require cross-chunk helpers) can load ESM chunks
synchronously instead of CJS-wrapping them (which throws "import declarations
may only appear at top level of a module"). require/import/import() of one
resolved path share a single module instance and namespace. A required module
with a pending top-level await throws ERR_REQUIRE_ASYNC_MODULE; a synchronous
evaluation rejection is rethrown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nkey-node

The whole-branch review of the require()-of-ESM work noted the dedup test only
covered require-then-import; import-first-then-require is the dominant real
ordering (import() is the common route, require(esm) rare) and its symmetry
was assumed, not asserted. Add a reverse-order case: import() a type:module
`.js`, then require() the same path, and assert one shared native-registry
instance (shared counter + identity). Runs against the existing node.wasm --
no rebuild -- and passes (DEDUPREV 1 2 true), locking the symmetry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n on spidermonkey-node

Headless `claude -p` loaded the whole module graph (Phase B) but then died at
runtime with `Error: can't open //path/win32`: the app statically imports the
Node builtin subpath `path/win32` (standard cross-platform code that imports
both path variants and only calls the win32 one under `process.platform ===
'win32'` guards, which are false on Kandelo -- platform is already `linux`).
node-compat resolved `path/posix` but not `path/win32`, so the bare specifier
fell through to file resolution and failed. Register `path/win32` -> path.win32
in `_builtinModules`, mirroring `path/posix`. (path.win32 is a POSIX
approximation with `sep: '\\'`; real win32 semantics are tracked future work.)

Also fold in the Phase B whole-branch-review canonicalization finding: require()
of an ES module passed the realpath'd path to the native seam while import/
dynamic import() reach the shell ModuleLoader (lexical normalizePath, no symlink
resolution) with the pre-realpath path, so the same specifier under a symlinked
dir keyed the per-path registry twice and double-instantiated. Pass the
pre-realpath `resolvedPath` to __kandeloRequireModule so require and import share
one registry key (and one instance); the JS-side _moduleCache stays realpath-keyed.

Record the complete inventory of node-compat's stubbed/approximated Node core
surface in docs/posix-status.md as tracked future work, grouped by honesty class
(fail-loud throwing stubs vs. silent approximations vs. approximate impls),
including path/win32 and the CSPRNG/TLS-identity/BlockList silent stubs.

Guard (host/test/esm-probe-guest.test.ts): path/win32 resolves via require and
import (sep is backslash); require()+import() through a symlinked dir share one
instance (SYM 1 2 true). All 12 esm-probe cases green after one node.wasm
rebuild. Throwaway `claude -p` acceptance: the path/win32 error is gone; the app
now reaches its next blocker, the external npm dep `ws` (Phase D seed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… socket deferred)

Headless `claude -p` loaded the whole graph (Phase C) but then died in main
init with `Error: can't open //ws`: the app imports the bare npm package `ws`
(WebSocket). Reconnaissance (Bun graph manifest + the npm package layout)
showed `ws` is neither bundled into the Bun standalone nor shipped in the
platform package -- Bun provides a native ws-compatible module at runtime, so
Bun-bundled apps mark `ws` external. node-compat is the equivalent runtime
layer on spidermonkey-node, so it provides `ws` here.

A scope probe (throwaway: a stubbed, instrumented ws staged via node_modules)
proved `claude -p` *imports* `ws` but never constructs or uses a WebSocket --
its ws usage is feature-gated. So a minimal, honest module unblocks `-p`
without implementing WebSocket I/O: `_builtinModules['ws']` is the `WebSocket`
class itself (matching real ws's `module.exports`), so `import WebSocket from
"ws"` / `require("ws")` resolve and the class, static ready-state constants,
`instanceof`, and subclassing all work at module scope; but constructing a
live `WebSocket`/`WebSocketServer` or calling `createWebSocketStream` throws a
clear "not implemented on spidermonkey-node" (fail-loud, never silently wrong).

Full real `ws` (a WebSocket client/server over the platform's TCP/TLS with
permessage-deflate; depends on the TLS keep-alive egress path + a CSPRNG for
Sec-WebSocket-Key) is recorded as tracked future work in docs/posix-status.md.

Guard (host/test/esm-probe-guest.test.ts): `ws` resolves via require and ESM
default+named import, default is the WebSocket class with OPEN===1, and
`new WebSocket()` throws (WS function 1 true true true). All 13 esm-probe
cases green after one node.wasm rebuild. Throwaway `claude -p` acceptance:
the `ws` error is gone; the next blocker is `zlib.constants.Z_SYNC_FLUSH`
(zlib constants + Brotli/gzip streaming for HTTP response decompression) --
the Phase E seed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…idermonkey-node

Headless `claude -p` loaded the whole graph (Phase D) but died in main init
with `TypeError: can't access property "Z_SYNC_FLUSH", (intermediate value).
constants is undefined`: node-compat's zlib module had real gzip/deflate/
inflate (native libz backend, patch 0012) but no `constants`, no `createUnzip`
(the Unzip class existed but was unexported), and no Brotli. The app reads
`zlib.constants.{Z_SYNC_FLUSH,BROTLI_OPERATION_FLUSH}` and uses `createUnzip`
and `createBrotliDecompress` for HTTP response decompression.

Add real `zlib.constants` (the standard Z_* and BROTLI_* numeric values Node
ships), expose `createUnzip` (backed by the native gunzip auto-detect), and
provide Brotli honestly: there is no Brotli codec in the native backend, so
`createBrotliCompress`/`createBrotliDecompress` construct (module-init that
builds a Brotli stream succeeds) but the stream errors on the first byte of
data, and `brotli*Sync` throw on call -- fail-loud, never silently wrong
bytes. gzip/deflate/inflate/createUnzip are real.

A scope probe (throwaway `claude -p`) proved the app reads zlib.constants and
constructs decompressors but never feeds the Brotli stream (no `Content-
Encoding: br` on the default Anthropic-API path), so the stub unblocks `-p`.
Full real Brotli (link libbrotli into the C build, or bundle a JS/wasm
decoder) is recorded as tracked future work in docs/posix-status.md.

Guard (host/test/esm-probe-guest.test.ts): zlib.constants Z_SYNC_FLUSH===2 and
BROTLI_OPERATION_FLUSH===1, createUnzip constructs, and createBrotliDecompress
errors on data (ZLIB 2 1 true true). All 14 esm-probe cases green after one
node.wasm rebuild. Throwaway `claude -p` acceptance: the zlib error is gone;
the next blocker is an ESM module-system re-entrancy error
("module record has unexpected status: Evaluating") -- the Phase F seed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e (M2 Phase F)

The Milestone 2 Phase B require(esm) seam (patch 0018) links and evaluates its
target unconditionally, so require() of a module that is mid-evaluation in a
dependency cycle re-enters JS::ModuleEvaluate on an already-Evaluating module
and the engine throws "module record has unexpected status: Evaluating" --
the blocker headless `claude -p` now hits in main init. The design adds a
module-status branch to the seam so a cyclic require returns the module's
partial namespace (Node's circular require(esm) semantics), an EvaluatingAsync
module reached via a cycle throws ERR_REQUIRE_ASYNC_MODULE, and an errored
already-Evaluated module rethrows its stored error -- with the confirmed cycle
repro kept as a regression guard. ABI-neutral (shell C++ compiled into
node.wasm).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ase F)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on spidermonkey-node

The require(esm) seam (patch 0018) linked and evaluated its target
unconditionally, so require() (or import.meta.require / the Bun __breq helper)
of a module mid-evaluation in a dependency cycle re-entered JS::ModuleEvaluate
on an already-Evaluating module and the engine threw "module record has
unexpected status: Evaluating". Branch on the module status (read from the
internal js::ModuleObject::status(), since JS::GetModuleStatus is not public in
ESR 140) after loadAndParse: an Evaluating module (cycle) returns its partial
live namespace (Node circular require(esm) semantics) without re-evaluating; an
Unlinked module links first; every other status evaluates as before
(JS::ModuleEvaluate is idempotent for linked/evaluating-async/evaluated, so a
pending top-level await still yields ERR_REQUIRE_ASYNC_MODULE and an errored
Evaluated module still rethrows). Two shared helpers de-duplicate the
namespace-return and the async-error synthesis. Requires builtin/ModuleObject.h
(placed before builtin/TestingUtility.h to satisfy the include-order style
check). ABI-neutral shell C++ compiled into node.wasm.

Guard (host/test/esm-probe-guest.test.ts): a cyclic import.meta.require pair
returns the partial namespace (CYC A B) instead of throwing; an uninitialized
binding read mid-cycle is TDZ (TDZ EA TDZ:ReferenceError); a captured namespace
sees a later-initialized export fill in (LIVE LATE). 17/17 esm-probe cases green.

Scope note: this fixes the synchronous require() cycle (the Milestone 2 Phase F
spec). The headless `claude -p` acceptance revealed the app's cyclic path also
exercises the *dynamic* import() cycle and, deeper, a cyclic module resolved
through the bare-specifier resolve hook during evaluation (engine
HostResolveImportedModule), which are separate, deeper facets deferred to a
follow-up phase -- so `-p` is not yet unblocked past this error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y-node (cyclic import)

After the sync require(esm) cycle fix, headless `claude -p` still died with
"module record has unexpected status: Evaluating". An instrumentation spike
pinned the exact site: SpiderMonkey's InnerModuleLinking (js/src/vm/Modules.cpp)
threw because its step-2 early-return set is {Linking, Linked, EvaluatingAsync,
Evaluated} -- it omits Evaluating. The ES spec omits it because it assumes the
whole module graph is linked before any evaluation begins, so a dependency can
never be mid-evaluation during linking. Kandelo's node-compat loads modules
incrementally (each require()/import() during evaluation links a fresh
sub-graph), so linking a newly loaded module legitimately recurses into an
already-evaluating module reached through a dependency cycle -- and the
assertion fired.

Add Evaluating to that early-return set (patch 0019). An evaluating module is
already fully linked (its environment and bindings were resolved before
evaluation started), so treat it like linked/evaluated and skip re-linking
instead of throwing. ABI-neutral shell/engine C++ compiled into node.wasm.

Guard (host/test/esm-probe-guest.test.ts): during alink's evaluation it
require()s blink, which statically imports alink; linking blink now recurses
into the still-evaluating alink and resolves (LINKCYC A A) instead of throwing.
18/18 esm-probe cases green.

Result: `claude -p` now gets past ALL module-loading/cycle errors and reaches
runtime -- the next blocker is a node-compat `vm` gap (`vm.runInContext` not a
function), a separate completeness item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… Phase H)

Headless `claude -p` reaches runtime and dies at `Vt.runInContext is not a
function`. The app uses Node's vm as a real security sandbox (null-prototype
contexts with codeGeneration disabled, ~32 runInContext sites); node-compat's
vm shim does no isolation. This design commits to a HOLISTIC, faithful Node vm
-- real bidirectional contextify via a sandbox-backed global (the mechanism
Gecko uses for Window), genuine codeGeneration enforcement (eval/Function throw
inside a strings:false context), and the full Script/createContext/isContext/
runInContext/runInNewContext/compileFunction surface with faithful cross-realm
value and error marshalling -- rather than an eval-based shim that would
silently break the sandbox. Any residual gap is a documented SpiderMonkey/wasm
boundary, never a silent compromise. Plan step 1 is a focused spike confirming
the sandbox-backed-global + codeGeneration mechanism before the rest is built.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…isolation (M2 Phase H)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…node

Implement Node's vm with real isolated contexts: a sandbox-backed global
(property ops delegate to the sandbox) gives faithful bidirectional contextify,
codeGeneration:{strings:false} disables eval/Function inside a context, and the
full createContext/isContext/runInContext/runInNewContext/runInThisContext/
Script/compileFunction surface marshals values and thrown errors across the
realm boundary. Backs the Claude Code sandbox (previously TypeError:
runInContext is not a function). C seam in the SpiderMonkey shell (patch 0020),
delegated through adapter.js, wrapped by node-compat's vm module. ABI-neutral.

Documented engine boundaries: reads are copy-on-first-access (ESR 140 JSClassOps
has no get/set interceptors, so an external sandbox mutation after first access
is not re-observed; write/delete/enumerate are fully live); options.timeout is
unsupported and throws rather than being silently ignored; createContext returns
a fresh isolated context per call (idempotency-by-sandbox not implemented).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hase I)

Headless `claude -p` reaches app runtime and dies at "embedded text asset is
missing or corrupt": the Claude app reads zstd-compressed embedded text assets
via Bun.zstdDecompressSync/zstdDecompress, but node.wasm has no zstd decoder
(native seam has zlib only; node-compat zlib.createZstdDecompress is a
_notImpl stub). This design adds native zstd DECOMPRESSION the same way zlib is
already linked: a kind=library `libzstd` dep (decompress-only, built from the
in-tree zstd single-file decoder amalgamation, proven to compile for wasm32)
linked into node.wasm, a self-contained __kandeloZstdDecompress C seam
(ZSTD_getFrameContentSize + ZSTD_decompress, streaming fallback, fail-loud on
error), delegated through adapter.js, graduating node-compat zlib's zstd stub
and wiring Bun.zstd* to it. Compress is out of scope (honest not-impl). Spike
confirmed the decode amalgamation compiles clean for wasm32 with the target
toolchain. ABI-neutral.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
brandonpayton and others added 14 commits September 7, 2026 12:28
A kind=library dep that builds a decompress-only libzstd.a from zstd 1.5.6's
official single-file decoder amalgamation, for linking into node.wasm (native
zstd decompression). Compress is intentionally absent. Mirrors the zlib
library package.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Why

Headless `claude -p` aborted initialization with
`Error: embedded text asset is missing or corrupt`. The Claude Code
app stores some of its embedded text assets zstd-compressed and reads
them at startup through `Bun.zstdDecompressSync` (and the Node 22+
`zlib.zstdDecompressSync` it maps to). spidermonkey-node's node.wasm
had no zstd decoder, so every such read threw and the CLI could not
start. Compression codecs are a platform capability, not something a
consumer should have to polyfill, so this closes the gap in the
runtime rather than working around it in the app.

## What changed

Native zstd DECOMPRESSION, wired the same way patch 0012 links libz:

- New decode-only library dependency `packages/registry/libzstd`
  (zstd 1.5.6, single-file decoder amalgamation → `libzstd.a` +
  `zstd.h`). Declared as a `spidermonkey` dependency and classified in
  `packages/sets/local-supported.toml`, so the build front-door
  resolves it and passes `WASM_POSIX_DEP_LIBZSTD_DIR`; the linker adds
  `libzstd.a` to LDFLAGS and the include dir to CFLAGS/CXXFLAGS.
- SpiderMonkey shell patch 0021 adds `__kandeloZstdDecompress(bytes)`:
  explicit-size frames via `ZSTD_getFrameContentSize` +
  `ZSTD_decompress`, unknown-size frames via a streaming
  `ZSTD_decompressStream` loop. It fails loud — a truncated or corrupt
  frame throws `"zstd decompression failed: <ZSTD_getErrorName>"`
  rather than returning partial bytes.
- adapter.js delegates the new native through the `_nodeNative`
  whitelist. bootstrap.js graduates `zlib.zstdDecompressSync` and
  `zlib.createZstdDecompress` to real implementations over the seam;
  bun-run.js exposes `Bun.zstdDecompressSync`/`zstdDecompress`.
- COMPRESSION stays honest not-implemented: the decode-only
  amalgamation omits the compressor, so `zlib.zstdCompressSync` /
  `createZstdCompress` remain fail-loud stubs. Documented in
  `docs/posix-status.md`.

This change is ABI-neutral: it is a SpiderMonkey shell C++ addition
plus a statically linked library compiled into node.wasm, with no
kernel, syscall, struct, or `ABI_VERSION` change.

## Validation

- `./run.sh build spidermonkey-node` succeeds; `check_spidermonkey_style.py`
  passes (patch 0021's `<zstd.h>` include-order is correct).
- `host/test/esm-probe-guest.test.ts` — the new zstd case decodes a
  known frame via both `zlib.zstdDecompressSync` and
  `Bun.zstdDecompressSync` to the exact text and confirms a corrupt
  frame fails loud (`ZSTD true hello zstd from kandelo THROW`). All
  Phase A–H ESM/vm/cyclic cases stay green. (One pre-existing in-kernel
  case per run hits its inline 90s timeout under concurrent host load;
  the failure rotates between different pre-existing tests across runs
  and is unrelated to this additive change.)
- Throwaway `claude -p` acceptance (isolated HOME/CLAUDE_CONFIG_DIR,
  dummy API key, TCP egress): the 1819-module graph loads and init no
  longer hits `embedded text asset is missing or corrupt`; it now
  advances past asset load. Probe deleted after use.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion

## Why

Task-review of the native zstd decompression seam (patch 0021) found two
defects against the fail-loud and bounded-allocation constraints. Because
the seam decodes UNTRUSTED embedded bytes, both are real:

1. The streaming decode path (used by frames whose header carries no
   content size — including the app's own embedded assets) broke out of
   the decode loop when input was exhausted WITHOUT checking that the
   frame had actually completed, silently returning the partial bytes
   decoded so far. A truncated-tail frame must fail, not return garbage.
2. The explicit-content-size path called `out.resize(contentSize)` with a
   value taken directly from the frame header. A ~20-byte crafted frame
   can declare a multi-gigabyte size, so the allocation throws `bad_alloc`
   out of the JSNative (an abort/DoS) rather than a catchable JS error.

## What changed

- Streaming path: break only when the decoder reports the frame complete
  (`ZSTD_decompressStream` returns 0). If it wants more input but the
  input is exhausted, throw `"zstd decompression failed: truncated frame"`.
- Both paths: cap decoded size at a documented 512 MiB
  (`kMaxZstdDecodeBytes`) — far above any real embedded asset, safely below
  the wasm guest's address space — and fail loud when a frame's declared
  or accumulated size exceeds it, before any large allocation.
- Empty input now decodes to empty output (matches Node/zlib-family
  behavior) instead of throwing "not a valid frame".
- esm-probe zstd case now also asserts a truncated valid frame fails loud
  and empty input yields empty: `THROW THROW EMPTY`.

Patch 0021 was regenerated as a real diff against the post-0020 source
base (not a hand-edited hunk). node.wasm rebuilt; `check_spidermonkey_style.py`
passes; esm-probe 25/25 green including the new assertions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Why

Headless `claude -p` on spidermonkey-node now boots + decompresses embedded
zstd assets (Phase I) but aborts when the app require()s a Markdown embedded
asset (loopAutonomousPreamble.md) — node-compat compiles it as an ES module
(SyntaxError '#') instead of returning its contents as a string. Bun's
standalone graph tags each module with a loader byte; our runtime drops it and
dispatches by file extension, which cannot disambiguate the app's loaders
(.js and .mjs each appear under multiple loaders).

## What changed

Design doc for the faithful fix: carry Bun's per-file loader byte through
bun-extract into a cache manifest and honor it in node-compat require() —
text→string, file→on-disk path, json→JSON.parse, js→module, napi→honest throw.
Closes the .md text-loader blocker now and the imminent .zst file-loader wave
(read-by-path then Bun.zstdDecompressSync) in one coherent contract. Two tasks;
ABI-neutral; scope boundary (static import-of-asset) documented.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Why

Bun's standalone module graph tags each module with a loader byte
(text/file/json/js/napi). bun-extract dropped it, so the runtime had no
way to tell that a .md is a text asset (return its string) versus a
module to execute. This records that decision so require() can honor it.

## What changed

bun-extract classifies each module by its loader byte, gates the
/$bunfs/root/->cachedir remap on class==js (writing text/file/json/napi
verbatim, fixing a latent bug where a file-loader .js was remapped), and
emits "loaders":{"<relpath>":"text"|"file"|"json"|"napi"} into
cache/manifest.json (js omitted = default; unknown bytes omitted +
counted to stderr). Guest test asserts the manifest and verbatim writes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Why

Headless `claude -p` decompresses its embedded zstd assets (Phase I) then
aborted: the app require()s a Markdown embedded asset
(loopAutonomousPreamble.md) and node-compat compiled it as an ES module
(SyntaxError '#') instead of returning its contents as a string. Bun's
standalone graph records a loader byte per module; the runtime must honor
it, because file extension cannot disambiguate (.js and .mjs each appear
under multiple loaders).

## What changed

bun-run reads the loader map bun-extract now emits and installs
globalThis.__kandeloAssetLoaders (absolute cache path -> class). node-compat
require() consults it before shebang-strip / .json-check / ESM dispatch:
text -> raw string, file -> on-disk path, json -> JSON.parse, js -> module
(unchanged), napi -> honest throw (native addons unsupported; real support
is future work needing the in-Kandelo C/C++ toolchain). Backward compatible:
no map -> require() unchanged. esm-probe covers all four dispatch paths;
`claude -p` now advances past the .md text-asset load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Why

Headless claude -p hangs on a subprocess deadlock: node-compat's
child_process is synchronous popen-only (pid:0, no stdin, no streaming,
no signals), so a spawned /bin/sh blocks on read(0)=EAGAIN with no way
to deliver stdin EOF. The agent tool loop needs a real async subprocess.

## What changed

Design doc for a real async child_process built on a general POSIX
primitive surface (Approach 2): native bindings for posix_spawn (+file
actions incl. chdir), pipe, async fd read/write/close, waitpid, kill,
generalizing patch 0012's socket watch-list + job-queue loop to
arbitrary fds and child reaping; plus wiring the dead real-delay timer
drain into that loop. node-compat child_process rewritten in JS to a
real streaming ChildProcess. ABI-neutral (existing syscalls). posix_spawn
preserves observable semantics incl. cwd; detached/uid/gid documented as
kernel boundaries. Two tasks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e K)

## Why

node-compat had no real subprocess primitives — only synchronous popen.
The Claude Code agent loop needs to spawn tools with streaming stdio,
deliver stdin EOF, learn exit codes, and kill children. This adds the
POSIX seam those require, and fixes real-delay timers (which were dead).

## What changed

SpiderMonkey shell patch 0022 adds __kandeloSpawn (posix_spawn with
dup2/close/open/chdir file-actions), __kandeloPipe, async
__kandeloFdRead/Write/Close, __kandeloWaitPid (WNOHANG reap), and
__kandeloKill, by generalizing patch 0012's socket watch-list + job-queue
poll loop to arbitrary fds and child reaping. Wires the previously-dead
__kandeloRunDueTimers into that loop so real-delay setTimeout fires.
All natives delegated through adapter.js. ABI-neutral (existing syscalls).
Guest seam test proves spawn+pipe+read+reap+timer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Why

Headless claude -p deadlocked: node-compat's child_process ran every
command through synchronous popen (pid:0, no stdin, no streaming), so a
spawned shell blocked on read(0)=EAGAIN with no way to deliver stdin EOF.

## What changed

Rewrote child_process spawn/exec/execFile on the Task-1 POSIX seam:
a real streaming ChildProcess (async stdin/stdout/stderr, real pid,
'exit'(code,signal)/'close'/'error', kill(), full stdio matrix, cwd,
shell). child.stdin.end() closes the write pipe -> sticky EOF, which
dissolves the -p deadlock. spawnSync/execSync stay popen-based (follow-up).
Guest tests cover streaming, the stdin-EOF regression, exit codes, kill,
and cwd. Boundaries (detached/uid/gid) documented in posix-status.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…se K review)

## Why

Task-2 review found two Important issues: (1) exec/execFile invoked
their callback on 'exit', which races the async stdout/stderr drain
and can deliver truncated output; a null exit code (signal-killed
child) was also silently treated as success. (2) posix-status.md
wrongly blamed the kernel for not implementing SETSID/SETPGROUP —
the kernel process table fully implements both (and RESETIDS); the
real gap is that node-compat's spawn() never wires
options.detached/uid/gid into the posix_spawnattr it builds.

## What changed

exec/execFile now share a _runWithCallback helper that waits for
'close' (after stdio has fully drained) and reports failure on
`code !== 0 || signal !== null`, matching Node's own exec() error
shape (.code/.signal/.killed). child.kill() now sets child.killed.
Rewrote the posix-status.md boundary: SETSID/SETPGROUP/RESETIDS are
kernel-implemented and unit-tested; options.detached is a small
node-compat wiring gap, while options.uid/gid have no kernel-side
posix_spawn mechanism yet at all (no SpawnAttrs field, no FileAction
op) -- POSIX itself has no such spawn attribute either.

Added two guest tests: exec() delivers non-truncated stdout for a
5000-byte command, and exec() of a non-zero-exit command yields an
Error with the real .code instead of reporting success.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gression, spawn/exec fd leaks, SIGPIPE) — M2 Phase K

## Why

The async child_process phase's whole-phase review found four
fix-before-merge issues that reach the CLI's own hot paths.

The shared Writable change (adding `_maybeFinish`/`_writableState.ending`
`pending` so child stdin delivers EOF only after in-flight writes settle)
silently regressed every Duplex-derived stream — net.Socket, tls.TLSSocket,
Transform, PassThrough, zlib Gunzip — because the Duplex mixin copied only
`write`/`end`/`destroy` and a partial `_writableState`, so `.write()`/`.end()`
threw `TypeError: this._maybeFinish is not a function`. That breaks http/https
`socket.write` and `.pipe(createGunzip())`.

Two parent-side pipe fds leaked: a posix_spawn failure (e.g. ENOENT) returned
without closing the stdio pipe fds already created (up to 6 per failed spawn),
and exec/execFile never ended the child's stdin, leaking its write fd per call.
Under Claude Code's heavy ENOENT-retry shell-out loops both reach EMFILE.

Writing to a pipe/socket whose read end has closed raises SIGPIPE, whose
kernel default disposition is Terminate — with no `signal(SIGPIPE, SIG_IGN)`
anywhere in the guest, `child.stdin.write()` after a child exits could kill the
node process instead of surfacing EPIPE as a stream error.

## What changed

- Duplex now initializes the full `_writableState` shape
  `{ended,ending,pending}` + `options.final`, and the mixin copies
  `_maybeFinish`, so every Writable-shaped stream shares one coherent contract.
- `_spawn`'s posix_spawn-failure catch closes every created pipe fd (child and
  parent ends) before returning the error-child.
- exec/execFile (`_runWithCallback`) end the child's stdin immediately, closing
  the parent write fd and delivering stdin EOF (matching Node), close-once.
- SpiderMonkey shell patch 0022 adds `signal(SIGPIPE, SIG_IGN)` at node.wasm
  startup, exactly as Node does. Authored via the real-diff method against the
  reconstructed post-0021 js.cpp; applies clean; check_spidermonkey_style ok.

Guest tests add a Duplex/PassThrough write+end regression guard and a
SIGPIPE stdin-write-after-exit survival guard; child_process 9/9 and
esm-probe 26/26 green under the rebuilt node.wasm. ABI-neutral.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant