Skip to content

Schedule the action from a monotonic clock, not the wall clock [patch] - #69

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/monotonic-interval
Sep 29, 2026
Merged

matt-edmondson merged 1 commit into
mainfrom
claude/monotonic-interval

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #54

Setting the system clock backwards made the action stop silently, for as long as the clock had moved.

Cause

TryRun decided whether to run with DateTimeOffset.Now - LastRunTime > ActionInterval, and LastRunTime was wall-clock time. When the clock is set back, for example by an NTP step, a manual change, or a VM resume, the last run appears to be in the future. The difference then stays negative until the clock catches up.

Fix

  • The scheduling decision (HasIntervalElapsed) now measures elapsed time between Stopwatch.GetTimestamp() readings. It no longer reads DateTimeOffset.Now.
  • Each run records both values through a new RecordRun():
    • LastRunTimestamp (new, internal) drives scheduling.
    • LastRunTime stays as wall-clock time, for reporting only.
  • Stopwatch.GetElapsedTime only exists from .NET 7, so a small private GetElapsedTime does the tick conversion for the older targets.
  • No public API change, and no new dependency.

Why not TimeProvider

The triage suggested TimeProvider, which would need Microsoft.Bcl.TimeProvider on the older targets. That would add a dependency and a public injection point, which is a minor-version change. The acceptance criterion needs neither. A clock change moves only the wall-clock value the instance holds, and the test can set that value directly, since LastRunTime is internal and InternalsVisibleTo the tests.

Test

SettingTheWallClockBackDoesNotDelayTheNextRun:

  1. Starts an action with a one-hour polling interval and waits for its first run to finish. From then on, the test drives TryRun itself.
  2. Sets LastRunTime an hour into the future. That is exactly where a clock set back an hour leaves it.
  3. Waits three action intervals and requires TryRun() to start the action.

Results:

  • Against main: the test fails, because TryRun() returns false.
  • With the fix: it passes.
  • Full suite: 21 of 21 passed, over 2 runs.
  • Build: every target builds with 0 warnings and 0 errors.

CLAUDE.md's implementation notes now describe the two fields.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TRHs5nFW38XRh3KGTHYjE6


Generated by Claude Code

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRHs5nFW38XRh3KGTHYjE6
@sonarqubecloud

Copy link
Copy Markdown

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Setting the system clock backwards silently stops the action for that long (interval timed with wall-clock DateTimeOffset.Now)

2 participants