Repository navigation
Description
Diff surface polls git diff HEAD --numstat unthrottled and is silently auto-recreated, saturating the main process and causing keystroke latency
wmux version: 0.41.0
OS: Windows 11 Pro 10.0.26200 (20 cores, 63.7 GB RAM)
Git: 2.41.0.windows.3
Shell in pane: powershell.exe
Summary
Two behaviours combine into a persistent, hard-to-diagnose typing lag:
-
A
diffsurface pollsgit diff HEAD --numstatwith no throttling — measured at
10–42 invocations per second while the session is completely idle. There is no debounce and
no back-pressure from how long the previous invocation took. -
The diff surface is silently auto-recreated whenever a new surface is opened in the workspace
(and on session restore). Closing it is not durable, which is why the problem appears to "come
back on its own" and why restarting the app does not help.
In my repo git diff HEAD --numstat takes ~0.8–1.0 s, so invocations overlap heavily — roughly
10–30 git.exe processes alive at any moment. The wmux main process burns 79–106% of a CPU
core while completely idle (no typing, no terminal output, no agent activity).
Because Electron's main process is a single-threaded Node event loop, and keystrokes travel
renderer → IPC → main → PTY, this manifests directly as typing lag in every terminal pane.
Closing the diff surface drops git spawns to zero and main-process CPU to ~1.7% immediately.
Reproduced four times.
Note: this is not a resurrection of the GitPoller from #100 — that class was deleted in
ca837b7 / v0.27.0, and the command being spawned here (git diff HEAD --numstat) is tied to the
lifetime of a diff surface, not to a cwd watcher. It is also unrelated to #139, which concerns
orphaned MCP node.exe children; every process in my case is git.exe parented directly to the
wmux main process.
The auto-recreation (this is the part that makes it persistent)
Observed sequence, all verified with wmux tree:
- Closed the diff surface.
wmux treeconfirmed the pane contained only a terminal surface.
Measured:git=0, main CPU1.8%. - Ran
wmux markdown <file>— a command that has nothing to do with git or diffs. wmux treenow showed three surfaces: the terminal, the markdown surface I asked for, and
a brand-newdiffsurface I never requested (surf-b8a1da2b…, a different ID from the one
I had closed).- Measured immediately after:
git=119 in 8 s (~15/s), main CPU57.8%. - Closed the new diff surface again:
git=0, main CPU1.7%, markdown surface unaffected.
The same thing happens on session restore, which is why restarting wmux does not fix the lag —
the diff surface comes back with the restored session and polling resumes.
It is not limited to creating surfaces. Running wmux markdown set <existing-surface-id> --file <path>,
which only replaces the content of a surface that already exists, also created a fresh diff surface
(surf-f14fbbab…). Read-only commands such as wmux tree do not trigger it. So the trigger appears
to be any operation that mutates surface state, which makes the diff surface very difficult to keep
closed in practice — I had to close it three separate times while writing this report.
Measurements
Sampling method: poll Win32_Process for git.exe, filter on ParentProcessId == <wmux main PID>,
count distinct PIDs. CPU measured as delta of Process.CPU over the window, expressed as % of one core.
All measurements taken with the session idle — no typing, no commands running in the pane.
Diff surface open
| Window (10 s, idle) | distinct git.exe spawned by wmux main |
wmux main CPU |
|---|---|---|
| 1 | 284 (~28/s) | 79.2% |
| 2 | 326 (~33/s) | 82.2% |
| 3 | 425 (~42/s) | 91.4% |
| 4 | 314 (~31/s) | 94.1% |
An earlier run, on the diff surface restored from the saved session, showed a lower but still
pathological rate: 159 spawns in ~15 s (~10/s), main process avg 32.1% / max 106% of one core
over an 80-sample × 500 ms idle run.
I could not confirm what accounts for ~10/s versus ~28–42/s. The most likely explanation is that
more than one diff surface was alive during the faster runs — by then three workspaces existed, two
sharing the same cwd, and the CLI made it impossible to enumerate surfaces across all of them
(see issue 2). If the poll is per-surface, the rate would scale with the number of live diff
surfaces. Flagging this as a hypothesis, not a finding.
Child process breakdown
Every spawned process is the same command:
PPID <wmux main> -> 245 git procs in 10 s
git diff HEAD --numstat
git diff HEAD --numstat
...
Per-process CPU within the wmux process tree during an idle window:
| Process | avg CPU (% of one core) |
|---|---|
| main | 32.1 (max 106) |
| renderer | 4.8 |
| gpu-process | 3.8 |
| utility (network) | 0.0 |
The load is entirely on the main process, not the renderer.
Diff surface closed
12 s idle: git=0 main cpu=1.8%
10 s idle: git=0 main cpu=1.7%
CPU drops by roughly an order of magnitude and the user-visible typing lag disappears.
Repository characteristics
This matters because the bug is invisible on small repos — git diff HEAD --numstat returns in a
few ms there, so even a 40 Hz poll goes unnoticed.
| Metric | Value |
|---|---|
| Working tree size | 14 GB |
Files on disk (excl. .git) |
187,645 |
| Tracked files | 2,543 |
.git size |
491 MB |
git diff HEAD --numstat wall time |
1.03 s |
| …after adding a Defender exclusion for the repo path | 0.79 s |
Git is already tuned — core.fsmonitor=true and core.untrackedCache=true are set, and the
fsmonitor daemon is confirmed running. The remaining ~0.8 s is the cost of the working tree itself.
Notably, reducing untracked file count does not help. Before filing this I added the bulk data
directories to .gitignore, taking untracked files from 115,665 down to 435. The lag was completely
unaffected, because git diff HEAD only compares tracked files against HEAD and never walks
untracked paths. The bottleneck is the poll rate, not the file count.
Steps to reproduce
- Open a workspace whose
cwdis a git repo large enough thatgit diff HEAD --numstattakes
several hundred ms. (Mine is 14 GB / 187k files; a synthetic large working tree should do.) - Ensure a diff surface is open (
wmux diff). - Leave the session completely idle — do not type, do not run anything.
- Count
git.exeprocesses whose parent is the wmux main process. On Windows:Poll in a loop and count distinct PIDs over a fixed window.Get-CimInstance Win32_Process -Filter "Name='git.exe'" | Select-Object ProcessId, ParentProcessId, CommandLine
- Observe main-process CPU while idle, and type into a terminal pane — input is visibly delayed.
- Close the diff surface (verify with
wmux tree, notlist-surfaces— see issue 2).
Spawns and CPU drop to near zero; typing returns to normal. - Now open any unrelated surface, e.g.
wmux markdown <some-file>. Runwmux treeagain:
a new diff surface has been created without being requested, and the polling has resumed.
Suggested fix
For the polling itself — any one of these would resolve the pathological case:
- Do not re-invoke while a previous invocation is still in flight. This alone caps concurrency
at 1 and would have prevented this entirely. - Debounce and drive refreshes from file-change events rather than a timer. A
git diffrefresh
only needs to run when the working tree actually changed, and there is already a filesystem
watcher in play for other features. - Adaptive interval: measure how long the last invocation took and set the next poll to some
multiple of it. A repo where the command costs 1 s should never be polled at 10–40 Hz. - Stop polling when the diff surface is not visible (background workspace, hidden tab).
For the auto-recreation:
- A diff surface the user explicitly closed should stay closed for that session, and opening an
unrelated surface should not resurrect it.
Given that the main process is also the keystroke relay, it may be worth moving git invocation and
output parsing off the main thread regardless of poll rate.
Issue 2 (secondary): CLI state commands disagree, making cleanup silently fail
Found while diagnosing the above. Possibly separate bugs, but they made this one much harder to pin
down and they broke a scripted cleanup.
-
wmux tree --workspace <id>ignores the flag. Passing three different workspace IDs returned
the byte-identical tree (samepaneId, same surface IDs) for all three. There appears to be no
working way to enumerate surfaces in a non-active workspace. -
wmux list-surfacesandwmux treedisagree about current state. At the same moment:wmux treereported panepane-c36f0826…containing a terminal and a diff surface.wmux list-surfacesreported panepane-48d68481…containing only a terminal.
$WMUX_SURFACE_IDin the running shell matched the surface reported bytree, not the one
reported bylist-surfaces.Consequence: a script doing "list surfaces → find
type == diff→ close it" found nothing to
close and exited reporting success, while the diff surface was still open and still polling.
This sent me chasing a phantom "polling restarts by itself" behaviour that was really a close
that never happened. -
wmux diffappears to create a new workspace. Before the call there were 2 workspaces;
after, 3 — the new one auto-named工作区 3. Note the locale inconsistency: the existing
workspaces were namedSession 1/W 10BC8 orkspace 3, so default names are being generated in
different languages within one session. The new workspace also duplicated an existingcwd.
I expectedwmux diffto add a surface to the current workspace, not create another one.
Workaround for other users
In a large repo, close the diff surface and verify with wmux tree (not list-surfaces) that it
actually went away:
wmux tree # find the surface with "type": "diff"
wmux close-surface <that-id>
wmux tree # confirm it is goneRestarting wmux does not help. And because the surface is auto-recreated whenever a new surface is
opened, this has to be repeated — check wmux tree again any time typing starts to feel sluggish.
Activity
Ray0483 commentedon Aug 2, 2026
Follow-up data point: the auto-recreation also happens without any CLI command being issued.
After filing this issue the pane was in a clean state —
wmux treeshowed only a terminal surface and a markdown surface, withgit=0and main-process CPU at 1.2%. Over the next few minutes the only commands I ran were read-only (wmux tree, plus unrelatedghcalls). When I checked again:surf-0ce44bfd…)git.exespawns in 6 s (~29/s), main process at 74.7% of one coreI can't say with certainty what removed the markdown surface — most likely it was closed from the UI rather than the CLI, since I issued no mutating command. Either way, the relevant part is that no scripted interaction was involved. This is not limited to people driving wmux from scripts: a normal GUI user closing a tab appears to be enough to resurrect the diff surface and restart the unthrottled polling.
Closing the new diff surface again returned it to
git=0, CPU 1.2%.That also makes the workaround in the issue body fairly impractical in daily use — over the course of investigating and filing this report I had to close the diff surface five separate times.
Ray0483 commentedon Aug 2, 2026
Correction to the issue body and my previous comment — please don't chase the
wmux markdownlead.I wrote that running
wmux markdown <file>andwmux markdown set <id> --file <path>created a fresh diff surface, and I treated "any operation that mutates surface state" as the trigger. I went back to verify that while building a workaround, and it does not hold up. Across three attempts I could not reproduce it:wmux markdownandwmux markdown setcompleted with no diff surface appearing inwmux treeand no git polling (git=0over the following 6 s). The two occasions I originally observed were almost certainly coincidental timing rather than causation. Sorry for the noise.What I can still state with confidence, all re-verified:
A diff surface polls
git diff HEAD --numstatunthrottled. Creating one explicitly withwmux new-surface --type diffand leaving the session idle produced 106git.exespawns in 6 s (~18/s). Closing it returns togit=0. This is the core bug and it reproduces on demand.Diff surfaces do reappear on their own. I left a watchdog polling
wmux treeevery 5 s, and it closed a diff surface (surf-a9e80a28…) that I had never requested — no CLI command of mine created it. So the recreation is real, it is just not triggered by what I originally claimed.I have not identified the actual recreation trigger. Things I tested and ruled out: writing a new untracked file into the repo, and simply letting the session sit idle for 40+ s (no recreation in either case). Session restore does appear to bring it back, which is why restarting the app doesn't help, but I can't currently give you a deterministic recipe for the spontaneous case.
Item 1 stands on its own as a fix target regardless — capping concurrency to one in-flight invocation, or debouncing off file-change events, would remove the user-visible symptom even while the recreation behaviour is still being tracked down.
wmux new-surface --type diffis the reliable reproduction path for item 1, and it's a much simpler starting point than the steps I originally listed.fix(diff): stop the diff pane restarting its own poll every render (#141
amirlehmam commentedon Aug 3, 2026
Fixed in #142, shipping in 0.42.0.
Thank you for this report — the process-level measurements and especially the
two follow-up corrections are what made it findable. Retracting the
wmux markdownlead saved me from chasing the wrong subsystem entirely, andwmux new-surface --type diffas a one-line repro was exactly the right call.What it actually was
Not the interval. The interval never ran.
DiffPanepolls from auseEffectwhose dependency array is[loadFiles, loadDiff], andloadFilesis auseCallbackkeyed on[cwd, t].tcomes fromuseT(), which built a fresh closure on every render. So theeffect tore down and re-ran on every render — and its body calls
poll()immediately. The
setTimeout(poll, Math.max(2000, elapsed))at the end wascancelled by the cleanup every single time and never once fired.
Then it amplified itself. The cleanup sets
cancelled = true, but that cannotun-spawn the
git.exealready running. And each completed poll calledsetError(null)/setLoading(false)→ render → effect restart → anotherimmediate poll. Two renders per completion, each starting a poll, growing until
the cost of spawning git throttled it.
That accounts for every number you posted:
git.exe— overlapping polls, since a "cancelled"poll's git call keeps running to completion
surfaces, because each one runs its own copy of this loop
git diffthe overlap window istoo narrow for anything to stack; the loop still spins, but nothing piles up
main, not the renderer — the renderer justre-renders a small component;
mainpays for every process spawn, and it isalso the PTY relay, which is the typing lag
Your suggested fix — "do not re-invoke while a previous invocation is still in
flight" — was correct, and I applied it. But it would have capped the damage
without removing the cause; the loop would still have been spinning at render
speed. Both are fixed:
useT()is now referentially stable (one memoised translator perlanguage). This was mis-invalidating dependency arrays at 42 call sites, not
just this one — you found a bug much larger than the diff pane.
through refs and depends only on
[cwd, surfaceId], and everysetStateisguarded on an actual change.
did; the git path did not. A caller asking faster than git answers now joins
the pass in flight.
rev-parse --is-inside-work-treeprobe is cached (30s TTL). It ran onevery call to both entry points — roughly a third of the processes you were
counting.
all, your suggestion 4. Panes keep every tab mounted by design, so the
surface had to opt out itself.
The auto-recreation
Confirmed, and you were right that closing it isn't durable:
maybeAutoOpenDiffTabre-opened the tab on every
Edit/Writehook event from Claude Code. That isyour "no CLI command was issued" case — an agent editing a file was enough. It
also explains the delay you saw between closing it and it coming back.
A closed diff tab is now recorded per workspace and stays closed for the session.
The hook-driven open is marked
autoso it can never retract that; only openingone yourself does. So: your suggestion, implemented as stated — "a diff surface
the user explicitly closed should stay closed for that session."
Issue 2 — the CLI disagreements
All three of these were real, and #2 in particular is a nasty one to be bitten by
while debugging something else. Fixed:
wmux tree --workspace <id>ignored the flag. It was parsed on thecommand line and then dropped —
treesent no params at all, so you alwaysgot the active workspace whatever id you passed.
list-surfacesignored--workspacethe same way, and--panewas accepted but never appliedrenderer-side.
treeandlist-surfacesdisagreeing. Both resolved their target asBrowserWindow.getAllWindows()[0]— an order Electron makes no promiseabout — so with more than one window open, two consecutive calls could
describe two different windows. That is why your cleanup script found nothing
to close and exited reporting success. V2 commands now carry the caller's
WMUX_SURFACE_ID(as thebrowser.*commands already did) and answer aboutthe window that actually owns the calling shell.
wmux diffcreating a workspace, and the工作区 3/Workspace 3localemix — I could not reproduce either, and I don't yet have a mechanism I can
point at, so I have deliberately not claimed a fix. The locale
inconsistency is a genuine bug regardless of what created that workspace.
Both are tracked separately rather than quietly folded into this fix.
Verifying
0.42.0 is building now. On your repo, the check is the same one you already ran:
Idle with a diff surface open should now be a single
git status+git diff HEAD --numstatpair roughly every 2s at worst — and zero while thediff tab is in the background. If it isn't, please reopen; the measurement
method you used is exactly what I'd want to see again.
amirlehmam commentedon Aug 3, 2026
0.42.0 is published: https://github.com/amirlehmam/wmux/releases/tag/v0.42.0
Portable zip or the setup.exe, both there. If you take the zip, remember the Mark-of-the-Web unblock before extracting.
Interested in whether the idle
git.execount on your 14 GB tree looks the way it should now — you have the only reproduction environment of the right size.Ray0483 commentedon Aug 4, 2026
Verified fixed in 0.42.0. Same repo as the original report (14 GB working tree, 2,543 tracked files,
git diff HEAD --numstat= 0.78 s), same sampling method (pollWin32_Processforgit.exewithParentProcessId == wmux main, count distinct PIDs; CPU asProcess.CPUdelta over the window, as % of one core). Idle throughout.That is roughly a 40–60× drop in spawn rate and 15–20× in main-process CPU, and the typing lag is gone. The
coalesce()in-flight map is doing its job — never more than onegitalive at a time across the whole window.The other two parts are fixed as well:
wmux markdown set <id> --file <path>(which reliably resurrected it in 0.41.0) leaves it closed — 0 diff surfaces over 10 s.wmux diffcreating an extra workspace: workspace count unchanged before/after.treevslist-surfacesdisagreement: they now report the same panes and surfaces.One observation, not a complaint: at 0.7 spawns/s the command still runs about every 1.4 s while it takes 0.78 s, so the main process spends roughly half its time with a
gitoutstanding. Measured CPU cost is small because the work is in git's own process, but driving refreshes from file-change events rather than a fixed interval would make the idle case free.I also filed #143 for two CLI-level problems found during this verification.
merge(upstream): bring amirlehmam/wmux master (0.46.0) into oh-my-pi
feat(agent-browser): teardown that cannot leak, bindable stream ports…
feat(explorer): change counts in the tree, and a code surface you can…
fix(explorer): the +N/-N column was blank because git status was trimmed
perf: cut typing latency under many concurrently streaming panes (#217)
feat(tabs): label a terminal tab with its program's window title (#221)
fix(prompts): a highlight must not outlive the row it points at (#230)
fix(detection): never stamp a verdict before its own idle period
feat(agents): a "question" state for a Claude pane that ended its tur…
fix(keep-awake): main owns and persists the mode, stale working claim…
fix(worktree): branch from the caller's repository and validate refs