Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions cmd/autocar/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type tunnelFlags struct {
dialTimeout time.Duration
primaryTimeout time.Duration
openTimeout time.Duration
h2WriteTimeout time.Duration
fallbackTTL time.Duration
h3Fingerprint string
pacing string
Expand All @@ -56,6 +57,7 @@ func addTunnelFlags(fs *flag.FlagSet, flags *tunnelFlags) {
fs.DurationVar(&flags.dialTimeout, "dial-timeout", 5*time.Second, "transport network dial timeout")
fs.DurationVar(&flags.primaryTimeout, "quic-attempt-timeout", 5*time.Second, "entire UDP primary phase budget before auto-mode TCP fallback")
fs.DurationVar(&flags.openTimeout, "open-timeout", 15*time.Second, "overall remote stream open timeout")
fs.DurationVar(&flags.h2WriteTimeout, "h2-write-timeout", 30*time.Second, "shared H2 connection write timeout (h2/web-auto only; 0 uses 30s; not an idle or per-stream timeout)")
fs.DurationVar(&flags.fallbackTTL, "fallback-cooldown", 30*time.Second, "base time to prefer the TCP fallback after a UDP path failure (each retry is jittered +/-20%)")
fs.StringVar(&flags.h3Fingerprint, "h3-fingerprint", string(tunnel.H3FingerprintChrome202608), "web H3 wire profile: chrome-2026-08 or native")
fs.StringVar(&flags.pacing, "pacing", "adaptive", "QUIC application pacing: adaptive, reno, or fixed-rate")
Expand All @@ -80,6 +82,9 @@ func buildTunnelDialer(flags tunnelFlags) (closeDialer, error) {
if flags.dialTimeout <= 0 || flags.openTimeout <= 0 {
return nil, errors.New("--dial-timeout and --open-timeout must be positive")
}
if flags.h2WriteTimeout < 0 {
return nil, errors.New("--h2-write-timeout must not be negative; zero uses the 30s default")
}
if (mode == "auto" || mode == "web-auto") && (flags.primaryTimeout <= 0 || flags.primaryTimeout >= flags.openTimeout) {
return nil, fmt.Errorf("%s mode requires 0 < --quic-attempt-timeout < --open-timeout so the TCP fallback retains time", mode)
}
Expand Down Expand Up @@ -225,6 +230,7 @@ func buildTunnelDialer(flags tunnelFlags) (closeDialer, error) {
TLSConfig: tlsConfig,
HandshakeTimeout: flags.openTimeout,
DialTimeout: flags.dialTimeout,
WriteByteTimeout: flags.h2WriteTimeout,
})
if err != nil {
return nil, err
Expand All @@ -239,6 +245,7 @@ func buildTunnelDialer(flags tunnelFlags) (closeDialer, error) {
HandshakeTimeout: flags.openTimeout,
H3DialTimeout: flags.dialTimeout,
H2DialTimeout: flags.dialTimeout,
H2WriteByteTimeout: flags.h2WriteTimeout,
PrimaryAttemptTimeout: flags.primaryTimeout,
FallbackCooldown: flags.fallbackTTL,
})
Expand Down
96 changes: 96 additions & 0 deletions cmd/autocar/h2_write_timeout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package main

import (
"context"
"flag"
"io"
"strings"
"testing"
"time"
)

func TestH2WriteTimeoutFlagAndConfig(t *testing.T) {
for _, test := range []struct {
name string
config string
args []string
want time.Duration
}{
{name: "default", want: 30 * time.Second},
{name: "CLI", args: []string{"--h2-write-timeout=90s"}, want: 90 * time.Second},
{name: "zero selects default", args: []string{"--h2-write-timeout=0"}, want: 0},
{name: "JSON", config: `{"h2-write-timeout":"2m"}`, want: 2 * time.Minute},
{name: "CLI overrides JSON", config: `{"h2-write-timeout":"2m"}`, args: []string{"--h2-write-timeout=45s"}, want: 45 * time.Second},
} {
t.Run(test.name, func(t *testing.T) {
fs := flag.NewFlagSet("test", flag.ContinueOnError)
var flags tunnelFlags
addTunnelFlags(fs, &flags)
args := test.args
if test.config != "" {
args = append([]string{"--config", writeTestCommandConfig(t, test.config)}, args...)
}
if err := parseFlagsWithConfig(fs, args); err != nil {
t.Fatal(err)
}
if flags.h2WriteTimeout != test.want {
t.Fatalf("H2 write timeout = %v, want %v", flags.h2WriteTimeout, test.want)
}
if flags.openTimeout != 15*time.Second || flags.dialTimeout != 5*time.Second {
t.Fatal("H2 write timeout changed unrelated setup timeouts")
}
})
}
}

func TestH2WriteTimeoutConfigRejectsMalformedValues(t *testing.T) {
for _, value := range []string{`12`, `null`, `"secret-invalid-duration"`} {
fs := flag.NewFlagSet("test", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var flags tunnelFlags
addTunnelFlags(fs, &flags)
path := writeTestCommandConfig(t, `{"h2-write-timeout":`+value+`}`)
err := parseFlagsWithConfig(fs, []string{"--config", path, "--h2-write-timeout=30s"})
if err == nil || strings.Contains(err.Error(), "secret-invalid-duration") {
t.Fatalf("malformed duration must fail without leaking its value: %v", err)
}
}
}

func TestH2WriteTimeoutPreflight(t *testing.T) {
clearPreflightEnvironment(t)
files := newPreflightFiles(t)
dnsCalls := denyPreflightDNS(t)
for _, mode := range []string{"h2", "web-auto"} {
for _, value := range []string{"0", "125ms", "2m", "-1s"} {
t.Run(mode+"/"+value, func(t *testing.T) {
args := append(files.clientArgs(), "--transport", mode, "--h2-write-timeout="+value)
err := runClient(context.Background(), args)
if value == "-1s" {
if err == nil || !strings.Contains(err.Error(), "--h2-write-timeout") {
t.Fatalf("negative timeout error = %v", err)
}
} else if err != nil {
t.Fatal(err)
}
})
}
}
if got := dnsCalls.Load(); got != 0 {
t.Fatalf("offline H2 checks attempted %d DNS connections", got)
}
}

func TestH2WriteTimeoutHelp(t *testing.T) {
fs := flag.NewFlagSet("test", flag.ContinueOnError)
var output strings.Builder
fs.SetOutput(&output)
var flags tunnelFlags
addTunnelFlags(fs, &flags)
fs.PrintDefaults()
for _, want := range []string{"h2-write-timeout", "h2/web-auto only", "0 uses 30s", "not an idle or per-stream timeout"} {
if !strings.Contains(output.String(), want) {
t.Fatalf("help omitted %q", want)
}
}
}
19 changes: 19 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,25 @@ it on the server as `--destination-write-timeout=30s` or JSON
32 KiB writes can legitimately take longer. Negative durations are rejected by
normal startup and `--check`.

Source builds provide a separate client `--h2-write-timeout` for explicit `h2`
and the H2 fallback of `web-auto`. Its default is `30s`; `0` selects that
default, and negative values are rejected. JSON uses
`"h2-write-timeout": "30s"`. It is independent of `--open-timeout`, local proxy
idle timeouts and server destination-write timeouts, and does not change H3 or
native transport settings. This option is not in the v1.0.1 release.

This bounds pending HTTP/2 physical-connection writes, including control
frames, so a non-reading peer cannot indefinitely hold the shared writer lock
and prevent stream cleanup. No write is pending on a purely idle connection,
and successful writes clear their deadlines. It is a TLS write-call budget,
not a precise kernel-level byte-idleness timer: an exceptionally slow write
can time out despite partial network progress. Increase it if needed for such
links. The `30s` default is an operational choice, not a measured universal
optimum. A TLS write timeout can terminate **all streams on that connection**;
new requests establish a fresh authenticated session. Normal per-stream
cancellation remains isolated. Stream close may wait for the pending write's
budget and cleanup; this is bounded recovery, not an immediate-close promise.

Before leaving a client running, verify a real authenticated relay path with
the same connection flags:

Expand Down
9 changes: 9 additions & 0 deletions docs/WEB_COVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ H2's handshake budget covers both TLS negotiation and the initial HTTP/2
preface/SETTINGS write. Caller cancellation or client shutdown also interrupts
that initialization, before the connection enters the reusable session pool.

Source builds additionally bound H2 physical writes with a separate
`--h2-write-timeout` (default `30s`, zero selects the default). It applies to
explicit `h2` and `web-auto`'s H2 fallback, including shared control-frame
writes; a stalled peer can no longer hold that writer indefinitely. This is
not an idle or per-stream deadline. A physical TLS write timeout can end all
streams on the affected connection, while ordinary stream cancellation must
preserve healthy siblings. See [timeout semantics and configuration](DEPLOYMENT.md)
for partial-progress and cleanup boundaries. This setting is not in v1.0.1.

There is no `autocar/2` ALPN or AutoCAR binary stream header on these paths.
The web ALPNs are `h2`, `h3`, and `http/1.1`. Native and web transports remain
separate modes and are not wire-compatible.
Expand Down
9 changes: 8 additions & 1 deletion internal/tunnel/web_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ type WebClientConfig struct {
HandshakeTimeout time.Duration
H3DialTimeout time.Duration
H2DialTimeout time.Duration
// H2WriteByteTimeout limits physical HTTP/2 writes, not stream lifetime or
// idle time. Zero uses thirty seconds; negative values are invalid. As with
// WebH2ClientConfig.WriteByteTimeout, partial network progress does not
// guarantee survival. A timeout can terminate every stream sharing the H2
// fallback connection; it does not affect HTTP/3.
H2WriteByteTimeout time.Duration

// PrimaryAttemptTimeout bounds an HTTP/3 CONNECT attempt, including a
// request on an already warm connection. Zero defaults to five seconds.
Expand Down Expand Up @@ -98,7 +104,7 @@ func NewWebClient(config WebClientConfig) (*WebClient, error) {
if config.ServerAddress == "" {
return nil, errors.New("tunnel: web-cover server address is required")
}
if config.HandshakeTimeout < 0 || config.H3DialTimeout < 0 || config.H2DialTimeout < 0 ||
if config.HandshakeTimeout < 0 || config.H3DialTimeout < 0 || config.H2DialTimeout < 0 || config.H2WriteByteTimeout < 0 ||
config.PrimaryAttemptTimeout < 0 || config.FallbackCooldown < 0 {
return nil, errors.New("tunnel: web-cover client timeouts cannot be negative")
}
Expand Down Expand Up @@ -133,6 +139,7 @@ func NewWebClient(config WebClientConfig) (*WebClient, error) {
TLSConfig: config.TLSConfig,
HandshakeTimeout: config.HandshakeTimeout,
DialTimeout: config.H2DialTimeout,
WriteByteTimeout: config.H2WriteByteTimeout,
})
if err != nil {
_ = h3.Close()
Expand Down
16 changes: 15 additions & 1 deletion internal/tunnel/web_h2_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import (
"golang.org/x/net/http2"
)

const defaultWebH2WriteByteTimeout = 30 * time.Second

// WebH2ClientConfig configures the HTTP/2 side of the web-cover transport.
type WebH2ClientConfig struct {
ServerAddress string
Expand All @@ -29,6 +31,13 @@ type WebH2ClientConfig struct {
FingerprintProfile FingerprintProfile
HandshakeTimeout time.Duration
DialTimeout time.Duration
// WriteByteTimeout limits stalled writes on the shared HTTP/2 connection.
// Zero uses a conservative thirty-second default; negative values are
// invalid. This is a TLS write-call budget, not a precise TCP byte-idle
// timer: it can expire despite partial network progress. A timeout can
// terminate all streams on that physical TLS connection; it is not a
// stream deadline or an idle-connection timeout.
WriteByteTimeout time.Duration
}

// WebH2Client implements transport.Dialer with one standard HTTP/2 CONNECT
Expand Down Expand Up @@ -107,7 +116,7 @@ func newWebH2ClientWithSigner(config WebH2ClientConfig, auth *webAuthSigner, cla
if err := validateWebAuthClaims(claims); err != nil {
return nil, err
}
if config.HandshakeTimeout < 0 || config.DialTimeout < 0 {
if config.HandshakeTimeout < 0 || config.DialTimeout < 0 || config.WriteByteTimeout < 0 {
return nil, errors.New("tunnel: web-cover HTTP/2 timeouts cannot be negative")
}
tlsConfig, err := webClientTLSConfig(config.TLSConfig, config.ServerAddress, webH2ALPN)
Expand Down Expand Up @@ -135,6 +144,10 @@ func newWebH2ClientWithSigner(config WebH2ClientConfig, auth *webAuthSigner, cla
if dialTimeout == 0 {
dialTimeout = defaultDialTimeout
}
writeByteTimeout := config.WriteByteTimeout
if writeByteTimeout == 0 {
writeByteTimeout = defaultWebH2WriteByteTimeout
}
ctx, cancel := context.WithCancel(context.Background())
return &WebH2Client{
address: config.ServerAddress,
Expand All @@ -148,6 +161,7 @@ func newWebH2ClientWithSigner(config WebH2ClientConfig, auth *webAuthSigner, cla
DisableCompression: true,
StrictMaxConcurrentStreams: true,
MaxHeaderListSize: defaultWebClientMaxResponseHeaderBytes,
WriteByteTimeout: writeByteTimeout,
},
utlsSessionCache: utlsSessionCache,
ctx: ctx,
Expand Down
Loading
Loading