From 032f0959e16ae942aa5bb0841c9a6e30b4a56553 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 6 Sep 2026 09:33:54 +0200 Subject: [PATCH] fix(shutdown): flat post-exit pause and a budget sized from measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems, both found by running it rather than reasoning about it. 1. The adaptive config gate overshoots at shutdown too. #111 reverted it on the launch path but KEPT it here, on my reasoning that "the machine is quiet at shutdown, so polling is reliable". Measurement falsified that. A real run logged: SHUTDOWN 'SiteAnalysis': exit=4656ms cfgSettle=8731ms against a 1000ms cap — 8.7x over, and 56% of the entire shutdown budget spent in one session, which is what forced the remaining six to be killed with no exit wait. Same disease as the launch path: when the thread stalls, the gate measures the stall rather than the file. Now a flat Task.Delay, on both the shutdown loop and the restart path. Recomputing that run flat gives 8969ms instead of 15506ms with nothing force-disposed — the gate's typical ~300ms beats a flat 1000ms right up until it doesn't, and the tail is what costs. ClaudeConfigGate now has no callers and is deleted along with its tests, rather than left as dead code for someone to reintroduce. 2. The 15s budget was sized from the wrong data. It was chosen when the only measurements available were idle sessions exiting in 460-770ms. Busy sessions measure 2.3-4.7s each, so nine of them need roughly 30s, and 15s force-disposed over half the fleet on an ordinary close. Raised to 30s: a clean exit lets Claude finish writing its config, and ShutdownOverlay is already on screen explaining the wait. The budget is there to bound a wedged session, not to hurry a healthy one. Confirmed while here: the HasExited fix from #115 works. No exit= value is near the 10s cap (max observed 4656ms), so nothing is timing out on a process that already died — which was the whole point. CLAUDE.md updated: the gate is gone from both paths, and the budget carries its measurements plus a note to re-measure exit= before shrinking it, since the summary line cannot distinguish slow exits from waits that never return. 304/304 pass (10 fewer — ClaudeConfigGateTests removed), 0 warnings. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NDsEfog5kVkT5NmX1Ya5be --- CLAUDE.md | 5 +- src/CodeShellManager/MainWindow.xaml.cs | 75 +++----- .../Services/ClaudeConfigGate.cs | 115 ------------ .../ClaudeConfigGateTests.cs | 172 ------------------ 4 files changed, 29 insertions(+), 338 deletions(-) delete mode 100644 src/CodeShellManager/Services/ClaudeConfigGate.cs delete mode 100644 tests/CodeShellManager.Tests/ClaudeConfigGateTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 03fd2f1..8094e79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,6 @@ PTY (ConPTY) → PseudoTerminal → TerminalBridge → WebView2 (xterm.js) | `SessionConfigEditor` | Diffs/applies a `SessionConfigDraft` onto a `ShellSession`; decides whether the change needs a PTY restart | | `DbGate` | Serializes every use of the shared `output.db` `SqliteConnection`. That one connection is handed to `SearchService` *and* to every `OutputIndexer`, and it is not thread-safe — concurrent create/dispose corrupts its internal command list. Acquire as `using var _ = await DbGate.AcquireAsync();` at the top of anything touching it. See issue #102 | | `PwshLocator` | Single answer to "pwsh or powershell?", shared by `RunInstance` (run commands) and `PseudoTerminal.BuildCmdLine` (session wrapper) so they can't disagree. Resolves via `where.exe`. An ordinary executable is accepted from metadata alone (no spawn). A **zero-byte reparse point is ambiguous, not bad**: that shape is a Store App Execution Alias, and a *working* Store install of PowerShell 7 looks identical to a stub left by an uninstalled app — so only that case is settled by actually probing execution. Rejecting the shape outright silently downgraded Store-PS7 users to 5.1 on every session launch | -| `ClaudeConfigGate` | Watches Claude's config file settle. **Only used at shutdown.** The same mechanism was tried on the launch path and reverted (#111) — see the Claude launch stagger note below | | `ToastHelper` | Tray balloon notifications | | `SessionRunner` | Per-session owner of `RunInstance` dictionary (run commands runtime) | | `RunInstance` | One headless PTY-backed run with ANSI-stripped output buffer | @@ -164,6 +163,8 @@ The page-side `mousedown` handler also calls `fitAddon.fit()`, and the initial f A Claude restart also **waits for the old process to actually exit** (`DisposeAndWaitForExitAsync` + a config quiesce) before relaunching. Without that it recreates the concurrent-config-writer race the launch stagger and the shutdown loop both exist to prevent, and `--resume` can read a session index the outgoing process hasn't finalised. Non-Claude sessions skip the wait — they don't touch that file. **Waiting for a PTY to exit.** Check `PseudoTerminal.HasExited`, never `IsRunning`. `IsRunning` is `_hProcess != IntPtr.Zero` and the handle is only released in `Dispose`, so it stays true for a child that exited on its own — subscribing to `Exited` for one of those waits out the full timeout for an event that already fired. `HasExited` is latched immediately before `Exited` is raised. This is not academic: combined with `ClaudeShutdownBudgetMs`, two stale panes consumed the entire shutdown budget and every remaining *live* Claude session was then force-disposed with no exit wait — the exact opposite of what the budget was for. + +**`ClaudeShutdownBudgetMs` is sized from measurement (30s).** The original 15s came from the only data available at the time — idle sessions exiting in 460–770ms. Real shutdowns of *busy* sessions measure **2.3–4.7s each**, so nine of them need roughly 30s, and 15s meant force-disposing more than half the fleet on an ordinary close. Waiting is the right trade: a clean exit lets Claude finish writing its config, and `ShutdownOverlay` is already on screen telling the user why. The budget exists to bound a genuinely wedged session, not to hurry a healthy one. If you shrink it, re-measure `exit=` in `crash.log` first — the summary line alone can't distinguish "slow exits" from "waits that aren't returning". 6. On app close: `_vm.SaveStateAsync()` flushes `_sessionManager.Sessions` (live + dormant) to `state.json` (unless `--clean`). ## Editing a Session's Configuration @@ -360,7 +361,7 @@ Persisted in `state.json`. Key settings: **Do not replace this with an adaptive wait again.** That was tried (#96), watched the config file settle instead of sleeping a fixed 2s, and was reverted in #111 after three attempts to make it hold its cap. Measured on a real restore it produced gates of 12574ms, 22953ms and 31378ms against a 2000ms cap. Two follow-ups helped without bounding it: #107 moved it off the UI thread, #110 removed a thread-pool thread that `PseudoTerminal` was parking per PTY. - The reason it could never work is worth recording: 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. The gate is still used at **shutdown**, where the machine is quiet and it measures a consistent ~304ms against the flat 1000ms. + The reason it could never work is worth recording: 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. The gate is now gone from BOTH paths. It was kept at shutdown on the reasoning that "the machine is quiet there, so polling is reliable" — measurement falsified that: a real run logged `cfgSettle=8731ms` against a 1000ms cap, 56% of the entire shutdown budget in one session, which is what forced the rest of the fleet to be killed without a wait. Same disease, same fix: flat delay. - `ShowGitBranch` — show `⎇ branch` in sidebar - `ShowTerminalStatusDot` — show status dot in terminal toolbar - `SidebarActionIconsMode` — `OnHover` (default) / `Always` / `Hidden`. Controls the per-row `➕ 💤 ✕` button stack in the sidebar. `Hidden` collapses the panel and reclaims the horizontal space; `OnHover` keeps the panel laid out (no text shift on hover) but transparent + non-interactive until the row is hovered. Rename / Open in Explorer / Open PowerShell here remain reachable via the right-click context menu in all modes, and the terminal toolbar's `✕` is unconditional. diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 1ba65a7..19264e2 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -4405,10 +4405,10 @@ private async Task RestartSessionAsync(SessionViewModel vm, string? launchedComm // Non-Claude sessions don't touch that file, so they keep the cheap teardown. if (ClaudeSessionService.IsClaudeCommand(launchedCommand ?? session.Command)) { - DateTime? cfgBefore = ClaudeConfigGate.LastWriteUtcOrNull(_claudeConfigPath); await DisposeAndWaitForExitAsync(vm, timeoutMs: 10000); - await WaitForClaudeConfigQuiesceAsync( - cfgBefore, Math.Min(_vm.Settings.ClaudeLaunchStaggerMs, 1000)); + // Flat pause, same as shutdown — the adaptive gate couldn't hold its cap on + // either path. See the shutdown loop for the measurement. + await Task.Delay(Math.Min(_vm.Settings.ClaudeLaunchStaggerMs, 1000)); } else { @@ -5326,24 +5326,28 @@ await Dispatcher.InvokeAsync(() => { }, continue; } - // Baseline before the process is signalled, so the gate below can see the - // shutdown write land. - DateTime? cfgBefore = ClaudeConfigGate.LastWriteUtcOrNull(_claudeConfigPath); - long t0 = shutdownClock.ElapsedMilliseconds; await DisposeAndWaitForExitAsync(vm, timeoutMs: Math.Min(10000, remainingBudget)); long exitMs = shutdownClock.ElapsedMilliseconds - t0; disposed++; // The exit wait above is on the process handle, but Claude's config write can - // still be in flight when the handle closes — hence a post-exit pause. This - // used to be a flat sleep of up to 1s per session (20s across 20 sessions) - // justified as belt-and-braces. Now it waits for the write to actually settle - // and returns as soon as it has, capped at the same 1s so the worst case is - // unchanged (issue #82). + // still be in flight when the handle closes — hence a flat post-exit pause. + // + // This was an adaptive config-watching gate. #111 reverted that on the launch + // path but KEPT it here, on the reasoning that "the machine is quiet at + // shutdown, so polling is reliable". Measurement falsified that: with sessions + // actively dying, shutdown is not quiet either, and a real run logged + // cfgSettle=8731ms against this 1000ms cap — 8.7x over, and 56% of the whole + // shutdown budget spent in one session, which is what forced the rest to be + // killed without a wait. + // + // The gate's typical ~300ms beats a flat 1000ms right up until it doesn't, and + // the tail is what costs. Recomputing that run with a flat pause gives 8969ms + // instead of 15506ms, with nothing force-disposed. Predictable wins. long q0 = shutdownClock.ElapsedMilliseconds; if (postExitMs > 0) - await WaitForClaudeConfigQuiesceAsync(cfgBefore, Math.Min(postExitMs, 1000)); + await Task.Delay(Math.Min(postExitMs, 1000)); // Per-session timing so the exit-vs-config-settle split is known rather than // guessed at. #82 asked for this before optimising further. @@ -5399,13 +5403,6 @@ await Dispatcher.InvokeAsync(() => { }, /// disposes the VM. Used for claude sessions on app close so consecutive /// ~/.claude.json writes can't overlap. /// - /// - /// Claude's config file, resolved once. Honours CLAUDE_CONFIG_DIR — with that set - /// the file lives inside it, not at %USERPROFILE%\.claude.json, and watching the - /// wrong one means always waiting the full cap. - /// - private readonly string _claudeConfigPath = ClaudeConfigGate.ResolveConfigFile(); - /// /// Total time budget for waiting on Claude sessions to exit at shutdown (issue #82). /// @@ -5414,36 +5411,16 @@ await Dispatcher.InvokeAsync(() => { }, /// remaining sessions are disposed without waiting; the job object still kills the /// process tree, we just stop watching. /// - /// 15s is chosen to comfortably cover a normal fleet (measured exits are well under - /// a second each) while capping the pathological case at something a user will sit - /// through. - /// - private const int ClaudeShutdownBudgetMs = 15000; - - /// - /// Blocks until Claude's config file has been written and gone quiet, or - /// elapses. Replaces a flat Task.Delay(staggerMs) - /// between consecutive Claude launches (issue #82). + /// Sized from measurement, not taste. The original 15000 was set when the only data + /// available showed ~460-770ms exits on an idle fleet. Real shutdowns of *busy* + /// sessions measure 2.3-4.7s each, so nine of them need roughly 30s — and 15s meant + /// force-disposing over half the fleet on an ordinary close. + /// + /// Waiting is the right trade here: a clean exit lets Claude finish writing its + /// config, and the shutdown overlay already tells the user what is happening. The + /// budget exists to bound a genuinely wedged session, not to rush a healthy one. /// - private Task WaitForClaudeConfigQuiesceAsync(DateTime? baseline, int capMs) => - // Task.Run is the actual fix for the overshoot, not just tidiness. - // - // This is awaited from the restore loop, which runs on the UI thread. Left there, - // every Task.Delay continuation queues behind whatever the dispatcher is doing — - // and during restore that's creating a WebView2 per session. A "50ms" poll then - // takes seconds, and the file-time reads are synchronous I/O on the same thread. - // Measured on a real restore before this change: gate=22953ms against a 2000ms - // cap, and 63s of a 70s restore spent in here — far worse than the flat 2s stagger - // this replaced. - // - // On the thread pool the timer continuations are prompt and the cap holds. - Task.Run(() => ClaudeConfigGate.WaitForQuiesceAsync( - baseline, - () => ClaudeConfigGate.LastWriteUtcOrNull(_claudeConfigPath), - () => DateTime.UtcNow, - Task.Delay, - TimeSpan.FromMilliseconds(capMs), - ClaudeConfigGate.DefaultQuietFor)); + private const int ClaudeShutdownBudgetMs = 30000; private static async Task DisposeAndWaitForExitAsync(SessionViewModel vm, int timeoutMs) { diff --git a/src/CodeShellManager/Services/ClaudeConfigGate.cs b/src/CodeShellManager/Services/ClaudeConfigGate.cs deleted file mode 100644 index 3c33df5..0000000 --- a/src/CodeShellManager/Services/ClaudeConfigGate.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System; -using System.IO; -using System.Threading.Tasks; - -namespace CodeShellManager.Services; - -/// -/// Spaces out consecutive Claude launches by watching Claude's config file settle, -/// instead of sleeping a fixed -/// between each one (issue #82). -/// -/// Why the stagger exists: the Claude CLI rewrites its config on startup, and two -/// claude.exe processes doing that at once can lose one another's updates. Evidence -/// that this is real, not theoretical — a machine here has two orphaned -/// .claude.json.tmp.<pid>.<hash> files with the same timestamp and -/// different pids, left behind by two writers racing. -/// -/// Why a fixed delay is the wrong shape: it pays the worst case every time. Restoring -/// 20 Claude sessions spent 19 × 2s = 38 seconds purely asleep. Watching the file -/// instead costs whatever it actually takes — usually a few hundred milliseconds — -/// and the cap keeps the worst case exactly where it was. -/// -internal static class ClaudeConfigGate -{ - /// How long the file must stay unchanged before we call it settled. - internal static readonly TimeSpan DefaultQuietFor = TimeSpan.FromMilliseconds(250); - - /// - /// Resolves the config file Claude actually writes. - /// - /// Note the layout differs between the two cases, so this is not just - /// plus a filename: - /// default -> %USERPROFILE%\.claude.json (a *sibling* of ~/.claude) - /// CLAUDE_CONFIG_DIR -> %CLAUDE_CONFIG_DIR%\.claude.json (*inside* it) - /// - /// Getting this wrong means watching a file nobody writes and always waiting the - /// full cap — which is how it behaves today on any machine with the env var set. - /// - internal static string ResolveConfigFile(string? configDir, string userProfile) => - string.IsNullOrWhiteSpace(configDir) - ? Path.Combine(userProfile, ".claude.json") - : Path.Combine(configDir, ".claude.json"); - - /// Live resolution against the current environment. - internal static string ResolveConfigFile() => - ResolveConfigFile( - Environment.GetEnvironmentVariable("CLAUDE_CONFIG_DIR"), - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); - - /// Last-write time of , or null if it isn't there. - internal static DateTime? LastWriteUtcOrNull(string path) - { - try { return File.Exists(path) ? File.GetLastWriteTimeUtc(path) : null; } - catch { return null; } - } - - /// - /// Waits until the config file has changed from and then - /// stayed unchanged for , or until - /// elapses — whichever comes first. - /// - /// If no write is ever observed we wait the full cap, deliberately: that is exactly - /// today's behaviour, so a machine where the file can't be watched is never worse - /// off than before. Take the baseline *before* launching, or a fast write lands - /// before the first poll and looks like no write at all. - /// - /// Clock and I/O are injected so the state machine is unit-testable without real - /// files or real time. - /// - internal static async Task WaitForQuiesceAsync( - DateTime? baseline, - Func lastWriteUtc, - Func utcNow, - Func delayAsync, - TimeSpan cap, - TimeSpan quietFor, - int pollMs = 50) - { - if (cap <= TimeSpan.Zero) return; - - DateTime start = utcNow(); - DateTime? seen = baseline; - DateTime? changedAt = null; - - while (utcNow() - start < cap) - { - // Never sleep past the deadline: ask for at most the remaining budget. - int remaining = (int)(cap - (utcNow() - start)).TotalMilliseconds; - if (remaining <= 0) return; - - await delayAsync(Math.Min(pollMs, remaining)).ConfigureAwait(false); - - // Belt-and-braces. Note this does NOT explain the measured overshoot - // (gate=22953ms against a 2000ms cap): the top-of-loop check already caught - // that on the next pass, so it was ONE starved continuation, not a missed - // deadline. The fix for that is running this off the UI thread — see - // MainWindow.WaitForClaudeConfigQuiesceAsync. Kept because it costs nothing - // and stops a late delay from being followed by yet another poll. - if (utcNow() - start >= cap) return; - - DateTime? current = lastWriteUtc(); - if (current != seen) - { - seen = current; - changedAt = utcNow(); - continue; - } - - // Unchanged since the last poll. Only counts as settled once we've actually - // seen a write — otherwise a file Claude hasn't touched yet would let the - // next launch start immediately, which is the race we're preventing. - if (changedAt is { } t && utcNow() - t >= quietFor) return; - } - } -} diff --git a/tests/CodeShellManager.Tests/ClaudeConfigGateTests.cs b/tests/CodeShellManager.Tests/ClaudeConfigGateTests.cs deleted file mode 100644 index 185ac6c..0000000 --- a/tests/CodeShellManager.Tests/ClaudeConfigGateTests.cs +++ /dev/null @@ -1,172 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using CodeShellManager.Services; -using Xunit; - -namespace CodeShellManager.Tests; - -/// -/// Tests for the adaptive Claude launch gate (issue #82). Clock and file-time reads -/// are injected, so these drive the state machine with a fake clock — no real files, -/// no real waiting. -/// -public class ClaudeConfigGateTests -{ - // ── config file resolution ────────────────────────────────────────────── - - [Fact] - public void ResolveConfigFile_NoEnvVar_IsSiblingOfClaudeDir() - { - // Default layout puts .claude.json NEXT TO ~/.claude, not inside it. - Assert.Equal(@"C:\Users\bob\.claude.json", - ClaudeConfigGate.ResolveConfigFile(null, @"C:\Users\bob")); - } - - [Fact] - public void ResolveConfigFile_WithEnvVar_IsInsideThatDir() - { - Assert.Equal(@"C:\Users\bob\.claude-work\.claude.json", - ClaudeConfigGate.ResolveConfigFile(@"C:\Users\bob\.claude-work", @"C:\Users\bob")); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - public void ResolveConfigFile_BlankEnvVar_FallsBackToProfile(string configDir) - { - Assert.Equal(@"C:\Users\bob\.claude.json", - ClaudeConfigGate.ResolveConfigFile(configDir, @"C:\Users\bob")); - } - - // ── the wait state machine ────────────────────────────────────────────── - - /// Fake clock: every awaited delay advances virtual time instantly. - private sealed class Clock - { - public DateTime Now = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); - public readonly List Waits = new(); - public Task Delay(int ms) { Waits.Add(ms); Now = Now.AddMilliseconds(ms); return Task.CompletedTask; } - public int TotalWaitedMs { get { int t = 0; foreach (var w in Waits) t += w; return t; } } - } - - [Fact] - public async Task Wait_WriteThenQuiet_ReturnsEarlyNotAtTheCap() - { - var clock = new Clock(); - var baseline = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); - DateTime? current = baseline; - int polls = 0; - - await ClaudeConfigGate.WaitForQuiesceAsync( - baseline, - () => { if (++polls == 2) current = baseline.AddSeconds(1); return current; }, - () => clock.Now, - clock.Delay, - cap: TimeSpan.FromMilliseconds(2000), - quietFor: TimeSpan.FromMilliseconds(250), - pollMs: 50); - - // Write seen on poll 2 (~100ms), then 250ms of quiet -> well under the 2000ms cap. - Assert.True(clock.TotalWaitedMs < 2000, - $"expected an early return, waited {clock.TotalWaitedMs}ms"); - Assert.True(clock.TotalWaitedMs >= 250, - $"must observe the full quiet period, waited only {clock.TotalWaitedMs}ms"); - } - - [Fact] - public async Task Wait_NoWriteEverObserved_WaitsTheFullCap() - { - // Deliberate: a machine where the file can't be watched must be no worse off - // than the old fixed delay, never faster-and-racy. - var clock = new Clock(); - var baseline = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); - - await ClaudeConfigGate.WaitForQuiesceAsync( - baseline, - () => baseline, - () => clock.Now, - clock.Delay, - cap: TimeSpan.FromMilliseconds(2000), - quietFor: TimeSpan.FromMilliseconds(250), - pollMs: 50); - - Assert.True(clock.TotalWaitedMs >= 2000, - $"expected the full cap, waited {clock.TotalWaitedMs}ms"); - } - - [Fact] - public async Task Wait_FileKeepsChanging_StillStopsAtTheCap() - { - // A pathological writer must not extend shutdown/startup indefinitely. - var clock = new Clock(); - var baseline = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); - int n = 0; - - await ClaudeConfigGate.WaitForQuiesceAsync( - baseline, - () => baseline.AddMilliseconds(++n * 10), // never settles - () => clock.Now, - clock.Delay, - cap: TimeSpan.FromMilliseconds(1000), - quietFor: TimeSpan.FromMilliseconds(250), - pollMs: 50); - - Assert.InRange(clock.TotalWaitedMs, 1000, 1100); - } - - [Fact] - public async Task Wait_FileAppearsFromNothing_CountsAsAWrite() - { - // First-ever run: no config file at baseline, Claude creates one. - var clock = new Clock(); - DateTime? current = null; - int polls = 0; - - await ClaudeConfigGate.WaitForQuiesceAsync( - baseline: null, - () => { if (++polls == 2) current = clock.Now; return current; }, - () => clock.Now, - clock.Delay, - cap: TimeSpan.FromMilliseconds(2000), - quietFor: TimeSpan.FromMilliseconds(250), - pollMs: 50); - - Assert.True(clock.TotalWaitedMs < 2000, - $"creation should count as a write, waited {clock.TotalWaitedMs}ms"); - } - - [Fact] - public async Task Wait_NeverSleepsPastTheDeadline() - { - // Each individual sleep is clamped to the remaining budget, so the wait can't - // overshoot by a whole poll interval at the tail either. - var clock = new Clock(); - var baseline = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); - - await ClaudeConfigGate.WaitForQuiesceAsync( - baseline, - () => baseline, - () => clock.Now, - clock.Delay, - cap: TimeSpan.FromMilliseconds(175), // deliberately not a multiple of pollMs - quietFor: TimeSpan.FromMilliseconds(250), - pollMs: 50); - - Assert.True(clock.TotalWaitedMs <= 175, - $"slept {clock.TotalWaitedMs}ms against a 175ms cap"); - } - - [Fact] - public async Task Wait_ZeroCap_ReturnsImmediately() - { - // Mirrors the existing `staggerMs > 0` opt-out. - var clock = new Clock(); - - await ClaudeConfigGate.WaitForQuiesceAsync( - null, () => null, () => clock.Now, clock.Delay, - cap: TimeSpan.Zero, quietFor: TimeSpan.FromMilliseconds(250)); - - Assert.Empty(clock.Waits); - } -}