Skip to content

perf: session restore on startup is fully sequential (~131s for 25 sessions; launch cost, not sleeps) #82

Open

Description

@AThraen

Problem

Reported from live use (2026-08-07): both app startup (restoring sessions) and shutdown take a long time — tens of seconds — scaling with the number of live sessions. Both are serial-by-construction, and most of the wall clock is deliberate sleeping rather than work.

Startup: ~2s of pure sleep per Claude session

MainWindow.OnLoaded (src/CodeShellManager/MainWindow.xaml.cs:284-301) restores sessions in a sequential awaited loop, and inserts a fixed delay between consecutive Claude launches:

foreach (var s in saved)
{
    if (s.IsDormant) continue;
    bool isClaude = ClaudeSessionService.IsClaudeCommand(s.Command);
    if (isClaude && lastWasClaude && staggerMs > 0)
        await Task.Delay(staggerMs);          // AppSettings.ClaudeLaunchStaggerMs
    try { await LaunchSessionAsync(s, restoring: true); }
    ...
}

AppSettings.ClaudeLaunchStaggerMs defaults to 2000 (Models/AppState.cs:42). With 20 restored Claude sessions that is 19 × 2s = 38 seconds spent asleep, before counting the actual work — and each iteration additionally awaits a full LaunchSessionAsync, which creates a WebView2 and awaits its navigation.

The stagger exists for a real reason (Claude's CLI does an unlocked read-modify-write of ~/.claude.json at startup, so simultaneous boots can corrupt the user's profile). The fix is not to delete it but to stop paying a fixed worst-case delay:

  • Serialize only the narrow ~/.claude.json critical section, not the entire launch. WebView2 creation and navigation for session N+1 can overlap session N's Claude boot.
  • Or gate on an observable signal that the previous Claude finished touching the file (watch ~/.claude.json's last-write, or wait for first PTY byte) instead of a blind 2s.
  • Non-Claude sessions need no stagger at all and could launch fully in parallel.

Shutdown: up to 11s per Claude session, serially

OnClosing (MainWindow.xaml.cs:4865-4872):

foreach (var vm in all)
{
    if (!ClaudeSessionService.IsClaudeCommand(vm.Command)) continue;
    await DisposeAndWaitForExitAsync(vm, timeoutMs: 10000);
    if (postExitMs > 0) await Task.Delay(Math.Min(postExitMs, 1000));
}

Sequential, with a 10s cap per session plus up to 1s post-exit pause per session. 20 Claude sessions is 20–220s worst case; even a fast 1s exit each still costs ~40s. Same root cause (protecting ~/.claude.json), same shape of fix — plus:

  • The 10s cap is per session; a single wedged Claude still stalls everything behind it. Consider an overall shutdown budget after which remaining processes are killed outright.
  • The post-exit Task.Delay is explicitly belt-and-braces ("in case the write continues after the shutdown signal"). If the wait-for-actual-exit is trustworthy, this is redundant; if it isn't, the pause is a guess. Worth resolving which.
  • Non-Claude sessions are already disposed in a parallel-ish loop, but that loop is synchronous vm.Dispose() on the UI thread (line 4852-4856) and runs before the overlay can render anything further.

Suggested measurement first

Log a timestamp per restored/disposed session so the split between "sleeping", "WebView2 init", and "Claude boot/exit" is known before optimizing. It's likely dominated by the fixed delays, but the WebView2 cost per session is not yet quantified.

Related

Activity

  1. AThraen commented on Sep 6, 2026

    @AThraen
    ContributorAuthor

    Status: the shutdown half is done; the startup half is not, and not for the reason above

    Enough has landed since this was filed to be worth restating what's true. The measurement this issue asked for has been done, and it falsifies the "mostly fixed sleeps" framing for startup.

    Shutdown — resolved

    PR What it did
    #100 Exited always fires, so a failed DuplicateHandle can't stall shutdown outright
    #101 Overall shutdown budget across all Claude disposals, plus per-session timing logs — the "consider an overall budget after which remaining processes are killed" ask from this issue
    #110 Wait for process exit without parking a thread-pool thread per PTY
    #115 The blocker. DisposeAndWaitForExitAsync guarded on IsRunning, which is _hProcess != IntPtr.Zero and released only in Dispose — it does not mean the child is alive. A pane whose Claude had already exited subscribed to Exited after it had fired and waited the full 10s. Two stale panes consumed the entire budget and every remaining live session was then force-disposed with no exit wait. Added PseudoTerminal.HasExited, latched before Exited is raised
    #117 Flat post-exit pause, and the budget resized from measurement
    #120 The shutdown overlay is actually visible now — it was being composited behind every terminal pane (HwndHost airspace), so force-disposals had been happening silently

    On the specific question this issue raised — "the post-exit Task.Delay is belt-and-braces; if the wait-for-exit is trustworthy it's redundant, if it isn't the pause is a guess. Worth resolving which." — resolved: the wait is now trustworthy (max observed exit=4656ms, nothing near the 10s cap), and the pause stays but is flat. The adaptive version of it logged cfgSettle=8731ms against a 1000ms cap — 56% of the whole budget in one session, which is what forced the rest of the fleet to be killed.

    The 15s budget was also sized from the wrong data: idle sessions exiting in 460–770ms. Busy ones measure 2.3–4.7s each, so it's now 30s. Recomputed on a real run: 8969ms instead of 15506ms, nothing force-disposed.

    Startup — still open, but this issue's diagnosis is wrong

    The adaptive gate proposed here was built (#96), moved off the UI thread (#107), and reverted (#111). It could never hold its cap: measured on a real restore it produced gates of 12574ms, 22953ms and 31378ms against a 2000ms cap. The reason is worth keeping — the restore loop periodically stalls for seconds at a time under load, and any timer's continuation absorbs that stall. After the revert, a plain Task.Delay(2000) still logged gate=36339ms. The gate was never slow; it was a stopwatch measuring someone else's freeze.

    So the flat 2s stagger is back, and ClaudeLaunchStaggerMs still defaults to 2000.

    But the instrumentation this issue asked for now exists (RESTORE '<name>': gate=…ms launch=…ms total=…ms in crash.log), and it says the sleeps are not the cost:

    • A 25-session restore runs ~131s
    • Per-session launch cost swings 12× — 0.6s to 10.9s
    • The gate column is now usually small

    Whatever remains is WebView2 create + navigate + PTY spawn, executed strictly sequentially. Parallelising the launch is the remaining lever; shortening the gate is not. The suggestion in this issue that still stands is the narrow one: serialize only the ~/.claude.json critical section so session N+1's WebView2 can be created while session N's Claude boots, and let non-Claude sessions launch fully in parallel.

    Partly mitigated in the meantime by #120: the restore rail is determinate (7 / 25 restoring), so a long restore now reads as progress rather than as a hang. That's a UX patch over the latency, not a fix for it.

    Retitling to scope this to startup.

  2. changed the title [-]perf: app startup and shutdown scale badly with live session count (mostly fixed sleeps)[/-] [+]perf: session restore on startup is fully sequential (~131s for 25 sessions; launch cost, not sleeps)[/+] on Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions