Skip to content

[BUG] Complete a curl operation that is never scheduled - #4395

Draft
thc1006 wants to merge 15 commits into
open-telemetry:mainfrom
thc1006:bugfix/complete-unscheduled-operation-4390
Draft

[BUG] Complete a curl operation that is never scheduled#4395
thc1006 wants to merge 15 commits into
open-telemetry:mainfrom
thc1006:bugfix/complete-unscheduled-operation-4390

Conversation

@thc1006

@thc1006 thc1006 commented Aug 10, 2026

Copy link
Copy Markdown
Member

Fixes #4390.
Fixes #4393.
Fixes #4408.

An operation could be given a promise and then never handed to the IO thread, and then nobody was in a position to complete it. FinishSession() blocked forever.

Three ways in, and each one hangs on main today:

A handler cancels from the Created or Connecting event SendAsync dispatched the event and only then reset is_aborted_, so the cancel was thrown away, while CleanupSession() had already taken the session out of the client. ScheduleAddSession then erased the pending abort for good measure
The URL does not parse CreateSession hands back a session it never registered and never gave an id. CURLOPT_URL is not checked when it is set, so Setup() succeeds and the request reaches ScheduleAddSession(0), which nothing can find
curl_multi_add_handle rejects the handle The result was dropped, so the operation sat there with nothing to run it

They are one invariant with three holes in it: an operation that never gets scheduled still has to finish.

What changed

The flags and the cancel route are published before the first event, so a handler cancelling from it is not overwritten by the rest of SendAsync. The future is published after the event, deliberately: a handler calling FinishSession() from Connecting would otherwise wait on a transfer that has not been scheduled yet, with SendAsync unable to schedule it because it is inside that handler.

ScheduleAddSession now reports whether the session was still registered, and SendAsync finishes the operation itself when it was not, or when the event cancelled it. doAddSessions does the same for the ones libcurl rejects, outside sessions_m_, since finishing one reaches the caller's handler and a handler that cancels takes that lock again.

All three say the same thing to the handler, a failed create carrying the reason, through one method rather than the same three lines in two places. The one libcurl refuses used to arrive as Cancelled, which the enum calls "(manually) cancelled" and which both exporters print in those words, for a request nobody cancelled and without the reason libcurl gave. Nothing behaves differently for the change: CreateFailed and Cancelled both set need_stop in the OTLP HTTP handler and both end the wait in the Elasticsearch one, so what moves is the message.

There is one ordering subtlety worth pointing at. Between publishing the future and scheduling the add, the IO thread can already have torn the operation down and found no future to complete, so SendAsync rechecks is_cleaned_ and settles it with an exchange. Whichever side gets there first, the value is set exactly once.

That same window had a second defect in it, raised by @lalitb in review. DispatchEvent stored session_state_ after the handler returned, so a handler cancelling from the first event on a client that is already polling had the IO thread storing a Cancelled of its own at the same time, and the store after the handler returned overwrote it. ThreadSanitizer reports it on this branch before the change, five runs out of five. The store now happens before the handler runs, which orders it ahead of the cancel through sessions_m_ and session_ids_m_, and the member is a std::atomic for the paths where a third thread cancels instead of the handler. SessionState is a std::uint8_t enum, and on the toolchains this was built with std::atomic of it stays one byte with alignment one, so the layout does not move. The standard promises neither, so both are static_asserts next to the member rather than a claim here: a toolchain where they do not hold fails the build instead of changing the layout quietly. Worth being exact about whose layout, since I was not. ext/CMakeLists.txt installs four headers from ext/http/client and http_operation_curl.h is not one of them, so a CMake consumer never sees this type. //ext takes hdrs = glob(["include/**/*.h"]), so a Bazel consumer does, which is why the guard is worth keeping rather than dropping. Lock freedom is a separate property and nothing here checks it, since is_always_lock_free is C++17 and this still builds at C++14.

The part I looked hardest at

This makes the completion callback run on the calling thread in those paths, where it always ran on the IO thread before. I went through every in-tree caller rather than assume:

  • OtlpHttpClient::addSession closes its session_manager_lock_ scope before SendRequest, which the comment there says is deliberate, and the lock is recursive anyway. The handler's Cancelled reaches Unbind and ReleaseSession, and ReleaseSession moves the session to gc_sessions_ rather than calling FinishSession, so nothing waits.
  • The Elasticsearch exporter holds no lock across either of its SendRequest calls, and the synchronous waitForResponse() waits on a predicate, which is exactly the case where the completion is recorded before the waiter arrives.
  • A handler calling FinishSession() from inside the completion callback is already covered: Cleanup() stamps the callback thread before invoking it and Finish() skips the wait for that thread.
  • This is not a new shape either way. Session::SendRequest already calls callback->OnEvent(CreateFailed, "") inline on the calling thread when SendAsync fails.

The behaviour change a user could notice is that a cancel from Connecting is now honoured. It used to be dropped and the request went out regardless.

Tests

CancelFromCreatedCompletes, CancelFromConnectingCompletes and InvalidUrlCompletes. All three hang rather than fail without the change: timeout 25 gives exit 124 and zero cases finished, and they pass in about 500 ms with it. Worth knowing for a bisect, since a regression here looks like a CI timeout rather than a red assertion.

CancelFromConnectingWhilePollingCompletes is the fourth, and the only one that puts an IO thread there before the event runs. A client spawns one only after SendAsync returns, so it warms the client with a completed request first, then cancels from Connecting and stays in the handler until the IO thread has entered it too. Two things follow from that. It reports the state store race without the change and none with it, five runs out of five each way. And instrumenting both tail branches of SendAsync and running the whole binary shows it is the only thing that reaches the is_cleaned_ recheck, which the three cases above never touch.

What it stays in the handler on took a second look. It waited on inside_events_, which is raised on the way into an event and lowered on the way out, and the event the IO thread dispatches there is a few atomics long, so sampling it every millisecond almost never caught it at two: four runs measured 30005, 30509, 30006 and 30015 ms and all passed, because the count the assertion reads is written by the thread that creates the overlap and never goes down. Waiting on that one instead leaves as soon as the overlap has happened, and the case goes from 30006 ms to 511 ms, ten runs out of ten, with the binary down from about 53 seconds to 23.5. The shorter wait is also the one that works: against a faithful revert of what this branch changed about session_state_, a plain member again and the store back after the handler, ThreadSanitizer reports the race five runs out of five with it and none at all with the thirty second version, which spends that long and a great many reads of the same address between the two writes. A bound that does expire now means the overlap really did not happen, so it is recorded and checked rather than left to read as one dispatched event.

ASessionTheMultiHandleRefusesIsFinished is the third root cause, and nothing reached it before. It needs no seam around libcurl: SendAsync does the whole async setup and Session::SendRequest is what starts the worker, so the case builds the operation, sends it, puts the client on a multi handle that refuses every add, and drives doAddSessions itself on one thread. Reverting that finish to the plain one it had fails the case twice over, on the state it reports and on the absence of a cancel.

Its overlap counters are relaxed on purpose. With acq_rel the two threads synchronize through the counter itself, which orders the very stores the case exists to keep unordered, and ThreadSanitizer goes quiet. A deliberate unsynchronized counter used as a control confirms it: the acq_rel build reports neither, the relaxed build reports both.

Checks

All 34 cases in curl_http_test pass, three runs out of three, and the new one ten out of ten on its own. Each of them now carries a CTest timeout of 120 seconds, read back from ctest --show-only=json-v1, because what these cases catch fails by hanging: without a bound a regression stops the job rather than reporting, which happened three times while this was being written. The whole binary takes 23.5 seconds, and the two slowest cases are the ones that wait out a request timeout on purpose, at 5007 ms each.

One number in here is pinned as it is rather than as it should be. Cancelling once the transfer belongs to the IO thread delivers two terminal events, one from Cleanup and one from the completion callback, and CancelFromConnectingCompletes requires exactly two so that a change to it is visible. One is the goal, and that is #4360, which this change does not close. A cancel from Created is reported as a failed create for the same reason: the event is dispatched from the constructor, before the Session holds the operation, so the cancel never reaches it. Moving the first events out of the constructor is the startup ordering work, not something to add here, and both are pinned rather than smoothed over. Under ThreadSanitizer the whole binary reports zero races with the change, and the new case reports the state store race five times out of five without it. Clean under OTELCPP_MAINTAINER_MODE=ON and clang-format 18.1.8, clang-tidy at zero delta against main under the CI filters, and IWYU reports correct includes for both changed translation units.

Related

#4392 fixed the cross thread write to the easy handle on the same file and has landed, and this is rebased on it: the resolution keeps its CURLOPT_NOPROGRESS placement and its atomic AsyncData::session alongside the ordering here. #4402 is the separate deadlock, FinishSession() called from an event the IO thread dispatches during a transfer, where the promise the wait is on can only be fulfilled further along that same thread. This change does not touch those events and does not close it. What it guarantees is narrower: the terminal event it generates itself, for an operation nothing is going to run, is dispatched after the cleanup that fulfils the promise, so FinishSession() from that one event returns. FinishFromTheCreateFailedEventReturns pins that and nothing wider.

#4405 changes teardown after Cleanup() has handed the easy resource back to the client, so the two are independent, but whichever lands second needs a real rebase rather than a metadata one because both touch the same removal path. I stacked all of #4405 on top of this branch to find out what that costs rather than leave you to work it out. Four of its eight commits conflict. doAddSessions is one thing wanted from two ends, since this branch finishes a session curl_multi_add_handle rejected and #4405 keeps that same handle off its attachment ledger, and it resolves as the union. PerformCurlMessage is another, where this branch adds a function immediately above it and #4405 changes what it returns. The rest is the test file, and that part wants judgement rather than a merge tool: both branches add cases at the same anchor whose first four lines are identical, so a mechanical union folds them into one function with two TEST_F headers stacked and a variable declared twice, and it does not compile. Taken apart again and resolved, the combined tree passes the curl suite 43 of 43 under AddressSanitizer with detect_leaks=1, the same with WITH_OTLP_RETRY_PREVIEW=ON, and under bazel.

For significant contributions please make sure you have completed the following items:

  • CHANGELOG.md updated for non-trivial changes
  • Unit tests have been added
  • Changes in public API reviewed

@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.72131% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.67%. Comparing base (60c3d11) to head (b2e9e22).

Files with missing lines Patch % Lines
ext/src/http/client/curl/http_client_curl.cc 93.75% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4395      +/-   ##
==========================================
+ Coverage   82.61%   82.67%   +0.06%     
==========================================
  Files         511      511              
  Lines       20132    20172      +40     
==========================================
+ Hits        16631    16675      +44     
+ Misses       3501     3497       -4     
Files with missing lines Coverage Δ
...ntelemetry/ext/http/client/curl/http_client_curl.h 95.00% <ø> (ø)
...lemetry/ext/http/client/curl/http_operation_curl.h 91.31% <100.00%> (ø)
ext/src/http/client/curl/http_operation_curl.cc 62.03% <100.00%> (+1.44%) ⬆️
ext/src/http/client/curl/http_client_curl.cc 91.14% <93.75%> (+0.81%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@thc1006

thc1006 commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

Two things on this page that a reviewer would otherwise have to dig for.

The two red jobs are both #4265

CMake gcc 14 (maintainer mode, abiv2) and CMake clang 18 (maintainer mode, sync) each fail on one test and pass everything else, and it is the same test with the same number in both:

ext/test/http/curl_http_test.cc:881: Failure
Value of: cost < std::chrono::milliseconds{20}
  Actual: false      cost ms: 247

That is #4265, which @dbarker opened on 17 July and @yogarajalakshmi-s is on. main hit it on 7 August as well.

This change does touch session teardown, so I did not want to leave it at "known flake". Same case, same build configuration, 25 runs on main and 25 on this branch:

n median max over 20 ms
main 25 0 ms 0 ms 0
this branch 25 0 ms 0 ms 0

The assertion is a 20 ms wall clock budget, so an idle box gives 0 and a loaded runner gives 247. Nothing here moves it.

Where the 11 uncovered lines are

Building the coverage configuration locally reproduces Codecov's 8 and 3 exactly. They are three different things:

Three are not new. The continue guards at the top of doAddSessions are unchanged except for the indentation of wrapping that loop in a scope, so the diff counts them as added. They were not executed before this change either.

Five are the curl_multi_add_handle failure branch. A test cannot make libcurl reject a handle, and a seam that could would be more machinery than the branch is worth. Say the word if you would rather have it covered than have it small.

Three are the is_cleaned_ recheck in SendAsync. That only runs when the IO thread tears the operation down while the first event is still being dispatched, and on a first request there is no IO thread yet, so nothing in the suite reaches it. It is there because the window is real once a client has been used, not because I caught it happening.

Comment thread ext/src/http/client/curl/http_operation_curl.cc Outdated
async_data_->session = session;
async_data_->callback = std::move(callback);

DispatchEvent(opentelemetry::ext::http::client::SessionState::Connecting);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, the callback publication reads correct now.

One more concern on this line, from the same window: if the client already has an IO thread, a handler can call CancelSession() from Connecting, and Cleanup() can dispatch Cancelled on the IO thread before this Connecting callback returns. That also makes both DispatchEvent() calls write session_state_ concurrently.

The current test uses a new client, so the background thread starts only after SendAsync() returns and does not cover this interleaving. Could we serialize cleanup with the initial event dispatch and add a test that starts the IO thread before cancelling from Connecting?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Measured it, and both halves hold.

The interleaving happens. On a client that has already completed one request, a handler cancelling from Connecting is still inside OnEvent when the IO thread runs Cleanup() and dispatches its own Cancelled. I held the handler open and counted threads inside it: two, five runs out of five. Drop the warming request and it is one, which is what every case in the file does today.

The stores race. ThreadSanitizer on that scenario, on this branch before the change, five runs out of five:

  Write of size 1 by main thread:
    #0 HttpOperation::DispatchEvent(...)  http_operation_curl.cc:415
    #1 HttpOperation::SendAsync(...)      http_operation_curl.cc:1466
    #2 Session::SendRequest(...)          http_client_curl.cc:209

  Previous write of size 1 by thread T2:
    #0 HttpOperation::DispatchEvent(...)  http_operation_curl.cc:415
    #1 HttpOperation::Cleanup()           http_operation_curl.cc:536
    #2 Session::FinishOperation()         http_client_curl.cc:269
    #3 HttpClient::doAbortSessions()      http_client_curl.cc:808

Past the undefined behavior the later store wins, so GetSessionState() finished at Connecting for an operation that had been cancelled.

What changed. DispatchEvent stores before it calls the handler, and session_state_ is now a std::atomic. Storing first is what fixes the value: the store is then ordered ahead of the cancel, which goes through sessions_m_ and session_ids_m_, and the IO thread takes session_ids_m_ before it can reach Cleanup(). The atomic covers the paths with no such handoff, where a third thread cancels rather than the handler. SessionState is a std::uint8_t enum and std::atomic of it is one byte with alignment one and lock free, so the layout of the installed type does not move.

I went through the other readers before moving the store. Nothing reads GetSessionState() from inside its own event, and every internal reader, ~HttpOperation, Cleanup, the progress and debug callbacks, and PerformCurlMessage, reads it after a dispatch has already returned, where both orders give the same value. The two synchronous Get and Post paths run on one thread with no handler at all.

The test. CancelFromConnectingWhilePollingCompletes warms the client, cancels from Connecting, and stays in the handler until the IO thread has entered it too, so the overlap is deterministic rather than lucky. Without the change it reports the race five runs out of five, with it none, and the whole file is clean under ThreadSanitizer. It is also the only thing in this file that reaches the is_cleaned_ recheck at the end of SendAsync, which closes the coverage gap I flagged on the Codecov comment. I instrumented both tail branches and ran the whole binary: the recheck was entered exactly once, by this scenario, and the three cases already on the branch all took the other branch instead.

One detail worth knowing for anyone who touches it. My first version counted the overlap with acq_rel atomics and ThreadSanitizer went quiet. The IO thread's release and the caller's later read-modify-write on the same counter synchronize with each other, which orders the two state stores and hides exactly what the case exists to catch. The counters are relaxed now. I confirmed that with a deliberate unsynchronized counter on the same line as a control: the acq_rel build reported neither it nor the real race, the relaxed build reported both.

I had opened #4408 for this before deciding to fix it here, since it reproduces on main independently of this change. This branch closes it, and I have corrected the claim there that the member could not be made atomic without moving the layout.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One correction to what I wrote above, because I stated it more broadly than it holds.

I said every internal reader takes GetSessionState() after a dispatch has already returned. There is one that does not have to. ~HttpOperation switches on it to decide whether to wait on the future, and a handler that destroys its own operation from inside an event, which is what Session::SendRequest does when a session is reused and is the shape of #4396, runs that switch while the dispatch is still in the handler. Its case list is Connecting, Connected, Sending, and it does not include Created, so storing first can change which side of it that path lands on.

I measured it rather than reason about it. Instrumenting the destructor to record every state it observes and running the same set of cases both ways:

store after the handler:   Response 28  ConnectFailed 11  SendFailed 2  Created 2  Connecting 1
store before the handler:  Response 29  ConnectFailed 11  SendFailed 1  Created 2  Connecting 1

The one that moves between SendFailed and Response is run to run variation in a suite that talks to a socket, not the ordering. So nothing the suite exercises changes, and the only path where the two could differ is a handler destroying its own operation, which is a use after free today with or without this.

@thc1006

thc1006 commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

Update on the eleven uncovered patch lines I wrote about earlier, now that the polling client case is on the branch. Measured with the same all-options-abiv2-preview configuration ci/do_ci.sh code.coverage uses, intersecting the uncovered lines with the lines this branch adds.

before now
http_operation_curl.cc 3 0
http_client_curl.cc 8 8

The three were the is_cleaned_ recheck at the end of SendAsync, which I had said nothing in the suite reached. CancelFromConnectingWhilePollingCompletes reaches it, and it is still the only thing that does: instrumenting both tail branches and running the whole binary shows the recheck entered exactly once, by that case.

Of the eight left, three are the continue guards at the top of doAddSessions, unchanged except for the indentation of wrapping that loop in a scope, so the diff counts them as added. They were not executed before this change either.

The other five are the curl_multi_add_handle rejection branch, and I owe a better answer than the one I gave. I said a test cannot make libcurl reject a handle. What I have since measured is more specific than that. A client whose multi handle is null does reject, but never in doAddSessions: the IO loop calls curl_multi_perform first, that rejects the null handle, the mc != CURLM_OK branch resets it, and doAddSessions then runs against a working handle. I confirmed that on a client built with curl_multi_init forced to fail, with the internal log captured in the same run to prove the handle really was null: the request completed normally, three runs out of three.

So reaching that branch needs the handle to be taken away and doAddSessions driven directly, which the HttpClientTestPeer friend added by #4394 can do in about fifteen lines. The reason I have not sent it is that the peer would have to win a race against the IO loop's reset, and a case that covers the branch only when it wins is a flaky case rather than a test. If you would rather have that branch covered than have the suite stay deterministic, say so and I will add it with the peer holding multi_handle_m_ across the call, which makes the reset wait rather than race.

@thc1006
thc1006 force-pushed the bugfix/complete-unscheduled-operation-4390 branch from 92b8bb5 to efbb0f5 Compare August 11, 2026 17:58
thc1006 added a commit to thc1006/opentelemetry-cpp that referenced this pull request Aug 13, 2026
…_read

Two corrections to the change before it, both found reviewing it rather than
reported.

The wait cannot be interrupted. wakeupBackgroundThread reaches the worker
through the multi handle, and the whole point of that branch is that there is
not one, so the destructor cannot cut the wait short and the previous commit
saying teardown was unchanged is wrong. Destroying a client whose worker was
inside the wait measured 168 ms, five runs out of five. Taking the same wait in
16 ms slices and rechecking is_shutdown_ leaves the retry rate where it was and
brings that to 10 ms, five runs out of five.

And curl_multi_info_read was still called with the handle libcurl had refused to
give. curl_multi_poll and curl_multi_wait cannot see one, since they sit inside
the branch that only runs when perform succeeded. curl_multi_add_handle and
curl_multi_remove_handle can, but they are in doAddSessions and doRemoveSessions,
which open-telemetry#4395 and open-telemetry#4405 rewrite, so they are named in the description rather than
changed here.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006
thc1006 marked this pull request as draft August 13, 2026 17:45
@thc1006
thc1006 force-pushed the bugfix/complete-unscheduled-operation-4390 branch from d545971 to 765f147 Compare August 14, 2026 05:49
thc1006 added a commit to thc1006/opentelemetry-cpp that referenced this pull request Aug 14, 2026
…_read

Two corrections to the change before it, both found reviewing it rather than
reported.

The wait cannot be interrupted. wakeupBackgroundThread reaches the worker
through the multi handle, and the whole point of that branch is that there is
not one, so the destructor cannot cut the wait short and the previous commit
saying teardown was unchanged is wrong. Destroying a client whose worker was
inside the wait measured 168 ms, five runs out of five. Taking the same wait in
16 ms slices and rechecking is_shutdown_ leaves the retry rate where it was and
brings that to 10 ms, five runs out of five.

And curl_multi_info_read was still called with the handle libcurl had refused to
give. curl_multi_poll and curl_multi_wait cannot see one, since they sit inside
the branch that only runs when perform succeeded. curl_multi_add_handle and
curl_multi_remove_handle can, but they are in doAddSessions and doRemoveSessions,
which open-telemetry#4395 and open-telemetry#4405 rewrite, so they are named in the description rather than
changed here.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
thc1006 added a commit to thc1006/opentelemetry-cpp that referenced this pull request Aug 14, 2026
RepeatedCallerThreadCancelsAreClean summed Cancelled and Response over twenty
attempts against a closed port and asked for at least one each. There is a
third way such an attempt ends: the connection fails before the cancel arrives,
and the cancel is then correctly a no-op. Where the caller and the IO thread
share a core that is the usual outcome, and the case fails.

Measured with the run pinned to one core, fifteen runs each: 15 of 15 fail on
main at 3fb1d31, on open-telemetry#4395 at 66ab56c, and on this branch either side of the
change it carries, all with the same 3 to 7 cancels out of 20. Unpinned all
four pass. The failure the valgrind job hit is that, not anything about the
handle accounting.

The connection failure is counted apart from terminal_count_, which two other
cases pin to an exact value, and it is taken before the existing chain rather
than inside it, because ACancelBeforeTheResponseReportsCancelled cancels from
ConnectFailed and needs that branch to keep firing. The case now asks each
attempt to end somewhere the handler is told about rather than to end the same
way twenty times, which is what it was after. Pinned to one core it goes from
15 of 15 failing to 0 of 15.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006
thc1006 force-pushed the bugfix/complete-unscheduled-operation-4390 branch from 66ab56c to 3bb5d7c Compare August 14, 2026 15:58
thc1006 added a commit to thc1006/opentelemetry-cpp that referenced this pull request Aug 14, 2026
…_read

Two corrections to the change before it, both found reviewing it rather than
reported.

The wait cannot be interrupted. wakeupBackgroundThread reaches the worker
through the multi handle, and the whole point of that branch is that there is
not one, so the destructor cannot cut the wait short and the previous commit
saying teardown was unchanged is wrong. Destroying a client whose worker was
inside the wait measured 168 ms, five runs out of five. Taking the same wait in
16 ms slices and rechecking is_shutdown_ leaves the retry rate where it was and
brings that to 10 ms, five runs out of five.

And curl_multi_info_read was still called with the handle libcurl had refused to
give. curl_multi_poll and curl_multi_wait cannot see one, since they sit inside
the branch that only runs when perform succeeded. curl_multi_add_handle and
curl_multi_remove_handle can, but they are in doAddSessions and doRemoveSessions,
which open-telemetry#4395 and open-telemetry#4405 rewrite, so they are named in the description rather than
changed here.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
An operation could be given a promise and then never handed to the IO thread,
which left the caller waiting on a future nobody was in a position to complete.
FinishSession() blocked forever.

Three ways in. SendAsync dispatched Connecting before it reset the operation
flags and published the cancel route, so a handler that cancelled from that
event had its cancel overwritten and the session removed from the client behind
it. CreateSession hands back an unregistered session when the URL does not
parse, and CURLOPT_URL is not checked when it is set, so Setup succeeds and the
same dead end is reached with no handler involved. And doAddSessions dropped
the result of curl_multi_add_handle, so a rejected handle left an operation
nobody would ever run.

The flags and the cancel route are published before the event now, and the
future is published after it, so a handler calling FinishSession() from the
event still returns rather than waiting on a transfer that has not been
scheduled. ScheduleAddSession reports whether the session was still registered,
and SendAsync finishes the operation itself when it was not, or when the event
cancelled it. doAddSessions finishes the ones the multi handle rejects, and
does it outside sessions_m_, since FinishOperation reaches the handler.

The completion callback is published with them rather than after the event.
Cleanup() takes is_cleaned_ at its start and swaps async_data_->callback about
forty lines later, so the is_cleaned_ recheck only says cleanup has begun, not
that it has finished reading the callback. A handler cancelling from Connecting
wakes the IO thread, which can reach the swap first, find an empty callback,
and never run the completion, which is also what clears is_session_active_. The
two accesses to the std::function race as well. Found by @lalitb in review.

The conditional include of global_log_handler.h goes too. The file already
pulled it in under the else branch of ENABLE_OTLP_COMPRESSION_PREVIEW, and this
change needs it unguarded, so with compression preview off both were live and
include-what-you-use reported one too many.

Fixes open-telemetry#4390.
Fixes open-telemetry#4393.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
DispatchEvent stored session_state_ after the handler had returned. A handler
that cancels from the first event of a client that is already polling lets the
background thread finish the operation and dispatch a state of its own, so both
threads wrote that member, and the store left until after the handler returned
overwrote the cancel with a state the operation had left. Found by @lalitb in
review.

The store moves ahead of the handler and the member becomes atomic. SessionState
is a uint8_t enum and std::atomic of it is one byte with alignment one, so the
layout of an installed type does not move.

The new case warms the client with a completed request first, because the
background thread is only spawned after SendAsync returns and without one there
is nothing for the event to overlap. It is the only case in this file that
reaches the is_cleaned_ recheck at the end of SendAsync. Its counters are
relaxed deliberately, since an acquire or a release on them gives the two
threads an ordering the code under test does not have and the racing store stops
being reported.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The comments carried the reasoning that found the bug as well as the rule the
code follows. The rule is what a reader needs; the rest belongs in the pull
request. Each block now states its constraint and stops, and the two member
comments in the installed headers follow the one line trailing form the file
already uses next to them.

No code changes.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Four things from review.

A session whose URL never parsed kept id 0. Pending removals are keyed by
session id, and HttpCurlEasyResource's move assignment is a swap whose
destructor frees nothing, so a second unparsable URL displaced the first
handle into a temporary that nothing freed. Measured with LeakSanitizer:
5556 bytes in 5 allocations, an easy handle and four strings, 3 of 3.
With an id, 0 bytes, 3 of 3.

doAddSessions answered false when every add failed, although finishing the
rejected sessions had just queued their removals. The loop's idle check runs
doRemoveSessions before doAddSessions, so it could exit with that work still
queued. It now reports the follow-up work.

The failure was logged while sessions_m_ was held. The log handler is
application code that can re-enter the client, so the rejection is collected
under the lock and reported outside it, next to the finish that was already
there for the same reason.

A scheduling failure reached Cleanup as Cancelled, which is documented as a
manual cancel, carrying a reason read from a curl result that is still
CURLE_OK. It is now reported as a create failure that says what happened.

The terminal assertions are exact rather than at least one. That matters:
measured, cancelling once the transfer belongs to the IO thread produces two
terminal events, which is open-telemetry#4360, and the old assertion absorbed it. The count
is pinned so a change to it is visible.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
thc1006 added 11 commits August 15, 2026 08:47
The branch added for a session the client never registered reported the
failure and then finished the operation. The handler is replaceable, and
one that calls FinishSession() from that event waits on a promise that
only the Cleanup() below the dispatch can fulfil, on the same stack.

Measured: the case hung for the whole 60 second bound, twice, exit 124.
With the order reversed it returns in under a second, 3 of 3.

The terminal state goes in before Cleanup() so it still reports one
event rather than a manual cancel, and the report now happens after the
operation is done, which is the order the exporters already follow:
retire, then call anything replaceable.

The case stays, because the failure mode is a hang. Nothing else in the
file would notice the order being put back.

32 tests pass, 3 of 3.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The case counted terminal events and stopped there, so an honoured cancel
and a request that was never registered looked the same to it. They are
not the same, and what this path reports today is the second one.

Created is dispatched from the constructor, before curl_operation_ holds
the operation, so a cancel from that event reaches the Session but not
the operation. Scheduling then finds no registration and reports a
failed create. The caller asked to cancel and is told the create failed.

Measured: zero Cancelled, one CreateFailed. An earlier measurement on
this branch saw the opposite, before the unregistered path was reordered
to finish before it reports, so the classification moved with that
change and nothing here was watching.

Pinned as it is rather than as it should be. Making it a cancel means
moving the first events out of the constructor, which is the startup
ordering the issue is about rather than something to add on the side.
Pinning it means the day it changes is visible.

The comment above the counting handler also still described the old
ordering, where the event reached the handler before the state was
stored. It stores first now.

32 tests pass, 3 of 3.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…serting it

Two things this branch said but did not enforce.

The cases here cover waits that are meant to end. When one stops ending
it hangs rather than fails, and nothing bounded them, so a regression
took the whole job with it instead of reporting. That happened three
times while this branch was being written, each time as exit 124 with no
case after it running. Every curl case now carries a CTest timeout of
120 seconds, read back from ctest --show-only=json-v1 as 120.0 on all
35. The slowest case that legitimately waits takes thirty.

The description also claimed that std::atomic<SessionState> is one byte,
aligned to one, and lock free, so the installed layout does not move.
That was a measurement on one toolchain stated as a property of the
language. The standard promises none of it. The size and alignment
halves are now static_asserts, so a toolchain where they do not hold
says so while building rather than silently changing an installed type.

32 tests pass.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The case asserts that two callbacks are inside the handler at once, which
is what this client does today. EventHandler does not say whether one
request's callbacks can overlap, so a handler written against the
interface is not obliged to be re-entrant, and an assertion that reads
like a promise is the wrong thing to leave behind.

The number stays, because reaching that overlap is the point of the case
and a client that began serialising callbacks per operation should be
noticed rather than absorbed. The comment now says which of the two it
is.

32 tests pass.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Startup handed the operation out in pieces. The Session route went out
before the callback, and the promise was created after the first event,
so a cancel arriving in between could reach an operation whose callback
was still empty. Whoever noticed that cleanup had started could then
publish the completion, which meant FinishSession could return while the
IO thread was still inside a user handler.

The route is now the last thing stored. Nothing else can bring the IO
thread to this operation: Abort only raises a flag, and the abort queue
is reached through that same route, so by the time the operation is
reachable the callback, the promise and the future all exist. Cleanup is
then the only thing that ever fulfils the promise, at its tail, after the
terminal event and the completion callback have run, and the recheck that
used to fulfil it from the caller is gone.

A promise that exists before the first event would make a handler calling
FinishSession from that event wait on itself, which is what the late
future used to avoid. A thread local scope answers it directly: every
user callback runs inside one, and Finish returns early when the calling
thread is already inside a callback for this operation. It returns before
the finished flag, so a caller outside the callback still gets to wait,
which the flag used to swallow.

Measured with a handler that holds inside the terminal event for 200 ms.
Before, on the runs where the IO thread delivered that event, Finish
returned in 0 ms with the handler still running. After, it waits 400 ms,
the length of the two held events, and the handler is never still running
when it returns: 6 runs of 6.

The case that came out of that probe is on the branch, and its comment
says what it does not prove. Which thread delivers the terminal event is
not something it can choose, and on the caller-thread schedule there is
nothing for Finish to wait for, so reverting this change leaves it green
10 times in 10. The evidence above is the before and after, not that case.

33 tests pass, 3 of 3.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Of the three ways an operation ends up with nothing to run it, two told the
handler CreateFailed and the third told it Cancelled. The enum documents that
one as "(manually) cancelled", and both exporters print that word: the OTLP HTTP
client logs "Session state: (manually) cancelled." and the Elasticsearch
exporter logs "(manually) cancelled". So a handle libcurl refused to schedule
reached the operator as a request somebody cancelled, and the reason libcurl
gave for refusing it was not reported at all.

Nothing behaves differently for it. Both states set need_stop in the OTLP
handler and both end the wait in the Elasticsearch one, so what changes is the
message, from a cancel that did not happen to a failed create carrying
curl_multi_strerror.

The ordering the registered path already used is now one method rather than
three lines in two places: the state goes in ahead of the cleanup, which would
otherwise report the cancel, and the cleanup goes in ahead of the event, so a
handler calling FinishSession() from it is not waiting on the promise that
cleanup is the only thing able to fulfil.

That branch had no test. It has one now, and it needs no seam around libcurl:
SendAsync does the whole async setup and Session::SendRequest is what starts the
worker, so a case can build the operation, send it, put the client on a multi
handle that refuses every add, and drive doAddSessions itself on one thread.
Reverting the finish to the plain one it had before fails the case on both
assertions.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…es up

The overlapping cancel case holds the calling thread inside the first event
until the background thread has entered one of its own, so the two really do
overlap. It waited on inside_events_, which is raised on the way into an event
and lowered on the way out, and the event the background thread dispatches
there is a few atomics long. Sampling that every millisecond almost never
catches it at two, so the bound was spent in full on every run: 30005, 30509,
30006 and 30015 ms across four runs that all passed, because the count the
assertion reads is written by the thread that creates the overlap and never
goes down.

Waiting on that count instead leaves as soon as the overlap has happened. The
case goes from 30006 ms to 511 ms, ten runs out of ten, and the whole binary
from about 53 seconds to 23.5.

It also makes the case detect what it exists for. Against a faithful revert of
what this branch changed about session_state_, a plain member again and the
store back after the handler, ThreadSanitizer reports the race five runs out of
five with the shorter wait and none at all with the longer one, which spends
thirty seconds and a great many reads of that same address between the two
writes. With the change in place it reports none either way.

A bound that does expire now means the overlap really did not happen, so it is
recorded and checked rather than left to be read as the client having
dispatched one event.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
A probe left cstdio behind when its printf went, and the lambda handed to SendAsync needs functional. Both are what include-what-you-use asks for.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
resetMultiHandle keeps the sessions whose ids are already in
pending_to_add_session_ids_ and takes the rest. A request that has not reached
ScheduleAddSession yet is one of the rest: the caller is between CreateSession,
which registered the session, and SendAsync, which is what queues the id. So
the reset cancels a session whose operation is about to be given a promise, and
on main the id then goes into the queue whatever became of the session, where
doAddSessions finds nothing to add and moves on. The promise is never fulfilled
and FinishSession never returns.

This branch already refuses that id, and this is the case for it. Nothing is
sent before the reset, so there is no IO thread and the caller stands exactly
where the interleaving puts it, in program order rather than in a window. That
also keeps it away from the multi handle: taking one from a running thread is
not something libcurl allows, and a case that did it could not say whether what
it saw was the client or itself.

Against a faithful revert of what this branch changed about ScheduleAddSession,
the id going in whatever became of the session, it hangs: three runs out of
three reach the 90 second bound with no case finished. It passes in 503 ms with
the change, three out of three. All 35 cases in the binary pass, in 24.5
seconds.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The case reads CURLM, and curlver.h is not where that comes from.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The copy inside the retry guard was there for the case that needs gmock. The one added at the top for include-what-you-use covers every build, so the guarded one is a duplicate, and the abiv2-preview job says so while abiv1 does not.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
thc1006 added a commit to thc1006/opentelemetry-cpp that referenced this pull request Aug 15, 2026
…_read

Two corrections to the change before it, both found reviewing it rather than
reported.

The wait cannot be interrupted. wakeupBackgroundThread reaches the worker
through the multi handle, and the whole point of that branch is that there is
not one, so the destructor cannot cut the wait short and the previous commit
saying teardown was unchanged is wrong. Destroying a client whose worker was
inside the wait measured 168 ms, five runs out of five. Taking the same wait in
16 ms slices and rechecking is_shutdown_ leaves the retry rate where it was and
brings that to 10 ms, five runs out of five.

And curl_multi_info_read was still called with the handle libcurl had refused to
give. curl_multi_poll and curl_multi_wait cannot see one, since they sit inside
the branch that only runs when perform succeeded. curl_multi_add_handle and
curl_multi_remove_handle can, but they are in doAddSessions and doRemoveSessions,
which open-telemetry#4395 and open-telemetry#4405 rewrite, so they are named in the description rather than
changed here.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006
thc1006 force-pushed the bugfix/complete-unscheduled-operation-4390 branch from 071ddeb to b2e9e22 Compare August 15, 2026 08:48
thc1006 added a commit to thc1006/opentelemetry-cpp that referenced this pull request Aug 15, 2026
RepeatedCallerThreadCancelsAreClean summed Cancelled and Response over twenty
attempts against a closed port and asked for at least one each. There is a
third way such an attempt ends: the connection fails before the cancel arrives,
and the cancel is then correctly a no-op. Where the caller and the IO thread
share a core that is the usual outcome, and the case fails.

Measured with the run pinned to one core, fifteen runs each: 15 of 15 fail on
main at 3fb1d31, on open-telemetry#4395 at 66ab56c, and on this branch either side of the
change it carries, all with the same 3 to 7 cancels out of 20. Unpinned all
four pass. The failure the valgrind job hit is that, not anything about the
handle accounting.

The connection failure is counted apart from terminal_count_, which two other
cases pin to an exact value, and it is taken before the existing chain rather
than inside it, because ACancelBeforeTheResponseReportsCancelled cancels from
ConnectFailed and needs that branch to keep firing. The case now asks each
attempt to end somewhere the handler is told about rather than to end the same
way twenty times, which is what it was after. Pinned to one core it goes from
15 of 15 failing to 0 of 15.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants