Skip to content

Stop() followed by Restart() leaves the old polling loop alive, so two loops run the action and it can execute concurrently #61

Description

@matt-edmondson

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)

  1. PollingInterval = 200 ms, ActionInterval = 0, and the action increments a counter.
  2. Start it, wait 50 ms, save var old = a.PollingTask;, call a.Stop(); a.Restart();, then wait 1 s.
  3. 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.

Activity

  1. matt-edmondson commented on Sep 27, 2026

    @matt-edmondson
    ContributorAuthor

    Triage

    • Category: Bug
    • Priority: High. An ordinary Stop() then Restart() breaks the class's core guarantee that executions never overlap. It also doubles the firing rate and orphans a loop whose faults RethrowExceptions() never sees. Nothing exotic is needed to hit it. Stop/restart inside one polling interval is a normal pattern, for example when toggling a feature off and on.
    • Area / suggested assignee: IntervalAction.RestartCoreAsync and the polling-loop lifetime, plus TryRun's unlocked ActionTask is null check-then-assign. Owner: @matt-edmondson
    • Duplicates / in progress: not a duplicate. It is closely related to Stop() called while a RestartAsync() is still pending is ignored, so the action keeps running after the caller stopped it #57, where a Stop() arrives while RestartAsync() is still pending. The issue explains the difference, but both come from ShouldPoll being a shared flag rather than a per-loop generation. A per-loop CancellationToken or generation counter would fix both, so consider one PR for the pair. Open PR Keep the action's stack trace when RethrowExceptions rethrows [patch] #62 (preserving the stack trace in RethrowExceptions) does not cover it.
    • Next step: give each polling loop its own CancellationTokenSource, cancelled and awaited on every Stop/Restart regardless of ShouldPoll. Move the ActionTask check-and-assign under Lock. Add both MSTest repros from the issue as regression tests.

    Generated by Claude Code

  2. matt-edmondson commented on Sep 27, 2026

    @matt-edmondson
    ContributorAuthor

    Triage

    This is already fixed on main (7c16ec8), by the v1.4.2 change "wait for a stopped polling loop before a restart starts a new one":

    • Old loop is always stopped: RestartCoreAsync now clears ShouldPoll and awaits the old PollingTask whether or not it was polling. A loop left in its Task.Delay after Stop() therefore ends before the new loop starts.
    • No overlapping runs: TryRun checks and claims ActionTask inside lock (Lock), so two loops can't both start the action.
    • Covered by a test: RestartRightAfterStopLeavesOnlyOnePollingLoop uses this issue's own repro (200 ms interval, stop and restart during the delay). It asserts the old task has completed and that execution stays within a single loop's rate. It passes on main.

    The related race in #57, where a Stop() during a pending RestartAsync() was ignored, is still open and is fixed in #63. Closing this one as completed.


    Generated by Claude Code

  3. removed their assignment
    on Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions