8000 0.41.0: diff surface polls `git diff HEAD --numstat` at 10-42/s and is silently auto-recreated — main process saturated, typing lag · Issue #141 · amirlehmam/wmux · GitHub
[go: up one dir, main page]

Skip to content

0.41.0: diff surface polls git diff HEAD --numstat at 10-42/s and is silently auto-recreated — main process saturated, typing lag #141

Closed

Description

@Ray0483

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:

  1. A diff surface polls git diff HEAD --numstat with 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.

  2. 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:

  1. Closed the diff surface. wmux tree confirmed the pane contained only a terminal surface.
    Measured: git=0, main CPU 1.8%.
  2. Ran wmux markdown <file> — a command that has nothing to do with git or diffs.
  3. wmux tree now showed three surfaces: the terminal, the markdown surface I asked for, and
    a brand-new diff surface I never requested (surf-b8a1da2b…, a different ID from the one
    I had closed).
  4. Measured immediately after: git=119 in 8 s (~15/s), main CPU 57.8%.
  5. Closed the new diff surface again: git=0, main CPU 1.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

  1. Open a workspace whose cwd is a git repo large enough that git diff HEAD --numstat takes
    several hundred ms. (Mine is 14 GB / 187k files; a synthetic large working tree should do.)
  2. Ensure a diff surface is open (wmux diff).
  3. Leave the session completely idle — do not type, do not run anything.
  4. Count git.exe processes whose parent is the wmux main process. On Windows:
    Get-CimInstance Win32_Process -Filter "Name='git.exe'" |
      Select-Object ProcessId, ParentProcessId, CommandLine
    Poll in a loop and count distinct PIDs over a fixed window.
  5. Observe main-process CPU while idle, and type into a terminal pane — input is visibly delayed.
  6. Close the diff surface (verify with wmux tree, not list-surfaces — see issue 2).
    Spawns and CPU drop to near zero; typing returns to normal.
  7. Now open any unrelated surface, e.g. wmux markdown <some-file>. Run wmux tree again:
    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 diff refresh
    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.

  1. wmux tree --workspace <id> ignores the flag. Passing three different workspace IDs returned
    the byte-identical tree (same paneId, same surface IDs) for all three. There appears to be no
    working way to enumerate surfaces in a non-active workspace.

  2. wmux list-surfaces and wmux tree disagree about current state. At the same moment:

    • wmux tree reported pane pane-c36f0826… containing a terminal and a diff surface.
    • wmux list-surfaces reported pane pane-48d68481… containing only a terminal.

    $WMUX_SURFACE_ID in the running shell matched the surface reported by tree, not the one
    reported by list-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.

  3. wmux diff appears 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 named Session 1 / Workspace 3, so default names are being generated in
    different languages within one session. The new workspace also duplicated an existing cwd.
    I expected wmux diff to 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 gone

Restarting 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

  1. Ray0483 commented on Aug 2, 2026

    @Ray0483
    ContributorAuthor

    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 tree showed only a terminal surface and a markdown surface, with git=0 and main-process CPU at 1.2%. Over the next few minutes the only commands I ran were read-only (wmux tree, plus unrelated gh calls). When I checked again:

    • the markdown surface was gone
    • a new diff surface was present (surf-0ce44bfd…)
    • polling had resumed: 176 git.exe spawns in 6 s (~29/s), main process at 74.7% of one core

    I 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.

  2. Ray0483 commented on Aug 2, 2026

    @Ray0483
    ContributorAuthor

    Correction to the issue body and my previous comment — please don't chase the wmux markdown lead.

    I wrote that running wmux markdown <file> and wmux 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 markdown and wmux markdown set completed with no diff surface appearing in wmux tree and no git polling (git=0 over 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:

    1. A diff surface polls git diff HEAD --numstat unthrottled. Creating one explicitly with wmux new-surface --type diff and leaving the session idle produced 106 git.exe spawns in 6 s (~18/s). Closing it returns to git=0. This is the core bug and it reproduces on demand.

    2. Diff surfaces do reappear on their own. I left a watchdog polling wmux tree every 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.

    3. 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 diff is the reliable reproduction path for item 1, and it's a much simpler starting point than the steps I originally listed.

  3. amirlehmam commented on Aug 3, 2026

    @amirlehmam
    Owner

    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 markdown lead saved me from chasing the wrong subsystem entirely, and
    wmux new-surface --type diff as a one-line repro was exactly the right call.

    What it actually was

    Not the interval. The interval never ran.

    DiffPane polls from a useEffect whose dependency array is
    [loadFiles, loadDiff], and loadFiles is a useCallback keyed on [cwd, t].
    t comes from useT(), which built a fresh closure on every render. So the
    effect tore down and re-ran on every render — and its body calls poll()
    immediately. The setTimeout(poll, Math.max(2000, elapsed)) at the end was
    cancelled by the cleanup every single time and never once fired.

    Then it amplified itself. The cleanup sets cancelled = true, but that cannot
    un-spawn the git.exe already running. And each completed poll called
    setError(null) / setLoading(false) → render → effect restart → another
    immediate 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:

    • the 10–30 concurrent git.exe — overlapping polls, since a "cancelled"
      poll's git call keeps running to completion
    • 10/s vs 28–42/s — your hypothesis was right, it scales with live diff
      surfaces, because each one runs its own copy of this loop
    • why small repos are immune — at ~5ms per git diff the overlap window is
      too narrow for anything to stack; the loop still spins, but nothing piles up
    • why the load was entirely on main, not the renderer — the renderer just
      re-renders a small component; main pays for every process spawn, and it is
      also 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 per
      language). This was mis-invalidating dependency arrays at 42 call sites, not
      just this one — you found a bug much larger than the diff pane.
    • The poll can no longer be restarted by a render. It reads its loaders
      through refs and depends only on [cwd, surfaceId], and every setState is
      guarded on an actual change.
    • The git path coalesces per directory. The snapshot (non-git) path already
      did; the git path did not. A caller asking faster than git answers now joins
      the pass in flight.
    • The rev-parse --is-inside-work-tree probe is cached (30s TTL). It ran on
      every call to both entry points — roughly a third of the processes you were
      counting.
    • A diff surface in a hidden tab or background window no longer polls at
      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: maybeAutoOpenDiffTab
    re-opened the tab on every Edit/Write hook event from Claude Code. That is
    your "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 auto so it can never retract that; only opening
    one 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:

    1. wmux tree --workspace <id> ignored the flag. It was parsed on the
      command line and then dropped — tree sent no params at all, so you always
      got the active workspace whate 10BC8 ver id you passed. list-surfaces ignored
      --workspace the same way, and --pane was accepted but never applied
      renderer-side.
    2. tree and list-surfaces disagreeing. Both resolved their target as
      BrowserWindow.getAllWindows()[0] — an order Electron makes no promise
      about — 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 the browser.* commands already did) and answer about
      the window that actually owns the calling shell.
    3. wmux diff creating a workspace, and the 工作区 3 / Workspace 3 locale
      mix
      — 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:

    Get-CimInstance Win32_Process -Filter "Name='git.exe'" |
      Where-Object ParentProcessId -eq <wmux main PID>

    Idle with a diff surface open should now be a single git status +
    git diff HEAD --numstat pair roughly every 2s at worst — and zero while the
    diff 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.

  4. amirlehmam commented on Aug 3, 2026

    @amirlehmam
    Owner

    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.exe count on your 14 GB tree looks the way it should now — you have the only reproduction environment of the right size.

  5. Ray0483 commented on Aug 4, 2026

    @Ray0483
    ContributorAuthor

    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 (poll Win32_Process for git.exe with ParentProcessId == wmux main, count distinct PIDs; CPU as Process.CPU delta over the window, as % of one core). Idle throughout.

    Scenario git spawns rate wmux main CPU
    baseline, no diff surface, 15 s 0 0/s 0.5%
    diff surface open in foreground, 30 s idle 20 0.7/s 5.3%
    (0.41.0, same scenario) 284–425 / 10 s 28–42/s 79–106%

    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 one git alive at a time across the whole window.

    The other two parts are fixed as well:

    • Auto-recreation: closing the diff surface and then running wmux markdown set <id> --file <path> (which reliably resurrected it in 0.41.0) leaves it closed — 0 diff surfaces over 10 s.
    • wmux diff creating an extra workspace: workspace count unchanged before/after.
    • tree vs list-surfaces disagreement: 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 git outstanding. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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

      0