Skip to content

Commit 818aad7

Browse files
committed
internal/http3: add server to client trailer header support
This change implements support for Server to send trailer headers, and for ClientConn to receive said trailer headers. This is just like go.dev/cl/743600, but in the opposite direction. The bulk of the implementation relies on the trailer header encoding and decoding support that was added to bodyWriter and bodyReader respectively in go.dev/cl/743600. For golang/go#70914 Change-Id: I0efded4b1ac3e3c6b9479f18402e02e9e764d4a2 Reviewed-on: https://go-review.googlesource.com/c/net/+/744220 LUCI-TryBot-Result: Go LUCI <[email protected]> Reviewed-by: Nicholas Husin <[email protected]> Reviewed-by: Damien Neil <[email protected]>
1 parent c1bbe1a commit 818aad7

File tree

6 files changed

+220
-3
lines changed

6 files changed

+220
-3
lines changed

internal/http3/body.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,29 @@ import (
1010
"io"
1111
"net"
1212
"net/http"
13+
"net/textproto"
14+
"strings"
1315
"sync"
1416

1517
"golang.org/x/net/http/httpguts"
1618
)
1719

20+
// extractTrailerFromHeader extracts the "Trailer" header values from a header
21+
// map, and populates a trailer map with those values as keys. The extracted
22+
// header values will be canonicalized.
23+
func extractTrailerFromHeader(header, trailer http.Header) {
24+
for _, names := range header["Trailer"] {
25+
names = textproto.TrimString(names)
26+
for name := range strings.SplitSeq(names, ",") {
27+
name = textproto.CanonicalMIMEHeaderKey(textproto.TrimString(name))
28+
if !httpguts.ValidTrailerHeader(name) {
29+
continue
30+
}
31+
trailer[name] = nil
32+
}
33+
}
34+
}
35+
1836
// A bodyWriter writes a request or response body to a stream
1937
// as a series of DATA frames.
2038
type bodyWriter struct {

internal/http3/roundtrip.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,16 @@ func (cc *ClientConn) RoundTrip(req *http.Request) (_ *http.Response, err error)
154154
if err != nil {
155155
return nil, err
156156
}
157-
if contentLength != 0 && req.Method != http.MethodHead {
157+
158+
trailer := make(http.Header)
159+
extractTrailerFromHeader(h, trailer)
160+
delete(h, "Trailer")
161+
162+
if (contentLength != 0 && req.Method != http.MethodHead) || len(trailer) > 0 {
158163
rt.respBody = &bodyReader{
159-
st: st,
160-
remain: contentLength,
164+
st: st,
165+
remain: contentLength,
166+
trailer: trailer,
161167
}
162168
} else {
163169
rt.respBody = http.NoBody
@@ -169,6 +175,7 @@ func (cc *ClientConn) RoundTrip(req *http.Request) (_ *http.Response, err error)
169175
StatusCode: statusCode,
170176
Status: strconv.Itoa(statusCode) + " " + http.StatusText(statusCode),
171177
ContentLength: contentLength,
178+
Trailer: trailer,
172179
Body: (*transportResponseBody)(rt),
173180
}
174181
// TODO: Automatic Content-Type: gzip decoding.

internal/http3/roundtrip_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,3 +546,90 @@ func TestRoundTripWriteTrailerNoBody(t *testing.T) {
546546
st.wantClosed("request is complete")
547547
})
548548
}
549+
550+
func TestRoundTripReadTrailer(t *testing.T) {
551+
synctest.Test(t, func(t *testing.T) {
552+
tc := newTestClientConn(t)
553+
tc.greet()
554+
555+
var req *http.Request
556+
req, _ = http.NewRequest("GET", "https://example.tld/", nil)
557+
rt := tc.roundTrip(req)
558+
st := tc.wantStream(streamTypeRequest)
559+
560+
st.wantHeaders(nil)
561+
st.writeHeaders(http.Header{
562+
":status": {"200"},
563+
"Trailer": {"Server-Trailer-A, Server-Trailer-B", "server-trailer-c"}, // Should be canonicalized.
564+
})
565+
body := []byte("body from server")
566+
st.writeData(body)
567+
st.writeHeaders(http.Header{
568+
"Server-Trailer-A": {"valuea"},
569+
// Note that Server-Trailer-B is skipped.
570+
"Server-Trailer-C": {"valuec"},
571+
"Undeclared-Trailer": {"undeclared"}, // Should be ignored.
572+
})
573+
574+
rt.wantStatus(200)
575+
// Trailer is stripped off from http.Response.Header and given in http.Response.Trailer.
576+
rt.wantHeaders(http.Header{})
577+
rt.wantTrailers(http.Header{
578+
"Server-Trailer-A": nil,
579+
"Server-Trailer-B": nil,
580+
"Server-Trailer-C": nil,
581+
})
582+
583+
// Trailer updated after reading the body to EOF.
584+
rt.wantBody(body)
585+
rt.wantTrailers(http.Header{
586+
"Server-Trailer-A": {"valuea"},
587+
"Server-Trailer-B": nil,
588+
"Server-Trailer-C": {"valuec"},
589+
})
590+
st.wantClosed("request is complete")
591+
})
592+
}
593+
594+
func TestRoundTripReadTrailerNoBody(t *testing.T) {
595+
synctest.Test(t, func(t *testing.T) {
596+
tc := newTestClientConn(t)
597+
tc.greet()
598+
599+
var req *http.Request
600+
req, _ = http.NewRequest("GET", "https://example.tld/", nil)
601+
rt := tc.roundTrip(req)
602+
st := tc.wantStream(streamTypeRequest)
603+
604+
st.wantHeaders(nil)
605+
st.writeHeaders(http.Header{
606+
":status": {"200"},
607+
"Content-Length": {"0"},
608+
"Trailer": {"Server-Trailer-A, Server-Trailer-B", "server-trailer-c"}, // Should be canonicalized.
609+
})
610+
st.writeHeaders(http.Header{
611+
"Server-Trailer-A": {"valuea"},
612+
// Note that Server-Trailer-B is skipped.
613+
"Server-Trailer-C": {"valuec"},
614+
"Undeclared-Trailer": {"undeclared"}, // Should be ignored.
615+
})
616+
617+
rt.wantStatus(200)
618+
// Trailer is stripped off from http.Response.Header and given in http.Response.Trailer.
619+
rt.wantHeaders(http.Header{"Content-Length": {"0"}})
620+
rt.wantTrailers(http.Header{
621+
"Server-Trailer-A": nil,
622+
"Server-Trailer-B": nil,
623+
"Server-Trailer-C": nil,
624+
})
625+
626+
// Trailer updated after reading the empty body to EOF.
627+
rt.wantBody(make([]byte, 0))
628+
rt.wantTrailers(http.Header{
629+
"Server-Trailer-A": {"valuea"},
630+
"Server-Trailer-B": nil,
631+
"Server-Trailer-C": {"valuec"},
632+
})
633+
st.wantClosed("request is complete")
634+
})
635+
}

internal/http3/server.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ package http3
77
import (
88
"context"
99
"io"
10+
"maps"
1011
"net/http"
12+
"slices"
1113
"strconv"
14+
"strings"
1215
"sync"
1316

1417
"golang.org/x/net/http/httpguts"
@@ -252,12 +255,14 @@ func (sc *serverConn) handleRequestStream(st *stream) error {
252255
rw := &responseWriter{
253256
st: st,
254257
headers: make(http.Header),
258+
trailer: make(http.Header),
255259
isHeadResp: req.Method == "HEAD",
256260
bw: &bodyWriter{
257261
st: st,
258262
remain: -1,
259263
flush: false,
260264
name: "response",
265+
enc: &sc.enc,
261266
},
262267
}
263268
defer rw.close()
@@ -290,6 +295,7 @@ type responseWriter struct {
290295
bw *bodyWriter
291296
mu sync.Mutex
292297
headers http.Header
298+
trailer http.Header
293299
wroteHeader bool // Non-1xx header has been (logically) written.
294300
isHeadResp bool // response is for a HEAD request.
295301
}
@@ -298,12 +304,37 @@ func (rw *responseWriter) Header() http.Header {
298304
return rw.headers
299305
}
300306

307+
// prepareTrailerForWriteLocked populates any pre-declared trailer header with
308+
// its value, and passes it to bodyWriter so it can be written after body EOF.
309+
// Caller must hold rw.mu.
310+
func (rw *responseWriter) prepareTrailerForWriteLocked() {
311+
for name := range rw.trailer {
312+
if val, ok := rw.headers[name]; ok {
313+
rw.trailer[name] = val
314+
} else {
315+
delete(rw.trailer, name)
316+
}
317+
}
318+
if len(rw.trailer) > 0 {
319+
rw.bw.trailer = rw.trailer
320+
}
321+
}
322+
301323
// Caller must hold rw.mu. If rw.wroteHeader is true, calling this method is a
302324
// no-op.
303325
func (rw *responseWriter) writeHeaderLockedOnce(statusCode int) {
304326
if rw.wroteHeader {
305327
return
306328
}
329+
330+
// If there is any Trailer declared in headers, save them so we know which
331+
// trailers have been pre-declared. Also, write back the extracted value,
332+
// which is canonicalized, to rw.Header for consistency.
333+
if _, ok := rw.headers["Trailer"]; ok {
334+
extractTrailerFromHeader(rw.headers, rw.trailer)
335+
rw.headers.Set("Trailer", strings.Join(slices.Sorted(maps.Keys(rw.trailer)), ", "))
336+
}
337+
307338
enc := &qpackEncoder{}
308339
enc.init()
309340
encHeaders := enc.encode(func(f func(itype indexType, name, value string)) {
@@ -356,5 +387,10 @@ func (rw *responseWriter) close() error {
356387
rw.mu.Lock()
357388
defer rw.mu.Unlock()
358389
rw.writeHeaderLockedOnce(http.StatusOK)
390+
rw.prepareTrailerForWriteLocked()
391+
392+
if err := rw.bw.Close(); err != nil {
393+
return err
394+
}
359395
return rw.st.stream.Close()
360396
}

internal/http3/server_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,68 @@ func TestServerHandlerReadTrailerNoBody(t *testing.T) {
489489
})
490490
}
491491

492+
func TestServerHandlerWriteTrailer(t *testing.T) {
493+
synctest.Test(t, func(t *testing.T) {
494+
body := []byte("some body")
495+
ts := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
496+
w.Header().Set("Trailer", "server-trailer-a, server-trailer-b") // Trailer header will be canonicalized.
497+
w.Header().Add("Trailer", "Server-Trailer-C")
498+
499+
w.Write(body)
500+
501+
w.Header().Set("server-trailer-a", "valuea") // Trailer header will be canonicalized.
502+
w.Header().Set("Server-Trailer-C", "valuec") // skipping B
503+
w.Header().Set("Server-Trailer-Not-Declared", "should be omitted")
504+
}))
505+
tc := ts.connect()
506+
tc.greet()
507+
508+
reqStream := tc.newStream(streamTypeRequest)
509+
reqStream.writeHeaders(requestHeader(nil))
510+
synctest.Wait()
511+
reqStream.wantHeaders(http.Header{
512+
":status": {"200"},
513+
"Trailer": {"Server-Trailer-A, Server-Trailer-B, Server-Trailer-C"},
514+
})
515+
reqStream.wantData(body)
516+
reqStream.wantHeaders(http.Header{
517+
"Server-Trailer-A": {"valuea"},
518+
"Server-Trailer-C": {"valuec"},
519+
})
520+
reqStream.wantClosed("request is complete")
521+
})
522+
}
523+
524+
func TestServerHandlerWriteTrailerNoBody(t *testing.T) {
525+
synctest.Test(t, func(t *testing.T) {
526+
ts := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
527+
w.Header().Set("Trailer", "server-trailer-a, server-trailer-b") // Trailer header will be canonicalized.
528+
w.Header().Add("Trailer", "Server-Trailer-C")
529+
530+
w.(http.Flusher).Flush()
531+
532+
w.Header().Set("server-trailer-a", "valuea") // Trailer header will be canonicalized.
533+
w.Header().Set("Server-Trailer-C", "valuec") // skipping B
534+
w.Header().Set("Server-Trailer-Not-Declared", "should be omitted")
535+
}))
536+
tc := ts.connect()
537+
tc.greet()
538+
539+
reqStream := tc.newStream(streamTypeRequest)
540+
reqStream.writeHeaders(requestHeader(nil))
541+
synctest.Wait()
542+
reqStream.wantHeaders(http.Header{
543+
":status": {"200"},
544+
"Trailer": {"Server-Trailer-A, Server-Trailer-B, Server-Trailer-C"},
545+
})
546+
reqStream.wantHeaders(http.Header{
547+
"Server-Trailer-A": {"valuea"},
548+
"Server-Trailer-C": {"valuec"},
549+
})
550+
reqStream.wantClosed("request is complete")
551+
})
552+
}
553+
492554
type testServer struct {
493555
t testing.TB
494556
s *Server

internal/http3/transport_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,13 @@ func (rt *testRoundTrip) wantHeaders(want http.Header) {
452452
}
453453
}
454454

455+
func (rt *testRoundTrip) wantTrailers(want http.Header) {
456+
rt.t.Helper()
457+
if diff := diffHeaders(rt.response().Trailer, want); diff != "" {
458+
rt.t.Fatalf("unexpected response trailers:\n%v", diff)
459+
}
460+
}
461+
455462
// readBody reads the contents of the response body.
456463
func (rt *testRoundTrip) readBody() ([]byte, error) {
457464
t := rt.t

0 commit comments

Comments
 (0)