Skip to content

Commit f01df08

Browse files
frenchie4111claude
andauthored
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>
1 parent 239a0c8 commit f01df08

10 files changed

Lines changed: 187 additions & 44 deletions

File tree

CLAUDE.md

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -306,8 +306,19 @@ Two log files in `userData`:
306306
(removes both `debug.log` and `debug.log.1`).
307307
- **`perf.log`** — perf trace. **Append-only across sessions** so lag
308308
that happened earlier (possibly before the most recent restart) is
309-
still inspectable. Tail with `npm run log:perf`. Clear before a fresh
310-
repro with `npm run log:perf:clear`.
309+
still inspectable. Rotated at 10MB into `perf.log.1` (one archive
310+
only). Tail with `npm run log:perf` (uses `tail -F` so it survives
311+
rotation). Clear before a fresh repro with `npm run log:perf:clear`
312+
(removes both `perf.log` and `perf.log.1`).
313+
314+
Writes are **buffered and async** — lines accumulate and flush on a
315+
1 s timer or at 500 buffered lines, whichever comes first, via
316+
`appendFile` rather than `appendFileSync`. This matters: the profiler
317+
runs on the same main thread it's measuring, so a blocking write per
318+
line makes it a source of the lag it reports. `flushPerfLogSync()`
319+
drains the buffer on shutdown so the tail of a session isn't lost.
320+
The corollary for anyone reading the log live: the last second or so
321+
of events may not be on disk yet.
311322

312323
What gets written to `perf.log` (and where the threshold lives):
313324

@@ -327,11 +338,20 @@ What gets written to `perf.log` (and where the threshold lives):
327338
at 60 fps; `src/renderer/main.tsx`). Forwarded from the renderer over
328339
the `perf:logSlowRender` fire-and-forget signal — telemetry must not
329340
block the render.
330-
- `[changed-files]` — every `getChangedFiles` / `getCommitChangedFiles`
331-
call (these are infrequent and a complete trace is invaluable).
332-
- `[git-op]` — per-call timing breakdown for slow git functions, capturing exec/post/bytes split.
341+
- `[changed-files]``getChangedFiles` / `getCommitChangedFiles` /
342+
`getCommitRangeChangedFiles` calls taking ≥ `SLOW_CHANGED_FILES_MS`
343+
(50 ms; `src/main/worktree.ts`).
344+
- `[git-op]` — per-call timing breakdown (exec/post/bytes split) for git
345+
functions taking ≥ `SLOW_GIT_OP_MS` (50 ms; `src/main/worktree.ts`).
333346
- `[microtask-drift]` — main-thread blocks ≥50ms (higher resolution than the 500ms event-loop sampler).
334347

348+
`[changed-files]` and `[git-op]` were originally logged unconditionally,
349+
on the theory that they were infrequent enough for a complete trace to
350+
be worth it. They aren't — they fire on every panel refresh for every
351+
worktree, and a single session produced 77k and 115k lines respectively.
352+
Don't un-gate them for a "complete" trace; if you need one for a
353+
specific repro, lower the constant temporarily instead.
354+
335355
The HUD at **Cmd+Opt+P** shows live aggregates (rates, history sparkline,
336356
React commits per second, top event types). `perf.log` captures the
337357
per-event detail the HUD can't display. They're complementary —

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
"dev:debug": "sh -c 'trap \"kill 0\" EXIT; tail -F \"$HOME/Library/Application Support/Harness (Dev)/debug.log\" & ELECTRON_DISABLE_SANDBOX=1 electron-vite dev'",
1414
"log": "tail -F ~/Library/Application\\ Support/harness/debug.log",
1515
"log:clear": "rm -f ~/Library/Application\\ Support/harness/debug.log ~/Library/Application\\ Support/harness/debug.log.1",
16-
"log:perf": "tail -f ~/Library/Application\\ Support/harness/perf.log",
17-
"log:perf:clear": "rm -f ~/Library/Application\\ Support/harness/perf.log",
16+
"log:perf": "tail -F ~/Library/Application\\ Support/harness/perf.log",
17+
"log:perf:clear": "rm -f ~/Library/Application\\ Support/harness/perf.log ~/Library/Application\\ Support/harness/perf.log.1",
1818
"build": "electron-vite build && vite build --config vite.web.config.ts",
1919
"build:web": "vite build --config vite.web.config.ts",
2020
"build:headless": "vite build --config vite.headless.config.ts && vite build --config vite.headless-web.config.ts",

src/main/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ import { getControlServerInfo } from './control-server'
121121
import { recordActivity, getActivityLog, clearAllActivity, clearActivityForWorktree, sealAllActive, touchActivityMeta, finalizeActivity, type ActivityState, type PRState } from './activity'
122122
import { log, getLogFilePath } from './debug'
123123
import { loadCustomThemes } from './themes-loader'
124-
import { perfLog } from './perf-log'
124+
import { perfLog, flushPerfLogSync } from './perf-log'
125125
import { buildInitialAppState } from './build-initial-state'
126126
import { AnnouncementsPoller } from './announcements-poller'
127127

@@ -4777,6 +4777,7 @@ if (desktopShellMod && desktopEarly) {
47774777
// running (intentional; see plans/remote-main.md §4).
47784778
sshTunnelManager.closeAll()
47794779
worktreeWatcher.shutdown()
4780+
flushPerfLogSync()
47804781
},
47814782
setWarnBeforeQuitting
47824783
})
@@ -4802,6 +4803,7 @@ if (desktopShellMod && desktopEarly) {
48024803
sshTunnelManager.closeAll()
48034804
sealAllActive()
48044805
saveConfigSync(config)
4806+
flushPerfLogSync()
48054807
process.exit(0)
48064808
}
48074809
process.on('SIGINT', shutdown)

src/main/perf-log.ts

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,25 @@
1-
import { appendFileSync, existsSync } from 'fs'
1+
import { appendFile, appendFileSync, existsSync, renameSync, statSync, unlinkSync } from 'fs'
22
import { join } from 'path'
33
import { userDataDir } from './paths'
44

55
// Append-only across sessions — the user is debugging lag that may have
66
// happened earlier in the session, possibly before the most recent restart,
7-
// so unlike debug.log we don't truncate on startup.
7+
// so unlike debug.log we don't truncate on startup. We do rotate, because an
8+
// unbounded file is both a disk hazard and a write-latency one.
9+
//
10+
// Writes are buffered and flushed asynchronously. The naive version called
11+
// appendFileSync per line, which put a blocking syscall on the main thread for
12+
// every traced event — at production event rates that's ~1M blocking writes a
13+
// session, i.e. the profiler became a top source of the lag it was measuring.
14+
const FLUSH_INTERVAL_MS = 1000
15+
const MAX_BUFFERED_LINES = 500
16+
const MAX_LOG_BYTES = 10 * 1024 * 1024
17+
818
let logPath: string | null = null
919
let headerWritten = false
20+
let buffer: string[] = []
21+
let flushTimer: NodeJS.Timeout | null = null
22+
let flushing = false
1023

1124
function getLogPath(): string {
1225
if (!logPath) {
@@ -15,15 +28,49 @@ function getLogPath(): string {
1528
return logPath
1629
}
1730

31+
/** Rotate into a single `.1` archive once the live file exceeds the cap,
32+
* mirroring debug.log. Sync is fine here: it happens at most once per 10MB. */
33+
function rotateIfNeeded(path: string): void {
34+
try {
35+
if (statSync(path).size < MAX_LOG_BYTES) return
36+
const archive = `${path}.1`
37+
if (existsSync(archive)) unlinkSync(archive)
38+
renameSync(path, archive)
39+
} catch {
40+
// Missing file or a losing race with another rotation — nothing to do.
41+
}
42+
}
43+
44+
function flush(): void {
45+
if (flushing || buffer.length === 0) return
46+
flushing = true
47+
const chunk = buffer.join('')
48+
buffer = []
49+
const path = getLogPath()
50+
rotateIfNeeded(path)
51+
appendFile(path, chunk, () => {
52+
flushing = false
53+
// Anything queued while the write was in flight goes out on the next tick
54+
// rather than recursing here, so a hot stream can't starve the loop.
55+
if (buffer.length > 0) scheduleFlush()
56+
})
57+
}
58+
59+
function scheduleFlush(): void {
60+
if (flushTimer) return
61+
flushTimer = setTimeout(() => {
62+
flushTimer = null
63+
flush()
64+
}, FLUSH_INTERVAL_MS)
65+
// Never hold the process open just to write a diagnostic log.
66+
flushTimer.unref?.()
67+
}
68+
1869
function ensureHeader(path: string): void {
1970
if (headerWritten) return
2071
headerWritten = true
2172
const sep = existsSync(path) ? '\n' : ''
22-
try {
23-
appendFileSync(path, `${sep}=== session started at ${new Date().toISOString()} ===\n`)
24-
} catch {
25-
// ignore write errors
26-
}
73+
buffer.push(`${sep}=== session started at ${new Date().toISOString()} ===\n`)
2774
}
2875

2976
export function perfLog(category: string, message: string, data?: unknown): void {
@@ -38,8 +85,19 @@ export function perfLog(category: string, message: string, data?: unknown): void
3885
line += ' [unserializable]'
3986
}
4087
}
88+
buffer.push(line + '\n')
89+
// Bound memory if something floods faster than the flush interval.
90+
if (buffer.length >= MAX_BUFFERED_LINES) flush()
91+
else scheduleFlush()
92+
}
93+
94+
/** Drain synchronously — for shutdown, where an async flush would be dropped. */
95+
export function flushPerfLogSync(): void {
96+
if (buffer.length === 0) return
97+
const chunk = buffer.join('')
98+
buffer = []
4199
try {
42-
appendFileSync(path, line + '\n')
100+
appendFileSync(getLogPath(), chunk)
43101
} catch {
44102
// ignore write errors
45103
}

src/main/worktree.ts

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,13 @@ async function numstatExec(
563563
}
564564
}
565565

566+
// These fire on every panel refresh for every worktree — thousands per minute
567+
// in a busy session. Tracing all of them made the log (and its writes) a
568+
// bigger cost than the thing being traced, so both are gated the same way
569+
// every other perf category is: only the slow ones are worth a line.
570+
const SLOW_GIT_OP_MS = 50
571+
const SLOW_CHANGED_FILES_MS = 50
572+
566573
function logGitOp(
567574
name: string,
568575
ctx: Record<string, unknown>,
@@ -574,6 +581,7 @@ function logGitOp(
574581
): void {
575582
const total = performance.now() - t0
576583
const postMs = Math.max(0, total - walledExec)
584+
if (cumExec < SLOW_GIT_OP_MS && total < SLOW_GIT_OP_MS) return
577585
perfLog(
578586
'git-op',
579587
`${name} exec=${cumExec.toFixed(0)}ms post=${postMs.toFixed(0)}ms bytes=${outputBytes}`,
@@ -609,11 +617,13 @@ export async function getChangedFiles(
609617
const t0 = performance.now()
610618
const result = await getChangedFilesImpl(worktreePath, mode)
611619
const ms = performance.now() - t0
612-
perfLog(
613-
'changed-files',
614-
`mode=${mode} path=${basename(worktreePath)} took=${ms.toFixed(0)}ms files=${result.length}`,
615-
{ worktreePath, mode, ms: +ms.toFixed(1), fileCount: result.length }
616-
)
620+
if (ms >= SLOW_CHANGED_FILES_MS) {
621+
perfLog(
622+
'changed-files',
623+
`mode=${mode} path=${basename(worktreePath)} took=${ms.toFixed(0)}ms files=${result.length}`,
624+
{ worktreePath, mode, ms: +ms.toFixed(1), fileCount: result.length }
625+
)
626+
}
617627
return result
618628
}
619629

@@ -795,11 +805,13 @@ export async function getCommitChangedFiles(
795805
const t0 = performance.now()
796806
const result = await getCommitChangedFilesImpl(worktreePath, hash)
797807
const ms = performance.now() - t0
798-
perfLog(
799-
'changed-files',
800-
`mode=commit path=${basename(worktreePath)} took=${ms.toFixed(0)}ms files=${result.length}`,
801-
{ worktreePath, mode: 'commit', hash, ms: +ms.toFixed(1), fileCount: result.length }
802-
)
808+
if (ms >= SLOW_CHANGED_FILES_MS) {
809+
perfLog(
810+
'changed-files',
811+
`mode=commit path=${basename(worktreePath)} took=${ms.toFixed(0)}ms files=${result.length}`,
812+
{ worktreePath, mode: 'commit', hash, ms: +ms.toFixed(1), fileCount: result.length }
813+
)
814+
}
803815
return result
804816
}
805817

@@ -893,11 +905,14 @@ export async function getCommitRangeChangedFiles(
893905
}
894906

895907
logGitOp('getCommitRangeChangedFiles', { fromHash, toHash }, t0, walledExec, cumExec, outputBytes, execParts)
896-
perfLog(
897-
'changed-files',
898-
`mode=range path=${basename(worktreePath)} took=${(performance.now() - t0).toFixed(0)}ms files=${result.length}`,
899-
{ worktreePath, mode: 'range', fromHash, toHash, ms: +(performance.now() - t0).toFixed(1), fileCount: result.length }
900-
)
908+
const rangeMs = performance.now() - t0
909+
if (rangeMs >= SLOW_CHANGED_FILES_MS) {
910+
perfLog(
911+
'changed-files',
912+
`mode=range path=${basename(worktreePath)} took=${rangeMs.toFixed(0)}ms files=${result.length}`,
913+
{ worktreePath, mode: 'range', fromHash, toHash, ms: +rangeMs.toFixed(1), fileCount: result.length }
914+
)
915+
}
901916
return result
902917
}
903918

src/renderer/components/ChangedFilesPanel.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ChangedFile } from '../types'
44
import { Tooltip } from './Tooltip'
55
import { RightPanel } from './RightPanel'
66
import { useWatchedQuery } from '../hooks/useWatchedQuery'
7+
import { requestChangedFiles } from '../hooks/changed-files-request'
78
import { useBackend } from '../backend'
89

910
type Mode = 'working' | 'branch'
@@ -38,10 +39,10 @@ interface ChangedFilesData {
3839

3940
export function ChangedFilesPanel({ worktreePath, onOpenDiff, onSendToAgent, onOpenReview }: ChangedFilesPanelProps): JSX.Element {
4041
const backend = useBackend()
41-
const fetcher = useCallback(async (path: string): Promise<ChangedFilesData> => {
42+
const fetcher = useCallback(async (path: string, opts: { force: boolean }): Promise<ChangedFilesData> => {
4243
const [working, branch] = await Promise.all([
43-
backend.getChangedFiles(path, 'working'),
44-
backend.getChangedFiles(path, 'branch'),
44+
requestChangedFiles(backend, path, 'working', opts),
45+
requestChangedFiles(backend, path, 'branch', opts),
4546
])
4647
return { working, branch }
4748
}, [backend])

src/renderer/components/CollapsedRightPanel.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { CommitInfoModal } from './CommitInfoModal'
2121
import { useActiveBackend, usePanes, usePrs, useRepoConfigs, useSettings, useWorktrees } from '../store'
2222
import { useBackend } from '../backend'
2323
import { useWatchedQuery } from '../hooks/useWatchedQuery'
24+
import { requestChangedFiles } from '../hooks/changed-files-request'
2425
import { useReviewProgress } from '../review-progress'
2526
import { getLeaves } from '../../shared/state/terminals'
2627
import { effectiveHiddenRightPanels } from '../../shared/state/repo-configs'
@@ -86,10 +87,10 @@ export function CollapsedRightPanel({
8687
// and BranchCommitsPanel so the expanded right column doesn't re-fetch
8788
// when the user toggles between collapsed and expanded.
8889
const changedFilesFetcher = useCallback(
89-
async (path: string): Promise<ChangedFilesData> => {
90+
async (path: string, opts: { force: boolean }): Promise<ChangedFilesData> => {
9091
const [working, branch] = await Promise.all([
91-
backend.getChangedFiles(path, 'working'),
92-
backend.getChangedFiles(path, 'branch')
92+
requestChangedFiles(backend, path, 'working', opts),
93+
requestChangedFiles(backend, path, 'branch', opts)
9394
])
9495
return { working, branch }
9596
},
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { ChangedFile } from '../types'
2+
import type { ElectronAPI } from '../types'
3+
4+
// In-flight dedup for `getChangedFiles`.
5+
//
6+
// The panels that show changed files don't share a cache entry: ChangedFilesPanel
7+
// keys on 'changedFiles' (and fetches working+branch together) while
8+
// useChangedFilesSet keys on 'branchChangedFiles'. When both are mounted they
9+
// ask the main process the same `mode: 'branch'` question at the same instant,
10+
// and each answer costs three git subprocesses. Production perf traces showed a
11+
// steady 2:1 branch:working ratio — i.e. one of every two branch diffs was
12+
// redundant.
13+
//
14+
// Dedup is deliberately in-flight only; nothing is cached past settlement, so a
15+
// join can only ever merge calls that already overlap in time. `force` skips the
16+
// join for invalidation-driven refreshes, which must not be served by a request
17+
// that started before the change they're reacting to.
18+
19+
type Mode = 'working' | 'branch'
20+
21+
const inFlight = new Map<string, Promise<ChangedFile[]>>()
22+
23+
export function requestChangedFiles(
24+
backend: ElectronAPI,
25+
path: string,
26+
mode: Mode,
27+
opts: { force?: boolean } = {}
28+
): Promise<ChangedFile[]> {
29+
const key = `${mode}::${path}`
30+
if (!opts.force) {
31+
const existing = inFlight.get(key)
32+
if (existing) return existing
33+
}
34+
const promise = backend.getChangedFiles(path, mode).finally(() => {
35+
// Only clear if we're still the current entry — a forced refresh may have
36+
// replaced us while this request was in flight.
37+
if (inFlight.get(key) === promise) inFlight.delete(key)
38+
})
39+
inFlight.set(key, promise)
40+
return promise
41+
}

src/renderer/hooks/useChangedFilesSet.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react'
22
import type { ChangedFile } from '../types'
33
import { useBackend } from '../backend'
44
import { useWatchedQuery } from './useWatchedQuery'
5+
import { requestChangedFiles } from './changed-files-request'
56

67
/** Single-letter glyphs that mirror the labels used in the
78
* ChangedFilesPanel "Committed" section. Surfaces that want to
@@ -45,7 +46,8 @@ const EMPTY: ChangedFilesSetResult = {
4546
export function useChangedFilesSet(worktreePath: string | null): ChangedFilesSetResult {
4647
const backend = useBackend()
4748
const fetcher = useCallback(
48-
(path: string) => backend.getChangedFiles(path, 'branch'),
49+
(path: string, opts: { force: boolean }) =>
50+
requestChangedFiles(backend, path, 'branch', opts),
4951
[backend]
5052
)
5153

0 commit comments

Comments
 (0)