Skip to content

A sub-millisecond PollingInterval still spins a core, and one over ~24.8 days faults the loop after the first run #65

Description

@matt-edmondson

What's wrong

Start() rejects PollingInterval <= TimeSpan.Zero (IntervalAction/IntervalAction.cs:110), and the comment above that check says zero is rejected because it "would spin a core". The polling loop then calls await Task.Delay(PollingInterval) (IntervalAction.cs:201), and Task.Delay(TimeSpan) has two limits the check doesn't cover:

  1. It truncates to whole milliseconds. A positive interval under 1 ms, such as TimeSpan.FromTicks(1) or TimeSpan.FromMicroseconds(500), becomes Task.Delay(0). That returns a task that has already completed, so the await continues synchronously and the while loop never yields. This is the same core-spinning case that the zero check was added to prevent (cc6aaad, Non-positive or infinite PollingInterval silently kills the loop or makes Restart()/Stop() hang forever #53).
  2. It has a maximum. That maximum is int.MaxValue ms (about 24.8 days) on netstandard2.0/2.1 and uint.MaxValue - 1 ms (about 49.7 days) on .NET 5 and later. A larger PollingInterval passes Start() and the action runs once. After that, Task.Delay throws ArgumentOutOfRangeException, the polling task faults, and the action never runs again.

Repro (net10.0)

static double CpuFor(TimeSpan polling) {
    var p = Process.GetCurrentProcess(); p.Refresh(); var before = p.TotalProcessorTime;
    var ia = IntervalAction.Start(new() { Action = () => { }, ActionInterval = TimeSpan.FromHours(1), PollingInterval = polling });
    Thread.Sleep(2000); ia.Stop(); Thread.Sleep(100); p.Refresh();
    return (p.TotalProcessorTime - before).TotalMilliseconds;
}
Console.WriteLine(CpuFor(TimeSpan.FromMilliseconds(1)));   // ~223 ms CPU over 2 s
Console.WriteLine(CpuFor(TimeSpan.FromMicroseconds(500))); // ~851 ms CPU: busy loop
Console.WriteLine(CpuFor(TimeSpan.FromTicks(1)));          // ~830 ms CPU: busy loop
Console.WriteLine(Task.Delay(TimeSpan.FromTicks(1)).IsCompleted); // True

int runs = 0;
var big = IntervalAction.Start(new() { Action = () => Interlocked.Increment(ref runs), PollingInterval = TimeSpan.FromDays(60) });
Thread.Sleep(500);
Console.WriteLine(runs);   // 1
big.RethrowExceptions();   // ArgumentOutOfRangeException (Parameter 'delay')

For comparison, a bare while (true) loop in the same throttled sandbox used about 1250 ms of CPU over 2 s.

Why it matters

A caller who asks for a fine-grained interval, for example FromMicroseconds(100) for a tight poll, gets a hot loop instead of an error. A caller who asks for a long interval, for example a monthly check, gets exactly one run and then silence, and only finds out if they call RethrowExceptions().

Suggested fix

In Start(), validate against the range Task.Delay can actually honor on every target framework:

  • Reject PollingInterval < TimeSpan.FromMilliseconds(1) with the same ArgumentOutOfRangeException used for zero, or round it up to 1 ms.
  • Reject PollingInterval.TotalMilliseconds > int.MaxValue, which is the limit common to all targets including netstandard, or clamp it and loop the delay.
  • Add tests for TimeSpan.FromTicks(1) and TimeSpan.FromDays(60), and update the comment above the check.

Related but distinct: #64 (Restart blocks for a full PollingInterval).

Activity

  1. matt-edmondson commented on Sep 27, 2026

    @matt-edmondson
    ContributorAuthor

    Triage


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't workingreadyFully specified; implement as written

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions