fix(dev): preserve command arguments in exclusive runners

Use the existing pinned cross-spawn dependency at the command-spawn boundary instead of forcing every Windows executable and argument vector through cmd.exe. Preserve native executable arguments while retaining PATH/PATHEXT and command-shim support.

Validation: public runner argv and lock/cancellation tests passed on POSIX. Windows-only tests cover an executable path with spaces, bare node, npm, and the local turbo shim; native Windows execution remains unverified.

Fixes #2319
This commit is contained in:
morluto 2026-10-13 04:12:45 +08:00
parent b7169c8b74
commit fd9588089d
2 changed files with 86 additions and 3 deletions

View File

@ -1,6 +1,6 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import spawn from "cross-spawn";
import { mkdir } from "node:fs/promises";
import { join, resolve } from "node:path";
import { DatabaseSync } from "node:sqlite";
@ -74,7 +74,6 @@ function runCommand() {
child = spawn(command[0], command.slice(1), {
cwd: process.cwd(),
env: process.env,
shell: process.platform === "win32",
stdio: "inherit",
});
child.once("error", reject);

View File

@ -1,5 +1,5 @@
import { spawn } from "node:child_process";
import { readFile, utimes, writeFile } from "node:fs/promises";
import { copyFile, mkdir, readFile, utimes, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { setTimeout as delay } from "node:timers/promises";
@ -116,6 +116,66 @@ it("does not treat an old database timestamp as permission to replace a live own
}
});
it("preserves executable arguments through the public runner", async () => {
const directory = await createTestTempDirectory("rea-runner-argv-");
const payload = 'spaces, "quotes", & ^ % ! (parens)';
const code = "process.stdout.write(JSON.stringify(process.argv.slice(1)))";
expect(
await execute(directory, process.execPath, ["-e", code, payload]),
).toMatchObject({
code: 0,
stdout: JSON.stringify([payload]),
stderr: "",
});
});
it.runIf(process.platform === "win32")(
"launches an executable from a path containing spaces",
async () => {
const directory = await createTestTempDirectory("rea-runner-spaced-exe-");
const executable = join(directory, "node runtime", "node.exe");
await mkdir(join(directory, "node runtime"), { recursive: true });
await copyFile(process.execPath, executable);
const payload = 'argument with spaces and "quotes" & ^ % !';
const code = "process.stdout.write(JSON.stringify(process.argv.slice(1)))";
expect(
await execute(directory, executable, ["-e", code, payload]),
).toMatchObject({
code: 0,
stdout: JSON.stringify([payload]),
stderr: "",
});
},
);
it.runIf(process.platform === "win32")(
"resolves bare node and Windows command shims without losing arguments",
async () => {
const directory = await createTestTempDirectory("rea-runner-shims-");
const payload = 'argument with spaces and "quotes" & ^ % !';
const code = "process.stdout.write(JSON.stringify(process.argv.slice(1)))";
expect(
await execute(directory, "node", ["-e", code, payload]),
).toMatchObject({
code: 0,
stdout: JSON.stringify([payload]),
stderr: "",
});
const npm = await execute(directory, "npm", ["--version"]);
expect(npm).toMatchObject({ code: 0, stderr: "" });
expect(npm.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/u);
const turbo = await execute(
directory,
join(process.cwd(), "node_modules", ".bin", "turbo"),
["--version"],
);
expect(turbo).toMatchObject({ code: 0, stderr: "" });
expect(turbo.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/u);
},
);
it.runIf(process.platform !== "win32")(
"recovers after abrupt owner death and keeps simultaneous contenders exclusive",
async () => {
@ -219,6 +279,30 @@ function launch(directory: string, command: string) {
return { child, ready, closed };
}
function execute(
directory: string,
command: string,
arguments_: string[] = [],
) {
return new Promise<{
code: number | null;
stdout: string;
stderr: string;
}>((resolve, reject) => {
const child = spawn(
process.execPath,
[runnerPath, "test", command, ...arguments_],
{ cwd: directory, stdio: ["ignore", "pipe", "pipe"] },
);
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => (stdout += chunk.toString()));
child.stderr.on("data", (chunk) => (stderr += chunk.toString()));
child.once("error", reject);
child.once("close", (code) => resolve({ code, stdout, stderr }));
});
}
function withTimeout<T>(promise: Promise<T>, message: string): Promise<T> {
let timeout: NodeJS.Timeout | undefined;
return Promise.race([