Skip to content

Commit 6562235

Browse files
fix(server): process-group kills never reach every process the user owns (#14461)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 89f4f81 commit 6562235

6 files changed

Lines changed: 74 additions & 7 deletions

File tree

‎apps/server/src/orchestration-v2/Adapters/PiRpc.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3030
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3131
import { resolveSpawnCommand } from "@t3tools/shared/shell";
3232

33+
import { signalProcessGroup } from "../../process/processGroup.ts";
34+
3335
export class PiRpcError extends Schema.TaggedError<PiRpcError>()("PiRpcError", {
3436
operation: Schema.String,
3537
detail: Schema.optional(Schema.String),
@@ -238,7 +240,7 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp
238240
if (platform === "win32") {
239241
process.kill(Number(child.pid), signal);
240242
} else {
241-
process.kill(-Number(child.pid), signal);
243+
signalProcessGroup(Number(child.pid), signal);
242244
}
243245
return true;
244246
} catch {
@@ -250,7 +252,8 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp
250252
const hasExited = (): boolean => {
251253
if (childExited) return true;
252254
try {
253-
process.kill(platform === "win32" ? Number(child.pid) : -Number(child.pid), 0);
255+
if (platform === "win32") process.kill(Number(child.pid), 0);
256+
else signalProcessGroup(Number(child.pid), 0);
254257
return false;
255258
} catch {
256259
return true;
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// @effect-diagnostics nodeBuiltinImport:off
2+
import * as NodeChildProcess from "node:child_process";
3+
4+
import { describe, expect, it } from "@effect/vitest";
5+
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
6+
7+
import { signalProcessGroup } from "./processGroup.ts";
8+
9+
const errorCode = (run: () => void) => {
10+
try {
11+
run();
12+
return undefined;
13+
} catch (cause) {
14+
return (cause as NodeJS.ErrnoException).code;
15+
}
16+
};
17+
18+
describe.skipIf(HostProcessPlatform.defaultValue() === "win32")("signalProcessGroup", () => {
19+
// Signal 0 only probes, so this is safe to run unguarded: `kill(-1, 0)` and
20+
// `kill(0, 0)` succeed, which is how a fake spawner's pid 1 became
21+
// `kill(-1, SIGKILL)` and took down every process the user owned.
22+
it("never reaches this server's group or every process the user owns", () => {
23+
for (const pid of [1, 0, -1, Number.NaN, 1.5]) {
24+
expect(
25+
errorCode(() => signalProcessGroup(pid, 0)),
26+
`pid ${pid}`,
27+
).toBe("ESRCH");
28+
}
29+
});
30+
31+
it("signals a spawned process group", async () => {
32+
const child = NodeChildProcess.spawn("/bin/sh", ["-c", "sleep 600 & wait"], {
33+
detached: true,
34+
stdio: "ignore",
35+
});
36+
const exited = new Promise<NodeJS.Signals | null>((resolve) =>
37+
child.once("exit", (_code, signal) => resolve(signal)),
38+
);
39+
const pid = child.pid!;
40+
41+
expect(errorCode(() => signalProcessGroup(pid, 0))).toBeUndefined();
42+
signalProcessGroup(pid, "SIGKILL");
43+
expect(await exited).toBe("SIGKILL");
44+
});
45+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Signals a POSIX process group this server spawned, as `process.kill(-pid)`.
3+
*
4+
* `kill(0)` signals this server's own group and `kill(-1)` every process the
5+
* user owns, and a fake spawner in tests reports pid 1. So a pid of 0 or 1, or
6+
* one that is not an integer, fails with ESRCH like a group that has already
7+
* exited, and callers keep their existing handling.
8+
*/
9+
export function signalProcessGroup(pid: number, signal: NodeJS.Signals | 0): void {
10+
if (!Number.isSafeInteger(pid) || pid <= 1) {
11+
throw Object.assign(new Error(`kill ESRCH: not a spawned process group (${pid})`), {
12+
code: "ESRCH",
13+
});
14+
}
15+
process.kill(-pid, signal);
16+
}

‎apps/server/src/provider/OpenCodeServerLedger.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import * as Schema from "effect/Schema";
99
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1010

1111
import * as ServerConfig from "../config.ts";
12+
import { signalProcessGroup } from "../process/processGroup.ts";
1213

1314
const ProcessIdentity = Schema.Struct({ pid: Schema.Int, startTime: Schema.String });
1415
type ProcessIdentity = typeof ProcessIdentity.Type;
@@ -91,15 +92,15 @@ const parseDarwinPs = (output: string): ReadonlyArray<ObservedProcess> =>
9192

9293
const signalGroup = (pgid: number, signal: NodeJS.Signals) => {
9394
try {
94-
process.kill(-pgid, signal);
95+
signalProcessGroup(pgid, signal);
9596
} catch {
9697
// The group may already be gone.
9798
}
9899
};
99100

100101
const groupExists = (pgid: number) => {
101102
try {
102-
process.kill(-pgid, 0);
103+
signalProcessGroup(pgid, 0);
103104
return true;
104105
} catch (cause) {
105106
return (cause as NodeJS.ErrnoException | undefined)?.code !== "ESRCH";

‎apps/server/src/provider/acp/AcpSessionRuntime.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import type * as EffectAcpProtocol from "effect-acp/protocol";
2929
import { resolveSpawnCommand } from "@t3tools/shared/shell";
3030
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3131

32+
import { signalProcessGroup } from "../../process/processGroup.ts";
3233
import { appendAcpStderrTail, sanitizeAcpStderrExcerpt } from "./AcpStderr.ts";
3334
import {
3435
collectSessionConfigOptionValues,
@@ -1647,7 +1648,7 @@ export const make = (
16471648
const signalOwnedProcessGroup = (signal: NodeJS.Signals) =>
16481649
Effect.try({
16491650
try: () => {
1650-
process.kill(-Number(child.pid), signal);
1651+
signalProcessGroup(Number(child.pid), signal);
16511652
return true;
16521653
},
16531654
catch: (cause) =>

‎apps/server/src/provider/opencodeRuntime.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import * as Schema from "effect/Schema";
3030
import * as Stream from "effect/Stream";
3131
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3232

33+
import { signalProcessGroup } from "../process/processGroup.ts";
3334
import { isWindowsCommandNotFound } from "../processRunner.ts";
3435
import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts";
3536
import { collectStreamAsString } from "./providerSnapshot.ts";
@@ -609,7 +610,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
609610
? child.kill({ killSignal: "SIGKILL" }).pipe(Effect.asVoid)
610611
: Effect.sync(() => {
611612
try {
612-
process.kill(-Number(child.pid), "SIGKILL");
613+
signalProcessGroup(Number(child.pid), "SIGKILL");
613614
} catch {
614615
// The command and its process group may already have exited.
615616
}
@@ -731,7 +732,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
731732
? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid)
732733
: Effect.sync(() => {
733734
try {
734-
process.kill(-Number(child.pid), signal);
735+
signalProcessGroup(Number(child.pid), signal);
735736
} catch {
736737
// The direct child may already have exited after starting the
737738
// server; the process group kill is best-effort cleanup for

0 commit comments

Comments
 (0)