XHTTP: fix two data races in the client - #6665
Open
dudkin-2005 wants to merge 2 commits into
Open
Conversation
`uploadWriter.Write` read `buff.Len()` after handing the buffer to the pipe. Past that point the buffer belongs to the pipe's reader, which drains it into the body of the POST request -- `MultiBufferContainer.Read` -> `SplitBytes` -> `Buffer.Read`, and `Buffer.Read` calls `Clear()` once the buffer runs out, zeroing start and end while the writer is still reading them. The result is not only a race but a short count: with `Len()` reading zero, `Write` reports fewer bytes than it accepted, and `buf.WriteAllBytes` advances its payload by that count in a loop, so the same bytes go out a second time. Those bytes are already in the pipe and already on their way, so the proxied stream gets duplicated data. Should the buffer have been recycled and refilled instead, `Len()` can read larger than expected and the caller's `payload[n:]` panics on the slice bounds. Taking the length before the write keeps the deliberate per-buffer splitting that bounds how far a single ReadMultiBuffer may exceed the pipe's size limit. `DefaultDialerClient.closed` was a plain bool written from concurrent goroutines -- one per uplink packet in packet-up, plus the response goroutine in OpenStream -- and read by XMUX in `GetXmuxClient` under a mutex the writers never take, so there is no happens-before between them. A byte store does not tear on amd64, but nothing orders it either: the reader may keep observing a stale false and go on handing new proxied requests to a dead connection until `hMaxRequestTimes` or `hMaxReusableSecs` evicts it. Made it an atomic.Bool, matching `LeftRequests`, `Running` and `NotUsed` next to it. Both races reproduce under `go test -race` and are gone after this change
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
1.
uploadWriter.Writereads a buffer it no longer ownsOnce
WriteMultiBuffersucceeds, the buffer belongs to the pipe's reader. Thatreader is the upload loop in
Dial, which drains it into the body of the POSTrequest —
MultiBufferContainer.Read→SplitBytes→Buffer.Read— andBuffer.ReadcallsClear()once the buffer runs out, zeroingstartandendwhile
Len()is being read.This is not only a race. With
Len()reading zero,Writereports fewer bytesthan it accepted, and
buf.WriteAllBytesadvances its payload by the returnedcount in a loop:
so the same bytes go out a second time. They are already in the pipe and already
on their way to the server, so the proxied stream carries duplicated data. Should
the buffer have been recycled and refilled instead,
Len()can read larger thanexpected and the caller's
payload[n:]panics on the slice bounds.Reading the length before the write keeps the deliberate per-buffer splitting
that bounds how far a single
ReadMultiBuffermay exceed the pipe's size limit,so nothing else about the behaviour changes.
2.
DefaultDialerClient.closedis a plain boolIt is written from concurrent goroutines — one per uplink packet in
packet-up,plus the response goroutine in
OpenStream— and read byGetXmuxClientunderglobalDialerAccess, a mutex the writers never take, so there is nohappens-before between them.
A byte store does not tear on amd64, but nothing orders it either: the reader can
keep observing a stale
falseand go on handing new proxied requests to a deadconnection until
hMaxRequestTimesorhMaxReusableSecsevicts it. Making it anatomic.BoolmatchesLeftRequests,RunningandNotUsedused a few linesaway in the very same
GetXmuxClientcheck.Reproduction
The first one, in
package splithttp:The second one needs only concurrent
PostPacketcalls against a transport thatalways errors, plus one goroutine calling
IsClosed().On the unpatched tree the detector reports the race on every run, while the short
count itself lands in roughly 3 runs out of 5, 1–4 times per 3000 writes.
Verification
go build ./...clean,go vetunchanged, package tests pass.go test -race ./transport/internet/splithttp/drops from ~20 race reports to~12. What remains is
WaitReadCloser.ReadCloserand the certificate cache intransport/internet/tls, both unrelated to this change — the same 7 tests failunder
-racebefore and after it.