[BUG] Remove a curl easy handle before releasing what it points at - #4405
[BUG] Remove a curl easy handle before releasing what it points at#4405thc1006 wants to merge 8 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4405 +/- ##
==========================================
+ Coverage 82.61% 82.65% +0.04%
==========================================
Files 511 511
Lines 20132 20187 +55
==========================================
+ Hits 16631 16684 +53
- Misses 3501 3503 +2
🚀 New features to boost your workflow:
|
dd5df05 to
44b53fe
Compare
|
Where the five uncovered patch lines are, measured with the same One is the guard this change is about. One is a null easy handle in Three are the I would rather say that plainly than add cases that pin defensive branches into place with test-only seams. If you want any of the three covered, say which and I will send them. |
44b53fe to
e625eef
Compare
…_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>
5a7e671 to
707ce77
Compare
5ef4bf6 to
1e12bd8
Compare
…_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>
a2f59a9 to
7714cb9
Compare
…_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>
Teardown touched the easy handle in the wrong order and on the wrong thread. Cleanup cleared CURLOPT_PRIVATE and called curl_easy_reset on it, from whichever thread ran the cancel, while the transfer could still be active. doRemoveSessions then freed the header list before curl_multi_remove_handle and cleaned the handle up whatever that call returned. libcurl is explicit on all three: changing options while a transfer is in progress may have undefined behaviour, the header list has to outlive the handle that points at it, and a handle has to leave the multi handle before it can be cleaned up. Cleanup now hands the resource over untouched. doRemoveSessions removes it, checks the CURLMcode, and only then frees the list and the handle. A failed removal leaves both alone: leaking one easy handle is better than freeing one the multi stack may still own. A handle that was never added reports CURLM_OK, so no separate bookkeeping is needed to tell the two apart. Clearing CURLOPT_PRIVATE was load bearing rather than tidy up. The IO loop reads it back to decide whether a CURLMSG_DONE belongs to a session that has already gone, so PerformCurlMessage checks is_cleaned_ instead. That keeps the behaviour without writing to a handle from a thread that does not own it. Part of open-telemetry#4391. The retry path still calls curl_multi_remove_handle and curl_multi_add_handle without checking either result, so the issue stays open. 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>
Handing the easy handle over untouched leaves CURLOPT_PRIVATE and the write, header, read and progress callback data pointing at the session and its operation. Clearing them was what made that safe before, and this branch stopped clearing them on purpose, because the cancelling thread does not own a handle the multi handle may still be driving. The owner then has to outlive the handle, and on the abort path it did not. ScheduleAbortSession takes the session out of sessions_, so doRemoveSessions cannot find it to hold one, and doAbortSessions kept the only remaining shared_ptr in a local map that died on return. The handle could reach curl_multi_remove_handle and curl_easy_cleanup with those pointers dangling, and curl_easy_cleanup is documented as able to run the progress and header callbacks. pending_to_remove_sessions_ already exists for this: doRemoveSessions swaps it out and holds it until the handle is freed. The abort path hands the session to it now. 26 tests pass, 3 of 3, and under AddressSanitizer with leak detection 2 of 2 with no leaks and no errors. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
curl_multi_remove_handle reports whether that call succeeded, not whether the multi handle was holding the easy handle. libcurl 8.10 and 8.11 refuse a handle the multi handle does not hold, and a multi handle that failed to initialize refuses every one, so reading the refusal as ownership dropped the record: the easy handle, the header list it points at and the session it names went with it, while the message loop still reads that session back out of CURLOPT_PRIVATE. The client now records an easy handle when curl_multi_add_handle accepts it, asks for no removal of one that was never added, and keeps the whole record, session included, when libcurl will not give a handle back. Kept handles are freed once curl_multi_cleanup has detached them. The queue holds one record per easy handle rather than one per session, so a session that starts another request no longer displaces the handle its previous request left behind, and the destructor releases what the background thread did not get to. The retry path goes through the same accounting, and reports a re-arm that libcurl would not accept rather than dropping the session from the queue in silence. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
RepeatedCallerThreadCancelsAreClean summed Cancelled and Response over twenty attempts against a closed port and asked for at least one each. There is a third way such an attempt ends: the connection fails before the cancel arrives, and the cancel is then correctly a no-op. Where the caller and the IO thread share a core that is the usual outcome, and the case fails. Measured with the run pinned to one core, fifteen runs each: 15 of 15 fail on main at 3fb1d31, on open-telemetry#4395 at 66ab56c, and on this branch either side of the change it carries, all with the same 3 to 7 cancels out of 20. Unpinned all four pass. The failure the valgrind job hit is that, not anything about the handle accounting. The connection failure is counted apart from terminal_count_, which two other cases pin to an exact value, and it is taken before the existing chain rather than inside it, because ACancelBeforeTheResponseReportsCancelled cancels from ConnectFailed and needs that branch to keep firing. The case now asks each attempt to end somewhere the handler is told about rather than to end the same way twenty times, which is what it was after. Pinned to one core it goes from 15 of 15 failing to 0 of 15. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
A queued easy handle still names its session through CURLOPT_PRIVATE, and the message loop reads that back and dereferences it. Removing a handle from an active transfer also runs the progress callback once, measured on 8.14.1, and that callback dereferences the operation the session owns. So the session has to outlive the record, and the record was relying on finding it again when it was drained, in sessions_ or in a second queue that CleanupSession filled. Neither survives the ordinary ordering. Instrumented over the suite, one or two sessions per run reach CleanupSession with a record of theirs in flight, no record for them visible because the background thread has already swapped the queue out, and the session inactive because its completion callback has run. Neither branch keeps it, so its last reference goes while the handle it names is still being released. The record now takes the session in the same critical section that publishes it. CleanupSession has nothing left to decide about a queued handle, and the queue that existed only to carry sessions found later is gone. The abort path gives its record a session directly, because scheduling an abort takes that session out of sessions_ before the handle is queued, and nothing drains in between: both steps belong to the background thread, one after the other. A handle recorded against a multi handle that gets replaced is not offered to the replacement, because curl_multi_cleanup detaches what it held and the ledger is emptied in the same step. That is what a generation on the record would be for, and there is a case for it now. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The guard added earlier on this branch returns before the message is read, and the caller then asked IsRetryable() anyway. That predicate reads response_code_, last_curl_result_ and retry_attempts_, none of which the guard updates, so for an operation that has been cleaned up it answers with whatever the message before it left behind. A retryable status from that earlier message puts a session back in the retry queue while the easy handle it names is on its way out of the client, and doRetrySessions offers a moved-out handle to the multi handle without reading either return code. PerformCurlMessage now answers whether it has rewound the operation for another attempt, which is the only thing that makes the push correct, and the predicate is read once rather than twice with a Cleanup possible in between. The guard's comment claimed more than the flag carries. is_cleaned_ is exchanged at the top of Cleanup, ahead of the terminal event, the hand over of the easy handle, the completion callback and the promise, so reading it true says only that some thread has entered Cleanup. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
curl_easy_cleanup documents that it can reach the application: "Occasionally you may get your progress callback or header callback called from within curl_easy_cleanup (if previously set for the handle using curl_easy_setopt)", for protocols that need a command and response before disconnecting. By then the event handler is gone. The only shared_ptr to it is captured in the completion callback that SendRequest builds, Cleanup swaps that out and runs it, and it is released when Cleanup returns. The write, header and read callbacks all dispatch through the raw pointer the operation keeps, so the one libcurl says it may call is the one with nothing behind it. Clearing the handle first takes the callbacks and their data off it, so freeing it cannot reach anything the client has let go of, and the header list is freed after that because libcurl does not copy it and the clear is what stops it being read. This runs on the background thread with the handle detached, which is what the version removed from Cleanup earlier on this branch could not say: that one ran on whichever thread cancelled, against a handle the multi handle still held. Measured on 8.14.1 over HTTP, neither curl_multi_remove_handle nor curl_easy_cleanup reaches a callback once the transfer has finished, and removing a handle from a live transfer reaches the progress callback once. So nothing here is what stops a failure today over HTTP. What it stops is the call libcurl reserves the right to make. 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>
f4a41eb to
18bb79d
Compare
Fixes #4391.
What was wrong, and one thing I had wrong about it
The issue has three parts:
Cleanup()writes to an easy handle that may still be transferring,doRemoveSessions()frees the header list before it takes the handle out of the multi handle, and neither the removal nor the retry path reads theCURLMcodeit gets back. The first two are ordering and they're fixed by moving two calls.The third one I got wrong in my own comment on the issue, where I said a handle the multi handle never took reports
CURLM_OK, so nothing has to remember which handles were added. That's true of the libcurl I tested on and it isn't true in general. The prologue ofcurl_multi_remove_handlepicked up a second condition for two minor releases:if(!GOOD_EASY_HANDLE(data))CURLM_OKif(!GOOD_EASY_HANDLE(data) || !multi->num_easy)CURLM_BAD_EASY_HANDLEif(!GOOD_EASY_HANDLE(data))CURLM_OKfind_package(CURL)here has no lower bound, so both are supported. And the version window isn't even needed to reach the same branch: I measuredcurl_multi_remove_handle(NULL, easy)returningCURLM_BAD_HANDLEon 8.14.1, which is what a client whosecurl_multi_init()failed hands to every removal.What made that reading dangerous is what the failure branch did with it.
pending_to_remove_session_handles_is swapped into a local, so acontinuepast a record ends the life of that record at the end of the pass. The easy handle and its header list go with it, and so does theshared_ptr<Session>the same pass was releasing, while the handle still carries that session inCURLOPT_PRIVATE. The message loop reads it straight back:What this does now
curl_multi_add_handleaccepts it, and unrecorded whencurl_multi_remove_handlegives it back. A handle that was never accepted is freed without asking the multi handle anything, so neither libcurl's answer nor the version it came from decides the path.curl_multi_cleanupdetaches them, inresetMultiHandle()and in the destructor. No retry loop, because a refusal here doesn't become acceptance later, and I didn't want a busy path in the IO loop.doRetrySessions()goes through the same accounting and reports a re-arm libcurl won't accept, which is the silence the issue asks about.Evidence
Fail before, pass after, against
dd2ca474(this branch before the change) with the same two scenarios written against the members that existed there:ARefusedRemovalKeepsTheHandleAndTheSessionItNamesASecondHandleFromTheSameSessionDoesNotDisplaceTheFirstEach case was then checked against the mutation it's meant to catch, on an otherwise clean tree:
ARefusedRemovalKeepsTheHandleAndTheSessionItNamesAHandleTheMultiHandleNeverTookIsFreedNotStrandedASecondHandleFromTheSameSessionDoesNotDisplaceTheFirstTheAttachmentLedgerIsEmptyOnceARequestFinishesThat last row is honest rather than tidy. With the record never written, every release takes the never-added path and frees a handle the multi handle still holds, and I could not get that to show as anything: a small probe says
curl_easy_cleanupdetaches the handle itself on the way out on 8.14.1, so the state afterwards is identical either way. The half of the accounting that's covered is the half that skips a removal it doesn't need. The half that isn't is the documented call order, which the same probe suggests only bites while a transfer is actually running, and I don't have a way to hold one open and free underneath it from a test.One case fixed that isn't about any of this
The valgrind job went red on
RepeatedCallerThreadCancelsAreClean, which came in with #4392 and has nothing to do with the accounting here. It sumsCancelledandResponseover twenty attempts against a closed port and asks for at least one each, and there's a third way such an attempt ends: the connection fails before the cancel arrives, and the cancel is then correctly a no-op. Instrumented, the silent attempts runCreated,Connecting,ConnectFailedand stop there.Pinned to one core, fifteen runs each:
3fb1d31766ab56caSame on all four, so it isn't the change, it's a case that pinned one of two legitimate outcomes. It's now asking each attempt to end somewhere the handler is told about rather than to end the same way twenty times, and pinned to one core that goes from 15 of 15 failing to 0 of 15. The connection failure is counted apart from
terminal_count_, which two other cases pin to an exact value, and outside the existing chain, becauseACancelBeforeTheResponseReportsCancelledcancels fromConnectFailedand needs that branch to keep firing.It's a separate commit. It rides along because it's the job this PR makes red and it's a case I added, but it splits out cleanly if you'd rather have it on its own.
Checks
curl_http_testWITH_OTLP_RETRY_PREVIEW=ONdetect_leaks=1bazel test //ext/test/http:curl_http_testWITH_STL=CXX14Related
Rebased on current main. #4395 also touches
Cleanup()and is still open, and this revision doesn't go near it: the accounting is all inhttp_client_curl.ccand its header, and theCleanup()hunk is the one the first commit on this branch already had.For significant contributions please make sure you have completed the following items:
CHANGELOG.mdupdated for non-trivial changes