Repository navigation
Description
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.jsoncritical 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.Delayis 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
- perf: coalesce per-session PTY output dispatcher posts to prevent UI starvation #70 — dispatcher starvation. Same scale-with-session-count theme; the git-poll and output-post load described there also applies during the restore loop, so startup and the freeze may compound.
- Terminal display corruption: rendered output overlaps/duplicates mid-session #38 — terminal corruption; also suspected to correlate with stalls.
Activity
fix(terminal): make the pane you click or type in the active session (#…
refactor(pwsh): one PowerShell locator instead of two disagreeing ones (
perf(startup): gate Claude launches on the config file settling, not …
fix(pty): always fire Exited so a failed DuplicateHandle can't stall …
perf(shutdown): bound total shutdown time and log per-session timings (…
AThraen commentedon Sep 6, 2026
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
Exitedalways fires, so a failedDuplicateHandlecan't stall shutdown outrightDisposeAndWaitForExitAsyncguarded onIsRunning, which is_hProcess != IntPtr.Zeroand released only inDispose— it does not mean the child is alive. A pane whose Claude had already exited subscribed toExitedafter 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. AddedPseudoTerminal.HasExited, latched beforeExitedis raisedHwndHostairspace), so force-disposals had been happening silentlyOn the specific question this issue raised — "the post-exit
Task.Delayis 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 observedexit=4656ms, nothing near the 10s cap), and the pause stays but is flat. The adaptive version of it loggedcfgSettle=8731msagainst 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 loggedgate=36339ms. The gate was never slow; it was a stopwatch measuring someone else's freeze.So the flat 2s stagger is back, and
ClaudeLaunchStaggerMsstill defaults to 2000.But the instrumentation this issue asked for now exists (
RESTORE '<name>': gate=…ms launch=…ms total=…msincrash.log), and it says the sleeps are not the cost: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.jsoncritical 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.
[-]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)[/+]