Skip to content

[BUG] HttpOperation::Abort writes to the curl easy handle from the cancelling thread #4375

Description

@thc1006

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions