-
Notifications
You must be signed in to change notification settings - Fork 634
[BUG] Rewind the curl request body with a seek callback #4557
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
49c05e8
fbf5562
5a1dad0
4a26843
a55a599
2f21b04
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> | ||
|
|
@@ -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 | ||
|
|
@@ -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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 l
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 :)
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 The part I did not expect is that this may not be the blocker. // Rewind request data so that read callback can re-transfer the payload
request_nwrite_ = 0;That sits inside the 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, 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: Untested from here: a 307 redirect under 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 |
||
| { | ||
| 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(); | ||
|
|
||
There was a problem hiding this comment.
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.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
offset < 0check 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 incurl_off_t.There was a problem hiding this comment.
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
mainatf77c1a5c,ext/src/http/client/curl/http_operation_curl.cc:350: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_tis 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:
@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.
There was a problem hiding this comment.
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.