perf(git): take the git pipeline off the UI thread, and stop polling blind - #126
Merged
Conversation
…blind Fixes the typing stall in issue #70: input stops echoing, then arrives all at once. Diagnosed by measurement rather than inspection, after three of the four standing hypotheses turned out to be wrong. ## What the trace said Instrumented, then run at the reporter's real workload (47 sessions): 42 UI-STALL events, worst 28547ms d974929c OUTPUT flush len=3350 dispatcher-latency=12781ms prio=fg since-input=22312ms That flush line is the bug report restated as data: a foreground pane blocked 12.8s, accumulating 3.3KB, then rendering it in one blob. Ruled out, each with evidence: - renderer starvation 0 write-blocked, 3 paint-lag in 4 minutes - ConPTY write backpressure 662 PTY-WROTE, 661 of them 0ms - stale IsForeground the typed pane logged prio=fg throughout - output flooding the queue 69KB across 12 sessions in a 68s window that still contained a 9.1s stall The last one is what broke it open: it falsified the theory this issue is titled after, and pointed at work on the UI thread that wasn't terminal I/O. ## Root cause An async method runs everything before its first await synchronously on the calling thread. RunGitFullAsync had Process.Start there, and the poll chain starts in SessionViewModel's constructor, which LaunchSessionAsync runs on the UI thread — so the SynchronizationContext was captured, every continuation came back, and git process creation happened on the UI thread. ~94 spawns per 10s cycle at 47 sessions. Measured here: Process.Start alone is ~15ms, so ~1.4s of hard UI block per cycle on an idle machine, before contention. And the cost is process creation, not git — `git --version` is 42ms against `branch --show-current` at 41ms — so there was never a faster query to switch to. #70's step 4 had the right target and the wrong mechanism: it called this "background load". It was never background. ## The three fixes 1. GitService cannot depend on the caller's thread. RunGitFullAsync is Task.Run-wrapped so Process.Start can't run inline; every await is ConfigureAwait(false) so nothing climbs back. Both halves are needed — ConfigureAwait alone would not have moved Process.Start. SessionViewModel also starts the poll loop under Task.Run so the context is never captured. 2. GitRepoWatcher replaces most of the polling. A debounced FileSystemWatcher on .git/HEAD and .git/index catches checkout, commit, merge and staging immediately. It follows the `gitdir:` indirection, so a linked worktree watches its own HEAD rather than the main repo's and reports the right branch. Polling survives only for working-tree edits, which dirty status without touching .git: foreground 10s, background 120s, plus an immediate refresh when a pane becomes active. 3. One notification instead of three. GitBranch/GitIsDirty/GitInfoLoaded are hand-written properties sharing GitInfoVersion; as [ObservableProperty] they raised three events per poll per session, each crossing a blocking Dispatcher.Invoke and rebuilding a sidebar row's WPF inlines — 141 rebuilds per cycle. ApplyGitInfo also returns early on an unchanged result, which is almost every poll. ## Instrumentation, which had rotted The trace could not answer the question it existed for. dispatcher-latency was added in 092b801 and deleted by 71e1294 — the coalescing commit for this same issue — which dropped the enqueue timestamp along with the per-chunk post. The fix removed the instrument that measured the problem, invisibly, because the trace still produced plausible output. It would also have caused the stall it measured: Trace() did CreateDirectory + a synchronous AppendAllText per call, on the PTY thread and the UI thread. - DiagnosticTrace: bounded queue, one background drain, timestamps taken at call time. Overflow reported in-band, since a silent gap in a latency log reads as a stall that never happened. - dispatcher-latency restored on the coalesced flush, with the priority the batch was queued at and time-since-last-input. - UiThreadHeartbeat: UI lateness attributed to no session, the only way to separate real saturation from Background priority yielding correctly. - Page-side term.write duration and paint lag, threshold-gated. The host is blind past PostWebMessageAsString and renderer starvation was still live. - GIT-SPAWN on-ui=<bool> spawn=<ms>, which is what proved the diagnosis. ## Tests 19 new, 367 total, 0 warnings. GitServiceThreadingTests calls GitService from a thread whose SynchronizationContext never runs work — if any await captures it, the call never completes and the test times out. All three fail against the pre-fix GitService (verified by reverting it) and pass after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NDsEfog5kVkT5NmX1Ya5be
Two unrelated additions; the second is a drive-by from earlier in the same session and can be dropped from this PR if it belongs elsewhere. **Never spawn a process on the UI thread.** The mechanism behind #70, with the measurements that make the case: ~85-90% of any git call is process startup (`git --version` 42ms vs `branch --show-current` 41ms), so there is no faster query — the only fixes are to be off the UI thread and to not spawn at all. Records the three load-bearing rules and points at the regression tests, since `ConfigureAwait(false)` and a `Task.Run` wrapper both look exactly like the kind of thing a later reader tidies away. **Chocolatey is blocked on moderation.** The post-tag checklist said to dispatch both mirrors unconditionally; v0.5.0 is still in the human review queue, so dispatching for a newer tag fails rather than queueing behind it. Also records that the GitHub MSI download counter is the union of all three channels — the winget manifest's InstallerUrl and chocolateyinstall.ps1's substituted __URL64__ both point at that same asset — so the three numbers must not be summed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NDsEfog5kVkT5NmX1Ya5be
…ently origin/main landed #121, and 55609f0 within it independently addressed the same root cause from the other end. Neither side is redundant, so both are kept. **Their half** wrapped the two call sites in SessionViewModel with Task.Run, which moves GitService's synchronous prefix — Directory.Exists, and on a \wsl$ share that boots a stopped distro — off the dispatcher. Plus a WSL-specific 30s cadence, a negative repo-root cache so a not-a-repo answer stops costing a wsl.exe spawn per tick, and a cancellation guard so a probe finishing after Dispose can't touch a dead VM. **My half** hardens GitService itself: RunGitFullAsync is Task.Run-wrapped and every await is ConfigureAwait(false), so *every* caller is safe, not just the two that were patched. ListWorktreesAsync, ListBranchesAsync and CreateWorktreeAsync are all reachable from UI code and all still had Process.Start on the caller's thread. Reconciliations that needed a decision rather than a pick: - **Dropped my Task.Run around the constructor's poll start.** It existed to prevent the UI SynchronizationContext being captured, but with GitService hardened nothing dangerous runs on that thread any more, and dropping the capture would have moved every property set off the UI thread — losing the invariant their comment is explicit about. Capturing the context is correct again now that the thing which made it harmful is gone. - **GitRepoWatcher fires on a threadpool thread**, which would have broken that same invariant from the other direction. It now posts to the SynchronizationContext captured at construction, so the watcher path sets properties on the same thread the poll path does. - **Merged the two cadence policies into one function.** GitPollIntervalFor now takes (kind, isForeground): background 120s regardless of kind, foreground 10s local / 30s WSL. Their kind axis and my foreground axis are answering different questions and compose cleanly. - **The watcher is Local-only.** A WSL session's folder is a \wsl$ UNC, and watching that keeps the distro's 9p server busy — defeating exactly the idle-VM shutdown their 30s cadence protects. - ApplyGitInfo now sits behind their cancellation guard, so the coalesced notification inherits the disposed-VM protection. 495/495 tests, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NDsEfog5kVkT5NmX1Ya5be
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #70. The typing stall — input stops echoing, then arrives all at once — diagnosed by measurement, after three of the four standing hypotheses turned out to be wrong.
What the trace said
Instrumented first, then run at the reporter's real workload (47 sessions, not the 25 previously assumed):
That first line is the bug report restated as data: a foreground pane, blocked 12.8s, accumulating 3.3KB, then rendering it in one blob.
PAGE write-blocked, 3paint-lagin 4 minutesPTY-WROTE, 661 of them 0msIsForeground→ Background starvationprio=fgthroughoutThe last one falsified the theory this issue is titled after, and is what broke the case open — it pointed at work on the UI thread that wasn't terminal I/O at all. Stalls also continued at 10:55:40, 10:56:01 and 10:56:23, after restore finished and before shutdown began, with the app logging nothing.
Root cause
An
asyncmethod runs everything before its firstawaitsynchronously on the calling thread.The git poll chain starts in
SessionViewModel's constructor, whichLaunchSessionAsyncruns on the UI thread. So theSynchronizationContextwas captured, every continuation returned there, and git process creation happened on the UI thread — ~94 spawns per 10s cycle at 47 sessions.Measured on this hardware:
cmd /c exitgit --versiongit branch --show-currentgit status --porcelainProcess.Startalone85–90% of any git call is process startup, not git. There was never a faster query to switch to. That is ~1.4s of hard UI block per cycle on an idle machine — and a
git --versionoutlier at 525ms against its 42ms median shows what contention does to that floor.#70's step 4 had the right target and the wrong mechanism. It called this "background load — ~46 process spawns per 10s". It was never background, which is why backing off the interval would have made the freeze rarer rather than fixing it.
The three fixes
1.
GitServicecannot depend on the caller's thread.RunGitFullAsyncisTask.Run-wrapped soProcess.Startcannot run inline; every await isConfigureAwait(false)so nothing climbs back. Both halves are load-bearing —ConfigureAwaitalone would not have movedProcess.Start.SessionViewModelalso starts the poll loop underTask.Run, so the context is never captured in the first place.2.
GitRepoWatcherreplaces most of the polling. A debouncedFileSystemWatcheron.git/HEADand.git/indexcatches checkout, commit, merge and staging immediately — more responsive than a 10s poll, not less. It follows thegitdir:indirection so a linked worktree watches its own HEAD rather than the main repo's, which would otherwise report the wrong branch entirely.Polling survives only for the case the watcher genuinely cannot see — a working-tree edit dirties
statuswithout touching anything under.git. Foreground 10s, background 120s, plus an immediate refresh when a pane becomes active.3. One notification instead of three.
GitBranch/GitIsDirty/GitInfoLoadedare now hand-written properties sharingGitInfoVersion. As[ObservableProperty]they raised three events per poll per session, each crossing a blockingDispatcher.Invokeand rebuilding a sidebar row's WPF inlines — 141 rebuilds per cycle.ApplyGitInfoalso returns early on an unchanged result, which is almost every poll: a branch changes maybe once an hour.The instrumentation had rotted
Worth stating plainly, because it nearly cost another round.
dispatcher-latencywas added in092b801and deleted by71e1294— the coalescing commit for this same issue — which dropped the enqueue timestamp along with the per-chunk post it was attached to. The fix removed the instrument that measured the problem, and nothing looked wrong afterwards because the trace still produced plausible output. I repeated the stale claim that it existed before checking.It would also have caused the stall it measured:
Trace()didDirectory.CreateDirectoryplus a synchronousFile.AppendAllTextper call, on the PTY read thread for output and the UI thread for every flush.DiagnosticTrace— bounded queue, one background drain, timestamps taken at call time so deferring the I/O does not distort the measurement. Overflow reported in-band, since a silent gap in a latency log reads as a stall that never happened.dispatcher-latencyrestored on the coalesced flush, with the priority the batch was queued at and time-since-last-input.UiThreadHeartbeat— UI lateness attributed to no session, the only way to separate real saturation from Background priority yielding correctly.term.writeduration and paint lag, threshold-gated. The host is blind pastPostWebMessageAsString.GIT-SPAWN on-ui=<bool> spawn=<ms>— the probe that proved the diagnosis, now also asserted by a test.Tests
367 total (19 new), 0 warnings.
GitServiceThreadingTestscallsGitServicefrom a thread whoseSynchronizationContextnever runs work: if any await captures it, the call never completes and the test times out. All three fail against the pre-fixGitService— verified by stashing it and re-running, not assumed:GitRepoWatcherTestscovers worktreegitdir:resolution, that only HEAD/index wake it, debouncing a checkout's burst into one notification, and that a non-repo returns null rather than throwing.SessionViewModelGitInfoTestspins the single-notification contract and the unchanged-result early-out.Still worth doing after this
The same trace showed
47 saved sessionsrestoring over 151 seconds, andSHUTDOWN complete: 17 waited, 10 force-disposed, 30341ms— the full budget spent. Both are #82, untouched here.Verification
GitServiceterminal-init.jssyntaxnode --checkcleanNot yet run against the live 47-session workload — that is the one thing to confirm on merge. The trace is still in, so a post-merge run should show
GIT-SPAWN on-ui=False,UI-HEARTBEAT stalls=0, and spawn counts collapsing from ~94 per 10s to near zero at idle.The second commit also carries an unrelated drive-by docs fix (chocolatey moderation, download-count overlap) — say the word and I will pull it out.
🤖 Generated with Claude Code
https://claude.ai/code/session_01NDsEfog5kVkT5NmX1Ya5be