diff --git a/lib/http/HttpClient_Curl.cpp b/lib/http/HttpClient_Curl.cpp index b910cdf28..e69b7b31e 100644 --- a/lib/http/HttpClient_Curl.cpp +++ b/lib/http/HttpClient_Curl.cpp @@ -54,7 +54,44 @@ namespace MAT_NS_BEGIN { HttpClient_Curl::~HttpClient_Curl() { - curl_global_cleanup(); + auto state = m_state; + auto activeOps = state->activeOps; + + // Detached worker threads run curl_easy_cleanup in ~CurlHttpOperation after + // the request callback has already been removed from HttpClientManager's + // tracking, so waiting only on that tracking is not enough. Wait (bounded) + // for all in-flight operations to finish their easy-handle cleanup before + // curl_global_cleanup, which must not run concurrently with it. + bool drained; + { + std::unique_lock lock(activeOps->mtx); + drained = activeOps->cv.wait_for(lock, std::chrono::seconds(5), + [activeOps] { return activeOps->inFlight == 0; }); + if (!drained) + { + TRACE("~HttpClient_Curl: %d operation(s) still in flight after 5s; skipping curl_global_cleanup\n", activeOps->inFlight); + } + } + if (!drained) + { + activeOps->abandonCallbacks.store(true, std::memory_order_release); + std::lock_guard lock(state->requestsMtx); + // Detached workers capture this shared state, not HttpClient_Curl. If the + // bounded drain times out, the client object is about to be destroyed; do + // not retain raw request pointers or dispatch late response/logging + // callbacks that may refer to shutdown-owned state. The worker will erase + // no-op and drop the response instead of dereferencing the destroyed client. + state->requests.clear(); + } + // curl_global_cleanup must not run concurrently with any other libcurl use, + // including the curl_easy_cleanup that in-flight CurlHttpOperation destructors + // run on their detached workers. If the drain timed out, skip it: leaking + // libcurl's global state once at shutdown is safer than the crash/UB of tearing + // it down while an easy handle is still live on another thread. + if (drained) + { + curl_global_cleanup(); + } TRACE("Destroyed HttpClient_Curl.\n"); }; @@ -65,6 +102,8 @@ namespace MAT_NS_BEGIN { void HttpClient_Curl::SendRequestAsync(IHttpRequest* request, IHttpResponseCallback* callback) { + auto state = m_state; + // Note: 'request' is never owned by IHttpClient and gets deleted in EventsUploadContext.clear() AddRequest(request); auto curlRequest = static_cast(request); @@ -77,16 +116,43 @@ namespace MAT_NS_BEGIN { std::string sslCaInfo; { - std::lock_guard lock(m_requestsMtx); - sslCaInfo = m_sslCaInfo; + std::lock_guard lock(state->requestsMtx); + sslCaInfo = state->sslCaInfo; } + // Copy the request body into the operation instead of moving it out. The + // detached send needs an owned buffer (the request can be released -- e.g. by + // cancellation -- while the worker is still sending), but the request's + // m_body is also read again after the send: HttpResponseDecoder emits the + // request payload on EVT_HTTP_OK / EVT_HTTP_ERROR when requestDone runs the + // decode chain, before the request is released. Moving it out would leave + // those debug events with an empty payload -- a curl-only regression versus + // the WinInet and NSURLSession clients, which leave the request intact. auto curlOperation = std::make_shared(curlRequest->m_method, curlRequest->m_url, callback, requestHeaders, curlRequest->m_body, false, HTTP_CONN_TIMEOUT, m_sslVerify, sslCaInfo); + // Count this operation before the async send starts so ~HttpClient_Curl waits + // for its curl_easy_cleanup to complete before curl_global_cleanup. + curlOperation->trackWith(state->activeOps); curlRequest->SetOperation(curlOperation); - - // The lifetime of curlOperation is guarnteed by the call to result.wait() in the d'tor. - curlOperation->SendAsync([this, callback, requestId](CurlHttpOperation& operation) { - this->EraseRequest(requestId); + + // The async Send() runs on a detached worker that holds its own shared_ptr + // to curlOperation (see CurlHttpOperation::SendAsync), so the operation -- + // and its curl handle, response buffer and owned copy of the request body -- + // stay alive until Send() and the callback below have finished, regardless + // of when the owning CurlHttpRequest is released. If the callback leads to + // that request being destroyed on the worker thread (OnHttpResponse -> + // EventsUploadContext::clear()), the operation is simply destroyed there + // once the worker returns; there is no future to join. + curlOperation->SendAsync([state, callback, requestId](CurlHttpOperation& operation) { + const bool abandonCallback = state->activeOps->abandonCallbacks.load(std::memory_order_acquire); + { + std::lock_guard lock(state->requestsMtx); + state->requests.erase(requestId); + } + if (abandonCallback) + { + TRACE("HttpClient_Curl shutdown abandoned response callback for %s\n", requestId.c_str()); + return; + } auto response = std::unique_ptr(new SimpleHttpResponse(requestId)); response->m_result = HttpResult_OK; @@ -116,14 +182,16 @@ namespace MAT_NS_BEGIN { void HttpClient_Curl::CancelRequestAsync(std::string const& id) { + auto state = m_state; CurlHttpRequest* request = nullptr; { // Hold the lock only while iterating over the list of requests - std::lock_guard lock(m_requestsMtx); - if (m_requests.find(id) != m_requests.cend()) { - request = static_cast(m_requests[id]); + std::lock_guard lock(state->requestsMtx); + auto requestIt = state->requests.find(id); + if (requestIt != state->requests.cend()) { + request = static_cast(requestIt->second); LOG_TRACE("HTTP request=%p id=%s being aborted...", request, id.c_str()); - m_requests.erase(id); + state->requests.erase(requestIt); } } @@ -142,23 +210,18 @@ namespace MAT_NS_BEGIN { void HttpClient_Curl::SetSslVerification(bool sslVerify, const std::string& caInfo) { m_sslVerify = sslVerify; - std::lock_guard lock(m_requestsMtx); - m_sslCaInfo = caInfo; - } - - void HttpClient_Curl::EraseRequest(std::string const& id) - { - std::lock_guard lock(m_requestsMtx); - m_requests.erase(id); + auto state = m_state; + std::lock_guard lock(state->requestsMtx); + state->sslCaInfo = caInfo; } void HttpClient_Curl::AddRequest(IHttpRequest* request) { - std::lock_guard lock(m_requestsMtx); - m_requests[request->GetId()] = request; + auto state = m_state; + std::lock_guard lock(state->requestsMtx); + state->requests[request->GetId()] = request; } } MAT_NS_END #endif - diff --git a/lib/http/HttpClient_Curl.hpp b/lib/http/HttpClient_Curl.hpp index b1bb5344c..beada6ed2 100644 --- a/lib/http/HttpClient_Curl.hpp +++ b/lib/http/HttpClient_Curl.hpp @@ -17,11 +17,19 @@ #include #include #include +#include #include #include -#include +#include #include +#include +#include +#include +#include +#include +#include +#include #include #include @@ -44,6 +52,32 @@ namespace MAT_NS_BEGIN { +// Tracks the number of in-flight CurlHttpOperations so ~HttpClient_Curl can wait for +// their detached-worker curl_easy_cleanup to finish before it runs +// curl_global_cleanup (the two must not run concurrently). If shutdown times +// out, abandonCallbacks tells late workers to skip callback/log dispatch. +struct CurlOperationTracker { + std::mutex mtx; + std::condition_variable cv; + int inFlight = 0; + std::atomic abandonCallbacks { false }; +}; + +// State shared with detached curl worker callbacks. A worker can outlive +// HttpClient_Curl if shutdown's bounded drain times out, so callbacks must only +// touch this shared state and never capture/dereference the parent client. +struct CurlClientSharedState { + CurlClientSharedState() : + activeOps(std::make_shared()) + { + } + + std::mutex requestsMtx; + std::map requests; + std::string sslCaInfo; + std::shared_ptr activeOps; +}; + /** * Curl-based HTTP client */ @@ -60,20 +94,21 @@ class HttpClient_Curl : public IHttpClient { void SetSslVerification(bool sslVerify, const std::string& caInfo = ""); private: - void EraseRequest(std::string const& id); void AddRequest(IHttpRequest* request); - std::mutex m_requestsMtx; - std::map m_requests; + std::shared_ptr m_state { std::make_shared() }; std::atomic m_sslVerify { true }; - std::string m_sslCaInfo; }; -class CurlHttpOperation { +class CurlHttpOperation : public std::enable_shared_from_this { public: void DispatchEvent(HttpStateEvent type) { + if (m_tracker && m_tracker->abandonCallbacks.load(std::memory_order_acquire)) + { + return; + } if (m_callback != nullptr) { m_callback->OnHttpStateEvent(type, static_cast(curl), 0); @@ -82,6 +117,11 @@ class CurlHttpOperation { std::atomic isAborted { false }; // Set to 'true' when async callback is aborted + // Set once the completion callback has run. After that point the externally + // owned IHttpResponseCallback (m_callback) may already be destroyed, so it must + // not be dispatched to again (see ~CurlHttpOperation). + std::atomic m_completed { false }; + /** * Create local CURL instance for url and body * @@ -94,11 +134,13 @@ class CurlHttpOperation { std::string method, std::string url, IHttpResponseCallback* callback, - // requestHeaders is copied into the curl_slist during construction - // and need not outlive this operation. requestBody is stored by - // reference and read by Send(), so it must outlive this operation. + // requestHeaders is copied into the curl_slist during construction and + // need not outlive this operation. requestBody is taken by value and + // owned by this operation: the detached worker in SendAsync can outlive + // the caller's request, so a reference into it could dangle during + // Send(). const std::map& requestHeaders, - const std::vector& requestBody, + std::vector requestBody, // Default connectivity and response size options bool rawResponse = false, size_t httpConnTimeout = HTTP_CONN_TIMEOUT, @@ -116,7 +158,7 @@ class CurlHttpOperation { m_sslCaInfo(sslCaInfo), // Local vars - requestBody(requestBody) + requestBody(std::move(requestBody)) { TRACE("--------------------------------------------------------------------------------------------------\n"); response.memory = nullptr; @@ -173,17 +215,56 @@ class CurlHttpOperation { */ virtual ~CurlHttpOperation() { - // Given the request has not been aborted we should wait for completion here - // This guarantees the lifetime of this request. - if (result.valid()) + // When Send() ran asynchronously, it was on a detached worker that held a + // shared_ptr to this operation (see SendAsync), so this destructor runs only + // after that worker finished and released its reference; the curl handle, + // response buffer and owned request body are then no longer in use. It can also + // run without any async worker: for an operation that was never sent, or when + // SendAsync fell back to a synchronous run on the caller's thread. There is no + // future to join in any case, so destruction is safe on any thread -- including + // the worker thread itself, which is where it happens when the callback drops + // the last other reference. + // OnDestroy is dispatched only when this operation is destroyed without its send + // ever having run -- i.e. SendAsync was never called. Once RunSendAndCallback + // runs it sets m_completed regardless of the result (even when Send() fails + // immediately, e.g. curl_easy_init returns an error), and once the completion + // callback has run m_callback may already be freed: synchronous-handler builds + // delete the IHttpResponseCallback inside onHttpResponse, called from the + // completion callback. Dispatching through it then would be a use-after-free, so + // it is suppressed. (Consequently the curl client does not emit OnDestroy for a + // request whose send was attempted.) + if (!m_completed.load(std::memory_order_acquire)) { - result.wait(); + DispatchEvent(OnDestroy); } - DispatchEvent(OnDestroy); res = CURLE_OK; curl_easy_cleanup(curl); curl_slist_free_all(m_headersChunk); ReleaseResponse(); + + // Signal HttpClient_Curl that this operation's curl_easy_cleanup is done, so + // its destructor can safely run curl_global_cleanup once all operations end. + if (m_tracker) + { + std::lock_guard lock(m_tracker->mtx); + if (--m_tracker->inFlight == 0) + { + m_tracker->cv.notify_all(); + } + } + } + + // Associate this operation with HttpClient_Curl's in-flight tracker so its + // lifetime (through the curl_easy_cleanup in the destructor above) is awaited + // before curl_global_cleanup. Called once, before the async send starts. + void trackWith(std::shared_ptr tracker) + { + m_tracker = std::move(tracker); + if (m_tracker) + { + std::lock_guard lock(m_tracker->mtx); + ++m_tracker->inFlight; + } } /** @@ -317,14 +398,97 @@ class CurlHttpOperation { return res; } - std::future & SendAsync(std::function callback = nullptr) { - result = std::async(std::launch::async, [this, callback] { - long result = Send(); - if (callback!=nullptr) + // Runs the blocking Send() and then the callback, guaranteeing the callback is + // invoked exactly once and that no exception escapes (a detached worker must not + // let one escape -> std::terminate; std::async previously captured exceptions in + // its never-observed future). Shared by the detached worker and the synchronous + // fallbacks in SendAsync(). + void RunSendAndCallback(const std::function& callback) { + try + { + Send(); + } + catch (const std::exception& e) + { + TRACE("CurlHttpOperation Send() failed by exception: %s\n", e.what()); + res = CURLE_FAILED_INIT; // report a failure result to the callback + } + catch (...) + { + TRACE("CurlHttpOperation Send() failed by unknown exception\n"); + res = CURLE_FAILED_INIT; + } + // Invoke the callback even if Send() threw, so the operation is always + // completed (with the failure result set above) and the request is never + // left outstanding. Guard it so a throwing callback cannot escape either. + if (callback != nullptr) + { + try + { callback(*this); - return result; - }); - return result; + } + catch (const std::exception& e) + { + TRACE("CurlHttpOperation callback threw: %s\n", e.what()); + } + catch (...) + { + TRACE("CurlHttpOperation callback threw unknown exception\n"); + } + } + // The send has completed. The completion callback (if any) may have destroyed + // the IHttpResponseCallback -- synchronous-handler builds run onHttpResponse, + // which deletes it -- so m_callback must not be dispatched to after this point. + // Set completion regardless of whether a callback was provided: a request that + // was actually sent must never emit OnDestroy from the destructor. + m_completed.store(true, std::memory_order_release); + } + + void SendAsync(std::function callback = nullptr) { + // Run the blocking Send() on a detached worker that keeps this operation + // alive for the duration by holding a shared_ptr to itself. This replaces + // std::async, whose returned future joins its worker thread on destruction: + // when the callback below caused this operation to be destroyed on the + // async thread (OnHttpResponse -> EventsUploadContext::clear()), that join + // was a self-join and raised std::system_error("Resource deadlock avoided") + // out of the noexcept destructor, aborting the process. With + // the self-keepalive there is no future and no join: the worker simply + // exits, releasing the last reference, and ~CurlHttpOperation runs + // trivially on whichever thread drops it. + std::shared_ptr self; + try + { + self = shared_from_this(); + } + catch (const std::bad_weak_ptr&) + { + // The detached-worker self-keepalive requires this operation to be owned + // by a std::shared_ptr (it always is in practice -- created via + // make_shared in HttpClient_Curl.cpp). If a future caller ever constructs + // one outside a shared_ptr (stack / unique_ptr), shared_from_this() throws; + // fall back to a synchronous run on the caller's thread rather than letting + // std::bad_weak_ptr escape SendAsync(). The caller owns the object for the + // duration and the callback is still invoked. + RunSendAndCallback(callback); + return; + } + try + { + // Constructing the worker lambda copies `callback` (a std::function, + // which can throw std::bad_alloc), and std::thread construction can throw + // std::system_error / std::bad_alloc -- both are inside this try. The + // worker holds `self`, keeping this operation alive for the detached run. + std::thread([self, callback]() { self->RunSendAndCallback(callback); }).detach(); + } + catch (const std::exception& e) + { + // Building the callable or starting the worker thread failed. Run the + // operation synchronously as a fallback so the IHttpClient callback is + // still always invoked and the exception does not escape SendAsync(). + // `self` keeps this operation alive for the duration of the run. + TRACE("CurlHttpOperation could not start worker thread: %s; running synchronously\n", e.what()); + RunSendAndCallback(callback); + } } /** @@ -437,18 +601,20 @@ class CurlHttpOperation { CURL *curl; // Local curl instance CURLcode res = CURLE_OK; // Curl result OR HTTP status code if successful - + IHttpResponseCallback* m_callback = nullptr; + // In-flight tracker shared with HttpClient_Curl; decremented in the destructor. + std::shared_ptr m_tracker; + // Request values std::string m_method; std::string m_url; std::string m_sslCaInfo; - // The SDK upload path keeps the owning IHttpRequest alive through the - // callback context until Send() completes; copying this body would duplicate - // every upload payload. Unlike CURLOPT_CAINFO, the body pointer is set and - // consumed during Send(), not retained from construction. - const std::vector& requestBody; + // Owned copy of the request body, read by Send(). Owned (not a reference into + // the caller's IHttpRequest) because the detached worker in SendAsync can + // outlive that request, so a reference could dangle mid-send. + std::vector requestBody; struct curl_slist *m_headersChunk = nullptr; // Processed response headers and body @@ -464,8 +630,6 @@ class CurlHttpOperation { size_t sendlen = 0; // # bytes sent by client size_t acklen = 0; // # bytes ack by server - std::future result; - /** * Helper routine to wait for data on socket * diff --git a/tests/unittests/HttpClientCurlTests.cpp b/tests/unittests/HttpClientCurlTests.cpp index c9894b90d..383c5f565 100644 --- a/tests/unittests/HttpClientCurlTests.cpp +++ b/tests/unittests/HttpClientCurlTests.cpp @@ -12,6 +12,13 @@ #include "http/HttpClient_Curl.hpp" #include "config/RuntimeConfig_Default.hpp" +#include +#include +#include +#include +#include +#include + using namespace testing; using namespace MAT; @@ -24,6 +31,32 @@ class HttpClientCurlTests : public ::testing::Test const std::vector m_body; }; +// Wait for a detached async operation to be fully destroyed before the test returns, so +// the worker's curl_easy_cleanup cannot race fixture teardown (m_client -> +// curl_global_cleanup). These operations are not tracked by HttpClient_Curl::m_activeOps, +// so nothing else bounds that race. The .invalid host fails DNS in milliseconds, so this +// normally completes immediately; a stuck worker is aborted as a fallback. If the +// operation is STILL alive after that (a genuine keepalive/abort regression), hard-stop +// the process rather than proceed into curl_global_cleanup with an in-flight curl worker. +static void DrainOperationOrDie(const std::weak_ptr& weakOp) +{ + for (int i = 0; i < 500 && !weakOp.expired(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + if (!weakOp.expired()) + { + if (auto liveOp = weakOp.lock()) + liveOp->Abort(); + for (int i = 0; i < 500 && !weakOp.expired(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + if (!weakOp.expired()) + { + ADD_FAILURE() << "detached curl worker did not terminate after abort; hard-stopping " + "so curl_global_cleanup cannot run concurrently with an in-flight worker"; + std::abort(); + } +} + // --- SetSslVerification wiring --- TEST_F(HttpClientCurlTests, SslVerification_DefaultsToTrue) @@ -126,4 +159,134 @@ TEST_F(HttpClientCurlTests, SetSslVerification_ConcurrentCallsNoRace) SUCCEED(); } +// --- Regression: EDEADLK self-join in ~CurlHttpOperation --- + +// When the async callback drops the last *external* reference to the operation, +// ~CurlHttpOperation runs on the worker thread. The old std::async design joined +// its own future there (self-join) and aborted the process with +// std::system_error("Resource deadlock avoided"). The worker now holds a +// shared_ptr keepalive and there is no future, so destruction on the worker thread +// is trivial and safe. This test aborts the process on the old code and passes on +// the fix. +TEST_F(HttpClientCurlTests, SendAsync_DestroyOnWorkerThread_NoSelfJoin) +{ + // Heap-owned promise so a captured copy keeps it alive: if the ASSERT below + // fails and the test returns early, the still-detached worker can safely call + // set_value() on it instead of touching a destroyed stack promise. + auto callbackDone = std::make_shared>(); + auto done = callbackDone->get_future(); + + // Host under the RFC 6761 reserved .invalid TLD never resolves, so Send() fails + // fast and deterministically (name resolution error) on any environment -- + // unlike a fixed port, which could happen to be open. + auto op = std::make_shared( + "GET", "http://selfjoin.regression.invalid/", nullptr, m_headers, m_body, + false, 1 /*connTimeout*/, false /*sslVerify*/, ""); + + // Non-owning handle, used only to cancel the worker on the timeout path below. + // It must not keep the operation alive, or the callback's box->reset() would no + // longer drop the last external reference (the exact scenario under test). + std::weak_ptr weakOp = op; + + // A shared box holds the only external reference. The callback resets the + // contained shared_ptr (on the worker thread) to drop the last external + // reference -- the exact trigger -- without raw new/delete. + auto box = std::make_shared>(std::move(op)); + + (*box)->SendAsync([box, callbackDone](CurlHttpOperation&) { + // Runs on the worker thread. Drop the last external reference here. On the + // old code this destroyed the operation on this thread and self-joined its + // own future -> abort. With the keepalive fix the worker still holds a + // reference, so this is safe and the operation is destroyed once the worker + // returns. + box->reset(); + callbackDone->set_value(); + }); + + const bool completed = (done.wait_for(std::chrono::seconds(15)) == std::future_status::ready); + + // Make sure the operation is destroyed before this test returns, regardless of + // whether the send completed: the callback sets the promise while the detached + // worker still holds its self-reference, so the worker (and its curl_easy_cleanup) + // can outlive this frame and race fixture teardown (m_client -> curl_global_cleanup). + DrainOperationOrDie(weakOp); + EXPECT_TRUE(completed) << "SendAsync did not complete within 15s"; +} + +// A stack-constructed operation is not owned by a shared_ptr, so shared_from_this() +// throws std::bad_weak_ptr. SendAsync() must not let that escape: it falls back to a +// synchronous run and still invokes the callback. +TEST_F(HttpClientCurlTests, SendAsync_NotSharedOwned_RunsSynchronouslyNoThrow) +{ + CurlHttpOperation op( + "GET", "http://selfjoin.regression.invalid/", nullptr, m_headers, m_body, + false, 1 /*connTimeout*/, false /*sslVerify*/, ""); + + bool callbackRan = false; + // No shared owner -> the fallback runs Send()+callback synchronously on this + // thread, so SendAsync() returns only after the callback has run. Capturing + // callbackRan by reference is therefore safe. + op.SendAsync([&callbackRan](CurlHttpOperation&) { callbackRan = true; }); + + EXPECT_TRUE(callbackRan); +} + +// Regression test for the completion-path use-after-free: in synchronous-handler +// builds the IHttpResponseCallback is deleted inside the completion callback +// (HttpClientManager::onHttpResponse), while the operation is kept alive slightly +// longer by the detached worker's self-reference. The destructor must therefore +// NOT dispatch OnDestroy through m_callback once the completion callback has run, +// or it would touch a freed callback. Here the callback is kept alive so the +// dispatch is observable: it must not happen after completion. +TEST_F(HttpClientCurlTests, SendAsync_NoOnDestroyDispatchAfterCompletion) +{ + struct TrackingCallback : public IHttpResponseCallback + { + std::atomic completed { false }; + std::atomic onDestroyAfterComplete { 0 }; + void OnHttpResponse(IHttpResponse* response) override { delete response; } + void OnHttpStateEvent(HttpStateEvent state, void*, size_t) override + { + if (state == OnDestroy && completed.load()) + onDestroyAfterComplete++; + } + }; + // Heap-own the callback and tie its lifetime to the detached worker (the completion + // lambda below captures the shared_ptr by value). In the timeout/FAIL path the worker + // may still be running when this test returns, so a stack callback captured by + // reference could be read after it is destroyed -- a use-after-free. + auto cb = std::make_shared(); + + auto callbackDone = std::make_shared>(); + auto done = callbackDone->get_future(); + + auto op = std::make_shared( + "GET", "http://selfjoin.regression.invalid/", cb.get(), m_headers, m_body, + false, 1 /*connTimeout*/, false /*sslVerify*/, ""); + std::weak_ptr weakOp = op; + auto box = std::make_shared>(std::move(op)); + + (*box)->SendAsync([box, callbackDone, cb](CurlHttpOperation&) { + // Mark completion, then drop the last external reference on the worker + // thread -- mirroring onHttpResponse deleting the callback and releasing + // the request while the worker still holds its self-reference. + cb->completed.store(true); + box->reset(); + callbackDone->set_value(); + }); + + const bool completed = (done.wait_for(std::chrono::seconds(15)) == std::future_status::ready); + + // Ensure the operation is destroyed before this test returns so the worker cannot + // outlive fixture teardown (m_client -> curl_global_cleanup); cb is heap-owned and + // captured by the worker, so it stays alive on its own. + DrainOperationOrDie(weakOp); + ASSERT_TRUE(completed) << "SendAsync did not complete within 15s"; + // Let the destructor body finish so a missing OnDestroy guard (which would increment + // the counter inside ~CurlHttpOperation) is observed rather than raced past. + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + + EXPECT_EQ(cb->onDestroyAfterComplete.load(), 0); +} + #endif // MATSDK_PAL_CPP11 && !_MSC_VER && HAVE_MAT_DEFAULT_HTTP_CLIENT