Skip to content

Commit 8920d04

Browse files
committed
🤖 fix: use PGID for Windows MSYS2 process termination (no /proc on macOS)
1 parent c20e98e commit 8920d04

3 files changed

Lines changed: 38 additions & 26 deletions

File tree

‎src/node/runtime/LocalBackgroundHandle.ts‎

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ export class LocalBackgroundHandle implements BackgroundHandle {
2121

2222
constructor(
2323
private readonly pid: number,
24+
/**
25+
* Process group ID for termination.
26+
* - Unix (setsid=true): equals pid, since setsid makes process a session/group leader
27+
* - Windows MSYS2 (setsid=false): actual PGID from /proc, used with kill -PGID
28+
*/
29+
private readonly pgid: number,
2430
public readonly outputDir: string
2531
) {}
2632

@@ -53,31 +59,23 @@ export class LocalBackgroundHandle implements BackgroundHandle {
5359

5460
const exitCodePath = path.join(this.outputDir, "exit_code");
5561

56-
// Windows: convert MSYS2 PID to Windows PID and use taskkill
57-
// Must run via bash because /proc/PID/winpid is MSYS2's virtual filesystem,
58-
// not visible to Node.js running as a native Windows process
62+
// Windows: use MSYS2's kill command with negative PGID to kill process group
63+
// taskkill doesn't work because MSYS2's process tree doesn't match Windows' process tree
64+
// MSYS2's kill understands its own process groups, so kill -PGID works correctly
5965
if (process.platform === "win32") {
6066
try {
61-
// Script reads winpid from MSYS2's /proc, then uses taskkill
62-
// taskkill /F = force, /T = kill child processes
63-
// Double slashes needed because bash interprets single slash as path
64-
const terminateScript = `
65-
if [ -f /proc/${this.pid}/winpid ]; then
66-
winpid=$(cat /proc/${this.pid}/winpid)
67-
taskkill //PID $winpid //F //T 2>/dev/null
68-
fi
69-
kill -9 ${this.pid} 2>/dev/null || true
70-
`;
71-
log.debug(`LocalBackgroundHandle: Terminating MSYS2 PID ${this.pid} via bash`);
67+
// Use PGID to kill entire process group via MSYS2's kill command
68+
const terminateScript = `kill -9 -${this.pgid} 2>/dev/null || true`;
69+
log.debug(`LocalBackgroundHandle: Terminating MSYS2 process group ${this.pgid} via bash`);
7270
using proc = execAsync(terminateScript, { shell: getBashPath() });
7371
await proc.result;
7472
} catch (error) {
75-
// Process already dead or winpid unavailable
73+
// Process already dead
7674
log.debug(
7775
`LocalBackgroundHandle: Windows terminate error: ${error instanceof Error ? error.message : String(error)}`
7876
);
7977
}
80-
// Write exit code - trap may not fire with taskkill
78+
// Write exit code - trap may not fire with kill -9
8179
try {
8280
await fs.access(exitCodePath);
8381
} catch {
@@ -89,11 +87,11 @@ export class LocalBackgroundHandle implements BackgroundHandle {
8987
}
9088

9189
// Unix: use process group signals
92-
const pgid = -this.pid; // Negative PID = process group
90+
const negativePgid = -this.pgid; // Negative PGID = process group
9391

9492
try {
95-
log.debug(`LocalBackgroundHandle: Sending SIGTERM to process group ${pgid}`);
96-
process.kill(pgid, "SIGTERM");
93+
log.debug(`LocalBackgroundHandle: Sending SIGTERM to process group ${negativePgid}`);
94+
process.kill(negativePgid, "SIGTERM");
9795

9896
// Wait 2 seconds for graceful shutdown
9997
await new Promise((resolve) => setTimeout(resolve, 2000));
@@ -108,8 +106,8 @@ export class LocalBackgroundHandle implements BackgroundHandle {
108106
}
109107

110108
if (stillRunning) {
111-
log.debug(`LocalBackgroundHandle: Process still running, sending SIGKILL to group ${pgid}`);
112-
process.kill(pgid, "SIGKILL");
109+
log.debug(`LocalBackgroundHandle: Process still running, sending SIGKILL to group ${negativePgid}`);
110+
process.kill(negativePgid, "SIGKILL");
113111

114112
// Write exit code for SIGKILL since we had to force kill
115113
await fs.writeFile(exitCodePath, String(EXIT_CODE_SIGKILL)).catch(() => {

‎src/node/runtime/LocalBaseRuntime.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -382,14 +382,19 @@ export abstract class LocalBaseRuntime implements Runtime {
382382
using proc = execAsync(spawnCommand, { shell: getBashPath() });
383383
const result = await proc.result;
384384

385-
const pid = parseInt(result.stdout.trim(), 10);
385+
// Parse "PID PGID" from output
386+
// On Unix with setsid, both values are the same (process is group leader)
387+
// On Windows MSYS2, PGID comes from /proc/$!/pgid
388+
const [pidStr, pgidStr] = result.stdout.trim().split(/\s+/);
389+
const pid = parseInt(pidStr, 10);
390+
const pgid = parseInt(pgidStr, 10);
386391
if (isNaN(pid) || pid <= 0) {
387392
log.debug(`LocalBaseRuntime.spawnBackground: Invalid PID: ${result.stdout}`);
388393
return { success: false, error: `Failed to get valid PID from spawn: ${result.stdout}` };
389394
}
390395

391-
log.debug(`LocalBaseRuntime.spawnBackground: Spawned with PID ${pid}`);
392-
const handle = new LocalBackgroundHandle(pid, outputDir);
396+
log.debug(`LocalBaseRuntime.spawnBackground: Spawned with PID ${pid}, PGID ${pgid}`);
397+
const handle = new LocalBackgroundHandle(pid, isNaN(pgid) ? pid : pgid, outputDir);
393398
return { success: true, handle, pid };
394399
} catch (e) {
395400
const err = e as Error;

‎src/node/runtime/backgroundCommands.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,19 +94,28 @@ export interface SpawnCommandOptions {
9494
* setsid: creates new session, process becomes group leader (enables kill -PID)
9595
* nohup: ignores SIGHUP (survives terminal hangup)
9696
*
97-
* Returns PID via echo $! (correct because & is inside subshell)
97+
* Returns "PID PGID" via echo:
98+
* - Unix with setsid: outputs "PID PID" (process is session leader, so PID == PGID)
99+
* - Windows MSYS2 without setsid: outputs "PID PGID" where PGID is read from /proc/$!/pgid
100+
* (MSYS2 provides /proc filesystem; macOS does not have /proc)
98101
*/
99102
export function buildSpawnCommand(options: SpawnCommandOptions): string {
100103
const bash = options.bashPath ?? "bash";
101104
const nicePrefix = options.niceness !== undefined ? `nice -n ${options.niceness} ` : "";
102105
const setsidPrefix = options.useSetsid === false ? "" : "setsid ";
103106
const quotePath = options.quotePath ?? shellQuote;
104107

108+
// With setsid (Unix): process becomes session leader, so PID == PGID
109+
// Without setsid (Windows MSYS2): must read PGID from /proc (MSYS2 provides this)
110+
// CRITICAL: We can't run this on macOS, since it doesn't have /proc
111+
const pgidExpr =
112+
options.useSetsid === false ? '$(cat /proc/$!/pgid 2>/dev/null || echo $!)' : '$!';
113+
105114
return (
106115
`(${nicePrefix}${setsidPrefix}nohup ${shellQuote(bash)} -c ${shellQuote(options.wrapperScript)} ` +
107116
`> ${quotePath(options.stdoutPath)} ` +
108117
`2> ${quotePath(options.stderrPath)} ` +
109-
`< /dev/null & echo $!)`
118+
`< /dev/null & echo "$! ${pgidExpr}")`
110119
);
111120
}
112121

0 commit comments

Comments
 (0)