Skip to content

fix(actor): convert timer delays and elapsed times with exact integer arithmetic - #260

Merged
dborgards merged 3 commits into
mainfrom
fix/actor-exact-tick-arithmetic
Oct 2, 2026
Merged

dborgards merged 3 commits into
mainfrom
fix/actor-exact-tick-arithmetic

Conversation

@dborgards

@dborgards dborgards commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

What does this change?

Fixes finding A1 of the 2026-09-30 deep review: the actor converted a TimeSpan delay to source ticks and back through a double, which put about one whole millisecond in twelve a tick off in each direction. Real timers shifted by at most one source tick; the virtual clock the tests use matches the reported delay exactly, so a test with a 35 or 43 ms interval failed deterministically with a message that looked like a protocol error.

The conversions now live in one internal helper, TickMath (decimal arithmetic, exact here; up for a delay or remaining time, down for an elapsed time), and replace the double arithmetic in every package that had its own copy: actor, UDS response windows, ISO-TP functional client and listener, J1939 node, CANopen PDO inhibit time.

Type of change

  • feat — new behaviour (minor release)
  • fix / perf — bug or performance fix (patch release)
  • docs / test / refactor / chore / ci — no release
  • Breaking change (! in the title, plus a BREAKING CHANGE: footer explaining the migration)

Checklist

  • dotnet build CanKit.Pro.sln -c Release succeeds (with -p:CI=true)
  • dotnet test CanKit.Pro.sln -c Release passes (net10.0, --no-build; the net48 leg runs in CI only)
  • Public API changes are documented with XML comments — none
  • New behaviour is covered by a test — over every millisecond up to 2 s at both Stopwatch frequencies; fails on the old code
  • The requirement or ADR this relates to is referenced — FR-RAW-022 (timers)

Also run: dotnet format --verify-no-changes, dotnet pack + eng/verify-packages.py.

Notes

  • Why the diff reaches beyond the actor: the first push was actor-only and went red on the Windows net48 leg in UdsClientTests.A_Pending_Answer_Still_On_Its_Way_Through_The_Channel_Extends_The_Window. On .NET Framework TimeSpan.FromSeconds(double) rounds to whole milliseconds, which had hidden that the UDS client counted its 100 ms window as 999 999 ticks; the now-exact actor made it visible. That failure is caused by this branch, so the same conversion is fixed everywhere in this PR rather than in a follow-up.
  • ManualTimeSource takes an optional frequency (default unchanged), so the tests can run at 10^7 and 10^9.
  • Two review nits in the same test files: the real-time delay test asserted 150 ms for a 200 ms timer (a 0.8x due time passed it; it now fails), and the clean-dispose test gated on a 500 ms wall-clock join.

🤖 Generated with Claude Code

DueTimestamp multiplied delay.TotalSeconds by the source frequency in a double and took
the ceiling; for about one whole millisecond in twelve (35, 70, 85, 101 ... ms) the
product came out a tick above the integer and the ceiling added a tick, so a timer became
due a tick late. NextTimerDelayAsync converted back through TimeSpan.FromSeconds(double),
which truncates on current runtimes and lost a tick for about one millisecond in twelve
(43, 51, 71, 86 ... ms).

Both directions now go through decimal: a TimeSpan is a whole number of ticks and the
frequency a whole number per second, so the product is exact and division by 10^7 is exact
in decimal. The rounding stays upward, so a delay is still a floor. The effect on real
timers is at most one source tick; it mattered on the virtual clock, where
WaitUntilTimerArmedAsync matched the reported delay against the armed one exactly and
failed for those values.

Tests cover both Stopwatch frequencies (10^7 and 10^9) over every millisecond up to two
seconds and fail on the old code. Two review nits in the same tests: the real-time delay
test asserted 150 ms for a 200 ms timer, and the clean-dispose test gated on a 500 ms wall
clock join.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@cursor

cursor Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

PR Summary

Medium Risk
Broad changes to shared deadline and timer conversion logic across actor, UDS, ISO-TP, J1939, and CANopen, though behavior is intentionally conservative (round up/down) and heavily regression-tested.

Overview
Fixes off-by-one source-tick timer and deadline math caused by converting TimeSpan ↔ monotonic ticks through double (roughly one millisecond in twelve wrong). The stack now routes those conversions through a new internal TickMath helper using exact decimal arithmetic, with ceil for delays/remaining time and floor for elapsed time, plus saturation at long.MaxValue.

ProtocolActor delegates due-time and next-wait calculations to TickMath; the same replacements land in UDS response windows and elapsed measurement, ISO-TP functional collection deadlines, J1939 periodic scheduling, and CANopen PDO inhibit timing so every layer agrees with the actor.

Tests add TickMathTests and frequency-parameterized actor timer sweeps (1–2000 ms at Windows and Linux/macOS Stopwatch rates), extend ManualTimeSource with configurable frequency and exact advance, and tighten a couple of assertions that previously masked the bug.

Reviewed by Cursor Bugbot for commit d0902e8. Bugbot is set up for automated code reviews on this repo. Configure here.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-02T04:59:59.376697Z d0902e8 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@codecov

codecov Bot commented Oct 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…ad its own

Making the actor's reported delay exact exposed the same double conversion elsewhere: on
.NET Framework TimeSpan.FromSeconds(double) rounds to whole milliseconds, which had hidden
that the UDS client counted its 100 ms response window as 999 999 ticks instead of
1 000 000. UdsClientTests.A_Pending_Answer_Still_On_Its_Way_Through_The_Channel_Extends_The_Window
then failed on the net48 leg: the timer it waited for was armed 100 ns short of the
expected delay.

TickMath holds the exact conversions (rounding up for a delay or a remaining time, down
for an elapsed time) and replaces the double arithmetic in the actor, the UDS response
windows and elapsed time, the ISO-TP functional client and listener, the J1939 node's
period and elapsed time, and the CANopen PDO inhibit time.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@dborgards dborgards changed the title fix(actor): convert timer delays with exact integer arithmetic fix(actor): convert timer delays and elapsed times with exact integer arithmetic Oct 2, 2026
Three tests built the deadline they expect with the same double arithmetic the library
used to have: (long)(window.TotalSeconds * Frequency), which is one tick short for a
100 ms window on .NET Framework. With the library now exact,
Functional_Collect_On_An_Injected_Clock_Drains_By_That_Clocks_Deadline failed on the net48
leg because its expected deadline was 999 999 ticks and the real one 1 000 000.
Same fix in the actor test's Ms helper and the UDS channel double's Ticks.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@dborgards
dborgards merged commit d78fd27 into main Oct 2, 2026
14 checks passed
@dborgards
dborgards deleted the fix/actor-exact-tick-arithmetic branch October 2, 2026 05:08
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.

1 participant