fix(desktop): kill all terminal child processes on close#892
Conversation
Interactive shells with job control give foreground jobs their own process groups, so killing the shell's process group doesn't kill child processes like claude or npm dev servers. Fix by using pkill -t <tty> to kill all processes sharing the same controlling terminal, which includes all descendants regardless of their process group.
|
Caution Review failedThe pull request is closed. 📝 WalkthroughWalkthroughReplaces direct process killing in the desktop PTY subprocess with the tree-kill library, adding a 2s escalation to SIGKILL and a forced-exit fallback; updates disposal to fire-and-forget tree-kill; adds the Changes
Sequence Diagram(s)sequenceDiagram
participant Caller
participant PTYHost
participant treeKillLib as tree-kill
participant OS
Caller->>PTYHost: request kill(pid, signal)
PTYHost->>treeKillLib: treeKill(pid, signal, callback)
treeKillLib->>OS: send signal to pid and its children
treeKillLib-->>PTYHost: callback (error/ok)
PTYHost->>PTYHost: start 2s escalation timer
alt still alive after 2s
PTYHost->>treeKillLib: treeKill(pid, "SIGKILL", callback)
treeKillLib->>OS: send SIGKILL to pid tree
treeKillLib-->>PTYHost: callback (error/ok)
end
PTYHost->>Caller: emit onExit or synthesize exit if needed
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@apps/desktop/src/main/terminal-host/pty-subprocess.ts`:
- Around line 371-399: The block in handleKill currently uses execSync with
interpolated signal and tty (and swallows errors); replace execSync calls with
execFileSync and pass the command and args array (e.g., ["pkill", `-SIG...`,
"-t", tty]) to avoid shell interpolation, validate the incoming signal (allowed
set like SIGHUP,SIGTERM,SIGKILL) and ensure tty is a safe value (reject "?" or
whitespace), and when catching errors: if pkill failed inspect the error.status
and if status === 1 treat as "no processes matched" (log at debug/info)
otherwise log an error with context (include pid, tty, signal and
error.message); do the same input validation and logging for the process.kill
fallback (keep existing behavior but log failures with context and do not
silently swallow exceptions). Ensure identical hardening/logging is applied in
the escalation timer and handleDispose code paths referenced by the diff.
Addresses PR feedback about shell injection risk by using execFileSync with array arguments instead of execSync with string interpolation.
|
I think this actually happens inconsistently which points to a race condition or we're not sending the proper kill signal. perhaps tree-kill would be a cleaner solution |
from my testing it seems to happen every time, but perhaps I am just very unlucky haha |
Replace custom TTY-based approach (ps + pkill -t) with tree-kill library for killing terminal child processes. tree-kill traverses by PPID which handles job control process groups just as well, with simpler code and no shell command dependencies.
|
@Kitenite though you are probably right that we should use tree-kill. Just updated and for the issues I have reproduced this seems to be equivalently functional but definitely cleaner |
tree-kill throws synchronously when no callback is provided and an error occurs. Add callbacks with error logging to all three call sites to prevent unhandled exceptions and improve debuggability.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.
This PR is being reviewed by Cursor Bugbot
Details
You are on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle.
To receive Bugbot reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.
| if (err) { | ||
| console.error("[pty-subprocess] Failed to kill process tree:", err); | ||
| } | ||
| }); |
There was a problem hiding this comment.
Async tree-kill skipped by immediate process.exit
Medium Severity
In handleDispose(), treeKill() is called asynchronously but process.exit(0) executes synchronously on the next line. Since tree-kill internally spawns child processes to discover and kill the process tree, calling process.exit(0) immediately terminates the Node.js event loop before tree-kill can complete its work. The comment "fire and forget since we're exiting" is misleading—async operations cannot complete if the process exits synchronously. Child processes won't be killed in this code path.
Summary
Fixes terminal child processes (Claude Code, npm dev servers, Next.js, etc.) not being killed when closing a terminal tab with Cmd+W. Previously, only the shell process was terminated while child processes continued running in the background.
Problem
When closing a terminal in the desktop app:
npm run dev, etc. survive and accumulateReproduction
claudeto start Claude Codeecho $PPIDto get the shell's PID (e.g.,12345)ps aux | grep 12345Root Cause
The original implementation used node-pty's
ptyProcess.kill(signal)which only sends the signal to the shell's PID directly - not to any child processes.A naive fix would be to use
process.kill(-pid, signal)to kill by process group. However, this doesn't work because interactive shells with job control (zsh, bash) give foreground jobs their own process groups:So
process.kill(-100, signal)only kills processes in PGID 100 (the shell), not PGID 101 (claude).Solution
Use tree-kill to terminate the entire process tree by traversing parent-child relationships (PPID). This handles job control correctly because the parent-child link is preserved even when zsh assigns a new process group.
An alternative approach is TTY-based killing (
pkill -t <tty>), which catches all processes sharing the controlling terminal. tree-kill is simpler (no shelling out tops/pkill) and sufficient here—the edge cases where TTY wins (daemonized processes that keep the TTY) don't apply to interactive terminal commands.Changes
tree-killdependencyhandleKill()to use tree-kill with SIGTERM → SIGKILL escalationhandleDispose()to use tree-killhandleSignal()unchanged—Ctrl+C should only signal the foreground process, not kill the treeTesting
claude(ornpm run dev)pgrep -f claudeshould not show the terminated instanceResolves #893
Note
Ensures terminal child processes are reliably terminated when a tab is closed.
ptyProcess.kill()withtree-killinhandleKill()to terminate the full process tree; escalate toSIGKILLafter 2s and force-exit ifonExitnever fireshandleDispose()totree-killthe process tree withSIGKILLon exithandleSignal()behavior unchanged (signals likeSIGINTonly)tree-killdependency and bump desktop app version to0.0.59Written by Cursor Bugbot for commit d386714. This will update automatically on new commits. Configure here.
Summary by CodeRabbit
✏️ Tip: You can customize this high-level summary in your review settings.