* fix: add shell and windowsHide options for Windows spawn in app-server
On Windows, spawn("codex", ["app-server"]) fails with ENOENT because
Node.js cannot resolve .cmd shims without shell: true. This adds
platform-gated shell and windowsHide options to the app-server spawn
call, and uses terminateProcessTree for proper process tree cleanup
since shell: true wraps the child in cmd.exe.
Without terminateProcessTree, plain SIGTERM only kills cmd.exe and
leaves the actual codex node process orphaned — verified with 274+
zombie node.exe processes accumulating on Windows.
Fixes #32
Fixes #46
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: guard terminateProcessTree against PID reuse after process exit
ChildProcess.killed only reflects whether .kill() was called by this
process — it stays false when the child exits on its own. On Windows,
where PIDs are recycled quickly, the 50 ms timer could fire after
cmd.exe has exited and its PID has been reassigned, causing taskkill
to terminate an unrelated process.
Add an exitCode === null check so the tree-kill path is skipped once
the child has already exited.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
135 lines
3.9 KiB
JavaScript
135 lines
3.9 KiB
JavaScript
import { spawnSync } from "node:child_process";
|
|
import process from "node:process";
|
|
|
|
export function runCommand(command, args = [], options = {}) {
|
|
const result = spawnSync(command, args, {
|
|
cwd: options.cwd,
|
|
env: options.env,
|
|
encoding: "utf8",
|
|
input: options.input,
|
|
stdio: options.stdio ?? "pipe",
|
|
shell: process.platform === "win32",
|
|
windowsHide: true
|
|
});
|
|
|
|
return {
|
|
command,
|
|
args,
|
|
status: result.status ?? 0,
|
|
signal: result.signal ?? null,
|
|
stdout: result.stdout ?? "",
|
|
stderr: result.stderr ?? "",
|
|
error: result.error ?? null
|
|
};
|
|
}
|
|
|
|
export function runCommandChecked(command, args = [], options = {}) {
|
|
const result = runCommand(command, args, options);
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
if (result.status !== 0) {
|
|
throw new Error(formatCommandFailure(result));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function binaryAvailable(command, versionArgs = ["--version"], options = {}) {
|
|
const result = runCommand(command, versionArgs, options);
|
|
if (result.error && /** @type {NodeJS.ErrnoException} */ (result.error).code === "ENOENT") {
|
|
return { available: false, detail: "not found" };
|
|
}
|
|
if (result.error) {
|
|
return { available: false, detail: result.error.message };
|
|
}
|
|
if (result.status !== 0) {
|
|
const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`;
|
|
return { available: false, detail };
|
|
}
|
|
return { available: true, detail: result.stdout.trim() || result.stderr.trim() || "ok" };
|
|
}
|
|
|
|
function looksLikeMissingProcessMessage(text) {
|
|
return /not found|no running instance|cannot find|does not exist|no such process/i.test(text);
|
|
}
|
|
|
|
export function terminateProcessTree(pid, options = {}) {
|
|
if (!Number.isFinite(pid)) {
|
|
return { attempted: false, delivered: false, method: null };
|
|
}
|
|
|
|
const platform = options.platform ?? process.platform;
|
|
const runCommandImpl = options.runCommandImpl ?? runCommand;
|
|
const killImpl = options.killImpl ?? process.kill.bind(process);
|
|
|
|
if (platform === "win32") {
|
|
const result = runCommandImpl("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
|
cwd: options.cwd,
|
|
env: options.env
|
|
});
|
|
|
|
if (!result.error && result.status === 0) {
|
|
return { attempted: true, delivered: true, method: "taskkill", result };
|
|
}
|
|
|
|
const combinedOutput = `${result.stderr}\n${result.stdout}`.trim();
|
|
if (!result.error && looksLikeMissingProcessMessage(combinedOutput)) {
|
|
return { attempted: true, delivered: false, method: "taskkill", result };
|
|
}
|
|
|
|
if (result.error?.code === "ENOENT") {
|
|
try {
|
|
killImpl(pid);
|
|
return { attempted: true, delivered: true, method: "kill" };
|
|
} catch (error) {
|
|
if (error?.code === "ESRCH") {
|
|
return { attempted: true, delivered: false, method: "kill" };
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
|
|
throw new Error(formatCommandFailure(result));
|
|
}
|
|
|
|
try {
|
|
killImpl(-pid, "SIGTERM");
|
|
return { attempted: true, delivered: true, method: "process-group" };
|
|
} catch (error) {
|
|
if (error?.code !== "ESRCH") {
|
|
try {
|
|
killImpl(pid, "SIGTERM");
|
|
return { attempted: true, delivered: true, method: "process" };
|
|
} catch (innerError) {
|
|
if (innerError?.code === "ESRCH") {
|
|
return { attempted: true, delivered: false, method: "process" };
|
|
}
|
|
throw innerError;
|
|
}
|
|
}
|
|
|
|
return { attempted: true, delivered: false, method: "process-group" };
|
|
}
|
|
}
|
|
|
|
export function formatCommandFailure(result) {
|
|
const parts = [`${result.command} ${result.args.join(" ")}`.trim()];
|
|
if (result.signal) {
|
|
parts.push(`signal=${result.signal}`);
|
|
} else {
|
|
parts.push(`exit=${result.status}`);
|
|
}
|
|
const stderr = (result.stderr || "").trim();
|
|
const stdout = (result.stdout || "").trim();
|
|
if (stderr) {
|
|
parts.push(stderr);
|
|
} else if (stdout) {
|
|
parts.push(stdout);
|
|
}
|
|
return parts.join(": ");
|
|
}
|