Skip to content

[BUG] Check what curl_slist_append and curl_multi_init return - #4406

Draft
thc1006 wants to merge 38 commits into
open-telemetry:mainfrom
thc1006:bugfix/check-curl-allocation-failures-4404
Draft

[BUG] Check what curl_slist_append and curl_multi_init return#4406
thc1006 wants to merge 38 commits into
open-telemetry:mainfrom
thc1006:bugfix/check-curl-allocation-failures-4404

Conversation

@thc1006

@thc1006 thc1006 commented Aug 11, 2026

Copy link
Copy Markdown
Member

Fixes #4404

Changes

Two allocation points whose failure was read as success. Each sits next to a check the same file already makes, which is what makes them look like oversights rather than a decision.

curl_slist_append

The docs name this pattern: "A null pointer is returned if anything went wrong, otherwise the new list pointer is returned. To avoid overwriting an existing non-empty list on failure, the new list should be returned to a temporary variable which can be tested for NULL before updating the original list pointer."

Assigning straight back over headers_chunk is the opposite of that. The pointer to everything appended so far goes with it, so the two places that free the list, Cleanup() and doRemoveSessions(), both see null and neither runs. Then the constructor carries on and dispatches Created, and Setup() only sets CURLOPT_HTTPHEADER when headers_chunk is non-null, so the request goes out with none of the caller's headers rather than not going out at all. For an OTLP export that is no Content-Type: application/x-protobuf and a receiver rejecting a request the exporter believes it sent correctly.

It appends into a temporary now, and on failure the partial list is released.

Reporting it is not the whole fix, which is the part I had wrong when I first opened this. Returning from the constructor does not stop Session::SendRequest: it calls SendAsync unconditionally, the easy handle is still valid, and Setup() only skips CURLOPT_HTTPHEADER rather than failing. So the first version of this announced CreateFailed and then sent the request anyway, without the caller's headers, which is the behaviour this PR exists to stop. Measured three runs out of three before the fix, one request reaching the test server each time.

The construction result is now held on the operation, Send() and SendAsync() both refuse on it before Setup(), and Session::SendRequest reports it once with the reason curl gave rather than the empty string that branch used to pass. The constructor also marks the operation terminal, or Cleanup() from ~HttpOperation announces a cancel for something that never started, to a handler the caller may already have dropped. That one aborts with pure virtual method called, and it only showed up once the send was actually stopped.

curl_multi_init

Same shape, three sites: both HttpClient constructors and resetMultiHandle(). The docs: "If this function returns NULL, something went wrong and you cannot use the other curl functions."

What a null one does, measured rather than assumed, against libcurl 8.14.1:

multi_add_handle(NULL, easy)    = 1  (Invalid multi handle)
multi_remove_handle(NULL, easy) = 1  (Invalid multi handle)
  CURLM_BAD_HANDLE              = 1

doAddSessions() drops that return, so such a client would accept every session, add none of them to a multi handle, and complete none of them. resetMultiHandle() is the one I would worry about most: it runs while recovering from a multi error, so a failure there turns a working client into a black hole for the rest of its life with nothing logged. All three now go through one small helper that logs.

HttpClient::wakeupBackgroundThread() already guards the same member for null before curl_multi_wakeup, so a null handle is something this file half expects. This makes it visible rather than silent.

The IO thread, once the handle is null

@lalitb asked whether logging the failure changes anything, since the client still takes requests. Following that down found two things it does not change.

A client built on a null multi handle does answer. curl_multi_perform rejects the handle, the loop creates a new one, the pending session moves to it, and the request completes. Measured three runs out of three, with the internal log captured in the same run so the numbers are not describing a healthy client. So refusing at SendRequest would trade a transient failure that repairs itself for a client that is permanently dead.

What does not recover is a handle that can never be created, and that used to be much worse than a missing feature. curl_multi_perform leaves still_running alone when it rejects the handle, and the loop initializes it to one, so the client kept reporting work it did not have: every pass took the still_running > 0 branch, which is above the is_shutdown_ check and above the wait. With the client alive, that cost 3.00 seconds of CPU and 1,168,126 error lines over a three second window, three runs out of three, and ~HttpClient could never join the thread.

Three changes close it. curl_multi_perform and curl_multi_info_read are not called with a handle libcurl refused to give, which is what curl_multi_init asks for. resetMultiHandle() reports whether the client has a handle afterwards. And a run of failures is reported once and waited on for as long as a poll would have taken. The same window now costs 0.00 seconds of CPU and two log lines.

Two things that reviewing the change turned up, rather than review. The wait cannot be interrupted, because wakeupBackgroundThread reaches the worker through the multi handle and the branch exists precisely because there is not one, so destroying a client whose worker was inside it measured 168 ms. It is taken in 16 ms slices with is_shutdown_ rechecked now, which measures 10 ms, five runs out of five. And curl_multi_poll and curl_multi_wait need no guard, since they sit inside the branch that only runs when perform succeeded. curl_multi_add_handle and curl_multi_remove_handle do get a null handle, and the section below is what became of that.

The handle was also created before libcurl was initialized

Members are initialized in declaration order, and multi_handle_ is declared before curl_global_initializer_, so both constructors called curl_multi_init before HttpCurlGlobalInitializer had called curl_global_init. libcurl asks for the opposite. No CI job could see it: the allocation cases install their allocators from SetUpTestSuite with curl_global_init_mem, which initializes libcurl before any client exists. The handle is created in the constructor body now, after every member including that one.

Tests

Both failures are covered now, and the first thing to say is that I was wrong above. I wrote that neither could be forced without an allocator hook. The hook is curl_global_init_mem, a documented part of libcurl, so that was a reason not to go looking rather than a reason it could not be done.

libcurl routes its internal allocations through the callbacks that function is given, so the suite installs a set of them and arms a thread local switch around the call under test. Thread local matters: once installed these callbacks serve the whole binary, and a client's background thread has to keep allocating normally while one test is arming a failure.

The switch is per allocation function rather than per call count, which is what keeps it independent of how many allocations a particular libcurl happens to make. Measured against 8.14.1:

malloc calloc strdup
curl_easy_init 0 1 4
curl_slist_append 1 0 1
curl_multi_init 0 8 4

Failing malloc alone therefore selects the list append and leaves the easy handle intact, and failing calloc selects the multi handle. The header case also holds the reported reason to "Out of memory" rather than "Failed initialization", so it cannot pass on a curl_easy_init failure by accident, and it asserts what the fix is actually for: the server receives no request, and exactly one terminal outcome reaches the handler.

The flag those hooks set is std::atomic<bool>. It was a plain bool in the first version and the Bazel TSAN job reported the race, since libcurl calls the callbacks from whichever thread is allocating, including a client's background thread.

curl_global_init_mem returns CURLE_OK and quietly changes nothing once libcurl has been initialised, so the hooks go in from SetUpTestSuite. A third case asserts they were installed in time, since otherwise the other two would fail with a confusing message rather than a clear one.

Fail before and pass after, reverting one production file at a time:

reverted result
the curl_slist_append change only AFailedHeaderAllocationIsReported fails
the curl_multi_init change only AFailedMultiHandleAllocationIsReported fails
nothing 26 of 26 pass

Checks

result
curl_http_test, OTELCPP_MAINTAINER_MODE=ON 27 of 27, 0 warnings, rebased over #4394
the same suite without WITH_STL 26 of 26, so dropping <memory> costs nothing off the CXX14 path
clang-format 18.1.8 clean on all three changed files
clang-tidy 22 with the CI filters 0 on the test file and on http_client_curl.cc, and 4 on http_operation_curl.cc which is exactly what main reports for it
include-what-you-use 0.26 with --mapping_file=.iwyu.imp, CXX14 0 diagnostics on all three, having first reported and then cleared two

Installed surface

Correcting myself on where these classes live, because I had it wrong here. ext/CMakeLists.txt installs four headers from ext/http/client, and http_client_curl.h and http_operation_curl.h are not among them: they are the implementation behind http_client_factory_curl.h. So a CMake consumer does not see either layout. Bazel is the other way round, since //ext takes hdrs = glob(["include/**/*.h"]), so a Bazel consumer can include both. That difference is its own question and not this one.

With that said, two layouts move. HttpOperation gains construction_result_, and HttpClient gains wakeup_generation_, an atomic the producers raise so the background thread can be woken without a multi handle. Both are private, and HttpClient::resetMultiHandle() becoming bool is private too. If the project holds these two to a layout guarantee anyway, say so: the construction result can be carried in the existing last_curl_result_ and session_state_, and the wakeup counter can go behind the existing multi_handle_m_ instead of being its own member.

What review turned up after that, and what came of it

Six things in the change itself, each measured before it was accepted and each with a case that fails without the fix, and five in the cases.

The worker could retire holding a request. Without a multi handle the add, remove and retry phases are skipped, and the retirement check read that as having nothing to do, so a request the client had already accepted could be sitting in pending_to_add_session_ids_ while the thread that was going to schedule it detached and left. Nothing brought it back, and FinishSession waited on a promise nobody was left to fulfil. How long that took depended on the libcurl the job was built against: the idle grace is a minute, but the line that sets it is behind a version check and older libcurl leaves it at zero, where the thread reaches the retirement check on its first idle pass. CMake asks for no minimum libcurl, so both are supported, and that is why the case was red on some jobs and green here. The thread now stays while it has work of its own left, with shutdown exempt, which is the whole difference from an earlier version of this gate that took every queue at its size and held the thread against the join in the destructor. Actionable is not the same as present, so an id whose session has gone is dropped rather than counted.

wakeupBackgroundThread did nothing without a handle. It goes through curl_multi_wakeup, and on libcurl older than 7.68.0 it compiled to nothing at all. Every producer now raises a counter first, and the wait taken when there is no handle watches that, so a queued request is picked up within a slice rather than at the end of the delay.

Three multi calls were still made with no multi handle. curl_multi_cleanup twice, in the destructor and in resetMultiHandle, and curl_multi_remove_handle through doRemoveSessions, which resetMultiHandle calls whether or not there is a handle. That is the contract the rest of this branch guards, so it should not have been the branch breaking it. Cleanup now goes through one function that answers whether there is a handle, reports a result that is not CURLM_OK, and leaves none behind. Detaching needs something to detach from, and there is nothing a null handle could name: the one it would have named was destroyed by curl_multi_cleanup, which detaches what it still holds, and nothing has been attached since, so the resource is released. Removing therefore no longer waits for a handle, since waiting held easy handles and header lists for the whole outage, which is the wrong way round.

The recovery cases were unsound. Both sent a request first, so the IO thread existed, and then called resetMultiHandle from the test thread, which destroys the multi handle and builds another while the IO thread may be inside curl_multi_perform, curl_multi_poll or curl_multi_info_read on the same one. libcurl leaves handles unsynchronized, and the IO thread does not hold multi_handle_m_ around those calls, so the lock the reset takes does not stand for it. Neither case needs to take a handle away: curl_multi_init is what fails in #4404, and it is called first by the constructor, so a client built while the allocator is refusing has no multi handle from the start and every attempt after that is the IO thread's own. Both are built that way now.

Resources could be left with no owner. An operation hands its easy handle and header list to pending_to_remove_session_handles_ on its way out, and that record is two raw pointers whose container frees neither. Everything that drains it runs on the background thread. Anything queued after that thread retired, or by the cancel the destructor itself does, had nobody left to release it, and the caller may already have had its terminal outcome: what is left over is the libcurl resources behind it. The destructor does that last pass itself now, before the multi handle goes so a handle still attached can be given back.

A retry queue entry outlived what it named. Under ENABLE_OTLP_RETRY_PREVIEW a retryable response puts the session in the retry queue with a time on it, and a cancel or a reset before that time left the entry there, since the session was still a session and still had an operation. When the time came the easy handle had gone back to the client, so libcurl was handed a null one and the result was never read, and until it came the idle check reported the queue as work. At shutdown that is the join waiting for an operation that finished long before: 6.4 seconds, three runs out of three. Entries are dropped when the operation is gone, was cancelled, or no longer holds an easy handle, and the remove and the add are read now, with the add only attempted if the remove worked and the operation finished rather than forgotten if it did not.

What the cases were getting wrong

Global state a failing case could leave behind. The allocator switches were armed with three fatal assertions between the arming and the disarming, so one of those returning would have left every later case in the binary allocating through a calloc that refuses, on every thread. The internal log handler is a process global that GlobalLogHandler reads and writes through a plain shared pointer with nothing synchronizing it, and one case put it back while the client, and the thread writing to it, were still there. Both are guards now, the log one declared before the client so it is destroyed after it. And curl_global_init_mem in SetUpTestSuite had no matching cleanup, so with every client taking one of its own through HttpCurlGlobalInitializer the count never reached zero and the allocator callbacks stayed installed into static teardown.

A count that did not say what it claimed. The log capture counted every line rather than the one the case is about, so anything else the binary wrote moved a bound that is meant to say how often one failure was reported. It takes the text it wants now. The allocation count had the same problem from the other end: the constructor's own attempt is one, so the assertion saying the IO thread had tried could be satisfied without the IO thread existing. Both cases put it back to zero after the constructor and before there is a thread to make one, and the thread that armed it is exempt from that point, so what is counted afterwards was refused to the IO thread and to nothing else.

A fix with no case that could fail without it. The header cases sent one header, so the append that failed was the first one and the list it was given was empty, which is the one shape the temporary pointer cannot be told apart from assigning the result straight back. Aiming at the second append needs the failure to land on a particular allocation, and the injection asked for any block of 64 bytes or less, which is a guess about a libcurl that promises none of it. It is measured now: the file watches one append happen and takes the size of the block it asked for, then refuses blocks of exactly that size after letting a stated number through. That also narrows what has to be skipped, from anything else having consumed the failure to an append not reaching the malloc callback at all. Against the assignment main has, LeakSanitizer reports 27 bytes in 2 allocations, three runs out of three.

The policy this picks, and the one it does not

A client with no multi handle can be answered two ways, and until now it was answered both, badly. This branch is recovery: work that has been accepted is kept, the IO thread stays while it owes somebody an answer, and the request goes out as soon as curl_multi_init succeeds, which the case measures at 1081 ms. Shutdown is the bound. There everything unscheduled is finished and the native resources are released, by the IO thread while it is still there and by the destructor when it is not. There is deliberately no retry budget: while the client is alive, a request it accepted keeps being tried.

Fail fast is the other coherent answer, a budget and then every pending operation finished exactly once, and it is a bound plus one condition on the same gate. @lalitb has already looked at this and said preserving the transient recovery is preferable to failing the client permanently, so recovery is what this does rather than an open question, and the fail fast shape is written down here only so that the choice is visible.

What was there before was neither. Recovery sometimes, a Cancelled nobody asked for sometimes, a request stranded until some later request happened to spawn a thread sometimes. Each of those is a commit here, and each has a mutation check: reverting the retirement gate hangs the queued case three runs out of three at the 240 second bound, reverting the destructor drain leaks 5582 bytes in 7 allocations, reverting the retry purge makes the destructor take 6.4 seconds, and reverting the temporary pointer in the header loop leaks 27 bytes in 2. The null handle guards are the one exception and are marked as such below: measured libcurl answers a null multi handle with CURLM_BAD_HANDLE rather than crashing, so what changes there is the contract rather than the behaviour.

What this does not close

Four things sit next to this and are not fixed by it. Saying so here rather than letting the branch read as if the whole client were now exactly-once.

curl_global_init's return is still ignored. This branch fixed the ordering, since multi_handle_ is declared before curl_global_initializer_ and member initialisation order meant curl_multi_init ran before curl_global_init had. Ordering is not success, and libcurl says a non-zero return means the other functions cannot be used. #4434 has the detail, including why there is no patch: the suite installs its allocator callbacks with curl_global_init_mem, which is the global initialisation, so by the time any client exists libcurl is initialised and the singleton's curl_global_init is a reference count bump that returns CURLE_OK whatever the allocator does. Reaching that failure needs a seam, and that is a conversation rather than something to slip in here.

Exactly-once is claimed for construction failure only. A Setup() failure still dispatches ConnectFailed from SendAsync and then CreateFailed from Session::SendRequest, which is #4360 item 3 and is untouched. What this branch does is give the constructor a result and make SendAsync refuse on it before Setup() runs, so a failed curl_easy_init or header list produces one terminal event. AFailedEasyHandleIsReportedOnce counts that and nothing wider.

The gzip branch still reports and sends. CreateFailed goes to the caller and then control falls through to SendAsync, carrying a body deflateInPlace may have written into, at its original length and with no Content-Encoding. #4360 item 1, untouched.

A direct HttpOperation caller is told by the return code, not by its handler. The constructor records a failure and does not dispatch it, so a failed curl_easy_init or header list reaches an EventHandler only through Session::SendRequest, once, as CreateFailed. That is coherent if Session is the only supported owner of asynchronous notification, and it is what keeps the count at one. The asymmetry is that the constructor does dispatch Created on its way out when it succeeds. Making it symmetric means the constructor reports and Session::SendRequest then must not, which is more machinery for the same single event, so I have left it as it is and would rather be told. How much that matters depends on which build you take. ext/CMakeLists.txt does not install http_operation_curl.h, so a CMake consumer cannot construct one directly at all, while //ext takes hdrs = glob(["include/**/*.h"]) and a Bazel consumer can. So the question is narrower than an installed API and wider than an internal detail, and it is still yours to answer rather than mine.

What the cases cover, and what they do not

Against the failure schedules worth having, since a list of green cases says less than a list of the ones that are missing.

Covered here: a multi handle that cannot be created and then can; one that never can, through to shutdown; a removal queued with no multi handle, and one still queued when the client goes; a retry entry whose session was cancelled before its time; the first header append succeeding and the second failing; a failed curl_easy_init on both the synchronous and asynchronous paths. Covered on #4395: a session a reset took before its id was queued, and one curl_multi_add_handle rejects.

Also covered, and added because the list of failure schedules worth having asked for it: a client destroyed with a retry still pending and nobody cancelling it, which is what an exporter shutting down actually does. Both retry cases fail against a queue check that only drops entries with no operation, three runs out of three, at 5.40 seconds against a bound of four.

Not covered, and each for a reason rather than an oversight: a working multi handle failing mid-transfer, because curl_multi_perform cannot be made to fail from a test without a seam; a retryable response already queued when a reset happens; a curl_easy_setopt failing inside Setup(), which is the #4360 path above; and the gzip failure, likewise. The libcurl version split is covered rather than skipped, because the queued case pins the zero idle grace that older libcurl uses, so both branches of that version check run on every platform.

Configurations, because one build saying yes is not the same as the matrix saying yes, and the first pass here was one build. All on Linux with gcc 14 and libcurl 8.14.1, and all from one commit, which is named because three commits have landed since: a review pass over the cases, and the two includes include-what-you-use asked for. The table is being re-measured on the head rather than carried forward, and this line will say which commit once it is.

cases
default, retry preview on 40 pass
retry preview off 35 pass, the four retry cases compile out
thread instrumentation preview on 40 pass
compression preview on 42 pass, and the two gzip cases only exist here
AddressSanitizer with detect_leaks=1 40 pass, no report
ThreadSanitizer 40 pass, no warning
UndefinedBehaviorSanitizer 40 pass, no report
C++20 40 pass

LeakSanitizer was checked with a deliberate leak first, so that a clean run means it was looking. Two things that came out of running the rest rather than assuming them: with compression on there are two more cases that had never been compiled in anything I ran, and ThreadSanitizer found two data races, both of them in a test peer of mine that read the retry queue from the test thread while the background thread pushed to it. That queue has no lock because only the background thread is meant to touch it, so the peer made its own premise false. It is gone, and the cases wait on what the server received instead.

Not run, and worth saying: Valgrind, a no-exceptions build, static against shared libcurl, Windows and macOS, and libcurl older than 7.68. The last one is the only one that worries me and it is the one the cases work around, by pinning the zero idle grace that older libcurl uses so both sides of that version check run everywhere.

Related

#4395 is a dependency rather than an overlap, and this section used to say the opposite. resetMultiHandle keeps the sessions whose ids are already in pending_to_add_session_ids_ and takes the rest, and a request between CreateSession and SendAsync is one of the rest: the reset cancels it, and on main the id then goes into the queue whatever became of the session, where doAddSessions finds nothing to add and moves on, leaving a promise nobody fulfils. #4395 refuses that id and finishes the operation itself, and ASessionResetTookBeforeItWasQueuedIsFinished on that branch is the case for it: it hangs three runs out of three against a revert, and passes in 503 ms. So this branch can recover a multi handle and still leave that one request unanswered without it, and the order that works is #4395 first with this rebased on it.

#4405 answers the ownership question this one raises, and the two do not combine mechanically. The ledger there records whether the multi handle was ever given a particular easy handle, which is the question a missing handle makes unanswerable from multi_handle_ alone, and a refused removal is kept in a member queue rather than dropped. The resolution that works puts attached_handles_.clear() inside the single function that releases the multi handle, so a ledger naming a handle that has gone is not a state either branch can reach. I built the two together to find out rather than assume: AResetThatCannotBuildAReplacementLeavesNothingAttached is the combined case, and without that one line it reports the handle still attached and the record quarantined for a multi handle that is never coming. The combined tree passes 46 of 46 under AddressSanitizer with detect_leaks=1.

Small overlaps with two open PRs, both one line, and I will take the rebase rather than ask anyone else to:

#4392 has landed and this branch is rebased on it, and its rule is kept: nothing here writes to an easy handle from the calling thread. The one conflict was in the test file, where both add cases at the same place, and both sets are present. #4394 is in the base too, and the sessions_m_ scope inside resetMultiHandle is still the snapshot only.

#4391 asked for the unchecked curl_multi_remove_handle and curl_multi_add_handle, and the retry path is one of the places it named. That pair is read now, and the add only happens if the remove worked, so that part of it is answered here. The rest of #4391 is the teardown ownership #4405 carries, and the issue should stay open for it.

About the lines Codecov marks

Codecov reported 11 lines of this change without cover. I read them rather than
adding a test per line, and they are not one thing.

Three of them were the TLS version range and the cipher list in Setup(), and
they were uncovered because nothing in the suite passed either option. Two cases
now do, and gcovr reports one hit each afterwards. The first draft of those
cases passed while covering none of it, because the whole SSL block sits behind
ssl_options_.use_ssl and I had set the TLS fields without it; the measurement
is what caught that, not the green.

The rest are in the recovery path this change adds for a null multi handle, and
in the two curl_multi_cleanup failure logs. Reaching the first needs a client
whose multi handle failed to initialise, and there is no seam for that here:
curl_multi_init is called directly. Reaching the second needs
curl_multi_cleanup to fail, which it did not in 104 and 13 evaluations
respectively. I have left both uncovered rather than build injection machinery
for them, and I would rather say so than have the number read as an oversight.

For significant contributions please make sure you have completed the following items:

  • CHANGELOG.md updated for non-trivial changes
  • Unit tests have been added
  • Changes in public API reviewed

@thc1006
thc1006 requested a review from a team as a code owner August 11, 2026 06:04
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.83969% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.38%. Comparing base (60c3d11) to head (fb70655).
⚠️ Report is 19 commits behind head on main.

Files with missing lines Patch % Lines
ext/src/http/client/curl/http_client_curl.cc 89.48% 12 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4406      +/-   ##
==========================================
+ Coverage   82.61%   83.38%   +0.77%     
==========================================
  Files         511      519       +8     
  Lines       20132    20358     +226     
==========================================
+ Hits        16631    16973     +342     
+ Misses       3501     3385     -116     
Files with missing lines Coverage Δ
...ntelemetry/ext/http/client/curl/http_client_curl.h 95.00% <ø> (ø)
...lemetry/ext/http/client/curl/http_operation_curl.h 91.67% <ø> (+0.37%) ⬆️
ext/src/http/client/curl/http_operation_curl.cc 68.54% <100.00%> (+7.95%) ⬆️
ext/src/http/client/curl/http_client_curl.cc 90.41% <89.48%> (+0.09%) ⬆️

... and 25 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@thc1006
thc1006 force-pushed the bugfix/check-curl-allocation-failures-4404 branch from 6b2a8a1 to 41d1001 Compare August 11, 2026 08:03
@thc1006
thc1006 marked this pull request as draft August 11, 2026 11:33
@thc1006
thc1006 force-pushed the bugfix/check-curl-allocation-failures-4404 branch from 41d1001 to b404833 Compare August 11, 2026 14:16
@thc1006
thc1006 marked this pull request as ready for review August 11, 2026 14:16
@thc1006

thc1006 commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

Both red jobs were mine, and one of them was a wrong assumption rather than a flake.

The two conan jobs. AFailedHeaderAllocationIsReported assumed a failing malloc reaches curl_slist_append, which is what happens on libcurl 8.14.1, where I measured it. On the libcurl those jobs build, curl_easy_init takes the allocation first, prints Curl_open failed, and the request leaves down a different path: terminal_count_ is 3 rather than 1 and the reason is not "Out of memory". Which function uses which allocator is not part of libcurl's contract, so the case should never have rested on it.

The failure is bounded by size now. A curl_slist node is two pointers and a Curl_easy is thousands of bytes on every version, so the bound aims it at the list append. That is not a guarantee either, so the reason check became a GTEST_SKIP that names the allocation that actually failed rather than an assertion against a path the build never took. Locally the bound still selects the list append on 8.14.1 with no skips, and the case still fails three runs out of three with the production change reverted.

The IWYU job. The file already included curl/curl.h inside the ENABLE_OTLP_RETRY_PREVIEW guard, and the scaffolding calls curl_global_init_mem from every configuration. So all-options-abiv1, where that guard is off, asked for the include. Adding an unconditional one then made both preview variants ask for the guarded copy to go. One unconditional include and no guarded copy, and all three variants report correct includes. Reproduced locally against the same cache files with CMAKE_CXX_STANDARD=14 and WITH_STL=CXX14, which is what ci/do_ci.sh cmake.iwyu.test passes on top of them.

27 of 27 in curl_http_test, three runs out of three, and clang-tidy at zero delta for that file under the CI filters.

Edited 16 Aug. The size bound described above did not hold either. It let one allocation of a node's size through and refused the next, and on the libcurl the Conan jobs build something else consumed the refusal, so the list was built, the request went out, and AHeaderListThatFailsPartWayThroughIsNotLost failed on all three of them while the same commit passed the other 68 jobs. A node's size is not part of libcurl's contract and neither is what gets allocated before the header list, so the injection is keyed on content now: the case names the header to refuse and the refusal happens when libcurl copies that exact string. All three jobs are green. Leaving the original text because the reasoning that led to the size bound is the same reasoning that had to be given up.

Comment thread ext/src/http/client/curl/http_client_curl.cc Outdated
}

// A null multi handle makes every curl_multi_add_handle report CURLM_BAD_HANDLE, so a client
// built on one accepts sessions and never sends any of them. Say so rather than fail quietly.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// built on one accepts sessions and never sends any of them. Say so rather than fail quietly.

This no longer matches the demonstrated behavior: the I/O loop can repair a transient initialization failure and complete the request. Could you update this comment and the matching test comment above AFailedMultiHandleAllocationIsReported?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, and sorry for the slow reply. You were right, and the comment came out in d9034527. What stands there now only claims what that function knows:

// Reported once, where it happens. The IO loop does its own reporting, because sharing this one
// would repeat the same line on every pass for as long as the handle stays missing.

Please close this thread if that reads right to you.

@thc1006

thc1006 commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

The Codecov patch report is answered rather than argued with. Both of the lines it named are reachable, and one of them turned out to be a real hole rather than a coverage artifact.

Send() carries the same construction_result_ guard as SendAsync, and nothing in the suite called it after a failed construction. ASynchronousSendRefusesAFailedHeaderAllocation builds an operation whose header list cannot be allocated and calls Send(). Reverting the guard and running it again is what makes the point: the request goes out carrying none of its headers, the embedded server records it, and the case fails, five runs out of five.

with the guard:     result=27(Out of memory)  requests_seen=0
without the guard:  result=0(No error)        requests_seen=1

The other line is the multi handle in the constructor that takes thread instrumentation. Both constructors reach it through the same helper, so AFailedMultiHandleIsReportedForAnInstrumentedClient uses that overload with a null instrumentation and holds that it reports a handle it could not create.

Measured with the same all-options-abiv2-preview configuration ci/do_ci.sh code.coverage uses, intersecting the uncovered lines with the lines this branch adds: three missing before, none after. 32 cases in curl_http_test, three runs out of three.

<< "the request was accepted and never reached an outcome";
EXPECT_FALSE(session->IsSessionActive()) << "the session stayed active with nothing running it";

session->FinishSession();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m still trying to understand this part. If recovery is broken, the checks above fail, but we still call FinishSession(). Wouldn’t that hang because the request never completed? Should we cancel the active session here so the test can fail normally?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and following it down found something that is not in the case at all.

I gated resetMultiHandle out to check, and the case does what you say: both assertions fail and print, and then it hangs. The stack says the hang is not where either of us assumed.

Thread 1 (main):
  #4 std::thread::join()
  #5 HttpClient::~HttpClient() at http_client_curl.cc:327

Thread 2 (IO):
  #4 HttpClient::doAbortSessions() at http_client_curl.cc:776   <- running, not blocked

FinishSession returns. What blocks is the client destructor joining a background thread that never exits, and the reason is a value the failed call did not write. curl_multi_perform leaves still_running alone when it rejects the handle, and the loop initialises it to one, so a client with a null multi handle keeps reporting work it does not have. Every pass takes the still_running > 0 branch and continues, which is above the is_shutdown_ check and above the wait, so the thread spins a core and can never be joined.

That is reachable without any test gate. Making curl_multi_init fail persistently, which is what a real allocation failure looks like, hangs the whole test binary: twice out of twice at a sixty second timeout, and again at two minutes for the full suite.

Clearing still_running on that branch is the fix, and it is in this change's scope since it is the reset's own curl_multi_init result going unchecked one layer up:

          if (mc != CURLM_OK)
          {
            still_running = 0;
            self->resetMultiHandle();
          }

With it, the persistent failure finishes in 521 ms, three runs out of three, and the case passes rather than hangs, because the reset cancels the session and the caller is told. A handle that fails once and then works is unaffected: the request still completes with a response, three runs out of three with the internal log confirming the first handle really was null.

And I took your suggestion for the case itself. It cancels the session before finishing it, so a failure ends the case instead of leaving FinishSession waiting on an operation nothing is going to complete.

30 cases in curl_http_test, three runs out of three.

@thc1006
thc1006 force-pushed the bugfix/check-curl-allocation-failures-4404 branch from 2d441c6 to 26b7744 Compare August 11, 2026 17:48
@thc1006
thc1006 marked this pull request as draft August 13, 2026 01:28
@thc1006
thc1006 force-pushed the bugfix/check-curl-allocation-failures-4404 branch 2 times, most recently from b570dc9 to 41c0689 Compare August 14, 2026 20:14
thc1006 added 15 commits August 15, 2026 08:47
Both are allocation points whose failure was treated as success, and both sit next
to a check the same file already makes.

curl_slist_append returns null without freeing the list it was given, and the docs
say so: "To avoid overwriting an existing non-empty list on failure, the new list
should be returned to a temporary variable which can be tested for NULL before
updating the original list pointer." Assigning straight back over headers_chunk did
the opposite. The pointer to everything appended so far went with it, so the two
places that free the list saw null and never ran, and Setup() only sets
CURLOPT_HTTPHEADER when headers_chunk is non-null, so the request went out with none
of the caller's headers rather than not going out. For an OTLP export that means no
Content-Type and a receiver that rejects a request the exporter believes it sent.

Reporting that is not enough on its own. Returning from the constructor does not
stop Session::SendRequest, which calls SendAsync unconditionally, and the easy
handle is still valid at that point, so the request went out anyway. The failure is
now recorded in the operation, both Send() and SendAsync() refuse on it before
Setup(), and Session::SendRequest reports it once with the reason curl gave. The
construction also marks itself terminal, otherwise Cleanup() from ~HttpOperation
announces a cancel for an operation that never started, to a handler the caller may
no longer be holding.

curl_multi_init returns null the same way, and its result went straight into
multi_handle_ in both constructors and in resetMultiHandle. Measured against libcurl
8.14.1, curl_multi_add_handle on a null multi handle reports CURLM_BAD_HANDLE, and
that return is discarded today, so such a client would accept every session, add
none of them, and complete none of them. The reset path is the worse of the three:
it runs while recovering from a multi error, so a failure there turns the client
into a black hole for the rest of its life. All three now go through one helper that
logs.

Both failures are covered. libcurl routes its internal allocations through the
callbacks given to curl_global_init_mem, so the test suite installs a set of them
and arms a thread local switch around the call under test. The switches are per
allocation function rather than per call count: curl_easy_init allocates with calloc
and strdup and never with malloc, while curl_slist_append uses one malloc, so
failing malloc alone selects the list append and leaves the easy handle intact.
curl_multi_init allocates with calloc, so the other case fails calloc instead. The
hooks go in from SetUpTestSuite, since curl_global_init_mem returns CURLE_OK and
quietly changes nothing once libcurl has been initialised, and a third case asserts
they were installed in time so the other two cannot pass or fail for that reason.
The flag those hooks set is atomic, because libcurl calls them from whichever thread
is allocating, including a client's background thread.

The header case asserts what the fix is actually for: the server receives no
request, and exactly one terminal outcome reaches the handler.

The conditional include of global_log_handler.h goes with this, since the file now
logs outside the compression guard and carrying both copies is a duplicate that
include-what-you-use rejects when the guard is off.

Fixes open-telemetry#4404

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
… use

Which libcurl call consumes the first failing malloc is a property of the
libcurl the tests are built against, not of this repository. On 8.14.1 it is
curl_slist_append, which is what the case wants. On the libcurl the conan jobs
build, curl_easy_init takes it first, reports Curl_open failed, and leaves down
a different path with three terminal outcomes and no out of memory message.

The failure is bounded by size now. A curl_slist node is two pointers and a
curl easy handle is thousands of bytes on every version, so the bound aims it
at the list append. That is not a guarantee, so the reason check became a
skip that says which allocation failed instead of an assertion against a path
the build never took.

curl/curl.h moves out of the retry preview guard and into the unconditional
block. The scaffolding calls curl_global_init_mem from every configuration, so
include-what-you-use asked for it on all-options-abiv1 where that guard is off,
and asked for the guarded copy to go on the two preview variants where it is on.
All three report correct includes with one unconditional include.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Reporting the curl_multi_init failure does not by itself stop the client taking
requests, so the case now holds what taking one leads to rather than only that
the failure was reported. Raised by @lalitb in review.

Measured before choosing an assertion. A client built on a null multi handle
answers normally: curl_multi_perform rejects the handle, the IO loop resets it,
the pending session moves to the new one and the request completes, three runs
out of three with the internal log confirming the handle really was null. Gate
resetMultiHandle out and the same case fails on the new assertion and then hangs,
which is the outcome the assertion exists to catch.

So the case asserts the caller reaches a terminal outcome and the session does
not stay active, without pinning which outcome. A build where the allocation is
still failing when the reset runs reports a failure instead of a response, and
both satisfy the property that matters.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Codecov had three patch lines nothing reached. Both are reachable.

Send() carries the same construction_result_ guard as SendAsync, and nothing
called it after a failed construction. The case builds an operation whose header
list cannot be allocated and calls Send(): without the guard the request goes out
carrying none of its headers and the server sees it, five runs out of five, so
the case fails rather than merely covering the line.

The other line is the multi handle in the constructor that takes thread
instrumentation. Both constructors reach it through the same helper, so the case
uses that overload with a null instrumentation and holds that it reports a handle
it could not create, the same way the plain one does.

Patch coverage for this branch measured with the abiv2-preview configuration the
Codecov job uses: three missing lines before, none after.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…ated

Raised by @lalitb in review, who asked whether the case would hang rather than
fail if the recovery were broken. It would, and following that down found the
reason, which is not in the case.

curl_multi_perform does not write still_running when it rejects the handle, and
the loop initialises it to one. So a client whose multi handle is null keeps
reporting work it does not have: the loop takes the still_running > 0 branch
every pass, never reaches the shutdown check below it, and never waits, so the
thread spins a core and ~HttpClient blocks in join for good. Gating
curl_multi_init to fail persistently, the whole test binary hangs, twice out of
twice at sixty seconds and once at two minutes. With still_running cleared on
that branch it finishes in 521 ms, three runs out of three, and the case passes
rather than hanging, because the reset then cancels the session and the caller
is told.

A multi handle that fails once and then works is unaffected: the reset creates
one, doAddSessions puts the pending session on it and sets still_running again,
and the request completes with a response, three runs out of three with the
internal log confirming the first handle really was null.

The case also cancels before it finishes, so an assertion failure ends the case
rather than leaving FinishSession waiting on an operation nothing will complete.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Three jobs went red on the test file. Two of the includes were mine and one of
them should never have been there: <cstdio> was left behind by a probe whose
prints are gone, and the file has no stdio use at all. <memory> goes because the
thread instrumentation header the new case needs already reaches it, and that
header is the one include-what-you-use asks to spell out.

Checked against the three cache files the jobs use, with CMAKE_CXX_STANDARD=14
and WITH_STL=CXX14 on top of them, which is what ci/do_ci.sh cmake.iwyu.test
passes. Nine combinations of variant and translation unit, all reporting correct
includes.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Clearing still_running stopped a client that could not create a multi handle
being undestroyable, but it left the worse half in place. With the client alive
and curl_multi_init failing every time, the loop retried and reported as fast as
the CPU allowed: 3.00 seconds of CPU and 1,168,126 error lines over a three
second window, three runs out of three.

Three parts to it. A missing handle is answered without calling into libcurl,
which is what curl_multi_init asks for: once it has returned null the other
multi functions cannot be used, so treating the rejection from
curl_multi_perform as the recovery trigger was relying on an implementation
detail. resetMultiHandle returns whether the client has a handle afterwards,
since the loop has nothing to run while it does not. And a run of failures is
reported once and waited on for as long as a poll would have taken, rather than
repeated every pass. Shutdown skips the wait, so teardown is unchanged.

The same window now costs 0.00 seconds of CPU and two log lines, one from the
constructor and one from the loop. A handle that fails once still recovers on
the next pass and the request completes.

APersistentMultiHandleFailureDoesNotSpin holds it. The switch it uses is not
thread_local, which is what reaches the re-initialization on the IO thread; the
existing case only fails the constructor, so the first reset succeeded and the
branch was never entered. Without this change it reports 0.74 seconds of CPU
over a one second window and 284,905 repetitions of one message, three runs out
of three.

Raised by @lalitb, who asked whether logging the failure changes anything.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Members are initialised in declaration order, and multi_handle_ is declared
before curl_global_initializer_, so both constructors called curl_multi_init
before HttpCurlGlobalInitializer had called curl_global_init. libcurl asks for
the opposite: curl_global_init has to have been called before any other libcurl
function.

Nothing in CI could see it. The allocation cases install their allocators from
SetUpTestSuite with curl_global_init_mem, which initialises libcurl before any
client exists, so every test runs against an already initialised library.

The handle is created in the constructor body instead, where every member,
including the one that runs curl_global_init, has been constructed.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The comment reached for the measurement that motivated it, which is the PR and
the commit message's job. What a reader of the file needs is the property.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…_read

Two corrections to the change before it, both found reviewing it rather than
reported.

The wait cannot be interrupted. wakeupBackgroundThread reaches the worker
through the multi handle, and the whole point of that branch is that there is
not one, so the destructor cannot cut the wait short and the previous commit
saying teardown was unchanged is wrong. Destroying a client whose worker was
inside the wait measured 168 ms, five runs out of five. Taking the same wait in
16 ms slices and rechecking is_shutdown_ leaves the retry rate where it was and
brings that to 10 ms, five runs out of five.

And curl_multi_info_read was still called with the handle libcurl had refused to
give. curl_multi_poll and curl_multi_wait cannot see one, since they sit inside
the branch that only runs when perform succeeded. curl_multi_add_handle and
curl_multi_remove_handle can, but they are in doAddSessions and doRemoveSessions,
which open-telemetry#4395 and open-telemetry#4405 rewrite, so they are named in the description rather than
changed here.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Guarding curl_multi_perform and curl_multi_info_read was not enough. Three more
phases in the same pass reach a multi function, and doAddSessions is the one
that does damage: it swaps the whole pending-to-add set out unconditionally,
calls curl_multi_add_handle without looking at the handle or the result, and
returns true regardless. resetMultiHandle builds its cancel snapshot from the
sessions that are NOT pending to add, so a request made while the handle cannot
be created is taken out of the set that protects it, reported as running, and
cancelled on the next pass.

doRemoveSessions and doRetrySessions reach curl_multi_remove_handle and
curl_multi_add_handle the same way. All three are gated on having a handle now.
doAbortSessions is not gated: it finishes operations and calls nothing from the
multi interface, so teardown keeps working while the handle is missing.

What this does not have is a case that fails without it. Reaching the state
needs curl_multi_init to fail while curl_easy_init still works, and they
allocate the same way, so the process wide switch the other cases use refuses
both and the request is turned away during construction rather than queued.
Three attempts at separating them either measured nothing or left a case that
timed out one run in six, so the gating rests on reading doAddSessions rather
than on a test. A worker owned failpoint for curl_multi_init would close that,
and it needs a seam in the client rather than in the tests.

The persistent failure case is unchanged in what it holds, but counts refused
allocations rather than process CPU time, since std::clock is elapsed wall time
on Microsoft's CRT and the one second wait would be counted there. It also has
lower bounds now: without them an injection that stopped working reads as a
pass.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The loop brackets curl_multi_poll with BeforeWait and AfterWait, and the wait
added for a missing multi handle is the same kind of blocking wait, so a runtime
with thread instrumentation was being told this thread was running while it was
asleep.

Compiled with WITH_THREAD_INSTRUMENTATION_PREVIEW=ON rather than only in the
default configuration, since the calls are behind that guard and would otherwise
never be seen by a compiler.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The comments carried the reasoning that found the bug as well as the rule the
code follows. The rule is what a reader needs; the rest belongs in the pull
request. Each block now states its constraint and stops, and the two member
comments in the installed headers follow the one line trailing form the file
already uses next to them.

No code changes.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The loop skips the three phases that call curl_multi_add_handle or
curl_multi_remove_handle while there is no multi handle. Nothing held that.
Ungated, a session queued during the outage leaves the pending queue for a
multi function that cannot take it, and the next successful reset cancels it,
so the caller is told a request was cancelled that nothing cancelled.

The case could not be written before because a process wide calloc failure
also fails curl_easy_init, so the request could not be built while the handle
was missing. One thread local exemption from that switch fixes it: the IO
thread keeps failing, the thread running the case keeps allocating.

Measured. With the gate the case passes 3 of 3. With the three calls ungated
it fails 3 of 3, on both assertions: cancels is non zero and no response
arrives. The whole file stays at 34 passing, 3 of 3.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
curl_easy_init has a failure return like the other two, and the constructor
already reports it. It did not record it, so Send() and SendAsync() ran anyway
and passed a null handle to libcurl, which answered with a second failure of a
different kind for the same request.

Measured. With curl_easy_init failing, the handler received a create failure
and a connect failure, 3 of 3. Recording the result in construction_result_,
which the two send paths already check, leaves the create failure alone, 3 of 3.
The whole file goes from 34 passing to 35, 3 of 3.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
thc1006 added 16 commits August 15, 2026 08:47
The loop skips the remove, add and retry phases while there is no multi
handle. Those phases are the only thing that sets still_running, and the idle
check reads still_running to decide there is nothing left to do, so the thread
could retire with an accepted request still in pending_to_add_session_ids_.
Nothing starts it again until another request arrives, so that request waits
for good even after allocation recovers.

hasPendingWork() answers the question the idle check was really asking. With
no handle and a non empty queue the pass is treated as work, which sends the
loop back through the bounded missing handle wait rather than out of it.

What is measured and what is not. The whole file passes, 35 of 35, twice. The
Bazel run of AQueuedRequestSurvivesAMissingMultiHandle timed out with no
response, which is what this path does, but running the same pair of cases in
one process locally passes 8 of 8 both with and without this change, so that
run does not discriminate and I am not claiming it as a reproduction. A case
that does discriminate has to shorten the idle window on the client under test
so the double check is reached while the handle is still missing.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…ork"

This reverts commit 7ac93ed.

The change was reasoned from the source and never reproduced: running the
same pair of cases in one process passed 8 of 8 with and without it, and
the commit said so. Its actual effect on CI was to make things worse.

AQueuedRequestSurvivesAMissingMultiHandle already failed under Bazel,
which runs every case in one process, and it failed by saying what was
wrong: no response arrived. With this change it stopped answering at all
and the target timed out at 300 seconds, so no case after it ran either.
Bazel went from 6 failing jobs to 21.

A hang is a worse failure than an assertion, and the defect this was
meant to close is still only argued, not demonstrated. Both go back to
where they were: the worker can still retire with queued work, and the
case still fails under Bazel, out loud.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The constructor dispatched CreateFailed and SendRequest dispatched it
again when SendAsync refused, so one failed curl_easy_init told the
handler twice. A terminal notification is not safe to repeat: a handler
that releases its own ownership on the first one is reading freed memory
on the second.

The header list branch a few lines below already had this right. It
records the result and the terminal state and dispatches nothing,
leaving the telling to the one caller that knows the request never went
out. The easy handle branch does the same now.

The case that was supposed to hold this was named ReportedOnce and asked
for at least one, so it passed while the code reported twice. It now
asks for exactly one create failure and exactly one terminal outcome,
and fails without this change.

35 tests pass, 3 of 3.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Without a multi handle the add, remove and retry phases are skipped, and the
retirement check reads that as having nothing to do. So a request the client
has already accepted can be sitting in pending_to_add_session_ids_ while the
thread that is going to schedule it detaches and leaves. Nothing brings it
back: wakeupBackgroundThread goes through the multi handle and there is not
one, and only a further request spawns a thread, so FinishSession waits on a
promise nobody is left to fulfil.

How long that takes depends on the libcurl the job was built against. The idle
grace is a minute, but the line that sets it is behind a version check and
older libcurl leaves it at zero, where the thread reaches the retirement check
on its first idle pass. CMake asks for no minimum libcurl, so both are
supported.

The thread now stays while it has work of its own left. Shutdown is exempt,
and that exemption is the whole difference from the first version of this
gate, which took every queue at its size and held the thread against the join
in the destructor for entries that could never drain. Actionable is not the
same as present: an id whose session has gone is what doAddSessions would drop
on its next pass, so hasActionableWork drops it rather than counting it, and
does the same for the retry entries doRetrySessions would drop.

The wait taken when there is no handle now watches a counter every producer
raises, so a queued request is picked up within a slice rather than at the end
of the delay, and wakeupBackgroundThread means something on libcurl older than
7.68.0, where it used to compile to nothing at all.

The case pins the shorter idle grace rather than inheriting whichever one the
job's libcurl allows, and joins the IO thread before taking its multi handle
away, which also stops the case destroying a handle another thread is inside.
Both together make it deterministic: three runs out of three time out at 240
seconds without the change, printing that the queued request never reached the
wire and then hanging in FinishSession, and three out of three pass in 1081 ms
with it. All 35 cases in the binary pass, in 24.2 seconds.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
curl_multi_init says the other multi functions cannot be used once it has
returned null. That is the contract the rest of this branch guards, and three
calls here were still outside it.

curl_multi_cleanup was reached with none twice. The destructor cleans up
whatever the constructor got, and the constructor may have got nothing.
resetMultiHandle cleans up before building the replacement, and it is the one
place that produces the null in the first place, so a second reset arrives with
none. Both now go through one function, which answers whether there is a handle
to clean up, reports a result that is not CURLM_OK, and leaves none behind
either way, so a failed reset cannot be cleaned up twice.

curl_multi_remove_handle was the third, through doRemoveSessions, which
resetMultiHandle calls whether or not there is a handle. Detaching needs
something to detach from, and there is nothing this could name: the handle it
would have named was destroyed by curl_multi_cleanup, which detaches what it
still holds, and nothing has been attached since. So the easy handle and its
header list are released. Measured libcurl answers a null multi handle with
CURLM_BAD_HANDLE rather than crashing, on every version this was checked
against, so what this changes today is the contract rather than the behaviour.

Removing no longer needs a handle, so the loop no longer waits for one before
doing it. Waiting held the easy handles and their header lists for the whole
outage, which is the wrong way round: an outage is when releasing them matters.
Adding and retrying do still need a handle and now say so themselves, before
the swap that would drop the ids they took, rather than leaving it to every
caller to remember.

AHandleQueuedWithoutAMultiHandleIsReleased drives the queue with no handle
in place and asks LeakSanitizer whether the release happened. It does not claim
to catch the null call, which the versions to hand tolerate.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
… away

Both recovery cases used to send a request first, so the IO thread existed,
and then call resetMultiHandle from the test thread. That thread destroys the
multi handle and builds another, while the IO thread may be inside
curl_multi_perform, curl_multi_poll or curl_multi_info_read on the same one.
libcurl leaves handles unsynchronized and says one handle is not to be used
from two threads at once, and the IO thread does not hold multi_handle_m_
around those calls, so the lock the reset takes does not stand for it.

That makes the cases unsound whatever they report. The unexpected cancel one
of them saw in CI is real, but a case that breaks handle ownership cannot say
whether what it saw came from the client or from itself.

Neither needs to take a handle away. curl_multi_init is what fails in open-telemetry#4404,
and it is called first by the constructor, so a client built while the
allocator is refusing has no multi handle from the start and every attempt
after that is the IO thread's own. The test thread exempts itself once the
client exists, since curl_easy_init allocates the same way and the request
still has to be built.

What each case asks is unchanged, and the queued one now pins the shorter idle
grace as well, so it does not inherit whichever one the job's libcurl allows.
The spin case keeps a request in flight because that is what keeps the IO
thread there now, and installs its log capture after the constructor so the
report the constructor makes is not counted as one of the loop's.

Three runs out of three for the seven cases that touch a missing handle, and
36 out of 36 for the binary, twice over. The queued case still hangs without
the retirement gate, three runs out of three, printing that the request never
reached the wire.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
An operation hands its easy handle and its header list to
pending_to_remove_session_handles_ on its way out, and that record is two raw
pointers whose container frees neither. Everything that drains it runs on the
background thread. When the client is destroyed the thread is joined, and
anything queued after it retired, or queued by the cancel the destructor
itself does, has nobody left to release it.

This is not the same as a request going unanswered. The caller can have had
its terminal outcome already: what is left over is the libcurl resources
behind it, with no owner.

So the destructor does the last pass itself, once the thread has gone and no
more sessions can be made. Aborting first, since finishing an operation is
what queues its resources, and both before the multi handle is released so a
handle still attached can be given back rather than freed underneath it. In
the ordinary case both queues are already empty and neither call does
anything.

AQueuedHandleIsReleasedWithTheClientThatQueuedIt queues one and destroys the
client without draining it. Nothing is sent, so there is no background thread
and nothing else is coming for it, and LeakSanitizer is the assertion:
without this it reports 5582 bytes in 7 allocations, and with it the whole
binary is clean, 37 cases out of 37.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
A retryable response puts the session in the retry queue with a time on it.
Anything that takes the operation apart before that time comes, a cancel or a
reset, leaves the entry there: the session is still a session and it still has
an operation, which is all the queue looked at.

Two things follow, and both are worse than a wasted entry. When the time comes,
the easy handle it wants has gone back to the client already, so what libcurl
is handed is a null handle, and what comes back was never read: the entry is
erased and reported as a retry that was arranged. And until the time comes, the
idle check reports the queue as work, so the background thread stays. At
shutdown that is the join waiting, for an operation that finished long before.

So an entry is dropped when the operation is gone, was cancelled, or no longer
holds an easy handle, which is what being torn down looks like from here since
Cleanup hands the resource back and leaves none. The queue is in order, so the
first entry whose time has not come still ends the pass.

The remove and the add are read now, and the add only happens if the remove
worked. If the handle cannot be put back to run, the operation is finished
rather than erased and forgotten, which is the same rule the rest of this file
follows: nothing is left holding a promise that nobody is going to fulfil.

ACancelledRetryDoesNotHoldTheClientOpen asks the server for a retryable answer
with an eight second backoff, cancels, and times the destructor. Without this
it takes 6.4 seconds, three runs out of three, and with it 11 to 512 ms. All 38
cases in the binary pass, in 25.5 seconds.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Four things, all the same shape: state that belongs to the process was set by a
case and put back by the same case writing it out again, which only happens if
the case gets that far and only means anything while nothing else is looking.

The allocator switches were armed with three fatal assertions between the
arming and the disarming. One of those returning would have left every later
case in the binary allocating through a calloc that refuses, on every thread,
which turns one mismatched injection into a matrix of timeouts rather than one
red case. They are a guard now, next to the two the file already had, and the
cases that disarm on their way through still do.

The internal log handler is a process global that GlobalLogHandler reads and
writes through a plain shared pointer with nothing synchronizing it, and the
documentation asks for it to be set once at startup for that reason. One case
put it back while the client, and the thread writing to it, were still there.
It is a guard too, declared before the client so it is destroyed after it, and
the count is read once the client has gone.

That handler counted every line rather than the one the case is about, so
anything else the binary wrote while it was installed moved a bound that is
meant to say how often one failure was reported. It takes the text it wants
now, which also leaves out the line the constructor writes about the same
failure, so the case no longer needs to be installed late to avoid it.

And curl_global_init_mem in SetUpTestSuite had no matching cleanup. libcurl
counts initializations and asks for one cleanup for each, and every client
takes one of its own through HttpCurlGlobalInitializer, so the count never
reached zero: the allocator callbacks stayed installed into static teardown and
what libcurl still held was reported as leaked.

One more thing worth saying about a bound rather than a guard. Both recovery
cases put the failure count back to zero after the constructor's own attempt
and before there is an IO thread to make one, and the thread that armed it is
exempt from that point, so what they count afterwards was refused to the IO
thread and to nothing else. Without that the assertion saying the IO thread
tried would have been satisfied by the constructor.

All 38 cases pass, in 25.5 seconds.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The header cases sent one header, so the append that failed was the first one
and the list it was given was empty. That is the one shape the temporary
pointer cannot be told apart from assigning the result straight back: with
nothing appended yet there is nothing to lose. So the fix had no case that
could fail without it.

Aiming at the second append needs the failure to land on a particular
allocation, and until now the injection asked for any block of 64 bytes or
less, which is a guess about a libcurl that does not promise which allocator a
list append uses, how large a node is, or what else asks for a small block
first. So it is measured instead: the file watches one append happen and takes
the size of the block it asked for, then refuses blocks of exactly that size,
after letting a stated number through. The string is copied through the strdup
callback, which is deliberately not the one being watched, so the only block a
measured append shows is the node.

That also narrows what has to be skipped. The existing cases skip when
something else consumed the failure, which is a property of the libcurl in use.
This one skips only if an append does not reach the malloc callback at all,
which is a much smaller claim, and it says so in those words.

AHeaderListThatFailsPartWayThroughIsNotLost sends two headers and refuses the
second node. Against the assignment main has, LeakSanitizer reports 27 bytes in
2 allocations, three runs out of three, and the whole binary is clean with the
temporary, 39 cases out of 39 under AddressSanitizer with detect_leaks=1.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…elling

The case next to this one cancels first, which is not what an exporter shutting
down does. It simply goes, and the entry left in the retry queue names an
operation that still holds a promise and an easy handle. Somebody has to finish
it and release them, and once the client has gone there is nobody.

What answers it is the destructor cancelling every session it still has, which
makes the entry stale, which the retry pass then drops. Both halves have to be
there: against the queue check main has, the destructor takes 6.41, 6.41 and
6.41 seconds waiting out a backoff nobody is going to run, and with them it is
517 ms, three runs out of three, with no leak.

The handler is declared outside the block on purpose. The terminal event for
this one comes from the client's destructor, so it has to outlive the client
rather than the other way round, which is the sort of thing a case can get
wrong and only find out under a sanitizer.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…ueue

The two retry cases waited by polling the retry queue through a test peer. That
queue has no lock, because only the background thread is supposed to touch it,
and a peer reading it from the test thread makes that untrue. ThreadSanitizer
says so: two data races, both this read against the background thread's
push_back, and both of them mine rather than the client's.

So the wait moved outside. It waits on what the server received, which is
behind a mutex and a condition variable, and then for long enough that the
answer has been read and the session put back with its wait on it. The wait is
eight seconds and the settling is one, so a request still outstanding at that
point is outstanding because it is queued, and there are seven seconds left for
the case to be wrong in.

The first attempt at this waited for the server to see a second request, on the
grounds that a retry having fired proves the queue works. It does, but it
proves it one step too late: at that moment the client is reading the second
answer rather than holding a queued session, so cancelling caught a transfer in
flight and the mutation check went green. Both cases now fail against a queue
check that only drops entries with no operation, three runs out of three, at
5.40 seconds against a bound of four, and pass in about a second.

ThreadSanitizer reports nothing on the whole binary now, and all 40 cases pass.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…to them

A pass over the things this project's reviews ask for every time.

Comments describing how the code got here rather than what it is. Three of
them: the stale queue check explaining what an earlier version of itself did,
the construction result explaining what dispatching from there used to cause,
and a test comment citing the ThreadSanitizer run that made it what it is. All
three now say what the code does, and the rest is in the commit history where
it belongs.

A comment that had drifted off its class. The one describing what
MultiHandleOutcomeHandler counts had come to rest above CountingLogHandler,
which counts something else entirely. It is back where it belongs, and the peer
comment that pointed at "the case below" names the case instead, since the case
is nine hundred lines below.

The ordering that makes a queued request safe was not written down anywhere.
ScheduleAddSession inserts the id before Session::SendRequest asks for a
background thread, and the retirement check holds background_thread_m_ while it
looks for work, so a thread on its way out either sees the id and stays or has
already cleared background_thread_ and the spawn that follows makes a new one.
Queue first, spawn second, and now it says so.

Two cases had no assertion at all, leaving LeakSanitizer as the only thing that
could fail them, which also meant an injection that stopped working read as a
pass. Both now check that the handle really was queued before asking whether it
was released. The count they use is taken under session_ids_m_, which every
producer of that queue takes, unlike the retry queue which has no lock and is
not a test's to read.

And three cases declared their handler after the client. The client dispatches
terminal events from its destructor, and members go in reverse, so the handler
was already gone by the time it might be called. Nothing reached it, but that
was luck rather than design. All three declare it first now.

All 40 cases pass, and clean under AddressSanitizer with detect_leaks=1.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The failure paths added here report through OTEL_INTERNAL_LOG_ERROR with a streamed curl_multi_strerror, which needs ostream. All three iwyu presets ask for it.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The peer that counts pending removals names the unordered_map the client keeps them in, and all three iwyu presets ask for it. The ostream one before this was the same shape on the other file.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006
thc1006 force-pushed the bugfix/check-curl-allocation-failures-4404 branch from 41f2f31 to e37fbf3 Compare August 15, 2026 08:48
The case that holds a header list failing part way through aimed its injection at
blocks of exactly the size of one list node, letting the first through and refusing
the next. A node's size is not part of libcurl's contract, and neither is what gets
allocated before the header list is built, so on the libcurl the conan jobs link
against something else took the refusal: the list was built, the request went out,
and the case failed on three platforms while the same code passed on every other
job.

It now names the header to refuse, and refuses the copy libcurl makes of that exact
string, so which append fails is a property of the case rather than of the libcurl
in use. It also records the order the two headers were copied in, so that a libcurl
which never routes an appended string through the allocator callbacks skips instead
of failing, and a refusal that landed on the first append is reported rather than
passing as though the list had already been non-empty.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
hasActionableWork() dropped a retry entry whose session or operation had gone, and
doRetrySessions() dropped those plus the ones whose operation was aborted or had already
handed its easy handle back. The comment above the first said it applied the same rule as
the second, and it no longer did.

The gap shows while the client has no multi handle. doRetrySessions() returns early
without one, so the entries it would discard are never discarded, while
hasActionableWork() counts them as work and keeps the background thread alive retrying
curl_multi_init for an operation that is already terminal. Shutdown is exempt from that
check so it does not hang, but until the handle comes back the thread stays up, retries
on a timer, logs, and holds the session and its resources for an entry nothing will ever
retry.

Both now call one function, which also puts the rule where it cannot drift again. It sits
outside the retry preview guard because the scan runs in both builds: with the preview off
the queue is empty rather than absent.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…raining it

The wakeup generation this branch added closed the lost wakeup for a thread sleeping
without a multi handle. The thread that is about to retire had the same hole and did not
consult it.

Everything that queues work for the background thread bumps the generation, and the
producers of the abort and removal queues only wake it: unlike SendRequest they never
call MaybeSpawnBackgroundThread. So an abort queued after the drains above have already
reported nothing sits there until the next request or the destructor, and a caller
waiting on that operation's promise waits with it, because the promise is fulfilled by
the cleanup the drain would have run.

The generation is read after the lock and before the drains, and compared after them, so
the pass that queued something goes round once more and retires on the next one when the
queues really are empty. A drain that queues a removal of its own therefore costs one
extra iteration rather than keeping the thread up: the suite finishes in the same 29
seconds it did before, and the retirement cases still retire.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
ReleaseMultiHandle() logged its own failure, and both callers hold multi_handle_m_ while
they call it. The log handler is whatever the application installed, and one that comes
back into this client reaches wakeupBackgroundThread(), which takes that same mutex on
the same thread. std::mutex is not recursive, so the failure path could deadlock the
thread reporting it.

It now answers with what curl_multi_cleanup said and each caller reports after its lock
scope closes. The second one has to hold the lock past the cleanup anyway, because it
builds the replacement handle under it, so it carries the result out instead.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Setup() reads ssl_min_tls, ssl_max_tls and ssl_cipher and returns early if curl
refuses any of them, and nothing reached that code: gcovr reported those three
lines at zero hits over the whole suite.

Two cases now do. The first passes a valid range and a cipher list and requires
the request to succeed, which is the branch that sets CURLOPT_SSLVERSION and
CURLOPT_SSL_CIPHER_LIST. The second passes a version the parser does not know and
requires CURLE_UNKNOWN_OPTION, which is the branch that refuses it before curl
sees it.

Both set use_ssl. Without it the whole block is skipped and the cases pass having
exercised none of it, which is how the first draft of them read as green. Both
also sit outside ENABLE_OTLP_RETRY_PREVIEW: a case that compiles out is still
registered by gtest_add_tests, and a filter matching nothing exits zero.

Measured after: the three lines report one hit each, and the suite passes with
the retry preview on and off.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
HttpOperation keeps references to its ssl options, headers, body, compression and
retry policy. The short constructor form materialises the defaulted ones as
temporaries that die at the end of the full expression, and Setup() reads them
from inside Send(): the Bazel asan job reported stack-use-after-scope in
HttpOperation::Setup() with the frame belonging to the case.

RetryPolicyEnabled in this file passes all twelve by name for that reason. The
case I patterned on, RetryJitterIsNotSharedAcrossThreads, uses the short form and
never calls Send(), so it never sees it.

Verified with bazel test --config=asan on the same target: no sanitizer report,
and the new cases run.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
curl_multi_init() may not run before curl_global_init(), so multi_handle_ was
assigned in the constructor body with a comment saying why. clang-tidy reads that
as prefer-member-initializer and reports it twice, which puts the abiv1-preview
preset two warnings over its limit.

Members are initialised in declaration order, so declaring the initializer first
lets the list do it and makes the ordering a property of the class rather than of
a comment a later edit can move away from. It also runs the global cleanup last
on the way out rather than first, since destruction is the reverse.

The destructor already calls curl_multi_cleanup() in its body, before any member
is destroyed, so nothing depended on the old order there.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] The curl client ignores curl_slist_append and curl_multi_init failures

2 participants