Repository navigation
Description
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:
- 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. - 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-sbackend 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
useTerminal→window.wmux.pty.create({surfaceId})—src/renderer/hooks/useTerminal.ts:699(and:712) - Main
PTY_CREATEhandler →ptyManager.create()—src/main/ipc-handlers.ts:39→src/main/pty-manager.ts:202 - Spawn —
src/main/pty-manager.ts:288, withuseConpty: true, useConptyDll: true(:279,:284) - PTY registered in the
ptyManager.ptysmap —src/main/pty-manager.ts:327
Kill — ptyManager.kill() at src/main/pty-manager.ts:390 is reached only via:
PTY_KILLIPC (src/main/ipc-handlers.ts:84) ← rendererwindow.wmux.pty.kill(), called from exactly two places:PaneWrapper.handleCloseSurface—src/renderer/components/SplitPane/PaneWrapper.tsx:349(tab-bar ×)PaneWrapper.handleClosePane—src/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:198 → store.closeSurface |
❌ |
CLI wmux close-surface |
src/renderer/pipe
8000
-bridge.ts:239 → store.closeSurface |
❌ |
CLI wmux close-pane |
src/renderer/pipe-bridge.ts:118 → removeLeaf 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)
useTerminalunmount 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 (-sbackend) 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.tscloseSurface→ kill the closed terminal
surface's PTY. One change fixes both Ctrl+W andwmux close-surface
(both funnel through it).src/renderer/store/workspace-slice.tscloseWorkspace→ walk the workspace's
splitTreeand kill every terminal surface. Fixes Ctrl+Shift+W, sidebar close,
andwmux close-workspace.src/renderer/pipe-bridge.ts__wmux_closePane→ kill the pane's terminal
surfaces beforeremoveLeaf(mirrorPaneWrapper.handleClosePane). Fixes
wmux close-pane.- The two
PaneWrapperkills 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.tskill()→ afterentry.pty.kill(), on Windows also
taskkill /PID <entry.pty.pid> /T /F(the pid is already exposed via
getPidatsrc/main/pty-manager.ts:431). This closes the grandchild-orphan
gap regardless of node-pty's DLL branch, and also benefitskillAllon 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.ptysentry 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-trackedshellState/ Claude activity,src/shared/types.ts:73),
distinct from this leak fix.
Steps to reproduce
- Open several panes and run tools (e.g.
claude) in them over a session. - Close some panes with Ctrl+W, or close a whole workspace, or use
wmux close-pane/wmux close-surface/wmux close-workspace. - 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-sbackend shell — held until wmux
is restarted.
Activity
amirlehmam commentedon Jul 1, 2026
Fixed in v0.14.0. Thanks for the exceptionally thorough trace — the diagnosis was spot on, and the fix follows your proposed A + B directly.
A — teardown moved to the state transition (not the UI). PTY reaping was bolted onto only the two
PaneWrapperbuttons; every other close route dropped the layout node without killing the shell. I introduced a single reaping primitive (src/renderer/store/pty-teardown.ts) and wired it into the store actions that every close funnels through:surface-slice.tscloseSurfacenow kills the closed terminal's PTY → fixes Ctrl+W andwmux close-surface(both funnel here).workspace-slice.tscloseWorkspacenow walks the workspacesplitTreeand kills every terminal surface → fixes Ctrl+Shift+W, the sidebar ×, andwmux close-workspace.pipe-bridge.ts__wmux_closePanenow kills the pane's terminal surfaces beforeremoveLeaf→ fixeswmux close-pane.PaneWrapper.handleCloseSurfacewas removed (the store owns teardown now);killSurfacePtyis idempotent so no double-kill matters.Non-terminal surfaces (browser/markdown/diff) are skipped, and surface move/split re-homes are untouched (they never route through
closeSurface), so keep-alive across tab/workspace switches still works exactly as before.B — real tree-kill.
PtyManager.kill(src/main/pty-manager.ts) now, on Windows, force-kills the shell's whole process subtree viataskkill /PID <pid> /T /Fbefore closing the pseudoconsole — while the parent→child snapshot is still intact. This closes the grandchild-orphan gap you identified: withuseConptyDll: true, node-pty's DLL kill path only callsClosePseudoConsole, leaving Claude Code's persistent-sbackend (powershell … -s …) orphaned.taskkillis resolved by absolute path from%SystemRoot%\System32, spawned detached + unref'd so it's non-blocking and survives even when it runs fromkillAll()on app quit.Net: closing a pane/surface/workspace by any route now reaps its wrapper shell and its
-sbackend, with no leftover processes.Regression coverage added in
tests/unit/pty-teardown.test.ts(terminal-only reaping, tree walk, safe no-op without preload). The idle-timeout / "close idle panes" affordance you flagged as a feature is intentionally left out of this fix — this is teardown-on-destructive-transition only, as you recommended.fix(#65,#66): reap PTYs on every close path + tree-kill; opt-out for …
fix(pty): reap process trees orphaned by an abnormal exit (#139) (#140)