Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion IntervalAction.Test/IntervalActionTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) 2023-2026 ktsu-dev contributors
// Copyright (c) 2023-2026 ktsu-dev contributors

[assembly: DoNotParallelize]

Expand Down Expand Up @@ -46,7 +46,7 @@
{
Interlocked.Increment(ref executions);
// Simulate a long running task.
Thread.Sleep(500);

Check warning on line 49 in IntervalAction.Test/IntervalActionTests.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not use 'Thread.Sleep()' in a test.

Check warning on line 49 in IntervalAction.Test/IntervalActionTests.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not use 'Thread.Sleep()' in a test.

Check warning on line 49 in IntervalAction.Test/IntervalActionTests.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not use 'Thread.Sleep()' in a test.

Check warning on line 49 in IntervalAction.Test/IntervalActionTests.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not use 'Thread.Sleep()' in a test.
},
IntervalType = IntervalType.FromLastStart
};
Expand Down Expand Up @@ -132,6 +132,42 @@
Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => IntervalAction.Start(options));
}

[TestMethod]
[DataRow(1L)] // Task.Delay truncates this to zero and spins a core
[DataRow(5_000L)] // 500 microseconds
[DataRow(9_999L)] // just under a millisecond
[DataRow(21_474_836_480_000L)] // one millisecond over int.MaxValue
[DataRow(51_840_000_000_000L)] // 60 days, which Task.Delay rejects after the first run
public void StartPollingIntervalOutsideTaskDelayRangeThrows(long pollingIntervalTicks)
{
IntervalActionOptions options = new()
{
PollingInterval = TimeSpan.FromTicks(pollingIntervalTicks),
ActionInterval = TimeSpan.FromMilliseconds(10),
Action = () => { },
IntervalType = IntervalType.FromLastCompletion
};
Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => IntervalAction.Start(options));
}

[TestMethod]
[DataRow(10_000L)] // exactly one millisecond
[DataRow(21_474_836_470_000L)] // exactly int.MaxValue milliseconds
public void StartPollingIntervalAtTaskDelayLimitsIsAccepted(long pollingIntervalTicks)
{
IntervalAction intervalAction = IntervalAction.Start(new()
{
PollingInterval = TimeSpan.FromTicks(pollingIntervalTicks),
ActionInterval = TimeSpan.FromHours(1),
Action = () => { },
IntervalType = IntervalType.FromLastCompletion
});

Assert.IsTrue(intervalAction.ShouldPoll, "An interval Task.Delay can honor must start polling.");
Assert.IsFalse(intervalAction.PollingTask.IsFaulted, "An interval Task.Delay can honor must not fault the polling loop.");
intervalAction.Stop();
}

[TestMethod]
public async Task RestartStopsPreviousPollingTaskAndStartsNewOne()
{
Expand Down
31 changes: 23 additions & 8 deletions IntervalAction/IntervalAction.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) 2023-2026 ktsu-dev contributors
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.IntervalAction;

Expand Down Expand Up @@ -30,6 +30,16 @@ public class IntervalAction
/// </summary>
internal long? LastRunTimestamp { get; set; }

/// <summary>
/// The shortest polling interval <see cref="Task.Delay(TimeSpan)"/> waits out rather than truncating to zero.
/// </summary>
private static readonly TimeSpan MinimumPollingInterval = TimeSpan.FromMilliseconds(1);

/// <summary>
/// The longest polling interval <see cref="Task.Delay(TimeSpan)"/> accepts on every target framework.
/// </summary>
private static readonly TimeSpan MaximumPollingInterval = TimeSpan.FromMilliseconds(int.MaxValue);

/// <summary>
/// Gets the polling interval for checking the action's status and attempting to start the action.
/// </summary>
Expand Down Expand Up @@ -157,8 +167,9 @@ private IntervalAction() { }
/// Thrown if both <see cref="IntervalActionOptions.Action"/> and <see cref="IntervalActionOptions.AsyncAction"/> are set.
/// </exception>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown if <see cref="IntervalActionOptions.PollingInterval"/> is zero or negative, which includes
/// <see cref="Timeout.InfiniteTimeSpan"/>.
/// Thrown if <see cref="IntervalActionOptions.PollingInterval"/> is shorter than one millisecond, which
/// includes zero, negative values and <see cref="Timeout.InfiniteTimeSpan"/>, or longer than
/// <see cref="int.MaxValue"/> milliseconds (about 24.8 days).
/// </exception>
public static IntervalAction Start(IntervalActionOptions intervalActionOptions)
{
Expand All @@ -175,15 +186,19 @@ public static IntervalAction Start(IntervalActionOptions intervalActionOptions)
nameof(intervalActionOptions));
}

// Rejected here rather than left to Task.Delay in the polling loop: a negative interval would
// fault the loop after the first run, an infinite one would leave Restart() and Stop() waiting
// on a delay that never ends, and zero would spin a core.
if (intervalActionOptions.PollingInterval <= TimeSpan.Zero)
// Rejected here rather than left to Task.Delay in the polling loop, which can only honor whole
// milliseconds up to int.MaxValue on every target. A negative interval would fault the loop
// after the first run, and an infinite one would leave Restart() and Stop() waiting on a delay
// that never ends. Anything under a millisecond, zero included, truncates to Task.Delay(0),
// which completes synchronously and spins a core. Anything over int.MaxValue milliseconds lets
// the action run once and then faults the loop.
if (intervalActionOptions.PollingInterval < MinimumPollingInterval
|| intervalActionOptions.PollingInterval > MaximumPollingInterval)
{
throw new ArgumentOutOfRangeException(
nameof(intervalActionOptions),
intervalActionOptions.PollingInterval,
$"{nameof(IntervalActionOptions.PollingInterval)} must be greater than zero.");
$"{nameof(IntervalActionOptions.PollingInterval)} must be between {MinimumPollingInterval} and {MaximumPollingInterval}.");
}

IntervalAction intervalAction = new()
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ Configuration options for an interval action.
| `Action` | `Action` | The action to execute at intervals (set this or `AsyncAction`) |
| `AsyncAction` | `Func<CancellationToken, Task>` | An asynchronous action to execute at intervals; a run lasts until its task completes, and the token is cancelled by `Stop()` (set this or `Action`) |
| `ActionInterval` | `TimeSpan` | The interval between executions (required) |
| `PollingInterval` | `TimeSpan` | How frequently to check if action should run (optional, default 1 second) |
| `PollingInterval` | `TimeSpan` | How frequently to check if action should run (optional, default 1 second; must be between 1 ms and `int.MaxValue` ms, about 24.8 days) |
| `IntervalType` | `IntervalType` | Determines how intervals are measured (optional, default FromLastCompletion) |

### `IntervalType` Enum
Expand Down
Loading