Skip to content

Orphaned daemon processes accumulate silently across app restarts/crashes — no cross-launch reaping #12243

Description

@Jaeyoung22

Problem

Orca spawns one long-lived daemon process per app-data profile (createOutOfProcessLauncher, src/main/daemon/daemon-init.ts:506), then immediately does child.disconnect(); child.unref() (daemon-init.ts:631-632) — deliberate, per the comment there: "Disconnect without killing: the daemon survives app quit so sessions stay warm for reattach." Each terminal tab's shell is spawned under that daemon via /usr/bin/login (src/main/providers/macos-tcc-login-shell.ts, for correct macOS TCC permission attribution), producing a login → zsh → <shell/CLI> tree.

On startup, daemon-health.ts (checkDaemonHealth, getDaemonLaunchIdentity, isDaemonStaleForCurrentBundle, killStaleDaemon) reconciles by reading <userData>/daemon/daemon-v<N>.pid plus a socket/token file, adopting a healthy daemon and killing a stale/mismatched one. It even probes every prior protocol version (1–29, daemon-protocol-version.ts) inside the same runtime dir and adopts them as legacy adapters — this part works well.

The gap: every part of that reconciliation is scoped to the launching instance's own app.getPath('userData')/daemon path. Nothing in the codebase:

  • sweeps other profiles' daemon runtime directories, or
  • scans the OS process table for self-owned Orca/daemon-entry processes with PPID=1 that don't correspond to any known pid/socket file.

On graceful quit (will-quit handler, src/main/index.ts:2941), killAllPty() only kills main-process-owned/local fallback PTYs, then disconnectDaemon() runs — which just closes the IPC socket and never signals the daemon (daemon-init.ts:1017-1020). Only a dev-mode "parent shutdown" path calls shutdownDaemon(), which kills everything. So a normal quit and a SIGKILL of the Electron main process both leave the daemon (and its whole PTY tree) alive. If the next launch's reconciliation ever fails to rediscover that exact daemon — different profile, a lost/deleted pid or socket file, whatever — it becomes a permanent, undiscoverable orphan. There is no fallback path that would ever find it again.

Also: daemon-init.ts:887 cites docs/daemon-staleness-ux.md §Phase 1 (a "7-step restart sequence") as the design rationale for this behavior, but that file does not exist anywhere in the repo — looks like it was never committed, or was deleted after the code that depends on it landed.

Evidence

Found live on a dev machine while investigating unrelated high memory usage. ps -eo pid,ppid,comm | grep -i "Orca Helper" showed 5 Helper processes with PPID=1 (reparented to launchd), alongside the Helpers that were legitimately children of the currently-running main Orca process:

12364     1  Orca Helper
29701     1  Orca Helper
34533     1  Orca Helper
92228     1  Orca Helper
96978     1  Orca Helper

None of these appeared anywhere in the current window's sidebar/tab list — there's no IPC channel back to a dead parent, so the running instance has no way to know they exist.

Walking each one's process tree (pgrep -P) found 32 login → zsh → <CLI> sessions total hanging off just these 5 orphans (one Helper alone, pid 96978, had 19). Ages ranged from 42 minutes to 14 days 17 hours, based on each zsh's elapsed time. Combined they were holding several GB of RSS — most of it from long-running claude CLI sessions started inside those tabs, not the daemon processes themselves. Orca version: 1.4.164, macOS (Darwin 25.5.0).

Exact trigger wasn't isolated (crash vs. force-quit vs. an app update relaunching with a new bundle/protocol version are all plausible, and different orphans here likely came from different causes given the wide age spread) — only the resulting state was confirmed.

Why this isn't a simple "scan and reap" fix

There's no sound local signal for condemning one of these processes:

  • PPID=1 alone can't distinguish a genuine orphan from a normal warm daemon — every daemon looks like this by design, immediately after disconnect()/unref().
  • A successful socket handshake proves the daemon is alive and who it is, but not whether some other currently-open profile/window still depends on it.
  • An unreachable handshake doesn't prove the daemon is dead either — it could be transiently wedged with live sessions still inside it. Existing code already treats "can't verify state" as a reason to preserve rather than discard (daemon-init.ts:435).
  • Nothing in the codebase establishes that all profile userData directories are enumerable from one common base path, which a cross-profile sweep would need.

Given all of that, a naive PID-table sweep risks reaping a daemon that's still the live backing store for another open profile/window — silently destroying that user's terminal sessions and scrollback with no undo.

What a fix likely needs

Not an inferred signal but a maintained one — an explicit ownership/lease record (profile runtime path, daemon start identity/nonce, which app instances currently hold a lease on it, last-detach timestamp). On startup, enumerate that record, handshake every daemon it names, and only reap one that's confirmed by process identity, has zero active leases, zero live sessions, and has been unreachable past a conservative grace period. OS process-table scanning is fine as diagnostic input for that decision, but shouldn't be the sole authority to kill something.

Filing this as an issue rather than a PR because the daemon lifecycle is deliberately built to survive quits/crashes for warm reattach, and a wrong fix here has an unusually bad failure mode (silent, unrecoverable loss of a user's terminal history).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions