Skip to content

perf(git): take the git pipeline off the UI thread, and stop polling blind - #126

Merged
AThraen merged 3 commits into
mainfrom
fix/git-poll-off-ui-thread
Sep 8, 2026
Merged

perf(git): take the git pipeline off the UI thread, and stop polling blind#126
AThraen merged 3 commits into
mainfrom
fix/git-poll-off-ui-thread

Conversation

@AThraen

@AThraen AThraen commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

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):

42 UI-STALL events, worst 28547ms

10.54.10  d974929c OUTPUT flush len=3350 dispatcher-latency=12781ms prio=fg since-input=22312ms
10.54.51  4b927f0e OUTPUT flush len=1405 dispatcher-latency=23000ms prio=fg since-input=-1ms

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.

Hypothesis Evidence against
Renderer starvation 0 PAGE write-blocked, 3 paint-lag in 4 minutes
ConPTY write backpressure 662 PTY-WROTE, 661 of them 0ms
Stale IsForeground → Background starvation the typed pane logged prio=fg throughout
Output flooding the dispatcher queue 69KB across 12 sessions in a 68s window that still contained a 9.1s stall

The 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 async method runs everything before its first await synchronously on the calling thread.

using var process = Process.Start(psi);   // before the first await

The git poll chain starts in SessionViewModel's constructor, which LaunchSessionAsync runs on the UI thread. So the SynchronizationContext was 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 exit 21 ms — Windows baseline process creation
git --version 42 ms — git startup, zero repo access
git branch --show-current 41 ms — indistinguishable from doing nothing
git status --porcelain 48–65 ms — only 6–23 ms is the actual tree walk
Process.Start alone ~15 ms — the slice that lands on the UI thread

85–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 --version outlier 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. GitService cannot depend on the caller's thread. RunGitFullAsync is Task.Run-wrapped so Process.Start cannot run inline; every await is ConfigureAwait(false) so nothing climbs back. Both halves are load-bearing — ConfigureAwait alone would not have moved Process.Start. SessionViewModel also starts the poll loop under Task.Run, so the context is never captured in the first place.

2. GitRepoWatcher replaces most of the polling. A debounced FileSystemWatcher on .git/HEAD and .git/index catches checkout, commit, merge and staging immediately — more responsive than a 10s poll, not less. It follows the gitdir: 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 status without 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 / GitInfoLoaded are now 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: a branch changes maybe once an hour.

The instrumentation had rotted

Worth stating plainly, because it nearly cost another round.

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 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() did Directory.CreateDirectory plus a synchronous File.AppendAllText per 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-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.
  • 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.

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 stashing it and re-running, not assumed:

GetGitInfoAsync_completes_without_the_callers_context_ever_running        [FAIL] 20.36s
GetRepoRootAsync_completes_without_the_callers_context_ever_running       [FAIL] 40.39s
Process_creation_never_happens_on_the_thread_designated_as_the_UI_thread  [FAIL]

GitRepoWatcherTests covers worktree gitdir: 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. SessionViewModelGitInfoTests pins the single-notification contract and the unchanged-result early-out.

Still worth doing after this

The same trace showed 47 saved sessions restoring over 151 seconds, and SHUTDOWN complete: 17 waited, 10 force-disposed, 30341ms — the full budget spent. Both are #82, untouched here.

Verification

Check Result
App build 0 errors, 0 warnings
Unit tests 367/367
Regression tests fail without the fix verified by reverting GitService
terminal-init.js syntax node --check clean

Not 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

AThraen and others added 3 commits September 8, 2026 11:32
…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
@AThraen
AThraen merged commit 6d8c65e into main Sep 8, 2026
1 check passed
@AThraen
AThraen deleted the fix/git-poll-off-ui-thread branch September 8, 2026 11:04
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.

perf: coalesce per-session PTY output dispatcher posts to prevent UI starvation

1 participant