Skip to content

Commit e2bc79d

Browse files
authored
fix(util): kill spawned process groups when opencode exits (#59411)
1 parent f97f82c commit e2bc79d

2 files changed

Lines changed: 25 additions & 0 deletions

File tree

‎packages/cli/src/shutdown-deadline.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,20 @@ export const install = Effect.fnUntraced(function* (input: Duration.Input = DEAD
3636
started = true
3737
fork(expire(signal))
3838
}
39+
// Closing the terminal sends SIGHUP, whose default action exits without teardown. Treat it as SIGTERM, but only
40+
// with a terminal: nohup and the background service have none, and a listener would override nohup's ignore.
41+
const onHangup = () => process.kill(process.pid, "SIGTERM")
42+
const hangup = process.stdin.isTTY || process.stdout.isTTY
3943
const off = () => {
4044
process.off("SIGTERM", onSignal)
4145
process.off("SIGINT", onSignal)
46+
process.off("SIGHUP", onHangup)
4247
}
4348
yield* Effect.acquireRelease(
4449
Effect.sync(() => {
4550
process.on("SIGTERM", onSignal)
4651
process.on("SIGINT", onSignal)
52+
if (hangup) process.on("SIGHUP", onHangup)
4753
}),
4854
// Once shutdown has started, keep listening: this release can run while a later finalizer is still stuck.
4955
() =>

‎packages/util/src/cross-spawn-spawner.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,19 @@ import { Teardown } from "./teardown.js"
2323
/** Closing a child's scope escalates to SIGKILL after this, unless the command sets its own `forceKillAfter`. */
2424
export const RELEASE_FORCE_KILL_AFTER = "3 seconds"
2525

26+
// Process groups of running children. Exiting without stopping them (a shutdown deadline, a second signal, a
27+
// crash) would orphan them, so a synchronous exit hook kills whatever is left.
28+
const groups = new Set<number>()
29+
let hooked = false
30+
const killGroups = () =>
31+
groups.forEach((pid) => {
32+
try {
33+
globalThis.process.kill(-pid, "SIGKILL")
34+
} catch {
35+
// Already gone.
36+
}
37+
})
38+
2639
const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))
2740

2841
const toTag = (err: NodeJS.ErrnoException): PlatformError.SystemErrorTag => {
@@ -297,6 +310,7 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
297310
resume(Effect.fail(toPlatformError("spawn", err, command)))
298311
})
299312
proc.on("exit", (...args) => {
313+
groups.delete(proc.pid!)
300314
exit = args
301315
Deferred.doneUnsafe(exited, Exit.succeed(args))
302316
})
@@ -308,6 +322,11 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
308322
Deferred.doneUnsafe(closed, Exit.succeed(exit ?? args))
309323
})
310324
proc.on("spawn", () => {
325+
if (opts.detached && globalThis.process.platform !== "win32") {
326+
if (!hooked) globalThis.process.once("exit", killGroups)
327+
hooked = true
328+
groups.add(proc.pid!)
329+
}
311330
settle = Teardown.track(
312331
`child ${proc.pid} running: ${[command.command, ...command.args].join(" ").slice(0, 200)}`,
313332
)

0 commit comments

Comments
 (0)