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
9 changes: 9 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,15 @@ connection and wake waiters to select a replacement. Reconnects and the new H2
connection selected after GOAWAY run the full bootstrap again. QUIC migration or
NAT rebinding that remains the same `*quic.Conn` retains authentication state.

The H2 client queues at a healthy peer's stream-concurrency limit instead of
opening another physical connection just because the existing one is full.
On Go 1.27, the pinned HTTP/2 wrapper exposes immediately available capacity,
so its live, non-closing state is a conservative queue hint, not proof of usability.
If the underlying transport rejects a ready session before any request header
is encoded, a live caller may reselect once within the same establishment budget.
Encoded requests are never replayed, and accepted sibling streams may drain on
the old connection. Transport state inspection never holds the client owner lock.

Stopping an opening cancellation watcher is not the connection handoff. H2
and H3 recheck the caller's cancellation and deadline under their final
ownership lock before publishing a TCP stream. Canceling an already handed-off
Expand Down
22 changes: 22 additions & 0 deletions internal/tunnel/web_h2_capacity_go127.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//go:build go1.27 && !http2legacy

package tunnel

import "golang.org/x/net/http2"

// x/net v0.58's Go1.27 wrapper reports immediately available slots, not whether
// its strict-concurrency RoundTrip can queue. State uses memory locks in this
// implementation. Call outside the owner mutex (as on the legacy path).
func webH2CanTakeRequest(conn *http2.ClientConn) bool {
if conn.CanTakeNewRequest() {
return true
}
state := conn.State()
// This is a conservative queue attempt, not an exact native usability
// test: active GOAWAY/PROTOCOL_ERROR retirement is not fully exposed by
// the wrapper. RoundTrip remains authoritative; only an entirely unencoded
// rejection with a live context may reselect once. Do not compare capacity
// counts: the wrapper synthesizes them from separate native snapshots and
// can report mismatched values while a stream finishes or starts.
return !state.Closed && !state.Closing
}
11 changes: 11 additions & 0 deletions internal/tunnel/web_h2_capacity_legacy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//go:build !(go1.27 && !http2legacy)

package tunnel

import "golang.org/x/net/http2"

// The legacy transport's strict-concurrency check already allows queuing and
// excludes draining connections without taking its wire-write mutex.
func webH2CanTakeRequest(conn *http2.ClientConn) bool {
return conn.CanTakeNewRequest()
}
116 changes: 116 additions & 0 deletions internal/tunnel/web_h2_capacity_release_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package tunnel

import (
"bytes"
"context"
"crypto/tls"
"testing"
"time"
)

// A live caller queued by the real peer's one-stream limit must receive the
// released slot on the original authenticated connection, not merely cancel
// cleanly while queued. This reuses the bounded owned-wire fixture unchanged.
func TestWebH2CapacityReleaseResumesQueuedAuthenticatedStream(t *testing.T) {
f := newWebH2CapacityFixture(t)
first := f.open()
f.ack(first, []byte("hold first H2 capacity slot\x00\xff"))
firstRequest := f.request(0)
f.client.mu.Lock()
session := f.client.current
ready := session != nil && session.authState == webH2ClientAuthReady && session.auth != nil
var authentication *webSessionClientAuth
if ready {
authentication = session.auth
}
f.client.mu.Unlock()
if !ready {
t.Fatal("first stream did not establish ready physical authentication")
}
state := session.conn.ConnectionState()
if state.Version != tls.VersionTLS13 || state.NegotiatedProtocol != webH2ALPN || firstRequest.short || firstRequest.physical == nil {
t.Fatalf("first stream TLS/ALPN/bootstrap=%#x/%q/%v", state.Version, state.NegotiatedProtocol, firstRequest.short)
}
observer := f.observe(session.h2)
f.checkpoint(observer, false)

caller, cancel := context.WithCancel(f.ctx)
defer cancel()
result := make(chan webH2CapacityDialResult, 1)
joined := make(chan struct{})
f.register(joined)
go func() {
defer close(joined)
conn, err := f.client.DialContext(caller, "tcp", f.target.Addr().String())
f.own(conn)
result <- webH2CapacityDialResult{conn: conn, err: err}
}()
// This requires an actual request waiting for peer capacity. If an old
// transport replacement is observed, retain the failure but continue the
// real release/echo/independent-cleanup controls rather than abandon them.
f.checkpoint(observer, true)
if got := f.physicalDials.Load(); got != 1 {
t.Errorf("live queued caller caused %d physical dials before release, want 1", got)
}
if got := f.entropy.nonceReads.Load(); got != 1 {
t.Errorf("live queued caller generated %d full-auth nonces before release, want 1", got)
}
if got := f.requestCount(); got != 1 {
t.Errorf("server handlers before release=%d, want only held first stream", got)
}
select {
case <-joined:
t.Error("second public DialContext finished before actual first stream capacity release")
default:
}
if err := caller.Err(); err != nil {
t.Fatalf("queued caller expired before capacity release: %v", err)
}
f.ack(first, []byte("first stream remains usable with a live waiter"))
f.finish(first, nil)
f.require(firstRequest.done, "first handler joined after actual FIN/capacity release")
f.targetResult()

f.require(joined, "live queued caller resumed after first stream release")
second := <-result // Result publication precedes the joined channel close.
if second.err != nil || second.conn == nil {
t.Fatalf("released queued public DialContext: %v", second.err)
}
if err := second.conn.SetDeadline(time.Now().Add(2 * time.Second)); err != nil {
t.Fatal(err)
}
secondRequest := f.request(1)
// Establishment cancellation is detached only after successful handoff.
// Canceling now must not interrupt the complete payload or either FIN.
cancel()
payload := bytes.Repeat([]byte{'q', 'u', 'e', 'u', 'e', 'd', 0, 255}, 8192)
f.finish(second.conn, payload)
f.targetResult()
f.require(secondRequest.done, "resumed second handler completed after full FIN echo")
f.sameSession(session)
f.client.mu.Lock()
sameAuth := f.client.current == session && session.auth == authentication
f.client.mu.Unlock()
if !sameAuth {
t.Error("capacity release replaced original physical session or authentication key")
}
requests := f.requestsSnapshot()
if len(requests) != 2 {
t.Errorf("server requests=%d, want full bootstrap then one short continuation", len(requests))
} else if requests[0].physical != requests[1].physical || requests[0].short || !requests[1].short {
t.Error("queued continuation did not preserve actual same-physical full/short authentication")
}
if got := f.physicalDials.Load(); got != 1 {
t.Errorf("physical dials=%d after queued payload, want 1", got)
}
if got := f.entropy.nonceReads.Load(); got != 1 {
t.Errorf("full-auth nonces=%d after queued payload, want 1", got)
}
if got := f.destinationDials.Load(); got != 2 {
t.Errorf("actual target dials=%d, want 2", got)
}
if len(f.admission.sem) != 0 {
t.Error("joined capacity-release handlers retained stream admission")
}
t.Log("real capacity=1 pending caller resumed on the original TLS13/h2 session; full/short auth, 64 KiB FIN echo, detached caller cancellation, and owned worker joins")
}
Loading
Loading