# HTTP/3 上游 `context canceled` 问题分析

## 状态

当前不改代码，只记录原因分析、MVP 复现测试和候选解决方案。

## 现象

本地代理 `127.0.0.1:8087` 使用 HTTP/3 上游 dialer 时，Codex CLI 访问 `chatgpt.com:443` 报：

```text
error=context canceled
http_method=CONNECT
http_host=chatgpt.com:443
message="dial host error"
```

但以下场景正常：

- 上游 dialer 改成 `https`。
- 使用 `curl -x http://127.0.0.1:8087 www.google.com`。

## 根因

CONNECT 分支在 `handler_http_forward.go` 中直接把客户端请求 context 传给上游 dialer：

```go
ctx := req.Context()
conn, err := dialer.DialContext(ctx, network, req.Host)
```

这会让客户端取消本地 CONNECT 请求时，直接取消上游拨号。

HTTP/3 上游 setup 比普通 HTTPS 更慢、更复杂，至少包含：

1. QUIC 握手。
2. HTTP/3 client conn 建立。
3. 打开 request stream。
4. 发送 CONNECT header。
5. 等待上游 CONNECT response。

Codex CLI 的取消时序更容易打中这个 setup 窗口，于是 `HTTP3Dialer` 返回 `context canceled`，HTTP forward handler 最终记录 `dial host error`。

`curl` 能成功、HTTPS 上游能成功，说明基本代理链路不是坏的；问题更像是 `CONNECT` 请求生命周期和 HTTP/3 上游 setup 生命周期绑定过紧。

## MVP 复现测试

这个测试不需要真实 HTTP/3，只验证核心问题：HTTP CONNECT 的 upstream dial context 会被客户端请求取消传播进去。

```go
package main

import (
	"context"
	"errors"
	"net"
	"net/http"
	"net/http/httptest"
	"net/netip"
	"net/url"
	"sync/atomic"
	"testing"
)

func TestHTTPForwardConnectDialContextCanceledByClient(t *testing.T) {
	dialer := &cancelProbeDialer{
		started: make(chan struct{}),
		release: make(chan struct{}),
	}

	var cfg HTTPConfig
	cfg.Forward.Policy = "bypass_auth"
	cfg.Forward.Dialer = "test"

	h := &HTTPForwardHandler{
		Config:      cfg,
		Dialers:     map[string]Dialer{"test": dialer},
		GeoResolver: &GeoResolver{},
	}

	ctx, cancel := context.WithCancel(context.Background())
	ctx = context.WithValue(ctx, HTTPRequestInfoContextKey, &HTTPRequestInfo{
		RemoteAddr: netip.MustParseAddrPort("127.0.0.1:12345"),
		RealIP:     netip.MustParseAddr("127.0.0.1"),
		ServerAddr: netip.MustParseAddrPort("127.0.0.1:8087"),
	})

	req := (&http.Request{
		Method:     http.MethodConnect,
		URL:        &url.URL{Host: "chatgpt.com:443"},
		Host:       "chatgpt.com:443",
		RequestURI: "chatgpt.com:443",
		Proto:      "HTTP/1.1",
		ProtoMajor: 1,
		ProtoMinor: 1,
		Header:     make(http.Header),
		Body:       http.NoBody,
		RemoteAddr: "127.0.0.1:12345",
	}).WithContext(ctx)

	done := make(chan struct{})
	go func() {
		h.ServeHTTP(httptest.NewRecorder(), req)
		close(done)
	}()

	<-dialer.started
	cancel()
	close(dialer.release)
	<-done

	if !dialer.sawCanceled.Load() {
		t.Fatal("expected CONNECT dial context to be canceled by client request")
	}
}

type cancelProbeDialer struct {
	started     chan struct{}
	release     chan struct{}
	sawCanceled atomic.Bool
}

func (d *cancelProbeDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
	close(d.started)
	<-d.release

	select {
	case <-ctx.Done():
		d.sawCanceled.Store(true)
		return nil, ctx.Err()
	default:
		return nil, errors.New("dial context was not canceled")
	}
}
```

运行方式：

```bash
CGO_ENABLED=0 GOCACHE=/tmp/liner-go-build go test . -run TestHTTPForwardConnectDialContextCanceledByClient -count=1
```

## 候选解决方案

### 方案 A：CONNECT 上游拨号脱离客户端取消

在 CONNECT 分支里使用：

```go
ctx := context.WithoutCancel(req.Context())
```

并保留原 deadline：

```go
if deadline, ok := req.Context().Deadline(); ok {
	ctx, cancel = context.WithDeadline(ctx, deadline)
	defer cancel()
}
```

优点：

- 最直接修复根因。
- 改动范围小。
- 符合 tunnel setup 和客户端请求生命周期分离的设计。

风险：

- 客户端断开后，上游拨号可能继续到自身超时。
- 最好配合明确的 setup timeout。

### 方案 B：只调整 HTTP3Dialer

在 `dialer_http3.go` 内部弱化 caller context 对 CONNECT stream setup 的取消影响。

优点：

- 影响范围更局部。

缺点：

- 不能完全解决根因。
- handler 仍然会把客户端取消传播给 dialer。
- QUIC 建连、settings 等阶段仍可能被 `req.Context()` 取消。

### 方案 C：新增 `forward.dial_timeout`

给 detached CONNECT setup 明确超时，例如：

```yaml
forward:
  dial_timeout: 10
```

优点：

- 语义最清楚。
- 可以同时解决客户端取消误伤和资源占用上限问题。

缺点：

- 属于新增配置面。
- 需要更新 `config.go`、配置 merge、示例和文档。

## 推荐

优先采用方案 A：CONNECT 分支使用 detached dial context，并增加回归测试，断言 upstream `DialContext` 不会被客户端请求取消直接打断。

如果担心客户端断开后上游拨号继续占用资源，再追加方案 C，为 detached CONNECT setup 增加明确超时。
