From 76946386ebbb112ba3ac6f5c9082b06c12cb69d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 2 Sep 2026 14:20:37 +0200 Subject: [PATCH 1/4] refactor(cjs-default): one shared table for the `.default` module set (#9500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The set of Node builtins whose CommonJS `module.exports` is a distinct `.default` namespace was hand-maintained in five places: the runtime's `cjs_default_base_module` and `cjs_default_namespace_name` tables, the `cjs_default_export_value` match arm, the method-call router's list (which drifted far enough to break `require('child_process').spawn` — #9485/#9498), and the HIR's `is_cjs_style_native_default_import`, itself duplicated in `module_decl/native_default_import.rs` and `lower_expr/helpers.rs` with the two copies already disagreeing (`ffi`, `inspector`, `inspector/promises`, `wasi` missing from the latter). Move the table to `perry-dispatch` (the crate both perry-hir and perry-runtime already depend on) as `CJS_DEFAULT_NAMESPACE_MODULES`, built by a macro from one literal per module so the two spellings cannot disagree, and derive every consumer from it: - runtime: `cjs_default_base_module` / `cjs_default_namespace_name` become views over the table; the `cjs_default_export_value` wildcard arm is a guard on `has_cjs_default_namespace` (the explicit arms before it — the callable/plain-namespace defaults — keep winning, so behaviour is unchanged); - HIR: one predicate, derived from the table plus the spelled-out differences (`events` and the `sys`/`path/posix`/`path/win32` aliases are CJS-style; `node-pty`/`process`/`repl`/`sea` stay on the namespace-object default), used by both former call sites; - tests pin the table's shape, the HIR classification of every row, and that the router test's spelled-out list equals the table in both directions. perry-runtime gains perry-dispatch as a regular dependency (it was build-only); the crate has no dependencies of its own. Claude-Session: https://claude.ai/code/session_0184JRgBs978K6X7qKJFB4Hp --- .../perry-dispatch/src/cjs_default_modules.rs | 151 ++++++++++++++++++ crates/perry-dispatch/src/lib.rs | 5 + .../perry-hir/src/lower/lower_expr/helpers.rs | 27 +--- crates/perry-hir/src/lower/module_decl.rs | 2 +- .../module_decl/native_default_import.rs | 124 +++++++++++--- crates/perry-runtime/Cargo.toml | 4 + .../perry-runtime/src/object/native_module.rs | 68 ++------ .../src/object/native_module_dispatch.rs | 19 +++ 8 files changed, 296 insertions(+), 104 deletions(-) create mode 100644 crates/perry-dispatch/src/cjs_default_modules.rs diff --git a/crates/perry-dispatch/src/cjs_default_modules.rs b/crates/perry-dispatch/src/cjs_default_modules.rs new file mode 100644 index 0000000000..798f436d19 --- /dev/null +++ b/crates/perry-dispatch/src/cjs_default_modules.rs @@ -0,0 +1,151 @@ +//! The one table of Node builtins whose CommonJS `module.exports` is a +//! namespace object distinct from the ESM namespace — the modules for which +//! `require('')`, `import x from ''` and +//! `process.getBuiltinModule('')` hand out a `.default` namespace +//! whose method calls and property reads must reach the base module. +//! +//! #9500 (from #9485 / #9498): this knowledge used to live in FOUR +//! hand-maintained copies — the runtime's `cjs_default_base_module` and +//! `cjs_default_namespace_name` tables, the `cjs_default_export_value` match +//! arm, the method-call router's own `.default → base` list, and the +//! HIR's `is_cjs_style_native_default_import` (itself duplicated in two files +//! that had already drifted apart: one lacked `ffi`, `inspector`, +//! `inspector/promises` and `wasi`). The router's copy drifted far enough that +//! `require('child_process').spawn(...)` dispatched under a name with no +//! bucket and returned `undefined` WITHOUT SPAWNING, which is why claude-code's +//! MCP stdio client reported `Failed to connect` (#9485). Every consumer now +//! derives from this table: adding a module here is the whole edit. +//! +//! Base names are the runtime's canonical spellings (`path.posix`, not +//! `path/posix`; `util`, not `sys`) — the alias folding happens in +//! `normalize_native_module_name` before any lookup here. + +/// Builds the `(base, ".default")` pairs from one literal per module, so +/// the two spellings cannot disagree. +macro_rules! cjs_default_namespace_modules { + ($($base:literal),+ $(,)?) => { + /// `(base module, ".default")` for every Node builtin with a + /// distinct CommonJS default namespace. Sorted by base name. + pub const CJS_DEFAULT_NAMESPACE_MODULES: &[(&str, &str)] = + &[$(($base, concat!($base, ".default"))),+]; + }; +} + +cjs_default_namespace_modules!( + "async_hooks", + "child_process", + "cluster", + "constants", + "dns", + "dns/promises", + "ffi", + "inspector", + "inspector/promises", + "module", + "node-pty", + "os", + "path", + "path.posix", + "path.win32", + "process", + "punycode", + "querystring", + "repl", + "sea", + "url", + "util", + "wasi", +); + +/// Whether `base` (canonical spelling) has a distinct `.default` +/// CommonJS namespace. +pub fn has_cjs_default_namespace(base: &str) -> bool { + cjs_default_namespace_name(base).is_some() +} + +/// `base` → `".default"`, the name the CJS default namespace object is +/// created under. +pub fn cjs_default_namespace_name(base: &str) -> Option<&'static str> { + CJS_DEFAULT_NAMESPACE_MODULES + .iter() + .find(|(b, _)| *b == base) + .map(|(_, name)| *name) +} + +/// `".default"` → `base`: the module a CJS default namespace's method +/// calls and property reads dispatch against. +pub fn cjs_default_base_module(namespace_name: &str) -> Option<&'static str> { + CJS_DEFAULT_NAMESPACE_MODULES + .iter() + .find(|(_, name)| *name == namespace_name) + .map(|(base, _)| *base) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_row_round_trips() { + for (base, name) in CJS_DEFAULT_NAMESPACE_MODULES { + assert_eq!(*name, format!("{base}.default")); + assert_eq!(cjs_default_namespace_name(base), Some(*name)); + assert_eq!(cjs_default_base_module(name), Some(*base)); + assert!(has_cjs_default_namespace(base)); + } + } + + #[test] + fn rows_are_unique_and_sorted() { + let bases: Vec<&str> = CJS_DEFAULT_NAMESPACE_MODULES + .iter() + .map(|(b, _)| *b) + .collect(); + let mut sorted = bases.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(bases, sorted, "keep the table sorted and duplicate-free"); + } + + #[test] + fn base_names_are_canonical_spellings() { + for (base, _) in CJS_DEFAULT_NAMESPACE_MODULES { + assert!(!base.starts_with("node:"), "{base}: strip the node: scheme"); + assert!( + !matches!(*base, "sys" | "path/posix" | "path/win32"), + "{base}: an alias, not a canonical module name" + ); + } + } + + #[test] + fn modules_without_a_cjs_default_namespace_are_absent() { + for base in [ + "fs", + "crypto", + "events", + "http", + "stream", + "test", + "child_process.default", + ] { + assert!(!has_cjs_default_namespace(base), "{base}"); + assert_eq!(cjs_default_namespace_name(base), None, "{base}"); + } + assert_eq!(cjs_default_base_module("child_process"), None); + assert_eq!(cjs_default_base_module("fs.default"), None); + } + + /// The #9485 regression, pinned at the source of truth. + #[test] + fn child_process_default_maps_to_child_process() { + assert_eq!( + cjs_default_base_module("child_process.default"), + Some("child_process") + ); + assert_eq!( + cjs_default_namespace_name("child_process"), + Some("child_process.default") + ); + } +} diff --git a/crates/perry-dispatch/src/lib.rs b/crates/perry-dispatch/src/lib.rs index 7026ca6e7e..be1e330537 100644 --- a/crates/perry-dispatch/src/lib.rs +++ b/crates/perry-dispatch/src/lib.rs @@ -96,6 +96,7 @@ pub struct MethodRow { // re-exported below so consumers keep using `perry_dispatch::PERRY_*`. mod audio_table; mod background_table; +mod cjs_default_modules; mod i18n_table; mod ios_table; mod media_table; @@ -106,6 +107,10 @@ mod updater_table; pub use audio_table::PERRY_AUDIO_TABLE; pub use background_table::PERRY_BACKGROUND_TABLE; +pub use cjs_default_modules::{ + cjs_default_base_module, cjs_default_namespace_name, has_cjs_default_namespace, + CJS_DEFAULT_NAMESPACE_MODULES, +}; pub use i18n_table::PERRY_I18N_TABLE; pub use ios_table::PERRY_IOS_TABLE; pub use media_table::PERRY_MEDIA_TABLE; diff --git a/crates/perry-hir/src/lower/lower_expr/helpers.rs b/crates/perry-hir/src/lower/lower_expr/helpers.rs index db8b60f754..45679dda18 100644 --- a/crates/perry-hir/src/lower/lower_expr/helpers.rs +++ b/crates/perry-hir/src/lower/lower_expr/helpers.rs @@ -11,6 +11,10 @@ use anyhow::Result; use swc_ecma_ast as ast; use crate::lower_types::extract_ts_type_with_ctx; +// #9500: one CJS-default-import predicate, derived from the shared table — +// this file used to carry its own copy, which had drifted (no `ffi`, +// `inspector`, `inspector/promises`, `wasi`). +use crate::lower::module_decl::native_default_import::is_cjs_style_native_default_import; /// Whether `PERRY_GLOBAL_SCRIPT_THIS` is set — compile the program as a /// *global script* rather than a CJS module, so module top-level `this` @@ -185,29 +189,6 @@ pub(crate) fn is_fetch_global_value_name(name: &str) -> bool { ) } -pub(crate) fn is_cjs_style_native_default_import(module_name: &str) -> bool { - matches!( - module_name, - "async_hooks" - | "child_process" - | "cluster" - | "constants" - | "dns" - | "dns/promises" - | "events" - | "module" - | "os" - | "path" - | "path/posix" - | "path/win32" - | "punycode" - | "querystring" - | "sys" - | "url" - | "util" - ) -} - pub(crate) fn wrap_with_gets(property: &str, fallback: Expr, envs: Vec) -> Expr { envs.into_iter() .rev() diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index fc06854594..3f63f68aa7 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -10,7 +10,7 @@ use super::*; use crate::ir::*; mod namespace; -mod native_default_import; +pub(super) mod native_default_import; pub(super) mod native_profile_import; mod object_literal; mod static_import_bindings; diff --git a/crates/perry-hir/src/lower/module_decl/native_default_import.rs b/crates/perry-hir/src/lower/module_decl/native_default_import.rs index b509d628d3..1250026bad 100644 --- a/crates/perry-hir/src/lower/module_decl/native_default_import.rs +++ b/crates/perry-hir/src/lower/module_decl/native_default_import.rs @@ -10,31 +10,39 @@ pub(crate) fn canonicalize_native_import_source(raw_source: &str) -> String { } } +/// Whether a native module's default import binds its CommonJS +/// `module.exports` — a `default` property read plus a builtin-module alias +/// for member calls — rather than the historical namespace object. +/// +/// #9500: derived from the ONE shared table +/// (`perry_dispatch::CJS_DEFAULT_NAMESPACE_MODULES`) that the runtime's +/// property-read and method-call paths also consume, so the three cannot +/// drift apart again (the HIR alone used to carry two hand-written copies of +/// this list, and one had already lost `ffi`, `inspector`, +/// `inspector/promises` and `wasi`). The arms below are the deliberate +/// differences between "has a `.default` namespace at runtime" and +/// "lowers as a CJS-style default import", each spelled out so a new table +/// row is classified by this function automatically and only an exception +/// needs a line here. pub(crate) fn is_cjs_style_native_default_import(module_name: &str) -> bool { - matches!( - module_name, - "async_hooks" - | "child_process" - | "cluster" - | "constants" - | "dns" - | "dns/promises" - | "events" - | "ffi" - | "inspector" - | "inspector/promises" - | "module" - | "os" - | "path" - | "path/posix" - | "path/win32" - | "punycode" - | "querystring" - | "sys" - | "url" - | "util" - | "wasi" - ) + match module_name { + // `events`' CommonJS export is the `EventEmitter` class itself + // (`cjs_default_export_value("events")`), not a `.default` + // namespace, but the default import is still CJS-shaped. + "events" => true, + // Aliases the runtime folds before any table lookup + // (`normalize_native_module_name`: `sys` → `util`, `path/posix` → + // `path.posix`, `path/win32` → `path.win32`); the HIR sees the + // import's own spelling. + "sys" | "path/posix" | "path/win32" => true, + // Table rows whose default import the HIR keeps on the namespace + // object: `process` has its own lowering (the `source == "process"` + // arms in `module_decl.rs`); `node-pty`, `repl` and `sea` never took + // the CJS-style path — flipping them is a lowering change, not a + // dedup, and is left for a follow-up. + "node-pty" | "process" | "repl" | "sea" => false, + other => perry_dispatch::has_cjs_default_namespace(other), + } } pub(crate) fn node_submodule_default_export_key(module_name: &str) -> Option<&'static str> { @@ -43,3 +51,71 @@ pub(crate) fn node_submodule_default_export_key(module_name: &str) -> Option<&'s _ => None, } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Shared-table rows the HIR deliberately keeps on the namespace-object + /// default. Adding a row to the table classifies it CJS-style unless it + /// is listed here — so this list, not the table, is what a lowering + /// decision edits. + const NAMESPACE_OBJECT_DEFAULT_ROWS: &[&str] = &["node-pty", "process", "repl", "sea"]; + + #[test] + fn every_shared_table_row_is_classified() { + for (base, _) in perry_dispatch::CJS_DEFAULT_NAMESPACE_MODULES { + let expected = !NAMESPACE_OBJECT_DEFAULT_ROWS.contains(base); + assert_eq!( + is_cjs_style_native_default_import(base), + expected, + "`{base}`: shared-table row classified unexpectedly" + ); + } + for base in NAMESPACE_OBJECT_DEFAULT_ROWS { + assert!( + perry_dispatch::has_cjs_default_namespace(base), + "`{base}` is listed as an exclusion but is not a shared-table row" + ); + } + } + + /// The spellings only the HIR sees (aliases + the callable-default + /// `events`) stay CJS-style. + #[test] + fn hir_only_spellings_are_cjs_style() { + for module in ["events", "sys", "path/posix", "path/win32"] { + assert!(is_cjs_style_native_default_import(module), "{module}"); + } + } + + /// #9485 / #9500: the rows one of the two former copies had lost, plus + /// the module the regression was found on. + #[test] + fn formerly_drifted_rows_are_cjs_style() { + for module in [ + "child_process", + "ffi", + "inspector", + "inspector/promises", + "wasi", + ] { + assert!(is_cjs_style_native_default_import(module), "{module}"); + } + } + + #[test] + fn plain_esm_shaped_builtins_are_not() { + for module in [ + "fs", + "fs/promises", + "crypto", + "http", + "stream", + "test", + "buffer", + ] { + assert!(!is_cjs_style_native_default_import(module), "{module}"); + } + } +} diff --git a/crates/perry-runtime/Cargo.toml b/crates/perry-runtime/Cargo.toml index ec1f9b4340..0f83c76603 100644 --- a/crates/perry-runtime/Cargo.toml +++ b/crates/perry-runtime/Cargo.toml @@ -278,6 +278,10 @@ node-api-host = ["dep:hex", "dep:sha2"] dyn-eval = ["dep:perry-parser", "dep:perry-diagnostics"] [dependencies] +# #9500: the CJS-default module table (`.default` <-> base) is shared +# with perry-hir through perry-dispatch, so the runtime's property-read and +# method-call paths and the HIR's import lowering cannot drift apart. +perry-dispatch.workspace = true thiserror.workspace = true anyhow.workspace = true libc.workspace = true diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index 014de86012..768a9fa811 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -670,62 +670,16 @@ pub(crate) fn subtle_crypto_namespace() -> f64 { js_create_native_module_namespace(b"crypto.subtle".as_ptr(), "crypto.subtle".len()) } +/// `".default"` → `mod`. #9500: a thin view over the ONE shared table +/// (`perry_dispatch::CJS_DEFAULT_NAMESPACE_MODULES`); the hand-maintained copy +/// that used to live here is what the method-call router drifted from (#9485). pub(crate) fn cjs_default_base_module(module_name: &str) -> Option<&'static str> { - match module_name { - "async_hooks.default" => Some("async_hooks"), - "child_process.default" => Some("child_process"), - "cluster.default" => Some("cluster"), - "constants.default" => Some("constants"), - "dns.default" => Some("dns"), - "dns/promises.default" => Some("dns/promises"), - "ffi.default" => Some("ffi"), - "inspector.default" => Some("inspector"), - "inspector/promises.default" => Some("inspector/promises"), - "module.default" => Some("module"), - "node-pty.default" => Some("node-pty"), - "os.default" => Some("os"), - "path.default" => Some("path"), - "path.posix.default" => Some("path.posix"), - "path.win32.default" => Some("path.win32"), - "process.default" => Some("process"), - "punycode.default" => Some("punycode"), - "querystring.default" => Some("querystring"), - "repl.default" => Some("repl"), - "sea.default" => Some("sea"), - "url.default" => Some("url"), - "util.default" => Some("util"), - "wasi.default" => Some("wasi"), - _ => None, - } + perry_dispatch::cjs_default_base_module(module_name) } +/// `mod` → `".default"`, from the same shared table (#9500). fn cjs_default_namespace_name(module_name: &str) -> Option<&'static str> { - match module_name { - "async_hooks" => Some("async_hooks.default"), - "child_process" => Some("child_process.default"), - "cluster" => Some("cluster.default"), - "constants" => Some("constants.default"), - "dns" => Some("dns.default"), - "dns/promises" => Some("dns/promises.default"), - "ffi" => Some("ffi.default"), - "inspector" => Some("inspector.default"), - "inspector/promises" => Some("inspector/promises.default"), - "module" => Some("module.default"), - "node-pty" => Some("node-pty.default"), - "os" => Some("os.default"), - "path" => Some("path.default"), - "path.posix" => Some("path.posix.default"), - "path.win32" => Some("path.win32.default"), - "process" => Some("process.default"), - "punycode" => Some("punycode.default"), - "querystring" => Some("querystring.default"), - "repl" => Some("repl.default"), - "sea" => Some("sea.default"), - "url" => Some("url.default"), - "util" => Some("util.default"), - "wasi" => Some("wasi.default"), - _ => None, - } + perry_dispatch::cjs_default_namespace_name(module_name) } fn create_cjs_default_namespace(module_name: &str) -> Option { @@ -764,10 +718,12 @@ pub(crate) fn cjs_default_export_value(module_name: &str) -> Option { b"wasi.default".as_ptr(), "wasi.default".len(), )), - "async_hooks" | "child_process" | "constants" | "dns" | "dns/promises" | "ffi" - | "node-pty" | "os" | "path" | "path.posix" | "path.win32" | "punycode" | "querystring" - | "repl" | "sea" | "url" | "util" | "inspector" | "inspector/promises" => { - create_cjs_default_namespace(module_name) + // #9500: every remaining row of the shared CJS-default table gets its + // `.default` namespace — the arms above are the modules whose + // default export is NOT that namespace (a callable, or the plain + // namespace itself) and must keep winning. + other if perry_dispatch::has_cjs_default_namespace(other) => { + create_cjs_default_namespace(other) } _ => None, } diff --git a/crates/perry-runtime/src/object/native_module_dispatch.rs b/crates/perry-runtime/src/object/native_module_dispatch.rs index 2fba11baaf..ded6e1039e 100644 --- a/crates/perry-runtime/src/object/native_module_dispatch.rs +++ b/crates/perry-runtime/src/object/native_module_dispatch.rs @@ -456,6 +456,25 @@ mod cjs_default_dispatch_tests { ); } + /// #9500: the spelled-out list above IS the shared table + /// (`perry_dispatch::CJS_DEFAULT_NAMESPACE_MODULES`), in both directions — + /// so a row added there is exercised by the two tests above, and a row + /// listed here that the table dropped is a failure rather than a stale name. + #[test] + fn spelled_out_list_matches_the_shared_table() { + let mut listed: Vec<&str> = CJS_DEFAULT_NAMESPACES.to_vec(); + let mut table: Vec<&str> = perry_dispatch::CJS_DEFAULT_NAMESPACE_MODULES + .iter() + .map(|(_, name)| *name) + .collect(); + listed.sort_unstable(); + table.sort_unstable(); + assert_eq!( + listed, table, + "CJS_DEFAULT_NAMESPACES and perry_dispatch::CJS_DEFAULT_NAMESPACE_MODULES differ" + ); + } + /// The exact regression, pinned by name. #[test] fn child_process_default_dispatches_as_child_process() { From a23fa1b6c2a7d70a7f474cc0c6f8f8caadcb505d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 2 Sep 2026 14:20:37 +0200 Subject: [PATCH 2/4] test(gap): pin claude-code's MCP debug logger write shape (#9500) De-minified from the cc bundle: the `using`-downlevel fs wrapper (error stashed by a catch-block `var`, re-thrown from `finally`), the 1 s-timer / size / dispose buffered writer, the cleanup set awaited by graceful shutdown before `process.exit`, and the `try { appendFileSync } catch { mkdirSync(recursive); appendFileSync }` recovery arm that is the only code creating the log directory tree. Byte-compared to node. Claude-Session: https://claude.ai/code/session_0184JRgBs978K6X7qKJFB4Hp --- .../test_gap_9500_mcp_debug_logger_shape.ts | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 test-files/test_gap_9500_mcp_debug_logger_shape.ts diff --git a/test-files/test_gap_9500_mcp_debug_logger_shape.ts b/test-files/test_gap_9500_mcp_debug_logger_shape.ts new file mode 100644 index 0000000000..1161e35ca9 --- /dev/null +++ b/test-files/test_gap_9500_mcp_debug_logger_shape.ts @@ -0,0 +1,132 @@ +// #9500: claude-code's MCP debug logger wrote NOTHING under perry — not even +// the `~/.cache/claude-cli-nodejs//mcp-logs-/` tree — although +// the connect-failure path that feeds it demonstrably ran. This fixture is the +// bundle's exact write shape, de-minified: +// +// * every fs call goes through a wrapper compiled from a `using` declaration +// (esbuild's downlevel): the error is stashed by `var O=A,w=1` in the CATCH +// block and re-thrown from FINALLY by the dispose helper; +// * records go into a buffered writer flushed by a 1 s timer, a size cap, or +// `dispose()`; the logger registers `dispose` in a cleanup set that the +// graceful-shutdown path awaits (raced against a 2 s timer) before +// `process.exit`; +// * the flush's write function is `try { appendFileSync } catch { mkdirSync; +// appendFileSync }` — the ONLY code that ever creates the log directory +// tree, so it relies on the first append THROWING ENOENT. +// +// Under perry the append silently succeeded-without-writing (#9421, fixed for +// this surface by #9491), the recovery arm never ran, and the tree was never +// created. This pins the whole shape end to end: the throw, the `using` +// re-throw, the recursive mkdir recovery, the timer/dispose flush, and the +// exit sequencing. +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +// ── esbuild's `using` downlevel helpers, verbatim shape ────────────────────── +const SYM_DISPOSE = Symbol.dispose || Symbol.for("Symbol.dispose"); +const SYM_ASYNC_DISPOSE = Symbol.asyncDispose || Symbol.for("Symbol.asyncDispose"); +const rz = (q: any[], K: any, _: any) => { + if (K != null) { + if (typeof K !== "object" && typeof K !== "function") throw TypeError('Object expected to be assigned to "using" declaration'); + var z; + if (_) z = K[SYM_ASYNC_DISPOSE]; + if (z === void 0) z = K[SYM_DISPOSE]; + if (typeof z !== "function") throw TypeError("Object not disposable"); + q.push([_, z, K]); + } else if (_) q.push([_]); + return K; +}; +const oz = (q: any[], K: any, _: any) => { + var z = typeof (globalThis as any).SuppressedError === "function" + ? (globalThis as any).SuppressedError + : function (O: any, w: any, $: any, j?: any) { return (j = Error($)), (j.name = "SuppressedError"), (j.error = O), (j.suppressed = w), j; }, + Y = (O: any) => (K = _ ? new z(O, K, "An error was suppressed during disposal") : ((_ = !0), O)), + A: any = (O?: any) => { + while ((O = q.pop())) + try { + var w = O[1] && O[1].call(O[2]); + if (O[0]) return Promise.resolve(w).then(A, ($: any) => (Y($), A())); + } catch ($) { Y($); } + if (_) throw K; + }; + return A(); +}; +// Tracing is off in the shipped CLI: the span tag yields nothing disposable. +function Jw(_s: TemplateStringsArray, ..._v: any[]): any { return undefined; } + +// ── the fs wrapper (`V8()`), the two methods the logger uses ──────────────── +const V8 = { + appendFileSync(q: string, K: string) { let Y: any[] = []; try { const _ = rz(Y, Jw`fs.appendFileSync(${q})`, 0); fs.appendFileSync(q, K); } catch (A) { var O = A, w = 1; } finally { oz(Y, O, w); } }, + mkdirSync(q: string) { let Y: any[] = []; try { const _ = rz(Y, Jw`fs.mkdirSync(${q})`, 0); try { fs.mkdirSync(q, { recursive: true }); } catch ($: any) { if ($?.code !== "EEXIST") throw $; } } catch (A) { var O = A, w = 1; } finally { oz(Y, O, w); } }, +}; + +// ── the buffered writer (`bD6`) ───────────────────────────────────────────── +function bufferedWriter({ writeFn, flushIntervalMs = 1000, maxBufferSize = 100 }: { writeFn: (s: string) => void; flushIntervalMs?: number; maxBufferSize?: number }) { + let buf: string[] = [], timer: any = null, pending: string[] | null = null; + const clear = () => { if (timer) clearTimeout(timer), (timer = null); }; + const flush = () => { if (pending) writeFn(pending.join("")), (pending = null); if (buf.length === 0) return; writeFn(buf.join("")), (buf = []), clear(); }; + const arm = () => { if (!timer) timer = setTimeout(flush, flushIntervalMs); }; + const flushSoon = () => { if (pending) { pending.push(...buf), (buf = []), clear(); return; } const M = buf; buf = [], clear(), (pending = M), setImmediate(() => { const P = pending; if (((pending = null), P)) writeFn(P.join("")); }); }; + return { write(M: string) { buf.push(M), arm(), buf.length >= maxBufferSize && flushSoon(); }, flush, dispose() { flush(); } }; +} + +// ── the cleanup registry (`eq` / `_w8`) ───────────────────────────────────── +const cleanups = new Set<() => Promise>(); +function onCleanup(fn: () => Promise) { cleanups.add(fn); return () => cleanups.delete(fn); } +async function runCleanups() { await Promise.all(Array.from(cleanups).map((q) => q())); } + +// ── the per-file logger cache (`BJ7`) and the MCP sinks ───────────────────── +let recoveries = 0; +const loggers = new Map>(); +function loggerFor(file: string) { + let K = loggers.get(file); + if (!K) { + const dir = path.dirname(file); + const w = bufferedWriter({ + writeFn: (z) => { try { V8.appendFileSync(file, z); } catch { recoveries++; V8.mkdirSync(dir); V8.appendFileSync(file, z); } }, + flushIntervalMs: 1000, + maxBufferSize: 50, + }); + K = { write: (o: unknown) => w.write(JSON.stringify(o) + "\n"), flush: w.flush, dispose: w.dispose }; + loggers.set(file, K); + onCleanup(async () => K?.dispose()); + } + return K; +} +const home = fs.mkdtempSync(path.join(os.tmpdir(), "gap9500-")); +const cacheRoot = path.join(home, ".cache", "claude-cli-nodejs", "-cwd-key"); // nothing under `home` exists yet +const mcpLogPath = (server: string) => path.join(cacheRoot, `mcp-logs-${server}`, "session.jsonl"); +function logMCPDebug(server: string, msg: string) { loggerFor(mcpLogPath(server)).write({ debug: msg, timestamp: "T" }); } +function logMCPError(server: string, err: unknown) { loggerFor(mcpLogPath(server)).write({ error: err instanceof Error ? err.message : String(err), timestamp: "T" }); } + +// ── the connect-failure path, then graceful shutdown (`WK`) ───────────────── +logMCPDebug("alpha", "Connection failed: spawn /bin/echo ENOENT"); +logMCPError("alpha", new Error("Connection failed: spawn /bin/echo ENOENT")); +logMCPDebug("beta", "Connection failed: fetch failed"); +console.log("queued; tree exists before flush:", fs.existsSync(path.join(home, ".cache"))); + +function report() { + for (const server of ["alpha", "beta"]) { + const p = mcpLogPath(server); + const exists = fs.existsSync(p); + const records = exists ? fs.readFileSync(p, "utf8").trim().split("\n").map((l) => JSON.parse(l)) : []; + console.log(`${server}: exists=${exists} records=${records.length}`, records.map((r) => r.debug ?? `ERR ${r.error}`).join(" | ")); + } + console.log("recoveries:", recoveries); + console.log("tree:", fs.existsSync(cacheRoot) ? fs.readdirSync(cacheRoot).sort().join(",") : ""); + fs.rmSync(home, { recursive: true, force: true }); +} +async function gracefulShutdown(code: number) { + let timer: any; + try { + await Promise.race([ + (async () => { try { await runCleanups(); } catch {} })(), + new Promise((_resolve, reject) => { timer = setTimeout((rej: (e: Error) => void) => rej(new Error("cleanup timeout")), 2000, reject); }), + ]); + clearTimeout(timer); + } catch { clearTimeout(timer); } + report(); + process.exit(code); +} +void gracefulShutdown(0); From f5d15197581e1c9be754d1ab976d1bc399500940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 2 Sep 2026 14:22:29 +0200 Subject: [PATCH 3/4] test(gap): exec/execFile callbacks fire in completion order (#9500 part 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issue's inverted exec→execFile order for two instant echos is a same-turn batch-delivery artefact (node itself flips it with submission order); the property both engines actually guarantee — a child that finishes first calls back first, whichever API launched it — is what this pins. Claude-Session: https://claude.ai/code/session_0184JRgBs978K6X7qKJFB4Hp --- ...gap_9500_exec_callback_completion_order.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 test-files/test_gap_9500_exec_callback_completion_order.ts diff --git a/test-files/test_gap_9500_exec_callback_completion_order.ts b/test-files/test_gap_9500_exec_callback_completion_order.ts new file mode 100644 index 0000000000..d6941258a6 --- /dev/null +++ b/test-files/test_gap_9500_exec_callback_completion_order.ts @@ -0,0 +1,32 @@ +// #9500 (part 2): `cp.exec` / `cp.execFile` callbacks fire in COMPLETION order, +// not submission order — a child that finishes first calls back first +// regardless of which API launched it or which call came first. (When both +// children finish inside the same loop turn, node's order is a libuv +// batch-delivery artefact, not a rule — the issue's "exec→execFile vs +// execFile→exec" for two instant `echo`s — so only the order with a real +// completion gap is pinned here.) +import * as cp from "node:child_process"; + +function race(label: string, first: () => Promise, second: () => Promise) { + const order: string[] = []; + const tag = (name: string) => (p: Promise) => p.then((out) => { order.push(`${name}:${out}`); }); + return Promise.all([tag("A")(first()), tag("B")(second())]).then(() => { + console.log(label, "→", order.join(" ")); + }); +} +const exec = (cmd: string) => new Promise((res) => cp.exec(cmd, (_e, out) => res(String(out).trim()))); +const execFile = (file: string, args: string[]) => new Promise((res) => cp.execFile(file, args, (_e, out) => res(String(out).trim()))); + +// exec submitted first but slow; execFile submitted second and instant. +race("slow exec, instant execFile", () => exec("sleep 0.3; echo slow"), () => execFile("/bin/echo", ["fast"])) + // execFile submitted first but slow (via sh); exec second and instant. + .then(() => race("slow execFile, instant exec", () => execFile("/bin/sh", ["-c", "sleep 0.3; echo slow"]), () => exec("echo fast"))) + // three children with staggered durations, submitted longest-first. + .then(() => { + const order: string[] = []; + return Promise.all([ + exec("sleep 0.45; echo c").then((o) => { order.push(o); }), + execFile("/bin/sh", ["-c", "sleep 0.3; echo b"]).then((o) => { order.push(o); }), + exec("sleep 0.15; echo a").then((o) => { order.push(o); }), + ]).then(() => console.log("staggered → " + order.join(" "))); + }); From 1ac69757dcd05b82fb4ee604cbcffcd1fbcf6bf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 2 Sep 2026 14:47:31 +0200 Subject: [PATCH 4/4] changelog: fragment for #9531 (#9500) --- ...cjs-default-table-mcp-logger-exec-order.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 changelog.d/9531-cjs-default-table-mcp-logger-exec-order.md diff --git a/changelog.d/9531-cjs-default-table-mcp-logger-exec-order.md b/changelog.d/9531-cjs-default-table-mcp-logger-exec-order.md new file mode 100644 index 0000000000..82c15e6cbf --- /dev/null +++ b/changelog.d/9531-cjs-default-table-mcp-logger-exec-order.md @@ -0,0 +1,25 @@ +**One shared table for the CommonJS-default module set; claude-code's MCP +debug logger shape and exec/execFile callback order pinned (#9500).** + +The set of Node builtins whose `require()` / default import hands out a +distinct `.default` namespace was hand-maintained in five places (two of +them inside the HIR alone, already disagreeing on `ffi`, `inspector`, +`inspector/promises` and `wasi`); the method-call router's copy is the one +that drifted far enough to break `require('child_process').spawn` (#9485, +#9498). The table now lives once in `perry-dispatch`, built from one literal +per module, and the runtime's property-read and method-call paths, the +`default`-export resolver and the HIR's import lowering all derive from it. +Adding a module is one line; tests pin the table's shape, the HIR's +classification of every row, and the router test's list against the table in +both directions. No behaviour change. + +Two fixtures pin the issue's other findings. The MCP debug logger's exact +write shape — the `using`-downlevel fs wrapper, the timer/dispose buffered +writer, the graceful-shutdown cleanup set and the `appendFileSync` → ENOENT → +`mkdirSync(recursive)` recovery arm that is the only code creating the log +tree — is byte-compared to node; it fails on a pre-#9491 build (the append +did not throw, so the tree was never created) and passes on main. The +exec/execFile callback order is pinned as what node guarantees — completion +order, whichever API launched the child or came first; the inverted order for +two instant `echo`s is a same-turn batch-delivery artefact node flips with +submission order, not a rule.