Skip to content

[BUG] Cancelling from a Created or Connecting event loses the cancel and leaves the operation unfinished #4390

Open

Description

@thc1006

Line numbers, re-resolved 19 October 2026. Everything quoted below is at f6e48180, which the environment section names. Against main at cdd17eb1 the file has moved, so here is where each cited line is now.

cited what it is on cdd17eb1
:1454 the Connecting dispatch in SendAsync 1492
:1456 the is_aborted_ clear 1494
:1459 where the session route is published 1497, and it is async_data_->session.store(session, std::memory_order_release) now, the member having become atomic in #4392
:1462 where the promise is created 1500
:1467 the ScheduleAddSession call 1505
http_operation_curl.cc:516 the result_future wait in Finish() 534
http_client_curl.cc:259 Session::FinishSession 280

main also has a curl_easy_setopt(..., CURLOPT_NOPROGRESS, 0L) at 1490 that the listing below does not, between the setopt block and the dispatch. The ordering the report is about is unchanged.

Describe your environment main at f6e4818, Ubuntu 24.04, gcc 14, libcurl 8.14.1, CMake. The ordering below has no platform conditionals in it.

HttpOperation::SendAsync() hands the first event to the user before the operation is ready to be cancelled, and a cancel that lands in that window is thrown away. The request is then neither sent nor completed, and the caller blocks when it tries to finish the session.

  curl_easy_setopt(curl_resource_.easy_handle, CURLOPT_PRIVATE, session);

  DispatchEvent(...SessionState::Connecting);                    // :1454  user handler runs here
  is_finished_.store(false, std::memory_order_release);
  is_aborted_.store(false, std::memory_order_release);           // :1456  clears the cancel
  is_cleaned_.store(false, std::memory_order_release);

  async_data_->session = session;                                // :1459  route published here
  ...
  async_data_->result_promise = std::promise<CURLcode>();        // :1462  promise created here
  ...
  session->GetHttpClient().ScheduleAddSession(...);              // :1467

Say the handler calls Session::CancelSession() from that Connecting event, which the public API allows. Abort() raises is_aborted_, then checks async_data_->session, which is still the nullptr SendAsync left there, so no abort is scheduled. CleanupSession() does run, and it removes the session from sessions_.

SendAsync then resumes and undoes the rest of it. It puts is_aborted_ back to false. ScheduleAddSession erases the pending abort. When the IO thread reaches doAddSessions, the session is no longer in sessions_, so the lookup misses and it continues without ever adding the handle to the multi handle.

What is left is a live promise with nobody to fulfil it. HttpOperation::Finish() waits on it, and ~HttpOperation waits on it too when the state is Connecting.

Steps to reproduce Add this to ext/test/http/curl_http_test.cc on unmodified main. It uses TerminalCountingHandler, which is already there:

TEST_F(BasicCurlHttpTests, CancelFromConnecting)
{
  auto session_manager = std::make_shared<http_client::curl::HttpCurlClientFactory>()->Create();
  auto session = session_manager->CreateSession("http://127.0.0.1:19000");
  auto request = session->CreateRequest();
  request->SetUri("get/");

  auto handler            = std::make_shared<TerminalCountingHandler>();
  handler->cancel_target_ = session.get();
  handler->cancel_at_     = http_client::SessionState::Connecting;

  session->SendRequest(handler);
  session->FinishSession();
  session_manager->FinishAllSessions();
}

SendRequest returns and FinishSession never does. Under a 60 second timeout the binary exits 124 with zero cases finished. A core taken at 12 seconds:

#5  std::__future_base::_State_baseV2::wait (...) at /usr/include/c++/14/future:360
#7  HttpOperation::Finish (...) at ext/src/http/client/curl/http_operation_curl.cc:516
#8  Session::FinishSession (...) at ext/src/http/client/curl/http_client_curl.cc:259

What is the expected behavior? A cancel from any event either takes effect or does nothing, and either way the operation reaches a terminal state and releases whoever is waiting on it.

What is the actual behavior? The cancel is erased, the request is never sent, and the session cannot be finished or destroyed without blocking forever.

Additional context Created has the same shape one step earlier. The constructor dispatches it, but Session::curl_operation_ is only assigned when the curl_operation_.reset(...) in Session::SendRequest returns, so a handler cancelling from Created cannot reach an operation at all.

The fix I would reach for is to publish the promise, the callback and the session route before the first DispatchEvent, and to make add versus abort a single decision that abort always wins, rather than two flags that can overwrite each other. That is a bigger change than I want to guess at, so I would rather ask first.

Found while working on #4375. Different mechanism, same function, and #4375 does not touch this.

Activity

  1. added
    needs-triageIndicates an issue or PR lacks a `triage/foo` label and requires one.
    on Aug 10, 2026
  2. added 6 commits that reference this issue on Aug 11, 2026
    c26167e
    8a01384
    6df2d59
    244311c
    fb0840f
    64807b2
  3. added a commit that references this issue on Aug 31, 2026
    39f6efb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageIndicates an issue or PR lacks a `triage/foo` label and requires one.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions