Repository: quic-go/quic-go
Pull request #5154 (2025-05-24 07:40:22)

http3: convert Stream from an interface to a struct
~~Depends on #5153.~~ Last part of #5152.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/quic-go/quic-go/pull/5154?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) Report
Attention: Patch coverage is `85.71429%` with `3 lines` in your changes missing coverage. Please review.
> Project coverage is 83.79%. Comparing base [(`0aedf41`)](https://app.codecov.io/gh/quic-go/quic-go/commit/0aedf41a7f4f3c4f4c7ff843ccaca59101eb4a82?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) to head [(`8bad645`)](https://app.codecov.io/gh/quic-go/quic-go/commit/8bad645c81eeea65269794fd41fd847f99bbe56b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go).

| [Files with missing lines](https://app.codecov.io/gh/quic-go/quic-go/pull/5154?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) | Patch % | Lines |
|---|---|---|
| [http3/http\_stream.go](https://app.codecov.io/gh/quic-go/quic-go/pull/5154?src=pr&el=tree&filepath=http3%2Fhttp_stream.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go#diff-aHR0cDMvaHR0cF9zdHJlYW0uZ28=) | 80.00% | [2 Missing and 1 partial :warning: ](https://app.codecov.io/gh/quic-go/quic-go/pull/5154?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) |

<details><summary>Additional details and impacted files</summary>


```diff
@@                       Coverage Diff                       @@
##           http3-request-stream-struct    #5154      +/-   ##
===============================================================
- Coverage                        83.92%   83.79%   -0.13%     
===============================================================
  Files                              153      150       -3     
  Lines                            17316    16965     -351     
===============================================================
- Hits                             14532    14215     -317     
+ Misses                            2223     2203      -20     
+ Partials                           561      547      -14     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/quic-go/quic-go/pull/5154?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/http3/body.go b/http3/body.go
index 8f17bbeb..4ca597b4 100644
--- a/http3/body.go
+++ b/http3/body.go
@@ -19,14 +19,14 @@ var errTooMuchData = errors.New("peer sent too much data")
 
 // The body is used in the requestBody (for a http.Request) and the responseBody (for a http.Response).
 type body struct {
-	str *stream
+	str *Stream
 
 	remainingContentLength int64
 	violatedContentLength  bool
 	hasContentLength       bool
 }
 
-func newBody(str *stream, contentLength int64) *body {
+func newBody(str *Stream, contentLength int64) *body {
 	b := &body{str: str}
 	if contentLength >= 0 {
 		b.hasContentLength = true
@@ -81,7 +81,7 @@ type requestBody struct {
 
 var _ io.ReadCloser = &requestBody{}
 
-func newRequestBody(str *stream, contentLength int64, connCtx context.Context, rcvdSettings <-chan struct{}, getSettings func() *Settings) *requestBody {
+func newRequestBody(str *Stream, contentLength int64, connCtx context.Context, rcvdSettings <-chan struct{}, getSettings func() *Settings) *requestBody {
 	return &requestBody{
 		body:         *newBody(str, contentLength),
 		connCtx:      connCtx,
@@ -102,7 +102,7 @@ type hijackableBody struct {
 
 var _ io.ReadCloser = &hijackableBody{}
 
-func newResponseBody(str *stream, contentLength int64, done chan<- struct{}) *hijackableBody {
+func newResponseBody(str *Stream, contentLength int64, done chan<- struct{}) *hijackableBody {
 	return &hijackableBody{
 		body:    *newBody(str, contentLength),
 		reqDone: done,
diff --git a/http3/client.go b/http3/client.go
index 7c2c4192..019c4d4f 100644
--- a/http3/client.go
+++ b/http3/client.go
@@ -265,7 +265,7 @@ func (c *ClientConn) roundTrip(req *http.Request) (*http.Response, error) {
 // It cancels writing on the stream if any error other than io.EOF occurs.
 type cancelingReader struct {
 	r   io.Reader
-	str Stream
+	str *RequestStream
 }
 
 func (r *cancelingReader) Read(b []byte) (int, error) {
@@ -276,7 +276,7 @@ func (r *cancelingReader) Read(b []byte) (int, error) {
 	return n, err
 }
 
-func (c *ClientConn) sendRequestBody(str Stream, body io.ReadCloser, contentLength int64) error {
+func (c *ClientConn) sendRequestBody(str *RequestStream, body io.ReadCloser, contentLength int64) error {
 	defer body.Close()
 	buf := make([]byte, bodyCopyBufferSize)
 	sr := &cancelingReader{str: str, r: body}
diff --git a/http3/http_stream.go b/http3/http_stream.go
index 93d398ae..46fb1c47 100644
--- a/http3/http_stream.go
+++ b/http3/http_stream.go
@@ -16,15 +16,7 @@ import (
 
 // A Stream is an HTTP/3 request stream.
 // When writing to and reading from the stream, data is framed in HTTP/3 DATA frames.
-type Stream interface {
-	quic.Stream
-
-	SendDatagram([]byte) error
-	ReceiveDatagram(context.Context) ([]byte, error)
-}
-
-// When writing to and reading from the stream, data is framed in HTTP/3 DATA frames.
-type stream struct {
+type Stream struct {
 	quic.Stream
 	conn *connection
 
@@ -38,10 +30,8 @@ type stream struct {
 	parsedTrailer bool
 }
 
-var _ Stream = &stream{}
-
-func newStream(str quic.Stream, conn *connection, datagrams *datagrammer, parseTrailer func(io.Reader, uint64) error) *stream {
-	return &stream{
+func newStream(str quic.Stream, conn *connection, datagrams *datagrammer, parseTrailer func(io.Reader, uint64) error) *Stream {
+	return &Stream{
 		Stream:       str,
 		conn:         conn,
 		buf:          make([]byte, 16),
@@ -50,7 +40,7 @@ func newStream(str quic.Stream, conn *connection, datagrams *datagrammer, parseT
 	}
 }
 
-func (s *stream) Read(b []byte) (int, error) {
+func (s *Stream) Read(b []byte) (int, error) {
 	fp := &frameParser{
 		r:    s.Stream,
 		conn: s.conn,
@@ -98,11 +88,11 @@ func (s *stream) Read(b []byte) (int, error) {
 	return n, err
 }
 
-func (s *stream) hasMoreData() bool {
+func (s *Stream) hasMoreData() bool {
 	return s.bytesRemainingInFrame > 0
 }
 
-func (s *stream) Write(b []byte) (int, error) {
+func (s *Stream) Write(b []byte) (int, error) {
 	s.buf = s.buf[:0]
 	s.buf = (&dataFrame{Length: uint64(len(b))}).Append(s.buf)
 	if _, err := s.Stream.Write(s.buf); err != nil {
@@ -111,11 +101,11 @@ func (s *stream) Write(b []byte) (int, error) {
 	return s.Stream.Write(b)
 }
 
-func (s *stream) writeUnframed(b []byte) (int, error) {
+func (s *Stream) writeUnframed(b []byte) (int, error) {
 	return s.Stream.Write(b)
 }
 
-func (s *stream) StreamID() protocol.StreamID {
+func (s *Stream) StreamID() protocol.StreamID {
 	return s.Stream.StreamID()
 }
 
@@ -124,7 +114,7 @@ func (s *stream) StreamID() protocol.StreamID {
 // the application to optimistically use the stream (and, for example, send datagrams)
 // before receiving the response.
 type RequestStream struct {
-	*stream
+	*Stream
 
 	responseBody io.ReadCloser // set by ReadResponse
 
@@ -143,7 +133,7 @@ type RequestStream struct {
 }
 
 func newRequestStream(
-	str *stream,
+	str *Stream,
 	requestWriter *requestWriter,
 	reqDone chan<- struct{},
 	decoder *qpack.Decoder,
@@ -153,7 +143,7 @@ func newRequestStream(
 	trace *httptrace.ClientTrace,
 ) *RequestStream {
 	return &RequestStream{
-		stream:             str,
+		Stream:             str,
 		requestWriter:      requestWriter,
 		reqDone:            reqDone,
 		decoder:            decoder,
@@ -184,7 +174,7 @@ func (s *RequestStream) SendRequestHeader(req *http.Request) error {
 	}
 	s.isConnect = req.Method == http.MethodConnect
 	s.sentRequest = true
-	return s.requestWriter.WriteRequestHeader(s.Stream, req, s.requestedGzip)
+	return s.requestWriter.WriteRequestHeader(s.Stream.Stream, req, s.requestedGzip)
 }
 
 // ReadResponse reads the HTTP response from the stream.
@@ -192,10 +182,11 @@ func (s *RequestStream) SendRequestHeader(req *http.Request) error {
 // It doesn't set Response.Request and Response.TLS.
 // It is invalid to call it after Read has been called.
 func (s *RequestStream) ReadResponse() (*http.Response, error) {
+	qstr := s.Stream.Stream
 	fp := &frameParser{
 		conn: s.conn,
 		r: &tracingReader{
-			Reader: s.Stream,
+			Reader: qstr,
 			first:  &s.firstByte,
 			trace:  s.trace,
 		},
@@ -217,7 +208,7 @@ func (s *RequestStream) ReadResponse() (*http.Response, error) {
 		return nil, fmt.Errorf("http3: HEADERS frame too large: %d bytes (max: %d)", hf.Length, s.maxHeaderBytes)
 	}
 	headerBlock := make([]byte, hf.Length)
-	if _, err := io.ReadFull(s.Stream, headerBlock); err != nil {
+	if _, err := io.ReadFull(qstr, headerBlock); err != nil {
 		s.CancelRead(quic.StreamErrorCode(ErrCodeRequestIncomplete))
 		s.CancelWrite(quic.StreamErrorCode(ErrCodeRequestIncomplete))
 		return nil, fmt.Errorf("http3: failed to read response headers: %w", err)
@@ -237,7 +228,7 @@ func (s *RequestStream) ReadResponse() (*http.Response, error) {
 
 	// Check that the server doesn't send more data in DATA frames than indicated by the Content-Length header (if set).
 	// See section 4.1.2 of RFC 9114.
-	respBody := newResponseBody(s.stream, res.ContentLength, s.reqDone)
+	respBody := newResponseBody(s.Stream, res.ContentLength, s.reqDone)
 
 	// Rules for when to set Content-Length are defined in https://tools.ietf.org/html/rfc7230#section-3.3.2.
 	isInformational := res.StatusCode >= 100 && res.StatusCode < 200
@@ -259,12 +250,12 @@ func (s *RequestStream) ReadResponse() (*http.Response, error) {
 	return res, nil
 }
 
-func (s *stream) SendDatagram(b []byte) error {
+func (s *Stream) SendDatagram(b []byte) error {
 	// TODO: reject if datagrams are not negotiated (yet)
 	return s.datagrams.Send(b)
 }
 
-func (s *stream) ReceiveDatagram(ctx context.Context) ([]byte, error) {
+func (s *Stream) ReceiveDatagram(ctx context.Context) ([]byte, error) {
 	// TODO: reject if datagrams are not negotiated (yet)
 	return s.datagrams.Receive(ctx)
 }
diff --git a/http3/response_writer.go b/http3/response_writer.go
index bcfc85c2..61d5b0a1 100644
--- a/http3/response_writer.go
+++ b/http3/response_writer.go
@@ -17,7 +17,7 @@ import (
 // The HTTPStreamer allows taking over a HTTP/3 stream. The interface is implemented by the http.ResponseWriter.
 // When a stream is taken over, it's the caller's responsibility to close the stream.
 type HTTPStreamer interface {
-	HTTPStream() Stream
+	HTTPStream() *Stream
 }
 
 // The maximum length of an encoded HTTP/3 frame header is 16:
@@ -27,7 +27,7 @@ const frameHeaderLen = 16
 const maxSmallResponseSize = 4096
 
 type responseWriter struct {
-	str *stream
+	str *Stream
 
 	conn     Connection
 	header   http.Header
@@ -65,7 +65,7 @@ var (
 	} = &responseWriter{}
 )
 
-func newResponseWriter(str *stream, conn Connection, isHead bool, logger *slog.Logger) *responseWriter {
+func newResponseWriter(str *Stream, conn Connection, isHead bool, logger *slog.Logger) *responseWriter {
 	return &responseWriter{
 		str:    str,
 		conn:   conn,
@@ -331,7 +331,7 @@ func (w *responseWriter) writeTrailers() error {
 	return err
 }
 
-func (w *responseWriter) HTTPStream() Stream {
+func (w *responseWriter) HTTPStream() *Stream {
 	w.hijacked = true
 	w.Flush()
 	return w.str


Test patch:
diff --git a/http3/body_test.go b/http3/body_test.go
index 11cecc33..c96e34a1 100644
--- a/http3/body_test.go
+++ b/http3/body_test.go
@@ -21,7 +21,7 @@ func TestResponseBodyReading(t *testing.T) {
 	str := mockquic.NewMockStream(mockCtrl)
 	str.EXPECT().Read(gomock.Any()).DoAndReturn(buf.Read).AnyTimes()
 	reqDone := make(chan struct{})
-	rb := newResponseBody(&stream{Stream: str}, -1, reqDone)
+	rb := newResponseBody(&Stream{Stream: str}, -1, reqDone)
 
 	data, err := io.ReadAll(rb)
 	require.NoError(t, err)
@@ -33,7 +33,7 @@ func TestResponseBodyReadError(t *testing.T) {
 	str := mockquic.NewMockStream(mockCtrl)
 	str.EXPECT().Read(gomock.Any()).Return(0, assert.AnError).Times(2)
 	reqDone := make(chan struct{})
-	rb := newResponseBody(&stream{Stream: str}, -1, reqDone)
+	rb := newResponseBody(&Stream{Stream: str}, -1, reqDone)
 
 	_, err := rb.Read([]byte{0})
 	require.ErrorIs(t, err, assert.AnError)
@@ -52,7 +52,7 @@ func TestResponseBodyClose(t *testing.T) {
 	str := mockquic.NewMockStream(mockCtrl)
 	str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeRequestCanceled)).Times(2)
 	reqDone := make(chan struct{})
-	rb := newResponseBody(&stream{Stream: str}, -1, reqDone)
+	rb := newResponseBody(&Stream{Stream: str}, -1, reqDone)
 	require.NoError(t, rb.Close())
 	select {
 	case <-reqDone:
@@ -69,7 +69,7 @@ func TestResponseBodyConcurrentClose(t *testing.T) {
 	str := mockquic.NewMockStream(mockCtrl)
 	str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeRequestCanceled)).MaxTimes(3)
 	reqDone := make(chan struct{})
-	rb := newResponseBody(&stream{Stream: str}, -1, reqDone)
+	rb := newResponseBody(&Stream{Stream: str}, -1, reqDone)
 
 	for range 3 {
 		go rb.Close()
@@ -105,7 +105,7 @@ func testResponseBodyLengthLimiting(t *testing.T, alongFrameBoundary bool) {
 	str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeMessageError))
 	str.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeMessageError))
 	str.EXPECT().Read(gomock.Any()).DoAndReturn(buf.Read).AnyTimes()
-	rb := newResponseBody(&stream{Stream: str}, l, make(chan struct{}))
+	rb := newResponseBody(&Stream{Stream: str}, l, make(chan struct{}))
 	data, err := io.ReadAll(rb)
 	require.Equal(t, []byte("foobar")[:l], data)
 	require.ErrorIs(t, err, errTooMuchData)