Centralize timer-arming to close TOCTOU race#983
Merged
Conversation
Follow-up to #975 and #980: the CAS-then-Start pattern for retargeting m_timerDue had a TOCTOU window where thread A could win the CAS but before calling Start(), thread B could CAS+Start an earlier deadline, which thread A's Start() would then overwrite — stranding the earlier callback until independent traffic arrived. The fix in #980 added post-Start verification in SubmitPendingCallbacks, but the same unguarded pattern existed in QueueItem and PromoteReadyPendingCallbacks. This change extracts the CAS+Start+verify logic into three helpers (ArmTimerIfEarlier, ArmNextPendingCallback, RearmObservedDueTime) so the post-Start verification is applied uniformly at every call site. ~67 lines of duplicated inline CAS logic removed.
jhugard
force-pushed
the
user/jhugard/fix-timer-arm-toctou
branch
from
May 21, 2026 06:57
499ff7d to
8386b37
Compare
brianpepin
approved these changes
May 21, 2026
brianpepin
left a comment
Contributor
There was a problem hiding this comment.
Much cleaner. Thanks for the effort!
Collaborator
Author
I should have seen this race earlier, so thanks for pointing it out! |
jasonsandlin
approved these changes
May 27, 2026
rgomez391
pushed a commit
that referenced
this pull request
Jun 23, 2026
#975 rewrote the shared STL wait-timer backend (WaitTimer_stl.cpp) to replace the O(N) pointer-keyed cancellation scan with an O(log N) per-timer generation scheme: every Start() bumps a generation counter and pushes a fresh heap entry, and the worker discards any popped entry whose generation no longer matches the timer's current generation. That scheme can drop a due callback. A Start() that races the worker between its Peek() of the earliest entry and its dispatch of that entry bumps the generation, so when the worker pops the entry it now reads a stale generation and discards it instead of firing it. The re-pushed entry carries a new deadline, so the original due callback is never delivered. With the entry gone, the worker sleeps on the next (later or absent) deadline and goes idle. On a single-port manual XTaskQueue this is unrecoverable: the platform HTTP provider re-arms its poll via XTaskQueueSubmitDelayedCallback, and once that re-poll is dropped there is no independent traffic to wake the queue and sweep the pending list again. The symptom is an infinite sign-in / inventory hang (the delayed re-poll sits in the pending list while the timer thread waits in _Cnd_timedwait, never re-armed). Why the arming-layer fixes did not catch or fix it: - #975, #980 and #983 (#983 = "Centralize timer-arming to close TOCTOU race") all hardened the CAS->Start bookkeeping in TaskQueuePortImpl (m_timerDue) in TaskQueue.cpp. Those fixes are correct and are retained here. But they assume Start() reliably arms the backend for the published deadline. The generation backend violates that assumption by dropping the entry after Start(), so no amount of arming-layer verification can recover it. - The defect only affects WaitTimer_stl.cpp. Win32/UWP use WaitTimer_win32.cpp (OS CreateThreadpoolTimer, no generation scheme) and are unaffected. The libHttpClient unit-test project compiles the Win32 backend, so the Windows suite stayed green and none of #975/#980/#983 exercised the STL backend. Fix: restore the proven pre-#975 STL backend algorithm (libHttpClient 2.3.1, commit 0fa5f24) - pointer-keyed cancellation and an unconditional notify on every Set, which cannot discard a due entry - bridged to the post-#975 public WaitTimer API (GetCurrentTime / GetDueTime / Start(dueTime)). The clock is pinned to steady_clock (the pre-#975 code used high_resolution_clock, which is steady_clock on libc++ but wall-clock system_clock on libstdc++); pinning keeps the delayed-callback ordering monotonic on every platform that compiles this backend, preserving #975's monotonic-time intent. The arming-layer improvements in TaskQueue.cpp from #975/#980/#983 are kept unchanged; only the STL timer backend is reverted. Validated on-device in a shipping title: the reproducing infinite sign-in / inventory hang no longer occurs with this backend and the current TaskQueue arming logic. Follow-up: the STL backend has no unit-test coverage (the test project compiles only the Win32 backend). A dedicated STL-backend test lane is needed so a future re-land of the generation optimization cannot reintroduce this class of strand.
jasonsandlin
pushed a commit
that referenced
this pull request
Jul 9, 2026
…ed (UINT64_MAX) (#1001) PR #983 removed the `if (dueTime == UINT64_MAX) return;` guard from the top of TaskQueuePortImpl::SubmitPendingCallbacks and routed the early (now < dueTime) path through the new RearmTimerIfDueTimeUnchanged() helper, which calls m_timer.Start(dueTime) unconditionally. Only ArmTimerIfEarlier() kept a UINT64_MAX guard. UINT64_MAX is the "nothing armed" sentinel for m_timerDue. When a timer callback fires while the timer is disarmed, SubmitPendingCallbacks re-arms the sentinel: WaitTimerImpl::Start(UINT64_MAX) builds Deadline(Deadline::duration(UINT64_MAX)), and because the STL backend's duration rep is a signed int64, UINT64_MAX becomes -1 -- a deadline 1ns before the steady_clock epoch, i.e. perpetually in the past. The wait-timer thread then fires immediately, calls SubmitPendingCallbacks, sees m_timerDue == UINT64_MAX (now < UINT64_MAX is always true), re-arms the sentinel, and fires again: an infinite fire/re-arm spin that pins the timer thread and starves every real pending delayed callback (they sit unpromoted in m_pendingList because PromoteReadyPendingCallbacks is never reached). Observed on PS5 as an intermittent hang where a delayed websocket send-completion poll is never promoted, so the operation never completes. Fix: restore the pre-#983 sentinel guard at the top of SubmitPendingCallbacks and add the same guard to RearmTimerIfDueTimeUnchanged, so the disarm sentinel can never be programmed as a real deadline. Co-authored-by: Praneet Kacha <pkacha@microsoft.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Follow-up to #975 and #980: retargeting
m_timerDuestill had a TOCTOU windowaround the non-atomic CAS -> Start sequence. Thread A could publish a later due
time, thread B could then publish and arm an earlier one, and thread A's
trailing
Start()could overwrite the OS timer with the later deadline andstrand the earlier callback until unrelated traffic arrived, as in #973.
The previous fix in #980 added a post-Start verification check in
SubmitPendingCallbacks, but the same unguarded pattern existed inQueueItemand
PromoteReadyPendingCallbacks.This change centralizes the CAS+Start+verify logic into three focused helpers
so every timer-arm path uses the same post-Start verification pattern without
burying it in call-site control flow.
The public
XTaskQueueAPI surface is unchanged. The fix is internal to thedelayed-callback scheduling path and matters to any consumer relying on timed
waits, deferred continuations, retry back-off, or timeout enforcement.
Bug addressed
Timer-arm TOCTOU overwrite. When two threads race to arm
m_timerDue, thesequence CAS → Start is not atomic. Between a successful CAS and the subsequent
Start()call, a concurrent thread can:m_timerDue.Start()with that earlier deadline.The first thread's
Start()then fires after thread B's, overwriting the OStimer with a later deadline. The earlier pending entry is stranded until some
unrelated timer fire or new submission happens to flush it.
The external symptom matches the stale-callback bug fixed in #975: a delayed
callback becomes runnable only after unrelated later work wakes the queue.
Under sustained concurrent delayed-callback submission this can show up as
latency jitter or starvation for a particular deadline bucket.
What changed
New helpers (
TaskQueue.cpp)Three private methods replace all inline CAS+Start patterns:
ArmTimerIfEarlier(dueTime)falseonly when the published due time moved later and the caller should re-evaluate.ArmTimerForNextPendingDueTime(previousDueTime, nextDueTime)m_timerDuelater. Returnsfalsewhen the caller must rescan because the published value changed again.RearmTimerIfDueTimeUnchanged(dueTime)falseif the observed due time is stale.Call-site changes
QueueItem: Replaced the inline CAS loop with a single call toArmTimerIfEarlier(entry.enqueueTime). The return value is intentionallyignored because the entry is already safe in
m_pendingListeven if anotherthread retargets the timer first.
PromoteReadyPendingCallbacks: Replaced the inline CAS loop thatpublished
nextItem.enqueueTimewithArmTimerForNextPendingDueTime(dueTime, nextItem.enqueueTime). Onfalse(world changed), the function refreshesnowanddueTimeand rescans.SubmitPendingCallbacks: Replaced the inline CAS + post-verificationstanza from Update TaskQueue with changes from Windows repo #980 with
RearmTimerIfDueTimeUnchanged(dueTime). Onfalse,the outer loop reloads state and re-evaluates.
The helper extraction removes the duplicated inline CAS logic from all three
call sites.
Design rationale
Why three helpers instead of one?
Each call site has distinct preconditions on the direction the published
deadline may move:
QueueItem: only moves earlier (new entry).PromoteReadyPendingCallbacks: moves later (just-fired deadline replacedwith next future one).
SubmitPendingCallbacks: re-arms the same value (early/stale timer fire).A single function would need mode flags or extra parameters to express these
constraints, and its post-Start verification logic would diverge per mode. Three
small functions with clear names and focused contracts are simpler to audit.
Why
<=inArmTimerIfEarlier?The
<=comparison keeps equality on the same verified path. That matters forbenign races where concurrent submitters compute the same due time, and it
keeps
ArmTimerIfEarliercorrect for callers that need to re-arm a deadlinethat is already published rather than strictly earlier.
Post-Start verification pattern
All three helpers perform the same post-
Start()check against the publishedvalue:
That verification closes the TOCTOU window: if another thread's CAS lands
between ours and
Start(), we detect it and either repair the earlier arm oryield to the thread that already published it, rather than leaving it stranded.
ABA safety
If
m_timerDuegoes throughX → Y → Xbetween our CAS and verification, weread back our own value and return
true. The concurrent thread that moved itto
Yand back called its ownStart(X), so the timer is correctly armed forX. ABA here is benign by construction.Public API surface
XTaskQueuesignatures change.XTaskQueueSubmitDelayedCallbackbehavior is unchanged from the caller'sperspective: the not-before-deadline guarantee is now upheld more reliably
under concurrent submission and timer retargeting races.
Validation
libHttpClientWindows unit test suite:87 passed, 0 failed, 0 skipped.
current tree still carries unrelated WebSocket connect and mock multi-match
expectation failures.
VerifyDelayedCallbackTimerRaceOnManualQueueVerifyFutureDelayedCallbackQueuedDuringEmptySweepDoesNotStallVerifyTerminationDoesNotEarlyPromoteSiblingDelayedCallbackVerifyStaleDelayedCallbackDoesNotEarlyPromoteNextPendingEntryThese tests were added in #975 and exercise the exact interleaving scenarios
that this change hardens. The TOCTOU window itself is probabilistic and
difficult to deterministically reproduce in a unit test (the window is a few
nanoseconds between CAS and Start), but the structural guarantee — that
post-Start verification is now uniformly applied — eliminates the class of bug
rather than papering over individual instances.
Additional downstream integration validation:
queues and delayed callbacks to implement sleep, timeout, and cancellation.
on that coroutine layer.
Scope
code paths).
unrelated task-queue behavior are unchanged.
the fix through the shared
TaskQueuePortImplscheduling logic.Review focus
<=semantics inArmTimerIfEarlier, especially around equal-deadlineraces between concurrent submitters.
ArmTimerForNextPendingDueTime's decision to returntruewhen another threadpublishes an earlier deadline (delegating responsibility to that thread's own
verify cycle).
RearmTimerIfDueTimeUnchanged's refusal to resurrect a stale deadline whenm_timerDuehas already advanced past it.true= stable/covered,false=re-evaluate) is clear and consistently used at each call site.