[BUG] Check what curl_slist_append and curl_multi_init return - #4406
[BUG] Check what curl_slist_append and curl_multi_init return#4406thc1006 wants to merge 38 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ 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
🚀 New features to boost your workflow:
|
6b2a8a1 to
41d1001
Compare
41d1001 to
b404833
Compare
|
Both red jobs were mine, and one of them was a wrong assumption rather than a flake. The two conan jobs. The failure is bounded by size now. A The IWYU job. The file already included 27 of 27 in 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 |
| } | ||
|
|
||
| // 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. |
There was a problem hiding this comment.
// 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?
There was a problem hiding this comment.
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.
|
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.
The other line is the multi handle in the constructor that takes thread instrumentation. Both constructors reach it through the same helper, so Measured with the same |
| << "the request was accepted and never reached an outcome"; | ||
| EXPECT_FALSE(session->IsSessionActive()) << "the session stayed active with nothing running it"; | ||
|
|
||
| session->FinishSession(); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
2d441c6 to
26b7744
Compare
b570dc9 to
41c0689
Compare
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>
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>
41f2f31 to
e37fbf3
Compare
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>
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_appendThe 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_chunkis the opposite of that. The pointer to everything appended so far goes with it, so the two places that free the list,Cleanup()anddoRemoveSessions(), both see null and neither runs. Then the constructor carries on and dispatchesCreated, andSetup()only setsCURLOPT_HTTPHEADERwhenheaders_chunkis 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 noContent-Type: application/x-protobufand 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 callsSendAsyncunconditionally, the easy handle is still valid, andSetup()only skipsCURLOPT_HTTPHEADERrather than failing. So the first version of this announcedCreateFailedand 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()andSendAsync()both refuse on it beforeSetup(), andSession::SendRequestreports it once with the reason curl gave rather than the empty string that branch used to pass. The constructor also marks the operation terminal, orCleanup()from~HttpOperationannounces a cancel for something that never started, to a handler the caller may already have dropped. That one aborts withpure virtual method called, and it only showed up once the send was actually stopped.curl_multi_initSame shape, three sites: both
HttpClientconstructors andresetMultiHandle(). 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:
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 beforecurl_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_performrejects 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 atSendRequestwould 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_performleavesstill_runningalone 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 thestill_running > 0branch, which is above theis_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~HttpClientcould never join the thread.Three changes close it.
curl_multi_performandcurl_multi_info_readare not called with a handle libcurl refused to give, which is whatcurl_multi_initasks 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
wakeupBackgroundThreadreaches 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 withis_shutdown_rechecked now, which measures 10 ms, five runs out of five. Andcurl_multi_pollandcurl_multi_waitneed no guard, since they sit inside the branch that only runs when perform succeeded.curl_multi_add_handleandcurl_multi_remove_handledo 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 beforecurl_global_initializer_, so both constructors calledcurl_multi_initbeforeHttpCurlGlobalInitializerhad calledcurl_global_init. libcurl asks for the opposite. No CI job could see it: the allocation cases install their allocators fromSetUpTestSuitewithcurl_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:
curl_easy_initcurl_slist_appendcurl_multi_initFailing 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_initfailure 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 plainboolin 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_memreturnsCURLE_OKand quietly changes nothing once libcurl has been initialised, so the hooks go in fromSetUpTestSuite. 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:
curl_slist_appendchange onlyAFailedHeaderAllocationIsReportedfailscurl_multi_initchange onlyAFailedMultiHandleAllocationIsReportedfailsChecks
curl_http_test,OTELCPP_MAINTAINER_MODE=ONWITH_STL<memory>costs nothing off theCXX14pathclang-format18.1.8clang-tidy22 with the CI filtershttp_client_curl.cc, and 4 onhttp_operation_curl.ccwhich is exactly whatmainreports for itinclude-what-you-use0.26 with--mapping_file=.iwyu.imp,CXX14Installed surface
Correcting myself on where these classes live, because I had it wrong here.
ext/CMakeLists.txtinstalls four headers fromext/http/client, andhttp_client_curl.handhttp_operation_curl.hare not among them: they are the implementation behindhttp_client_factory_curl.h. So a CMake consumer does not see either layout. Bazel is the other way round, since//exttakeshdrs = 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.
HttpOperationgainsconstruction_result_, andHttpClientgainswakeup_generation_, an atomic the producers raise so the background thread can be woken without a multi handle. Both are private, andHttpClient::resetMultiHandle()becomingboolis private too. If the project holds these two to a layout guarantee anyway, say so: the construction result can be carried in the existinglast_curl_result_andsession_state_, and the wakeup counter can go behind the existingmulti_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, andFinishSessionwaited 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.wakeupBackgroundThreaddid nothing without a handle. It goes throughcurl_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_cleanuptwice, in the destructor and inresetMultiHandle, andcurl_multi_remove_handlethroughdoRemoveSessions, whichresetMultiHandlecalls 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 notCURLM_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 bycurl_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
resetMultiHandlefrom the test thread, which destroys the multi handle and builds another while the IO thread may be insidecurl_multi_perform,curl_multi_pollorcurl_multi_info_readon the same one. libcurl leaves handles unsynchronized, and the IO thread does not holdmulti_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_initis 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_PREVIEWa 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
GlobalLogHandlerreads 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. Andcurl_global_init_meminSetUpTestSuitehad no matching cleanup, so with every client taking one of its own throughHttpCurlGlobalInitializerthe 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
mainhas, 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_initsucceeds, 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
Cancellednobody 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 withCURLM_BAD_HANDLErather 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, sincemulti_handle_is declared beforecurl_global_initializer_and member initialisation order meantcurl_multi_initran beforecurl_global_inithad. 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 withcurl_global_init_mem, which is the global initialisation, so by the time any client exists libcurl is initialised and the singleton'scurl_global_initis a reference count bump that returnsCURLE_OKwhatever 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 dispatchesConnectFailedfromSendAsyncand thenCreateFailedfromSession::SendRequest, which is #4360 item 3 and is untouched. What this branch does is give the constructor a result and makeSendAsyncrefuse on it beforeSetup()runs, so a failedcurl_easy_initor header list produces one terminal event.AFailedEasyHandleIsReportedOncecounts that and nothing wider.The gzip branch still reports and sends.
CreateFailedgoes to the caller and then control falls through toSendAsync, carrying a bodydeflateInPlacemay have written into, at its original length and with noContent-Encoding. #4360 item 1, untouched.A direct
HttpOperationcaller is told by the return code, not by its handler. The constructor records a failure and does not dispatch it, so a failedcurl_easy_initor header list reaches anEventHandleronly throughSession::SendRequest, once, asCreateFailed. That is coherent ifSessionis the only supported owner of asynchronous notification, and it is what keeps the count at one. The asymmetry is that the constructor does dispatchCreatedon its way out when it succeeds. Making it symmetric means the constructor reports andSession::SendRequestthen 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.txtdoes not installhttp_operation_curl.h, so a CMake consumer cannot construct one directly at all, while//exttakeshdrs = 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_initon both the synchronous and asynchronous paths. Covered on #4395: a session a reset took before its id was queued, and onecurl_multi_add_handlerejects.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_performcannot be made to fail from a test without a seam; a retryable response already queued when a reset happens; acurl_easy_setoptfailing insideSetup(), 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.
detect_leaks=1LeakSanitizer 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.
resetMultiHandlekeeps the sessions whose ids are already inpending_to_add_session_ids_and takes the rest, and a request betweenCreateSessionandSendAsyncis one of the rest: the reset cancels it, and onmainthe id then goes into the queue whatever became of the session, wheredoAddSessionsfinds nothing to add and moves on, leaving a promise nobody fulfils. #4395 refuses that id and finishes the operation itself, andASessionResetTookBeforeItWasQueuedIsFinishedon 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 putsattached_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:AResetThatCannotBuildAReplacementLeavesNothingAttachedis 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 withdetect_leaks=1.Small overlaps with two open PRs, both one line, and I will take the rebase rather than ask anyone else to:
global_log_handler.hin place of the conditional copy.sessions_m_is still scoped to the snapshot insideresetMultiHandle(), andfriend class HttpClientTestPeeris still on the client.#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 insideresetMultiHandleis still the snapshot only.#4391 asked for the unchecked
curl_multi_remove_handleandcurl_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(), andthey were uncovered because nothing in the suite passed either option. Two cases
now do, and
gcovrreports one hit each afterwards. The first draft of thosecases passed while covering none of it, because the whole SSL block sits behind
ssl_options_.use_ssland I had set the TLS fields without it; the measurementis 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_cleanupfailure logs. Reaching the first needs a clientwhose multi handle failed to initialise, and there is no seam for that here:
curl_multi_initis called directly. Reaching the second needscurl_multi_cleanupto fail, which it did not in 104 and 13 evaluationsrespectively. 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.mdupdated for non-trivial changes