From 8f626d3c4b9428f8d2b21ce108ca559b0b3f7587 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 22:32:55 +0000 Subject: [PATCH] Schedule the action from a monotonic clock, not the wall clock [patch] TryRun compared DateTimeOffset.Now against the wall-clock time of the last run. Setting the system clock back (an NTP step, a manual change, a VM resume) put that last run in the future, so the action silently stopped for as long as the clock moved. Scheduling now measures elapsed Stopwatch time since the last run. LastRunTime is still recorded as wall-clock time, for reporting only. Fixes #54 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TRHs5nFW38XRh3KGTHYjE6 --- CLAUDE.md | 2 +- IntervalAction.Test/IntervalActionTests.cs | 36 +++++++++++++++ IntervalAction/IntervalAction.cs | 54 ++++++++++++++++++---- 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3ba920d..8549c11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,7 @@ Import-Module ./scripts/PSBuild.psm1 - Actions execute in separate tasks to prevent blocking the polling loop - Exception handling: Exceptions from actions are captured and can be rethrown via `RethrowExceptions()` - The library prevents overlapping executions - if an action is still running when the next interval arrives, it waits until the current action completes -- `LastRunTime` tracks when the action last executed (based on `IntervalType`) +- `LastRunTimestamp` (a `Stopwatch` timestamp) tracks when the action last executed (based on `IntervalType`), and is what scheduling measures from. `LastRunTime` records the same moment as wall-clock time for reporting only: the wall clock can be set back, which would hold the action off for as long as it moved - Polling can be stopped/restarted via `Stop()` and `Restart()`/`RestartAsync()` ## Testing diff --git a/IntervalAction.Test/IntervalActionTests.cs b/IntervalAction.Test/IntervalActionTests.cs index fc95cb8..bb361d5 100644 --- a/IntervalAction.Test/IntervalActionTests.cs +++ b/IntervalAction.Test/IntervalActionTests.cs @@ -485,4 +485,40 @@ public async Task StopIsIdempotent() intervalAction.RethrowExceptions(); } + + [TestMethod] + public async Task SettingTheWallClockBackDoesNotDelayTheNextRun() + { + int counter = 0; + IntervalActionOptions options = new() + { + // Long enough that the loop runs the action once and then leaves TryRun to the test. + PollingInterval = TimeSpan.FromHours(1), + ActionInterval = TimeSpan.FromMilliseconds(50), + Action = () => Interlocked.Increment(ref counter), + IntervalType = IntervalType.FromLastCompletion + }; + + IntervalAction intervalAction = IntervalAction.Start(options); + + // Wait for the first run to finish, so it has recorded when it ran. The loop then sits in its + // hour-long delay, and stopping it now keeps it from ever calling TryRun again. + DateTimeOffset deadline = DateTimeOffset.Now.AddSeconds(10); + while (intervalAction.ActionTask is not { IsCompleted: true } && DateTimeOffset.Now < deadline) + { + await Task.Delay(10).ConfigureAwait(false); + } + + intervalAction.Stop(); + Assert.AreEqual(1, counter, "Expected the first run to have happened."); + + // This is where a wall clock that has just been set back an hour puts the last run: an hour + // ahead of now. Only wall-clock time moves when the clock is set, so this is the whole of what + // a clock change does to the instance. + intervalAction.LastRunTime = DateTimeOffset.Now.AddHours(1); + + await Task.Delay(options.ActionInterval * 3).ConfigureAwait(false); + + Assert.IsTrue(intervalAction.TryRun(), "Expected the action to run once its interval had passed, whatever the wall clock says."); + } } diff --git a/IntervalAction/IntervalAction.cs b/IntervalAction/IntervalAction.cs index 8f014b6..182b9bf 100644 --- a/IntervalAction/IntervalAction.cs +++ b/IntervalAction/IntervalAction.cs @@ -2,6 +2,7 @@ namespace ktsu.IntervalAction; +using System.Diagnostics; using System.Runtime.ExceptionServices; /// @@ -14,10 +15,21 @@ namespace ktsu.IntervalAction; public class IntervalAction { /// - /// Gets or sets the last run time of the action. + /// Gets or sets the last run time of the action, as wall-clock time. /// + /// + /// For reporting only. Scheduling measures from , because the wall + /// clock can be set back, and a last run that appears to lie in the future would hold the action + /// off for as long as the clock moved. + /// internal DateTimeOffset LastRunTime { get; set; } = DateTimeOffset.MinValue; + /// + /// Gets or sets the timestamp of the last run, or + /// if the action has not run yet. + /// + internal long? LastRunTimestamp { get; set; } + /// /// Gets the polling interval for checking the action's status and attempting to start the action. /// @@ -263,26 +275,20 @@ internal bool TryRun() ActionTask = null; } - if (ActionInterval >= TimeSpan.Zero && ActionTask is null && DateTimeOffset.Now - LastRunTime > ActionInterval) + if (ActionInterval >= TimeSpan.Zero && ActionTask is null && HasIntervalElapsed()) { ActionTask = Task.Run(() => { if (IntervalType == IntervalType.FromLastStart) { - lock (Lock) - { - LastRunTime = DateTimeOffset.Now; - } + RecordRun(); } Action(); if (IntervalType == IntervalType.FromLastCompletion) { - lock (Lock) - { - LastRunTime = DateTimeOffset.Now; - } + RecordRun(); } }); @@ -293,6 +299,34 @@ internal bool TryRun() } } + /// + /// Reports whether has passed since the last run, measured on a + /// monotonic clock. Callers hold . + /// + private bool HasIntervalElapsed() => + LastRunTimestamp is not long lastRun || GetElapsedTime(lastRun) > ActionInterval; + + /// + /// Records that the action has just run. + /// + private void RecordRun() + { + lock (Lock) + { + LastRunTime = DateTimeOffset.Now; + LastRunTimestamp = Stopwatch.GetTimestamp(); + } + } + + /// + /// Returns the time elapsed since a timestamp. + /// + /// A value from . + /// The time elapsed since . + /// Stopwatch.GetElapsedTime does this, but only from .NET 7. + private static TimeSpan GetElapsedTime(long startingTimestamp) => + TimeSpan.FromTicks((long)((Stopwatch.GetTimestamp() - startingTimestamp) * ((double)TimeSpan.TicksPerSecond / Stopwatch.Frequency))); + /// /// Rethrows any exceptions that occurred during the polling task. ///