fix(handler/forward): split readTimeout from pipe idleTimeout to prevent 15s download abort

The readTimeout field (default 15s) was being applied via xnet.Pipe to both
directions of a bidirectional proxy connection. During asymmetric transfers
(e.g. HTTP file download), the direction reading from the tunnel sees no
data after the initial request is forwarded, causing SetReadDeadline to fire
after 15s and abort the entire transfer.

Fix: add a separate idleTimeout field (default 0=disabled) to the metadata
structs in both forward/local and forward/remote handlers, and switch
xnet.Pipe to use idleTimeout instead of readTimeout. The readTimeout field
now only applies to the initial protocol sniffing/handshake phase.

Also document readTimeout vs idleTimeout semantics across all 24 locations
in the x/ module where these timeouts appear:
- readTimeout: handshake sniffing deadline (handlers), upstream response
  header timeout (http.Transport), or transport-level read deadline
- idleTimeout: idle read deadline per Pipe direction (0=disabled)
- ReadTimeout on Sniffer/SnifferBuilder: upstream response header/TLS
  handshake read timeout during sniffing
This commit is contained in:
ginuerzh
2026-05-31 17:05:21 +08:00
parent b8d9c79f72
commit 722dde5cfc
24 changed files with 128 additions and 6 deletions
+5
View File
@@ -18,6 +18,11 @@ const (
// metadata holds parsed DNS handler configuration.
type metadata struct {
// readTimeout is the deadline for reading DNS query and writing DNS
// response on the client connection. Each DNS exchange (one query +
// one response) must complete within this window.
// Default: 0 (no timeout set by handler — DNS upstream timeout is
// controlled separately by the "timeout" field).
readTimeout time.Duration
ttl time.Duration
timeout time.Duration
+1 -1
View File
@@ -82,7 +82,7 @@ func (h *forwardHandler) handleRawForwarding(ctx context.Context, conn net.Conn,
t := time.Now()
log.Infof("%s <-> %s", conn.RemoteAddr(), target.Addr)
if err := xnet.Pipe(ctx, conn, cc, xnet.WithReadTimeout(h.md.readTimeout)); err != nil {
if err := xnet.Pipe(ctx, conn, cc, xnet.WithReadTimeout(h.md.idleTimeout)); err != nil {
log.Debugf("pipe: %v", err)
}
log.WithFields(map[string]any{
+15
View File
@@ -13,7 +13,17 @@ import (
)
type metadata struct {
// readTimeout is the deadline for the initial protocol sniffing phase
// (used by SnifferBuilder.ReadTimeout). After sniffing completes and
// raw forwarding begins, this timeout no longer applies.
// Default: 15s.
readTimeout time.Duration
// idleTimeout is the idle read timeout applied to each direction of
// the bidirectional pipe (xnet.Pipe). A value of 0 or negative disables
// the timeout entirely, relying on TCP keepalives and context
// cancellation to detect dead connections.
// Default: 0 (disabled).
idleTimeout time.Duration
httpKeepalive bool
proxyProtocol int
@@ -34,6 +44,11 @@ func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
h.md.readTimeout = 15 * time.Second
}
h.md.idleTimeout = mdutil.GetDuration(md, "idleTimeout")
if h.md.idleTimeout < 0 {
h.md.idleTimeout = 0
}
h.md.httpKeepalive = mdutil.GetBool(md, "http.keepalive")
h.md.proxyProtocol = mdutil.GetInt(md, "proxyProtocol")
+3
View File
@@ -32,6 +32,9 @@ type SnifferBuilder struct {
ALPN string
CertPool tls_util.CertPool
MitmBypass bypass.Bypass
// ReadTimeout is the timeout for reading upstream HTTP response headers
// and TLS ServerHello during sniffing. Passed through to forwarder.Sniffer.
// See forwarder.Sniffer.ReadTimeout for details.
ReadTimeout time.Duration
}
+1 -1
View File
@@ -78,7 +78,7 @@ func (h *forwardHandler) handleRawForwarding(ctx context.Context, conn net.Conn,
t := time.Now()
log.Infof("%s <-> %s", conn.RemoteAddr(), target.Addr)
if err := xnet.Pipe(ctx, conn, cc, xnet.WithReadTimeout(h.md.readTimeout)); err != nil {
if err := xnet.Pipe(ctx, conn, cc, xnet.WithReadTimeout(h.md.idleTimeout)); err != nil {
log.Debugf("pipe: %v", err)
}
log.WithFields(map[string]any{
+15
View File
@@ -13,7 +13,17 @@ import (
)
type metadata struct {
// readTimeout is the deadline for the initial protocol sniffing phase
// (used by SnifferBuilder.ReadTimeout). After sniffing completes and
// raw forwarding begins, this timeout no longer applies.
// Default: 15s.
readTimeout time.Duration
// idleTimeout is the idle read timeout applied to each direction of
// the bidirectional pipe (xnet.Pipe). A value of 0 or negative disables
// the timeout entirely, relying on TCP keepalives and context
// cancellation to detect dead connections.
// Default: 0 (disabled).
idleTimeout time.Duration
httpKeepalive bool
proxyProtocol int
@@ -34,6 +44,11 @@ func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
h.md.readTimeout = 15 * time.Second
}
h.md.idleTimeout = mdutil.GetDuration(md, "idleTimeout")
if h.md.idleTimeout < 0 {
h.md.idleTimeout = 0
}
h.md.httpKeepalive = mdutil.GetBool(md, "http.keepalive")
h.md.proxyProtocol = mdutil.GetInt(md, "proxyProtocol")
+3
View File
@@ -32,6 +32,9 @@ type SnifferBuilder struct {
ALPN string
CertPool tls_util.CertPool
MitmBypass bypass.Bypass
// ReadTimeout is the timeout for reading upstream HTTP response headers
// and TLS ServerHello during sniffing. Passed through to forwarder.Sniffer.
// See forwarder.Sniffer.ReadTimeout for details.
ReadTimeout time.Duration
}
+3
View File
@@ -230,6 +230,9 @@ type SnifferBuilder struct {
ALPN string
CertPool tls_util.CertPool
MitmBypass bypass.Bypass
// ReadTimeout is the timeout for reading upstream HTTP response headers
// and TLS ServerHello during sniffing. Passed through to sniffing.Sniffer.
// See sniffing.Sniffer.ReadTimeout for details.
ReadTimeout time.Duration
}
+2 -2
View File
@@ -23,8 +23,8 @@ const (
// Fields are populated by parseMetadata and read throughout the handler's
// request-processing methods.
type metadata struct {
readTimeout time.Duration // read timeout for upstream responses; default 15s, negative disables
idleTimeout time.Duration // idle timeout for pipe forwarding; 0 means disabled
readTimeout time.Duration // deadline for upstream response headers (http.Transport.ResponseHeaderTimeout); 0=15s default, negative=disabled
idleTimeout time.Duration // idle read deadline per Pipe direction during CONNECT/forwarding; 0 or negative = disabled
keepalive bool // enable HTTP keep-alive on the upstream transport
compression bool // enable HTTP compression on the upstream transport
+1 -1
View File
@@ -20,7 +20,7 @@ type metadata struct {
authBasicRealm string
observerPeriod time.Duration
observerResetTraffic bool
idleTimeout time.Duration
idleTimeout time.Duration // idle read deadline per Pipe direction during forwarding; read from "readTimeout" or "read.timeout" metadata key. 0 = disabled.
limiterRefreshInterval time.Duration
limiterCleanupInterval time.Duration
+5
View File
@@ -13,6 +13,11 @@ import (
)
type metadata struct {
// readTimeout is passed to SnifferBuilder as the timeout for reading
// upstream response headers during HTTP/TLS sniffing. It is NOT used
// as a deadline on the initial client connection (unlike socks/ss
// handlers), because redirect/tcp has no protocol handshake.
// 0 or negative defaults to 15s.
readTimeout time.Duration
tproxy bool
+6
View File
@@ -14,6 +14,12 @@ import (
)
type metadata struct {
// readTimeout is the deadline for reading the initial relay protocol
// handshake (relay.Request) from the client connection. The deadline
// is cleared immediately after the handshake completes, so it does
// not affect subsequent data transfer. Also passed to SnifferBuilder
// for the upstream response header read timeout.
// 0 or negative defaults to 15s.
readTimeout time.Duration
udpBufferSize int
enableBind bool
+5
View File
@@ -18,6 +18,11 @@ const (
)
type metadata struct {
// readTimeout is the deadline for reading the initial relay protocol
// handshake from the client connection. The deadline is cleared
// after the handshake, so it does not affect subsequent data
// transfer.
// 0 or negative means no timeout is applied.
readTimeout time.Duration
bufferSize int
+6
View File
@@ -13,6 +13,12 @@ import (
)
type metadata struct {
// readTimeout is passed to SnifferBuilder as the timeout for reading
// upstream response headers during HTTP/TLS sniffing. It is NOT used
// as a deadline on the initial client connection (unlike socks/ss
// handlers), because SNI routing has no protocol handshake beyond
// the TLS ClientHello which is parsed during sniffing.
// 0 or negative defaults to 15s.
readTimeout time.Duration
hash string
+6
View File
@@ -13,6 +13,12 @@ import (
)
type metadata struct {
// readTimeout is the deadline for reading the initial SOCKS4/4A
// handshake (connect/bind request) from the client connection. The
// deadline is cleared after the handshake, so it does not affect
// subsequent data transfer. Also passed to SnifferBuilder for the
// upstream response header read timeout.
// 0 or negative defaults to 15s.
readTimeout time.Duration
hash string
+6
View File
@@ -15,6 +15,12 @@ import (
type metadata struct {
publicAddr string
// readTimeout is the deadline for reading the initial SOCKS5
// handshake (auth + connect/associate/udp request) from the client
// connection. The deadline is cleared after the handshake, so it
// does not affect subsequent data transfer. Also passed to
// SnifferBuilder for the upstream response header read timeout.
// 0 or negative defaults to 15s.
readTimeout time.Duration
noTLS bool
enableBind bool
+6
View File
@@ -16,6 +16,12 @@ import (
type metadata struct {
key string
hash string
// readTimeout is the deadline for reading the initial Shadowsocks
// handshake from the client connection. The deadline is cleared
// after the handshake, so it does not affect subsequent data
// transfer. Also passed to SnifferBuilder for the upstream response
// header read timeout.
// 0 or negative defaults to 15s.
readTimeout time.Duration
sniffing bool
+5
View File
@@ -14,6 +14,11 @@ const (
type metadata struct {
key string
// readTimeout is the deadline for reading a single UDP datagram from
// the client. Since UDP is connectionless, this timeout applies to
// each individual datagram read. Default: 1 minute (longer than the
// typical 15s used by TCP handlers to accommodate the stateless
// nature of UDP).
readTimeout time.Duration
udpBufferSize int
+6
View File
@@ -13,6 +13,12 @@ import (
)
type metadata struct {
// readTimeout is the deadline for reading the initial SSH handshake
// from the client connection. The deadline is cleared after the
// handshake, so it does not affect subsequent data transfer. Also
// passed to SnifferBuilder for the upstream response header read
// timeout.
// 0 or negative defaults to 15s.
readTimeout time.Duration
sniffing bool
+3
View File
@@ -63,6 +63,9 @@ type entrypoint struct {
sniffingWebsocket bool
websocketSampleRate float64
// readTimeout is applied as SetReadDeadline on the upstream connection
// before sniffing HTTP/TLS reads. It mirrors entryPointReadTimeout
// from the handler metadata.
readTimeout time.Duration
}
+5 -1
View File
@@ -21,6 +21,10 @@ const (
)
type metadata struct {
// readTimeout is the deadline for reading the initial relay protocol
// handshake from the client connection. The deadline is cleared
// after the handshake, so it does not affect subsequent data
// transfer. 0 or negative means no timeout is applied.
readTimeout time.Duration
entrypoints []entrypointConfig
@@ -30,7 +34,7 @@ type metadata struct {
entryPointProxyProtocol int
entryPointKeepalive bool
entryPointCompression bool
entryPointReadTimeout time.Duration
entryPointReadTimeout time.Duration // deadline for reading upstream HTTP response headers in the entrypoint's http.Transport.ResponseHeaderTimeout. Also passed as readTimeout to entrypoint dialer for SetReadDeadline on upstream conn. 0 or negative defaults to 15s.
sniffingWebsocket bool
sniffingWebsocketSampleRate float64
+6
View File
@@ -13,6 +13,12 @@ import (
)
type metadata struct {
// readTimeout is the deadline for reading data from the upstream
// (the target Unix socket or pipe). It is set on the upstream conn
// via SetReadDeadline before each sniffing HTTP/TLS read. Also
// passed to SnifferBuilder for the upstream response header read
// timeout.
// 0 or negative defaults to 15s.
readTimeout time.Duration
sniffing bool
+7
View File
@@ -122,6 +122,13 @@ type Sniffer struct {
CertPool tls_util.CertPool
MitmBypass bypass.Bypass
// ReadTimeout is the deadline for reading the upstream response
// headers during HTTP sniffing (http.ReadResponse) and the TLS
// ServerHello during TLS sniffing. This timeout is applied once
// per request/response pair in the HTTP keep-alive loop and cleared
// after each response is received. It does NOT affect the client
// connection or the response body transfer.
// Default: DefaultReadTimeout (30s) if not set.
ReadTimeout time.Duration
}
+7
View File
@@ -103,6 +103,13 @@ type Sniffer struct {
CertPool tls_util.CertPool
MitmBypass bypass.Bypass
// ReadTimeout is the deadline for reading the upstream response
// headers during HTTP sniffing (http.ReadResponse) and the TLS
// ServerHello during TLS sniffing. This timeout is applied once
// per request/response pair in the HTTP keep-alive loop and cleared
// after each response is received. It does NOT affect the client
// connection or the response body transfer.
// Default: DefaultReadTimeout (30s) if not set.
ReadTimeout time.Duration
}