From 2beeb6322781b27e46a24b842f58d7568c33166f Mon Sep 17 00:00:00 2001 From: Varun Agarwal Date: Sun, 13 Sep 2026 05:11:40 +0530 Subject: [PATCH] fix(intra): don't discard rwext read/write deadlines when TCP_USER_TIMEOUT is set forward() unwrapped the remote conn from its rwext deadline wrapper whenever rwext.SetTimeout() reported didSet=true, treating a successfully-applied low-level sockopt as equivalent to having a software read/write deadline in place. SetTimeout() only sets TCP_USER_TIMEOUT via core.SetTimeoutSockOpt(). TCP_USER_TIMEOUT bounds how long unacknowledged outbound data may go unacked before the kernel force-closes the connection - it has no effect on a blocking Read() that is simply waiting to receive more data from a peer that has gone idle without sending RST/FIN. It does not implement a receive/idle timeout. Once remote was unwrapped, the only mechanism that could bound such a Read() - rwext's extendr()/extendw(), which apply Go's real per-call SetReadDeadline/SetWriteDeadline via settings.DialerOpts - was discarded entirely. As a result, a relayed TCP connection to a peer that silently stops sending (common with some CDN/load-balancer behavior on idle keep-alive connections, or after a NAT/middlebox timeout that never surfaces an RST) blocks forward()'s Read() forever. The socket stays visibly ESTABLISHED with zero rx/tx queue activity indefinitely, and the app-level effect is a permanent hang (e.g., a media player stuck in a buffering state) with no path to recovery short of killing the connection/process. This was reproduced consistently on-device: a live TCP socket to a video CDN would enter this idle-ESTABLISHED state with zero queue bytes and never recover, while process CPU stayed idle (ruling out a busy loop) and DNS/WAN connectivity remained healthy throughout - pointing squarely at a stuck blocking Read() in the relay path. Fix: only unwrap remote from rwext when timeoutsecs <= 0, i.e. when no read/write deadline is configured at all and rwext.Read/Write would be a true no-op. When a positive timeout is configured, keep remote wrapped so extendr()/extendw() continue to enforce a genuine per-call deadline in addition to (not instead of) the TCP_USER_TIMEOUT sockopt optimization. --- intra/common.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/intra/common.go b/intra/common.go index 827e5cf3..8ca0a332 100644 --- a/intra/common.go +++ b/intra/common.go @@ -312,12 +312,20 @@ func (h *baseHandler) forward(local, remote net.Conn, smm *FlowSummary) { isrwext := false didSet := false timeoutsecs := 0 - // enable core.Pipe (sendfile/zero-copy) optimizations on TCP if - // read & write deadlines are not set (as in rwext is effectively - // a no-op) by unwrapping the underlying remote conn from rwext. + // enable core.Pipe (sendfile/zero-copy) optimizations on TCP only + // when no read/write deadline is actually configured (timeoutsecs + // <= 0), in which case rwext is a no-op wrapper and unwrapping is + // safe. Do NOT unwrap merely because didSet is true: SetTimeout + // (via core.SetTimeoutSockOpt) only sets TCP_USER_TIMEOUT, which + // bounds unacknowledged *writes*, not idle *reads*. If remote is + // unwrapped here while a positive timeoutsecs is configured, the + // only mechanism that can bound a stalled Read() (rwext's + // extendr/extendw, which set a real per-call deadline) is lost, + // and a peer that silently stops sending (no RST/FIN) causes + // Read() -- and thus this whole forward() -- to block forever. if r, ok := remote.(rwext); ok { isrwext = true - if timeoutsecs, didSet = r.SetTimeout(); didSet || timeoutsecs <= 0 { + if timeoutsecs, didSet = r.SetTimeout(); timeoutsecs <= 0 { remote = r.Unwrap() // c may be *net.TCPConn or *demuxconn or *dialers.retrier|splitter } }