Skip to content

fix(orphans): reclaim dead helper trees that leaked behind dead sessions - #87

Merged
vishr merged 1 commit into
mainfrom
fix/orphans-background-cpu
Oct 31, 2026
Merged

vishr merged 1 commit into
mainfrom
fix/orphans-background-cpu

Conversation

@vishr

@vishr vishr commented Oct 31, 2026

Copy link
Copy Markdown
Member

What changed

Driga now finds and can kill leaked Codex plugin broker trees, which were never offered for cleanup. Idle means under 5% of a core per process and 25% per tree, not exactly zero. Socket probes report one of four states (none, listening, system, connected). A new signature flag, idle_without_clients, lets the Codex broker and claude mcp serve count as idle when no real client is connected. It also fixes a macOS lsof parsing bug that made every Unix socket look externally connected.

Why

On one machine, 27 orphaned app-server-broker.mjs trees held about 1,500 processes and 5.8 GB, and driga orphans list showed none of them. Five rules each blocked them:

  • CPU: a tree counted as idle only at exactly 0% CPU. Idle MCP servers tick at 0.01–1.8%.
  • App bundle: any process from a running app's bundle protected its tree. Codex app-server starts ChatGPT.app's cua_node.
  • Socket address field: macOS lsof -F prints a Unix socket's own address in d, not D. So no peer ever resolved, and even the broker's connection to its own child counted as external. The fixture was hand-written with D, so tests never caught it.
  • Listeners: brokers listen on broker.sock by design, and any listener counted as use. The plugin reuses a broker while the socket answers and starts a new one when it doesn't, so a broker with no client is idle.
  • Accepted connections: macOS lsof shows a connection a server accepted only on the client's side. Finding a listener's clients takes one pass over all of the user's processes (PeerResolver). Using that pass's snapshot for the tree's own Unix sockets avoids a gap between two scans.

Rejected alternatives:

  • A fixed per-process CPU limit with no tree cap. Many busy workers could add up to real work.
  • Waiving every descendant's listeners under a flagged root. A dev server started by claude mcp serve would become killable.

Risk and rollout

This changes when Driga can signal or kill a process. The guards:

  • Kill still re-verifies identity, descendants and sockets right before signaling (spec §7.2).
  • Only a flagged helper's own listeners are waived. Under a flagged root, connections to other users' processes, such as system services, are waived too. Connections to the user's processes, network connections, and any other process's listener still keep the tree.
  • The all-process lsof pass runs only when a tree contains a flagged helper. On any error, including exit 1, every process in the tree counts as connected.
  • A broker that a live session will reuse later can now be killed while it has no client. The plugin starts a new broker on its next call; I read that in the plugin code but didn't test it.
  • No migration. Nothing new is written to agent settings, and no extra text is captured.

Contract changes

  • platform.SocketProber.ExternalPeers now returns map[int32]model.PeerState instead of map[int32]bool. New optional platform.PeerResolver interface.
  • New signature key idle_without_clients, documented in docs/usage.md. Extra signature files without it keep the strict behavior.
  • No CLI flag, schema 2, MCP or config key changes.

Verified

Run on macOS (Darwin 27, arm64).

  • go test ./... and go test -race ./...: all pass except TestPermissionHookBuiltBinary (TempDir cleanup), which fails the same way on main without this change.
  • GOOS=linux go build ./...; go vet with GOOS=linux and GOOS=windows; just _fmt-check tidy-check vet generate-check docs-check.
  • Live test against a real broker started by the Codex plugin:
    • Client connected: the broker reads as connected, and driga orphans kill --yes refused 3 times in a row.
    • Client gone: the broker reads as listening and its child as system, and the kill succeeded.
    • A freshly started codex app-server holds an HTTPS connection for a few seconds, and Driga correctly refuses during that time.
  • Reviewed with /code-review at low and high effort. All 11 findings are fixed, and each has a test.
  • I have accepted the CLA, or will when the bot asks on my first pull request.

Leaked Codex plugin broker trees (up to 300 processes each) were never
offered for cleanup. Several rules each blocked them:

- Any non-zero CPU sample counted as work, so idle MCP servers ticking
  timers and GC hid every tree. A member now counts as idle below 5% of
  one core and the whole tree below 25%, in both detection and the
  kill-time recheck.
- A process from a live app's bundle protected its tree even when an
  agent helper only borrowed the bundle's runtime (ChatGPT.app's
  cua_node under codex app-server). It still protects the tree unless
  its parent is a known agent helper.
- macOS lsof prints a Unix socket's own address in field d, not D, so no
  peer ever resolved and every connection, even to the tree's own
  children, read as external. The probe now requests and parses d.
- Socket probes now report a PeerState: none, listening, system (peers
  only another user owns), or connected. A peer shown as (none) has
  closed, and a bound UDP socket on Linux counts as connected, as on
  Windows.
- Signatures can set idle_without_clients. A flagged helper's own
  listeners no longer keep its tree, and under a flagged root neither do
  system-service sockets. Other processes' listeners, connections to the
  user's processes and network connections still do. The Codex broker
  and claude mcp serve set it, since the plugin starts a new broker when
  the old endpoint stops answering.
- On macOS, PeerResolver takes the tree's Unix sockets from one lsof pass
  over all of the user's processes, so a listener's clients (lsof shows
  an accepted connection only on the client's side) and the owners of
  outside peers come from one snapshot. It runs only for trees with a
  flagged helper and fails closed on any lsof error.
- The macOS probe deadline scales with the number of 200-pid batches.
- The TUI kill path takes the configured signature set instead of a
  second copy of the closure.
@vishr
vishr merged commit a9e7e58 into main Oct 31, 2026
7 checks passed
@vishr
vishr deleted the fix/orphans-background-cpu branch October 31, 2026 05:41
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