What's wrong
RestartCoreAsync (IntervalAction/IntervalAction.cs ~153-164) only waits for the existing PollingTask when ShouldPoll is currently true:
if (shouldPoll)
{
Stop();
await WaitAndDiscardOutcomeAsync(PollingTask).ConfigureAwait(false);
}
lock (Lock) { ShouldPoll = true; ... }
PollingTask = Task.Run(async () => { ... while (shouldPoll) { TryRun(); await Task.Delay(PollingInterval); /* re-read ShouldPoll */ } });
After a plain Stop(), ShouldPoll is false, but the old loop is usually still inside await Task.Delay(PollingInterval). Restart() then skips the wait, sets ShouldPoll = true and starts a second loop. When the old loop wakes up it re-reads ShouldPoll, finds true, and keeps polling. Nothing holds a reference to it any more, because PollingTask has been overwritten.
The result:
- Two loops both call
TryRun(). Its ActionTask is null check-then-assign is not under the lock, so both can start the action at the same time. That breaks the "no overlapping executions" behaviour the class relies on.
- The action fires about twice as often as
ActionInterval/PollingInterval allow.
- A fault surfaced by the orphaned loop is never seen by
RethrowExceptions(), which only observes PollingTask.
- Each further
Stop()/Restart() inside one polling interval can add another loop.
Repro (reproduced with MSTest)
PollingInterval = 200 ms, ActionInterval = 0, and the action increments a counter.
- Start it, wait 50 ms, save
var old = a.PollingTask;, call a.Stop(); a.Restart();, then wait 1 s.
- Observed:
old.IsCompleted == false and count == 11. Expected: old has finished and count is about 5-6.
A second repro checks for overlap. With PollingInterval = 5 ms and an action that tracks its own concurrency (1 ms sleep inside), run 20 × (Delay(1), Stop(), Restart()) and then wait 3 s. Observed: max concurrent executions is 2. Expected: 1.
This differs from #57 (a Stop() that arrives while a RestartAsync() is still pending). Here the Stop() has completed before an ordinary Restart().
Suggested fix
- In
RestartCoreAsync, always call Stop() and await WaitAndDiscardOutcomeAsync(PollingTask), whatever the current ShouldPoll value is. Alternatively, give each loop its own generation counter or CancellationToken, so a stale loop exits even if ShouldPoll has since been set back to true.
- Add a regression test:
Stop(); Restart(); inside one polling interval must leave the previous PollingTask completed and never run the action concurrently.
What's wrong
RestartCoreAsync(IntervalAction/IntervalAction.cs~153-164) only waits for the existingPollingTaskwhenShouldPollis currentlytrue:After a plain
Stop(),ShouldPollisfalse, but the old loop is usually still insideawait Task.Delay(PollingInterval).Restart()then skips the wait, setsShouldPoll = trueand starts a second loop. When the old loop wakes up it re-readsShouldPoll, findstrue, and keeps polling. Nothing holds a reference to it any more, becausePollingTaskhas been overwritten.The result:
TryRun(). ItsActionTask is nullcheck-then-assign is not under the lock, so both can start the action at the same time. That breaks the "no overlapping executions" behaviour the class relies on.ActionInterval/PollingIntervalallow.RethrowExceptions(), which only observesPollingTask.Stop()/Restart()inside one polling interval can add another loop.Repro (reproduced with MSTest)
PollingInterval = 200 ms,ActionInterval = 0, and the action increments a counter.var old = a.PollingTask;, calla.Stop(); a.Restart();, then wait 1 s.old.IsCompleted == falseandcount == 11. Expected:oldhas finished andcountis about 5-6.A second repro checks for overlap. With
PollingInterval = 5 msand an action that tracks its own concurrency (1 ms sleep inside), run 20 × (Delay(1),Stop(),Restart()) and then wait 3 s. Observed: max concurrent executions is 2. Expected: 1.This differs from #57 (a
Stop()that arrives while aRestartAsync()is still pending). Here theStop()has completed before an ordinaryRestart().Suggested fix
RestartCoreAsync, always callStop()andawait WaitAndDiscardOutcomeAsync(PollingTask), whatever the currentShouldPollvalue is. Alternatively, give each loop its own generation counter orCancellationToken, so a stale loop exits even ifShouldPollhas since been set back totrue.Stop(); Restart();inside one polling interval must leave the previousPollingTaskcompleted and never run the action concurrently.