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 -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
useTerminal → window.wmux.pty.create({surfaceId}) — src/renderer/hooks/useTerminal.ts:699 (and :712)
- Main
PTY_CREATE handler → ptyManager.create() — src/main/ipc-handlers.ts:39 → src/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
Kill — ptyManager.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.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-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)
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
- 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 -s backend shell — held until wmux
is restarted.
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:
other way to close a surface/pane/workspace — Ctrl+W, the CLI
(
close-surface/close-pane/close-workspace), and closing aworkspace 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.
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:
powershell.exe … -NoExit -Command ". $env:WMUX_PS1_SCRIPT"),all children of the single live wmux process.
claudeprocess, yet the wmux wrapper shell and a leftover
-sbackend shell wereboth still running.
accumulated ~500 s of CPU time.
(which also keeps antivirus busy scanning the churn).
exactly why Windows never cleaned them up.
Root cause (code trace)
Lifecycle
Create (one wrapper shell per terminal surface):
useTerminal→window.wmux.pty.create({surfaceId})—src/renderer/hooks/useTerminal.ts:699(and:712)PTY_CREATEhandler →ptyManager.create()—src/main/ipc-handlers.ts:39→src/main/pty-manager.ts:202src/main/pty-manager.ts:288, withuseConpty: true, useConptyDll: true(:279,:284)ptyManager.ptysmap —src/main/pty-manager.ts:327Kill —
ptyManager.kill()atsrc/main/pty-manager.ts:390is 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:808Defect 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
PaneWrapperUI handlers instead of the state transition, so every other callerleaks:
closeSurfaceOrPane)src/renderer/hooks/useKeyboardShortcuts.ts:198→store.closeSurfacewmux close-surfacesrc/renderer/pipe-bridge.ts:239→store.closeSurfacewmux close-panesrc/renderer/pipe-bridge.ts:118→removeLeafonlysrc/renderer/store/workspace-slice.ts:61closeWorkspaceAn orphaned PTY stays in
ptyManager.ptysand its wrapper shell (plus anything itspawned) 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). Innode-pty 1.1.0,
WindowsPtyAgent.kill()has a dedicated DLL branch(
node_modules/node-pty/lib/windowsPtyAgent.js:153-160) that only callsClosePseudoConsole— it does not enumerate and force-kill the consoleprocess list. (The non-DLL ConPTY branch at
:137-152and the winpty branch at:162-179do sweep the list withprocess.kill(pid); the DLL path skips it.)ptyManager.kill(src/main/pty-manager.ts:395) just callsentry.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
-sbackend (spawned by the
claudenode 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 isvisible"), 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 — onlyexplicit close").
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, souseTerminalcallspty.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
Ctrl+W, CLI, closing the workspace) — terminates its shell and any
backend/stdin helper shells it spawned, with no leftover processes.
Actual
grandchildren (
-sbackend) orphaned. Every other close route leaks the wholewrapper 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 terminalsurface's PTY. One change fixes both Ctrl+W and
wmux close-surface(both funnel through it).
src/renderer/store/workspace-slice.tscloseWorkspace→ walk the workspace'ssplitTreeand 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 terminalsurfaces before
removeLeaf(mirrorPaneWrapper.handleClosePane). Fixeswmux close-pane.PaneWrapperkills then become redundant (harmless — kill isidempotent) and can be dropped in favour of the store owning teardown.
B. Make the kill a real tree-kill (fixes the orphaned
-son every path):src/main/pty-manager.tskill()→ afterentry.pty.kill(), on Windows alsotaskkill /PID <entry.pty.pid> /T /F(the pid is already exposed viagetPidatsrc/main/pty-manager.ts:431). This closes the grandchild-orphangap regardless of node-pty's DLL branch, and also benefits
killAllon quit.A + B together = the wrapper is always reaped and its Claude Code
-sbackenddies with it.
Optional follow-ups (not required to fix the leak)
ptyManager.ptysentry whose surfaceId no longer appears in any workspace tree —catches any future close path that forgets to reap.
the already-tracked
shellState/ Claude activity,src/shared/types.ts:73),distinct from this leak fix.
Steps to reproduce
claude) in them over a session.wmux close-pane/wmux close-surface/wmux close-workspace.Get-Process powershell/ Task Manager) vs. panes actuallyopen. 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 wmuxis restarted.