Summary
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.
Repro
- Open a workspace with
cwd set to a non-git directory with a reasonably large tree (a home directory is ideal).
- Open a diff pane in it.
- Within a few seconds the UI stops responding;
wmux identify starts returning Error: timeout.
Root cause
DiffPane polls every 2 seconds:
|
// 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:
- re-reads every file in the snapshot via
readCurrentFile — fs.statSync + fs.readFileSync, full contents; and
- runs a fresh
walkDir over the whole tree to detect new files.
|
async function getSnapshotChangedFiles(cwd: string): Promise<ChangedFile[]> { |
|
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.
Profile
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.
Confirmation
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 |
Suggested fixes
Roughly in order of value:
- Never let a poll overlap itself. Schedule the next run only after the previous one resolves (
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.
- Make the snapshot path async (
fs.promises) so a slow scan degrades into slow diffs rather than a frozen window and a dead pipe.
- Stat before reading. Compare
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.
- Don't re-walk every poll just to find new files, or do it on a much slower cadence than the content check.
- Widen the ignore list with platform dirs (
AppData, Library, AppData\Local\Temp, $RECYCLE.BIN, OneDrive) and consider a directory-count budget alongside the file cap.
- Consider not offering the snapshot fallback at all for very large / home-like trees — an empty diff pane with "not a git repo" is friendlier than a frozen app.
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.
Summary
Opening a diff pane in a workspace whose
cwdis 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 everywmux <cmd>call fails withError: timeout— includingidentifyandconfig show. Only V1pingkeeps 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 defaultcwdfor 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.Repro
cwdset to a non-git directory with a reasonably large tree (a home directory is ideal).wmux identifystarts returningError: timeout.Root cause
DiffPanepolls every 2 seconds:wmux/src/renderer/components/Diff/DiffPane.tsx
Lines 124 to 143 in fbbd375
The
~50msbudget in that comment holds for the git path. It does not hold for the non-git fallback. WhenisGitRepo(cwd)is false,getChangedFilesfalls through togetSnapshotChangedFiles, which on every poll:readCurrentFile—fs.statSync+fs.readFileSync, full contents; andwalkDirover the whole tree to detect new files.wmux/src/main/diff-provider.ts
Lines 183 to 228 in fbbd375
Three things compound:
1. It is synchronous, on the main process.
walkDir/readCurrentFileusereaddirSync/statSync/readFileSyncinside anipcMain.handle.getChangedFilesbeingasyncdoesn'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:MAX_SNAPSHOT_FILEScap)3.
SNAPSHOT_IGNOREdoesn't exclude platform junk. It coversnode_modules,.git,dist,build,.next,__pycache__,.venv,venv, plus dotfile dirs. On Windows that missesAppData, so a home-directory snapshot happily descends intoAppData\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.Profile
Attached the debugger to the main process (
wmux.exe --inspect=9230) and took a CPU profile viaProfiler.start/stop. 9,535 samples over 10 s, self-time:~92% of main-process CPU is filesystem syscalls under
walkDir/readCurrentFile. Nothing else is close.Confirmation
Removing the two
"type": "diff"surfaces fromsession.jsonand restarting, with nothing else changed:Respondingsystem.identifyround-trippane.list(round-trips the UI thread)Suggested fixes
Roughly in order of value:
setTimeoutchain instead ofsetInterval), 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/sizeagainst the snapshot and onlyreadFilewhen 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.