Skip to content

Commit 5c66bd6

Browse files
committed
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.
1 parent dba2d33 commit 5c66bd6

9 files changed

Lines changed: 93 additions & 39 deletions

File tree

carrier/websocket.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ func (ws *Websocket) ServeStream(options *StartOptions, conn io.ReadWriter) erro
3737
}
3838
defer func() { _ = wsConn.Close() }()
3939

40-
stream.Pipe(wsConn, conn, ws.log)
40+
stream.Pipe(wsConn, conn, stream.DefaultTimeoutAfterFirstClose, ws.log)
4141
return nil
4242
}
4343

connection/connection_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ func wsEchoEndpoint(w ResponseWriter, r *http.Request) error {
151151
}()
152152

153153
originConn := &echoPipe{reader: readPipe, writer: writePipe}
154-
stream.Pipe(wsConn, originConn, &log)
154+
stream.Pipe(wsConn, originConn, 0, &log)
155155
cancel()
156156
wsConn.Close()
157157
return nil
@@ -190,7 +190,7 @@ func wsFlakyEndpoint(w ResponseWriter, r *http.Request) error {
190190
rInt, _ := rand.Int(rand.Reader, big.NewInt(50))
191191
closedAfter := time.Millisecond * time.Duration(rInt.Int64())
192192
originConn := &flakyConn{closeAt: time.Now().Add(closedAfter)}
193-
stream.Pipe(wsConn, originConn, &log)
193+
stream.Pipe(wsConn, originConn, 0, &log)
194194
cancel()
195195
wsConn.Close()
196196
return nil

ingress/origin_connection.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@ type OriginConnection interface {
2525
type streamHandlerFunc func(originConn io.ReadWriter, remoteConn net.Conn, log *zerolog.Logger)
2626

2727
// DefaultStreamHandler is an implementation of streamHandlerFunc that
28-
// performs a two way io.Copy between originConn and remoteConn.
28+
// performs a two-way io.Copy between originConn and remoteConn.
2929
func DefaultStreamHandler(originConn io.ReadWriter, remoteConn net.Conn, log *zerolog.Logger) {
30-
stream.Pipe(originConn, remoteConn, log)
30+
stream.Pipe(originConn, remoteConn, stream.DefaultTimeoutAfterFirstClose, log)
3131
}
3232

3333
// tcpConnection is an OriginConnection that directly streams to raw TCP.
@@ -38,7 +38,7 @@ type tcpConnection struct {
3838
}
3939

4040
func (tc *tcpConnection) Stream(_ context.Context, tunnelConn io.ReadWriter, _ *zerolog.Logger) {
41-
stream.Pipe(tunnelConn, tc, tc.logger)
41+
stream.Pipe(tunnelConn, tc, stream.DefaultTimeoutAfterFirstClose, tc.logger)
4242
}
4343

4444
func (tc *tcpConnection) Write(b []byte) (int, error) {

ingress/origin_connection_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ func TestStreamTCPConnection(t *testing.T) {
3838
tcpConn := tcpConnection{
3939
Conn: cfdConn,
4040
writeTimeout: 30 * time.Second,
41+
logger: TestLogger,
4142
}
4243

4344
eyeballConn, edgeConn := net.Pipe()
@@ -157,7 +158,7 @@ func TestSocksStreamWSOverTCPConnection(t *testing.T) {
157158
require.NoError(t, err)
158159
defer func() { _ = wsForwarderInConn.Close() }()
159160

160-
stream.Pipe(wsForwarderInConn, &wsEyeball{wsForwarderOutConn}, TestLogger)
161+
stream.Pipe(wsForwarderInConn, &wsEyeball{wsForwarderOutConn}, stream.DefaultTimeoutAfterFirstClose, TestLogger)
161162
return nil
162163
})
163164

ingress/origin_service.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,6 @@ type rawTCPService struct {
9999
name string
100100
dialer net.Dialer
101101
writeTimeout time.Duration
102-
logger *zerolog.Logger
103102
}
104103

105104
func (o *rawTCPService) String() string {
@@ -233,10 +232,12 @@ func (o *helloWorld) start(
233232
if err != nil {
234233
return errors.Wrap(err, "Cannot start Hello World Server")
235234
}
236-
go hello.StartHelloWorldServer(log, helloListener, shutdownC)
235+
go func() {
236+
_ = hello.StartHelloWorldServer(log, helloListener, shutdownC)
237+
}()
237238
o.server = helloListener
238239

239-
o.httpService.url = &url.URL{
240+
o.url = &url.URL{
240241
Scheme: "https",
241242
Host: o.server.Addr().String(),
242243
}
@@ -356,7 +357,7 @@ func newHTTPTransport(service OriginService, cfg OriginRequestConfig, log *zerol
356357
IdleConnTimeout: cfg.KeepAliveTimeout.Duration,
357358
TLSHandshakeTimeout: cfg.TLSTimeout.Duration,
358359
ExpectContinueTimeout: 1 * time.Second,
359-
TLSClientConfig: &tls.Config{RootCAs: originCertPool, InsecureSkipVerify: cfg.NoTLSVerify},
360+
TLSClientConfig: &tls.Config{RootCAs: originCertPool, InsecureSkipVerify: cfg.NoTLSVerify}, //nolint: gosec
360361
ForceAttemptHTTP2: cfg.Http2Origin,
361362
}
362363
if _, isHelloWorld := service.(*helloWorld); !isHelloWorld && cfg.OriginServerName != "" {
@@ -374,7 +375,6 @@ func newHTTPTransport(service OriginService, cfg OriginRequestConfig, log *zerol
374375
// DialContext depends on which kind of origin is being used.
375376
dialContext := dialer.DialContext
376377
switch service := service.(type) {
377-
378378
// If this origin is a unix socket, enforce network type "unix".
379379
case *unixSocketPath:
380380
httpTransport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {

proxy/proxy.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ func (p *Proxy) proxyHTTPRequest(
256256
reader: tr.Body,
257257
}
258258

259-
stream.Pipe(eyeballStream, rwc, logger)
259+
stream.Pipe(eyeballStream, rwc, time.Second*0, logger)
260260
return nil
261261
}
262262

@@ -311,7 +311,7 @@ func (p *Proxy) proxyStream(
311311

312312
// proxyTCPStream proxies private network type TCP connections as a stream towards an available origin.
313313
//
314-
// This is different than proxyStream because it's not leveraged ingress rule services and uses the
314+
// This is different from proxyStream because it's not leveraged ingress rule services and uses the
315315
// originDialer from OriginDialerService.
316316
func (p *Proxy) proxyTCPStream(
317317
tr *tracing.TracedContext,
@@ -344,7 +344,8 @@ func (p *Proxy) proxyTCPStream(
344344
connectLatency.Observe(float64(time.Since(start).Milliseconds()))
345345
logger.Debug().Msg("proxy stream acknowledged")
346346

347-
stream.Pipe(tunnelConn, originConn, logger)
347+
stream.Pipe(tunnelConn, originConn, stream.DefaultTimeoutAfterFirstClose, logger)
348+
348349
return nil
349350
}
350351

proxy/proxy_test.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,9 @@ func testProxyWebsocket(proxy connection.OriginProxy) func(t *testing.T) {
223223
}
224224
if ctx.Err() == context.DeadlineExceeded {
225225
t.Errorf("Test timed out")
226-
readPipe.Close()
227-
writePipe.Close()
228-
responseWriter.Close()
226+
_ = readPipe.Close()
227+
_ = writePipe.Close()
228+
_ = responseWriter.Close()
229229
}
230230
return nil
231231
})
@@ -647,7 +647,7 @@ func TestConnections(t *testing.T) {
647647
ingressServiceScheme: "tcp://",
648648
originService: func(t *testing.T, ln net.Listener) {
649649
// closing the listener created by the test.
650-
ln.Close()
650+
_ = ln.Close()
651651
},
652652
eyeballResponseWriter: newTCPRespWriter(replayer),
653653
eyeballRequestBody: newTCPRequestBody([]byte("test2")),
@@ -756,6 +756,8 @@ func newTCPRequestBody(data []byte) *requestBody {
756756
pr, pw := io.Pipe()
757757
go func() {
758758
_, _ = pw.Write(data)
759+
// Close the write side once the payload has been sent.
760+
_ = pw.Close()
759761
}()
760762
return &requestBody{
761763
pr: pr,
@@ -801,8 +803,8 @@ func (p *pipedRequestBody) roundtrip(addr string) []byte {
801803
if err != nil {
802804
panic(err)
803805
}
804-
defer conn.Close()
805-
defer resp.Body.Close()
806+
defer func() { _ = conn.Close() }()
807+
defer func() { _ = resp.Body.Close() }()
806808

807809
if resp.StatusCode != http.StatusSwitchingProtocols {
808810
panic(fmt.Errorf("resp returned status code: %d", resp.StatusCode))
@@ -949,7 +951,7 @@ func runEchoTCPService(t *testing.T, l net.Listener) {
949951
if err != nil {
950952
panic(err)
951953
}
952-
defer conn.Close()
954+
defer func() { _ = conn.Close() }()
953955

954956
for {
955957
buf := make([]byte, 1024)
@@ -987,7 +989,7 @@ func runEchoWSService(t *testing.T, l net.Listener) {
987989
t.Log(err)
988990
return
989991
}
990-
defer conn.Close()
992+
defer func() { _ = conn.Close() }()
991993

992994
for {
993995
messageType, p, err := conn.ReadMessage()

stream/stream.go

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,25 @@ package stream
22

33
import (
44
"encoding/hex"
5+
"errors"
56
"fmt"
67
"io"
78
"runtime/debug"
89
"sync/atomic"
910
"time"
1011

1112
"github.com/getsentry/sentry-go"
12-
"github.com/pkg/errors"
1313
"github.com/rs/zerolog"
1414

1515
"github.com/cloudflare/cloudflared/cfio"
1616
)
1717

18+
// DefaultTimeoutAfterFirstClose controls the upper bound of how long we wait for the second stream to finish.
19+
// We use bidirectional streams for our communication. Since the read and write sides can be closed independently,
20+
// we must have a way to close the second stream once the first one finishes. We don't want to wait indefinitely,
21+
// since we want to prevent misbehaving clients from blocking cloudflared.
22+
const DefaultTimeoutAfterFirstClose = time.Second * 10
23+
1824
type Stream interface {
1925
Reader
2026
WriterCloser
@@ -37,7 +43,7 @@ type nopCloseWriterAdapter struct {
3743
io.ReadWriter
3844
}
3945

40-
func NopCloseWriterAdapter(stream io.ReadWriter) *nopCloseWriterAdapter {
46+
func noopCloseWriter(stream io.ReadWriter) *nopCloseWriterAdapter {
4147
return &nopCloseWriterAdapter{stream}
4248
}
4349

@@ -72,7 +78,7 @@ func (s *bidirectionalStreamStatus) wait(maxWaitForSecondStream time.Duration) e
7278

7379
select {
7480
case <-timer.C:
75-
return fmt.Errorf("timeout waiting for second stream to finish")
81+
return fmt.Errorf("timeout waiting for second stream to finish %s", maxWaitForSecondStream)
7682
case <-s.doneChan:
7783
return nil
7884
}
@@ -85,23 +91,27 @@ func (s *bidirectionalStreamStatus) isAnyDone() bool {
8591
}
8692

8793
// Pipe copies copy data to & from provided io.ReadWriters.
88-
func Pipe(tunnelConn, originConn io.ReadWriter, log *zerolog.Logger) {
89-
_ = PipeBidirectional(NopCloseWriterAdapter(tunnelConn), NopCloseWriterAdapter(originConn), 0, log)
94+
func Pipe(tunnelConn, originConn io.ReadWriter, timeoutAfterFirstClose time.Duration, log *zerolog.Logger) {
95+
if err := PipeBidirectional(noopCloseWriter(tunnelConn), noopCloseWriter(originConn), timeoutAfterFirstClose, log); err != nil {
96+
log.Warn().Err(err).Msg("Failed to pipe bidirectional stream")
97+
}
9098
}
9199

92-
// PipeBidirectional copies data to two unidirectional streams. It is a special case of Pipe where it receives a concept that allows for Read and Write side to be closed independently.
93-
// The main difference is that when piping data from a reader to a writer, if EOF is read, then this implementation propagates the EOF signal to the destination/writer by closing the write side of the
94-
// Bidirectional Stream.
95-
// Finally, depending on once EOF is ready from one of the provided streams, the other direction of streaming data will have a configured time period to also finish, otherwise,
96-
// the method will return immediately with a timeout error. It is however, the responsibility of the caller to close the associated streams in both ends in order to free all the resources/go-routines.
100+
// PipeBidirectional copies data between two unidirectional streams. It is a special case of Pipe that accepts streams
101+
// whose read and write sides can be closed independently. The main difference is that when piping data from a reader
102+
// to a writer, if EOF is read, this implementation propagates the EOF signal to the destination by closing the write
103+
// side of the bidirectional stream.
104+
// Finally, once EOF is received from one of the provided streams, the other direction has a configured grace period to
105+
// finish; otherwise, the method returns a timeout error. It is, however, the responsibility of the caller to close
106+
// the associated streams at both ends in order to free all resources and goroutines.
97107
func PipeBidirectional(downstream, upstream Stream, maxWaitForSecondStream time.Duration, log *zerolog.Logger) error {
98108
status := newBiStreamStatus()
99109

100110
go unidirectionalStream(downstream, upstream, "upstream->downstream", status, log)
101111
go unidirectionalStream(upstream, downstream, "downstream->upstream", status, log)
102112

103113
if err := status.wait(maxWaitForSecondStream); err != nil {
104-
return errors.Wrap(err, "unable to wait for both streams while proxying")
114+
return fmt.Errorf("unable to wait for both streams while proxying: %w", err)
105115
}
106116

107117
return nil

stream/stream_test.go

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ func TestPipeBidirectionalFinishOneSideTimeout(t *testing.T) {
3030

3131
func TestPipeBidirectionalClosingWriteBothSidesAlsoExists(t *testing.T) {
3232
fun := func(upstream, downstream *mockedStream) {
33-
downstream.CloseWrite()
34-
upstream.CloseWrite()
33+
_ = downstream.CloseWrite()
34+
_ = upstream.CloseWrite()
3535

3636
downstream.writeToReader("abc")
3737
upstream.writeToReader("abc")
@@ -42,7 +42,7 @@ func TestPipeBidirectionalClosingWriteBothSidesAlsoExists(t *testing.T) {
4242

4343
func TestPipeBidirectionalClosingWriteSingleSideAlsoExists(t *testing.T) {
4444
fun := func(upstream, downstream *mockedStream) {
45-
downstream.CloseWrite()
45+
_ = downstream.CloseWrite()
4646

4747
downstream.writeToReader("abc")
4848
upstream.writeToReader("abc")
@@ -51,6 +51,46 @@ func TestPipeBidirectionalClosingWriteSingleSideAlsoExists(t *testing.T) {
5151
testPipeBidirectionalUnblocking(t, fun, time.Millisecond*200, true)
5252
}
5353

54+
// TestPipeBidirectionalReturnsWhenBothSidesFinish verifies that
55+
// PipeBidirectional returns as soon as both stream directions finish, without
56+
// waiting for the full timeout grace period to expire. This guards against a
57+
// regression where the second-stream wait would block for the whole timeout
58+
// even when the result is already available.
59+
func TestPipeBidirectionalReturnsWhenBothSidesFinish(t *testing.T) {
60+
t.Parallel()
61+
62+
const (
63+
timeout = time.Second * 5
64+
maxWallTime = time.Millisecond * 500
65+
)
66+
67+
logger := zerolog.Nop()
68+
downstream := newMockedStream()
69+
upstream := newMockedStream()
70+
71+
resultCh := make(chan error, 1)
72+
go func() {
73+
resultCh <- PipeBidirectional(downstream, upstream, timeout, &logger)
74+
}()
75+
76+
// Close both reader sides so both stream directions reach EOF promptly.
77+
downstream.closeReader()
78+
upstream.closeReader()
79+
80+
start := time.Now()
81+
select {
82+
case err := <-resultCh:
83+
elapsed := time.Since(start)
84+
require.NoError(t, err)
85+
require.Less(t, elapsed, maxWallTime,
86+
"PipeBidirectional should return as soon as both streams finish, not after the full %s timeout (took %s)",
87+
timeout, elapsed,
88+
)
89+
case <-time.After(timeout):
90+
require.Fail(t, "PipeBidirectional did not return before the timeout expired")
91+
}
92+
}
93+
5494
func testPipeBidirectionalUnblocking(t *testing.T, afterFun func(*mockedStream, *mockedStream), timeout time.Duration, expectTimeout bool) {
5595
logger := zerolog.Nop()
5696

@@ -67,9 +107,9 @@ func testPipeBidirectionalUnblocking(t *testing.T, afterFun func(*mockedStream,
67107
select {
68108
case err := <-resultCh:
69109
if expectTimeout {
70-
require.NotNil(t, err)
110+
require.Error(t, err)
71111
} else {
72-
require.Nil(t, err)
112+
require.NoError(t, err)
73113
}
74114

75115
case <-time.After(timeout * 2):

0 commit comments

Comments
 (0)