[BUG] Cancel a curl session without writing to the easy handle - #4392
Conversation
81b6794 to
87d5faf
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #4392 +/- ##
==========================================
+ Coverage 82.35% 82.38% +0.04%
==========================================
Files 502 502
Lines 19877 19883 +6
==========================================
+ Hits 16368 16379 +11
+ Misses 3509 3504 -5
🚀 New features to boost your workflow:
|
lalitb
left a comment
There was a problem hiding this comment.
LGTM. Nice work finding and fixing this race. Thanks!
87d5faf to
df23d75
Compare
|
Please resolve merge conflicts, will merge after that. |
Abort() ran curl_easy_setopt on whichever thread cancelled, while the IO thread was driving the same handle through curl_multi_perform. libcurl does not allow one easy handle to be used from two threads at once. SendAsync now sets CURLOPT_NOPROGRESS beside CURLOPT_PRIVATE, on the thread that owns the handle and before the handle is scheduled, so a cancel only has to raise the atomic flag the progress callback already reads. Only a session can be cancelled, so the synchronous path is left alone. AsyncData::session becomes atomic for the same reason: Abort() reads it while Cleanup() clears it on the IO thread. The callback's non-aborted return changes from CURL_PROGRESSFUNC_CONTINUE to 0. CURL_PROGRESSFUNC_CONTINUE asks libcurl to run its built-in progress meter as well, and that meter writes to stderr. The branch was unreachable while the option was only set after the abort flag went up. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
df23d75 to
5e58389
Compare
|
Rebased onto main and it's mergeable again. Both conflicts were with #4394, which landed in between. The test file one keeps both cases, 26 of 26 in The two commits also became one. The second only corrected the description of the first, so keeping both would have put the same subject line on the branch twice. |
|
Conflicts resolved, the branch is on current main. The one red job, The three steps after it, Re-running it needs admin rights on the repository, so that one is yours rather than mine. |
Fixes #4375.
Abort()rancurl_easy_setopton whichever thread calledCancelSession(), while the IO thread was driving the same easy handle throughcurl_multi_perform. That is the pairing TSAN caught in #4369, and libcurl's rule is that one easy handle must not be used by two threads at the same time.Where the option is set now
CURLOPT_NOPROGRESSwas the only option still left at its default, which is the whole reasonAbort()had to reach for the handle.SendAsync()sets it now, right besideCURLOPT_PRIVATE, on the thread that owns the handle and before the handle is scheduled. Cancelling is left with the atomic flag the progress callback already reads plus the abort it schedules.It goes there rather than in
Setup()becauseSetup()serves the synchronous path too, and nothing on that path can be cancelled:Abort()runs only fromSession::CancelSession(), andHttpClientSyncnever makes a session. Arming the callback there would have bought nothing.@owent, this keeps the fallback you described.
ScheduleAbortSession()already callswakeupBackgroundThread(), so on 7.68 and latercurl_multi_wakeup()gets the IO thread out ofcurl_multi_pollon its own. What the progress callback still buys is the other case, where the IO thread is insidecurl_multi_performrather than waiting in the poll, and arming it up front costs one atomic load per progress tick instead of a cross thread write to the handle. The callback fires 376 times acrosscurl_http_test, so the option is taking effect, and the cost does not show up: over five runs of the same 22 cases,maincame in between 17.6 s and 18.6 s and this branch between 16.1 s and 18.6 s, which is the same spread.The race that actually reproduces
There is a second one in the same call, and it turned out to be the one I could trigger.
AsyncData::sessionis a plainSession *thatAbort()reads at:1516whileCleanup()writesnullptrto it at:552on the IO thread. It isstd::atomic<Session *>now.There is no lifetime question sitting behind the pointer value here:
Abort()has exactly one caller,Session::CancelSession()athttp_client_curl.cc:249, so the pointer is always the session whose own method is running, and callers hold that byshared_ptr.Against unmodified
main, with only the new test case added, TSAN reports on every run:curl_http_test --gtest_filter='*RepeatedCallerThreadCancels*', 5 runsmainat f6e4818 plus the new caseThe return value had to change with it
OnProgressCallback()returnedCURL_PROGRESSFUNC_CONTINUEwhen the request had not been aborted. That branch was unreachable whileCURLOPT_NOPROGRESSwas only cleared insideAbort(), since by thenis_aborted_is already up and the callback returns early. Arming the option up front makes it the normal path, and it does not mean what the name suggests:lib/progress.cfalls straight through toprogress_meter(), which writes todata->set.err, and that is stderr unlessCURLOPT_STDERRsays otherwise:With the option armed and nothing differing but the return value:
curl_http_teststderrCURL_PROGRESSFUNC_CONTINUE0So without this half, the fix would have printed curl progress bars to stderr for every export that ran longer than a second. A standalone libcurl program built twice from one source, differing only in that return, gives the same answer: 711 bytes of meter against none.
Tests
RepeatedCallerThreadCancelsAreCleancancels from the caller thread while the IO thread owns the handle, twenty times. Nothing listens on 19937, so each attempt fails to connect and the IO thread reachesCleanup()while the caller is still insideCancelSession(), which is the overlap the race needs. It passes onmaintoo, since a data race is not a functional failure, and it is there for the sanitizer builds.The two existing cancel cases keep asserting they cancel from the IO thread, but the comment explaining why has to go: it said cancelling reaches
curl_easy_setoptand therefore has to run on the owning thread, and after this that is no longer true.What this does not fix
Three things I ran into on the way and filed rather than folding in here: #4389, where the IO thread deadlocks on
sessions_m_recovering from acurl_multi_performerror, #4390, where cancelling from aCreatedorConnectingevent loses the cancel and leaves the caller blocked forever, and #4391, where the handle is reset and its header list freed before the removal from the multi handle. All three are on this file or its neighbour and none of them changes what is here.#4360 is also unaffected: a cancel that the progress callback turns into
CURLE_ABORTED_BY_CALLBACKstill producesConnectFailedorSendFailedfollowed byCancelled. That was already the case before this change, sinceAbort()armed the same callback, so the event sequence is the same as it was.Checks
24 of 24 in
curl_http_test, clean underOTELCPP_MAINTAINER_MODE=ON, clean underclang-format18.1.8. The whole suite under TSAN is 24 of 24 with zero warnings.For significant contributions please make sure you have completed the following items:
CHANGELOG.mdupdated for non-trivial changes