Files
cloudflared/stream/stream_test.go
T
Miguel da Costa Martins Marcelino 5c66bd68ab TUN-10621: Propagate max wait timeout
This PR addresses an issue where cloudflared prematurely closes the origin connection before the upstream-to-downstream goroutine finishes reading, causing intermittent connection drops when a client immediately closes the write-side of a connection.

When a client finishes writing data, it immediately closes its side of the connection. Under the current implementation in cloudflared's downstream-to-upstream goroutine does not wait for the second stream to complete. It unblocks the moment the first stream writes to the channel. Once this happens, the pipe returns control to `proxyTCPStream`, which prematurely closes the origin connection. Consequently, when the upstream-to-downstream goroutine attempts to read the remaining data from the origin connection, the connection is already gone, leading to unexpected failures.

We started propagating the `TimeoutAfterFirstClose` configuration/parameter. This allows the proxy to wait for a designated period, giving the second stream sufficient time to finish processing and read all remaining data before `proxyTCPStream` tears down the origin connection.
2026-06-29 10:57:12 +00:00

163 lines
3.7 KiB
Go

package stream
import (
"fmt"
"io"
"sync"
"testing"
"time"
"github.com/rs/zerolog"
"github.com/stretchr/testify/require"
)
func TestPipeBidirectionalFinishBothSides(t *testing.T) {
fun := func(upstream, downstream *mockedStream) {
downstream.closeReader()
upstream.closeReader()
}
testPipeBidirectionalUnblocking(t, fun, time.Millisecond*200, false)
}
func TestPipeBidirectionalFinishOneSideTimeout(t *testing.T) {
fun := func(upstream, downstream *mockedStream) {
downstream.closeReader()
}
testPipeBidirectionalUnblocking(t, fun, time.Millisecond*200, true)
}
func TestPipeBidirectionalClosingWriteBothSidesAlsoExists(t *testing.T) {
fun := func(upstream, downstream *mockedStream) {
_ = downstream.CloseWrite()
_ = upstream.CloseWrite()
downstream.writeToReader("abc")
upstream.writeToReader("abc")
}
testPipeBidirectionalUnblocking(t, fun, time.Millisecond*200, false)
}
func TestPipeBidirectionalClosingWriteSingleSideAlsoExists(t *testing.T) {
fun := func(upstream, downstream *mockedStream) {
_ = downstream.CloseWrite()
downstream.writeToReader("abc")
upstream.writeToReader("abc")
}
testPipeBidirectionalUnblocking(t, fun, time.Millisecond*200, true)
}
// TestPipeBidirectionalReturnsWhenBothSidesFinish verifies that
// PipeBidirectional returns as soon as both stream directions finish, without
// waiting for the full timeout grace period to expire. This guards against a
// regression where the second-stream wait would block for the whole timeout
// even when the result is already available.
func TestPipeBidirectionalReturnsWhenBothSidesFinish(t *testing.T) {
t.Parallel()
const (
timeout = time.Second * 5
maxWallTime = time.Millisecond * 500
)
logger := zerolog.Nop()
downstream := newMockedStream()
upstream := newMockedStream()
resultCh := make(chan error, 1)
go func() {
resultCh <- PipeBidirectional(downstream, upstream, timeout, &logger)
}()
// Close both reader sides so both stream directions reach EOF promptly.
downstream.closeReader()
upstream.closeReader()
start := time.Now()
select {
case err := <-resultCh:
elapsed := time.Since(start)
require.NoError(t, err)
require.Less(t, elapsed, maxWallTime,
"PipeBidirectional should return as soon as both streams finish, not after the full %s timeout (took %s)",
timeout, elapsed,
)
case <-time.After(timeout):
require.Fail(t, "PipeBidirectional did not return before the timeout expired")
}
}
func testPipeBidirectionalUnblocking(t *testing.T, afterFun func(*mockedStream, *mockedStream), timeout time.Duration, expectTimeout bool) {
logger := zerolog.Nop()
downstream := newMockedStream()
upstream := newMockedStream()
resultCh := make(chan error)
go func() {
resultCh <- PipeBidirectional(downstream, upstream, timeout, &logger)
}()
afterFun(upstream, downstream)
select {
case err := <-resultCh:
if expectTimeout {
require.Error(t, err)
} else {
require.NoError(t, err)
}
case <-time.After(timeout * 2):
require.Fail(t, "test timeout")
}
}
func newMockedStream() *mockedStream {
return &mockedStream{
readCh: make(chan *string),
writeCh: make(chan struct{}),
}
}
type mockedStream struct {
readCh chan *string
writeCh chan struct{}
writeCloseOnce sync.Once
}
func (m *mockedStream) Read(p []byte) (n int, err error) {
result := <-m.readCh
if result == nil {
return 0, io.EOF
}
return len(*result), nil
}
func (m *mockedStream) Write(p []byte) (n int, err error) {
<-m.writeCh
return 0, fmt.Errorf("closed")
}
func (m *mockedStream) CloseWrite() error {
m.writeCloseOnce.Do(func() {
close(m.writeCh)
})
return nil
}
func (m *mockedStream) closeReader() {
close(m.readCh)
}
func (m *mockedStream) writeToReader(content string) {
m.readCh <- &content
}