Fix WaitTimer teardown and wrapper lifetime races#1005
Open
jhugard wants to merge 4 commits into
Open
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens WaitTimer lifetime behavior to eliminate two race classes: (1) STL timer dispatch occurring after queue-owned teardown begins, and (2) wrapper-level races where Start()/Cancel() can overlap Terminate() and use a destroyed implementation. It does this while preserving the pointer-keyed cancellation and unconditional worker wake behavior needed to avoid the previously observed delayed-callback strand.
Changes:
- STL backend: move callback payload/dispatch bookkeeping into a shared
WaitTimerStateleaseable by a due queue entry, and gate/quiesce dispatch during teardown while keeping pointer-keyed cancellation. - Wrapper contract: serialize
Initialize(),Start(),Cancel(), and the impl ownership transfer inTerminate()(STL/Win32/iOS), ensuring backend teardown happens outside the wrapper lock. - Add deterministic white-box standalone Linux regressions (queue teardown, queue retirement ordering, wrapper termination) and wire them into Linux CTest.
Reviewed changes
Copilot reviewed 9 out of 11 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| Tests/StandaloneTests/WaitTimerWrapperTerminateRepro.cpp | Deterministic repro/guard for wrapper Start()/Cancel() overlapping Terminate() using internal test hooks. |
| Tests/StandaloneTests/WaitTimerTestMemory.cpp | Provides local default allocator plumbing for white-box standalone tests that compile private implementation. |
| Tests/StandaloneTests/WaitTimerQueueTeardownRepro.cpp | Deterministic repro/guard for STL worker-pop vs teardown dispatch lifetime race. |
| Tests/StandaloneTests/WaitTimerQueueRetirementRepro.cpp | Deterministic guard for last-owner queue retirement not being resurrectable by concurrent Initialize(). |
| Tests/StandaloneTests/TaskQueueStarvationRepro.cpp | Relocated/packaged Linux standalone starvation repro to the StandaloneTests folder. |
| Tests/StandaloneTests/SingleQueuePollStrandRepro.cpp | Relocated/packaged Linux standalone poll-strand repro to the StandaloneTests folder. |
| Source/Task/WaitTimer.h | Adds internal test hook seam and wrapper mutex to serialize impl lifetime transitions. |
| Source/Task/WaitTimer_win32.cpp | Serializes wrapper operations with the new mutex to prevent impl use-after-free races. |
| Source/Task/WaitTimer_stl.cpp | Implements shared-state dispatch leasing + teardown gating/quiescing; adds internal hook plumbing and shared queue lifetime management. |
| Source/Task/iOS/ios_WaitTimer.mm | Serializes wrapper operations with the new mutex to prevent impl lifetime races. |
| Build/libHttpClient.Linux/CMakeLists.txt | Adds new Linux CTest standalone targets for deterministic WaitTimer lifetime regressions. |
jasonsandlin
approved these changes
Jul 14, 2026
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.
Fix WaitTimer teardown and wrapper lifetime races
Summary
This PR fixes two independent timer lifetime races:
WaitTimer::Start()orCancel()can overlapWaitTimer::Terminate()and use an implementation that termination has already destroyed.The change preserves the pointer-keyed cancellation and unconditional wake behavior restored by PR #998. That behavior is required to avoid reintroducing the delayed-callback strand that #998 corrected. The TaskQueue timer-arming improvements from PR #975 and its follow-ups are unchanged.
Related reports:
Changes
STL timer dispatch lifetime
The STL queue now keeps each timer's callback payload and dispatch bookkeeping in a
shared
WaitTimerState. A due queue entry holds ashared_ptrlease on thatstate, so a dispatch that is already under way owns the state it needs and can
never be invalidated by concurrent teardown of the raw
WaitTimerImpl.Teardown prevents new dispatches (
BeginTerminate), removes matchingpointer-keyed entries from the queue, and then waits for an already-started
dispatch to quiesce before the state is released.
Queue ownership is shared. The single process-wide
TimerQueueis held by ashared_ptrfrom every live timer and, critically, by the worker thread itself(captured in
Init). A separatem_timerCount, guarded byg_timerQueueMutex,lets the last timer retire the queue; the decrement and the last-owner decision
run under the same lock that
Initializetakes to adopt a timer, so a retiringqueue can never be resurrected by a concurrent
Initialize. Because the workerholds its own reference, a callback may tear down its own timer (and the last
queue reference): the teardown skips the quiesce wait when it runs on the worker
thread, and
~TimerQueuedetaches instead of self-joining, so callback-drivenshutdown neither deadlocks nor touches freed state.
The existing pointer-keyed cancellation scan remains in place. Every
Set()continues to wake the worker unconditionally. There is no generation-based
stale-entry filter, so a concurrent re-arm cannot discard an otherwise due
callback as the reverted #975 backend did.
Shared WaitTimer wrapper lifetime
The shared
WaitTimerwrapper now serializesInitialize(),Start(),Cancel(), and the ownership transfer inTerminate().Terminate()removes the implementation from the wrapper while holding the wrapper lock, then performs the potentially blocking backend teardown after releasing that lock. The blocking quiesce never runs under the wrapper lock, so a timer callback that re-entersStart(),Cancel(), or its own teardown cannot deadlock against it.The synchronization applies to STL, Win32, and iOS implementations because the ownership race is in the shared wrapper contract rather than any one backend timer primitive.
Commit structure
The production changes are layered as two red/green pairs for review:
Add deterministic STL WaitTimer teardown reproFix STL WaitTimer queue teardown lifetimeAdd WaitTimer wrapper termination reproSerialize WaitTimer wrapper terminationEach repro commit fails before its corresponding fix commit, verified on Linux:
waittimer-queue-teardown-linuxfails on commit 1 and passes on commit 2, andwaittimer-wrapper-terminate-linuxfails on commit 3 and passes on commit 4.The first pair demonstrates queue-owned teardown overlapping a due STL timer
dispatch; the second demonstrates
Start()andCancel()overlappingTerminate()after observing the timer implementation.The teardown fix also adds a
waittimer-queue-retirementregression. This is aregression guard, not a third red/green pair: the shared-queue design is
introduced in that same commit already-correct — the last-owner refcount
decrement and the queue reset both run under
g_timerQueueMutex, so a retiringqueue cannot be resurrected by a concurrent
Initialize()— so the test has nored predecessor in this branch. It exists to lock that ordering in against
future regressions.
Testing approach
Public-API coverage is preferred, and the existing TaskQueue regressions
(
taskqueue-starvation,taskqueue-poll-strand) drive the timer throughXTaskQueue. The three new WaitTimer regressions, however, target races thathave no reliable public-API interleaving:
copies a due entry and releases the queue lock. No public
XTaskQueueoperation can stop the worker at that point.
Start()/Cancel()immediately after theyread the private
m_implpointer whileTerminate()runs concurrently. Thatinterleaving is internal to
WaitTimer, and public queue references keep theport alive while scheduling is in progress, so it cannot be driven from the
public API.
concurrent
Initialize()runs.These three tests are therefore built as white-box standalone executables that
compile
Source/Task/WaitTimer_stl.cppdirectly and drive it through aninternal
WaitTimerTestHooksseam. The seam is source-internal and is not partof the public libHttpClient ABI; when no hooks are installed, production pays
only a single relaxed atomic load. This keeps the deterministic timer-lifetime
coverage beside the timer implementation, which is internally consistent for
this branch.
Two limitations of this coverage are worth calling out for review:
competing
Terminate()orInitialize()thread reached the contended regionfrom a bounded wait rather than from a second explicit "contender arrived"
synchronization point. On the fixed revisions the contended path blocks
deterministically, so the observed result is correct; the residual exposure is
a scheduling-dependent false negative on the pre-fix revisions under heavy
load. A follow-up could add that second synchronization point.
backends, but only the shared contract is executed on Linux. The Win32 and
iOS paths receive the same mechanical
m_mutexchange and are not covered bya native regression test.
Validation
Built on Linux (Release, STL backend) and validated at each commit boundary: the
two repro commits fail before their fixes and pass after them. On the final
revision, the full
taskqueuelabel passes:Passed:
The existing poll-strand regression continues to pass, confirming the change preserves the #998 scheduling behavior while adding lifetime protection.
Windows x64 validation built
libHttpClient.UnitTest.TAEFin both Debug andRelease configurations, including the updated
WaitTimer_win32.cpp, then ranthe full TAEF suite in process:
Both runs passed all 103 tests (
Total=103, Passed=103, Failed=0). Thisvalidates that the Win32 wrapper change does not regress the existing Windows
unit-test suite; however, it does not cover the missing native deterministic wrapper
race repro described above.
Scope
XTaskQueueor HTTP client API changes.private STL timer implementation directly; the
WaitTimerTestHooksseam issource-internal and is not exported from the shared library.
Review focus