From 2bfd84b2c8484afad29dc3c32bacd82bcc8e69c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E4=BD=B3=E6=B5=A9Alben?= <35291372+XiaoBinGan@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:38:29 +0800 Subject: [PATCH] fix(javascript): emit no fork when a literal stdio array omits the IPC channel (#7992) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(javascript): emit no fork when a literal stdio array omits the IPC channel Node's fork() requires an explicit stdio array to carry the IPC channel: if (!ArrayPrototypeIncludes(options.stdio, 'ipc')) throw new ERR_CHILD_PROCESS_IPC_REQUIRED('options.stdio') The throw is synchronous, before any child process exists, but the analysis reported a resolved fork with stdioMode "array" — byte-identical to the valid `[..., "ipc"]` form — which projects to a child-process node, a resolved spawns relation and resolved connects-stdio. Measured on Node 24.22.1: fork({stdio: ["inherit","inherit","inherit"]}) -> THROWS ERR_CHILD_PROCESS_IPC_REQUIRED fork({stdio: ["pipe","pipe","pipe"]}) -> THROWS ERR_CHILD_PROCESS_IPC_REQUIRED fork({stdio: ["ipc"]}) -> SPAWNED connected=true fork({stdio: ["inherit","inherit","inherit","ipc"]}) -> SPAWNED connected=true fork({stdio: "pipe"}) -> SPAWNED connected=true spawn({stdio: ["inherit","inherit","inherit"]})-> SPAWNED connected=false A string stdio is normalised with "ipc" appended, so only the array form can omit it. The same array is valid for spawn, which has no IPC requirement. Drop a fork candidate only when its stdio array is fully literal and provably lacks "ipc"; a spread or a non-literal element may still supply the channel and stays a candidate. * chore: retrigger title and readiness checks after the title was corrected --- ...iptSemanticAnalysis.fork_stdio_ipc.test.ts | 62 +++++++++++++++++++ .../javascriptSemanticChildProcesses.ts | 39 +++++++++++- 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 src/domain/javascript/javascriptSemanticAnalysis.fork_stdio_ipc.test.ts diff --git a/src/domain/javascript/javascriptSemanticAnalysis.fork_stdio_ipc.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.fork_stdio_ipc.test.ts new file mode 100644 index 0000000000..0d3e845a3d --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.fork_stdio_ipc.test.ts @@ -0,0 +1,62 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +/** + * Node's `fork()` requires an explicit `stdio` array to carry the IPC channel: + * `if (!ArrayIncludes(options.stdio, 'ipc')) throw new ERR_CHILD_PROCESS_IPC_REQUIRED`. + * The throw is synchronous, before any child process exists, so such a call is + * not a spawn. A string `stdio` is normalised with `"ipc"` appended, so only the + * array form can omit it, and the same array is valid for `spawn`. + */ +const spawnsFor = (call: string) => + analyzeJavaScriptSemantics( + `import { fork } from "node:child_process"; ${call};`, + ).childProcessSpawns; + +it.each([ + ["three inherits", `["inherit", "inherit", "inherit"]`], + ["three pipes", `["pipe", "pipe", "pipe"]`], + ["ignore then pipes", `["ignore", "pipe", "pipe"]`], + ["one inherit", `["inherit"]`], + ["an empty array", `[]`], +] as const)( + "emits no fork for a literal stdio array with %s and no ipc", + (_name, stdio) => { + expect(spawnsFor(`fork("worker.js", { stdio: ${stdio} })`)).toEqual([]); + }, +); + +it.each([ + ["a fourth element", `["inherit", "inherit", "inherit", "ipc"]`], + ["only ipc", `["ipc"]`], + ["ipc and a pipe", `["pipe", "ipc"]`], +] as const)("keeps the fork for a stdio array with %s", (_name, stdio) => { + expect(spawnsFor(`fork("worker.js", { stdio: ${stdio} })`)).toEqual([ + expect.objectContaining({ method: "fork", stdioMode: "array" }), + ]); +}); + +it.each([ + ["a string stdio", `fork("worker.js", { stdio: "pipe" })`], + ["omitted stdio", `fork("worker.js", {})`], +] as const)("keeps the fork for %s", (_name, call) => { + expect(spawnsFor(call)).toHaveLength(1); +}); + +it.each([ + ["a spread element", `[...base, "ipc"]`], + ["a non-literal element", `[channel]`], +] as const)( + "keeps the fork when %s may still supply the channel", + (_name, stdio) => { + expect(spawnsFor(`fork("worker.js", { stdio: ${stdio} })`)).toHaveLength(1); + }, +); + +it("keeps the same array for spawn, which has no IPC requirement", () => { + const spawned = analyzeJavaScriptSemantics( + `import { spawn } from "node:child_process"; spawn("tool", [], { stdio: ["inherit", "inherit", "inherit"] });`, + ); + expect(spawned.childProcessSpawns).toHaveLength(1); +}); diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.ts b/src/domain/javascript/javascriptSemanticChildProcesses.ts index d16211e277..7668963085 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.ts @@ -13,6 +13,7 @@ import { type JavaScriptExpressionShapeResolver, } from "./javascriptSemanticExpressionShapes.js"; import { + readExactJavaScriptLiteral, semanticStaticPropertyName, staticJavaScriptTruthiness, staticObjectProperty, @@ -68,8 +69,10 @@ export const collectJavaScriptSemanticChildProcesses = ( state: JavaScriptSemanticAnalysisState, callables: readonly JavaScriptSemanticCallable[], ): ChildProcessAnalysis => { - const candidates = collectSpawns(program, state, callables); const context = createSemanticExpressionShapeResolver(state); + const candidates = collectSpawns(program, state, callables).filter( + (candidate) => !forkRejectsStdioArray(candidate, context), + ); const childProcessSpawns = candidates.map((candidate) => immutableSpawn(candidate, context), ); @@ -444,6 +447,40 @@ const stdioMode = ( return "dynamic"; }; +/** + * Node's `fork` requires an explicit `stdio` array to carry the IPC channel. An + * array that provably omits `"ipc"` throws ERR_CHILD_PROCESS_IPC_REQUIRED + * synchronously, before any child process exists, so it is not a spawn at all. + * A string `stdio` is normalised with `"ipc"` appended, so only the array form + * can omit it; a spread or unreadable element may still supply the channel and + * is therefore left alone. + */ +const forkRejectsStdioArray = ( + candidate: SpawnCandidate, + context: ChildSpawnContext, +): boolean => { + if (candidate.method !== "fork") return false; + const { options } = spawnArguments(candidate, context); + if (options === undefined) return false; + const expression = unwrapJavaScriptExpression(options).node; + if (context.isNullish(expression)) return false; + const lookup = staticObjectProperty(expression, "stdio", context.isNullish); + if (lookup.status !== "explicit") return false; + const value = unwrapJavaScriptExpression(lookup.value).node; + if (!t.isArrayExpression(value)) return false; + const literals = value.elements.map((element) => + element === null || t.isSpreadElement(element) + ? undefined + : readExactJavaScriptLiteral(element), + ); + if (literals.some((literal) => literal === undefined || !literal.found)) + return false; + return !literals.some( + (literal) => + literal !== undefined && literal.found && literal.value === "ipc", + ); +}; + /** * Without a string or array `stdio`, Node's `fork` pipes the standard streams * when `silent` is truthy and inherits them otherwise.