Conversation
563d1d6 to
936a638
Compare
936a638 to
a9bd765
Compare
shrimech
left a comment
There was a problem hiding this comment.
can you confirm the operation can't be redriven for a retry (PerformCurlMessage() storing a newer code) between Cleanup() setting the promise and this store running? If it can the store would overwrite the newer value with a stale one
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4618 +/- ##
==========================================
+ Coverage 86.53% 86.53% +0.01%
==========================================
Files 525 525
Lines 20482 20480 -2
==========================================
- Hits 17722 17721 -1
+ Misses 2760 2759 -1
🚀 New features to boost your workflow:
|
a9bd765 to
fb12be2
Compare
Finish() and ~HttpOperation() stored into last_curl_result_ after waiting on the completion promise, while the IO thread read the same member from IsRetryable() as it drained curl messages. Nothing ordered the two, so ThreadSanitizer reported a write of size 4 racing a read of size 4. The store was writing back a value the IO thread had already published: Cleanup() sets the promise from last_curl_result_, and the future orders that write ahead of the waiter. Dropping it leaves the member with a single writer and removes the race rather than making it benign. Cleanup() is the only place the promise is set, it is reached only from the branch that will not retry, and it detaches the handle by clearing CURLOPT_PRIVATE, so no later attempt can produce a newer code for the waiter to overwrite. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
fb12be2 to
db18c4a
Compare
|
Good question, and it pushed the change in a better direction. The short answer is that it cannot happen today, but the store was the wrong thing to defend, so I removed it instead. Why a newer code cannot exist between
That is three functions across two files holding one unwritten invariant, which is a thin thing to rely on. So rather than argue the store is safe, the branch now drops it: The diff is down to four lines in Edited: I had written that |
shrimech
left a comment
There was a problem hiding this comment.
no issues from my side. Fix is correct and the removal of the redundant write-backs is a solid improvement over the original approach.
|
Marking this ready. @shrimech's question turned out to be the useful one: the first version made @marcalff, on your question in #4614, it is a new race rather than #4408. |
marcalff
left a comment
There was a problem hiding this comment.
LGTM,
Great work, not only with the fix but more importantly on the analysis.
The comment said the IO thread stores the result before the promise is set, without saying where, so the invariant could not be checked from the wait. PerformCurlMessage() does the store at its top and calls Cleanup() at its end, and Cleanup() is what sets the promise. Naming both puts the check one hop away. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> # Conflicts: # CHANGELOG.md
Fixes #4614.
The interleaving
On the IO thread, draining one curl message:
PerformCurlMessage()stores the transfer result intolast_curl_result_, and on an attempt that will not be retried it reachesCleanup(), which callsresult_promise.set_value(last_curl_result_).Finish(), which stored the same value back intolast_curl_result_.PerformCurlMessage()and callsoperation->IsRetryable(), which readslast_curl_result_.Steps 2 and 3 are unordered, which is the pair in the report: a write of size 4 under
cleanupGCSessions()against a read of size 4 in the background thread.CURLcodeis 4 bytes here andresponse_code_, the other memberIsRetryable()reads, is alongat 8, which is how the sizes identify it.~HttpOperation()has the same wait then store, so it is the second write site.The change
Drop both stores.
The waiter has nothing to write.
Cleanup()sets the promise fromlast_curl_result_, and the future orders that write ahead of whoever is waiting, so by the timeget()returns the member already holds the value the waiter was about to assign to it. Dropping the store leaves the member with one writer and removes the race rather than making it benign.get()is still called, so the future is consumed exactly as before and nothing else about the wait changes. The discard is written out withstatic_cast<void>rather than left implicit.Why no later attempt can produce a newer code
@shrimech asked whether the operation can be redriven between
Cleanup()setting the promise and the store running, which would have made the store overwrite a newer code with a stale one. It cannot, for three independent reasons, and the change removes the question anyway.Cleanup(). At the end ofPerformCurlMessage()it is called only fromif (!is_retryable || retry_after_exceeds_max_delay). A retry takes the other branch, which rewinds and dispatchesConnecting, and returns. So the promise is only ever set on the attempt that will not be retried.Cleanup()clears the operation's back pointer. It setsCURLOPT_PRIVATEto null and handscurl_resource_toScheduleRemoveSession(), which queues thecurl_multi_remove_handle()for the IO thread rather than performing it there and then. The message loop resolves the operation throughCURLINFO_PRIVATE, so once that is null it skips the message without callingPerformCurlMessage(), whether or not the handle has left the multi yet.Cleanup()runs once.is_cleaned_.exchange(true)returns early on re-entry, andis_promise_running.exchange(false)independently capsset_valueat one, so neitherSession::FinishOperation()nor the destructor can produce a second one.doRetrySessions()also only re-adds the existing easy handle withcurl_multi_add_handle(); it does not go back throughSendAsync(), so it never resetsis_cleaned_.That chain spans three functions in two files and nothing states it, which is a good argument for not depending on it. Without the store there is nothing to go stale: if a future change ever did let a later attempt run, the member would simply hold the newer code.
Evidence
mainat1525d6a5, gcc 14.2, Bazel:mainFinish()/IsRetryable()pairAll six clean runs used
TSAN_OPTIONS=report_atomic_races=1. All 40 cases pass,//ext/test/http/...passes, and the plain non-sanitised build passes.That configuration is where it reproduces every time. Under the flags the
bazel.tsanjob actually uses I did not hit it in six runs on unpatchedmain, and the patched tree is clean over three, which fits this being reported as an intermittent CI failure rather than a reliable one.What this does not fix
Only the reported access. The IO thread still consults an operation after another thread has been released to finish or destroy it; the
shared_from_this()hold is what keeps that safe today.GetLastResultCode()is also still a plain read of a member the IO thread writes, which is fine while it has no callers, but it is not a thread-safe accessor.An earlier revision of this pull request made the member
std::atomic<CURLcode>instead. That also passes, but it keeps the cross thread write and only makes it defined, so this version is the smaller and more direct one. Happy to go back to it if you would rather have the type level guarantee forGetLastResultCode().On testing
There is no new unit test, because I could not write one that fails deterministically without a sanitiser. The regression test is the existing
OtlpHttpExporterRetryIntegrationTestsunder--config=tsan, which is what caught it. The honest gap is that thebazel.tsanjob does not use the configuration where it fires every time, so a regression there would surface the way this one did, intermittently.