Fix NetworkState submit/cleanup TOCTOU races#1000
Merged
Merged
Conversation
Two deterministic reproductions of the submission-vs-cleanup races in NetworkState: - TestCleanupKeepsNetworkStateForInFlightSingletonRef (Race B): a caller holding a live get_http_singleton() reference observes m_networkState being std::move'd out during cleanup Begin, which is where a concurrent HCHttpCallPerformAsync would null-deref the moved-from UniquePtr. - TestHttpPerformRejectedAfterCleanupStarted (Race A): once cleanup has begun, a perform whose Begin op runs afterwards is still inserted into m_activeHttpRequests instead of being rejected, orphaning it past the cleanup snapshot. Adds the m_cleanupStarted admission gate field plus a TestSetCleanupStarted HC_UNITTEST_API seam used by the Race A test. No enforcement yet, so both tests fail on this commit.
…ce A) HttpCallPerformAsyncProvider and WebSocketConnectAsyncProvider inserted into their tracking sets in the Begin op without checking whether cleanup had already started. Because the insert and the CleanupAsyncProvider snapshot both take m_mutex but in an unconstrained order, a submission whose Begin acquired the lock after the snapshot was orphaned: never canceled or awaited, and able to run against a torn-down provider. Set m_cleanupStarted under m_mutex in CleanupAsyncProvider::Begin and refuse new performs/connects whose Begin acquires m_mutex after that point (returning E_HC_NOT_INITIALISED). Submissions that acquired m_mutex first are already tracked and captured by the snapshot, so the admission window is closed with no gap. Updates the stale invariant comment accordingly.
http_singleton::CleanupAsyncProvider std::move'd m_networkState out of the singleton during its Begin op, then NetworkState destroyed itself when provider cleanup completed. An in-flight HCHttpCallPerformAsync / HCWebSocketConnectAsync that had already obtained a strong singleton reference via get_http_singleton() but not yet dereferenced m_networkState could then read a moved-from (null) or destroyed pointer and crash. NetworkState::CleanupAsync now takes a non-owning NetworkState* and never destroys the instance; it stays owned by the singleton and is destroyed together with it. The singleton's existing use_count gate already waits for all outstanding references (including in-flight API callers) before destruction, so those callers always observe a live NetworkState. Documents that gate and the supporting invariant that no code may hold a get_http_singleton() reference across an async wait.
Explain why HttpCallPerformAsyncProvider's Cleanup op gates the cleanup wakeup on m_activeHttpRequests.erase(performContext) != 0: a perform rejected by the m_cleanupStarted admission guard was never inserted, so it must not schedule cleanup's async block. Comment-only; no behavior change.
Collaborator
Author
|
Identified another potential shutdown race not covered by these fixes. Closing this PR until investigation completes & will resubmit once resolved one way or the other. |
Collaborator
Author
|
We feel comfortable that the other shutdown heisenbug we are seeing has a different root cause, so reopening this PR. |
jasonsandlin
approved these changes
Jul 9, 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.
Fixes #999
Summary
NetworkStatedid not serialize network-operation submission against the start of cleanup.Calling
HCHttpCallPerformAsync/HCWebSocketConnectAsyncon one thread while another threadruns
HCCleanup/HCCleanupAsynccould crash on a moved-fromm_networkState, or orphan arequest that lands in the cleanup tracking sets after they were snapshotted. See the linked issue
for the full analysis.
This PR closes both facets. The public API surface is unchanged, and there is no new
shared_ptror per-call allocation on the submission path.
Code review also found a cleanup wake-up edge case in the admission-gate implementation: a
rejected HTTP perform still runs its
XAsyncOp::Cleanupprovider path, and if that rejectedcontext is treated like a tracked request it can consume cleanup's one schedule slot or double-run
provider cleanup. The final fix only lets contexts that were actually present in
m_activeHttpRequestswake cleanup.Bug addressed
Two facets of one root cause — submission is not serialized against cleanup-begin:
Beginop acquiresm_mutexafterCleanupAsyncProvider::Begintook its snapshot was inserted intom_activeHttpRequests/m_connectingWebSocketsanyway, orphaning it past cleanup: never canceled or awaited, and ableto run against a torn-down provider.
m_networkState).HCHttpCallPerformAsyncdereferenceshttpSingleton->m_networkStatewhilehttp_singleton::CleanupAsyncProvider::Beginstd::moves it out.get_http_singleton()does not increment the singleton use count, so anin-flight caller holding a strong reference is unsynchronized against the move — a null
dereference / data race.
The PR also includes a code-review hardening fix for the rejection path itself: an HTTP perform
that is refused after cleanup begins is never inserted into
m_activeHttpRequests, so its laterprovider cleanup must reclaim only its local context and must not wake or reschedule cleanup.
What changed
Race A — admission gate (
NetworkState)m_cleanupStarted, set underm_mutexinCleanupAsyncProvider::Beginalongside theexisting snapshot.
HttpCallPerformAsyncProvider::BeginandWebSocketConnectAsyncProvider::Beginnow checkm_cleanupStartedinside the samem_mutexcritical section as the tracking-set insert. Ifcleanup has begun, the submission is refused with
E_HC_NOT_INITIALISEDinstead of beinginserted. Because insert and snapshot share
m_mutexand the flag is set under that lock, asubmission either lands before the snapshot (and is captured/canceled) or is cleanly refused —
no gap.
HttpCallPerformAsyncProvider::Cleanupnow wakes cleanup only whenm_activeHttpRequests.erase(performContext)actually removed a tracked request. Rejectedperforms still reclaim their async context, but cannot consume cleanup's schedule slot or cause
provider cleanup to run twice because they were never admitted to the active set.
Race B — keep
NetworkStateowned by the singletonNetworkState::CleanupAsyncnow takes a non-owningNetworkState*and no longer transfersownership;
global.cpppassessingleton->m_networkState.get()instead ofstd::move.NetworkState(HttpProviderCleanupCompleteand theDoWorkfailure path no longer reclaim it). The instance stays owned by the
http_singletonand isdestroyed with the singleton, after the singleton's existing
use_countgate confirms no otherreferences remain. An in-flight caller holding a strong singleton reference keeps
use_countabove 1, so it can never observe a moved-from or destroyed
m_networkState.global.cpp, including the supporting invariant that no code may hold aget_http_singleton()reference across an async wait.Tests
TestCleanupKeepsNetworkStateForInFlightSingletonRef(Race B) — a caller holding a liveget_http_singleton()reference assertsm_networkStatestays non-null across cleanup; failspre-fix when it is moved out from under that reference.
TestHttpPerformRejectedAfterCleanupStarted(Race A) — a perform whoseBeginruns aftercleanup has begun must be refused rather than inserted into
m_activeHttpRequests; fails pre-fixwhen it is orphaned past the snapshot. The test was strengthened during code review with a real
unscheduled cleanup async block, so it also fails if a rejected perform consumes cleanup's one
allowed schedule.
XAsyncBegindispatchingBeginsynchronously) and were committed first as failing tests, then made to pass by the fixes. The
code-review cleanup wake-up regression was also committed red, then fixed green.
TestSetCleanupStartedHC_UNITTEST_APIseam used by the Race A test; the seam can alsoattach a cleanup async block so the rejection path is tested against realistic cleanup state.
Design rationale
m_mutex? The insert and the cleanup snapshot alreadyserialize on
m_mutex. Setting/reading the flag in that same critical section makes theadmission decision atomic with the insert/snapshot, closing the window without a new lock.
m_cleanupStartedis a plainbool, not atomic. Every read and write occurs underm_mutex, which provides the required mutual exclusion and ordering; an atomic would beredundant and would misleadingly imply lockless access. The correctness of the guard depends on
the read and the insert being in the same critical section as cleanup's write+snapshot, which
the mutex — not an atomic — provides.
erase? A rejected HTTP perform still owns an async context andstill receives
XAsyncOp::Cleanup, but it was never part of the cleanup tracking set. Only anadmitted request can be work that cleanup is waiting for, so the cleanup wake-up is tied to the
active-set erase actually removing an entry. This prevents a refused submit from stealing the
cleanup async block's schedule slot or invoking provider cleanup a second time.
NetworkStateowned by the singleton instead of ashared_ptr? The singletonalready has a
use_countgate that waits for all outstanding references before destruction.Reusing it (by not moving
NetworkStateout early) fixes Race B with no newshared_ptr, nonew atomics, and no per-call cost — versus adding an in-flight submitter counter that would
re-implement
use_count, touch every public entry point, and add hot-path overhead. Thetrade-off is that
NetworkState(and its already-cleaned providers) lives until the singleton isdestroyed — a few milliseconds longer under the gate — and a documented invariant that callers
must not hold a singleton reference across an async wait.
Public API surface
HC*signatures added, removed, or changed.HCHttpCallPerformAsync/HCWebSocketConnectAsyncbehavior is unchanged in the normal case;when they race a concurrent cleanup they now fail deterministically with
E_HC_NOT_INITIALISEDrather than crashing or orphaning the request.
Validation
libHttpClient.UnitTest.TE:TestHttpPerformRejectedAfterCleanupStartedred withE_UNEXPECTEDwhen the rejected perform consumed cleanup's schedule slot → green after
Skip cleanup wakeup for rejected HTTP performs.GlobalTestsclass passes 7/7, including the cleanup-focused cases(
TestAsyncCleanup,TestAsyncCleanupWithHttpCall,TestAsyncCleanupWithHttpCallPendingRetry,TestHttpCallCompletionCallbackIsNotCleanupCancelable,TestCleanupKeepsNetworkStateForInFlightSingletonRef,TestHttpPerformRejectedAfterCleanupStarted).load-induced global XTaskQueue teardown flakes (
VerifyGlobalQueueTermination, andTaskQueueTests::TestCleanupXTaskQueueUninitialize(250)) that are unrelated to this change,pass in isolation on both
mainand this branch, and clear on re-run.Scope
NetworkStateadmission control and singleton↔NetworkStateownership; thecancel/await handshake for already-tracked requests is unchanged.
Review focus
m_cleanupStartedguard sharing them_mutexcritical section with the tracking-setinsert/snapshot (the core Race A fix), and the
E_HC_NOT_INITIALISEDrejection semantics.m_activeHttpRequestsmay wake cleanup.NetworkStateis no longer moved out or destroyed by cleanup, and is nowdestroyed with the singleton after the
use_countgate (the core Race B fix).get_http_singleton()reference across an asyncwait, and whether it holds for all current callers.