blob: b929e33a011e80c8862f3aaa05a67640b222f97d [file]
// Copyright 2021-2023 Buf Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package triple_protocol
import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
"time"
)
import (
"dubbo.apache.org/dubbo-go/v3/protocol/triple/triple_protocol/internal/assert"
)
const closeTestTimeout = time.Second
// newTestDuplexClientCall builds a duplexHTTPCall for a client-streaming RPC
// backed by a server that simply drains the request body and replies 200. It is
// the minimal setup needed to exercise the Write/CloseWrite/CloseRead lifecycle
// without pulling in the full codec/stream stack.
func newTestDuplexClientCall(t *testing.T, client HTTPClient, serverURL *url.URL) *duplexHTTPCall {
t.Helper()
call := newDuplexHTTPCall(
t.Context(),
client,
serverURL,
Spec{StreamType: StreamTypeClient, Procedure: "/triple.test.v1.RaceService/Sum"},
http.Header{},
)
// makeRequest dereferences validateResponse, so it must be set.
call.SetValidateResponse(func(*http.Response) *Error { return nil })
return call
}
// TestDuplexHTTPCallConcurrentWriteAndCloseWrite is a regression GUARD -- not a
// reproducer -- for the concurrent Send + CloseAndReceive nil-pointer
// dereference fixed upstream in connect-go v1.19.2 (connectrpc/connect-go#919).
//
// Upstream created the request-body io.Pipe lazily, inside the CompareAndSwap
// winner branch of the first Send. A racing CloseWrite (reached via
// CloseAndReceive -> CloseRequest -> CloseWrite) could win that CAS first and
// return without creating the pipe, so a later Send dereferenced a nil
// requestBodyWriter and crashed at io.(*PipeWriter).Write.
//
// Dubbo-go's newDuplexHTTPCall instead allocates the pipe at construction and
// never reassigns requestBodyWriter, so that ordering is structurally
// impossible here. Two consequences worth stating plainly:
//
// - On a correct tree this test ALWAYS passes and it cannot reproduce the
// panic: with requestBodyWriter set once at construction, concurrent Write
// and CloseWrite are safe in either order, so the interleaving below is not
// load-bearing.
// - What actually pins the invariant is assert.NotNil(call.requestBodyWriter)
// plus the source comment on that field. If pipe creation is moved back into
// a lazy/first-send path, the assertion fails and the concurrent Write
// begins dereferencing nil again -- the regression we want to catch.
//
// At this layer Write/CloseWrite/CloseRead are what the Triple client-stream
// APIs call (grpcClientConn.Send -> marshaler -> Write, CloseRequest ->
// CloseWrite, CloseResponse -> CloseRead), so it covers the client-stream
// send + close lifecycle the issue is concerned with.
func TestDuplexHTTPCallConcurrentWriteAndCloseWrite(t *testing.T) {
t.Parallel()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
_ = r.Body.Close()
w.WriteHeader(http.StatusOK)
})
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
serverURL, err := url.Parse(server.URL)
assert.Nil(t, err)
const iterations = 10
for range iterations {
call := newTestDuplexClientCall(t, server.Client(), serverURL)
// The invariant that makes the upstream nil-deref impossible here: the
// write side of the request-body pipe is usable immediately after
// construction, before any Write or CloseWrite call.
assert.NotNil(t, call.requestBodyWriter)
var (
wg sync.WaitGroup
panicVal any
)
wg.Go(func() {
defer func() {
if r := recover(); r != nil {
panicVal = r
}
}()
_, _ = call.Write([]byte{0, 0, 0, 0, 0})
})
// Race CloseWrite against the concurrent Write above. The order is
// intentionally unspecified: both interleavings are safe once
// requestBodyWriter is set at construction.
_ = call.CloseWrite()
wg.Wait()
if panicVal != nil {
t.Fatalf("concurrent Write during CloseWrite panicked: %v", panicVal)
}
_ = call.CloseRead()
}
}
// TestDuplexHTTPCallConcurrentWriteCloseRace exercises concurrent Write,
// CloseWrite, CloseRead and the response accessors to guard the concurrent
// send + close/receive lifecycle (the scenario behind connect-go#919). Like the
// test above this is a guard, not a reproducer: on a correct tree it always
// passes. It is most valuable under `go test -race`, where it would flag a data
// race on requestBodyWriter or response if the construction-time pipe setup were
// ever broken.
func TestDuplexHTTPCallConcurrentWriteCloseRace(t *testing.T) {
t.Parallel()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
_ = r.Body.Close()
w.Header().Set("Test-Header", "value")
w.WriteHeader(http.StatusOK)
})
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
serverURL, err := url.Parse(server.URL)
assert.Nil(t, err)
const (
iterations = 30
writers = 4
)
for range iterations {
call := newTestDuplexClientCall(t, server.Client(), serverURL)
// Same construction-time invariant as the guard test above; assert it
// here too so a lazy-init regression fails with a clear assertion
// instead of panicking on a nil writer inside the goroutines below.
assert.NotNil(t, call.requestBodyWriter)
var wg sync.WaitGroup
for range writers {
wg.Go(func() {
// After the send side is closed Write returns io.EOF rather
// than panicking; any panic here is a real regression and is
// allowed to fail the test.
_, _ = call.Write([]byte{0, 0, 0, 0, 0})
})
}
wg.Go(func() {
_ = call.CloseWrite()
})
wg.Go(func() {
// These block until the response is ready and then read the
// response that makeRequest stored; exercises the response
// happens-before edge established by closing responseReady.
_ = call.ResponseHeader()
_ = call.ResponseTrailer()
})
wg.Wait()
_ = call.CloseRead()
}
}
// TestDuplexHTTPCallCloseReadDoesNotDrainResponseBody verifies that CloseRead
// closes the response body directly instead of reading until EOF. This guards
// the stream-close hang fixed in connect-go#791.
//
// A streaming peer may keep the response open after it stops sending data, so
// draining the body during close can block forever. Callers that need final
// trailers should read to EOF before closing the read side.
func TestDuplexHTTPCallCloseReadDoesNotDrainResponseBody(t *testing.T) {
t.Parallel()
body := newBlockingReadCloser()
call := &duplexHTTPCall{
ctx: context.Background(),
responseReady: make(chan struct{}),
response: &http.Response{
Body: body,
Trailer: make(http.Header),
},
}
close(call.responseReady)
done := make(chan error, 1)
go func() {
done <- call.CloseRead()
}()
select {
case err := <-done:
assert.Nil(t, err)
case <-body.readStarted:
body.unblock()
assert.Nil(t, <-done)
t.Fatal("CloseRead attempted to drain the response body")
case <-time.After(closeTestTimeout):
body.unblock()
assert.Nil(t, <-done)
t.Fatal("CloseRead blocked while closing the response body")
}
select {
case <-body.closed:
default:
t.Fatal("CloseRead did not close the response body")
}
}
// blockingReadCloser records attempted reads and then blocks. It lets the test
// catch drain-before-close behavior without depending on a real stalled stream.
type blockingReadCloser struct {
readStarted chan struct{}
readUnblock chan struct{}
closed chan struct{}
startReadOnce sync.Once
unblockOnce sync.Once
closeOnce sync.Once
}
func newBlockingReadCloser() *blockingReadCloser {
return &blockingReadCloser{
readStarted: make(chan struct{}),
readUnblock: make(chan struct{}),
closed: make(chan struct{}),
}
}
func (b *blockingReadCloser) Read([]byte) (int, error) {
b.startReadOnce.Do(func() {
close(b.readStarted)
})
<-b.readUnblock
return 0, io.EOF
}
func (b *blockingReadCloser) Close() error {
b.closeOnce.Do(func() {
close(b.closed)
})
return nil
}
func (b *blockingReadCloser) unblock() {
b.unblockOnce.Do(func() {
close(b.readUnblock)
})
}