fix(javascript): emit no fork when a literal stdio array omits the IPC channel (#7992)

* 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
This commit is contained in:
吴佳浩Alben authored and GitHub committed 2026-10-29 13:38:29 +08:00
1 parent d39b3000ec
commit 2bfd84b2c8
2 files changed
+100 -1

No files matched your search

@@ -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);
});
@@ -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.