Skip to content

perf(terminal): coalesce PTY output posts and unblock the keystroke path - #83

Merged
AThraen merged 1 commit into
mainfrom
perf/coalesce-dispatcher-posts
Aug 10, 2026
Merged

perf(terminal): coalesce PTY output posts and unblock the keystroke path#83
AThraen merged 1 commit into
mainfrom
perf/coalesce-dispatcher-posts

Conversation

@AThraen

@AThraen AThraen commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes the typing-freeze reported from live use: with many live sessions, typing into a pane could lag by minutes before the characters appeared.

Closes #70.

Root cause

Three compounding causes, all on the shared WPF UI dispatcher. Full trace in #70; summary:

  1. One dispatcher post per PTY chunk. TerminalBridge.OnPtyData did a Dispatcher.BeginInvoke for every chunk from every session. Chatty background sessions (Claude spinners, status-line repaints) flooded the queue.
  2. Input was starved too, not just rendering. CoreWebView2.WebMessageReceived is raised on the UI thread, so a keystroke waits in the same backlog it's competing with — then the echo waits again to render. Two full queue traversals per visible character, which is how hundreds of ms becomes minutes.
  3. O(N) work per keystroke. AlertCount (Sessions.Count(s => s.NeedsAttention)) was recomputed twice per keystroke with WPF binding invalidation each time.

Scale context: this was originally measured at 23 sessions; the reporter's state.json has 39.

Changes

  • New OutputCoalescer (Terminal/OutputCoalescer.cs) — chunks arriving before the dispatcher gets a turn collapse into a single post. Scheduler and emitter are injected, so the state machine is unit-testable with no dispatcher and no WebView2.
  • Active-session priority — new TerminalBridge.IsForeground, driven from UpdateActiveTerminalHighlight. Background sessions post at DispatcherPriority.Background; only the active pane posts at Normal.
  • Keystroke path — removed the redundant AlertCount raise in the UserInput handler (AlertCleared fires unconditionally from NotifyUserInteracted and already raises it). The remaining raise goes through RaiseAlertCountIfChanged, which suppresses no-op raises. AlertRaised/AlertCleared move from blocking Invoke to BeginInvoke.
  • The pre-ready output buffer now drains through the coalescer as well, so load-time output and post-ready chunks share one buffer and keep arrival order.

MainViewModel's GroupsChanged Invoke is deliberately left blocking — low-frequency group CRUD, not on the hot path, and deferring it could reorder sidebar rebuilds.

Verification

  • 7 new unit tests for the coalescer (single/many appends, re-entrancy during flush, concurrent appends losing no data), written test-first and watched fail
  • 213/213 unit tests pass
  • App builds and starts clean

Not yet verified: the latency improvement itself is unmeasured under real load. That needs a multi-session run with Settings > Diagnostics > Trace terminal input/output on, comparing OUTPUT dispatcher latency before/after. Worth doing before this is assumed fixed.

Follow-ups (deliberately not in this PR)

🤖 Generated with Claude Code

Typing into a session could lag by minutes with many live sessions. Three
compounding causes, all on the shared UI dispatcher (issue #70):

1. TerminalBridge.OnPtyData did one Dispatcher.BeginInvoke per PTY chunk, so
   chatty background sessions flooded the queue. Introduce OutputCoalescer:
   chunks arriving before the dispatcher gets a turn collapse into a single
   post. The scheduler and emitter are injected so the state machine is
   unit-testable without a dispatcher or WebView2.

2. Keystrokes were starved by that same queue, not just rendering. WebView2
   raises WebMessageReceived on the UI thread, so an input event waits behind
   the backlog, and the echo waits again to render - two queue traversals per
   visible character. Background sessions now post at DispatcherPriority
   .Background and only the active pane posts at Normal, via the new
   TerminalBridge.IsForeground flag driven from UpdateActiveTerminalHighlight.

3. Every keystroke synchronously recomputed AlertCount (an O(N) scan over all
   sessions) twice and invalidated WPF bindings both times. AlertCleared fires
   unconditionally from NotifyUserInteracted, so the second raise in the
   UserInput handler was redundant - removed. The remaining raise goes through
   RaiseAlertCountIfChanged, which suppresses no-op raises. AlertRaised and
   AlertCleared switch from blocking Invoke to BeginInvoke; neither caller
   consumes a result and AlertRaised arrives on a threadpool timer callback.

The pre-ready output buffer now drains through the coalescer too, so load-time
output and post-ready chunks share one buffer and keep arrival order.

MainViewModel's GroupsChanged Invoke is deliberately left blocking: it is
low-frequency group CRUD, not on the hot path, and deferring it could reorder
sidebar rebuilds.

Verified: 213/213 unit tests pass (7 new for the coalescer); app starts clean.
The latency improvement itself is NOT yet measured under load - that needs a
real multi-session run with DebugTerminalTrace enabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AThraen
AThraen merged commit c2eec40 into main Aug 10, 2026
1 check passed
@AThraen
AThraen deleted the perf/coalesce-dispatcher-posts branch August 10, 2026 19:14
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