Skip to content

fix(perf): take three blocking paths off the UI thread and the thread pool - #160

Merged
mortenaslo merged 1 commit into
mainfrom
fix/ui-thread-hot-paths
Nov 6, 2026
Merged

mortenaslo merged 1 commit into
mainfrom
fix/ui-thread-hot-paths

Conversation

@AThraen

@AThraen AThraen commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Three small, contained fixes from the investigation into typing still freezing at times. Each one removes a path that either blocks the UI thread or ties up thread-pool threads.

These are fixes chosen by reading the code, not yet confirmed against a captured freeze. A stall recorder is running against the live app. The output path (foreground flush priority, batch size) is deliberately not touched here until that recorder shows it is involved.

1. Run-command output

Before: every 4 KB chunk of run-command output raised InstancesChanged and made a synchronous Dispatcher.Invoke call.

  • That rebuilt every chip.
  • If the drawer was open, it also re-assigned the whole buffer, up to 1 MB, to the TextBox.
  • A chatty dev server was enough to cause a freeze, and its PTY reader stalled on each repaint.

After:

  • SessionRunner has a separate OutputChanged event. InstancesChanged now fires only for structural and state changes, and is marshalled with BeginInvoke, so it never parks the PTY thread.
  • Output triggers a drawer-only refresh, throttled to about 4 per second at Background priority (below input) through the new ThrottledSignal class.
  • The drawer appends only the new tail, using RunInstance.ReadSince. It falls back to a full snapshot when:
    • the head of the buffer was trimmed past what the drawer shows,
    • the drawer switched to a different run, or
    • the box has grown past 1.5 MB.

2. state.json saves

Before: SaveAsync had no ConfigureAwait, and File.Replace is synchronous, so the temp-file write and the swap both ran on the UI thread. Antivirus or a synced folder can make that swap slow. Saves are triggered by OSC 9001 prompt hooks, window moves, git RepoRoot updates and about 30 other call sites.

After:

  • The JSON snapshot is still taken on the caller's thread, because the AppState graph is changed on the UI thread.
  • The file I/O runs on the thread pool.
  • Snapshots are now taken before the (non-FIFO) save gate, so a sequence number drops an older snapshot that reaches the disk after a newer one. Without it, state could roll back.

3. PTY reads

Before: ReadLoopAsync blocked a thread-pool thread for the whole life of each session. On the live app I measured 55 pool threads parked in Read with about 60 sessions, against a pool minimum of 24 on a 24-core machine. Past the minimum, the pool adds threads gradually, so bursts of other pool work queued up. That includes the next read of the active pane, which looks exactly like a keystroke that doesn't echo.

After: each PTY gets one dedicated background thread, the same reasoning as the MonitorExitAsync change in #110.

Tests

All 621 unit tests pass. New tests:

  • ThrottledSignalTests: a burst of signals runs the action once; a later signal runs it again; a signal that arrives while the action is running is not lost; the action never runs inline.

  • SessionRunnerTests:

    • output fires OutputChanged and not InstancesChanged;
    • ReadSince returns only the new tail;
    • ReadSince returns null once the buffer head has been trimmed past the reader's position.
  • StateServiceTests:

    • under 40 concurrent saves, the newest snapshot wins;
    • a save completes on a caller whose SynchronizationContext never runs work.

    The second test was checked against the old StateService: it fails after 20 s, and the save gate the old code leaks then stalls every later save in the run.

Manual testing needed

These weren't exercised in a running app:

  • Run a chatty run command (npm run dev, dotnet watch) with the drawer open, and type in another pane.
  • Check that the drawer keeps scrolling to the end and that Copy and Send still return the full output.
  • Sleep, wake and restart sessions, and check that state.json still updates.

🤖 Generated with Claude Code

… pool

Three small, contained fixes from the typing-freeze investigation:

- Run commands: output no longer shares InstancesChanged. Each 4KB chunk used
  to rebuild the chips and re-assign the whole drawer (up to 1MB) through a
  synchronous Dispatcher.Invoke. Output now drives a drawer-only refresh at
  most ~4x/s at Background priority, and the drawer appends only the new tail.
- State saves: JSON is still snapshotted on the caller's thread, but the temp
  write and File.Replace now run on the pool. A sequence number keeps an
  older snapshot from overwriting a newer one, since the gate is not FIFO.
- PTY reads: one dedicated thread per PTY instead of a permanently parked
  pool thread. 55 were measured blocked at ~60 sessions, against a pool
  minimum of 24, so other pool work queued behind thread injection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mortenaslo
mortenaslo merged commit b4b8ae6 into main Nov 6, 2026
1 check passed
@mortenaslo
mortenaslo deleted the fix/ui-thread-hot-paths branch November 6, 2026 08:49
mortenaslo added a commit that referenced this pull request Nov 6, 2026
…xed 500ms (#176)

A_burst_of_signals_runs_the_action_once failed on main's CI right after #160
merged (Expected: 1, Actual: 0) while passing in #160's own run and locally.
Each test slept a fixed 500ms of wall clock for a 50ms timer; xunit runs test
classes in parallel, and on a loaded runner the Task.Delay continuation can
land later than that. A_signal_after_the_action_ran_schedules_another had a
second form of the same race: if the first run was late, the second Signal
piggybacked on it and the test saw one run instead of two.

Now each test polls until the expected count is reached (10s ceiling, only
for a broken implementation), then watches a further 300ms to catch an extra
run. ThrottledSignal itself is unchanged.

Mutation-checked: removing the coalescing fails the burst test; clearing
_pending after the action instead of before fails the during-action test.


Claude-Session: https://claude.ai/code/session_01NsYm9iLc2aRnRDfV2uZRQX

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants