Repository navigation
[BUG] Cancelling from a Created or Connecting event loses the cancel and leaves the operation unfinished #4390
Description
Line numbers, re-resolved 19 October 2026. Everything quoted below is at
f6e48180, which the environment section names. Againstmainatcdd17eb1the file has moved, so here is where each cited line is now.
cited what it is on cdd17eb1:1454the Connectingdispatch inSendAsync1492 :1456the is_aborted_clear1494 :1459where 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:1462where the promise is created 1500 :1467the ScheduleAddSessioncall1505 http_operation_curl.cc:516the result_futurewait inFinish()534 http_client_curl.cc:259Session::FinishSession280
mainalso has acurl_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(...); // :1467Say 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
[BUG] Complete a curl operation that is never scheduled
[BUG] Complete a curl operation that is never scheduled
[BUG] Complete a curl operation that is never scheduled
[BUG] Complete a curl operation that is never scheduled
[BUG] Complete a curl operation that is never scheduled
[BUG] Complete a curl operation that is never scheduled
[BUG] Complete a curl operation that is never scheduled