Repository: quic-go/quic-go
Pull request #5096 (2025-04-29 13:10:23)

http3: migrate the client tests away from Ginkgo
Part of #3652.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/quic-go/quic-go/pull/5096?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 84.01%. Comparing base [(`41cc018`)](https://app.codecov.io/gh/quic-go/quic-go/commit/41cc01890da08993bb681d1e73f5701bd53d94cf?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) to head [(`59192a5`)](https://app.codecov.io/gh/quic-go/quic-go/commit/59192a53e8995ca81d5b9489c88ea13987c69f43?dropdown=coverage&el=desc&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             @@
##           master    #5096      +/-   ##
==========================================
- Coverage   84.12%   84.01%   -0.11%     
==========================================
  Files         153      150       -3     
  Lines       16998    16653     -345     
==========================================
- Hits        14299    13990     -309     
+ Misses       2165     2144      -21     
+ Partials      534      519      -15     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/quic-go/quic-go/pull/5096?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/client.go b/http3/client.go
index 91f43e41..1438cfe2 100644
--- a/http3/client.go
+++ b/http3/client.go
@@ -32,6 +32,8 @@ const (
 	defaultMaxResponseHeaderBytes = 10 * 1 << 20 // 10 MB
 )
 
+const max1xxResponses = 5 // arbitrary bound on number of informational responses
+
 var defaultQuicConfig = &quic.Config{
 	MaxIncomingStreams: -1, // don't allow the server to create bidirectional streams
 	KeepAlivePeriod:    10 * time.Second,
@@ -323,9 +325,7 @@ func (c *ClientConn) doRequest(req *http.Request, str *requestStream) (*http.Res
 	}
 
 	// copy from net/http: support 1xx responses
-	num1xx := 0               // number of informational 1xx headers received
-	const max1xxResponses = 5 // arbitrary bound on number of informational responses
-
+	var num1xx int // number of informational 1xx headers received
 	var res *http.Response
 	for {
 		var err error
@@ -340,10 +340,10 @@ func (c *ClientConn) doRequest(req *http.Request, str *requestStream) (*http.Res
 		if is1xxNonTerminal {
 			num1xx++
 			if num1xx > max1xxResponses {
-				return nil, errors.New("http: too many 1xx informational responses")
+				return nil, errors.New("http3: too many 1xx informational responses")
 			}
 			traceGot1xxResponse(trace, resCode, textproto.MIMEHeader(res.Header))
-			if resCode == 100 {
+			if resCode == http.StatusContinue {
 				traceGot100Continue(trace)
 			}
 			continue


Test patch:
diff --git a/http3/client_test.go b/http3/client_test.go
index 035ca14d..e0e92a6e 100644
--- a/http3/client_test.go
+++ b/http3/client_test.go
@@ -7,1124 +7,739 @@ import (
 	"errors"
 	"io"
 	"net/http"
-	"net/http/httptrace"
-	"net/textproto"
-	"sync"
+	"testing"
 	"time"
 
+	"github.com/quic-go/qpack"
 	"github.com/quic-go/quic-go"
 	mockquic "github.com/quic-go/quic-go/internal/mocks/quic"
 	"github.com/quic-go/quic-go/quicvarint"
 
-	"github.com/quic-go/qpack"
-
-	. "github.com/onsi/ginkgo/v2"
-	. "github.com/onsi/gomega"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 	"go.uber.org/mock/gomock"
 )
 
-func encodeResponse(status int) []byte {
+func TestClientSettings(t *testing.T) {
+	t.Run("enable datagrams", func(t *testing.T) {
+		testClientSettings(t, true, nil)
+	})
+	t.Run("additional settings", func(t *testing.T) {
+		testClientSettings(t, false, map[uint64]uint64{13: 37})
+	})
+}
+
+func testClientSettings(t *testing.T, enableDatagrams bool, other map[uint64]uint64) {
+	tr := &Transport{
+		EnableDatagrams:    enableDatagrams,
+		AdditionalSettings: other,
+	}
+
+	mockCtrl := gomock.NewController(t)
+	conn := mockquic.NewMockEarlyConnection(mockCtrl)
+	conn.EXPECT().Context().Return(context.Background()).AnyTimes()
+	done := make(chan struct{})
+	conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
+		<-done
+		return nil, assert.AnError
+	}).MaxTimes(1)
+	controlStr := mockquic.NewMockStream(mockCtrl)
+	var buf bytes.Buffer
+	controlStr.EXPECT().Write(gomock.Any()).DoAndReturn(func(b []byte) (int, error) {
+		buf.Write(b)
+		close(done)
+		return len(b), nil
+	})
+	conn.EXPECT().OpenUniStream().Return(controlStr, nil)
+
+	tr.NewClientConn(conn)
+
+	select {
+	case <-done:
+		b := buf.Bytes()
+		typ, l, err := quicvarint.Parse(b)
+		require.NoError(t, err)
+		require.EqualValues(t, streamTypeControlStream, typ)
+		fp := (&frameParser{r: bytes.NewReader(b[l:])})
+		f, err := fp.ParseNext()
+		require.NoError(t, err)
+		require.IsType(t, &settingsFrame{}, f)
+		settingsFrame := f.(*settingsFrame)
+		require.Equal(t, settingsFrame.Datagram, enableDatagrams)
+		require.Equal(t, settingsFrame.Other, other)
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+}
+
+func closedChan() <-chan struct{} {
+	ch := make(chan struct{})
+	close(ch)
+	return ch
+}
+
+func encodeResponse(t *testing.T, status int) []byte {
+	mockCtrl := gomock.NewController(t)
 	buf := &bytes.Buffer{}
 	rstr := mockquic.NewMockStream(mockCtrl)
 	rstr.EXPECT().Write(gomock.Any()).Do(buf.Write).AnyTimes()
 	rw := newResponseWriter(newStream(rstr, nil, nil, func(r io.Reader, u uint64) error { return nil }), nil, false, nil)
-	if status == http.StatusEarlyHints {
-		rw.header.Add("Link", "</style.css>; rel=preload; as=style")
-		rw.header.Add("Link", "</script.js>; rel=preload; as=script")
-	}
 	rw.WriteHeader(status)
 	rw.Flush()
 	return buf.Bytes()
 }
 
-var _ = Describe("Client", func() {
-	var handshakeChan <-chan struct{} // a closed chan
-
-	BeforeEach(func() {
-		ch := make(chan struct{})
-		close(ch)
-		handshakeChan = ch
+func TestClientRequest(t *testing.T) {
+	t.Run("GET", func(t *testing.T) {
+		rsp := testClientRequest(t, false, http.MethodGet, encodeResponse(t, http.StatusTeapot))
+		require.Equal(t, http.StatusTeapot, rsp.StatusCode)
+		require.Equal(t, "HTTP/3.0", rsp.Proto)
+		require.Equal(t, 3, rsp.ProtoMajor)
+		require.NotNil(t, rsp.Request)
 	})
 
-	Context("hijacking bidirectional streams", func() {
-		var (
-			request              *http.Request
-			conn                 *mockquic.MockEarlyConnection
-			settingsFrameWritten chan struct{}
-		)
-		testDone := make(chan struct{})
-
-		BeforeEach(func() {
-			testDone = make(chan struct{})
-			settingsFrameWritten = make(chan struct{})
-			controlStr := mockquic.NewMockStream(mockCtrl)
-			controlStr.EXPECT().Write(gomock.Any()).Do(func(b []byte) (int, error) {
-				defer GinkgoRecover()
-				close(settingsFrameWritten)
-				return len(b), nil
-			})
-			conn = mockquic.NewMockEarlyConnection(mockCtrl)
-			conn.EXPECT().OpenUniStream().Return(controlStr, nil)
-			conn.EXPECT().HandshakeComplete().Return(handshakeChan)
-			conn.EXPECT().OpenStreamSync(gomock.Any()).Return(nil, errors.New("done"))
-			conn.EXPECT().AcceptUniStream(gomock.Any()).Return(nil, errors.New("done")).AnyTimes()
-			var err error
-			request, err = http.NewRequest("GET", "https://quic.clemente.io:1337/file1.dat", nil)
-			Expect(err).ToNot(HaveOccurred())
-		})
+	t.Run("GET 0-RTT", func(t *testing.T) {
+		rsp := testClientRequest(t, true, http.MethodGet, encodeResponse(t, http.StatusOK))
+		require.Equal(t, http.StatusOK, rsp.StatusCode)
+	})
 
-		AfterEach(func() {
-			testDone <- struct{}{}
-			Eventually(settingsFrameWritten).Should(BeClosed())
-		})
+	t.Run("HEAD", func(t *testing.T) {
+		rsp := testClientRequest(t, false, http.MethodHead, encodeResponse(t, http.StatusTeapot))
+		require.Equal(t, http.StatusTeapot, rsp.StatusCode)
+	})
 
-		It("hijacks a bidirectional stream of unknown frame type", func() {
-			id := quic.ConnectionTracingID(1234)
-			frameTypeChan := make(chan FrameType, 1)
-			tr := &Transport{
-				StreamHijacker: func(ft FrameType, connTracingID quic.ConnectionTracingID, _ quic.Stream, e error) (hijacked bool, err error) {
-					Expect(e).ToNot(HaveOccurred())
-					Expect(connTracingID).To(Equal(id))
-					frameTypeChan <- ft
-					return true, nil
-				},
-			}
+	t.Run("HEAD 0-RTT", func(t *testing.T) {
+		rsp := testClientRequest(t, true, http.MethodHead, encodeResponse(t, http.StatusOK))
+		require.Equal(t, http.StatusOK, rsp.StatusCode)
+	})
+}
 
-			buf := bytes.NewBuffer(quicvarint.Append(nil, 0x41))
-			unknownStr := mockquic.NewMockStream(mockCtrl)
-			unknownStr.EXPECT().Read(gomock.Any()).DoAndReturn(buf.Read).AnyTimes()
-			conn.EXPECT().AcceptStream(gomock.Any()).Return(unknownStr, nil)
-			conn.EXPECT().AcceptStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.Stream, error) {
-				<-testDone
-				return nil, errors.New("test done")
-			})
-			ctx := context.WithValue(context.Background(), quic.ConnectionTracingKey, id)
-			conn.EXPECT().Context().Return(ctx).AnyTimes()
-			cc := tr.NewClientConn(conn)
-			_, err := cc.RoundTrip(request)
-			Expect(err).To(MatchError("done"))
-			Eventually(frameTypeChan).Should(Receive(BeEquivalentTo(0x41)))
-			time.Sleep(scaleDuration(20 * time.Millisecond)) // don't EXPECT any calls to conn.CloseWithError
-		})
+func testClientRequest(t *testing.T, use0RTT bool, method string, rspBytes []byte) *http.Response {
+	mockCtrl := gomock.NewController(t)
+	conn := mockquic.NewMockEarlyConnection(mockCtrl)
+	if !use0RTT {
+		conn.EXPECT().HandshakeComplete().Return(closedChan()).AnyTimes()
+	}
+	conn.EXPECT().Context().Return(context.Background()).AnyTimes()
+	done := make(chan struct{})
+	conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
+		<-done
+		return nil, assert.AnError
+	}).MaxTimes(1)
+	conn.EXPECT().ConnectionState()
+	controlStr := mockquic.NewMockStream(mockCtrl)
+	controlStr.EXPECT().Write(gomock.Any()).MaxTimes(1)
+	conn.EXPECT().OpenUniStream().Return(controlStr, nil).MaxTimes(1)
+
+	reqMethod := method
+	if use0RTT {
+		switch method {
+		case http.MethodGet:
+			reqMethod = MethodGet0RTT
+		case http.MethodHead:
+			reqMethod = MethodHead0RTT
+		}
+	}
 
-		It("closes the connection when hijacker didn't hijack a bidirectional stream", func() {
-			frameTypeChan := make(chan FrameType, 1)
-			tr := &Transport{
-				StreamHijacker: func(ft FrameType, _ quic.ConnectionTracingID, _ quic.Stream, e error) (hijacked bool, err error) {
-					Expect(e).ToNot(HaveOccurred())
-					frameTypeChan <- ft
-					return false, nil
-				},
-			}
+	req, err := http.NewRequest(reqMethod, "http://quic-go.net", nil)
+	require.NoError(t, err)
+
+	var buf bytes.Buffer
+	str := mockquic.NewMockStream(mockCtrl)
+	str.EXPECT().Context().Return(context.Background()).AnyTimes()
+	str.EXPECT().StreamID().AnyTimes()
+	str.EXPECT().Write(gomock.Any()).DoAndReturn(buf.Write).AnyTimes()
+	str.EXPECT().Close()
+	str.EXPECT().Read(gomock.Any()).DoAndReturn(bytes.NewReader(rspBytes).Read).AnyTimes()
+	conn.EXPECT().OpenStreamSync(gomock.Any()).Return(str, nil)
+
+	cc := (&Transport{}).NewClientConn(conn)
+	rsp, err := cc.RoundTrip(req)
+	require.NoError(t, err)
+	hfs := decodeHeader(t, &buf)
+	require.Equal(t, []string{method}, hfs[":method"])
+
+	// make sure the http.Request.Method value was not modified
+	if use0RTT {
+		switch reqMethod {
+		case MethodGet0RTT:
+			require.Equal(t, req.Method, MethodGet0RTT)
+		case MethodHead0RTT:
+			require.Equal(t, req.Method, MethodHead0RTT)
+		}
+	}
+	return rsp
+}
 
-			buf := bytes.NewBuffer(quicvarint.Append(nil, 0x41))
-			unknownStr := mockquic.NewMockStream(mockCtrl)
-			unknownStr.EXPECT().Read(gomock.Any()).DoAndReturn(buf.Read).AnyTimes()
-			conn.EXPECT().AcceptStream(gomock.Any()).Return(unknownStr, nil)
-			conn.EXPECT().AcceptStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.Stream, error) {
-				<-testDone
-				return nil, errors.New("test done")
-			})
-			ctx := context.WithValue(context.Background(), quic.ConnectionTracingKey, quic.ConnectionTracingID(1234))
-			conn.EXPECT().Context().Return(ctx).AnyTimes()
-			conn.EXPECT().CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameUnexpected), gomock.Any()).Return(nil).AnyTimes()
-			cc := tr.NewClientConn(conn)
-			_, err := cc.RoundTrip(request)
-			Expect(err).To(MatchError("done"))
-			Eventually(frameTypeChan).Should(Receive(BeEquivalentTo(0x41)))
-		})
+func TestClientResponseValidation(t *testing.T) {
+	t.Run("HEADERS frame too large", func(t *testing.T) {
+		testClientResponseValidation(t,
+			&Transport{MaxResponseHeaderBytes: 1337},
+			(&headersFrame{Length: 1338}).Append(nil),
+			quic.StreamErrorCode(ErrCodeFrameError),
+		)
+	})
 
-		It("closes the connection when hijacker returned error", func() {
-			frameTypeChan := make(chan FrameType, 1)
-			tr := &Transport{
-				StreamHijacker: func(ft FrameType, _ quic.ConnectionTracingID, _ quic.Stream, e error) (hijacked bool, err error) {
-					Expect(e).ToNot(HaveOccurred())
-					frameTypeChan <- ft
-					return false, errors.New("error in hijacker")
-				},
-			}
+	t.Run("invalid headers", func(t *testing.T) {
+		headerBuf := &bytes.Buffer{}
+		enc := qpack.NewEncoder(headerBuf)
+		// not a valid response pseudo header
+		require.NoError(t, enc.WriteField(qpack.HeaderField{Name: ":method", Value: "GET"}))
+		require.NoError(t, enc.Close())
+		b := (&headersFrame{Length: uint64(headerBuf.Len())}).Append(nil)
+		b = append(b, headerBuf.Bytes()...)
 
-			buf := bytes.NewBuffer(quicvarint.Append(nil, 0x41))
-			unknownStr := mockquic.NewMockStream(mockCtrl)
-			unknownStr.EXPECT().Read(gomock.Any()).DoAndReturn(buf.Read).AnyTimes()
-			conn.EXPECT().AcceptStream(gomock.Any()).Return(unknownStr, nil)
-			conn.EXPECT().AcceptStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.Stream, error) {
-				<-testDone
-				return nil, errors.New("test done")
-			})
-			ctx := context.WithValue(context.Background(), quic.ConnectionTracingKey, quic.ConnectionTracingID(1234))
-			conn.EXPECT().Context().Return(ctx).AnyTimes()
-			conn.EXPECT().CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameUnexpected), gomock.Any()).Return(nil).AnyTimes()
-			cc := tr.NewClientConn(conn)
-			_, err := cc.RoundTrip(request)
-			Expect(err).To(MatchError("done"))
-			Eventually(frameTypeChan).Should(Receive(BeEquivalentTo(0x41)))
-		})
+		testClientResponseValidation(t, &Transport{}, b, quic.StreamErrorCode(ErrCodeMessageError))
+	})
+}
 
-		It("handles errors that occur when reading the frame type", func() {
-			testErr := errors.New("test error")
-			unknownStr := mockquic.NewMockStream(mockCtrl)
-			done := make(chan struct{})
-			tr := &Transport{
-				StreamHijacker: func(ft FrameType, _ quic.ConnectionTracingID, str quic.Stream, e error) (hijacked bool, err error) {
-					defer close(done)
-					Expect(e).To(MatchError(testErr))
-					Expect(ft).To(BeZero())
-					Expect(str).To(Equal(unknownStr))
-					return false, nil
-				},
-			}
+func testClientResponseValidation(t *testing.T, tr *Transport, rsp []byte, expectedReset quic.StreamErrorCode) {
+	mockCtrl := gomock.NewController(t)
+	conn := mockquic.NewMockEarlyConnection(mockCtrl)
+	conn.EXPECT().HandshakeComplete().Return(closedChan()).AnyTimes()
+	conn.EXPECT().Context().Return(context.Background()).AnyTimes()
+	done := make(chan struct{})
+	conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
+		<-done
+		return nil, assert.AnError
+	}).MaxTimes(1)
+	controlStr := mockquic.NewMockStream(mockCtrl)
+	controlStr.EXPECT().Write(gomock.Any()).MaxTimes(1)
+	conn.EXPECT().OpenUniStream().Return(controlStr, nil).MaxTimes(1)
+
+	req, err := http.NewRequest(http.MethodGet, "http://quic-go.net", nil)
+	require.NoError(t, err)
+
+	var buf bytes.Buffer
+	str := mockquic.NewMockStream(mockCtrl)
+	str.EXPECT().Context().Return(context.Background()).AnyTimes()
+	str.EXPECT().StreamID().AnyTimes()
+	str.EXPECT().Write(gomock.Any()).DoAndReturn(buf.Write).AnyTimes()
+	str.EXPECT().CancelRead(expectedReset)
+	str.EXPECT().CancelWrite(expectedReset)
+	str.EXPECT().Close()
+	str.EXPECT().Read(gomock.Any()).DoAndReturn(bytes.NewReader(rsp).Read).AnyTimes()
+	conn.EXPECT().OpenStreamSync(gomock.Any()).Return(str, nil)
+
+	cc := tr.NewClientConn(conn)
+	_, err = cc.RoundTrip(req)
+	require.Error(t, err)
+}
 
-			unknownStr.EXPECT().Read(gomock.Any()).Return(0, testErr).AnyTimes()
-			conn.EXPECT().AcceptStream(gomock.Any()).Return(unknownStr, nil)
-			conn.EXPECT().AcceptStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.Stream, error) {
-				<-testDone
-				return nil, errors.New("test done")
-			})
-			ctx := context.WithValue(context.Background(), quic.ConnectionTracingKey, quic.ConnectionTracingID(1234))
-			conn.EXPECT().Context().Return(ctx).AnyTimes()
-			conn.EXPECT().CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameUnexpected), gomock.Any()).Return(nil).AnyTimes()
-			cc := tr.NewClientConn(conn)
-			_, err := cc.RoundTrip(request)
-			Expect(err).To(MatchError("done"))
-			Eventually(done).Should(BeClosed())
-			time.Sleep(scaleDuration(20 * time.Millisecond)) // don't EXPECT any calls to conn.CloseWithError
-		})
+func TestClientRequestLengthLimit(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	conn := mockquic.NewMockEarlyConnection(mockCtrl)
+	conn.EXPECT().HandshakeComplete().Return(closedChan()).AnyTimes()
+	conn.EXPECT().Context().Return(context.Background()).AnyTimes()
+	done := make(chan struct{})
+	conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
+		<-done
+		return nil, assert.AnError
+	}).MaxTimes(1)
+	controlStr := mockquic.NewMockStream(mockCtrl)
+	controlStr.EXPECT().Write(gomock.Any()).MaxTimes(1)
+	conn.EXPECT().ConnectionState()
+	conn.EXPECT().OpenUniStream().Return(controlStr, nil).MaxTimes(1)
+
+	body := bytes.NewBufferString("request body")
+	req, err := http.NewRequest(http.MethodPost, "http://quic-go.net", body)
+	require.NoError(t, err)
+	req.ContentLength = 7
+
+	var buf bytes.Buffer
+	str := mockquic.NewMockStream(mockCtrl)
+	str.EXPECT().Context().Return(context.Background()).AnyTimes()
+	str.EXPECT().StreamID().AnyTimes()
+	str.EXPECT().Write(gomock.Any()).DoAndReturn(buf.Write).AnyTimes()
+	str.EXPECT().Read(gomock.Any()).DoAndReturn(
+		bytes.NewReader(encodeResponse(t, http.StatusTeapot)).Read,
+	).AnyTimes()
+	str.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeRequestCanceled)).Do(func(quic.StreamErrorCode) {
+		close(done)
 	})
+	str.EXPECT().Close()
+	conn.EXPECT().OpenStreamSync(gomock.Any()).Return(str, nil)
 
-	Context("hijacking unidirectional streams", func() {
-		var (
-			req                  *http.Request
-			conn                 *mockquic.MockEarlyConnection
-			settingsFrameWritten chan struct{}
-		)
-		testDone := make(chan struct{})
-
-		BeforeEach(func() {
-			testDone = make(chan struct{})
-			settingsFrameWritten = make(chan struct{})
-			controlStr := mockquic.NewMockStream(mockCtrl)
-			controlStr.EXPECT().Write(gomock.Any()).Do(func(b []byte) (int, error) {
-				defer GinkgoRecover()
-				close(settingsFrameWritten)
-				return len(b), nil
-			})
-			conn = mockquic.NewMockEarlyConnection(mockCtrl)
-			conn.EXPECT().OpenUniStream().Return(controlStr, nil)
-			conn.EXPECT().HandshakeComplete().Return(handshakeChan)
-			conn.EXPECT().OpenStreamSync(gomock.Any()).Return(nil, errors.New("done"))
-			var err error
-			req, err = http.NewRequest("GET", "https://quic.clemente.io:1337/file1.dat", nil)
-			Expect(err).ToNot(HaveOccurred())
-		})
+	cc := (&Transport{}).NewClientConn(conn)
+	_, err = cc.RoundTrip(req)
+	require.NoError(t, err)
 
-		AfterEach(func() {
-			testDone <- struct{}{}
-			Eventually(settingsFrameWritten).Should(BeClosed())
-		})
+	select {
+	case <-done:
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
 
-		It("hijacks an unidirectional stream of unknown stream type", func() {
-			id := quic.ConnectionTracingID(100)
-			streamTypeChan := make(chan StreamType, 1)
-			tr := &Transport{
-				UniStreamHijacker: func(st StreamType, connTracingID quic.ConnectionTracingID, _ quic.ReceiveStream, err error) bool {
-					Expect(connTracingID).To(Equal(id))
-					Expect(err).ToNot(HaveOccurred())
-					streamTypeChan <- st
-					return true
-				},
-			}
-			buf := bytes.NewBuffer(quicvarint.Append(nil, 0x54))
-			unknownStr := mockquic.NewMockStream(mockCtrl)
-			unknownStr.EXPECT().Read(gomock.Any()).DoAndReturn(buf.Read).AnyTimes()
-			conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
-				return unknownStr, nil
-			})
-			conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
-				<-testDone
-				return nil, errors.New("test done")
-			})
-			ctx := context.WithValue(context.Background(), quic.ConnectionTracingKey, id)
-			conn.EXPECT().Context().Return(ctx).AnyTimes()
-			cc := tr.NewClientConn(conn)
-			_, err := cc.RoundTrip(req)
-			Expect(err).To(MatchError("done"))
-			Eventually(streamTypeChan).Should(Receive(BeEquivalentTo(0x54)))
-			time.Sleep(scaleDuration(20 * time.Millisecond)) // don't EXPECT any calls to conn.CloseWithError
-		})
+	hfs := decodeHeader(t, &buf)
+	require.Equal(t, []string{http.MethodPost}, hfs[":method"])
+	require.Contains(t, buf.String(), "request")
+	require.NotContains(t, buf.String(), "request body")
+	// the entire body should have been read (and discarded)
+	require.Zero(t, body.Len())
+}
 
-		It("handles errors that occur when reading the stream type", func() {
-			testErr := errors.New("test error")
-			done := make(chan struct{})
-			unknownStr := mockquic.NewMockStream(mockCtrl)
-			tr := &Transport{
-				UniStreamHijacker: func(st StreamType, _ quic.ConnectionTracingID, str quic.ReceiveStream, err error) bool {
-					defer close(done)
-					Expect(st).To(BeZero())
-					Expect(str).To(Equal(unknownStr))
-					Expect(err).To(MatchError(testErr))
-					return true
-				},
-			}
-			unknownStr.EXPECT().Read(gomock.Any()).Return(0, testErr)
-			conn.EXPECT().AcceptUniStream(gomock.Any()).Return(unknownStr, nil)
-			conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
-				<-testDone
-				return nil, errors.New("test done")
-			})
-			ctx := context.WithValue(context.Background(), quic.ConnectionTracingKey, quic.ConnectionTracingID(1234))
-			conn.EXPECT().Context().Return(ctx).AnyTimes()
-			cc := tr.NewClientConn(conn)
-			_, err := cc.RoundTrip(req)
-			Expect(err).To(MatchError("done"))
-			Eventually(done).Should(BeClosed())
-			time.Sleep(scaleDuration(20 * time.Millisecond)) // don't EXPECT any calls to conn.CloseWithError
-		})
+func TestClientExtendedConnect(t *testing.T) {
+	t.Run("enabled", func(t *testing.T) {
+		testClientExtendedConnect(t, true)
+	})
 
-		It("cancels reading when hijacker didn't hijack an unidirectional stream", func() {
-			streamTypeChan := make(chan StreamType, 1)
-			tr := &Transport{
-				UniStreamHijacker: func(st StreamType, _ quic.ConnectionTracingID, _ quic.ReceiveStream, err error) bool {
-					Expect(err).ToNot(HaveOccurred())
-					streamTypeChan <- st
-					return false
-				},
-			}
-			buf := bytes.NewBuffer(quicvarint.Append(nil, 0x54))
-			unknownStr := mockquic.NewMockStream(mockCtrl)
-			unknownStr.EXPECT().Read(gomock.Any()).DoAndReturn(buf.Read).AnyTimes()
-			unknownStr.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeStreamCreationError))
-			conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
-				return unknownStr, nil
-			})
-			conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
-				<-testDone
-				return nil, errors.New("test done")
-			})
-			ctx := context.WithValue(context.Background(), quic.ConnectionTracingKey, quic.ConnectionTracingID(1234))
-			conn.EXPECT().Context().Return(ctx).AnyTimes()
-			cc := tr.NewClientConn(conn)
-			_, err := cc.RoundTrip(req)
-			Expect(err).To(MatchError("done"))
-			Eventually(streamTypeChan).Should(Receive(BeEquivalentTo(0x54)))
-			time.Sleep(scaleDuration(20 * time.Millisecond)) // don't EXPECT any calls to conn.CloseWithError
-		})
+	t.Run("disabled", func(t *testing.T) {
+		testClientExtendedConnect(t, false)
 	})
+}
 
-	Context("SETTINGS handling", func() {
-		sendSettings := func() {
-			settingsFrameWritten := make(chan struct{})
-			controlStr := mockquic.NewMockStream(mockCtrl)
-			var buf bytes.Buffer
-			controlStr.EXPECT().Write(gomock.Any()).Do(func(b []byte) (int, error) {
-				defer GinkgoRecover()
-				buf.Write(b)
-				close(settingsFrameWritten)
-				return len(b), nil
-			})
-			conn := mockquic.NewMockEarlyConnection(mockCtrl)
-			conn.EXPECT().Context().Return(context.Background())
-			conn.EXPECT().OpenUniStream().Return(controlStr, nil)
-			conn.EXPECT().OpenStreamSync(gomock.Any()).DoAndReturn(func(context.Context) (quic.Stream, error) {
-				<-settingsFrameWritten
-				return nil, errors.New("test done")
-			})
-			conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
-				<-settingsFrameWritten
-				return nil, errors.New("test done")
-			}).AnyTimes()
-			conn.EXPECT().HandshakeComplete().Return(handshakeChan)
-			tr := &Transport{
-				EnableDatagrams: true,
-			}
-			cc := tr.NewClientConn(conn)
-			req, err := http.NewRequest(http.MethodGet, "https://quic-go.net", nil)
-			Expect(err).ToNot(HaveOccurred())
-			_, err = cc.RoundTrip(req)
-			Expect(err).To(MatchError("test done"))
-			t, err := quicvarint.Read(&buf)
-			Expect(err).ToNot(HaveOccurred())
-			Expect(t).To(BeEquivalentTo(streamTypeControlStream))
-			settings, err := parseSettingsFrame(&buf, uint64(buf.Len()))
-			Expect(err).ToNot(HaveOccurred())
-			Expect(settings.Datagram).To(BeTrue())
-		}
+func testClientExtendedConnect(t *testing.T, enabled bool) {
+	mockCtrl := gomock.NewController(t)
+	conn := mockquic.NewMockEarlyConnection(mockCtrl)
+
+	done := make(chan struct{})
+	conn.EXPECT().OpenUniStream().DoAndReturn(func() (quic.SendStream, error) {
+		<-done
+		return nil, assert.AnError
+	}).MaxTimes(1)
+	conn.EXPECT().Context().Return(context.Background()).AnyTimes()
+	conn.EXPECT().HandshakeComplete().Return(closedChan()).AnyTimes()
+
+	b := quicvarint.Append(nil, streamTypeControlStream)
+	b = (&settingsFrame{ExtendedConnect: enabled}).Append(b)
+	r := bytes.NewReader(b)
+	allowSettings := make(chan struct{})
+
+	controlStr := mockquic.NewMockStream(mockCtrl)
+	controlStr.EXPECT().Read(gomock.Any()).DoAndReturn(func(b []byte) (int, error) {
+		<-allowSettings
+		return r.Read(b)
+	}).AnyTimes()
+	conn.EXPECT().AcceptUniStream(gomock.Any()).Return(controlStr, nil)
+	conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
+		<-done
+		return nil, assert.AnError
+	})
 
-		It("receives SETTINGS", func() {
-			sendSettings()
-			done := make(chan struct{})
-			conn := mockquic.NewMockEarlyConnection(mockCtrl)
-			conn.EXPECT().OpenUniStream().DoAndReturn(func() (quic.SendStream, error) {
-				<-done
-				return nil, errors.New("test done")
-			}).MaxTimes(1)
-			conn.EXPECT().Context().Return(context.Background())
-			b := quicvarint.Append(nil, streamTypeControlStream)
-			b = (&settingsFrame{ExtendedConnect: true}).Append(b)
-			r := bytes.NewReader(b)
-			controlStr := mockquic.NewMockStream(mockCtrl)
-			controlStr.EXPECT().Read(gomock.Any()).DoAndReturn(r.Read).AnyTimes()
-			conn.EXPECT().AcceptUniStream(gomock.Any()).Return(controlStr, nil)
-			conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
-				<-done
-				return nil, errors.New("test done")
-			})
-
-			tr := &Transport{}
-			cc := tr.NewClientConn(conn)
-			Eventually(cc.ReceivedSettings()).Should(BeClosed())
-			settings := cc.Settings()
-			Expect(settings.EnableExtendedConnect).To(BeTrue())
-			// test shutdown
-			conn.EXPECT().CloseWithError(gomock.Any(), gomock.Any()).MaxTimes(1)
-			close(done)
-		})
+	if enabled {
+		requestStr := mockquic.NewMockStream(mockCtrl)
+		requestStr.EXPECT().Write(gomock.Any()).Return(0, assert.AnError)
+		requestStr.EXPECT().StreamID().AnyTimes()
+		requestStr.EXPECT().Context().Return(context.Background()).AnyTimes()
+		conn.EXPECT().OpenStreamSync(gomock.Any()).Return(requestStr, nil)
+	}
 
-		It("checks the server's SETTINGS before sending an Extended CONNECT request", func() {
-			sendSettings()
-			done := make(chan struct{})
-			var wg sync.WaitGroup
-			wg.Add(2)
-			conn := mockquic.NewMockEarlyConnection(mockCtrl)
-			conn.EXPECT().OpenUniStream().DoAndReturn(func() (quic.SendStream, error) {
-				<-done
-				wg.Done()
-				return nil, errors.New("test done")
-			}).MaxTimes(1)
-			conn.EXPECT().Context().Return(context.Background())
-			b := quicvarint.Append(nil, streamTypeControlStream)
-			b = (&settingsFrame{ExtendedConnect: true}).Append(b)
-			r := bytes.NewReader(b)
-			controlStr := mockquic.NewMockStream(mockCtrl)
-			controlStr.EXPECT().Read(gomock.Any()).DoAndReturn(r.Read).AnyTimes()
-			conn.EXPECT().AcceptUniStream(gomock.Any()).Return(controlStr, nil)
-			conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
-				<-done
-				wg.Done()
-				return nil, errors.New("test done")
-			})
-			conn.EXPECT().HandshakeComplete().Return(handshakeChan)
-			conn.EXPECT().Context().Return(context.Background())
-			conn.EXPECT().OpenStreamSync(gomock.Any()).Return(nil, errors.New("test error"))
-
-			tr := &Transport{}
-			cc := tr.NewClientConn(conn)
-			_, err := cc.RoundTrip(&http.Request{
-				Method: http.MethodConnect,
-				Proto:  "connect",
-				Host:   "localhost",
-			})
-			Expect(err).To(MatchError("test error"))
-
-			// test shutdown
-			conn.EXPECT().CloseWithError(gomock.Any(), gomock.Any()).MaxTimes(1)
-			close(done)
-			wg.Wait()
-		})
+	cc := (&Transport{}).NewClientConn(conn)
+	req, err := http.NewRequest(http.MethodConnect, "http://quic-go.net", nil)
+	require.NoError(t, err)
+	req.Proto = "connect"
+	errChan := make(chan error)
+	go func() {
+		_, err := cc.RoundTrip(req)
+		errChan <- err
+	}()
+
+	select {
+	case <-errChan:
+		t.Fatal("RoundTrip should have blocked until SETTINGS were received")
+	case <-time.After(scaleDuration(10 * time.Millisecond)):
+	}
 
-		It("rejects Extended CONNECT requests if the server doesn't enable it", func() {
-			sendSettings()
-			done := make(chan struct{})
-			var wg sync.WaitGroup
-			wg.Add(2)
-			conn := mockquic.NewMockEarlyConnection(mockCtrl)
-			conn.EXPECT().Context().Return(context.Background())
-			conn.EXPECT().OpenUniStream().DoAndReturn(func() (quic.SendStream, error) {
-				<-done
-				wg.Done()
-				return nil, errors.New("test done")
-			}).MaxTimes(1)
-			b := quicvarint.Append(nil, streamTypeControlStream)
-			b = (&settingsFrame{}).Append(b)
-			r := bytes.NewReader(b)
-			controlStr := mockquic.NewMockStream(mockCtrl)
-			controlStr.EXPECT().Read(gomock.Any()).DoAndReturn(r.Read).AnyTimes()
-			conn.EXPECT().AcceptUniStream(gomock.Any()).Return(controlStr, nil)
-			conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
-				<-done
-				wg.Done()
-				return nil, errors.New("test done")
-			})
-			conn.EXPECT().HandshakeComplete().Return(handshakeChan)
-			conn.EXPECT().Context().Return(context.Background())
-
-			tr := &Transport{}
-			cc := tr.NewClientConn(conn)
-			_, err := cc.RoundTrip(&http.Request{
-				Method: http.MethodConnect,
-				Proto:  "connect",
-				Host:   "localhost",
-			})
-			Expect(err).To(MatchError("http3: server didn't enable Extended CONNECT"))
-
-			// test shutdown
-			conn.EXPECT().CloseWithError(gomock.Any(), gomock.Any()).MaxTimes(1)
-			close(done)
-			wg.Wait()
-		})
-	})
+	// now send the SETTINGS
+	close(allowSettings)
 
-	Context("Doing requests", func() {
-		var (
-			req                  *http.Request
-			str                  *mockquic.MockStream
-			conn                 *mockquic.MockEarlyConnection
-			settingsFrameWritten chan struct{}
-		)
-		testDone := make(chan struct{})
-
-		decodeHeader := func(str io.Reader) map[string]string {
-			fields := make(map[string]string)
-			decoder := qpack.NewDecoder(nil)
-
-			fp := frameParser{r: str}
-			frame, err := fp.ParseNext()
-			ExpectWithOffset(1, err).ToNot(HaveOccurred())
-			ExpectWithOffset(1, frame).To(BeAssignableToTypeOf(&headersFrame{}))
-			headersFrame := frame.(*headersFrame)
-			data := make([]byte, headersFrame.Length)
-			_, err = io.ReadFull(str, data)
-			ExpectWithOffset(1, err).ToNot(HaveOccurred())
-			hfs, err := decoder.DecodeFull(data)
-			ExpectWithOffset(1, err).ToNot(HaveOccurred())
-			for _, p := range hfs {
-				fields[p.Name] = p.Value
-			}
-			return fields
+	select {
+	case <-cc.ReceivedSettings():
+	case <-time.After(time.Second):
+		t.Fatal("timeout waiting for settings")
+	}
+	settings := cc.Settings()
+	require.Equal(t, enabled, settings.EnableExtendedConnect)
+
+	select {
+	case err := <-errChan:
+		if enabled {
+			require.ErrorIs(t, err, assert.AnError)
+		} else {
+			require.EqualError(t, err, "http3: server didn't enable Extended CONNECT")
 		}
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+}
 
-		BeforeEach(func() {
-			settingsFrameWritten = make(chan struct{})
-			controlStr := mockquic.NewMockStream(mockCtrl)
-			controlStr.EXPECT().Write(gomock.Any()).Do(func(b []byte) (int, error) {
-				defer GinkgoRecover()
-				r := bytes.NewReader(b)
-				streamType, err := quicvarint.Read(r)
-				Expect(err).ToNot(HaveOccurred())
-				Expect(streamType).To(BeEquivalentTo(streamTypeControlStream))
-				close(settingsFrameWritten)
-				return len(b), nil
-			}) // SETTINGS frame
-			str = mockquic.NewMockStream(mockCtrl)
-			str.EXPECT().Context().Return(context.Background()).AnyTimes()
-			str.EXPECT().StreamID().AnyTimes()
-			conn = mockquic.NewMockEarlyConnection(mockCtrl)
-			conn.EXPECT().Context().Return(context.Background())
-			conn.EXPECT().OpenUniStream().Return(controlStr, nil)
-			conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
-				<-testDone
-				return nil, errors.New("test done")
-			})
-			var err error
-			req, err = http.NewRequest("GET", "https://quic.clemente.io:1337/file1.dat", nil)
-			Expect(err).ToNot(HaveOccurred())
-		})
+func TestClient1xxHandling(t *testing.T) {
+	t.Run("a few early hints", func(t *testing.T) {
+		testClient1xxHandling(t, max1xxResponses, http.StatusOK, false)
+	})
+	t.Run("too many early hints", func(t *testing.T) {
+		testClient1xxHandling(t, max1xxResponses+1, http.StatusOK, true)
+	})
+	t.Run("EarlyHints followed by StatusSwitchingProtocols", func(t *testing.T) {
+		testClient1xxHandling(t, 1, http.StatusSwitchingProtocols, false)
+	})
+}
 
-		AfterEach(func() {
-			testDone <- struct{}{}
-			Eventually(settingsFrameWritten).Should(BeClosed())
-		})
+func testClient1xxHandling(t *testing.T, numEarlyHints int, terminalStatus int, tooMany bool) {
+	mockCtrl := gomock.NewController(t)
 
-		It("errors if it can't open a request stream", func() {
-			testErr := errors.New("stream open error")
-			conn.EXPECT().OpenStreamSync(context.Background()).Return(nil, testErr)
-			conn.EXPECT().CloseWithError(gomock.Any(), gomock.Any()).MaxTimes(1)
-			conn.EXPECT().HandshakeComplete().Return(handshakeChan)
-			tr := &Transport{}
-			cc := tr.NewClientConn(conn)
-			_, err := cc.RoundTrip(req)
-			Expect(err).To(MatchError(testErr))
-		})
+	var rspBuf bytes.Buffer
+	rstr := mockquic.NewMockStream(mockCtrl)
+	rstr.EXPECT().Write(gomock.Any()).Do(rspBuf.Write).AnyTimes()
+	rw := newResponseWriter(newStream(rstr, nil, nil, func(r io.Reader, u uint64) error { return nil }), nil, false, nil)
+	rw.header.Add("Link", "foo")
+	rw.header.Add("Link", "bar")
+	for range numEarlyHints {
+		rw.WriteHeader(http.StatusEarlyHints)
+	}
+	rw.WriteHeader(terminalStatus)
+	rw.Flush()
+	rspBytes := rspBuf.Bytes()
+
+	conn := mockquic.NewMockEarlyConnection(mockCtrl)
+	conn.EXPECT().Context().Return(context.Background()).AnyTimes()
+	conn.EXPECT().HandshakeComplete().Return(closedChan()).AnyTimes()
+	done := make(chan struct{})
+	conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
+		<-done
+		return nil, assert.AnError
+	}).MaxTimes(1)
+	controlStr := mockquic.NewMockStream(mockCtrl)
+	controlStr.EXPECT().Write(gomock.Any()).MaxTimes(1)
+	conn.EXPECT().OpenUniStream().Return(controlStr, nil).MaxTimes(1)
+	conn.EXPECT().ConnectionState().MaxTimes(1)
+
+	req, err := http.NewRequest(http.MethodGet, "http://quic-go.net", nil)
+	require.NoError(t, err)
+
+	var buf bytes.Buffer
+	str := mockquic.NewMockStream(mockCtrl)
+	str.EXPECT().Context().Return(context.Background()).AnyTimes()
+	str.EXPECT().StreamID().AnyTimes()
+	str.EXPECT().Write(gomock.Any()).DoAndReturn(buf.Write).AnyTimes()
+	str.EXPECT().Close()
+	str.EXPECT().Read(gomock.Any()).DoAndReturn(bytes.NewReader(rspBytes).Read).AnyTimes()
+	conn.EXPECT().OpenStreamSync(gomock.Any()).Return(str, nil)
+
+	cc := (&Transport{}).NewClientConn(conn)
+	rsp, err := cc.RoundTrip(req)
+	if tooMany {
+		require.EqualError(t, err, "http3: too many 1xx informational responses")
+		return
+	}
+	require.NoError(t, err)
+	require.Equal(t, []string{"foo", "bar"}, rsp.Header["Link"])
+	require.Equal(t, terminalStatus, rsp.StatusCode)
 
-		DescribeTable(
-			"performs a 0-RTT request",
-			func(method, serialized string) {
-				testErr := errors.New("stream open error")
-				req.Method = method
-				// don't EXPECT any calls to HandshakeComplete()
-				conn.EXPECT().OpenStreamSync(context.Background()).Return(str, nil)
-				buf := &bytes.Buffer{}
-				str.EXPECT().Write(gomock.Any()).DoAndReturn(buf.Write).AnyTimes()
-				str.EXPECT().Close()
-				str.EXPECT().CancelWrite(gomock.Any())
-				str.EXPECT().CancelRead(gomock.Any())
-				str.EXPECT().Read(gomock.Any()).DoAndReturn(func([]byte) (int, error) {
-					return 0, testErr
-				})
-				tr := &Transport{}
-				cc := tr.NewClientConn(conn)
-				_, err := cc.RoundTrip(req)
-				Expect(err).To(MatchError(testErr))
-				Expect(decodeHeader(buf)).To(HaveKeyWithValue(":method", serialized))
-				// make sure the request wasn't modified
-				Expect(req.Method).To(Equal(method))
-			},
-			Entry("GET", MethodGet0RTT, http.MethodGet),
-			Entry("HEAD", MethodHead0RTT, http.MethodHead),
-		)
+	// request headers
+	hfs := decodeHeader(t, &buf)
+	require.Equal(t, hfs[":method"], []string{http.MethodGet})
+}
 
-		It("returns a response", func() {
-			rspBuf := bytes.NewBuffer(encodeResponse(418))
-			gomock.InOrder(
-				conn.EXPECT().HandshakeComplete().Return(handshakeChan),
-				conn.EXPECT().OpenStreamSync(context.Background()).Return(str, nil),
-				conn.EXPECT().ConnectionState().Return(quic.ConnectionState{}),
-			)
-			str.EXPECT().Write(gomock.Any()).AnyTimes().DoAndReturn(func(p []byte) (int, error) { return len(p), nil })
-			str.EXPECT().Close()
-			str.EXPECT().Read(gomock.Any()).DoAndReturn(rspBuf.Read).AnyTimes()
-			tr := &Transport{}
-			cc := tr.NewClientConn(conn)
-			rsp, err := cc.RoundTrip(req)
-			Expect(err).ToNot(HaveOccurred())
-			Expect(rsp.Proto).To(Equal("HTTP/3.0"))
-			Expect(rsp.ProtoMajor).To(Equal(3))
-			Expect(rsp.StatusCode).To(Equal(418))
-			Expect(rsp.Request).ToNot(BeNil())
-		})
+func TestClientGzip(t *testing.T) {
+	var buf bytes.Buffer
+	w := gzip.NewWriter(&buf)
+	w.Write([]byte("foobar"))
+	w.Close()
+	gzippedFoobar := buf.Bytes()
 
-		It("returns a response with trailers", func() {
-			rspBuf := bytes.NewBuffer(encodeResponse(418))
-
-			trailerBuf := &bytes.Buffer{}
-			enc := qpack.NewEncoder(trailerBuf)
-			Expect(enc.WriteField(qpack.HeaderField{Name: "This-Is-A-Trailer", Value: "0"})).To(Succeed())
-			Expect(enc.Close()).To(Succeed())
-			b := (&headersFrame{Length: uint64(trailerBuf.Len())}).Append(nil)
-			b = append(b, trailerBuf.Bytes()...)
-			rspBuf.Write(b)
-
-			gomock.InOrder(
-				conn.EXPECT().HandshakeComplete().Return(handshakeChan),
-				conn.EXPECT().OpenStreamSync(context.Background()).Return(str, nil),
-				conn.EXPECT().ConnectionState().Return(quic.ConnectionState{}),
-			)
-			str.EXPECT().Write(gomock.Any()).AnyTimes().DoAndReturn(func(p []byte) (int, error) { return len(p), nil })
-			str.EXPECT().Close()
-			str.EXPECT().Read(gomock.Any()).DoAndReturn(rspBuf.Read).AnyTimes()
-			tr := &Transport{}
-			cc := tr.NewClientConn(conn)
-			rsp, err := cc.RoundTrip(req)
-			Expect(err).ToNot(HaveOccurred())
-			_, err = io.ReadAll(rsp.Body)
-			Expect(err).ToNot(HaveOccurred())
-			Expect(rsp.Trailer).To(Equal(http.Header{"This-Is-A-Trailer": []string{"0"}}))
-			Expect(rsp.Proto).To(Equal("HTTP/3.0"))
-			Expect(rsp.ProtoMajor).To(Equal(3))
-			Expect(rsp.StatusCode).To(Equal(418))
-			Expect(rsp.Request).ToNot(BeNil())
-		})
+	t.Run("gzipped", func(t *testing.T) {
+		testClientGzip(t, gzippedFoobar, []byte("foobar"), false, true)
+	})
+	t.Run("not gzipped", func(t *testing.T) {
+		testClientGzip(t, []byte("foobar"), []byte("foobar"), false, false)
+	})
+	t.Run("disable compression", func(t *testing.T) {
+		testClientGzip(t, gzippedFoobar, gzippedFoobar, true, true)
+	})
+}
 
-		It("errors on invalid HEADERS frames", func() {
-			rspBuf := bytes.NewBuffer(encodeResponse(418))
-
-			b := (&headersFrame{Length: 10}).Append(nil)
-			b = append(b, []byte("invalid headers frame")...)
-			rspBuf.Write(b)
-
-			gomock.InOrder(
-				conn.EXPECT().HandshakeComplete().Return(handshakeChan),
-				conn.EXPECT().OpenStreamSync(context.Background()).Return(str, nil),
-				conn.EXPECT().ConnectionState().Return(quic.ConnectionState{}),
-			)
-			str.EXPECT().Write(gomock.Any()).AnyTimes().DoAndReturn(func(p []byte) (int, error) { return len(p), nil })
-			str.EXPECT().Close()
-			str.EXPECT().Read(gomock.Any()).DoAndReturn(rspBuf.Read).AnyTimes()
-			tr := &Transport{}
-			cc := tr.NewClientConn(conn)
-			rsp, err := cc.RoundTrip(req)
-			Expect(err).ToNot(HaveOccurred())
-			_, err = io.ReadAll(rsp.Body)
-			Expect(err).To(HaveOccurred())
-		})
+func testClientGzip(t *testing.T,
+	data []byte,
+	expectedRsp []byte,
+	transportDisableCompression bool,
+	responseAddContentEncoding bool,
+) {
+	mockCtrl := gomock.NewController(t)
 
-		It("returns an error if trailers are sent twice", func() {
-			rspBuf := bytes.NewBuffer(encodeResponse(418))
-
-			{
-				trailerBuf := &bytes.Buffer{}
-				enc := qpack.NewEncoder(trailerBuf)
-				Expect(enc.WriteField(qpack.HeaderField{Name: "This-Is-A-Trailer", Value: "0"})).To(Succeed())
-				Expect(enc.Close()).To(Succeed())
-				b := (&headersFrame{Length: uint64(trailerBuf.Len())}).Append(nil)
-				b = append(b, trailerBuf.Bytes()...)
-				rspBuf.Write(b)
-			}
+	var rspBuf bytes.Buffer
+	rstr := mockquic.NewMockStream(mockCtrl)
+	rstr.EXPECT().Write(gomock.Any()).Do(rspBuf.Write).AnyTimes()
+	rw := newResponseWriter(newStream(rstr, nil, nil, func(r io.Reader, u uint64) error { return nil }), nil, false, nil)
+	rw.WriteHeader(http.StatusOK)
+	if responseAddContentEncoding {
+		rw.header.Add("Content-Encoding", "gzip")
+	}
+	rw.Write(data)
+	rw.Flush()
 
-			{
-				trailerBuf := &bytes.Buffer{}
-				enc := qpack.NewEncoder(trailerBuf)
-				Expect(enc.WriteField(qpack.HeaderField{Name: "This-Is-A-Trailer", Value: "1"})).To(Succeed())
-				Expect(enc.Close()).To(Succeed())
-				b := (&headersFrame{Length: uint64(trailerBuf.Len())}).Append(nil)
-				b = append(b, trailerBuf.Bytes()...)
-				rspBuf.Write(b)
-			}
+	conn := mockquic.NewMockEarlyConnection(mockCtrl)
+	conn.EXPECT().Context().Return(context.Background()).AnyTimes()
+	conn.EXPECT().HandshakeComplete().Return(closedChan()).AnyTimes()
+	done := make(chan struct{})
+	conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
+		<-done
+		return nil, assert.AnError
+	}).MaxTimes(1)
+	controlStr := mockquic.NewMockStream(mockCtrl)
+	controlStr.EXPECT().Write(gomock.Any()).MaxTimes(1)
+	conn.EXPECT().OpenUniStream().Return(controlStr, nil).MaxTimes(1)
+	conn.EXPECT().ConnectionState().MaxTimes(1)
+
+	req, err := http.NewRequest(http.MethodGet, "http://quic-go.net", nil)
+	require.NoError(t, err)
+
+	var buf bytes.Buffer
+	str := mockquic.NewMockStream(mockCtrl)
+	str.EXPECT().Context().Return(context.Background()).AnyTimes()
+	str.EXPECT().StreamID().AnyTimes()
+	str.EXPECT().Write(gomock.Any()).DoAndReturn(buf.Write).AnyTimes()
+	str.EXPECT().Close()
+	str.EXPECT().Read(gomock.Any()).DoAndReturn(bytes.NewReader(rspBuf.Bytes()).Read).AnyTimes()
+	conn.EXPECT().OpenStreamSync(gomock.Any()).Return(str, nil)
+
+	cc := (&Transport{DisableCompression: transportDisableCompression}).NewClientConn(conn)
+	rsp, err := cc.RoundTrip(req)
+	require.NoError(t, err)
+	require.Equal(t, http.StatusOK, rsp.StatusCode)
+	body, err := io.ReadAll(rsp.Body)
+	require.NoError(t, err)
+	require.Equal(t, expectedRsp, body)
+
+	// request headers
+	hfs := decodeHeader(t, &buf)
+	if transportDisableCompression {
+		require.NotContains(t, hfs, "accept-encoding")
+	} else {
+		require.Equal(t, hfs["accept-encoding"], []string{"gzip"})
+	}
+}
 
-			gomock.InOrder(
-				conn.EXPECT().HandshakeComplete().Return(handshakeChan),
-				conn.EXPECT().OpenStreamSync(context.Background()).Return(str, nil),
-				conn.EXPECT().ConnectionState().Return(quic.ConnectionState{}),
-			)
-			str.EXPECT().Write(gomock.Any()).AnyTimes().DoAndReturn(func(p []byte) (int, error) { return len(p), nil })
-			str.EXPECT().Close()
-			str.EXPECT().Read(gomock.Any()).DoAndReturn(rspBuf.Read).AnyTimes()
-			tr := &Transport{}
-			cc := tr.NewClientConn(conn)
-			rsp, err := cc.RoundTrip(req)
-			Expect(err).ToNot(HaveOccurred())
-			_, err = io.ReadAll(rsp.Body)
-			Expect(err).To(MatchError(errors.New("additional HEADERS frame received after trailers")))
-			Expect(rsp.Trailer).To(Equal(http.Header{"This-Is-A-Trailer": []string{"0"}}))
-			Expect(rsp.Proto).To(Equal("HTTP/3.0"))
-			Expect(rsp.ProtoMajor).To(Equal(3))
-			Expect(rsp.StatusCode).To(Equal(418))
-			Expect(rsp.Request).ToNot(BeNil())
-		})
+func TestClientRequestCancellationBeforeHandshakeCompletion(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	conn := mockquic.NewMockEarlyConnection(mockCtrl)
+	conn.EXPECT().HandshakeComplete().Return(make(chan struct{})).AnyTimes()
+	conn.EXPECT().Context().Return(context.Background()).AnyTimes()
+	done := make(chan struct{})
+	conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
+		<-done
+		return nil, assert.AnError
+	}).MaxTimes(1)
+	controlStr := mockquic.NewMockStream(mockCtrl)
+	controlStr.EXPECT().Write(gomock.Any()).MaxTimes(1)
+	conn.EXPECT().OpenUniStream().Return(controlStr, nil).MaxTimes(1)
+
+	ctx, cancel := context.WithCancel(context.Background())
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://quic-go.net", nil)
+	require.NoError(t, err)
+
+	cc := (&Transport{}).NewClientConn(conn)
+	errChan := make(chan error)
+	go func() {
+		_, err := cc.RoundTrip(req)
+		errChan <- err
+	}()
+	select {
+	case <-errChan:
+		t.Fatalf("RoundTrip not have returned: %v", err)
+	case <-time.After(scaleDuration(10 * time.Millisecond)):
+	}
+	cancel()
+	select {
+	case err := <-errChan:
+		require.ErrorIs(t, err, context.Canceled)
+	case <-time.After(scaleDuration(10 * time.Millisecond)):
+		t.Fatal("timeout")
+	}
+}
 
-		It("returns an error if body frame is received after trailers", func() {
-			rspBuf := bytes.NewBuffer(encodeResponse(418))
-
-			{
-				trailerBuf := &bytes.Buffer{}
-				enc := qpack.NewEncoder(trailerBuf)
-				Expect(enc.WriteField(qpack.HeaderField{Name: "This-Is-A-Trailer", Value: "0"})).To(Succeed())
-				Expect(enc.Close()).To(Succeed())
-				b := (&headersFrame{Length: uint64(trailerBuf.Len())}).Append(nil)
-				b = append(b, trailerBuf.Bytes()...)
-				rspBuf.Write(b)
-			}
+func TestClientRequestCancellation(t *testing.T) {
+	t.Run("before receiving response", func(t *testing.T) {
+		testClientRequestCancellation(t, false)
+	})
+	t.Run("after receiving response", func(t *testing.T) {
+		testClientRequestCancellation(t, true)
+	})
+}
 
-			{
-				dataBuf := &bytes.Buffer{}
-				dataBuf.Write([]byte("test body please ignore"))
-				b := (&dataFrame{Length: uint64(dataBuf.Len())}).Append(nil)
-				b = append(b, dataBuf.Bytes()...)
-				rspBuf.Write(b)
-			}
+func testClientRequestCancellation(t *testing.T, receiveResponse bool) {
+	mockCtrl := gomock.NewController(t)
+	conn := mockquic.NewMockEarlyConnection(mockCtrl)
+	conn.EXPECT().HandshakeComplete().Return(closedChan()).AnyTimes()
+	conn.EXPECT().Context().Return(context.Background()).AnyTimes()
+	done := make(chan struct{})
+	conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
+		<-done
+		return nil, assert.AnError
+	}).MaxTimes(1)
+	controlStr := mockquic.NewMockStream(mockCtrl)
+	controlStr.EXPECT().Write(gomock.Any()).MaxTimes(1)
+	conn.EXPECT().OpenUniStream().Return(controlStr, nil).MaxTimes(1)
+
+	var buf bytes.Buffer
+	str := mockquic.NewMockStream(mockCtrl)
+	str.EXPECT().Context().Return(context.Background()).AnyTimes()
+	str.EXPECT().StreamID().AnyTimes()
+	str.EXPECT().Write(gomock.Any()).DoAndReturn(buf.Write).AnyTimes()
+	// the order doesn't matter, but we should close the done channel after the last of the two calls
+	gomock.InOrder(
+		str.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeRequestCanceled)),
+		str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeRequestCanceled)).Do(func(quic.StreamErrorCode) {
+			close(done)
+		}),
+	)
+	// repeated cancellations are fine, the QUIC layer will ignore them
+	str.EXPECT().CancelWrite(gomock.Any()).AnyTimes()
+	str.EXPECT().CancelRead(gomock.Any()).AnyTimes()
+	str.EXPECT().Close()
+	if receiveResponse {
+		str.EXPECT().Read(gomock.Any()).DoAndReturn(
+			bytes.NewReader(encodeResponse(t, http.StatusTeapot)).Read,
+		).AnyTimes()
+		conn.EXPECT().ConnectionState()
+	} else {
+		str.EXPECT().Read(gomock.Any()).DoAndReturn(func(b []byte) (int, error) {
+			<-done
+			return 0, errors.New("done")
+		}).AnyTimes()
+	}
+	conn.EXPECT().OpenStreamSync(gomock.Any()).Return(str, nil)
 
-			gomock.InOrder(
-				conn.EXPECT().HandshakeComplete().Return(handshakeChan),
-				conn.EXPECT().OpenStreamSync(context.Background()).Return(str, nil),
-				conn.EXPECT().ConnectionState().Return(quic.ConnectionState{}),
-			)
-			str.EXPECT().Write(gomock.Any()).AnyTimes().DoAndReturn(func(p []byte) (int, error) { return len(p), nil })
-			str.EXPECT().Close()
-			str.EXPECT().Read(gomock.Any()).DoAndReturn(rspBuf.Read).AnyTimes()
-			tr := &Transport{}
-			cc := tr.NewClientConn(conn)
-			rsp, err := cc.RoundTrip(req)
-			Expect(err).ToNot(HaveOccurred())
-			_, err = io.ReadAll(rsp.Body)
-			Expect(err).To(MatchError(errors.New("DATA frame received after trailers")))
-			Expect(rsp.Trailer).To(Equal(http.Header{"This-Is-A-Trailer": []string{"0"}}))
-			Expect(rsp.Proto).To(Equal("HTTP/3.0"))
-			Expect(rsp.ProtoMajor).To(Equal(3))
-			Expect(rsp.StatusCode).To(Equal(418))
-			Expect(rsp.Request).ToNot(BeNil())
-		})
+	ctx, cancel := context.WithCancel(context.Background())
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://quic-go.net", nil)
+	require.NoError(t, err)
 
-		Context("requests containing a Body", func() {
-			var strBuf *bytes.Buffer
-
-			BeforeEach(func() {
-				strBuf = &bytes.Buffer{}
-				gomock.InOrder(
-					conn.EXPECT().HandshakeComplete().Return(handshakeChan),
-					conn.EXPECT().OpenStreamSync(context.Background()).Return(str, nil),
-				)
-				body := &mockBody{}
-				body.SetData([]byte("request body"))
-				var err error
-				req, err = http.NewRequest("POST", "https://quic.clemente.io:1337/upload", body)
-				Expect(err).ToNot(HaveOccurred())
-				str.EXPECT().Write(gomock.Any()).DoAndReturn(strBuf.Write).AnyTimes()
-			})
-
-			It("sends a request", func() {
-				done := make(chan struct{})
-				gomock.InOrder(
-					str.EXPECT().Close().Do(func() error { close(done); return nil }),
-					// when reading the response errors
-					str.EXPECT().CancelRead(gomock.Any()).MaxTimes(1),
-					str.EXPECT().CancelWrite(gomock.Any()).MaxTimes(1),
-				)
-				// the response body is sent asynchronously, while already reading the response
-				testErr := errors.New("test done")
-				str.EXPECT().Read(gomock.Any()).DoAndReturn(func([]byte) (int, error) {
-					<-done
-					return 0, testErr
-				})
-				tr := &Transport{}
-				cc := tr.NewClientConn(conn)
-				_, err := cc.RoundTrip(req)
-				Expect(err).To(MatchError(testErr))
-				hfs := decodeHeader(strBuf)
-				Expect(hfs).To(HaveKeyWithValue(":method", "POST"))
-				Expect(hfs).To(HaveKeyWithValue(":path", "/upload"))
-			})
-
-			It("doesn't send more bytes than allowed by http.Request.ContentLength", func() {
-				req.ContentLength = 7
-				var once sync.Once
-				done := make(chan struct{})
-				str.EXPECT().CancelRead(gomock.Any())
-				gomock.InOrder(
-					str.EXPECT().CancelWrite(gomock.Any()).Do(func(c quic.StreamErrorCode) {
-						once.Do(func() {
-							Expect(c).To(Equal(quic.StreamErrorCode(ErrCodeRequestCanceled)))
-							close(done)
-						})
-					}).AnyTimes(),
-					str.EXPECT().Close().MaxTimes(1),
-					str.EXPECT().CancelWrite(gomock.Any()).AnyTimes(),
-				)
-				str.EXPECT().Read(gomock.Any()).DoAndReturn(func([]byte) (int, error) {
-					<-done
-					return 0, errors.New("done")
-				})
-				tr := &Transport{}
-				cc := tr.NewClientConn(conn)
-				_, err := cc.RoundTrip(req)
-				Expect(err).To(HaveOccurred())
-				Expect(strBuf.String()).To(ContainSubstring("request"))
-				Expect(strBuf.String()).ToNot(ContainSubstring("request body"))
-			})
-
-			It("returns the error that occurred when reading the body", func() {
-				req.Body.(*mockBody).readErr = errors.New("testErr")
-				done := make(chan struct{})
-				str.EXPECT().CancelRead(gomock.Any())
-				gomock.InOrder(
-					str.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeRequestCanceled)).Do(func(quic.StreamErrorCode) {
-						close(done)
-					}),
-					str.EXPECT().CancelWrite(gomock.Any()),
-				)
-
-				// the response body is sent asynchronously, while already reading the response
-				testErr := errors.New("test done")
-				str.EXPECT().Read(gomock.Any()).DoAndReturn(func([]byte) (int, error) {
-					<-done
-					return 0, testErr
-				})
-				closed := make(chan struct{})
-				str.EXPECT().Close().Do(func() error { close(closed); return nil })
-				tr := &Transport{}
-				cc := tr.NewClientConn(conn)
-				_, err := cc.RoundTrip(req)
-				Expect(err).To(MatchError(testErr))
-				Eventually(closed).Should(BeClosed())
-			})
-
-			It("closes the connection when the first frame is not a HEADERS frame", func() {
-				b := (&dataFrame{Length: 0x42}).Append(nil)
-				conn.EXPECT().CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameUnexpected), gomock.Any())
-				closed := make(chan struct{})
-				r := bytes.NewReader(b)
-				str.EXPECT().Close().Do(func() error { close(closed); return nil })
-				str.EXPECT().Read(gomock.Any()).DoAndReturn(r.Read).AnyTimes()
-				tr := &Transport{}
-				cc := tr.NewClientConn(conn)
-				_, err := cc.RoundTrip(req)
-				Expect(err).To(MatchError("http3: expected first frame to be a HEADERS frame"))
-				Eventually(closed).Should(BeClosed())
-			})
-
-			It("cancels the stream when parsing the headers fails", func() {
-				headerBuf := &bytes.Buffer{}
-				enc := qpack.NewEncoder(headerBuf)
-				Expect(enc.WriteField(qpack.HeaderField{Name: ":method", Value: "GET"})).To(Succeed()) // not a valid response pseudo header
-				Expect(enc.Close()).To(Succeed())
-				b := (&headersFrame{Length: uint64(headerBuf.Len())}).Append(nil)
-				b = append(b, headerBuf.Bytes()...)
-
-				r := bytes.NewReader(b)
-				str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeMessageError))
-				str.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeMessageError))
-				closed := make(chan struct{})
-				str.EXPECT().Close().Do(func() error { close(closed); return nil })
-				str.EXPECT().Read(gomock.Any()).DoAndReturn(r.Read).AnyTimes()
-				tr := &Transport{}
-				cc := tr.NewClientConn(conn)
-				_, err := cc.RoundTrip(req)
-				Expect(err).To(HaveOccurred())
-				Eventually(closed).Should(BeClosed())
-			})
-
-			It("cancels the stream when the HEADERS frame is too large", func() {
-				tr := &Transport{MaxResponseHeaderBytes: 1337}
-				cc := tr.NewClientConn(conn)
-				b := (&headersFrame{Length: 1338}).Append(nil)
-				r := bytes.NewReader(b)
-				str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeFrameError))
-				str.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeFrameError))
-				closed := make(chan struct{})
-				str.EXPECT().Close().Do(func() error { close(closed); return nil })
-				str.EXPECT().Read(gomock.Any()).DoAndReturn(r.Read).AnyTimes()
-				_, err := cc.RoundTrip(req)
-				Expect(err).To(MatchError("http3: HEADERS frame too large: 1338 bytes (max: 1337)"))
-				Eventually(closed).Should(BeClosed())
-			})
-
-			It("opens a request stream", func() {
-				tr := &Transport{}
-				cc := tr.NewClientConn(conn)
-				conn.HandshakeComplete()
-				str, err := cc.OpenRequestStream(context.Background())
-				Expect(err).ToNot(HaveOccurred())
-				Expect(str.SendRequestHeader(req)).To(Succeed())
-				str.Write([]byte("foobar"))
-				d := dataFrame{Length: 6}
-				data := d.Append([]byte{})
-				data = append(data, []byte("foobar")...)
-				Expect(bytes.Contains(strBuf.Bytes(), data)).To(BeTrue())
-			})
-		})
+	cc := (&Transport{}).NewClientConn(conn)
 
-		Context("request cancellations", func() {
-			It("cancels a request while waiting for the handshake to complete", func() {
-				ctx, cancel := context.WithCancel(context.Background())
-				req := req.WithContext(ctx)
-				conn.EXPECT().HandshakeComplete().Return(make(chan struct{}))
-
-				tr := &Transport{}
-				cc := tr.NewClientConn(conn)
-				errChan := make(chan error)
-				go func() {
-					_, err := cc.RoundTrip(req)
-					errChan <- err
-				}()
-				Consistently(errChan).ShouldNot(Receive())
-				cancel()
-				Eventually(errChan).Should(Receive(MatchError("context canceled")))
-			})
-
-			It("cancels a request while the request is still in flight", func() {
-				ctx, cancel := context.WithCancel(context.Background())
-				req := req.WithContext(ctx)
-				conn.EXPECT().HandshakeComplete().Return(handshakeChan)
-				conn.EXPECT().OpenStreamSync(ctx).Return(str, nil)
-				buf := &bytes.Buffer{}
-				str.EXPECT().Close().MaxTimes(1)
-				str.EXPECT().Write(gomock.Any()).DoAndReturn(buf.Write)
-
-				done := make(chan struct{})
-				canceled := make(chan struct{})
-				gomock.InOrder(
-					str.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeRequestCanceled)).Do(func(quic.StreamErrorCode) { close(canceled) }),
-					str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeRequestCanceled)).Do(func(quic.StreamErrorCode) { close(done) }),
-				)
-				str.EXPECT().CancelWrite(gomock.Any()).MaxTimes(1)
-				str.EXPECT().CancelRead(gomock.Any()).MaxTimes(1)
-				str.EXPECT().Read(gomock.Any()).DoAndReturn(func([]byte) (int, error) {
-					cancel()
-					<-canceled
-					return 0, errors.New("test done")
-				})
-				tr := &Transport{}
-				cc := tr.NewClientConn(conn)
-				_, err := cc.RoundTrip(req)
-				Expect(err).To(MatchError(context.Canceled))
-				Eventually(done).Should(BeClosed())
-			})
-
-			It("cancels a request after the response arrived", func() {
-				rspBuf := bytes.NewBuffer(encodeResponse(404))
-
-				ctx, cancel := context.WithCancel(context.Background())
-				req := req.WithContext(ctx)
-				conn.EXPECT().HandshakeComplete().Return(handshakeChan)
-				conn.EXPECT().OpenStreamSync(ctx).Return(str, nil)
-				conn.EXPECT().ConnectionState().Return(quic.ConnectionState{})
-				buf := &bytes.Buffer{}
-				str.EXPECT().Close().MaxTimes(1)
-
-				done := make(chan struct{})
-				str.EXPECT().Write(gomock.Any()).DoAndReturn(buf.Write)
-				str.EXPECT().Read(gomock.Any()).DoAndReturn(rspBuf.Read).AnyTimes()
-				str.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeRequestCanceled))
-				str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeRequestCanceled)).Do(func(quic.StreamErrorCode) { close(done) })
-				tr := &Transport{}
-				cc := tr.NewClientConn(conn)
-				_, err := cc.RoundTrip(req)
-				Expect(err).ToNot(HaveOccurred())
-				cancel()
-				Eventually(done).Should(BeClosed())
-			})
-		})
+	if receiveResponse {
+		r, err := cc.RoundTrip(req)
+		require.NoError(t, err)
+		require.Equal(t, http.StatusTeapot, r.StatusCode)
+		cancel()
+
+		select {
+		case <-done:
+		case <-time.After(time.Second):
+			t.Fatal("stream should have been reset")
+		}
+		return
+	}
 
-		Context("gzip compression", func() {
-			BeforeEach(func() {
-				conn.EXPECT().HandshakeComplete().Return(handshakeChan)
-			})
-
-			It("adds the gzip header to requests", func() {
-				conn.EXPECT().OpenStreamSync(context.Background()).Return(str, nil)
-				buf := &bytes.Buffer{}
-				str.EXPECT().Write(gomock.Any()).DoAndReturn(buf.Write)
-				gomock.InOrder(
-					str.EXPECT().Close(),
-					// when the Read errors
-					str.EXPECT().CancelRead(gomock.Any()).MaxTimes(1),
-					str.EXPECT().CancelWrite(gomock.Any()).MaxTimes(1),
-				)
-				testErr := errors.New("test done")
-				str.EXPECT().Read(gomock.Any()).Return(0, testErr)
-				tr := &Transport{}
-				cc := tr.NewClientConn(conn)
-				_, err := cc.RoundTrip(req)
-				Expect(err).To(MatchError(testErr))
-				hfs := decodeHeader(buf)
-				Expect(hfs).To(HaveKeyWithValue("accept-encoding", "gzip"))
-			})
-
-			It("doesn't add gzip if the header disable it", func() {
-				tr := &Transport{DisableCompression: true}
-				client := tr.NewClientConn(conn)
-				conn.EXPECT().OpenStreamSync(context.Background()).Return(str, nil)
-				buf := &bytes.Buffer{}
-				str.EXPECT().Write(gomock.Any()).DoAndReturn(buf.Write)
-				gomock.InOrder(
-					str.EXPECT().Close(),
-					// when the Read errors
-					str.EXPECT().CancelRead(gomock.Any()).MaxTimes(1),
-					str.EXPECT().CancelWrite(gomock.Any()).MaxTimes(1),
-				)
-				testErr := errors.New("test done")
-				str.EXPECT().Read(gomock.Any()).Return(0, testErr)
-				_, err := client.RoundTrip(req)
-				Expect(err).To(MatchError(testErr))
-				hfs := decodeHeader(buf)
-				Expect(hfs).ToNot(HaveKey("accept-encoding"))
-			})
-
-			It("decompresses the response", func() {
-				conn.EXPECT().OpenStreamSync(context.Background()).Return(str, nil)
-				conn.EXPECT().ConnectionState().Return(quic.ConnectionState{})
-				buf := &bytes.Buffer{}
-				rstr := mockquic.NewMockStream(mockCtrl)
-				rstr.EXPECT().StreamID().AnyTimes()
-				rstr.EXPECT().Write(gomock.Any()).Do(buf.Write).AnyTimes()
-				rw := newResponseWriter(newStream(rstr, nil, nil, func(r io.Reader, u uint64) error { return nil }), nil, false, nil)
-				rw.Header().Set("Content-Encoding", "gzip")
-				gz := gzip.NewWriter(rw)
-				gz.Write([]byte("gzipped response"))
-				gz.Close()
-				rw.Flush()
-				str.EXPECT().Write(gomock.Any()).AnyTimes().DoAndReturn(func(p []byte) (int, error) { return len(p), nil })
-				str.EXPECT().Read(gomock.Any()).DoAndReturn(buf.Read).AnyTimes()
-				str.EXPECT().Close()
-
-				tr := &Transport{}
-				cc := tr.NewClientConn(conn)
-				rsp, err := cc.RoundTrip(req)
-				Expect(err).ToNot(HaveOccurred())
-				data, err := io.ReadAll(rsp.Body)
-				Expect(err).ToNot(HaveOccurred())
-				Expect(rsp.ContentLength).To(BeEquivalentTo(-1))
-				Expect(string(data)).To(Equal("gzipped response"))
-				Expect(rsp.Header.Get("Content-Encoding")).To(BeEmpty())
-				Expect(rsp.Uncompressed).To(BeTrue())
-			})
-
-			It("only decompresses the response if the response contains the right content-encoding header", func() {
-				conn.EXPECT().OpenStreamSync(context.Background()).Return(str, nil)
-				conn.EXPECT().ConnectionState().Return(quic.ConnectionState{})
-				buf := &bytes.Buffer{}
-				rstr := mockquic.NewMockStream(mockCtrl)
-				rstr.EXPECT().StreamID().AnyTimes()
-				rstr.EXPECT().Write(gomock.Any()).Do(buf.Write).AnyTimes()
-				rw := newResponseWriter(newStream(rstr, nil, nil, func(r io.Reader, u uint64) error { return nil }), nil, false, nil)
-				rw.Write([]byte("not gzipped"))
-				rw.Flush()
-				str.EXPECT().Write(gomock.Any()).AnyTimes().DoAndReturn(func(p []byte) (int, error) { return len(p), nil })
-				str.EXPECT().Read(gomock.Any()).DoAndReturn(buf.Read).AnyTimes()
-				str.EXPECT().Close()
-
-				tr := &Transport{}
-				cc := tr.NewClientConn(conn)
-				rsp, err := cc.RoundTrip(req)
-				Expect(err).ToNot(HaveOccurred())
-				data, err := io.ReadAll(rsp.Body)
-				Expect(err).ToNot(HaveOccurred())
-				Expect(string(data)).To(Equal("not gzipped"))
-				Expect(rsp.Header.Get("Content-Encoding")).To(BeEmpty())
-			})
+	errChan := make(chan error)
+	go func() {
+		_, err := cc.RoundTrip(req)
+		errChan <- err
+	}()
+	select {
+	case <-errChan:
+		t.Fatalf("RoundTrip not have returned: %v", err)
+	case <-time.After(scaleDuration(10 * time.Millisecond)):
+	}
+	cancel()
+	select {
+	case err := <-errChan:
+		require.ErrorIs(t, err, context.Canceled)
+	case <-time.After(scaleDuration(10 * time.Millisecond)):
+		t.Fatal("timeout")
+	}
+}
+
+func TestClientStreamHijacking(t *testing.T) {
+	t.Run("unidirectional", func(t *testing.T) {
+		t.Run("hijacking", func(t *testing.T) {
+			testClientStreamHijacking(t, false, true, nil)
+		})
+		t.Run("stream error", func(t *testing.T) {
+			testClientStreamHijacking(t, false, false, assert.AnError)
 		})
+	})
 
-		Context("1xx status code", func() {
-			It("continues to read next header if code is 103", func() {
-				var (
-					cnt    int
-					status int
-					hdr    textproto.MIMEHeader
-				)
-				header1 := "</style.css>; rel=preload; as=style"
-				header2 := "</script.js>; rel=preload; as=script"
-				ctx := httptrace.WithClientTrace(req.Context(), &httptrace.ClientTrace{
-					Got1xxResponse: func(code int, header textproto.MIMEHeader) error {
-						cnt++
-						status = code
-						hdr = header
-						return nil
-					},
-				})
-				req := req.WithContext(ctx)
-				rspBuf := bytes.NewBuffer(encodeResponse(103))
-				gomock.InOrder(
-					conn.EXPECT().HandshakeComplete().Return(handshakeChan),
-					conn.EXPECT().OpenStreamSync(ctx).Return(str, nil),
-					conn.EXPECT().ConnectionState().Return(quic.ConnectionState{}),
-				)
-				str.EXPECT().Write(gomock.Any()).AnyTimes().DoAndReturn(func(p []byte) (int, error) { return len(p), nil })
-				str.EXPECT().Close()
-				str.EXPECT().Read(gomock.Any()).DoAndReturn(rspBuf.Read).AnyTimes()
-				tr := &Transport{}
-				cc := tr.NewClientConn(conn)
-				rsp, err := cc.RoundTrip(req)
-				Expect(err).ToNot(HaveOccurred())
-				Expect(rsp.Proto).To(Equal("HTTP/3.0"))
-				Expect(rsp.ProtoMajor).To(Equal(3))
-				Expect(rsp.StatusCode).To(Equal(200))
-				Expect(rsp.Header).To(HaveKeyWithValue("Link", []string{header1, header2}))
-				Expect(status).To(Equal(103))
-				Expect(cnt).To(Equal(1))
-				Expect(hdr).To(HaveKeyWithValue("Link", []string{header1, header2}))
-				Expect(rsp.Request).ToNot(BeNil())
-			})
-
-			It("doesn't continue to read next header if code is a terminal status", func() {
-				cnt := 0
-				status := 0
-				ctx := httptrace.WithClientTrace(req.Context(), &httptrace.ClientTrace{
-					Got1xxResponse: func(code int, header textproto.MIMEHeader) error {
-						cnt++
-						status = code
-						return nil
-					},
-				})
-				req := req.WithContext(ctx)
-				rspBuf := bytes.NewBuffer(encodeResponse(101))
-				gomock.InOrder(
-					conn.EXPECT().HandshakeComplete().Return(handshakeChan),
-					conn.EXPECT().OpenStreamSync(ctx).Return(str, nil),
-					conn.EXPECT().ConnectionState().Return(quic.ConnectionState{}),
-				)
-				str.EXPECT().Write(gomock.Any()).AnyTimes().DoAndReturn(func(p []byte) (int, error) { return len(p), nil })
-				str.EXPECT().Close()
-				str.EXPECT().Read(gomock.Any()).DoAndReturn(rspBuf.Read).AnyTimes()
-				tr := &Transport{}
-				cc := tr.NewClientConn(conn)
-				rsp, err := cc.RoundTrip(req)
-				Expect(err).ToNot(HaveOccurred())
-				Expect(rsp.Proto).To(Equal("HTTP/3.0"))
-				Expect(rsp.ProtoMajor).To(Equal(3))
-				Expect(rsp.StatusCode).To(Equal(101))
-				Expect(status).To(Equal(0))
-				Expect(cnt).To(Equal(0))
-				Expect(rsp.Request).ToNot(BeNil())
-			})
+	t.Run("bidirectional", func(t *testing.T) {
+		t.Run("hijacking", func(t *testing.T) {
+			testClientStreamHijacking(t, true, true, nil)
+		})
+		t.Run("stream error", func(t *testing.T) {
+			testClientStreamHijacking(t, true, false, assert.AnError)
 		})
 	})
-})
+}
+
+func testClientStreamHijacking(t *testing.T, bidirectional, doHijack bool, streamReadErr error) {
+	type hijackCall struct {
+		ft            FrameType  // for bidirectional streams
+		st            StreamType // for unidirectional streams
+		connTracingID quic.ConnectionTracingID
+		e             error
+	}
+
+	hijackChan := make(chan hijackCall, 1)
+	tr := &Transport{}
+	switch bidirectional {
+	case true:
+		tr.StreamHijacker = func(ft FrameType, id quic.ConnectionTracingID, _ quic.Stream, e error) (hijacked bool, err error) {
+			hijackChan <- hijackCall{ft: ft, connTracingID: id, e: e}
+			if !doHijack {
+				return false, errors.New("not hijacking")
+			}
+			return true, nil
+		}
+	case false:
+		tr.UniStreamHijacker = func(st StreamType, id quic.ConnectionTracingID, rs quic.ReceiveStream, e error) (hijacked bool) {
+			hijackChan <- hijackCall{st: st, connTracingID: id, e: e}
+			return doHijack
+		}
+	}
+
+	const id = quic.ConnectionTracingID(1234)
+	ctx := context.WithValue(context.Background(), quic.ConnectionTracingKey, id)
+
+	mockCtrl := gomock.NewController(t)
+	str := mockquic.NewMockStream(mockCtrl)
+	if streamReadErr == nil {
+		str.EXPECT().Read(gomock.Any()).DoAndReturn(
+			bytes.NewReader(quicvarint.Append(nil, 0x41)).Read,
+		).AnyTimes()
+	} else {
+		str.EXPECT().Read(gomock.Any()).Return(0, streamReadErr).AnyTimes()
+	}
+
+	conn := mockquic.NewMockEarlyConnection(mockCtrl)
+	conn.EXPECT().HandshakeComplete().Return(closedChan()).AnyTimes()
+	conn.EXPECT().Context().Return(ctx).AnyTimes()
+
+	done := make(chan struct{})
+	if bidirectional {
+		conn.EXPECT().AcceptStream(gomock.Any()).Return(str, nil)
+		conn.EXPECT().AcceptStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.Stream, error) {
+			<-done
+			return nil, errors.New("done")
+		}).MaxTimes(1)
+		if !doHijack {
+			conn.EXPECT().CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameUnexpected), gomock.Any()).AnyTimes()
+		}
+	} else {
+		conn.EXPECT().AcceptUniStream(gomock.Any()).Return(str, nil)
+	}
+
+	conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
+		<-done
+		return nil, errors.New("done")
+	}).MaxTimes(1)
+	controlStr := mockquic.NewMockStream(mockCtrl)
+	controlStr.EXPECT().Write(gomock.Any()).MaxTimes(1)
+	conn.EXPECT().OpenUniStream().Return(controlStr, nil).MaxTimes(1)
+
+	_ = tr.NewClientConn(conn)
+
+	select {
+	case hijackCall := <-hijackChan:
+		assert.Equal(t, id, hijackCall.connTracingID)
+		if streamReadErr == nil {
+			if bidirectional {
+				assert.Equal(t, FrameType(0x41), hijackCall.ft)
+			} else {
+				assert.Equal(t, StreamType(0x41), hijackCall.st)
+			}
+			assert.NoError(t, hijackCall.e)
+		} else {
+			assert.ErrorIs(t, hijackCall.e, streamReadErr)
+		}
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+}
diff --git a/http3/http_stream_test.go b/http3/http_stream_test.go
index b10ad782..755391ae 100644
--- a/http3/http_stream_test.go
+++ b/http3/http_stream_test.go
@@ -184,6 +184,16 @@ var _ = Describe("Request Stream", func() {
 	})
 
 	It("reads after the response", func() {
+		encodeResponse := func(status int) []byte {
+			buf := &bytes.Buffer{}
+			rstr := mockquic.NewMockStream(mockCtrl)
+			rstr.EXPECT().Write(gomock.Any()).Do(buf.Write).AnyTimes()
+			rw := newResponseWriter(newStream(rstr, nil, nil, func(r io.Reader, u uint64) error { return nil }), nil, false, nil)
+			rw.WriteHeader(status)
+			rw.Flush()
+			return buf.Bytes()
+		}
+
 		req, err := http.NewRequest(http.MethodGet, "https://quic-go.net", nil)
 		Expect(err).ToNot(HaveOccurred())
 		qstr.EXPECT().Write(gomock.Any()).AnyTimes()