Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 55 additions & 3 deletions scripts/dev/lib/proc.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,69 @@
import { spawn, spawnSync } from "node:child_process";
import { openSync } from "node:fs";
import { openSync, statSync } from "node:fs";
import { delimiter, join } from "node:path";
import { connect } from "node:net";
import { bestEffort, sleep } from "./util.ts";

/**
* On Windows, child_process.spawn cannot execute the `.cmd`/`.bat` shims that
* npm, tsx & co. place on PATH: the bare name fails with ENOENT and the
* explicit `.cmd` fails with EINVAL (current Node rejects batch files without a
* shell). Resolve the command against PATH ourselves and route batch shims
* through cmd.exe, the way npm's own CLI does (#551). `.exe`/`.com` resolves to
* its full path and spawns directly; non-Windows platforms are untouched.
*/
function resolveWindowsCommand(cmd: string): string | null {
// PATHEXT extensions come first, extensionless last: Node's install dir ships
// an extensionless `npm` sh-script for bash environments, and matching it
// before npm.cmd would hand spawn a non-executable file.
const exts = [...(process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean), ""];
const dirs = /[\\/]/.test(cmd)
? [""]
: (process.env.PATH ?? "")
.split(delimiter)
.filter(Boolean);
for (const dir of dirs) {
for (const ext of exts) {
const candidate = dir ? join(dir, cmd + ext) : cmd + ext;
try {
if (statSync(candidate).isFile()) return candidate;
} catch {
// Not present at this candidate; keep scanning.
}
}
}
return null;
}

export function winSpawnArgv(cmd: string, args: string[]): { cmd: string; args: string[]; verbatim: boolean } {
if (process.platform !== "win32") return { cmd, args, verbatim: false };
const resolved = resolveWindowsCommand(cmd);
if (resolved && /\.(cmd|bat)$/i.test(resolved)) {
// cmd.exe re-parses everything after /c as one command line: when the shim
// path contains spaces (e.g. "C:\Program Files\nodejs\npm.cmd"), per-argument
// quoting makes cmd execute only up to the first quote. Quote each part
// ourselves and hand cmd a single pre-quoted command line, wrapped in an
// outer pair of quotes that /s strips. `verbatim` keeps Node from escaping
// the embedded quotes with backslashes, which cmd.exe cannot parse.
const commandLine = [resolved, ...args].map((part) => (/\s/.test(part) ? `"${part}"` : part)).join(" ");
return { cmd: process.env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", `"${commandLine}"`], verbatim: true };
}
return { cmd: resolved ?? cmd, args, verbatim: false };
}

export function run(
cmd: string,
args: string[],
opts: { cwd?: string; env?: NodeJS.ProcessEnv; input?: string; timeoutMs?: number } = {},
): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const child = spawn(cmd, args, {
const spawnArgv = winSpawnArgv(cmd, args);
const child = spawn(spawnArgv.cmd, spawnArgv.args, {
cwd: opts.cwd,
env: opts.env,
timeout: opts.timeoutMs ?? 120_000,
killSignal: "SIGKILL",
windowsVerbatimArguments: spawnArgv.verbatim,
stdio: [opts.input !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
});
let stdout = "";
Expand Down Expand Up @@ -58,11 +108,13 @@ export function spawnDetached(opts: {
const [cmd, ...rest] = opts.argv;
if (!cmd) throw new Error("spawnDetached: empty argv");
const fd = openSync(opts.logFile, "a");
const child = spawn(cmd, rest, {
const spawnArgv = winSpawnArgv(cmd, rest);
const child = spawn(spawnArgv.cmd, spawnArgv.args, {
cwd: opts.cwd,
detached: true,
stdio: ["ignore", fd, fd],
env: opts.env,
windowsVerbatimArguments: spawnArgv.verbatim,
});
child.unref();
if (!child.pid) throw new Error(`failed to spawn ${opts.argv.join(" ")}`);
Expand Down
65 changes: 65 additions & 0 deletions test/dev-proc-windows-spawn.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { run, spawnDetached, winSpawnArgv } from "../scripts/dev/lib/proc.ts";

const onWindows = process.platform === "win32";

test("winSpawnArgv routes batch shims through cmd.exe on Windows", { skip: !onWindows }, () => {
const { cmd, args } = winSpawnArgv("npm", ["--version"]);
assert.match(cmd, /cmd\.exe$/i);
assert.deepEqual(args.slice(0, 3), ["/d", "/s", "/c"]);
// Everything after /c is one pre-quoted command line (paths with spaces stay
// intact for cmd.exe's re-parse), wrapped in an outer pair /s strips.
assert.equal(args.length, 4, "single command-line argument after /c");
const line = args[3]!;
assert.ok(line.startsWith('"') && line.endsWith('"'), "outer quote pair present");
assert.match(line, /npm\.cmd/i, "npm must resolve to its .cmd shim on PATH");
assert.match(line, /--version"?\s*$/, "original args preserved");
});

test("winSpawnArgv resolves plain executables without cmd.exe on Windows", { skip: !onWindows }, () => {
const { cmd, args } = winSpawnArgv("node", ["-e", "0"]);
assert.doesNotMatch(cmd, /cmd\.exe$/i, "node.exe must not be routed through cmd.exe");
assert.match(cmd, /node(\.exe)?$/i);
assert.equal(args[0], "-e");
});

test("winSpawnArgv passes non-batch and unknown commands through", { skip: onWindows }, () => {
assert.deepEqual(winSpawnArgv("npm", ["--version"]), { cmd: "npm", args: ["--version"], verbatim: false });
});

test("run() executes npm on Windows (bare-name spawn regression)", { skip: !onWindows }, async () => {
// Before the fix this resolved to spawn("npm") -> ENOENT (or spawn("npm.cmd") ->
// EINVAL), reported as code -1 with the spawn error in stderr.
const res = await run("npm", ["--version"], { timeoutMs: 60_000 });
assert.equal(res.code, 0, `npm --version failed: ${res.stderr}`);
assert.match(res.stdout.trim(), /^\d+\.\d+\.\d+/, "npm printed its version");
});

test("spawnDetached runs a node child through the same choke point", async () => {
const dir = mkdtempSync(join(tmpdir(), "qm-proc-test-"));
try {
const marker = join(dir, "marker.txt");
spawnDetached({
cwd: dir,
logFile: join(dir, "log.txt"),
argv: [
process.execPath,
"-e",
`require("node:fs").writeFileSync(${JSON.stringify(marker)}, "ok")`,
],
env: process.env as Record<string, string>,
});
const deadline = Date.now() + 15_000;
while (Date.now() < deadline && !existsSync(marker)) {
await new Promise((r) => setTimeout(r, 100));
}
assert.ok(existsSync(marker), "detached child ran");
assert.equal(readFileSync(marker, "utf8"), "ok");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});