fix(util): kill spawned process groups when opencode exits (#59411)

This commit is contained in:
Aiden Cline
2026-10-26 17:12:31 -05:00
committed by GitHub
Unverified
parent f97f82c96f
commit e2bc79dc0b
2 changed files with 25 additions and 0 deletions
+6
View File
@@ -36,14 +36,20 @@ export const install = Effect.fnUntraced(function* (input: Duration.Input = DEAD
started = true
fork(expire(signal))
}
// Closing the terminal sends SIGHUP, whose default action exits without teardown. Treat it as SIGTERM, but only
// with a terminal: nohup and the background service have none, and a listener would override nohup's ignore.
const onHangup = () => process.kill(process.pid, "SIGTERM")
const hangup = process.stdin.isTTY || process.stdout.isTTY
const off = () => {
process.off("SIGTERM", onSignal)
process.off("SIGINT", onSignal)
process.off("SIGHUP", onHangup)
}
yield* Effect.acquireRelease(
Effect.sync(() => {
process.on("SIGTERM", onSignal)
process.on("SIGINT", onSignal)
if (hangup) process.on("SIGHUP", onHangup)
}),
// Once shutdown has started, keep listening: this release can run while a later finalizer is still stuck.
() =>
+19
View File
@@ -23,6 +23,19 @@ import { Teardown } from "./teardown.js"
/** Closing a child's scope escalates to SIGKILL after this, unless the command sets its own `forceKillAfter`. */
export const RELEASE_FORCE_KILL_AFTER = "3 seconds"
// Process groups of running children. Exiting without stopping them (a shutdown deadline, a second signal, a
// crash) would orphan them, so a synchronous exit hook kills whatever is left.
const groups = new Set<number>()
let hooked = false
const killGroups = () =>
groups.forEach((pid) => {
try {
globalThis.process.kill(-pid, "SIGKILL")
} catch {
// Already gone.
}
})
const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))
const toTag = (err: NodeJS.ErrnoException): PlatformError.SystemErrorTag => {
@@ -297,6 +310,7 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
resume(Effect.fail(toPlatformError("spawn", err, command)))
})
proc.on("exit", (...args) => {
groups.delete(proc.pid!)
exit = args
Deferred.doneUnsafe(exited, Exit.succeed(args))
})
@@ -308,6 +322,11 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
Deferred.doneUnsafe(closed, Exit.succeed(exit ?? args))
})
proc.on("spawn", () => {
if (opts.detached && globalThis.process.platform !== "win32") {
if (!hooked) globalThis.process.once("exit", killGroups)
hooked = true
groups.add(proc.pid!)
}
settle = Teardown.track(
`child ${proc.pid} running: ${[command.command, ...command.args].join(" ").slice(0, 200)}`,
)