8000 fix(diff): keep the app responsive when a diff pane watches a non-git cwd by capad-xyz · Pull Request #135 · amirlehmam/wmux · GitHub
[go: up one dir, main page]

Skip to content

fix(diff): keep the app responsive when a diff pane watches a non-git cwd - #135

Merged
amirlehmam merged 1 commit into
amirlehmam:masterfrom
capad-xyz:fix/diff-pane-nongit-freeze
Jul 31, 2026
Merged

amirlehmam merged 1 commit into
amirlehmam:masterfrom
capad-xyz:fix/diff-pane-nongit-freeze

Conversation

@capad-xyz
Copy link
Copy Markdown
Contributor

Fixes #133 — the three mechanical fixes proposed there: a diff pane on a non-git cwd (e.g. a home directory) pinned the main process at 100% of a core, froze the window, and stopped the pipe server answering.

What changed

1. The poll can no longer overlap itself (DiffPane.tsx)
setInterval(poll, 2000) fired on a fixed clock regardless of how long the previous pass took — with a ~3s snapshot scan against a 2s interval, work queued faster than it drained, permanently. The poll is now a self-scheduling setTimeout chain: the next pass is scheduled only after the previous one (files + diff) fully resolves, with the delay stretched to max(2000, elapsed) so a slow scan naturally backs the cadence off instead of piling up.

2. The snapshot path is fully async (diff-provider.ts)
walkDir / takeSnapshot / readCurrentFile used readdirSync / statSync / readFileSync inside ipcMain.handle, so the whole scan executed as one uninterruptible block on the main process — which is why IPC, painting, and the named pipe all stalled together. All of it now uses fs.promises; a slow scan degrades into slow diffs instead of a frozen app.

3. Stat before read
Snapshot entries now store { content, mtimeMs, size } instead of bare content. On each poll a file is stat-ed first and its content only re-read when mtime/size actually moved — in the steady state (nothing changed) this eliminates every read; on the tree from #133 that's ~26 MB of readFileSync per poll gone. When only metadata moved (touch) the stored stat is refreshed so the next poll returns to the cheap path.

Also added, in the same spirit: concurrent scans of the same directory now coalesce onto one in-flight promise (scansInFlight), so two diff panes on the same cwd can't double the work.

Not included (deliberately — they're design decisions, per the issue): slower re-walk cadence for new-file detection, widening SNAPSHOT_IGNORE with platform dirs, and gating the fallback off for home-like trees.

Behavior preserved

  • Git repos: untouched — same git status --porcelain path.
  • Baseline semantics: first call snapshots and reports nothing; changes are always relative to the baseline until resetSnapshot.
  • A file that grows past the 500 KB cap or turns binary is still reported as deleted, as before.
  • Depth/file caps, extension filter, ignore list: unchanged.

Tests

New tests/unit/diff-provider.test.ts (the snapshot system previously had no coverage):

  • baseline call reports no changes
  • modified / added / deleted detection
  • mtime moved but content unchanged → no report, and the skip path holds on the following poll (the stat-before-read behavior)
  • unified diff rendering for a modified file
  • concurrent scans coalesce to one result

npm run typecheck (both tsconfigs), npm run lint, and the full npm test suite pass.

Measured effect

Same machine and tree as #133 (3,578 dirs / 2,000 snapshot files / 26.6 MB):

before after
Main-process CPU with a diff pane on ~ ~100% of a core, sustained idle between polls
Window Responding False within seconds True
system.identify over the pipe timeout sub-ms

… cwd (amirlehmam#133)

The non-git snapshot fallback ran a synchronous full-tree walk plus a full
content re-read of every snapshot file inside ipcMain.handle, on a fixed
2s setInterval that could overlap itself. On a large cwd (a home directory)
each pass took longer than the interval, so the main process pinned a core,
the window went Not Responding, and the pipe server stopped answering.

- DiffPane: self-scheduling poll; next pass only after the previous one
  resolves, delay stretched to max(2s, elapsed) so slow scans back off
- diff-provider: snapshot path converted to fs.promises end to end
- snapshot entries store mtime/size; content is only re-read when the
  stat moved, and a metadata-only change (touch) refreshes the stored
  stat so later polls return to the cheap skip path
- concurrent scans of the same cwd coalesce onto one in-flight promise
- new unit coverage for the snapshot system (previously untested)
@amirlehmam
amirlehmam merged commit ea7c3bb into amirlehmam:master Jul 31, 2026
@amirlehmam
Copy link
Copy Markdown
Owner

Merged in ea7c3bb, shipping in v0.40.0. Thank you — for the PR, and for #133, which is one of the best bug reports this project has had.

You did not just say "the app freezes". You attached a debugger to the main process, took a CPU profile, and came back with 92% of self-time in readdirSync/statSync/readFileSync under walkDir/readCurrentFile, plus a before/after table showing system.identify going from timeout to 0.7 ms. That is the difference between a report I have to reproduce and a report I can act on, and it is also what made the symptom legible: only V1 ping kept answering, so it looked like a pipe or auth bug when it was a stalled event loop. I would have spent a day in pipe-server.ts.

The three fixes are the right three, in the right order.

Never overlapping is the one that converts a hard freeze into merely-slow, and it is correct regardless of how fast the scan gets. setInterval fires on a wall clock that knows nothing about whether the previous pass finished; with a ~3s pass against a 2s interval, work queues faster than it drains and never recovers. Stretching to max(2000, elapsed) means the cadence degrades with the tree instead of collapsing.

Async is what stops a slow scan from taking IPC, painting and the named pipe down with it. getChangedFiles being async never helped — the sync calls inside it still owned the thread, which is a distinction worth having in the comments.

Stat before read is the one that makes the steady state nearly free. Nothing has changed on most polls, so comparing mtimeMs/size removes essentially all 26 MB of re-reads. And refreshing the stored stat when only metadata moved is the detail I would expect to be missing — without it a touch leaves the file permanently on the expensive path.

scansInFlight coalescing two diff panes on the same cwd onto one promise is a good addition that was not asked for.

I also appreciate that you drew a line and said so. Slowing the re-walk, widening SNAPSHOT_IGNORE with AppData/OneDrive/$RECYCLE.BIN, and gating the fallback off for home-like trees are design calls, not mechanical fixes, and leaving them out of a fix-the-freeze PR was right. I have kept #133 open to track them — the AppData point in particular is real: the 2,000-file cap bounds the read but the walk still enumerates thousands of directories first, and which 2,000 files you end up with is arbitrary.

One observation, not a blocker: resolveWithin uses a startsWith prefix check, which would accept C:\foobar as inside C:\foo. That behaviour came over unchanged from readCurrentFile, and the paths fed to it come from a walk of cwd itself, so it is not reachable today — noting it so it is written down somewhere.

amirlehmam pushed a commit that referenced this pull request Aug 2, 2026
…dence (#133) (#138)

Follow-ups (1) and (2) from #133, left out of #135 as design calls.

- MAX_SNAPSHOT_DIRS (500) is threaded through walkDir as a mutable budget shared
  across the recursion, so traversal is bounded independently of the file cap.
  SNAPSHOT_IGNORE gains the platform dirs (AppData, OneDrive, $RECYCLE.BIN, ...).
- The re-walk that discovers NEW files now runs every 5th poll. Modifications
  and deletions still come off the snapshot map on the very next poll.
- Discovered additions are remembered per-cwd in addedPaths so they do not
  flicker between re-walks, and are dropped when the file goes away.
- resolveWithin's startsWith prefix check (which accepted C:\foobar as inside
  C:\foo) and its twin in getFileDiff both go through one isWithin helper built
  on path.relative. Neither was reachable — hygiene, not a security fix.

Verified locally: 61 files / 667 tests passing, typecheck clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

0