Skip to content

[AI-1707] Make daemon shutdown crash-free: idempotent dispose + contained teardown - #438

Merged
realtonyyoung merged 6 commits into
mainfrom
tonyyoung/ai-1707-daemon-dispose-idempotence
Aug 3, 2026
Merged

realtonyyoung merged 6 commits into
mainfrom
tonyyoung/ai-1707-daemon-dispose-idempotence

Conversation

@realtonyyoung

Copy link
Copy Markdown
Collaborator

Problem

Every daemon shutdown on 0.11.10 ends in SIGABRT: ObjectDisposedException from ServerConnection.DisposeAsync surfacing through DI host teardown into the NativeAOT unhandled path. Four crash reports on 2026-08-02 alone; each abort strands every disposable ordered after ServerConnection in the DI walk and stretched supervisor relaunch gaps to ~2 minutes (windows of no_daemon_available flow failures). Same defect class as AI-1624 (#419) — this is the unswept sibling: #419 is an ancestor of v0.11.10 but was scoped to LocalPermissionBridge only.

Root cause

DaemonRunner disposes the same ServerConnection singleton twice by construction — an explicit connection.DisposeAsync() in the finally, then DisposeHostAsync(host) walking the container's tracked disposables. DisposeAsync had no run-once guard, disposed _terminalSenderCts without 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: Interlocked run-once guard; per-resource containment for faultable awaits; mandatory release of CTS/HttpClient/hub in finally, each step individually guarded; ODE-guarded cancel.
  • DaemonRunner: teardown steps run through a new RunTeardownAsync coordinator — 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 guaranteed finally after all token-dependent cleanup (stays readonly).
  • Bonus defect found by the SIGTERM E2E: SIGTERM during the initial ConnectAsync retry loop let TaskCanceledException escape RunAsync → a second, pre-existing shutdown SIGABRT. Now caught when ApplicationStopping has 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, no Unhandled exception banner, no new .ips. (Note for reviewers: kcap daemon stop SIGKILLs 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

realtonyyoung and others added 4 commits August 2, 2026 22:31
…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>
@linear-code

linear-code Bot commented Aug 3, 2026

Copy link
Copy Markdown

AI-1707

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Make daemon shutdown crash-free with idempotent disposals and resilient teardown

🐞 Bug fix 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Make ServerConnection and AgentOrchestrator DisposeAsync run-once and non-throwing under
 double-dispose.
• Add a teardown coordinator so shutdown steps always run, with step-level failure logging.
• Add regression tests for production-shaped DI re-dispose, teardown ordering, and cancellation
 during startup.
Diagram

graph TD
  DR["DaemonRunner (finally)"] --> RT["RunTeardownAsync"] --> DL["DaemonLock.Dispose"] --> AO["AgentOrchestrator.DisposeAsync"] --> SC["ServerConnection.DisposeAsync"] --> HS["Host.StopAsync"] --> HD["Host.DisposeAsync"] --> DI["DI dispose walk"]
  DI --> AO
  DI --> SC
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Remove explicit singleton disposals; rely only on host disposal
  • ➕ Avoids structural double-dispose entirely
  • ➕ Less bespoke shutdown code in DaemonRunner
  • ➖ May lose the intentionally pinned teardown order (explicit dispose before host stop) relied on for deterministic retirement/cleanup
  • ➖ Still requires every tracked singleton disposal to be non-throwing to prevent partial DI disposal/hangs
2. Stop DI from tracking these singletons (custom factory/ownership)
  • ➕ Eliminates the DI re-dispose pass, reducing surface area for teardown bugs
  • ➖ Non-idiomatic in Microsoft.Extensions.DependencyInjection; increases lifecycle complexity and risk of leaks
  • ➖ Requires careful auditing of all registrations/hosted-service patterns to preserve stop/dispose semantics
3. Global catch around the finally cleanup block
  • ➕ Prevents NativeAOT aborts from teardown exceptions with minimal refactoring
  • ➖ Does not ensure later teardown steps run after an early failure unless steps are individually contained
  • ➖ Can mask resource leaks (e.g., host not disposed) and still leave foreground threads alive

Recommendation: Keep the PR’s approach: a step-level teardown coordinator plus run-once, non-throwing disposals on DI-tracked singletons. This directly addresses the root cause (structural double-dispose + uncontained teardown exceptions) while preserving the required shutdown ordering and ensuring host disposal still occurs even when individual cleanup steps fail.

Files changed (6) +538 / -60

Bug fix (3) +265 / -60
DaemonRunner.csContain startup cancellations and coordinate shutdown teardown steps +80/-5

Contain startup cancellations and coordinate shutdown teardown steps

• Swallows OperationCanceledException during startup when ApplicationStopping is requested to prevent NativeAOT aborts on cooperative shutdown. Replaces direct finally teardown calls with RunTeardownAsync, ensuring each named step is logged/contained and later steps still run, without changing exit codes on cleanup failures.

src/Capacitor.Cli.Daemon/DaemonRunner.cs

AgentOrchestrator.csMake AgentOrchestrator.DisposeAsync idempotent and failure-contained +99/-38

Make AgentOrchestrator.DisposeAsync idempotent and failure-contained

• Adds an Interlocked run-once guard and durable test seams (body-run counter, shutdown CTS exposure). Refactors DisposeAsync into contained sub-steps with mandatory cleanup in a finally, and disposes the shutdown CTS after token-dependent teardown completes.

src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

ServerConnection.csMake ServerConnection.DisposeAsync run-once with per-step containment and mandatory release +86/-17

Make ServerConnection.DisposeAsync run-once with per-step containment and mandatory release

• Adds an Interlocked run-once guard plus test seams (body-run counter, CTS exposure, injectable awaited task). Wraps faultable awaits and cancellation in per-step try/catch logging, and guarantees CTS/HttpClient/hub release in a guarded finally to prevent DI-teardown crashes under double-dispose.

src/Capacitor.Cli.Daemon/Services/ServerConnection.cs

Tests (3) +273 / -0
AgentOrchestratorDisposeTests.csAdd idempotent-dispose contract tests for AgentOrchestrator +63/-0

Add idempotent-dispose contract tests for AgentOrchestrator

• Introduces tests that assert DisposeAsync can be invoked twice (explicit + DI walk) without throwing, does not re-enter the body, and leaves the shutdown CTS cancelled and disposed. Uses durable assertions to prevent vacuous pass conditions.

test/Capacitor.Cli.Tests.Unit/AgentOrchestratorDisposeTests.cs

DaemonHostDisposalTests.csTest teardown coordinator ordering and containment behavior +76/-0

Test teardown coordinator ordering and containment behavior

• Extends existing host-disposal regression suite with tests for DaemonRunner.RunTeardownAsync. Pins the production step order and verifies a failure in any position is logged and does not prevent later steps from executing.

test/Capacitor.Cli.Tests.Unit/Daemon/DaemonHostDisposalTests.cs

ServerConnectionDisposeTests.csAdd production-shaped regression tests for ServerConnection double-dispose and containment +134/-0

Add production-shaped regression tests for ServerConnection double-dispose and containment

• Adds a focused suite verifying DisposeAsync is idempotent across explicit and DI-driven re-dispose, cancels/disposes the terminal-sender CTS, and logs/contains failures from awaited pipeline tasks while still performing later cleanup. Includes an integration-style test reproducing the real host/DI disposal shape.

test/Capacitor.Cli.Tests.Unit/Daemon/ServerConnectionDisposeTests.cs

@qodo-code-review

qodo-code-review Bot commented Aug 3, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Orchestrator cancel not contained ✓ Resolved 🐞 Bug ☼ Reliability
Description
AgentOrchestrator.DisposeAsync only catches ObjectDisposedException around
_shutdownCts.CancelAsync(); any other CancelAsync failure can escape DisposeAsync and skip the
subsequent agent termination/cleanup loops while the run-once guard prevents a retry.
Code

src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs[R3107-3110]

            try {
-                await agent.ReadCts.CancelAsync();
-                await agent.Runtime.TerminateAsync(TimeSpan.FromSeconds(5));
-            } catch {
-                /* best-effort */
+                await _shutdownCts.CancelAsync();
+            } catch (ObjectDisposedException) {
+                // Already torn down elsewhere — nothing left to cancel.
Evidence
The dispose method explicitly states faultable awaits must be contained, yet
_shutdownCts.CancelAsync() is only ODE-guarded, so other exceptions would escape and skip later
cleanup. The same class already treats other CancelAsync calls as best-effort (broad catch),
indicating cancellation is considered faultable on teardown paths.

src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs[3089-3142]
src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs[1862-1865]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`AgentOrchestrator.DisposeAsync()` intends to be non-throwing, but `_shutdownCts.CancelAsync()` is only guarded for `ObjectDisposedException`. If `CancelAsync` faults for any other reason, the exception escapes the method and prevents the later agent-teardown loops from running; because `_disposeOnce` is already set, DI’s second dispose pass cannot re-enter to finish cleanup.

### Issue Context
Other `CancelAsync` sites in the orchestrator are treated as best-effort and fully contained, but the top-level shutdown CTS cancellation is not.

### Fix Focus Areas
- src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs[3094-3142]

### Suggested fix
Wrap `_shutdownCts.CancelAsync()` in a broad `catch (Exception ex)` (keeping the ODE comment if desired), log via `LogDisposeStepFailed(ex, "shutdown-cancel")`, and continue so the rest of teardown (lane drain + agent termination + CleanupAgentAsync loop) still executes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Host dispose failure may hang ✓ Resolved 🐞 Bug ☼ Reliability
Description
RunTeardownAsync swallows exceptions from the host-dispose step; if DisposeHostAsync throws partway
through the DI dispose walk, later disposables (including UnixSpawnerThread) may not be disposed,
and its non-background thread can keep the process alive after shutdown completes.
Code

src/Capacitor.Cli.Daemon/DaemonRunner.cs[R629-632]

+                await action();
+            } catch (Exception ex) {
+                allSucceeded = false;
+                LogTeardownStepFailed(logger, ex, name);
Evidence
RunTeardownAsync catches and continues for any step failure, and it is used to invoke
DisposeHostAsync(host) as the final teardown step. UnixSpawnerThread clearly starts a
non-background thread and documents that Dispose() is what retires it; if host disposal aborts
before disposing it, the process may not terminate.

src/Capacitor.Cli.Daemon/DaemonRunner.cs[567-573]
src/Capacitor.Cli.Daemon/DaemonRunner.cs[623-637]
src/Capacitor.Cli.Daemon/Pty/Unix/UnixSpawnerThread.cs[27-43]
src/Capacitor.Cli.Daemon/Pty/Unix/UnixSpawnerThread.cs[103-114]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`DaemonRunner.RunTeardownAsync` intentionally contains step exceptions. However, if the `host-dispose` step throws (e.g., a DI-tracked singleton throws during disposal), the DI disposal walk can abort early and skip later disposables. Because `UnixSpawnerThread` runs a foreground (`IsBackground=false`) thread that is retired in `Dispose()`, a partial/aborted host disposal can leave that thread alive and prevent process exit.

### Issue Context
The PR comments already note the DI-walk limitation (a throwing disposable aborts the internal walk). Containing the `host-dispose` exception prevents a NativeAOT abort, but can trade it for a shutdown hang if the foreground thread wasn’t disposed.

### Fix Focus Areas
- src/Capacitor.Cli.Daemon/DaemonRunner.cs[567-580]
- src/Capacitor.Cli.Daemon/DaemonRunner.cs[623-637]

### Suggested fix
Treat `host-dispose` as a special case:
- Option A: If the `host-dispose` step fails, log and then force termination with a clean exit (e.g., `Environment.Exit(0)`), to avoid a foreground-thread hang.
- Option B: Add an explicit, guarded teardown step to retire critical foreground-thread resources (e.g., make `UnixSpawnerThread.Dispose()` idempotent and dispose it explicitly before `host-dispose`), so even a partial DI walk can’t strand the process.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs
Comment thread src/Capacitor.Cli.Daemon/DaemonRunner.cs
realtonyyoung and others added 2 commits August 2, 2026 23:26
…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>
@realtonyyoung

Copy link
Copy Markdown
Collaborator Author

NO FINDINGS

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.

1 participant