Skip to content

XHTTP: fix two data races in the client - #6665

Open
dudkin-2005 wants to merge 2 commits into
XTLS:mainfrom
dudkin-2005:fix-xhttp-data-races
Open

XHTTP: fix two data races in the client#6665
dudkin-2005 wants to merge 2 commits into
XTLS:mainfrom
dudkin-2005:fix-xhttp-data-races

Conversation

@dudkin-2005

Copy link
Copy Markdown
Contributor

1. uploadWriter.Write reads a buffer it no longer owns

for _, buff := range buffer.MultiBuffer {
	err := w.WriteMultiBuffer(buf.MultiBuffer{buff})
	if err != nil {
		return writed, err
	}
	writed += int(buff.Len())
}

Once WriteMultiBuffer succeeds, the buffer belongs to the pipe's reader. That
reader is the upload loop in Dial, which drains it into the body of the POST
request — MultiBufferContainer.ReadSplitBytesBuffer.Read — and
Buffer.Read calls Clear() once the buffer runs out, zeroing start and end
while Len() is being read.

This is not only a race. With Len() reading zero, Write reports fewer bytes
than it accepted, and buf.WriteAllBytes advances its payload by the returned
count in a loop:

for len(payload) > 0 {
	n, err := writer.Write(payload)
	wc += n
	if err != nil {
		return err
	}
	payload = payload[n:]
}

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 than
expected 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 ReadMultiBuffer may exceed the pipe's size limit,
so nothing else about the behaviour changes.

2. DefaultDialerClient.closed is a plain bool

It is written from concurrent goroutines — one per uplink packet in packet-up,
plus the response goroutine in OpenStream — and read by GetXmuxClient under
globalDialerAccess, 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 can
keep observing a stale false and go on handing new proxied requests to a dead
connection until hMaxRequestTimes or hMaxReusableSecs evicts it. Making it an
atomic.Bool matches LeftRequests, Running and NotUsed used a few lines
away in the very same GetXmuxClient check.

Reproduction

The first one, in package splithttp:

func Test_UploadWriterOwnership(t *testing.T) {
	reader, writer := pipe.New(pipe.WithSizeLimit(512 * 1024))
	uw := uploadWriter{writer, 512 * 1024}

	drained := make(chan struct{})
	go func() { // what the upload loop does with the buffers
		defer close(drained)
		for {
			mb, err := reader.ReadMultiBuffer()
			if err != nil {
				return
			}
			c := buf.MultiBufferContainer{MultiBuffer: mb}
			io.Copy(io.Discard, &c)
		}
	}()

	payload := make([]byte, 64*1024)
	short := 0
	for i := 0; i < 3000; i++ {
		n, err := uw.Write(payload)
		if err != nil {
			break
		}
		if n != len(payload) {
			short++
		}
	}
	writer.Close()
	<-drained

	if short > 0 {
		t.Errorf("Write reported a short count %d times", short)
	}
}

The second one needs only concurrent PostPacket calls against a transport that
always 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 vet unchanged, package tests pass.
  • go test -race ./transport/internet/splithttp/ drops from ~20 race reports to
    ~12. What remains is WaitReadCloser.ReadCloser and the certificate cache in
    transport/internet/tls, both unrelated to this change — the same 7 tests fail
    under -race before and after it.

dudkin-2005 and others added 2 commits August 22, 2026 22:48
`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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants