8000 Diff pane on a non-git cwd freezes the app: 2s poll runs a ~3s synchronous full-tree re-read on the main process · Issue #133 · amirlehmam/wmux · GitHub
[go: up one dir, main page]

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

Closed

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 readCurrentFile — fs.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[ 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.

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.

Activity

  1. amirlehmam commented on Jul 31, 2026

    @amirlehmam
    Owner

    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 setTimeout chain, backing off to max(2000, elapsed)), the snapshot path fully on fs.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:

    1. Widen SNAPSHOT_IGNORE with 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.

    2. Do not re-walk every poll just to find new files, or run it on a much slower cadence than the content check.

    3. 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 ping keeps working, which makes it look like a pipe/auth bug" saved me a day in the wrong file.

  2. capad-xyz commented on Jul 31, 2026

    @capad-xyz
    ContributorAuthor

    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 V1 ping answers 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_IGNORE gains AppData, Library, Application Data, $RECYCLE.BIN, System Volume Information, OneDrive, Recovery, plus a MAX_SNAPSHOT_DIRS budget 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 resolveWithin prefix check you noted, and the identical startsWith test in getFileDiff's untracked-file branch three lines away — both now go through one isWithin helper using path.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 #133 line 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).

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