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.
This commit is contained in:
Miguel da Costa Martins Marcelino
2026-06-29 10:57:12 +00:00
parent dba2d33a6b
commit 5c66bd68ab
9 changed files with 93 additions and 39 deletions
+45 -5
View File
@@ -30,8 +30,8 @@ func TestPipeBidirectionalFinishOneSideTimeout(t *testing.T) {
func TestPipeBidirectionalClosingWriteBothSidesAlsoExists(t *testing.T) {
fun := func(upstream, downstream *mockedStream) {
downstream.CloseWrite()
upstream.CloseWrite()
_ = downstream.CloseWrite()
_ = upstream.CloseWrite()
downstream.writeToReader("abc")
upstream.writeToReader("abc")
@@ -42,7 +42,7 @@ func TestPipeBidirectionalClosingWriteBothSidesAlsoExists(t *testing.T) {
func TestPipeBidirectionalClosingWriteSingleSideAlsoExists(t *testing.T) {
fun := func(upstream, downstream *mockedStream) {
downstream.CloseWrite()
_ = downstream.CloseWrite()
downstream.writeToReader("abc")
upstream.writeToReader("abc")
@@ -51,6 +51,46 @@ func TestPipeBidirectionalClosingWriteSingleSideAlsoExists(t *testing.T) {
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()
@@ -67,9 +107,9 @@ func testPipeBidirectionalUnblocking(t *testing.T, afterFun func(*mockedStream,
select {
case err := <-resultCh:
if expectTimeout {
require.NotNil(t, err)
require.Error(t, err)
} else {
require.Nil(t, err)
require.NoError(t, err)
}
case <-time.After(timeout * 2):