fix: prevent EDEADLK self-join in ~CurlHttpOperation on async-thread destruction#1481
Open
bmehta001 wants to merge 34 commits into
Open
fix: prevent EDEADLK self-join in ~CurlHttpOperation on async-thread destruction#1481bmehta001 wants to merge 34 commits into
bmehta001 wants to merge 34 commits into
Conversation
Under -Werror on Linux/macOS, the modules-repo CI (build-posix-latest-exp)
has been failing for ~2 weeks with:
config-default.h:36: error: 'HAVE_MAT_LIVEEVENTINSPECTOR' macro redefined
[-Werror,-Wmacro-redefined]
config-default.h:37: error: 'HAVE_MAT_PRIVACYGUARD' macro redefined
tests/functests/CMakeLists.txt and tests/unittests/CMakeLists.txt add
-DHAVE_MAT_LIVEEVENTINSPECTOR / -DHAVE_MAT_PRIVACYGUARD on the command
line when BUILD_LIVEEVENTINSPECTOR / BUILD_PRIVACYGUARD (default YES) and
the respective module dir exists. The three config-default headers then
redefined them unconditionally, which is fatal under -Werror (added by
microsoft#1415).
Wrapping the two defines in #ifndef in all three config-default*.h
headers preserves all existing behavior:
- Without command-line -D: macros get defined here as before.
- With command-line -D: header skips the redefinition, no warning.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…destruction
The modules-repo CI test ECSClientFuncTests.GetConfigs (and every
test in the ECSClientFuncTests suite) crashed on Linux/macOS with:
terminate called after throwing an instance of 'std::system_error'
what(): Resource deadlock avoided
Aborted (core dumped)
Root cause
==========
SendAsync() runs Send() + the user callback on a std::async worker
thread. The callback owns a strong ref to CurlHttpOperation, so when
it releases the last ref the ~CurlHttpOperation destructor runs on
the async thread itself.
libstdc++'s std::future<>::~future implicitly calls
_Async_state_impl::~_Async_state_impl, which calls _M_complete_async
-> _M_join via std::call_once. On the async thread that's a self-join;
call_once throws std::system_error(EDEADLK). Because the throw escapes
a noexcept destructor, terminate() aborts the process. A try/catch
around the future cannot rescue this — destructors of std::future are
noexcept.
Fix
===
Move the future onto a detached helper thread before its destructor
runs. The helper is by definition NOT the async thread (we'd only be
on the async thread if its work already finished), so the implicit
join completes immediately. On the common path (destruction from the
caller thread) it costs one short-lived thread spawn that exits in
microseconds.
Verified locally with sister + modules linked: all 113 FuncTests pass,
including all 25 ECSClientFuncTests (which include the formerly-fatal
GetConfigs).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ession)
Code review found that the previous fix detached the async future's join for
EVERY destruction. That removed the cross-thread lifetime guarantee the old
result.wait() provided: when the operation is destroyed from another thread
while the async Send() is still running, the destructor would proceed to
curl_easy_cleanup()/ReleaseResponse() and destroy the by-reference request body
while the worker thread is still using them -> use-after-free.
Restore the guarantee while keeping the EDEADLK self-join fix:
- Record the async task's thread id (atomic) when SendAsync's task starts.
- In the destructor, compare std::this_thread::get_id():
* self-join (destroyed from within our own async callback, e.g. EraseRequest
drops the last reference): the work is necessarily complete, so defer the
future's join to a detached helper thread instead of joining on this (the
async) thread, avoiding EDEADLK.
* cross-thread: result.wait() to keep the curl handle, response buffer and
by-reference request body alive until the async Send() finishes.
- Heap-allocate the deferred future first so a rare std::thread spawn failure
leaks the already-finished future rather than self-joining (EDEADLK) or
letting std::system_error escape this noexcept destructor (std::terminate).
- Refresh the stale HttpClient_Curl.cpp lifetime comment.
Logic validated with a standalone C++11 repro under AddressSanitizer: the
cross-thread path waits (no UAF) and the self-join path does not deadlock.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR addresses a Linux/macOS crash caused by std::future’s implicit join throwing on self-join when ~CurlHttpOperation runs on the same async thread created by std::async.
Changes:
- Adds tracking of the async task’s thread id to detect destructor execution on the async thread.
- Updates
~CurlHttpOperationto avoid self-join by deferringstd::futuredestruction to a detached helper thread. - Updates comments in the curl HTTP client to document the lifetime/join behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| lib/http/HttpClient_Curl.hpp | Adds async thread id tracking and modifies destructor logic to avoid std::future self-join aborts. |
| lib/http/HttpClient_Curl.cpp | Updates documentation comment describing the lifetime guarantees of CurlHttpOperation across async send. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…row-new failure - Add #include <new> so std::nothrow is not relied on transitively (review). - If new (std::nothrow) returns nullptr (OOM), result stays valid and would self-join (EDEADLK) at end of the noexcept dtor; abort() as a last resort instead of falling through to that, per review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…fix lifetime comments - Replace std::atomic<std::thread::id> (not guaranteed supported across standard libraries) with a plain std::thread::id published via an std::atomic<bool> flag using release/acquire ordering. - Correct the lifetime comments: the operation's last shared_ptr is held by the owning CurlHttpRequest (via SetOperation), not by EraseRequest (which only removes the raw id from m_requests). The self-join occurs when the async callback leads to that request being destroyed on the async thread (OnHttpResponse -> EventsUploadContext::clear()). Re-validated the wait-vs-detach logic with a standalone C++11 repro under AddressSanitizer + UBSan. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… reset flag on reuse - Reword the self-join comment: in that case Send() has returned (we are in its callback) but the async task itself has not yet returned (the destructor runs inside it), so the deferred helper's ~future join completes only after this destructor unwinds. Avoids implying the async task is already finished. - Reset m_asyncThreadIdSet to false at the start of SendAsync so self-join detection stays correct if the operation were ever reused (it is single-use today: one SendAsync per request). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…microsoft#1481) The EDEADLK self-join was a symptom of using std::async(std::launch::async) for the HTTP send: the returned std::future joins its worker thread on destruction, so when the async callback caused the operation to be destroyed on that same worker thread (OnHttpResponse -> EventsUploadContext::clear()), ~future self-joined and aborted the process out of the noexcept destructor. Rather than detect-and-defer that self-join (the previous approach: published thread id + atomic flag + heap-move the future to a detached helper, with OOM/ thread-exhaustion fallbacks), remove the joining future entirely: - CurlHttpOperation now derives from enable_shared_from_this. SendAsync runs Send() on a detached std::thread that holds a shared_ptr keepalive to the operation, so the operation (and its curl handle, response buffer, and by-reference request body) stays alive until the worker finishes -- the same lifetime guarantee the destructor's result.wait() used to provide. - There is no future, so ~CurlHttpOperation never joins anything and is safe on any thread, including the worker thread itself. The destructor drops to plain curl cleanup. - Removes the future member, the m_asyncThreadId/m_asyncThreadIdSet machinery, and the <future>/<new> includes. Net -54 lines in the client. Adds HttpClientCurlTests.SendAsync_DestroyOnWorkerThread_NoSelfJoin, which drops the last external reference from inside the callback (on the worker thread) -- the exact microsoft#1481 trigger. It aborts the process on the old std::async code and passes on this fix. Verified on Linux GCC 13: all HttpClientCurlTests (12) pass including the new regression; the full FuncTests suite (39) passes with the curl client. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… into bhamehta/fix-curl-async-self-join
…xceptions, tidy test - requestBody use-after-free (comments 1 & 3): the old blocking destructor kept the by-reference body alive because destroying the request waited for Send(). With the self-keepalive worker the operation can outlive the request, so a reference into CurlHttpRequest::m_body could dangle mid-send. CurlHttpOperation now takes the body by value and owns it, so it is valid for the operation's whole lifetime regardless of when the request is released. Costs one body copy per request (the prior zero-copy relied on the blocking wait that caused microsoft#1481). - Detached-worker exceptions (comment 2): an exception escaping Send()/callback would call std::terminate, whereas the old std::async captured (and effectively swallowed) it. Wrap the worker body in try/catch to preserve the non-terminating behavior. - Test (comment 4): replace the raw new/delete shared_ptr box with a shared_ptr<shared_ptr<CurlHttpOperation>> whose contained pointer is reset in the callback, so it cannot leak if SendAsync throws. Verified on Linux GCC 13: all HttpClientCurlTests (12) pass including the self-join regression; full FuncTests (39) pass with the by-value body. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Heap-own the TrackingCallback and capture the shared_ptr by value in the completion lambda so its lifetime is tied to the detached worker. Previously the stack callback was captured by reference: in the timeout/FAIL path the worker can still be running when the test returns, so it could read the callback after destruction (a use-after-free that could crash the whole test process). The final assertion now dereferences the shared_ptr. Files: tests/unittests/HttpClientCurlTests.cpp Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…lback cases The destructor runs after the detached worker releases its reference only when Send() ran asynchronously; it also runs for operations that were never sent or when SendAsync fell back to a synchronous run. Destruction is safe on any thread in all cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
RunSendAndCallback sets m_completed regardless of the send result (including an immediate curl_easy_init failure), so OnDestroy is dispatched only when the operation is destroyed without SendAsync ever having run -- not on construction failure. Reword the comment to match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…t path Mirror the stronger teardown from SendAsync_NoOnDestroyDispatchAfterCompletion: on the (unexpected) timeout path, wait for weakOp to expire after Abort so the detached worker cannot outlive fixture teardown (m_client/curl_global_cleanup, m_headers, m_body) and cause secondary crashes that obscure the real failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…s path The callback sets the promise, but the detached worker still holds its self- reference until RunSendAndCallback returns. Wait (bounded) for weakOp to expire before the test returns so the operation's curl_easy_cleanup cannot race with fixture teardown, matching the other async regression test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ests Extract a shared DrainOperation helper that waits for the operation to be destroyed and aborts a stuck worker as a fallback, then hard-asserts it is gone. Both async regression tests now ensure the detached worker (and its curl_easy_cleanup) cannot outlive fixture teardown (m_client -> curl_global_cleanup) on either the success or timeout path, rather than returning while the worker might still run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
These directly-constructed operations are not tracked by HttpClient_Curl::m_activeOps, so nothing else bounds the race between a lingering worker's curl_easy_cleanup and the fixture's curl_global_cleanup. DrainOperationOrDie now aborts a stuck worker and, if the operation is still alive afterward (a genuine keepalive/abort regression), records a failure and std::abort()s rather than returning into fixture teardown with an in-flight curl worker. In practice the .invalid host fails DNS in milliseconds so the operation is always gone immediately. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Move curl request tracking into shared state captured by detached workers so late callbacks do not dereference HttpClient_Curl after the shutdown drain times out. Preserve the bounded drain before curl_global_cleanup and abandon late callbacks/logging when shutdown cannot safely wait. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.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.
Problem
cpp_client_telemetry_modules(cpp_client_telemetry_modules) CI on Linux + macOS crashes during the firstECSClientFuncTeststest with:Affects every PR that gets past compile.
Root cause
The callback fired from the async thread is the last owner of the
CurlHttpOperation. When it releases itsshared_ptr,~CurlHttpOperationruns on that same async thread.~CurlHttpOperationthen destroys itsstd::future<long> resultmember. libstdc++'s~future->~_Async_state_implcalls_M_complete_async->_M_joinviastd::call_once. Joining the async thread from the async thread is a self-join —call_oncethrowsstd::system_error("Resource deadlock avoided"). Because the throw escapes a noexcept destructor,terminate()aborts the process.A
try/catcharound the future or even around an explicitresult.wait()cannot rescue this:std::future's destructor itself isnoexcept, so the throw lands at the noexcept boundary before any usercatchcan engage.Fix
Remove the joining future entirely rather than working around its self-join.
std::async(std::launch::async)is the root of the problem: thestd::futureit returns joins its worker thread on destruction, so any path that destroys the operation on that worker thread self-joins. Instead of detecting that case and deferring the join, this dropsstd::async:CurlHttpOperationnow derives fromstd::enable_shared_from_this.SendAsyncrunsSend()on a detachedstd::threadthat holds ashared_ptrkeepalive to the operation, so the operation — and its curl handle and response buffer — stays alive until the worker finishes. That is the same lifetime guarantee the destructor'sresult.wait()used to provide.~CurlHttpOperationnever joins anything and is safe on any thread, including the worker thread itself (where it runs when the callback drops the last other reference). The destructor collapses to plain curl cleanup.IHttpRequest: because the detached worker can now outlive the request, a reference into the caller's buffer could dangle mid-send. The call site copies (rather than moves) the body on purpose: the request'sm_bodyis read again after the send --HttpResponseDecoderemits the request payload onEVT_HTTP_OK/EVT_HTTP_ERRORwhenrequestDoneruns the decode chain -- so moving it out would leave those debug events with an empty payload, a curl-only regression versus the WinInet and NSURLSession clients.SendAsyncis total — it never lets an exception escape and always invokes theIHttpClientcallback.shared_from_this()(non-shared owner →std::bad_weak_ptr), the worker/callable construction (std::functioncopy →std::bad_alloc), and the thread start (std::system_error/std::bad_alloc) are all guarded; on any of these the operation runs synchronously on the caller's thread as a fallback.resultfuture member and the self-join-detection machinery.No API change;
std::asyncalready spawned one thread per send, so thread cost is unchanged.Verification
Verified on Linux (GCC 13 + clang 18):
HttpClientCurlTests.SendAsync_DestroyOnWorkerThread_NoSelfJoindrops the last external reference from inside the callback (on the worker thread) — the exact destruction-on-worker-thread trigger. It aborts the process on the oldstd::asynccode and passes on this fix.HttpClientCurlTests.SendAsync_NotSharedOwned_RunsSynchronouslyNoThrowcovers the non-shared-owner synchronous fallback.HttpClientCurlTests(13) pass, plus the fullFuncTestssuite (39) with the curl client — no hang, no behavioral regression.The original modules-CI repro (
ECSClientFuncTests, which links the modules submodule) continues to be covered by that repo's CI; the new unit test reproduces the same destruction-on-worker-thread path directly here so it is guarded in this repo too.Risk: low. One short-lived thread per send (unchanged from
std::async); no API or behavior change.