Repository navigation
Stop the daemon reconnect loop spinning against a healthy connection - #375
Conversation
ConnectWithRetryAsync had two compounding defects (#374): 1. A retry iteration always called StartAsync — so when the register step threw after a successful start (or automatic reconnect had meanwhile restored the transport), every subsequent iteration hit "cannot be started if it is not in the Disconnected state" and the loop could never succeed nor exit, logging at Warning every 30s forever against a fully healthy, registered connection. 2. The loop was not serialized: it ran from the initial connect AND from every Closed event, so each disconnect episode could mint another concurrent zombie loop (the incident log shows three interleaved loops racing one hub, one at attempt 1277). The loop is now serialized behind a lock, exits immediately when the connection is already live and registered (IsReady), only calls StartAsync on a hub that is actually Disconnected, and always re-attempts registration — so a register-step failure retries registration on the live hub instead of a doomed restart. Regression tests drive the loop through internal seams (HubState, StartHubAsync, RegisterDaemonAsync) with a fake that mirrors the real HubConnection.StartAsync contract; 5 of the 6 fail against the pre-fix logic (mutation-verified). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR Summary by QodoStop daemon reconnect loop spinning against a healthy SignalR connection
AI Description
Diagram
High-Level Assessment
Files changed (2)
|
Code Review by Qodo
1. Empty retry delays crash
|
| } catch (Exception ex) { | ||
| var delay = ConnectRetryDelays[Math.Min(attempt, ConnectRetryDelays.Length - 1)]; | ||
| LogConnectionAttemptFailed(ex, attempt + 1, delay.TotalSeconds); | ||
| await Task.Delay(delay, ct); | ||
| attempt++; |
There was a problem hiding this comment.
1. Empty retry delays crash 🐞 Bug ☼ Reliability
ConnectWithRetryAsync indexes ConnectRetryDelays without guarding for null/empty; if it’s set to an empty array, the retry handler throws IndexOutOfRangeException and aborts the reconnect loop instead of backing off and retrying. This is newly possible because the delay schedule is now an internal settable property rather than a fixed non-empty local array.
Agent Prompt
### Issue description
`ConnectWithRetryAsync` assumes `ConnectRetryDelays` is non-null and non-empty. Because it’s `internal` and settable, it can be set to an invalid value (e.g., empty array) and then the exception-handling path will throw an indexing exception, terminating the retry loop.
### Issue Context
This happens inside the general exception handler (the path that should be most robust), so a transient connection failure combined with an invalid delay schedule causes a hard failure rather than a retry.
### Fix Focus Areas
- src/Capacitor.Cli.Daemon/Services/ServerConnection.cs[404-465]
### Suggested fix
- Defensively handle `ConnectRetryDelays` being `null` or empty before indexing:
- Either throw an `ArgumentException` in the property setter (and keep the setter), or
- Fall back to a safe default schedule when `ConnectRetryDelays` is `null`/empty, e.g. a single delay of 30s.
- Consider making the setter validate and normalize (e.g., copy the array) to prevent later mutation by callers.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| /// "cannot be started if it is not in the Disconnected state" — forever, at Warning, | ||
| /// every 30s, against a perfectly healthy connection (issue #374). | ||
| /// </summary> | ||
| readonly SemaphoreSlim _connectLock = new(1, 1); |
There was a problem hiding this comment.
2. Connect lock not disposed 🐞 Bug ⚙ Maintainability
ServerConnection now owns a SemaphoreSlim instance for connect serialization, but DisposeAsync never disposes it. While SemaphoreSlim often doesn’t materialize expensive native resources unless its wait handle is used, this still leaves a new owned IDisposable unaccounted for in cleanup.
Agent Prompt
### Issue description
A new `_connectLock` (`SemaphoreSlim`) is introduced and owned by `ServerConnection`, but it isn’t disposed in `DisposeAsync`, leaving lifecycle/ownership unclear.
### Issue Context
`SemaphoreSlim.Dispose()` is usually low-impact, but if you choose to dispose it, do so only once you’re sure no in-flight code will `WaitAsync`/`Release` it.
### Fix Focus Areas
- src/Capacitor.Cli.Daemon/Services/ServerConnection.cs[390-472]
- src/Capacitor.Cli.Daemon/Services/ServerConnection.cs[1166-1188]
### Suggested fix
- Option A (preferred for clarity): dispose `_connectLock` near the end of `DisposeAsync` after you’ve ensured connect operations cannot still be running (e.g., after cancellation/shutdown sequencing).
- Option B: if you intentionally do **not** dispose it to avoid disposal races, add a short comment explaining why (so future maintainers don’t assume it was missed).
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
…tests Review round 1 (PR #375): P1: HubConnection.StopAsync's cancellation token is a dead parameter in the pinned 10.0.8 client — StopAsyncCore waits the connection lock and stops the transport on token: default — so ForceReconnectAsync's 5s cap never actually fired and a wedged transport would hang the single heartbeat tick forever, killing liveness probing for good. The cap is now enforced from outside via WaitAsync, through a new StopHubAsync seam, with a settable ForceStopCap and a regression test that wedges the stop and asserts the call returns at the cap (mutation-verified: passing the token to StopAsync instead fails the test). P2: the reconnecting-state test's fake registered successfully regardless of hub state, so the loop exited on its first iteration and the heal transition was dead code. The fake now mirrors the real hub-invoke contract (registration fails unless Connected), and the test observes two failed attempts deterministically (no timing sleep), asserts the loop is still incomplete, then heals the transport and asserts completion without any start attempt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes #374 — Linear AI-1545.
Root cause
ServerConnection.ConnectWithRetryAsynchad two compounding defects:StartAsync. The try block wasStartAsync→RegisterDaemon, so when registration threw after a successful start (the incident log shows exactly this:DaemonConnectAsync→The 'InvokeCoreAsync' method cannot be called if the connection is not active, i.e. the transport dropped intoReconnectingbetween the two steps), the next iteration calledStartAsyncon a non-Disconnectedhub. That throwsThe HubConnection cannot be started if it is not in the Disconnected state, lands in the generic catch, backs off, and repeats. Once automatic reconnect healed the transport andOnReconnectedre-registered, the daemon was fully healthy — but the loop could never succeed (it never reached the register step again) and never exit: an unkillable Warning every 30s against a live connection. 12,348 occurrences in one daemon log, streaks of 900+ (~7.8h).ConnectAsyncand from everyClosedevent, so each disconnect episode could mint another concurrent zombie loop. The log shows three interleaved loops racing one hub — "Connection attempt 1277", "attempt 3", and "attempt 2" logging within the same second.Fix
ConnectWithRetryAsyncis now:SemaphoreSlim(1,1)— the initial connect and everyClosed-triggered reconnect share one loop; a queued caller that acquires the lock after someone else already healed the connection returns immediately;IsReady(auto-reconnect +OnReconnected, or the heartbeat'sReRegisterAsync, already restored a registered connection), then callsStartAsynconly when the hub is actuallyDisconnected, then always (re-)attemptsRegisterDaemonAsync. A register-step failure now retries registration on the live hub instead of a doomed restart. While the hub isConnecting/Reconnecting, the register attempt fails transiently and the loop backs off — automatic reconnect terminally either restores the connection (loop observesIsReadyand exits) or exhausts toDisconnected(loop starts the hub), so it converges.No behavioural change to name-in-use propagation, cancellation, backoff schedule, or the
OnReconnected/heartbeat re-registration paths.Tests
New
ConnectWithRetryTests(mirrors theAcpServerConnectionTestsfake-subclass approach; new internal seamsHubState/StartHubAsync/RegisterDaemonAsync, andConnectRetryDelaysmade settable so the retry path runs in milliseconds). The fake mirrors the realHubConnection.StartAsynccontract of throwing when notDisconnected:Reconnectinghub → never started; loop exits once the transport heals;Mutation-verified: with the loop temporarily reverted to the pre-fix logic (unconditional
StartAsync, no lock), 5 of the 6 tests fail; all 6 pass against the fix.Verification
Tests.Unitsuite: 3962 passed / 42 failed — the 42 are exactly the pre-existing macOS/tmp-symlink / parallel-load flake families (CodexConfigToml, Uninstall, CodexLauncher, CodexConfigWriter, PluginCodexInstall), none daemon-related.dotnet publish -c Release: no IL3050/IL2026 AOT warnings.🤖 Generated with Claude Code
Review round (codex, clean on round 2)
The codex review flow surfaced a P1 in the adjacent recovery path, fixed in the second commit:
HubConnection.StopAsync's cancellation token is a dead parameter in the pinned 10.0.8 client (StopAsyncCorewaits the connection lock and stops the transport ontoken: default— verified against therelease/10.0source), soForceReconnectAsync's 5s cap never actually fired and a wedged transport would have hung the single heartbeat tick forever, permanently killing liveness probing. The cap is now enforced from outside via.WaitAsync(cts.Token)through a newStopHubAsyncseam with a settableForceStopCap, plus a regression test that wedges the stop and asserts the call returns at the cap (mutation-verified: passing the token inward instead fails the test). The reviewer's P2 also hardened the reconnecting-state test: the fake now fails registration unless the hub is Connected (mirroring the real invoke contract) and the test observes failed attempts deterministically instead of sleeping.