Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ Increment the:

## [Unreleased]

* [BUG] Install a curl seek callback so an OTLP/HTTP export body can be rewound
when libcurl restarts an upload, instead of failing with
`CURLE_SEND_FAIL_REWIND` and dropping the batch
([#4549](https://github.com/open-telemetry/opentelemetry-cpp/issues/4549))

* [DOC] Fix and clarify the `StartSpanOptions` documentation
[#4526](https://github.com/open-telemetry/opentelemetry-cpp/pull/4526)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,20 @@ class HttpOperation

static size_t ReadMemoryCallback(char *buffer, size_t size, size_t nitems, void *userp);

/**
* Reposition the request body for libcurl.
*
* libcurl calls this when it has to restart an upload it already began, for example after a
* connection it had reused was closed before the response arrived. Without it libcurl has no way
* to rewind the body and fails the transfer with CURLE_SEND_FAIL_REWIND.
*
* @param userp The HttpOperation, set through CURLOPT_SEEKDATA
* @param offset Byte offset to seek to, interpreted relative to origin
* @param origin One of SEEK_SET, SEEK_CUR or SEEK_END
* @return CURL_SEEKFUNC_OK on success, CURL_SEEKFUNC_CANTSEEK to tell libcurl to find another way
*/
static int SeekCallback(void *userp, curl_off_t offset, int origin);

static int CurlLoggerCallback(const CURL * /* handle */,
curl_infotype type,
const char *data,
Expand Down Expand Up @@ -371,6 +385,7 @@ class HttpOperation
std::future<CURLcode> result_future;
};
friend class HttpOperationAccessor;
friend class HttpOperationTestPeer;
std::unique_ptr<AsyncData> async_data_;
};
} // namespace curl
Expand Down
35 changes: 35 additions & 0 deletions ext/src/http/client/curl/http_operation_curl.cc
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <functional>
#include <future>
Expand Down Expand Up @@ -334,6 +335,27 @@ size_t HttpOperation::ReadMemoryCallback(char *buffer, size_t size, size_t nitem
return nwrite;
}

int HttpOperation::SeekCallback(void *userp, curl_off_t offset, int origin)
{
HttpOperation *self = reinterpret_cast<HttpOperation *>(userp);
if (nullptr == self)
{
return CURL_SEEKFUNC_CANTSEEK;
}

// The body is a fully buffered span owned by the caller, so an absolute seek inside it is just a
// move of the read cursor. Anything else is refused rather than approximated, because reporting
// success without repositioning would resume the upload from the wrong offset and send a
// truncated or misaligned body.
if (origin != SEEK_SET || offset < 0 || static_cast<size_t>(offset) > self->request_body_.size())

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.

nit- Could we check the offset before casting to size_t? On 32-bit systems, a large offset can wrap to zero and incorrectly pass this check.

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.

The offset < 0 check runs before the cast, so negatives are covered, but an offset of 2^32 or more still wraps on 32-bit. Follow-up coming that compares in curl_off_t.

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.

Still the same shape on main at f77c1a5c, ext/src/http/client/curl/http_operation_curl.cc:350:

if (origin != SEEK_SET || offset < 0 || static_cast<size_t>(offset) > self->request_body_.size())

I went looking for a case that reaches it and could not build one. The offset libcurl hands a seek callback comes from its own position in the upload, and the upload is the body this code gave it. On a build where size_t is 32 bits that body cannot be 4 GB to begin with, so the wrap needs an offset larger than any body the process can hold. If someone sees a path that produces one I would like to know, because I could not.

So it reads to me as the check having the wrong shape rather than a case waiting to happen, which is how the original note was phrased anyway. Comparing before narrowing costs nothing and removes the question:

if (origin != SEEK_SET || offset < 0 ||
    offset > static_cast<curl_off_t>(self->request_body_.size()))

@mateenali66 is the follow-up still on your list? No rush from me, and I am happy to send it instead if you would rather not carry it.

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.

Still mine, it's up at #4630. Same comparison you wrote.

Agreed on reachability, I couldn't build a case either. The offset comes from libcurl's own position in the upload, and a 32 bit process can't hold a body big enough to get past the size_t range.

The test case I added doesn't catch anything on a 64 bit build, both forms refuse it, so it's only pinning the contract.

{
return CURL_SEEKFUNC_CANTSEEK;
}

self->request_nwrite_ = static_cast<size_t>(offset);
return CURL_SEEKFUNC_OK;
}

#if LIBCURL_VERSION_NUM >= 0x075000
int HttpOperation::PreRequestCallback(void *clientp, char *, char *, int, int)
{
Expand Down Expand Up @@ -1336,6 +1358,19 @@ CURLcode HttpOperation::Setup()
{
return rc;
}

rc = SetCurlPtrOption(CURLOPT_SEEKFUNCTION,
reinterpret_cast<void *>(&HttpOperation::SeekCallback));
if (rc != CURLE_OK)
{
return rc;
}

rc = SetCurlPtrOption(CURLOPT_SEEKDATA, this);
if (rc != CURLE_OK)
{
return rc;
}
}
else if (method_ == opentelemetry::ext::http::client::Method::Get)
{
Expand Down
108 changes: 107 additions & 1 deletion ext/test/http/curl_http_test.cc
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

#include <curl/curl.h>
#include <curl/curlver.h>
#include "gtest/gtest.h"

#ifdef ENABLE_OTLP_RETRY_PREVIEW
# include <curl/curl.h>
# include "gmock/gmock.h"
#endif // ENABLE_OTLP_RETRY_PREVIEW

#ifdef ENABLE_OTLP_COMPRESSION_PREVIEW
# include <numeric>
#endif // ENABLE_OTLP_COMPRESSION_PREVIEW

#include <algorithm>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdio>
#include <cstring>
#include <map>
#include <memory>
Expand Down Expand Up @@ -57,6 +59,27 @@ class HttpClientTestPeer
public:
static void ResetMultiHandle(HttpClient &client) { client.resetMultiHandle(); }
};

class HttpOperationTestPeer
{
public:
static int Seek(HttpOperation &operation, curl_off_t offset, int origin)
{
return HttpOperation::SeekCallback(&operation, offset, origin);
}

static int SeekNullUserData(curl_off_t offset, int origin)
{
return HttpOperation::SeekCallback(nullptr, offset, origin);
}

static size_t ReadCursor(const HttpOperation &operation) { return operation.request_nwrite_; }

static void SetReadCursor(HttpOperation &operation, size_t value)
{
operation.request_nwrite_ = value;
}
};
} // namespace curl
} // namespace client
} // namespace http
Expand Down Expand Up @@ -405,6 +428,89 @@ TEST_F(BasicCurlHttpTests, SendPostRequest)
session_manager->FinishAllSessions();
}

// Send a body large enough to span several read callbacks and check it arrives whole, so a mistake
// in either CURLOPT_READFUNCTION or the seek callback registered beside it shows up as a corrupted
// or short upload.
TEST_F(BasicCurlHttpTests, SendPostRequestWithMultiChunkBody)

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.

Could we add the reproducer as a functional test in a follow-up? These tests still pass if the seek callback registration is removed, since neither exercises a rewind through libcurl.

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.

True, neither test reaches a rewind. @meastp offered to land his reproducer as a functional test. Mats, still want to? Otherwise I'll take it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@mateenali66 you go ahead, I became a bit busy after the bug report/PR :)

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.

Taking it, no problem.

Nothing in the suite makes libcurl call the seek callback during a real transfer, so the rewind path is still uncovered. Yours got there through connection reuse, which a unit test on the callback can't reach.

The question is whether the test server can close a reused connection mid body. If it can, this lands as a functional test. If not, it isn't worth faking. Separate PR either way, the offset one is #4630.

@thc1006 thc1006 Sep 23, 2026 •

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.

Measured the server side, and it splits the question in two.

A handler cannot close mid body. It runs once the connection reaches Processing, which only happens after ReceivingBody has finished, so the body is already in by the time the handler is called. What a handler does have is return -1, which processRequest turns into RequestOutcome::CloseConnection and applies after the request. There is no per-connection close from outside a handler either.

The part I did not expect is that this may not be the blocker. HttpOperation::PerformCurlMessage rewinds the body itself when it retries:

// Rewind request data so that read callback can re-transfer the payload
request_nwrite_ = 0;

That sits inside the is_retryable branch. request_nwrite_ is repositioned in exactly two places, that one and SeekCallback, on separate paths; the read callback only advances it with +=. So the transport's own retry does not go through the seek callback. I measured that rather than leaving it as a reading: a counter at the rewind and a counter in SeekCallback, then RetryPolicyEnabled, which POSTs to /retry/ and gets a 429.

PROBE-RETRY rewinds observed: 1
PROBE-SEEK calls observed:    0

The retry demonstrably happened and the callback took no part in it, which matches what @lalitb found by removing the registration: a test that makes the transport retry does not reach it either.

What would reach it is a rewind libcurl decides on by itself. I tried three shapes against a socket server, with the option set the client uses, POSTFIELDS null plus READFUNCTION and SEEKFUNCTION: a pooled keep-alive connection dropped between requests, a connection dropped part way through the body, and a 401 Digest challenge. None called the callback.

I would not lean on all three equally. Checking my own probes afterwards, the first reset the read cursor by hand and so did libcurl's job for it, and the 401 one handed over exactly one body's worth of bytes, meaning it never replayed. The control is sound: CURLOPT_RESUME_FROM_LARGE on an upload calls the callback once and the cursor lands at the offset, so the wiring is right and the zeros are not a broken probe.

Untested from here: a 307 redirect under FOLLOWLOCATION, Expect: 100-continue negotiation, and NTLM or Negotiate auth.

So before building the functional test it may be worth settling whether the callback is reachable at all in this configuration. If only libcurl-internal rewinds use it, the test has to provoke one of those, and nothing the embedded server does will get there.

Edited twice. I first wrote that the member has two assignments, which is wrong if you count the += in the read callback: two repositions, three mutations. And the claim about the retry not reaching the callback started as a reading of the code, so I went and measured it.

{
received_requests_.clear();
auto session_manager = std::make_shared<http_client::curl::HttpCurlClientFactory>()->Create();
EXPECT_TRUE(session_manager != nullptr);

auto session = session_manager->CreateSession("http://127.0.0.1:19000");
auto request = session->CreateRequest();
request->SetUri("post/");
request->SetMethod(http_client::Method::Post);

// Not a round number, so an off-by-one in the read cursor cannot land on a chunk boundary.
constexpr size_t kBodySize = 257u * 1024u + 7u;
http_client::Body body(kBodySize);
for (size_t i = 0; i < kBodySize; ++i)
{
body[i] = static_cast<http_client::Byte>('a' + (i % 26));
}
const http_client::Body expected = body;

request->SetBody(body);
request->AddHeader("Content-Type", "application/octet-stream");
auto handler = std::make_shared<PostEventHandler>();
session->SendRequest(handler);
ASSERT_TRUE(waitForRequests(30, 1));
session->FinishSession();
ASSERT_TRUE(handler->is_called_.load(std::memory_order_acquire));
ASSERT_TRUE(handler->got_response_.load(std::memory_order_acquire));

{
std::unique_lock<std::mutex> lk(mtx_requests);
ASSERT_EQ(received_requests_.size(), 1u);
const auto &received = received_requests_[0].content;
ASSERT_EQ(received.size(), expected.size());
EXPECT_TRUE(std::equal(expected.begin(), expected.end(), received.begin()));
}

session_manager->CancelAllSessions();
session_manager->FinishAllSessions();
}

// Cover both halves of the callback contract, the seeks it honours and the ones it refuses.
TEST_F(BasicCurlHttpTests, SeekCallbackRepositionsTheRequestBody)
{
CustomEventHandler handler;
http_client::HttpSslOptions no_ssl;
http_client::Headers headers;
const char *payload = "0123456789";
http_client::Body body = {payload, payload + std::strlen(payload)};

curl::HttpOperation operation(http_client::Method::Post, "http://127.0.0.1:19000/post/", no_ssl,
&handler, headers, body, http_client::Compression::kNone, false,
curl::kDefaultHttpConnTimeout);

using Peer = curl::HttpOperationTestPeer;

Peer::SetReadCursor(operation, 10);
EXPECT_EQ(CURL_SEEKFUNC_OK, Peer::Seek(operation, 4, SEEK_SET));
EXPECT_EQ(4u, Peer::ReadCursor(operation));

// Rewinding to the start is the case libcurl actually asks for.
EXPECT_EQ(CURL_SEEKFUNC_OK, Peer::Seek(operation, 0, SEEK_SET));
EXPECT_EQ(0u, Peer::ReadCursor(operation));

// Seeking to exactly the end is in range and leaves nothing left to send.
EXPECT_EQ(CURL_SEEKFUNC_OK, Peer::Seek(operation, 10, SEEK_SET));
EXPECT_EQ(10u, Peer::ReadCursor(operation));

// Everything below is refused, and must leave the cursor where it was.
Peer::SetReadCursor(operation, 3);

EXPECT_EQ(CURL_SEEKFUNC_CANTSEEK, Peer::Seek(operation, 11, SEEK_SET));
EXPECT_EQ(CURL_SEEKFUNC_CANTSEEK, Peer::Seek(operation, -1, SEEK_SET));
EXPECT_EQ(CURL_SEEKFUNC_CANTSEEK, Peer::Seek(operation, 0, SEEK_CUR));
EXPECT_EQ(CURL_SEEKFUNC_CANTSEEK, Peer::Seek(operation, 0, SEEK_END));
EXPECT_EQ(3u, Peer::ReadCursor(operation));

EXPECT_EQ(CURL_SEEKFUNC_CANTSEEK, Peer::SeekNullUserData(0, SEEK_SET));
}

TEST_F(BasicCurlHttpTests, RequestTimeout)
{
received_requests_.clear();
Expand Down
Loading