Skip to content

Commit 4664124

Browse files
committed
fix: resolve web transport lifecycle and evaluation bugs
Unblock established H2 deadlines and EOF-delimited responses, with the H2 upload limitation documented. Drain retired H3 sessions after all TCP and UDP owners close. Preserve final Alt-Svc after informational responses and correctly maximize empirical TPR within the FPR bound. Add regression coverage for each case.
1 parent 5aa4693 commit 4664124

12 files changed

Lines changed: 1322 additions & 58 deletions

‎docs/WEB_COVER.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ HTTP/2 CONNECT presented to the public origin is always treated as cover,
2828
including when it carries an otherwise valid ticket: the handler removes
2929
`Proxy-Authorization` before delegation and never dials its authority.
3030

31+
H2 preserves a client upload half-close while the destination's reply drains.
32+
When the destination itself reaches EOF, H2 finishes that CONNECT response and
33+
stops any remaining upload on that stream; the HTTP handler interface cannot
34+
finish its response while continuing to receive an independent upload. Other
35+
streams on the same connection remain usable. Applications that need to keep
36+
uploading after receiving a destination EOF require the H3 stream transport.
37+
3138
There is no `autocar/2` ALPN or AutoCAR binary stream header on these paths.
3239
The web ALPNs are `h2`, `h3`, and `http/1.1`. Native and web transports remain
3340
separate modes and are not wire-compatible.
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package tunnel
2+
3+
import (
4+
"io"
5+
"net/http"
6+
"net/http/httptest"
7+
"net/http/httptrace"
8+
"net/textproto"
9+
"net/url"
10+
"reflect"
11+
"strings"
12+
"testing"
13+
"time"
14+
15+
"github.com/cppla/autocar/internal/cover"
16+
)
17+
18+
func TestWebAltSvcReverseProxyInformationalResponses(t *testing.T) {
19+
for _, test := range []struct {
20+
name string
21+
hints []int
22+
implicit bool
23+
}{
24+
{name: "early-hints", hints: []int{http.StatusEarlyHints}},
25+
{name: "repeated-informational", hints: []int{http.StatusEarlyHints, http.StatusProcessing, http.StatusEarlyHints}},
26+
{name: "implicit-final", hints: []int{http.StatusEarlyHints, http.StatusEarlyHints}, implicit: true},
27+
} {
28+
t.Run(test.name, func(t *testing.T) {
29+
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
30+
for _, status := range test.hints {
31+
w.Header().Set("Link", "</style.css>; rel=preload")
32+
w.Header().Set("Alt-Svc", `h3=":4444"; ma=86400`)
33+
w.WriteHeader(status)
34+
clear(w.Header())
35+
}
36+
w.Header().Set("Alt-Svc", `h3=":4444"; ma=86400`)
37+
if !test.implicit {
38+
w.WriteHeader(http.StatusOK)
39+
}
40+
_, _ = io.WriteString(w, "ordinary origin")
41+
}))
42+
defer origin.Close()
43+
originURL, err := url.Parse(origin.URL)
44+
if err != nil {
45+
t.Fatal(err)
46+
}
47+
proxy, err := cover.NewReverseProxyHandler(originURL, nil)
48+
if err != nil {
49+
t.Fatal(err)
50+
}
51+
assertWebAltSvcInformationalResponse(t, proxy, test.hints, "ordinary origin")
52+
})
53+
}
54+
}
55+
56+
func TestWebAltSvcInformationalThenImplicitFinal(t *testing.T) {
57+
for _, mode := range []string{"write-header", "write", "flush", "read-from", "return"} {
58+
t.Run(mode, func(t *testing.T) {
59+
hints := []int{http.StatusEarlyHints, http.StatusProcessing, http.StatusEarlyHints}
60+
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
61+
for _, status := range hints {
62+
w.Header().Set("Alt-Svc", `h3=":4444"`)
63+
w.WriteHeader(status)
64+
clear(w.Header())
65+
}
66+
w.Header().Set("Alt-Svc", `h3=":4444"`)
67+
switch mode {
68+
case "write-header":
69+
w.WriteHeader(http.StatusOK)
70+
case "write":
71+
_, _ = w.Write([]byte("cover body"))
72+
case "flush":
73+
w.(http.Flusher).Flush()
74+
case "read-from":
75+
_, _ = w.(io.ReaderFrom).ReadFrom(strings.NewReader("cover body"))
76+
case "return":
77+
// net/http will send the empty final 200 after we return.
78+
}
79+
})
80+
wantBody := ""
81+
if mode == "write" || mode == "read-from" {
82+
wantBody = "cover body"
83+
}
84+
assertWebAltSvcInformationalResponse(t, handler, hints, wantBody)
85+
})
86+
}
87+
}
88+
89+
func TestWebAltSvcSwitchingProtocolsCommits(t *testing.T) {
90+
response := httptest.NewRecorder()
91+
writer := &webAltSvcResponseWriter{ResponseWriter: response, value: `h3=":8443"`}
92+
writer.WriteHeader(http.StatusSwitchingProtocols)
93+
if !writer.committed {
94+
t.Fatal("101 must commit headers because it switches away from HTTP")
95+
}
96+
if got := response.Header().Get("Alt-Svc"); got != writer.value {
97+
t.Fatalf("Alt-Svc = %q, want %q", got, writer.value)
98+
}
99+
}
100+
101+
func assertWebAltSvcInformationalResponse(t *testing.T, next http.Handler, wantHints []int, wantBody string) {
102+
t.Helper()
103+
for _, protocol := range []struct {
104+
name string
105+
h2 bool
106+
}{
107+
{name: "h1"},
108+
{name: "h2", h2: true},
109+
} {
110+
t.Run(protocol.name, func(t *testing.T) {
111+
const wantAltSvc = `h3=":8443"`
112+
frontend := httptest.NewUnstartedServer(&webAltSvcCover{next: next, value: wantAltSvc})
113+
frontend.EnableHTTP2 = protocol.h2
114+
frontend.StartTLS()
115+
defer frontend.Close()
116+
client := frontend.Client()
117+
client.Timeout = 3 * time.Second
118+
var gotHints []int
119+
var gotAltSvc []string
120+
trace := &httptrace.ClientTrace{
121+
Got1xxResponse: func(status int, header textproto.MIMEHeader) error {
122+
gotHints = append(gotHints, status)
123+
gotAltSvc = append(gotAltSvc, header.Get("Alt-Svc"))
124+
return nil
125+
},
126+
}
127+
request, err := http.NewRequest(http.MethodGet, frontend.URL, nil)
128+
if err != nil {
129+
t.Fatal(err)
130+
}
131+
request = request.WithContext(httptrace.WithClientTrace(request.Context(), trace))
132+
response, err := client.Do(request)
133+
if err != nil {
134+
t.Fatal(err)
135+
}
136+
defer response.Body.Close()
137+
body, err := io.ReadAll(response.Body)
138+
if err != nil {
139+
t.Fatal(err)
140+
}
141+
wantMajor := 1
142+
if protocol.h2 {
143+
wantMajor = 2
144+
}
145+
if response.ProtoMajor != wantMajor || response.StatusCode != http.StatusOK || string(body) != wantBody {
146+
t.Fatalf("response = %s %d %q, want HTTP/%d 200 %q", response.Proto, response.StatusCode, body, wantMajor, wantBody)
147+
}
148+
if !reflect.DeepEqual(gotHints, wantHints) {
149+
t.Fatalf("informational statuses = %v, want %v", gotHints, wantHints)
150+
}
151+
for index, value := range gotAltSvc {
152+
if value != wantAltSvc {
153+
t.Errorf("informational response %d Alt-Svc = %q, want %q", index, value, wantAltSvc)
154+
}
155+
}
156+
if got := response.Header.Get("Alt-Svc"); got != wantAltSvc {
157+
t.Fatalf("final Alt-Svc = %q, want %q", got, wantAltSvc)
158+
}
159+
})
160+
}
161+
}

0 commit comments

Comments
 (0)