Repository navigation
Keep the timer queue ordered when a timer is changed or disposed - #85
dexcompiler wants to merge 6 commits into
Conversation
ITimer.Change set DueAtTicks on a timer already in the priority queue, whose comparer read that field, and queued a second copy. A timer moved later, or stopped with Infinite, kept its heap slot under the new key and sat above earlier timers, so Advance stopped at it: a due timer did not fire, or fired late and out of order. CancellationTokenSource.CancelAfter and TryReset on a source made with the provider, and PeriodicTimer.Period, reach Change. Each queue entry now carries the timer's schedule version and an immutable (due, id) priority. Change increments the version and queues a new entry; Advance discards stale and disposed entries. Advance also collected due callbacks under its lock and invoked them later, so a timer disposed or rescheduled by an earlier callback of the same advance still fired. Each callback is now re-checked under the lock just before it runs, and a one-shot timer is spent when it fires rather than when it is collected.
A property drives SimulatedTimeProvider and a naive model through random create, change, dispose and advance operations and compares which timers fire, in what order, and what Change returns. Against the 1.4.0 queue it is falsified within about 100 cases; FsCheck shrinks it to changing a due timer to periodic at the same due time, after which the other due timer does not fire.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 922ac865f0
ℹ️ 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".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| Statistics.RecordCallbackFired(); | ||
| callback(state); | ||
| timer.Callback(timer.State); |
There was a problem hiding this comment.
Finalize due timers when a callback throws
When a callback throws, this foreach exits before later due entries are processed. Those later one-shot timers were already dequeued, but this change no longer marks them disposed until their individual iteration, leaving them live with no queue entry: future Advance calls cannot fire them, while Change unexpectedly still returns true. This occurs through an existing supported path such as Timeouts.CreateTimeout after its returned CTS is disposed, whose callback throws ObjectDisposedException; any later one-shot due in the same advance is orphaned.
Useful? React with 👍 / 👎.
dexcompiler
left a comment
There was a problem hiding this comment.
In-depth review of head 922ac865f0c2bf2109e572ff595ac0ff0575f896 against base/merge-base a57b92f236070c19892ac5f18685c1826f40411c.
One actionable finding: [P2] Bound stale-entry retention after moving timers earlier. Inline reproduction below. The immutable priorities fix ordering, but stale entries retain completed timers, callbacks and state behind any live earlier priority. In a differential probe with one periodic heartbeat and 1,000 disabled timers individually armed, fired and disposed, the base retained 0 completed payloads and 1 queue entry; this head retained 1,000 completed payloads and 1,001 queue entries after full GC. Recommend addressing this before merge.
Validation on .NET SDK 10.0.112, using an isolated archive of the exact head:
- Full Release solution build: passed, 0 errors. Existing package advisories remain; archive builds additionally warn about unavailable SourceLink metadata.
- Unit tests: 163/163 passed.
- Property tests: 77/77 passed, including the new 500-case model property.
- Read all four changed files and surrounding timer implementation, statistics, BCL integration tests, timeout helpers, documentation and CI configuration.
- GitHub build/test, documentation build and CodeQL checks passed on this head; docs deployment was skipped.
gh pr checks --requiredreported no required checks.
The new property checks fired order and Change results, but does not check object retention or queue growth. Add a bounded-queue/GC regression with a persistent live timer while repeatedly moving other timers earlier.
Existing exception comment: independently reproduced the scenario in #85 (comment) against both base and head. A throwing first callback prevents the second callback on both revisions; that callback-loss behavior is pre-existing. This PR changes the abandoned one-shot's lifecycle: base reports 2 disposed timers and rejects its Change; head reports 1 disposed timer and accepts Change. Exception cleanup deserves an explicit policy/test, but I am not counting the pre-existing callback loss as a second newly introduced defect.
No repository source files were changed.
| if (entry.Timer.IsDisposed || entry.Version != entry.Timer.Version) | ||
| { | ||
| // Disposed, or rescheduled since this entry was queued: a newer entry stands for it. | ||
| _queue.Dequeue(); |
There was a problem hiding this comment.
[P2] Bound stale-entry retention when timers move earlier
Root-only cleanup leaves stale entries at their original immutable priority. Keep one 1-second periodic timer alive, then repeatedly create a disabled one-shot, Change it to 1 second, and Advance by 1 second. Every one-shot fires and is disposed, but its original long.MaxValue entry remains behind the heartbeat's next occurrence forever, strongly retaining its callback/state. After 1,000 iterations and full GC, this head retained all 1,000 payloads and had 1,001 queue entries; the base retained zero payloads and only the heartbeat entry. Long-lived simulations therefore grow memory with total completed timers rather than live timers, and heap operations grow with that accumulated history. Use bounded stale-entry compaction or removal/replacement of superseded entries; also avoid enqueuing disabled schedules where possible. Add a regression that keeps a live periodic timer throughout the workload.
Minimal workload (supply a fresh state payload per iteration to observe retention):
var tp = new SimulatedTimeProvider();
using var heartbeat = tp.CreateTimer(_ => { }, null,
TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
for (var i = 0; i < 1000; i++)
{
using var timer = tp.CreateTimer(_ => { }, new byte[16 * 1024],
Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
timer.Change(TimeSpan.FromSeconds(1), Timeout.InfiniteTimeSpan);
tp.Advance(TimeSpan.FromSeconds(1));
}The same retention mechanism applies to far-future finite schedules moved earlier; merely omitting Infinite entries does not bound that case.
Change supersedes a timer's queue entry and Dispose abandons it, but the stale entry stayed in the heap until it reached the head. Behind a long-lived timer such as a heartbeat it never did: arming, rescheduling and disposing 1,000 timers behind one periodic timer left 1,002 entries, each pinning its timer's callback and state. Each timer now records whether its current entry is queued, so the provider counts current entries in O(1). Whenever stale entries outnumber current ones, the queue is rebuilt from the current entries in O(n); every stale entry is dropped once, so the cost is O(1) amortized per change or disposal, and the queue holds at most about twice the live timers. A disposed or spent timer also drops its callback and state, so a stale entry kept between rebuilds pins neither. Advance now reads a callback and its state under the lock when it decides the timer fires, since disposal clears them. Dispose takes the lock to unqueue the timer. The reference-model property now also checks the queue never grew past twice the most timers live at once; against the previous commit FsCheck shrinks a counterexample to three creates.
When a callback throws, Advance stops and the exception propagates; the callbacks after it in that advance do not run. In 1.4.0 the one-shot timers among them were already spent, because Advance marked them when it found them due. Since one-shot timers are now spent when they fire, those timers were left neither queued nor disposed: Change still returned true and queued a timer that had silently missed its occurrence. Advance now marks the one-shot timers it found due and did not reach as spent, as 1.4.0 did; periodic ones keep their next occurrence. Whether the remaining callbacks should run is unchanged and left for a separate decision.
Problem
Two
SimulatedTimeProvidertimer defects in 1.4.0, both found while evaluating Clockworks as the test clock for SkyLark.1.
ITimer.Changecorrupts the timer queue.ScheduledTimer.ChangesetDueAtTickson a timer that was already inPriorityQueue<ScheduledTimer, ScheduledTimer>. That queue's comparer reads the field, andChangethen queued a second copy. A timer moved later, or stopped withInfinite, kept its heap slot under the new key. It sat above earlier timers, soAdvancestopped at it.Stopping the earliest timer (
Change(Infinite, Infinite)) blocked every later timer for good. In one test, a later timer had fired 0 times afterAdvance(365 days). The BCL reachesChangethrough:CancellationTokenSource.CancelAfterandTryReseton aCancellationTokenSource(delay, provider);PeriodicTimer.Period.For example:
new CancellationTokenSource(1 s, time), thenTask.Delay(2 s, time), thenCancelAfter(5 s), thenAdvance(3 s). The delay is still pending.2. A timer disposed by an earlier callback of the same
Advancestill fires.Advancecollected the due callbacks under its lock and invoked them later without re-checking. One-shot timers were marked disposed at collection, soStatisticscannot see this.Raised in review
3. Stale entries accumulated behind a long-lived timer. The first fix left a superseded (
Change) or abandoned (Dispose) entry in the heap until it reached the head. Behind a long-lived timer, such as a heartbeat, it never did. Arming, rescheduling, firing and disposing 1,000 timers behind one periodic timer left 1,002 entries, each pinning its timer's callback and state.The
Changehalf was new in this PR: 1.4.0 moved the queued entry's key in place, which is defect 1, so it left no copy behind. TheDisposehalf was already in 1.4.0: a disposed timer stayed queued, pinning its state, until it reached the head.4. A throwing callback orphaned the due one-shot timers after it. In 1.4.0 those timers were spent when
Advancecollected them. Once the first fix moved spending to firing time, they were left neither queued nor disposed.Changethen returnedtrueand re-armed a timer that had silently missed its occurrence.Fix
(timer, version)with an immutable(due, id)priority. Order is unchanged: due time, then creation order.Changeincrements the timer's version and queues a new entry. It never reorders a queued one.Advancediscards stale and disposed entries as it reaches them.Advancefound it due does not fire for that occurrence.Changeafter the callback has run still returnsfalse, as before.Disposenow takes the provider's lock to unqueue the timer.Advancefound due and did not reach are spent, as in 1.4.0. Periodic ones keep their next occurrence.Tests
Regression tests in
tests/SimulatedTimeProviderTests.cs:Infinite;CancelAfterroute;All five fail on
mainand pass here.From review:
Rescheduled_timers_do_not_accumulate_behind_a_live_periodic_timer(disabled,far-futurefirst schedule)MaxQueueLength≤ 4 and no payload reachableA_finished_timer_releases_its_state_while_a_stale_entry_for_it_is_queued(disposed,fired)disposedfailsA_throwing_callback_leaves_the_other_due_one_shot_timers_of_its_advance_spentChangeand never fires; a periodic one fires at its next occurrenceThe property in
property-tests/SimulatedTimeProviderProperties.fsdrives the provider and a naive reference model through random create, change, dispose and advance operations. It compares the fired order andChange's return values.mainit is falsified within 103–129 cases (3 of 3 runs). FsCheck shrinks it to four operations: two timers due now, the first changed to periodic at the same due time, thenAdvance(0). The second timer does not fire.MaxQueueLengthnever exceeds twice the most timers live at once. On 922ac86 this is falsified within 34–63 cases (3 of 3 runs). FsCheck shrinks it to one periodic timer changed twice: three entries for one live timer. The a73fbd8 commit message calls this "three creates"; that is wrong, and the case is one create and two changes.Mutation checks. Each mutant was killed:
Release()a no-opRelease()inMarkDisposedfiredRelease()inDisposedisposedThe three review tests passed 20 of 20 repeated runs. Debug passes too.
Checks
On SDK 10.0.303 and 11.0.100-rc.1, following CI:
dotnet build Clockworks.sln -c Release: 0 errors. The 4 warnings are existing NU1902/NU1903 package advisories insrcand the integration demo, unchanged by this PR.dotnet test tests/Clockworks.Tests.csproj -c Release: 168/168.dotnet test property-tests/Clockworks.PropertyTests.fsproj -c Release: 77/77.Each of the three new commits builds and passes on its own.
Not changed
[Unreleased]changelog entries only.Changeon a one-shot timer after it has fired still returnsfalse.System.Threading.Timerwould re-arm it.Advanceis unchanged.Advance, as in 1.4.0. The exception propagates, and the callbacks after it in that advance do not run. This is a separate decision for the maintainer, so this PR does not change it. One option is to run every due callback and then rethrow, as anAggregateExceptionwhen several throw. On real timers, an unhandled exception in aSystem.Threading.Timercallback ends the process, so there is no BCL behaviour to follow.Infinite) schedules are still queued, so the existingQueueEnqueuesandMaxQueueLengthstatistics properties hold. Compaction bounds them with the rest.