From 663bff79a029620d2e37a4f4260174888e7b4716 Mon Sep 17 00:00:00 2001 From: anon0v0 <221615165+anon0v0@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:39:36 +0800 Subject: [PATCH 1/2] fix: keep QUIC connections alive during slow responses --- internal/aistudio/transport_quic.go | 31 +++-- internal/aistudio/transport_quic_test.go | 167 +++++++++++++++++++++++ 2 files changed, 185 insertions(+), 13 deletions(-) create mode 100644 internal/aistudio/transport_quic_test.go diff --git a/internal/aistudio/transport_quic.go b/internal/aistudio/transport_quic.go index 285dce7..64f57eb 100644 --- a/internal/aistudio/transport_quic.go +++ b/internal/aistudio/transport_quic.go @@ -126,10 +126,26 @@ func dialFirefoxQUIC(ctx context.Context, host string, resumption *quicResumptio MinVersion: utls.VersionTLS13, ClientSessionCache: resumption.sessions, OmitEmptyPsk: true, - }, &quic.Config{ + }, firefoxQUICConfig(datagram, resumption.tokens)) + if err != nil { + _ = transport.Close() + _ = socket.Close() + return nil, nil, err + } + go func() { + <-connection.Context().Done() + _ = transport.Close() + _ = socket.Close() + }() + return connection, settings, nil +} + +func firefoxQUICConfig(datagram int, tokens quic.TokenStore) *quic.Config { + return &quic.Config{ Versions: []quic.Version{quic.Version1}, HandshakeIdleTimeout: firefoxQUICIdleTimeout, MaxIdleTimeout: firefoxQUICIdleTimeout, + KeepAlivePeriod: 10 * time.Second, InitialStreamReceiveWindow: firefoxQUICMaxStreamData, MaxStreamReceiveWindow: firefoxQUICMaxStreamData, InitialConnectionReceiveWindow: firefoxQUICMaxData, @@ -138,19 +154,8 @@ func dialFirefoxQUIC(ctx context.Context, host string, resumption *quicResumptio MaxIncomingUniStreams: neqoMaxStreams, InitialPacketSize: uint16(datagram), DisablePathMTUDiscovery: true, - TokenStore: resumption.tokens, - }) - if err != nil { - _ = transport.Close() - _ = socket.Close() - return nil, nil, err + TokenStore: tokens, } - go func() { - <-connection.Context().Done() - _ = transport.Close() - _ = socket.Close() - }() - return connection, settings, nil } // firefoxQUICSpec 返回 Firefox 152(neqo 0.28)一次直连的 QUIC 首航:随机 DCID 长度与首个包号、SNI 切分的 CRYPTO 帧、Initial 数据报补零到 datagram 字节,psk 与 early 决定 ClientHello 是否带 pre_shared_key 与 early_data diff --git a/internal/aistudio/transport_quic_test.go b/internal/aistudio/transport_quic_test.go new file mode 100644 index 0000000..02cf2ee --- /dev/null +++ b/internal/aistudio/transport_quic_test.go @@ -0,0 +1,167 @@ +package aistudio + +import ( + "bufio" + "context" + "errors" + "io" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + fhttp "github.com/Mag1cFall/fhttp" + quic "github.com/Mag1cFall/uquic" + utls "github.com/Mag1cFall/utls" +) + +func TestHTTP3SlowResponse(t *testing.T) { + for _, flushHeaders := range []bool{false, true} { + name := "headers" + if flushHeaders { + name = "body" + } + t.Run(name, func(t *testing.T) { + var requests atomic.Int32 + conn := newTestHTTP3Conn(t, func(stream *quic.Stream) { + requests.Add(1) + block := newQPACKEncoder().encode([]qpackField{{":status", "200"}}, int64(stream.StreamID())) + headers := appendH3Frame(nil, h3FrameHeaders, block) + if flushHeaders { + _, _ = stream.Write(headers) + } + time.Sleep(1500 * time.Millisecond) + if !flushHeaders { + _, _ = stream.Write(headers) + } + _, _ = stream.Write(appendH3Frame(nil, h3FrameData, []byte("ok"))) + _ = stream.Close() + }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + request, err := fhttp.NewRequestWithContext(ctx, "POST", "https://localhost/", strings.NewReader("test")) + if err != nil { + t.Fatal(err) + } + response, opened, err := conn.roundTrip(request, h3RequestFields(request, false)) + if err != nil || !opened { + t.Fatalf("等待慢响应失败: opened=%v err=%v", opened, err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil || string(body) != "ok" || response.StatusCode != 200 { + t.Fatalf("响应=%q 状态=%d err=%v", body, response.StatusCode, err) + } + if requests.Load() != 1 { + t.Fatalf("POST 执行了 %d 次,应为 1 次", requests.Load()) + } + }) + } +} + +func TestHTTP3KeepAliveCancellation(t *testing.T) { + received, canceled := make(chan struct{}), make(chan struct{}) + conn := newTestHTTP3Conn(t, func(stream *quic.Stream) { + close(received) + <-stream.Context().Done() + close(canceled) + }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + request, err := fhttp.NewRequestWithContext(ctx, "POST", "https://localhost/", strings.NewReader("test")) + if err != nil { + t.Fatal(err) + } + result := make(chan error, 1) + go func() { + _, _, err := conn.roundTrip(request, h3RequestFields(request, false)) + result <- err + }() + select { + case <-received: + case <-time.After(3 * time.Second): + t.Fatal("服务端未收到请求") + } + cancel() + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("取消请求: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("取消请求未及时返回") + } + select { + case <-canceled: + case <-time.After(3 * time.Second): + t.Fatal("服务端请求未取消") + } + if err := conn.quic.Context().Err(); err != nil { + t.Fatalf("取消请求不应关闭共享连接: %v", err) + } +} + +func newTestHTTP3Conn(t *testing.T, handler func(*quic.Stream)) *http3Conn { + t.Helper() + certServer := httptest.NewTLSServer(nil) + t.Cleanup(certServer.Close) + cert := certServer.TLS.Certificates[0] + tlsConfig := &utls.Config{ + Certificates: []utls.Certificate{{Certificate: cert.Certificate, PrivateKey: cert.PrivateKey}}, + NextProtos: []string{"h3"}, + } + listener, err := quic.ListenAddr("127.0.0.1:0", tlsConfig, &quic.Config{MaxIdleTimeout: 500 * time.Millisecond}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + done := make(chan struct{}) + go func() { + defer close(done) + connection, err := listener.Accept(ctx) + if err != nil { + return + } + defer connection.CloseWithError(0, "") + var handlers sync.WaitGroup + defer handlers.Wait() + for { + stream, err := connection.AcceptStream(ctx) + if err != nil { + return + } + handlers.Add(1) + go func() { + defer handlers.Done() + reader := bufio.NewReader(stream) + if kind, _, err := readH3Frame(reader); err != nil || kind != h3FrameHeaders { + return + } + if kind, body, err := readH3Frame(reader); err == nil && kind == h3FrameData && string(body) == "test" { + handler(stream) + } + }() + } + }() + t.Cleanup(func() { + cancel() + _ = listener.Close() + <-done + }) + config := firefoxQUICConfig(neqoIPv4Datagram, nil) + config.MaxIdleTimeout = 500 * time.Millisecond + connection, err := quic.DialAddr(ctx, listener.Addr().String(), &utls.Config{InsecureSkipVerify: true, NextProtos: []string{"h3"}}, config) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = connection.CloseWithError(0, "") }) + conn, err := newHTTP3Conn(connection, nil, func(uint64, uint64) {}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(conn.close) + return conn +} From a0a70d12bc10d1045cfd2e8494d894140fa81e25 Mon Sep 17 00:00:00 2001 From: Mag1cFall Date: Mon, 26 Oct 2026 00:18:51 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20QUIC=20=E4=BF=9D=E6=B4=BB=E5=8F=AA?= =?UTF-8?q?=E5=9C=A8=E8=AF=B7=E6=B1=82=E8=BF=9B=E8=A1=8C=E4=B8=AD=E5=8F=91?= =?UTF-8?q?=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 保活 PING 改为只在请求流未完成时按空闲超时的一半发送,空闲连接与 Firefox 一样到时关闭 - 升级 uquic 分叉以支持按流保活,移出与保活无关的取消测试,补充 HTTP/3 保活文档 --- docs/waa.md | 3 +- go.mod | 2 +- go.sum | 4 +-- internal/aistudio/transport_quic.go | 4 ++- internal/aistudio/transport_quic_test.go | 45 ++---------------------- 5 files changed, 10 insertions(+), 48 deletions(-) diff --git a/docs/waa.md b/docs/waa.md index 1477572..fb080c3 100644 --- a/docs/waa.md +++ b/docs/waa.md @@ -172,11 +172,12 @@ HTTP/2 实现为 [Mag1cFall/fhttp](https://github.com/Mag1cFall/fhttp) v0.6.8-fi - QUIC 首航与 neqo 0.28 相同:DCID 长度与首个包号按 neqo 取随机值,ClientHello 在 SNI 中点切开分到前两个 Initial 包,数据报补零到 1252 字节(IPv6 为 1232),不做 PMTU 探测 - ClientHello 为 13 个随机排列的扩展,其后依次为 QUIC 传输参数、ECH GREASE 与恢复会话时的 `pre_shared_key`;X25519MLKEM768 与 X25519 共用一把 X25519 密钥,ECH GREASE 长度按 NSS 由内层 ClientHello 的长度补齐得出 - 传输参数与 Firefox 默认值相同:空闲超时 30 秒、连接接收窗口 25165824、本端双向流窗口 12582912、对端双向流与单向流窗口 1048576、各 100 条流、`max_ack_delay` 20、连接 ID 上限 8、`min_ack_delay` 1000 微秒与 `max_datagram_frame_size` 65535 +- 与 neqo 一样,有请求进行中时,自最近一次收到数据包或其后首次发出数据起经过空闲超时的一半仍没有收发,就发送一个保活 PING;没有请求时连接按空闲超时关闭 - 控制流 SETTINGS 依次为 QPACK 动态表容量 65536、阻塞流 20、WebTransport、两个 H3 DATAGRAM 设置与扩展 CONNECT,其后跟一个保留类型的 GREASE 帧;QPACK 按 neqo 把静态表中没有的头名插入动态表,字符串一律 Huffman 编码,并回送 Section Acknowledgment 与 Insert Count Increment - 每类连接保存会话票据与服务端下发的 `NEW_TOKEN`。之后的连接带令牌与 `pre_shared_key` 恢复会话;票据允许 0-RTT 时 ClientHello 带 `early_data`,控制流与 QPACK 流在 0-RTT 中发出并沿用上次的服务端 QPACK 设置;GET、HEAD、OPTIONS 等安全方法的请求在 0-RTT 中发出,其余请求等握手完成;服务端拒绝 0-RTT 时重新打开控制流与 QPACK 流,并重发 0-RTT 中的请求 - 恢复会话的首航与 Firefox 相同:第三个 Initial 数据报在 Initial 包之后带首个 0-RTT 包(控制流与 QPACK 流),再补零到 1252 字节 -QUIC 与 TLS 实现为 [Mag1cFall/uquic](https://github.com/Mag1cFall/uquic) v0.0.6-firefox.4 与 [Mag1cFall/utls](https://github.com/Mag1cFall/utls) v1.8.2-firefox.3,分别基于 refraction-networking 的 uquic 与 utls;utls 分叉在自定义 ClientHello 恢复会话时保留 `early_data`,握手中途关闭连接时结束握手;uquic 分叉在 1-RTT 超时探测没有可发数据时发 PING,并把恢复会话的首个 0-RTT 包并入首航最后一个 Initial 数据报。HTTP/3 与 QPACK 由服务自行实现。 +QUIC 与 TLS 实现为 [Mag1cFall/uquic](https://github.com/Mag1cFall/uquic) v0.0.6-firefox.6 与 [Mag1cFall/utls](https://github.com/Mag1cFall/utls) v1.8.2-firefox.3,分别基于 refraction-networking 的 uquic 与 utls;utls 分叉在自定义 ClientHello 恢复会话时保留 `early_data`,握手中途关闭连接时结束握手;uquic 分叉在 1-RTT 超时探测没有可发数据时发 PING,把恢复会话的首个 0-RTT 包并入首航最后一个 Initial 数据报,并提供按 neqo 只在请求流进行中保活的选项。HTTP/3 与 QPACK 由服务自行实现。 `Origin` 与目标主机不同的请求先发 CORS 预检:`OPTIONS` 带 `Accept: */*`、`Access-Control-Request-Method`、按字母序以逗号连接的小写 `Access-Control-Request-Headers`(页面脚本设置的头)、原请求的 `User-Agent`、`Accept-Language`、`Accept-Encoding`、`Referer`、`Origin` 与 `Sec-Fetch-*`,以及 `Priority: u=4`,不带 Cookie。预检按方法、URL 与请求头集合缓存,有效期取响应的 `Access-Control-Max-Age`(缺省 5 秒,上限 24 小时);非 2xx 预检使原请求失败。 diff --git a/go.mod b/go.mod index 71e940e..9f4f835 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.27 require ( github.com/Mag1cFall/fhttp v0.6.8-firefox.1 - github.com/Mag1cFall/uquic v0.0.6-firefox.4 + github.com/Mag1cFall/uquic v0.0.6-firefox.6 github.com/Mag1cFall/utls v1.8.2-firefox.3 github.com/bogdanfinn/fhttp v0.6.8 github.com/bogdanfinn/tls-client v1.15.1 diff --git a/go.sum b/go.sum index fded797..1905329 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk= aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ= github.com/Mag1cFall/fhttp v0.6.8-firefox.1 h1:7AMOM2KCVwfOD0mr/CayGHlrekbcJ1QlSfxK38GHuQw= github.com/Mag1cFall/fhttp v0.6.8-firefox.1/go.mod h1:1KQKGvAc98d+20flM1QVrKuupd7hdOw2tPPZDNLmWi4= -github.com/Mag1cFall/uquic v0.0.6-firefox.4 h1:UEFL2ewKtwHEOtI8AErc5USWHCb4laHjiIewqawxEM8= -github.com/Mag1cFall/uquic v0.0.6-firefox.4/go.mod h1:JHH4tzlX5KM0a4TJSglkbL2kW1hdVBenlA0YFKD8o0E= +github.com/Mag1cFall/uquic v0.0.6-firefox.6 h1:AvAWHvAMST2ffRJb+LKTWv8+Gz0u6JfzGxcXuXxOPFI= +github.com/Mag1cFall/uquic v0.0.6-firefox.6/go.mod h1:JHH4tzlX5KM0a4TJSglkbL2kW1hdVBenlA0YFKD8o0E= github.com/Mag1cFall/utls v1.8.2-firefox.3 h1:5DGK0Ps9Kew+pd1E0d96YeirOdk4AOHJmx+NeNJwRxM= github.com/Mag1cFall/utls v1.8.2-firefox.3/go.mod h1:HFKOiYks7qa1VvvhZBWu7Ic9tTYm1xNXHKiRcpw3Mtc= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= diff --git a/internal/aistudio/transport_quic.go b/internal/aistudio/transport_quic.go index 64f57eb..914379b 100644 --- a/internal/aistudio/transport_quic.go +++ b/internal/aistudio/transport_quic.go @@ -140,12 +140,14 @@ func dialFirefoxQUIC(ctx context.Context, host string, resumption *quicResumptio return connection, settings, nil } +// firefoxQUICConfig 返回 Firefox 152 的 QUIC 连接配置:空闲超时 30 秒,与 neqo 一样只在请求流尚未完成时每半个空闲超时发送保活 PING func firefoxQUICConfig(datagram int, tokens quic.TokenStore) *quic.Config { return &quic.Config{ Versions: []quic.Version{quic.Version1}, HandshakeIdleTimeout: firefoxQUICIdleTimeout, MaxIdleTimeout: firefoxQUICIdleTimeout, - KeepAlivePeriod: 10 * time.Second, + KeepAlivePeriod: firefoxQUICIdleTimeout / 2, + KeepAliveStreamsOnly: true, InitialStreamReceiveWindow: firefoxQUICMaxStreamData, MaxStreamReceiveWindow: firefoxQUICMaxStreamData, InitialConnectionReceiveWindow: firefoxQUICMaxData, diff --git a/internal/aistudio/transport_quic_test.go b/internal/aistudio/transport_quic_test.go index 02cf2ee..fe22cd0 100644 --- a/internal/aistudio/transport_quic_test.go +++ b/internal/aistudio/transport_quic_test.go @@ -3,7 +3,6 @@ package aistudio import ( "bufio" "context" - "errors" "io" "net/http/httptest" "strings" @@ -17,6 +16,7 @@ import ( utls "github.com/Mag1cFall/utls" ) +// TestHTTP3SlowResponse 验证上游超过空闲超时才返回响应头或响应体时,请求流未完成期间的保活使连接不断开,POST 只发一次 func TestHTTP3SlowResponse(t *testing.T) { for _, flushHeaders := range []bool{false, true} { name := "headers" @@ -61,48 +61,7 @@ func TestHTTP3SlowResponse(t *testing.T) { } } -func TestHTTP3KeepAliveCancellation(t *testing.T) { - received, canceled := make(chan struct{}), make(chan struct{}) - conn := newTestHTTP3Conn(t, func(stream *quic.Stream) { - close(received) - <-stream.Context().Done() - close(canceled) - }) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - request, err := fhttp.NewRequestWithContext(ctx, "POST", "https://localhost/", strings.NewReader("test")) - if err != nil { - t.Fatal(err) - } - result := make(chan error, 1) - go func() { - _, _, err := conn.roundTrip(request, h3RequestFields(request, false)) - result <- err - }() - select { - case <-received: - case <-time.After(3 * time.Second): - t.Fatal("服务端未收到请求") - } - cancel() - select { - case err := <-result: - if !errors.Is(err, context.Canceled) { - t.Fatalf("取消请求: %v", err) - } - case <-time.After(3 * time.Second): - t.Fatal("取消请求未及时返回") - } - select { - case <-canceled: - case <-time.After(3 * time.Second): - t.Fatal("服务端请求未取消") - } - if err := conn.quic.Context().Err(); err != nil { - t.Fatalf("取消请求不应关闭共享连接: %v", err) - } -} - +// newTestHTTP3Conn 返回连到本地 QUIC 服务的 HTTP/3 连接,连接使用生产 QUIC 配置并把空闲超时改为 500 ms,handler 处理正文为 test 的请求流 func newTestHTTP3Conn(t *testing.T, handler func(*quic.Stream)) *http3Conn { t.Helper() certServer := httptest.NewTLSServer(nil)