Splitting this out of #4369, which is about the test that happened to expose it. dbarker asked for a separate issue to track the client side and the discussion of how to fix it.
What happens
HttpOperation::Abort() writes to the curl easy handle from whichever thread cancels:
void HttpOperation::Abort()
{
is_aborted_.store(true, std::memory_order_release);
if (curl_resource_.easy_handle != nullptr)
{
// Enable progress callback to abort from polling thread
curl_easy_setopt(curl_resource_.easy_handle, CURLOPT_NOPROGRESS, 0L);
...
The background thread is driving that same handle through the multi handle at the time. libcurl's rule is that an easy handle must not be used from two threads at once, and TSAN saw exactly that pairing:
main thread Session::CancelSession -> HttpOperation::Abort -> curl_easy_setopt
thread T17 curl_multi_perform -> Curl_connect -> resolve_server -> Curl_resolv
Full log in #4369.
Reach
Session::CancelSession() is public, and HttpClient::CancelAllSessions() calls it for every session, which is what OtlpHttpClient::Shutdown() does. So an application that shuts down while a request is in flight is in this, not only a test. It is intermittent: the window is however long the IO thread spends inside the handle, so the odds go up when the cancel lands early, during name resolution or connect.
A possible fix
OnProgressCallback reads nothing but is_aborted_, which is atomic and is already set on the line above, and the callback is registered at setup through CURLOPT_XFERINFOFUNCTION and CURLOPT_XFERINFODATA. CURLOPT_NOPROGRESS is the only piece still left at its default, which is why Abort() has to reach for the handle at all. Turning it on at setup would leave Abort() with the flag and the scheduled teardown:
void HttpOperation::Abort()
{
is_aborted_.store(true, std::memory_order_release);
if (async_data_ && nullptr != async_data_->session)
{
async_data_->session->GetHttpClient().ScheduleAbortSession(
async_data_->session->GetSessionId());
}
}
The curl_resource_.easy_handle read goes too, since reading that member from the cancelling thread is the same kind of sharing.
The cost is a progress callback that runs during every transfer and does one atomic load. I have this built: 22 of 22 in curl_http_test, 19.1 s against 18.1 s on main so no slowdown, clean under OTELCPP_MAINTAINER_MODE=ON, and a probe confirms the callback really does fire, 459 times across the suite, so CURLOPT_NOPROGRESS = 0 at setup is taking effect.
I have not sent it, because it is a behaviour change in a shared client and worth agreeing on first. Happy to open it if this shape looks right.
What I could not do
Reproduce it. Twelve TSAN runs on the box, including one pointed at a slow-resolving host to widen the window, all clean. The evidence is the CI log and the code path, so whatever fix lands wants checking against the Bazel TSAN config rather than a local one.
Splitting this out of #4369, which is about the test that happened to expose it. dbarker asked for a separate issue to track the client side and the discussion of how to fix it.
What happens
HttpOperation::Abort()writes to the curl easy handle from whichever thread cancels:The background thread is driving that same handle through the multi handle at the time. libcurl's rule is that an easy handle must not be used from two threads at once, and TSAN saw exactly that pairing:
Full log in #4369.
Reach
Session::CancelSession()is public, andHttpClient::CancelAllSessions()calls it for every session, which is whatOtlpHttpClient::Shutdown()does. So an application that shuts down while a request is in flight is in this, not only a test. It is intermittent: the window is however long the IO thread spends inside the handle, so the odds go up when the cancel lands early, during name resolution or connect.A possible fix
OnProgressCallbackreads nothing butis_aborted_, which is atomic and is already set on the line above, and the callback is registered at setup throughCURLOPT_XFERINFOFUNCTIONandCURLOPT_XFERINFODATA.CURLOPT_NOPROGRESSis the only piece still left at its default, which is whyAbort()has to reach for the handle at all. Turning it on at setup would leaveAbort()with the flag and the scheduled teardown:The
curl_resource_.easy_handleread goes too, since reading that member from the cancelling thread is the same kind of sharing.The cost is a progress callback that runs during every transfer and does one atomic load. I have this built: 22 of 22 in
curl_http_test, 19.1 s against 18.1 s on main so no slowdown, clean underOTELCPP_MAINTAINER_MODE=ON, and a probe confirms the callback really does fire, 459 times across the suite, soCURLOPT_NOPROGRESS = 0at setup is taking effect.I have not sent it, because it is a behaviour change in a shared client and worth agreeing on first. Happy to open it if this shape looks right.
What I could not do
Reproduce it. Twelve TSAN runs on the box, including one pointed at a slow-resolving host to widen the window, all clean. The evidence is the CI log and the code path, so whatever fix lands wants checking against the Bazel TSAN config rather than a local one.