Let Restart resume polling after the action throws [patch] - #50
Conversation
An exception from the user's Action faults the polling loop and leaves ShouldPoll true. RestartAsync then awaited that faulted PollingTask and rethrew the same stale exception before reaching the code that starts a new loop, so one exception killed the instance forever — the documented RethrowExceptions-then-Restart recovery could never complete. A restart now observes the old loop's outcome and discards it, and clears a finished ActionTask so the new loop does not re-observe the same fault on its first tick. An action still running keeps its slot, so the no-overlap guarantee is unchanged. Restarts are also chained through a gate task, so two concurrent RestartAsync calls can no longer each start a polling loop and leave one running unreferenced against the same ActionTask field. Fixes #49 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UscjStBJdW3uHm5NR289DX
|
|
CI is green on Noting the "5 New issues" on the Sonar badge, since the count is more alarming than the contents. All five are the same rule,
Leaving them as they are. The file has 18 such calls and 13 of them predate this PR, all written the same way — Sonar reports only the five because it scores new code. Threading The quality gate passed, with 100% coverage on new code and no security hotspots. Generated by Claude Code |



Fixes #49
The documented recovery from an action that threw is
RethrowExceptions()to observe the failure, thenRestart()to resume. It could not complete.RestartAsync()opened with:ShouldPollis only cleared byStop(), so it is stilltrueafter the loop faults. The restart therefore awaited the already-faultedPollingTaskand rethrew the same stale exception — from insideRestartAsync, before anything started a new loop. One exception inActionkilled the instance permanently; every laterRestart()just replayed it.The fix, which is two things rather than one
1. A restart discards the old loop's outcome instead of awaiting it. A loop that faulted is replaceable exactly like one that ended normally, so the wait goes through a small
WaitAndDiscardOutcomeAsynchelper that observes the exception and moves on. This is the issue's suggested fix.2. A finished
ActionTaskis cleared as part of restarting. This one is not in the issue, and fixing only the first half leaves the bug standing in a different place.TryRunthrows on the tick that observes the fault and leavesActionTaskset — it clears the slot only on the non-faulted path:So the new loop's very first
TryRunre-observes the same faulted task and faults too. The restart would have swapped one dead loop for another, and the mutation test below shows exactly that.Only a task that has already finished is cleared. That distinction is the whole reason the clearing lives in the restart rather than being an unconditional
ActionTask = null: an action still running keeps its slot, so a restart mid-action cannot start a second one. The class's documented "prevents overlapping executions" guarantee is unchanged.The concurrency note from the issue
Also addressed, since it is in the same method.
RestartAsyncreadShouldPoll, stopped, awaited, and assigned — re-takingLockat each step rather than holding it across the awaits, which it cannot do. Two callers could each pass the checks and each start a loop, the second assignment orphaning the first, both then driving the sameActionTaskfield: the overlap the class says it prevents.Restarts are now chained through a
RestartGatetask, so each waits for the previous one before it begins. That serializes them without holding a lock over an await and adds no disposable state to the class — aSemaphoreSlimwould have madeIntervalActionown a disposable and dragged a publicIDisposablesurface in with it, which is a bigger change than this bug is owed.This half is not proven by a test, and I would rather say so than imply otherwise.
ConcurrentRestartsLeaveTheInstancePollingfires eight concurrent restarts and asserts the instance is left polling and still stops cleanly — it exercises the path, but it passes on the old code too, because the only externally visible symptom of the race is a duplicate action invocation that no test can provoke without being timing-dependent. The argument for this part is structural, not empirical. Happy to drop it to a separate issue if you'd rather keep this PR to the fault path.Tests
Three cases in
IntervalActionTests:RestartResumesPollingAfterActionThrowsRethrowExceptions()thenRestartAsync(), and the counter keeps climbing afterwardsRestartDoesNotRethrowTheStaleActionExceptionRestartAsync()itself completes rather than throwing, with an action that goes on throwingConcurrentRestartsLeaveTheInstancePollingConfirmed the tests depend on the change by reverting each half separately and re-running:
await PollingTaskrestored in place of the discardRestartResumesPollingAfterActionThrows,RestartDoesNotRethrowTheStaleActionExceptionActionTaskclearing removedRestartResumesPollingAfterActionThrowsEach half is independently load-bearing, which is why both are here.
Full suite green: 14 total, 0 failed (11 before, 3 added), run three times to check the timing-based cases are not marginal. Release build clean, zero warnings.
RethrowExceptionsThrowsExceptionis unchanged and still passes — an action exception still faults the loop and is still reported once throughRethrowExceptions(). This changes what happens after that, not whether it happens.Not covered
An action that faults while polling is already stopped is not separately tested. It works — the restart clears the finished task the same way — but the failure is never surfaced through
RethrowExceptions()in that window, which is pre-existing behaviour and not something this change should quietly alter.🤖 Generated with Claude Code
https://claude.ai/code/session_01UscjStBJdW3uHm5NR289DX
Generated by Claude Code