Repository navigation
fix(runtime): method calls on CJS-default namespaces reach their module — cp.spawn() was undefined, never spawning (#9485 layer 1) #9498
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| **`require('child_process').spawn(...)` now actually spawns.** A method call on | ||
| the CJS-default namespace object that `require()` / | ||
| `process.getBuiltinModule()` returns dispatched under the module name | ||
| `child_process.default`, which the runtime's method-call router did not | ||
| normalize — so the call found no dispatch bucket and returned `undefined` | ||
| without launching a process, throwing nothing. | ||
|
|
||
| The tell was the asymmetry. `const f = cp.spawn; f(...)`, `cp.spawn.call(...)` | ||
| and `(0, cp.spawn)(...)` all worked, because the property-READ path resolves | ||
| through `cjs_default_base_module`. Only the fused member-call form | ||
| (`cp.spawn(...)`, `cp['spawn'](...)`) was broken, because the router carried a | ||
| second, hand-maintained copy of that table — and it had drifted. | ||
| `child_process.default`, `constants.default`, `dns.default`, | ||
| `dns/promises.default`, `inspector.default`, `inspector/promises.default`, | ||
| `module.default`, `repl.default`, `sea.default` and `wasi.default` were all | ||
| missing from it, while `os.default`, `path.default`, `util.default` and friends | ||
| were present — which is why the failure looked module-specific rather than | ||
| structural. | ||
|
|
||
| `const cp = require('child_process'); cp.spawn(cmd, args, options)` is | ||
| verbatim what `cross-spawn` does, so every consumer of it inherited the bug: | ||
| `execa`, `which` (via `isexe`), and the MCP SDK's `StdioClientTransport`. Under | ||
| Perry, claude-code could *be* an MCP stdio server but never *connect* as one — | ||
| `claude mcp list` reported `✗ Failed to connect` for every configured stdio | ||
| server while `strace` showed no `execve` at all, because | ||
| `StdioClientTransport.start()` immediately dereferenced the `undefined` that | ||
| came back from `spawn()`. | ||
|
|
||
| The router now falls through to the one canonical table instead of duplicating | ||
| it, and the normalization is split into `normalize_dispatch_module_name` so the | ||
| agreement between the two paths is unit-testable: every `<mod>.default` name in | ||
| `cjs_default_base_module` must normalize to the same base module the | ||
| property-read path picks, and must reach the same dispatch bucket. Adding a row | ||
| to the canonical table without teaching the router about it is now a test | ||
| failure rather than a silent `undefined`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| // #9485: a METHOD CALL on the CJS-default namespace object that | ||
| // `require('child_process')` (and `process.getBuiltinModule('child_process')`) | ||
| // returns dispatched under the module name `"child_process.default"`. The | ||
| // runtime's method-call router normalized only a hand-maintained subset of the | ||
| // `<mod>.default` names — `child_process.default` was not on it — so the call | ||
| // found no dispatch bucket and returned `undefined` WITHOUT SPAWNING ANYTHING. | ||
| // | ||
| // The asymmetry is the tell: the two-step forms (`const f = cp.spawn; f(...)`, | ||
| // `cp.spawn.call(...)`) always worked, because the property-READ path resolves | ||
| // through the canonical `cjs_default_base_module` table. Only the fused | ||
| // member-call form was broken. | ||
| // | ||
| // That is exactly the shape `cross-spawn` uses | ||
| // (`const cp = require('child_process'); cp.spawn(command, args, options)`), | ||
| // which is what the MCP SDK's StdioClientTransport spawns servers with — so | ||
| // claude-code under perry reported `✗ Failed to connect` for every stdio MCP | ||
| // server while never launching a child at all. | ||
| import { createRequire } from "node:module"; | ||
|
|
||
| const req = createRequire(import.meta.url); | ||
|
|
||
| const cp: any = req("child_process"); | ||
| const dns: any = req("dns"); | ||
| const os: any = req("os"); | ||
|
|
||
| // ── 1. the regression: fused member call on the CJS-default namespace ── | ||
| const sync = cp.spawnSync("/bin/echo", ["spawnSync-member"], { encoding: "utf8" }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Use a platform-neutral child-process command.
Also applies to: 32-32, 35-35, 39-39, 48-48 🤖 Prompt for AI Agents |
||
| console.log("spawnSync member typeof:", typeof sync); | ||
| console.log("spawnSync member stdout:", String(sync.stdout).trim()); | ||
|
|
||
| // A computed member call takes the same route. | ||
| const syncComputed = cp["spawnSync"]("/bin/echo", ["spawnSync-computed"], { encoding: "utf8" }); | ||
| console.log("spawnSync computed stdout:", String(syncComputed.stdout).trim()); | ||
|
|
||
| console.log("execSync member stdout:", String(cp.execSync("/bin/echo execSync-member")).trim()); | ||
|
|
||
| // ── 2. control: the two-step forms were never broken ── | ||
| const hoisted = cp.spawnSync; | ||
| console.log("spawnSync hoisted stdout:", String(hoisted("/bin/echo", ["hoisted"], { encoding: "utf8" }).stdout).trim()); | ||
|
|
||
| // ── 3. sibling `<mod>.default` namespaces that shared the gap ── | ||
| console.log("dns.getServers isArray:", Array.isArray(dns.getServers())); | ||
|
|
||
| // ── 4. a namespace that was already on the list, as a negative control ── | ||
| console.log("os.platform is string:", typeof os.platform() === "string"); | ||
|
|
||
| // ── 5. the streaming spawn the MCP client actually uses ── | ||
| const child = cp.spawn("/bin/echo", ["spawn-streams"]); | ||
| console.log("spawn member typeof:", typeof child); | ||
| console.log("spawn member pid is number:", typeof child.pid === "number"); | ||
| await new Promise<void>((resolve) => { | ||
| let buf = ""; | ||
| child.stdout.on("data", (chunk: any) => { | ||
| buf += chunk.toString(); | ||
| }); | ||
| child.on("exit", () => { | ||
| console.log("spawn member stdout:", buf.trim()); | ||
| resolve(); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Keep the coverage guarantee accurate.
CJS_DEFAULT_NAMESPACESis a separate manual list. If a newcjs_default_base_modulearm is not also added to this list, neither test evaluates it. The current comments and changelog therefore overstate the protection.crates/perry-runtime/src/object/native_module_dispatch.rs#L374-L377: derive test cases from an iterable canonical mapping, or state that the list covers the mappings known when the test was written.changelog.d/9485-cjs-default-namespace-method-call.md#L29-L35: revise the claim that adding a canonical mapping row automatically causes a test failure.Based on learnings: Perry changelog fragments must ensure the entry detail matches the change type.
📍 Affects 2 files
crates/perry-runtime/src/object/native_module_dispatch.rs#L374-L377(this comment)changelog.d/9485-cjs-default-namespace-method-call.md#L29-L35🤖 Prompt for AI Agents
Source: Learnings