Skip to content

Stagger Claude restart + show restore loading indicators#37

Merged
AThraen merged 1 commit into
feat/per-group-layout-filterfrom
feat/restore-shutdown-staggers
May 13, 2026
Merged

Stagger Claude restart + show restore loading indicators#37
AThraen merged 1 commit into
feat/per-group-layout-filterfrom
feat/restore-shutdown-staggers

Conversation

@mortenaslo
Copy link
Copy Markdown
Contributor

Summary

Fixes the ~/.claude.json corruption error users hit on app close (and occasionally on restore) plus makes restore-on-startup feel responsive. Three changes that work together:

  • AppSettings.ClaudeLaunchStaggerMs (default 2000) — one setting drives the wait between consecutive Claude session starts and shutdowns. The prior hardcoded Task.Delay(2000) only covered the restore path; close-time had no pacing, so two claude.exe processes could rewrite ~/.claude.json simultaneously and corrupt it. Exposed in Settings → Session Settings with help text and a 0-disables footnote.
  • Claude-aware OnClosing — non-Claude sessions still dispose in parallel. For each Claude session, DisposeAndWaitForExitAsync subscribes to the PTY's Exited event, calls vm.Dispose() (which triggers ClosePseudoConsole → child gets the shutdown signal), then awaits actual process exit (capped at 10 s so a stuck child can't hang shutdown). A small post-exit pause acts as belt-and-braces. Waiting on real exit beats a fixed timer because Claude's shutdown can take longer than the configured stagger on slow disks.
  • Restore-time loading indicatorsOnLoaded now stages a muted placeholder sidebar row for every live session up-front, each with a pulsing accent dot and a Launching… subtitle. RebuildSidebarOrder walks SessionManager.Sessions in saved order and resolves each entry to either the live sidebar item (if registered) or the placeholder, so the full list of icons appears the moment the window opens and each row lights up in place as its session finishes booting. Placeholders are dropped automatically once the real entry is registered (or the launch fails).

Why now

User report: app close was producing a JSON parse error in ~/.claude.json with multiple Claude sessions open. Root cause is Claude's unlocked read-modify-write on that file at startup and on graceful exit — the restore-side mitigation already existed (commit 59a7067) but the shutdown side did not.

Files touched

  • src/CodeShellManager/Models/AppState.cs — new setting
  • src/CodeShellManager/Views/SettingsWindow.xaml + .xaml.cs — UI field + clone/load/save wiring
  • src/CodeShellManager/MainWindow.xaml.cs — uses the setting in both stagger spots, sequential close, placeholder infra (_launchingSidebarItems, BuildLaunchingSidebarItem, Resolve in RebuildSidebarOrder, AppendSessionsWithClusters signature updated to carry placeholder items)

Base

Targeted at feat/per-group-layout-filter because that branch is still open in #35 and these changes are stacked on top of it. Will rebase to main once #35 lands.

Test plan

  • Settings: open Settings, change Claude stagger to e.g. 500, Save, reopen — value persists
  • Settings: set stagger to 0, restore + close with multiple Claude sessions — no stagger applied (but ~/.claude.json may corrupt, which is the documented trade-off)
  • Restore: with 3+ saved Claude sessions, launch app — all sidebar icons appear immediately with pulsing dots and Launching… subtitles; each replaces with the real entry in place as it boots
  • Close: with 3+ live Claude sessions, close the app — ~/.claude.json stays valid JSON (parses cleanly) after each subsequent run
  • Close: kill a claude.exe external to the app, then close — shutdown still finishes (10 s timeout fallback)
  • --clean mode: still skips restore (no placeholders shown), still skips state save on close
  • Group filter + InlineHeaders mode: placeholders honor the active group filter and inline group headers during restore

🤖 Generated with Claude Code

Three lifecycle improvements aimed at preventing ~/.claude.json corruption
and making session restore feel responsive.

- AppSettings.ClaudeLaunchStaggerMs (default 2000): a single setting drives
  the wait between consecutive Claude session starts and shutdowns. The
  prior hardcoded 2 s only covered restore-on-startup; close-time had no
  pacing at all, so two claude.exe processes could rewrite ~/.claude.json
  concurrently and corrupt it.

- Claude-aware OnClosing: non-Claude sessions still dispose in parallel.
  For each Claude session the new DisposeAndWaitForExitAsync subscribes to
  the PTY's Exited event, calls Dispose (which triggers ClosePseudoConsole
  and signals the child), then awaits actual process exit (capped at 10 s
  so a stuck child can't hang shutdown). A small post-exit pause acts as
  belt-and-braces. Waiting on the real exit beats a fixed timer because
  claude's shutdown can take longer than the configured stagger on slow
  disks.

- Restore-time loading indicators: OnLoaded now stages a muted placeholder
  sidebar row for every live session up-front, each with a pulsing accent
  dot and a "Launching…" subtitle. RebuildSidebarOrder walks
  SessionManager.Sessions in saved order, picking the live sidebar item if
  registered else the placeholder (Resolve helper), so the full list of
  icons appears the moment the window opens and items light up in place as
  each session finishes booting. Placeholders are dropped automatically
  when the real entry is registered (or the launch fails).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@AThraen AThraen requested a review from Copilot May 13, 2026 13:54
@AThraen AThraen merged commit 886f442 into feat/per-group-layout-filter May 13, 2026
1 check passed
@AThraen AThraen deleted the feat/restore-shutdown-staggers branch May 13, 2026 13:54
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR aims to prevent Claude session collisions that can corrupt the user’s ~/.claude.json by introducing configurable staggering between Claude launches and by serializing Claude shutdown behavior, while also improving perceived responsiveness during restore by showing “Launching…” sidebar placeholders.

Changes:

  • Add AppSettings.ClaudeLaunchStaggerMs (default 2000) and wire it into Settings UI + restore-time launch pacing.
  • Add restore-time sidebar placeholders for launching sessions and weave them into sidebar ordering/filtering logic.
  • Update app shutdown to dispose non-Claude sessions in parallel while handling Claude sessions sequentially with an exit wait + post-exit pause.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
src/CodeShellManager/Models/AppState.cs Adds the new ClaudeLaunchStaggerMs persisted setting.
src/CodeShellManager/Views/SettingsWindow.xaml Adds UI field + help text for configuring the Claude stagger.
src/CodeShellManager/Views/SettingsWindow.xaml.cs Wires the new stagger setting into clone/load/save logic.
src/CodeShellManager/MainWindow.xaml.cs Uses the stagger for restore/worktree launch pacing; adds launching placeholders; updates sidebar rebuild logic; updates shutdown disposal flow for Claude sessions.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 4252 to +4282
protected override async void OnClosing(System.ComponentModel.CancelEventArgs e)
{
_windowStateTimer.Stop();
if (_windowStateReady)
_vm.UpdateWindowState(WindowState, Left, Top, Width, Height);
await _vm.SaveStateAsync();
foreach (var vm in _vm.Sessions.ToList())
vm.Dispose();

var all = _vm.Sessions.ToList();

// Non-Claude sessions don't fight over ~/.claude.json — dispose them in parallel.
foreach (var vm in all)
{
if (!ClaudeSessionService.IsClaudeCommand(vm.Command))
vm.Dispose();
}

// Claude rewrites ~/.claude.json on exit without locking, so two claude.exe
// processes flushing simultaneously can corrupt it. Dispose claude sessions one
// at a time, waiting for each process to *actually exit* before starting the next
// — a fixed time stagger isn't safe because claude's shutdown can take longer
// than the configured delay on slow disks. Cap each wait at 10s so a stuck claude
// doesn't hang application shutdown.
int postExitMs = _vm.Settings.ClaudeLaunchStaggerMs;
foreach (var vm in all)
{
if (!ClaudeSessionService.IsClaudeCommand(vm.Command)) continue;
await DisposeAndWaitForExitAsync(vm, timeoutMs: 10000);
// Small post-exit pause as belt-and-braces in case ~/.claude.json's write
// continues after the parent's shutdown signal but before its handles close.
if (postExitMs > 0) await Task.Delay(Math.Min(postExitMs, 1000));
}
Comment on lines +2655 to +2663
(Border item, SessionViewModel? vm)? Resolve(ShellSession s)
{
if (s.IsDormant) return null;
var liveVm = _vm.Sessions.FirstOrDefault(v => v.Id == s.Id);
if (liveVm != null && _sessionUi.TryGetValue(liveVm.Id, out var ui))
return (ui.sidebarItem, liveVm);
if (_launchingSidebarItems.TryGetValue(s.Id, out var ph))
return (ph, null);
return null;
Comment on lines 145 to +149
_edited.DefaultWorkingFolder = DefaultFolderBox.Text.Trim();
_edited.AutoRestoreSessions = AutoRestoreCheck.IsChecked == true;
_edited.AutoResumeClaude = AutoResumeClaudeCheck.IsChecked == true;
if (int.TryParse(ClaudeLaunchStaggerBox.Text, out int staggerMs) && staggerMs >= 0)
_edited.ClaudeLaunchStaggerMs = staggerMs;
<StackPanel Margin="0,10,0,0">
<TextBlock Text="Claude launch / shutdown stagger (ms)" Style="{StaticResource Label}"/>
<TextBox x:Name="ClaudeLaunchStaggerBox" Width="100" HorizontalAlignment="Left"/>
<TextBlock Text="Delay between consecutive Claude sessions during restore-on-startup and app shutdown. Claude's CLI rewrites ~/.claude.json on startup/exit without locking, so back-to-back Claude processes can corrupt it. 2000 ms is the safe default; set 0 to disable."
AThraen added a commit that referenced this pull request May 13, 2026
Conflicts: OnLoaded restore loop and OnClosing teardown both diverged
from the staggered-shutdown work that landed in #37.

Resolutions:
- OnLoaded: keep main's launching-placeholder + sequential staggered
  launch flow; layer this PR's batched WebView2-access-denied dialog
  on top so a single consolidated message replaces the previous N
  per-session popups. Drop the duplicate dormant-loop tail (main now
  adds dormant entries up-front before launches start).
- OnClosing: keep main's claude-aware sequential disposal and
  DisposeAndWaitForExitAsync wait; layer this PR's try/catch around
  _db.Close()/_db.Dispose() in to swallow the SqliteConnection NRE
  observed during shutdown.

Review fixes (from PR self-review):
- OnClosing was async void with multiple awaits, which WPF doesn't
  wait for. Switched to the standard e.Cancel=true + reclose pattern
  gated by _shutdownComplete so async cleanup actually finishes before
  the window tears down.
- _lastOutputTickMs in TerminalBridge.OnPtyData was read-modify-written
  without a memory barrier. Replaced with Interlocked.Exchange — atomic
  read+write in one call, and gives the JIT a barrier even though the
  PTY read loop is currently single-threaded.
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.

3 participants