Repository navigation
[AI-1707] Make daemon shutdown crash-free: idempotent dispose + contained teardown - #438
Conversation
…rowing The DI container tracks the ServerConnection singleton AND DaemonRunner disposes it explicitly, so DisposeAsync runs twice by construction on every shutdown. The second pass called CancelAsync on the already- disposed terminal-sender CTS; the ObjectDisposedException escaped into DI teardown and, under NativeAOT, aborted the process (SIGABRT). - Interlocked run-once guard (separate from the _disposed live-path flag) - faultable awaits contained + logged per step; mandatory resource release (cts/httpClient/hub) in a finally, each step guarded - durable test seams: body-entry counter, CTS exposure, faulting-task injection — so guard or Dispose removal fails the suite permanently - production-shape regression test: explicit dispose then host stop + dispose over a DI container tracking the same instance Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DaemonRunner's finally-block ran five teardown steps bare; any one throwing (e.g. a disposal ObjectDisposedException) skipped every later step and escaped into the NativeAOT unhandled path, which aborts the process (SIGABRT) — and a skipped host-dispose leaves the DI-owned UnixSpawnerThread's foreground thread parked forever. - RunTeardownAsync coordinator: each named step contained + logged, order preserved (explicit-dispose-before-host-stop is load-bearing), returns whether all steps succeeded - partial failure logs a summary line; exit code deliberately unchanged (the process was already exiting — a non-zero exit would make supervisors relaunch a daemon the user just stopped) - documented limitation: the coordinator cannot make the DI container's own internal dispose walk resilient, plus the structural double-teardown sweep (DaemonLock, RestartCoordinator, LocalControlServer) and why each shape is safe today Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… its shutdown CTS Identical structural double-teardown to ServerConnection: the DI container tracks the singleton AND DaemonRunner disposes it explicitly. The whole teardown body silently re-ran on pass 2, and _shutdownCts was never disposed — adding that Dispose without a guard would have recreated the ServerConnection crash exactly (proven by an up-front mutant check before implementing). - Interlocked run-once guard; _shutdownCts stays readonly (no null-swap) and is disposed LAST, in a guaranteed finally, after all token-dependent cleanup (agent-teardown loop, lane drain, timers) - cancel is ObjectDisposedException-guarded; faultable awaits (lane drain, per-agent cleanup) individually contained + logged; timer/processor/cts release each guarded in the finally - durable test seams (body-entry counter, shutdown-CTS exposure) so guard or Dispose removal fails the suite permanently Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…await Found during the SIGTERM E2E check of the dispose-idempotence work: a daemon signalled while its initial ConnectAsync retry loop was still running (server unreachable) let the resulting TaskCanceledException escape RunAsync — the only catch was the nameInUse arm — and reach Main, where NativeAOT aborts (SIGABRT + a kcap-daemon .ips crash report) even though the teardown finally had completed cleanly. Catch OperationCanceledException on the startup/wait block, gated on ApplicationStopping actually being requested (a cancellation with no shutdown requested still propagates), log it, and fall through to the normal exit-code selection. Verified on the published native binary: SIGTERM during connect-retry now logs 'Shutdown requested during startup', completes cleanup, and leaves no crash report. Also: the orchestrator/connection teardown-step lambdas become method groups (IDE0200 raised by the Release publish). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR Summary by QodoMake daemon shutdown crash-free with idempotent disposals and resilient teardown
AI Description
Diagram
High-Level Assessment
Files changed (6)
|
Code Review by Qodo
1.
|
…dispose Review finding (codex #438): _shutdownCts.CancelAsync() was only ObjectDisposedException-guarded, but a registered cancellation callback that throws faults the returned task (AggregateException per the CTS contract) even though the cancel succeeded. Uncontained, that jumped to the dispose finally — skipping the processor drain and ALL child termination/cleanup — and the run-once guard meant the DI pass could never retry, stranding live child processes. Catch and log all non-ODE cancel faults and continue teardown. Regression test registers a throwing callback and asserts the child was still terminated and the CTS still ended cancelled + disposed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…the spawner explicitly Review findings (codex + qodo on PR #438): - host.StartAsync sat outside the cancellation catch and unified teardown: SIGTERM while a hosted service was starting threw TaskCanceledException to Main without disposing the host or daemon lock (dotnet/runtime#111013 behavior) — a remaining shutdown-SIGABRT window. The whole start-to-wait lifecycle now runs inside RunGuardedStartupAsync (cancellation-while-stopping contained, ALWAYS tears down, early-exit codes preserved); the orchestrator local is nullable and its teardown step null-safe for the not-yet-resolved case. - The order-pin test used its own hard-coded step list, so production divergence stayed green. BuildDaemonTeardownSteps + RunDaemonTeardownAsync are now the single production step list RunAsync executes, and the sequencing tests drive that wrapper with spies. - A DI-tracked singleton throwing mid-walk during host dispose aborts the container's internal walk, and a skipped UnixSpawnerThread.Dispose strands its FOREGROUND thread — trading the abort for a hang. A dedicated spawner-retire step now runs before host-stop/host-dispose, and UnixSpawnerThread.Dispose gets a run-once guard (the explicit retire + the DI walk now double-dispose it by construction; the second pass previously hit CompleteAdding on the disposed queue). - Sweep comment corrected: Microsoft DI disposes once per DESCRIPTOR, not per instance — RestartCoordinator/LocalControlServer/ LocalPermissionBridge (dual-descriptor registrations) are each walked twice; LocalPermissionBridge documents and guards exactly this. New tests: six-step order + per-position fault containment driven through the production wrapper, null-orchestrator teardown, throwing DI disposable cannot strand the spawner thread, cancellation during host start is contained with full teardown, cancellation without a shutdown request still propagates, spawner dispose idempotence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
NO FINDINGS |
Problem
Every daemon shutdown on 0.11.10 ends in SIGABRT:
ObjectDisposedExceptionfromServerConnection.DisposeAsyncsurfacing through DI host teardown into the NativeAOT unhandled path. Four crash reports on 2026-08-02 alone; each abort strands every disposable ordered afterServerConnectionin the DI walk and stretched supervisor relaunch gaps to ~2 minutes (windows ofno_daemon_availableflow failures). Same defect class as AI-1624 (#419) — this is the unswept sibling: #419 is an ancestor of v0.11.10 but was scoped toLocalPermissionBridgeonly.Root cause
DaemonRunnerdisposes the sameServerConnectionsingleton twice by construction — an explicitconnection.DisposeAsync()in thefinally, thenDisposeHostAsync(host)walking the container's tracked disposables.DisposeAsynchad no run-once guard, disposed_terminalSenderCtswithout nulling it, and the second pass cancelled the disposed linked CTS. Nothing on the teardown path catches, so the throw aborts the process.Fix (per the reviewed spec/plan on AI-1707)
ServerConnection.DisposeAsync:Interlockedrun-once guard; per-resource containment for faultable awaits; mandatory release of CTS/HttpClient/hub infinally, each step individually guarded; ODE-guarded cancel.DaemonRunner: teardown steps run through a newRunTeardownAsynccoordinator — a throw in any step is logged with the step name and later steps still run, order pinned by test (daemon-lock → orchestrator → server-connection → host-stop → host-dispose). DI-walk limitation + the double-teardown sweep rationale documented in code.AgentOrchestrator.DisposeAsync(sweep item): same run-once + containment treatment, and its_shutdownCts— previously never disposed at all — is now disposed in a guaranteedfinallyafter all token-dependent cleanup (staysreadonly).ConnectAsyncretry loop letTaskCanceledExceptionescapeRunAsync→ a second, pre-existing shutdown SIGABRT. Now caught whenApplicationStoppinghas fired (unrelated cancellations still propagate).Tests
New durable-contract suites (not just no-throw): body-ran-once counters, CTS ends cancelled+disposed, faulting-await containment with later-cleanup assertion, the exact production shape (explicit dispose → host stop → host dispose over a container-tracked instance), order-pinning + throw-at-each-position coordinator tests, and the up-front vacuous-test check on AgentOrchestrator required by the spec review. Targeted classes all green (ServerConnectionDisposeTests 5/5, DaemonHostDisposalTests 8/8, ConnectWithRetryTests 7/7, LocalPermissionBridgeTests 46/46, AgentOrchestratorVendorTests 166/166). A pre-directive full unit run showed zero new failures vs a detached-origin/main baseline (47 ⊆ 50 known pre-existing macOS failures). Full suites delegated to CI per Tony. AOT publish (Release, osx-arm64, both csprojs) clean.
E2E on published binaries with isolated config dir: SIGTERM →
Cleanup: completed, daemon exiting, noUnhandled exceptionbanner, no new.ips. (Note for reviewers:kcap daemon stopSIGKILLs the tree, so it never exercises dispose — direct SIGTERM does.)Spec + plan + codex spec-review findings: https://linear.app/kurrent/issue/AI-1707
🤖 Generated with Claude Code