8000
Opening a diff pane in a workspace whose cwd is not a git repo pins the main process at ~100% of a core indefinitely and makes the whole app unresponsive. The window stops repainting, Windows marks it "Not Responding", and the named pipe stops answering, so every wmux <cmd> call fails with Error: timeout — including identify and config show. Only V1 ping keeps working, which makes it look like a pipe/auth bug rather than a stalled event loop.
A home directory is never a git repo, and ~ is the default cwd for a new workspace, so this is easy to hit by accident. My saved session had a diff surface as the active tab in both workspaces; every launch froze within seconds.
Reproduced on v0.39.1 (Windows 11, i5-12450HX, 16 GB). Code below is current master (fbbd375), unchanged.
cwd set to a non-git directory with a reasonably large tree (a home directory is ideal).wmux identify starts returning Error: timeout.DiffPane polls every 2 seconds:
wmux/src/renderer/components/Diff/DiffPane.tsx
Lines 124 to 143 in fbbd375
| // Poll git status every 2 seconds (~50ms per git status call) | |
| // This replaces the old mount-only load — ensures diffs always stay fresh | |
| useEffect(() => { | |
| lastFilesKeyRef.current = ''; | |
| lastDiffRawRef.current = ''; | |
| setLoading(true); | |
| const poll = async () => { | |
| const loaded = await loadFiles(); | |
| if (loaded.length > 0 && !selectedFileRef.current) { | |
| setSelectedFile(loaded[0].path); | |
| } | |
| if (selectedFileRef.current) { | |
| loadDiff(selectedFileRef.current); | |
| } | |
| }; | |
| poll(); | |
| const id = setInterval(poll, 2000); | |
| return () => clearInterval(id); |
// Poll git status every 2 seconds (~50ms per git status call)
...
const id = setInterval(poll, 2000);The ~50ms budget in that comment holds for the git path. It does not hold for the non-git fallback. When isGitRepo(cwd) is false, getChangedFiles falls through to getSnapshotChangedFiles, which on every poll:
readCurrentFile — fs.statSync + fs.readFileSync, full contents; andwalkDir over the whole tree to detect new files.wmux/src/main/diff-provider.ts
Lines 183 to 228 in fbbd375
| async function getSnapshotChangedFiles(cwd: string): Promise<ChangedFile[ 8000 ]> { | |
| if (!snapshots.has(cwd)) { | |
| snapshots.set(cwd, takeSnapshot(cwd)); | |
| return []; // First call: snapshot taken, no changes yet | |
| } | |
| const snap = snapshots.get(cwd)!; | |
| const changed: ChangedFile[] = []; | |
| // Check existing files for modifications | |
| for (const [rel, oldContent] of snap) { | |
| const current = readCurrentFile(cwd, rel); | |
| if (current === null) { | |
| // File was deleted | |
| const lines = oldContent.split('\n').length; | |
| changed.push({ path: rel, status: 'deleted', additions: 0, deletions: lines }); | |
| } else if (current !== oldContent) { | |
| // File was modified | |
| const oldLines = oldContent.split('\n'); | |
| const newLines = current.split('\n'); | |
| let additions = 0, deletions = 0; | |
| const max = Math.max(oldLines.length, newLines.length); | |
| for (let i = 0; i < max; i++) { | |
| const o = i < oldLines.length ? oldLines[i] : undefined; | |
| const n = i < newLines.length ? newLines[i] : undefined; | |
| if (o !== n) { | |
| if (o !== undefined) deletions++; | |
| if (n !== undefined) additions++; | |
| } | |
| } | |
| changed.push({ path: rel, status: 'modified', additions, deletions }); | |
| } | |
| } | |
| // Check for new files | |
| const currentFiles: string[] = []; | |
| walkDir(cwd, cwd, currentFiles); | |
| for (const rel of currentFiles) { | |
| if (!snap.has(rel)) { | |
| const content = readCurrentFile(cwd, rel); | |
| if (content !== null) { | |
| changed.push({ path: rel, status: 'added', additions: content.split('\n').length, deletions: 0 }); | |
| } | |
| } | |
| } | |
| return changed; |
Three things compound:
1. It is synchronous, on the main process. walkDir/readCurrentFile use readdirSync/statSync/readFileSync inside an ipcMain.handle. getChangedFiles being async doesn't help — the sync calls block the event loop, which is why IPC, painting, and the pipe server all stall together.
2. Each pass costs longer than the poll interval, so the polls overlap and never drain. Measured by replicating walkDir + the snapshot re-read against my home directory:
| Directories scanned | 3,578 |
| Files collected | 2,000 (MAX_SNAPSHOT_FILES cap) |
| Read from disk | 26.6 MB |
| Walk | 1,551 ms |
| Re-read all files | 1,465 ms |
| Total per poll | ~3,016 ms |
| Poll interval | 2,000 ms |
3. SNAPSHOT_IGNORE doesn't exclude platform junk. It covers node_modules, .git, dist, build, .next, __pycache__, .venv, venv, plus dotfile dirs. On Windows that misses AppData, so a home-directory snapshot happily descends into AppData\Local, AppData\Roaming, npm caches and installed apps. The 2,000-file cap bounds the read, but the walk still enumerates thousands of directories before hitting it — and which 2,000 files you end up with is essentially arbitrary.
Attached the debugger to the main process (wmux.exe --inspect=9230) and took a CPU profile via Profiler.start/stop. 9,535 samples over 10 s, self-time:
33.1% readdir < readdirSync < walkDir
25.8% stat < statSync < readCurrentFile
19.8% open < openSync < readFileSync
7.2% readdir < readdirSync < walkDir
2.9% readdir < readdirSync < walkDir
2.7% read < readSync < readFileSync
~92% of main-process CPU is filesystem syscalls under walkDir / readCurrentFile. Nothing else is close.
Removing the two "type": "diff" surfaces from session.json and restarting, with nothing else changed:
| Before | After | |
|---|---|---|
| Main-process CPU, idle | ~100% of a core, sustained | 0.42% avg over 60 s (0% in the last three 10 s samples) |
Responding |
False | True |
system.identify round-trip |
timeout (>5000 ms) | 0.7 ms median |
pane.list (round-trips the UI thread) |
timeout | 1.3 ms median |
| Main-process RSS | 188 MB, climbing | 111 MB, flat |
Roughly in order of value:
setTimeout chain instead of setInterval), and back off when a pass exceeds the interval. This alone converts a hard freeze into merely-slow, and is worth doing regardless of the rest.fs.promises) so a slow scan degrades into slow diffs rather than a frozen window and a dead pipe.mtimeMs/size against the snapshot and only readFile when they differ. In the steady state nothing has changed, so this removes nearly all 26 MB of re-reads.AppData, Library, AppData\Local\Temp, $RECYCLE.BIN, OneDrive) and consider a directory-count budget alongside the file cap.Happy to open a PR for (1)–(3) if that's useful.
Filed after debugging this on my own machine; all numbers above are measured, not estimated.
Activity
fix(diff): keep the app responsive when a diff pane watches a non-git…
amirlehmam commentedon Jul 31, 2026
Your PR #135 is merged (ea7c3bb) and ships in v0.40.0 — the freeze is gone. Full review is on the PR.
Short version: the three mechanical fixes you proposed and implemented are in. No overlapping polls (self-scheduling
setTimeoutchain, backing off tomax(2000, elapsed)), the snapshot path fully onfs.promises, and stat-before-read so the steady state does no reads at all. Plus in-flight coalescing so two diff panes on the same cwd cannot double the work.Keeping this open for the three you deliberately left out, because they are the ones that stop the snapshot fallback from being a bad idea on a home directory rather than merely a survivable one:
Widen
SNAPSHOT_IGNOREwith platform directories —AppData,Library,$RECYCLE.BIN,OneDrive, and a directory-count budget alongside the file cap. Your point that the 2,000-file cap bounds the read but not the walk is the important half: 3,578 directories get enumerated before the cap bites, and which 2,000 files you end up with is arbitrary. So even fully async and fully stat-gated, a home-directory diff pane is showing you an essentially random sample of your own machine.Do not re-walk every poll just to find new files, or run it on a much slower cadence than the content check.
Consider not offering the fallback at all for home-like trees. I think you are right that an empty pane saying "not a git repo" is friendlier than a slow one, and it is the option I am most inclined toward — a diff of a directory that is not under version control has no baseline anyone asked for. The counter-argument is that the snapshot fallback does have a real use (watching an agent edit files in a scratch directory), so the answer is probably a size/shape heuristic rather than removing it.
These are design calls rather than bug fixes, which is why splitting them out of #135 was right. If you want to take any of them, say which and I will leave it to you.
Thank you again for the profile and the before/after table. "Only V1
pingkeeps working, which makes it look like a pipe/auth bug" saved me a day in the wrong file.capad-xyz commentedon Jul 31, 2026
Thanks — glad the profile saved you the day in
pipe-server.ts. That misdirection is the part I'd flag for anyone else hitting this: a stalled event loop and a broken pipe look identical from the CLI, because V1pinganswers from the socket handler before anything touches the blocked path.I'll take (1) and (2). Opened as #138.
(1) Ignore list + directory budget.
SNAPSHOT_IGNOREgainsAppData,Library,Application Data,$RECYCLE.BIN,System Volume Information,OneDrive,Recovery, plus aMAX_SNAPSHOT_DIRSbudget threaded through the walk so the traversal is bounded independently of the file cap. As you say, that's the half that actually matters — the file cap bounds the read, but 3,578 directories get enumerated before it bites, and the resulting 2,000 files are an accident of traversal order.(2) Slower discovery cadence. The re-walk now runs every 5th poll instead of every poll; the stat-based content check still runs every poll. One thing worth flagging, because it's a user-visible behaviour change rather than a pure optimisation: a newly added file now takes up to ~10s to appear instead of ~2s. Modifications and deletions are unaffected — those come from the snapshot map and are still detected on the very next poll. If you'd rather have a different number than 5, it's a one-line constant.
That change also has a trap I hit while writing it: if discovery only runs every Nth poll, newly added files flicker out of the list on the polls in between, because they were never in the snapshot map. So discovered additions are now remembered per-cwd and re-reported each poll (and dropped when the file disappears). There's a test pinning exactly that, since it's the kind of thing that would come back as "the diff list flashes".
(3) I'm leaving to you. It's a question about what wmux should be when there's no git repo, and you've already got the clearer read on it — the scratch-directory-with-an-agent case is real, so a size/shape heuristic does sound more right than removing the fallback. Happy to implement whatever you land on if you'd rather not write it yourself.
I also fixed the
resolveWithinprefix check you noted, and the identicalstartsWithtest ingetFileDiff's untracked-file branch three lines away — both now go through oneisWithinhelper usingpath.relative. Neither was reachable, as you said; it just seemed odd to leave the twin sitting next to the one being fixed.One small thing: this issue is actually closed — the
Fixes #133line in my PR body auto-closed it on merge, so the tracker you meant to keep open isn't. Worth reopening if you still want it tracking (3).fix(diff): bound the snapshot walk and slow the new-file discovery ca…
merge(upstream): bring amirlehmam/wmux master (0.46.0) into oh-my-pi