You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
perf: stop the profiler and duplicate git reads from causing the lag (#248)
Investigating reported latency when switching between worktrees on a
live production instance. Two independent causes, both measured from
that instance's `perf.log`.
## 1. The profiler was a top source of the lag it measured
`perfLog` called `appendFileSync` once per line, on the main thread —
the same thread serving IPC. At production event rates that's roughly
**1M blocking writes per session**.
It compounded: `perf.log` is deliberately append-only across sessions,
but had no rotation and had reached **182MB / 1.1M lines**, so every
write was appending to an ever-growing file.
And two categories were logged *unconditionally* while every other
category had a threshold, on the assumption they were infrequent. They
aren't — they fire on every panel refresh for every worktree. One
session produced:
| category | lines in one session |
|---|---|
| `git-op` | 114,840 |
| `changed-files` | 77,512 |
Fixes:
- Buffered writes, flushed async on a 1s / 500-line trigger via
`appendFile`. `flushPerfLogSync()` drains on shutdown so the tail of a
session isn't lost.
- Rotation at 10MB into `perf.log.1`, matching `debug.log`. `log:perf`
now uses `tail -F` to survive rotation; `log:perf:clear` removes both
files.
- Both categories gated at 50ms like everything else.
## 2. The branch-mode git read was running twice
Two independent cache keys — `'changedFiles'` and `'branchChangedFiles'`
— fetch byte-identical `mode=branch` data for the same worktree with no
coordination. Each `getChangedFiles` costs three git subprocess spawns.
The log shows the duplication directly, across 77k samples and 179
distinct worktree paths:
| mode | requests | ratio |
|---|---|---|
| `branch` | 51,343 | **1.96 : 1** |
| `working` | 26,212 | |
Working-tree and branch reads are requested by the same surfaces, so
absent duplication these should be near 1:1. Nearly 2:1 only makes sense
if the branch fetch runs twice.
New `requestChangedFiles` joins calls already overlapping in time, keyed
by `(mode, path)`. Dedup is **in-flight only** — nothing is retained
past settlement — so it cannot serve stale data. `force` opts out of the
join entirely for invalidation-driven refreshes, which must not be
answered by a request that started before the change landed.
## Ruled out
Worth recording so the next person doesn't re-investigate: renderer
rendering (zero `[render-slow]` entries), shell-wrapped git spawns (uses
`execFile` directly), `useBackend()` identity churn (stable module
singleton), the reducer reference-identity anti-pattern (already uses
`findIndex + slice`), and event cascades (only 4 `[cascade]` lines).
I also initially blamed `jsonClaude/*` streaming for main-thread blocks
and **retracted it** — `[snapshot]` lines during lag show `store=1/s
ipc=1/s` with near-empty `topEventTypes`. That was inferring causation
from adjacency in the log. No changes were made to that slice.
## Relationship to #247
Complementary, different layers — #247 is main-side git caching and
index-lock contention; this is renderer-side request dedup and perf-log
cost. I deliberately stayed out of `git-poll-cache.ts` /
`git-ops-state.ts`.
I verified the one real interaction risk — that a `force` refresh here
could be served a stale value by #247's main-side cache — and **it's
safe**: #247 sets `fingerprintable: mode === 'branch'`, so working-tree
reads are never fingerprint-skipped, and the branch fingerprint spans
every input a `base...HEAD` diff depends on. The only cached answer to a
force refresh is the deliberate busy-skip during a real rebase, bounded
at 10 consecutive skips.
**Whoever merges second gets one conflict**, at the `getChangedFiles`
perf-log call site in `src/main/worktree.ts`. Resolution is to combine,
not to take a side: keep #247's `value`/`cached` and wrap it in this
PR's `if (ms >= SLOW_CHANGED_FILES_MS)`.
One decision for merge time: #247's log line ends with `${cached ? '
cached' : ''}`, but a cache hit returns in ~0ms and so never clears the
50ms gate. Merging both as-is silently destroys the "is the cache
working?" signal that flag exists to provide. Either exempt cache hits
from the threshold or count them separately — worth choosing
deliberately.
## Test plan
- [x] `npm run typecheck` clean
- [x] `npx electron-vite build` clean
- [x] `npx vitest run` — 1028 passed, 1 skipped
- [ ] CI green
⚠️ **Local test runs are unreliable on the machine this was developed
on.** Three git-spawning integration tests
(`worktree-watcher.integration.test.ts`, `git-ops-state.test.ts`,
`path-fix.test.ts`, plus `git-poll-cache.test.ts`) fail intermittently
on their 5s timeout under full-suite load, and pass 3/3 in isolation.
Confirmed load-sensitive rather than caused by this branch by
interleaving dirty/clean full-suite runs — clean `main` fails the same
test and this branch passes on green runs. Flagging as a likely
pre-existing CI flake worth a follow-up issue; not touched here.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
0 commit comments