fix: prevent zombie processes with proper process lifecycle management (#1306)

* fix: prevent zombie processes with proper process lifecycle management

- Await proc.exited for fire-and-forget spawns in tmux-utils.ts
- Remove competing process.exit() calls from LSP client and skill-mcp-manager
  signal handlers to let background-agent manager coordinate final exit
- Await process exit after kill() in interactive-bash timeout handler
- Await process exit after kill() in LSP client stop() method

These changes ensure spawned processes are properly reaped and prevent
orphan/zombie processes when running with tmux integration.

* fix: address Copilot review comments on process cleanup

- LSP cleanup: use async/sync split with Promise.allSettled for proper subprocess cleanup
- LSP stop(): make idempotent by nulling proc before await to prevent race conditions
- Interactive-bash timeout: use .then()/.catch() pattern instead of async callback to avoid unhandled rejections
- Skill-mcp-manager: use void+catch pattern for fire-and-forget signal handlers

* fix: address remaining Copilot review comments

- interactive-bash: reject timeout immediately, fire-and-forget zombie cleanup
- skill-mcp-manager: update comments to accurately describe signal handling strategy

* fix: address additional Copilot review comments

- LSP stop(): add 5s timeout to prevent indefinite hang on stuck processes
- tmux-utils: log warnings when pane title setting fails (both spawn/replace)
- BackgroundManager: delay process.exit() to next tick via setImmediate to allow other signal handlers to complete cleanup

* fix: address code review findings

- Increase exit delay from setImmediate to 100ms setTimeout to allow async cleanup
- Use asyncCleanup for SIGBREAK on Windows for consistency with SIGINT/SIGTERM
- Add try/catch around stderr read in spawnTmuxPane for consistency with replaceTmuxPane

* fix: address latest Copilot review comments

- LSP stop(): properly clear timeout when proc.exited wins the race
- BackgroundManager: use process.exitCode before delayed exit for cleaner shutdown
- spawnTmuxPane: remove redundant log import, reuse existing one

* fix: address latest Copilot review comments

- LSP stop(): escalate to SIGKILL on timeout, add logging
- tmux spawnTmuxPane/replaceTmuxPane: drain stderr immediately to avoid backpressure

* fix: address latest Copilot review comments

- Add .catch() to asyncCleanup() signal handlers to prevent unhandled rejections
- Await proc.exited after SIGKILL with 1s timeout to confirm termination

* fix: increase exit delay to 6s to accommodate LSP cleanup

LSP cleanup can take up to 5s (timeout) + 1s (SIGKILL wait), so the exit
delay must be at least 6s to ensure child processes are properly reaped.
This commit is contained in:
Nguyen Khac Trung Kien
2026-01-31 14:01:19 +07:00
committed by GitHub
parent 4a82ff40fb
commit b03e463bde
5 changed files with 102 additions and 40 deletions
+27 -4
View File
@@ -139,10 +139,22 @@ export async function spawnTmuxPane(
}
const title = `omo-subagent-${description.slice(0, 20)}`
spawn([tmux, "select-pane", "-t", paneId, "-T", title], {
const titleProc = spawn([tmux, "select-pane", "-t", paneId, "-T", title], {
stdout: "ignore",
stderr: "ignore",
stderr: "pipe",
})
// Drain stderr immediately to avoid backpressure
const stderrPromise = new Response(titleProc.stderr).text().catch(() => "")
const titleExitCode = await titleProc.exited
if (titleExitCode !== 0) {
const titleStderr = await stderrPromise
log("[spawnTmuxPane] WARNING: failed to set pane title", {
paneId,
title,
exitCode: titleExitCode,
stderr: titleStderr.trim(),
})
}
return { success: true, paneId }
}
@@ -217,10 +229,21 @@ export async function replaceTmuxPane(
}
const title = `omo-subagent-${description.slice(0, 20)}`
spawn([tmux, "select-pane", "-t", paneId, "-T", title], {
const titleProc = spawn([tmux, "select-pane", "-t", paneId, "-T", title], {
stdout: "ignore",
stderr: "ignore",
stderr: "pipe",
})
// Drain stderr immediately to avoid backpressure
const stderrPromise = new Response(titleProc.stderr).text().catch(() => "")
const titleExitCode = await titleProc.exited
if (titleExitCode !== 0) {
const titleStderr = await stderrPromise
log("[replaceTmuxPane] WARNING: failed to set pane title", {
paneId,
exitCode: titleExitCode,
stderr: titleStderr.trim(),
})
}
log("[replaceTmuxPane] SUCCESS", { paneId, sessionId })
return { success: true, paneId }