Skip to content

Commit 3bb5d7c

Browse files
committed
[BUG] Publish the operation once, and complete it only at the end
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>
1 parent f930429 commit 3bb5d7c

2 files changed

Lines changed: 128 additions & 16 deletions

File tree

‎ext/src/http/client/curl/http_operation_curl.cc‎

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,39 @@ static_assert(
413413
alignof(opentelemetry::ext::http::client::SessionState),
414414
"std::atomic<SessionState> is more aligned, which changes the layout of HttpOperation");
415415

416+
namespace
417+
{
418+
// Which operations the calling thread is currently inside a callback for. A handler is allowed to
419+
// call FinishSession() on the request it is being told about, and that call must not wait for a
420+
// completion only the thread it is running on can publish.
421+
thread_local std::vector<const HttpOperation *> callbacks_in_progress;
422+
423+
class CallbackScope
424+
{
425+
public:
426+
explicit CallbackScope(const HttpOperation *operation) noexcept
427+
{
428+
callbacks_in_progress.push_back(operation);
429+
}
430+
~CallbackScope() { callbacks_in_progress.pop_back(); }
431+
432+
CallbackScope(const CallbackScope &) = delete;
433+
CallbackScope &operator=(const CallbackScope &) = delete;
434+
435+
static bool InsideCallbackFor(const HttpOperation *operation) noexcept
436+
{
437+
for (const auto *entry : callbacks_in_progress)
438+
{
439+
if (entry == operation)
440+
{
441+
return true;
442+
}
443+
}
444+
return false;
445+
}
446+
};
447+
} // namespace
448+
416449
void HttpOperation::DispatchEvent(opentelemetry::ext::http::client::SessionState type,
417450
const std::string &reason)
418451
{
@@ -422,6 +455,7 @@ void HttpOperation::DispatchEvent(opentelemetry::ext::http::client::SessionState
422455

423456
if (event_handle_ != nullptr)
424457
{
458+
const CallbackScope scope{this};
425459
event_handle_->OnEvent(type, reason);
426460
}
427461
}
@@ -514,6 +548,14 @@ HttpOperation::~HttpOperation()
514548

515549
void HttpOperation::Finish()
516550
{
551+
// Called from inside a callback this operation dispatched, so the completion being waited for is
552+
// the one this thread has not published yet. Returning before the flag below leaves the wait
553+
// available to a caller that is not inside a callback.
554+
if (CallbackScope::InsideCallbackFor(this))
555+
{
556+
return;
557+
}
558+
517559
if (is_finished_.exchange(true, std::memory_order_acq_rel))
518560
{
519561
return;
@@ -572,6 +614,7 @@ void HttpOperation::Cleanup()
572614
callback.swap(async_data_->callback);
573615
if (callback)
574616
{
617+
const CallbackScope scope{this};
575618
HttpOperationAccessor::SetThreadId(*async_data_, std::this_thread::get_id());
576619
callback(*this);
577620
HttpOperationAccessor::SetThreadId(*async_data_, std::thread::id());
@@ -1472,26 +1515,18 @@ CURLcode HttpOperation::SendAsync(Session *session, std::function<void(HttpOpera
14721515
is_finished_.store(false, std::memory_order_release);
14731516
is_aborted_.store(false, std::memory_order_release);
14741517
is_cleaned_.store(false, std::memory_order_release);
1475-
async_data_->session.store(session, std::memory_order_release);
1476-
async_data_->callback = std::move(callback);
1477-
1478-
DispatchEvent(opentelemetry::ext::http::client::SessionState::Connecting);
1479-
1480-
// The future alone stays unpublished until after the event, so a handler calling
1481-
// FinishSession() from it returns instead of waiting on an unscheduled transfer.
1518+
async_data_->callback = std::move(callback);
14821519
async_data_->result_promise = std::promise<CURLcode>();
14831520
async_data_->result_future = async_data_->result_promise.get_future();
14841521
async_data_->is_promise_running.store(true, std::memory_order_release);
14851522

1486-
// A cancel from the event may have torn the operation down before the future existed.
1487-
if (is_cleaned_.load(std::memory_order_acquire))
1488-
{
1489-
if (async_data_->is_promise_running.exchange(false, std::memory_order_acq_rel))
1490-
{
1491-
async_data_->result_promise.set_value(last_curl_result_);
1492-
}
1493-
return CURLE_OK;
1494-
}
1523+
// Last, and the only thing that makes this operation reachable from the IO thread: an abort is
1524+
// queued through this route, so nothing can bring the operation there before the callback and
1525+
// the completion above exist. Cleanup is therefore the only thing that ever fulfils the promise,
1526+
// at its tail, once the terminal event and the completion callback have run.
1527+
async_data_->session.store(session, std::memory_order_release);
1528+
1529+
DispatchEvent(opentelemetry::ext::http::client::SessionState::Connecting);
14951530

14961531
if (WasAborted())
14971532
{

‎ext/test/http/curl_http_test.cc‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -912,6 +912,83 @@ class OverlappingCancelHandler : public TerminalCountingHandler
912912
// the first event of the first request has nothing to overlap. On a client that is already
913913
// polling, the IO thread finishes the operation while the handler is still inside that event,
914914
// and the caller reaches the end of SendAsync with the operation already cleaned up.
915+
namespace
916+
{
917+
// Cancels from Connecting, then holds inside the terminal event for a bounded 200 ms. The bound is
918+
// its own, so this can never block for good and a case built on it can only fail, not hang.
919+
class LatchedCancelHandler : public TerminalCountingHandler
920+
{
921+
public:
922+
void OnEvent(http_client::SessionState state, nostd::string_view reason) noexcept override
923+
{
924+
TerminalCountingHandler::OnEvent(state, reason);
925+
if (state == http_client::SessionState::Cancelled)
926+
{
927+
entries_.fetch_add(1, std::memory_order_relaxed);
928+
if (std::this_thread::get_id() == owner_)
929+
{
930+
on_owner_thread_.fetch_add(1, std::memory_order_relaxed);
931+
}
932+
inside_.fetch_add(1, std::memory_order_release);
933+
std::this_thread::sleep_for(std::chrono::milliseconds(200));
934+
inside_.fetch_sub(1, std::memory_order_release);
935+
}
936+
}
937+
938+
std::thread::id owner_{};
939+
std::atomic<int> entries_{0};
940+
std::atomic<int> on_owner_thread_{0};
941+
std::atomic<int> inside_{0};
942+
};
943+
} // namespace
944+
945+
// Holds that Finish does not return while a handler is still running. Which thread delivers the
946+
// terminal event is not something this case can choose: when the caller thread delivers it there
947+
// is nothing for Finish to wait for and the case only confirms that. Measured across separate
948+
// runs, the IO thread takes it roughly one time in three, and that is the run that matters. So
949+
// this asserts a true invariant on either schedule but does not on its own discriminate the
950+
// ordering change it came from; the evidence for that is in the commit that made it.
951+
TEST_F(BasicCurlHttpTests, FinishDoesNotReturnWhileAHandlerIsRunning)
952+
{
953+
received_requests_.clear();
954+
auto session_manager = std::make_shared<http_client::curl::HttpCurlClientFactory>()->Create();
955+
ASSERT_TRUE(session_manager != nullptr);
956+
957+
{
958+
auto warm = session_manager->CreateSession("http://127.0.0.1:19000");
959+
auto warm_request = warm->CreateRequest();
960+
warm_request->SetUri("get/");
961+
auto warm_handler = std::make_shared<TerminalCountingHandler>();
962+
warm->SendRequest(warm_handler);
963+
ASSERT_TRUE(waitForRequests(30, 1));
964+
warm->FinishSession();
965+
}
966+
967+
auto session = session_manager->CreateSession("http://127.0.0.1:19937");
968+
auto request = session->CreateRequest();
969+
request->SetUri("get/");
970+
971+
auto handler = std::make_shared<LatchedCancelHandler>();
972+
handler->owner_ = std::this_thread::get_id();
973+
handler->cancel_target_ = session.get();
974+
handler->cancel_at_ = http_client::SessionState::Connecting;
975+
976+
session->SendRequest(handler);
977+
978+
const auto start = std::chrono::steady_clock::now();
979+
session->FinishSession();
980+
const auto finished = std::chrono::duration_cast<std::chrono::milliseconds>(
981+
std::chrono::steady_clock::now() - start)
982+
.count();
983+
const int still_inside = handler->inside_.load(std::memory_order_acquire);
984+
session_manager->FinishAllSessions();
985+
986+
EXPECT_EQ(0, still_inside) << "Finish returned after " << finished
987+
<< " ms with a handler still running";
988+
EXPECT_GE(handler->entries_.load(std::memory_order_relaxed), 1)
989+
<< "the terminal event never arrived, so nothing was tested";
990+
}
991+
915992
TEST_F(BasicCurlHttpTests, CancelFromConnectingWhilePollingCompletes)
916993
{
917994
received_requests_.clear();

0 commit comments

Comments
 (0)