Skip to content

fix(desktop): tree-kill all child processes on daemon restart#1504

Merged
Kitenite merged 4 commits into
mainfrom
kitenite/restart-daemon-treekill
Feb 17, 2026
Merged

fix(desktop): tree-kill all child processes on daemon restart#1504
Kitenite merged 4 commits into
mainfrom
kitenite/restart-daemon-treekill

Conversation

@Kitenite
Copy link
Copy Markdown
Collaborator

@Kitenite Kitenite commented Feb 14, 2026

Summary

  • Daemon restart was only killing the immediate subprocess, leaving PTY shells and their children (git, npm, etc.) as orphans
  • The SIGKILL fallback timer was unreffed and got abandoned when the daemon called process.exit(0), so it never actually fired
  • Now session.dispose() tree-kills both the subprocess PID and PTY PID, and the shutdown path awaits completion before exiting

Changes

  • session.ts: dispose() now uses treeKillAsync on both subprocess and PTY PIDs instead of subprocess.kill("SIGKILL") with an unreffed timer. Extracted resetProcessState() to deduplicate state-reset logic shared with handleSubprocessExit. Extracted collectProcessPids() for clarity.
  • terminal-host.ts: dispose() is now async — awaits all session tree-kills with a 5s hard timeout to prevent hanging.
  • index.ts: stopServer() awaits terminalHost.dispose() before closing the server and exiting, ensuring ps/pgrep child enumeration completes before process.exit(0).
  • tree-kill-with-escalation.ts → tree-kill.ts: Renamed and added treeKillAsync (promisified tree-kill wrapper). Updated import in port-manager.ts.

Test Plan

  • Typecheck passes
  • Terminal-host tests pass (14 tests, 0 failures)
  • Manual: restart daemon via tray menu, verify no orphaned shell processes remain
  • Manual: restart daemon while a terminal has a long-running process (e.g. sleep 999), verify it gets killed

Summary by CodeRabbit

  • Chores

    • Made shutdown and disposal paths fully asynchronous so resources are reliably awaited during teardown.
    • Centralized and improved subprocess/process-tree termination with an awaitable kill path for cleaner cleanup.
    • Added per-session disposal coordination with timeout protection to avoid hangs during shutdown.
  • Bug Fixes

    • Reduced risk of orphaned subprocesses and race conditions during terminal/session shutdown.

Note

Medium Risk
Changes shutdown and process-termination behavior in the terminal daemon; mistakes could cause hangs (mitigated by a 5s timeout) or over/under-killing processes.

Overview
Fixes daemon restart/shutdown leaving orphaned terminal processes by making session/host disposal async and awaited during stopServer().

Session.dispose() now collects both subprocess and PTY PIDs and uses a new promisified treeKillAsync (SIGKILL) to tree-kill descendants, replacing the previous unreffed timeout-based kill; teardown state-reset logic is centralized via resetProcessState(). TerminalHost.dispose() awaits all session disposals with a 5s cap, and callers updated to fire-and-forget dispose() where waiting isn’t needed. Also updates the port manager to import treeKillWithEscalation from the consolidated tree-kill module.

Written by Cursor Bugbot for commit 25ebf1f. This will update automatically on new commits. Configure here.

Previously, daemon restart only called subprocess.kill("SIGKILL") which
killed the immediate subprocess but left PTY shells and their children
(git, npm, etc.) as orphans. The SIGKILL fallback timer was also unreffed
and abandoned when the daemon called process.exit(0).

Now session.dispose() uses tree-kill on both the subprocess PID and PTY
PID, and the shutdown path awaits completion before exiting. Also extracts
shared state-reset logic and consolidates tree-kill utilities into a
single module.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 14, 2026

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

Converts terminal process teardown to async flows: adds a Promise-based treeKillAsync, updates a port-manager import to use it, and changes dispose/stopServer signatures in TerminalHost, Session, and the host index to await disposal and process termination.

Changes

Cohort / File(s) Summary
Lib — tree-kill wrapper
apps/desktop/src/main/lib/tree-kill.ts
Adds treeKillAsync(pid: number, signal: string): Promise<void> — promisified wrapper around the callback-based tree-kill call.
Import update
apps/desktop/src/main/lib/terminal/port-manager.ts
Import path changed from ../tree-kill-with-escalation to ../tree-kill; usage unchanged.
Server lifecycle
apps/desktop/src/main/terminal-host/index.ts
stopServer() made async; now awaits terminalHost.dispose() and server.close(), and performs socket/PID cleanup afterward inside try/catch.
Session async teardown
apps/desktop/src/main/terminal-host/session.ts
Session.dispose()Promise<void>; adds collectProcessPids() and resetProcessState(); disposes emulator/clients, then conditionally awaits treeKillAsync on collected PIDs.
TerminalHost disposal
apps/desktop/src/main/terminal-host/terminal-host.ts
TerminalHost.dispose()Promise<void>; snapshots active sessions, clears kill timers, awaits all session disposals with a 5s timeout, and preserves existing spawn/detach/kill logic (disposal calls now fire awaited/discarded promises).
sequenceDiagram
  participant TH as TerminalHost
  participant S as Session
  participant TK as treeKillAsync
  participant OS as Operating System

  TH->>S: dispose() (await)
  S->>S: collectProcessPids()
  S->>TK: treeKillAsync(pid, signal)
  TK->>OS: send kill to pid tree
  OS-->>TK: callback/ack
  TK-->>S: resolve Promise
  S->>S: resetProcessState(), dispose emulator, clear clients
  S-->>TH: dispose resolved
  TH->>TH: await all sessions / timeout, continue shutdown
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐇 I nudged the kills to promise-led,

so processes sleep soft, not dead.
Awaited whispers, tidy state,
Sessions close at calmer gait,
A rabbit cheers — cleanup's fed.

🚥 Pre-merge checks | ✅ 2 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (42 files):

⚔️ .github/workflows/build-desktop.yml (content)
⚔️ .superset/lib/setup/steps.sh (content)
⚔️ apps/desktop/electron.vite.config.ts (content)
⚔️ apps/desktop/src/lib/trpc/routers/ai-chat/index.ts (content)
⚔️ apps/desktop/src/lib/trpc/routers/ai-chat/utils/auth/auth.ts (content)
⚔️ apps/desktop/src/main/lib/terminal/port-manager.ts (content)
⚔️ apps/desktop/src/main/terminal-host/index.ts (content)
⚔️ apps/desktop/src/main/terminal-host/session.ts (content)
⚔️ apps/desktop/src/main/terminal-host/terminal-host.ts (content)
⚔️ apps/desktop/src/renderer/env.renderer.ts (content)
⚔️ apps/desktop/src/renderer/index.html (content)
⚔️ apps/desktop/src/renderer/lib/auth-client.ts (content)
⚔️ apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx (content)
⚔️ apps/desktop/src/renderer/routes/_authenticated/components/TeardownLogsDialog/TeardownLogsDialog.tsx (content)
⚔️ apps/desktop/src/renderer/routes/_authenticated/components/TeardownLogsDialog/index.ts (content)
⚔️ apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts (content)
⚔️ apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx (content)
⚔️ apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabContentContextMenu.tsx (content)
⚔️ apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx (content)
⚔️ apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx (content)
⚔️ apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ModelPicker/ModelPicker.tsx (content)
⚔️ apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/constants.ts (content)
⚔️ apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/types.ts (content)
⚔️ apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx (content)
⚔️ apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx (content)
⚔️ apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts (content)
⚔️ apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRefs.ts (content)
⚔️ apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/WorkspaceInitializingView.tsx (content)
⚔️ apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/DeleteWorktreeDialog.tsx (content)
⚔️ apps/desktop/src/renderer/stores/tabs/terminal-callbacks.ts (content)
⚔️ apps/desktop/vite/helpers.ts (content)
⚔️ apps/marketing/src/app/components/HeroSection/HeroSection.tsx (content)
⚔️ apps/marketing/src/app/components/HeroSection/components/AppMockup/AppMockup.tsx (content)
⚔️ apps/marketing/src/app/components/HeroSection/components/ProductDemo/ProductDemo.tsx (content)
⚔️ apps/marketing/src/app/components/HeroSection/components/ProductDemo/components/SelectorPill/SelectorPill.tsx (content)
⚔️ apps/marketing/src/app/components/TrustedBySection/TrustedBySection.tsx (content)
⚔️ bun.lock (content)
⚔️ package.json (content)
⚔️ packages/agent/package.json (content)
⚔️ packages/agent/src/index.ts (content)
⚔️ packages/ui/src/components/ai-elements/message.tsx (content)
⚔️ turbo.jsonc (content)

These conflicts must be resolved before merging into main.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main objective of the PR: fixing daemon restart to properly kill all child processes using tree-kill.
Description check ✅ Passed The PR description covers all required sections: Summary explains the problem, Changes detail all modified files, and Test Plan documents completion status.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch kitenite/restart-daemon-treekill

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
apps/desktop/src/main/terminal-host/session.ts (2)

847-866: Good consolidation of reset logic.

One observation: ptyPid is not cleared here, so collectProcessPids() would still return it on a hypothetical second call. This is safe because dispose() guards with this.disposed and handleSubprocessExit doesn't call collectProcessPids(), but if resetProcessState is intended to fully reset process-related state, consider also clearing ptyPid for consistency.


806-808: Minor style nit: dispose() returns Promise<void> but isn't async.

The method uses explicit Promise.resolve() and .then() instead of async/await. Since sibling files (terminal-host.ts, index.ts) use async dispose(), making this async too would improve consistency and simplify the early returns to plain return;.

Comment thread apps/desktop/src/main/lib/tree-kill.ts
Comment thread apps/desktop/src/main/terminal-host/terminal-host.ts Outdated
Comment thread apps/desktop/src/main/terminal-host/terminal-host.ts Outdated
@Kitenite Kitenite merged commit f5743d4 into main Feb 17, 2026
6 of 7 checks passed
@Kitenite Kitenite deleted the kitenite/restart-daemon-treekill branch February 17, 2026 02:32
@github-actions
Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ⚠️ Neon database branch
  • ⚠️ Electric Fly.io app
  • ⚠️ Streams Fly.io app

Thank you for your contribution! 🎉

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

this.disposed = true;

const pidsToKill = this.collectProcessPids();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dispose frame never sent due to early disposed flag

Medium Severity

this.disposed is set to true before sendDisposeToSubprocess() is called. But sendFrameToSubprocess (which sendDisposeToSubprocess delegates to) has an early return if (this.disposed) return false, so the dispose frame is silently dropped and never reaches the subprocess. The graceful shutdown notification intended by the code never actually fires.

Additional Locations (1)

Fix in Cursor Fix in Web

await Promise.race([
Promise.all(sessions.map((s) => s.dispose())),
new Promise<void>((resolve) => setTimeout(resolve, 5000)),
]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaked timeout in TerminalHost.dispose() Promise.race

Low Severity

The 5-second setTimeout in Promise.race is never cleared when Promise.all resolves first. Unlike promiseWithTimeout in the same file which properly clears its timer, this timeout leaks and keeps the event loop alive unnecessarily. In production process.exit() masks this, but it could affect tests or other callers.

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant