Skip to content

Diff pane on a non-git cwd freezes the app: 2s poll runs a ~3s synchronous full-tree re-read on the main process #133

Description

@capad-xyz

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

  1. Open a workspace with cwd set to a non-git directory with a reasonably large tree (a home directory is ideal).
  2. Open a diff pane in it.
  3. 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:

  1. re-reads every file in the snapshot via readCurrentFilefs.statSync + fs.readFileSync, full contents; and
  2. 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:

  1. 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.
  2. Make the snapshot path async (fs.promises) so a slow scan degrades into slow diffs rather than a frozen window and a dead pipe.
  3. 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.
  4. Don't re-walk every poll just to find new files, or do it on a much slower cadence than the content check.
  5. 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.
  6. 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.

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