Skip to content

Terminal shells leak: most pane/workspace close paths never kill the PTY, and kills are not tree-kills (Claude Code -s backend orphans) #65

Description

@kevmtt

wmux: 0.13.0
OS: Windows 11 Home, build 26200

Summary

Over a long wmux session, terminal panes (and the shells spawned inside them)
accumulate and are never cleaned up. Windows does not reap them, because every
shell still has a live parent — the wmux process — so from the OS's point of view
nothing is orphaned. Only wmux knows a pane is gone, so only wmux can reap it, and
in most close paths it never does.

After tracing the code this is a genuine leak, not intentional session
retention. There are two distinct defects:

  1. Missing teardown. PTY kill is wired into only two UI button handlers. Every
    other way to close a surface/pane/workspace — Ctrl+W, the CLI
    (close-surface / close-pane / close-workspace), and closing a
    workspace
    from the sidebar — drops the layout node (or the whole workspace
    subtree) without killing the shell. The orphaned PTYs stay registered in the
    main process and run until the app quits.
  2. No process-tree kill. Even the two paths that do kill only close the
    ConPTY pseudoconsole; they don't sweep the process tree. Grandchildren that
    don't share the console lifetime — notably Claude Code's persistent backend
    shell (powershell -Version 5.1 -s -NoLogo -NoProfile) — survive as orphans.

There is no reaper, no idle-timeout, and no "close idle panes" affordance
anywhere in the code, so nothing ever collects the orphans between launch and quit.

What I observed

After ~10 hours of use, on a machine that felt sluggish:

  • 15 pane shells alive (powershell.exe … -NoExit -Command ". $env:WMUX_PS1_SCRIPT"),
    all children of the single live wmux process.
  • Only 6 of those panes had active work; 9 panes were "bare" — no claude
    process, yet the wmux wrapper shell and a leftover -s backend shell were
    both still running.
  • Each idle pane held ~180 MB (wrapper ~99 MB + leftover backend ~84 MB) and had
    accumulated ~500 s of CPU time.
  • 9 idle panes ≈ ~1.6 GB RAM held for nothing, plus a continuous CPU trickle
    (which also keeps antivirus busy scanning the churn).
  • None were OS-level zombies — every process had wmux as a live ancestor, which is
    exactly why Windows never cleaned them up.

Root cause (code trace)

Lifecycle

Create (one wrapper shell per terminal surface):

  • Renderer useTerminalwindow.wmux.pty.create({surfaceId})src/renderer/hooks/useTerminal.ts:699 (and :712)
  • Main PTY_CREATE handler → ptyManager.create()src/main/ipc-handlers.ts:39src/main/pty-manager.ts:202
  • Spawn — src/main/pty-manager.ts:288, with useConpty: true, useConptyDll: true (:279, :284)
  • PTY registered in the ptyManager.ptys map — src/main/pty-manager.ts:327

KillptyManager.kill() at src/main/pty-manager.ts:390 is reached only via:

  • PTY_KILL IPC (src/main/ipc-handlers.ts:84) ← renderer window.wmux.pty.kill(), called from exactly two places:
    • PaneWrapper.handleCloseSurfacesrc/renderer/components/SplitPane/PaneWrapper.tsx:349 (tab-bar ×)
    • PaneWrapper.handleClosePanesrc/renderer/components/SplitPane/PaneWrapper.tsx:381 (pane-close button)
  • ptyManager.killAll() on app quit — src/main/index.ts:808

Defect 1 — close paths that kill nothing

The store primitive closeSurface (src/renderer/store/surface-slice.ts:149)
only edits the split tree; it never kills a PTY. The kill was bolted onto the
PaneWrapper UI handlers instead of the state transition, so every other caller
leaks:

Close route Code Kills PTY?
Ctrl+W (closeSurfaceOrPane) src/renderer/hooks/useKeyboardShortcuts.ts:198store.closeSurface
CLI wmux close-surface src/renderer/pipe-bridge.ts:239store.closeSurface
CLI wmux close-pane src/renderer/pipe-bridge.ts:118removeLeaf only
Close workspace (Ctrl+Shift+W / sidebar × / CLI) src/renderer/store/workspace-slice.ts:61 closeWorkspace ❌ (drops the entire subtree — every pane)

An orphaned PTY stays in ptyManager.ptys and its wrapper shell (plus anything it
spawned) runs until will-quit.

Defect 2 — no tree-kill even on the button paths

wmux spawns with useConptyDll: true (src/main/pty-manager.ts:284). In
node-pty 1.1.0, WindowsPtyAgent.kill() has a dedicated DLL branch
(node_modules/node-pty/lib/windowsPtyAgent.js:153-160) that only calls
ClosePseudoConsole — it does not enumerate and force-kill the console
process list. (The non-DLL ConPTY branch at :137-152 and the winpty branch at
:162-179 do sweep the list with process.kill(pid); the DLL path skips it.)
ptyManager.kill (src/main/pty-manager.ts:395) just calls entry.pty.kill()
with no taskkill /T.

Result: closing the pseudoconsole terminates the directly-attached wrapper shell,
but a grandchild that doesn't share the console lifetime — Claude Code's -s
backend (spawned by the claude node process over piped stdio) — orphans.

Why the symptom matches

A restored session mounts all workspaces at once
(src/renderer/App.tsx:844 — "ALL workspaces stay mounted, only active is
visible"), producing the "startup burst" of wrapper shells. Closing 9 of them via
Ctrl+W or by closing workspaces removes them from the split tree but never kills
the wrapper shells
, and each still parents whatever it spawned (Claude Code's
leftover -s). Every orphan keeps wmux as a live ancestor → Windows never reaps →
~180 MB/pane held for the whole session.

Intentional keep-alive (this part is correct)

  • useTerminal unmount deliberately does not kill the PTY —
    src/renderer/hooks/useTerminal.ts:788-790 ("Do NOT kill the PTY here — only
    explicit close").
  • All workspaces/tabs stay mounted so PTYs survive workspace/tab switches and
    split-tree restructures (src/renderer/App.tsx:844-845).

This keep-alive is intended and fine. The bug is that PTY lifetime was decoupled
from component unmount but teardown was only re-attached to two buttons, so
genuinely-destructive transitions on every other path have no teardown.

Is this session-recovery behaviour? No.

Session persistence saves layout only — splitTree, title, cwd, shell — never
live PTY state (src/main/session-persistence.ts:12-29, and the note at :78-82:
named sessions are "layout-only snapshots… loading one always re-spawns fresh
PTYs"). On restore, pty.has(surfaceId) is false, so useTerminal calls
pty.create() and spawns a fresh shell (src/renderer/hooks/useTerminal.ts:693-708).

So there is no live-session reattach to preserve — not within a run, not across
restarts. Killing a shell when its surface/pane/workspace is genuinely closed
breaks nothing.

This is also why an idle-timeout is the wrong fix: it would kill shells in
panes the user deliberately left open (a long build, a backgrounded claude),
fighting the whole point of keep-alive. The correct fix is to reap on the
destructive transition, not on a timer.

Expected

  • Closing a pane/surface/workspace — by any route (tab ×, pane-close button,
    Ctrl+W, CLI, closing the workspace) — terminates its shell and any
    backend/stdin helper shells it spawned
    , with no leftover processes.

Actual

  • Only the tab-× and pane-close buttons kill anything, and even they leave
    grandchildren (-s backend) orphaned. Every other close route leaks the whole
    wrapper shell + its children, which then persist for the life of the wmux
    process.

Proposed fix

A. Tie PTY teardown to the destructive state transition (keyed on the surface
being removed, so surface move/split re-homes are untouched):

  • src/renderer/store/surface-slice.ts closeSurface → kill the closed terminal
    surface's PTY. One change fixes both Ctrl+W and wmux close-surface
    (both funnel through it).
  • src/renderer/store/workspace-slice.ts closeWorkspace → walk the workspace's
    splitTree and kill every terminal surface. Fixes Ctrl+Shift+W, sidebar close,
    and wmux close-workspace.
  • src/renderer/pipe-bridge.ts __wmux_closePane → kill the pane's terminal
    surfaces before removeLeaf (mirror PaneWrapper.handleClosePane). Fixes
    wmux close-pane.
  • The two PaneWrapper kills then become redundant (harmless — kill is
    idempotent) and can be dropped in favour of the store owning teardown.

B. Make the kill a real tree-kill (fixes the orphaned -s on every path):

  • src/main/pty-manager.ts kill() → after entry.pty.kill(), on Windows also
    taskkill /PID <entry.pty.pid> /T /F (the pid is already exposed via
    getPid at src/main/pty-manager.ts:431). This closes the grandchild-orphan
    gap regardless of node-pty's DLL branch, and also benefits killAll on quit.

A + B together = the wrapper is always reaped and its Claude Code -s backend
dies with it.

Optional follow-ups (not required to fix the leak)

  • Tree-orphan reaper (belt-and-suspenders): a main-process sweep that kills any
    ptyManager.ptys entry whose surfaceId no longer appears in any workspace tree —
    catches any future close path that forgets to reap.
  • A true idle-timeout / "close idle panes" UI would be a feature (keyed on
    the already-tracked shellState / Claude activity, src/shared/types.ts:73),
    distinct from this leak fix.

Steps to reproduce

  1. Open several panes and run tools (e.g. claude) in them over a session.
  2. Close some panes with Ctrl+W, or close a whole workspace, or use
    wmux close-pane / wmux close-surface / wmux close-workspace.
  3. Count pane shells (Get-Process powershell / Task Manager) vs. panes actually
    open. The shell count stays higher than the open-pane count, and closed panes
    leave behind both the wrapper shell and any -s backend shell — held until wmux
    is restarted.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions