From fd9588089d089e7cb02d12178e34407d6f889f22 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:12:45 +0800 Subject: [PATCH] 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 --- scripts/run-exclusive-command.mjs | 3 +- .../process-global/runnerCancellation.test.ts | 86 ++++++++++++++++++- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/scripts/run-exclusive-command.mjs b/scripts/run-exclusive-command.mjs index 11fe38d47..36efc29a2 100644 --- a/scripts/run-exclusive-command.mjs +++ b/scripts/run-exclusive-command.mjs @@ -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); diff --git a/tests/process-global/runnerCancellation.test.ts b/tests/process-global/runnerCancellation.test.ts index cede726bc..ee68738d4 100644 --- a/tests/process-global/runnerCancellation.test.ts +++ b/tests/process-global/runnerCancellation.test.ts @@ -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(promise: Promise, message: string): Promise { let timeout: NodeJS.Timeout | undefined; return Promise.race([