Repository: quic-go/quic-go
Pull request #5229 (2025-06-17 20:57:30)

quicvarint: speed up parsing of 1, 2 and 4 byte varints 
I would like to propose the following changes. quicvarint.Parse is one of the most called functions during parsing, and it is responsible for about 40% of time spend during parsing, at least for this benchmark. Maybe (depending on the number of ACK frames), it may be a bit lower, but even it if was 10%, it would be significant. 

`go test -bench=BenchmarkParseStreamAndACK -cpuprofile=cpu.pprof`

<img width="613" alt="Screenshot 2025-06-17 at 22 10 20" src="https://github.com/user-attachments/assets/e87de867-8ec6-463b-b31c-9df3c8640e1b" />

Thus, if we were to improve the performance of quicvarint.Parse, we could significantly improve the performance during parsing, and I believe adjustments here could be worth the effort. 

I propose the following changes: 

- We can further stop converting the length into a number, to further improve the performance
- We can use a bitwise OR pipe operator instead of the addition - this seems to be slightly faster than the addition, at least minimally. This also seems to be the way to go in the go standard library, see binary.BigEndian.Uint64. 
- We can use the binary.BigEndian module to convert 8 byte directly into an uint64. This seems to be faster for the 8 byte integer only though (even though it uses pretty much the same code, just in the standard library). Adding the bounds check from the standard library directly to our code also didn't made a difference. 

Overall, I believe this change could improve the overall stream parsing by another few percentage points.

```
goos: darwin
goarch: arm64
pkg: github.com/quic-go/quic-go/quicvarint
cpu: Apple M4 Max
                │ original.out │         original-pipe.out          │              plus.out               │                pipe.out                │               binary.out               │                mix.out                 │
                │    sec/op    │   sec/op     vs base               │   sec/op     vs base                │   sec/op     vs base                   │   sec/op     vs base                   │   sec/op     vs base                   │
Parse/1-byte-16    1.947n ± 1%   1.973n ± 1%  +1.34% (p=0.001 n=10)   1.723n ± 0%  -11.50% (p=0.000 n=10)   1.689n ± 1%  -13.25% (p=0.000 n=10+60)   1.705n ± 1%  -12.43% (p=0.000 n=10+60)   1.669n ± 0%  -14.30% (p=0.000 n=10+60)
Parse/2-byte-16    2.030n ± 1%   2.039n ± 0%  +0.44% (p=0.022 n=10)   1.775n ± 0%  -12.54% (p=0.000 n=10)   1.768n ± 0%  -12.88% (p=0.000 n=10+60)   1.769n ± 0%  -12.86% (p=0.000 n=10+60)   1.748n ± 0%  -13.89% (p=0.000 n=10+60)
Parse/4-byte-16    2.012n ± 1%   1.998n ± 1%       ~ (p=0.054 n=10)   1.958n ± 0%   -2.71% (p=0.000 n=10)   1.972n ± 0%   -1.99% (p=0.000 n=10+60)   1.956n ± 0%   -2.78% (p=0.000 n=10+60)   1.948n ± 0%   -3.16% (p=0.000 n=10+60)
Parse/8-byte-16    2.014n ± 0%   2.002n ± 1%  -0.60% (p=0.003 n=10)   2.444n ± 1%  +21.32% (p=0.000 n=10)   2.414n ± 2%  +19.83% (p=0.000 n=10+60)   2.201n ± 0%   +9.28% (p=0.000 n=10+60)   2.193n ± 0%   +8.89% (p=0.000 n=10+60)
geomean            2.001n        2.003n       +0.12%                  1.956n        -2.23%                  1.942n        -2.94%                     1.898n        -5.11%                     1.879n        -6.08%
```


I ran the following benchmarks: 

- original.out: The original code from before
- original-pipe.out: The original code, just with a bitwise OR instead of an addition
- pipe.out: The code attached below
- plus.out: The code for pipe.out, just replaced the | with an +
- binary.out: Attached below, it uses the binary module for the 4 byte / 8 byte varint
- mix.out: Using binary.BigEndian seems to be faster for 8 byte varints only. Thus, we're using this module here, and this is the version of the PR. I'm not fully sure why it is faster, as in the standard library, it is implemented the same way as if we wrote it here (and the bounds check should be omitted due to checking the length before anyway). still, it's faster this way. 

pipe.out:
```
func Parse(b []byte) (uint64 /* value */, int /* bytes consumed */, error) {
	if len(b) == 0 {
		return 0, 0, io.EOF
	}

	first := b[0]
	switch first >> 6 {
	case 0: // 1-byte encoding: 00xxxxxx
		return uint64(first & 0x3F), 1, nil
	case 1: // 2-byte encoding: 01xxxxxx
		if len(b) < 2 {
			return 0, 0, io.ErrUnexpectedEOF
		}
		return uint64(first&0x3F)<<8 | uint64(b[1]), 2, nil
	case 2: // 4-byte encoding: 10xxxxxx
		if len(b) < 4 {
			return 0, 0, io.ErrUnexpectedEOF
		}
		return uint64(first&0x3F)<<24 |
			uint64(b[1])<<16 |
			uint64(b[2])<<8 |
			uint64(b[3]), 4, nil
	case 3: // 8-byte encoding: 11xxxxxx
		if len(b) < 8 {
			return 0, 0, io.ErrUnexpectedEOF
		}
		return uint64(first&0x3F)<<56 |
			uint64(b[1])<<48 |
			uint64(b[2])<<40 |
			uint64(b[3])<<32 |
			uint64(b[4])<<24 |
			uint64(b[5])<<16 |
			uint64(b[6])<<8 |
			uint64(b[7]), 8, nil
	}

	// Should never be reached
	return 0, 0, io.ErrUnexpectedEOF
}
```

binary.out:
```
func Parse(b []byte) (uint64 /* value */, int /* bytes consumed */, error) {
	if len(b) == 0 {
		return 0, 0, io.EOF
	}

	first := b[0]
	switch first >> 6 {
	case 0: // 1-byte encoding: 00xxxxxx
		return uint64(first & 0x3F), 1, nil
	case 1: // 2-byte encoding: 01xxxxxx
		if len(b) < 2 {
			return 0, 0, io.ErrUnexpectedEOF
		}
		return uint64(first&0x3F)<<8 | uint64(b[1]), 2, nil
	case 2: // 4-byte encoding
		if len(b) < 4 {
			return 0, 0, io.ErrUnexpectedEOF
		}
		// inject the top 6 bits into the highest byte
		tmp := binary.BigEndian.Uint32(b[0:4])
		tmp &= 0x3FFFFFFF // clear top 2 bits (in first byte)
		return uint64(tmp), 4, nil

	case 3: // 8-byte encoding
		if len(b) < 8 {
			return 0, 0, io.ErrUnexpectedEOF
		}
		tmp := binary.BigEndian.Uint64(b[0:8])
		tmp &= 0x3FFFFFFFFFFFFFFF // clear top 2 bits
		return tmp, 8, nil
	}

	// Should never be reached
	return 0, 0, io.ErrUnexpectedEOF
}
```

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/quic-go/quic-go/pull/5229?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 `64.00000%` with `9 lines` in your changes missing coverage. Please review.
> Project coverage is 84.00%. Comparing base [(`d33d293`)](https://app.codecov.io/gh/quic-go/quic-go/commit/d33d293fd2ea5f8e399146f9237d2b179c90f1f1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) to head [(`acc4868`)](https://app.codecov.io/gh/quic-go/quic-go/commit/acc48682af9c14b69c735600731bcc75138cb51d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go).
> Report is 2 commits behind head on master.

| [Files with missing lines](https://app.codecov.io/gh/quic-go/quic-go/pull/5229?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 |
|---|---|---|
| [quicvarint/varint.go](https://app.codecov.io/gh/quic-go/quic-go/pull/5229?src=pr&el=tree&filepath=quicvarint%2Fvarint.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go#diff-cXVpY3ZhcmludC92YXJpbnQuZ28=) | 64.00% | [7 Missing and 2 partials :warning: ](https://app.codecov.io/gh/quic-go/quic-go/pull/5229?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             @@
##           master    #5229      +/-   ##
==========================================
- Coverage   84.10%   84.00%   -0.10%     
==========================================
  Files         154      150       -4     
  Lines       17449    17297     -152     
==========================================
- Hits        14674    14529     -145     
- Misses       2206     2208       +2     
+ Partials      569      560       -9     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/quic-go/quic-go/pull/5229?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).

jannis-seemann: @marten-seemann ready

Patch:
diff --git a/errors.go b/errors.go
index 502509b9..829730fe 100644
--- a/errors.go
+++ b/errors.go
@@ -7,38 +7,68 @@ import (
 )
 
 type (
-	TransportError          = qerr.TransportError
-	ApplicationError        = qerr.ApplicationError
+	// TransportError indicates an error that occurred on the QUIC transport layer.
+	// Every transport error other than CONNECTION_REFUSED and APPLICATION_ERROR is
+	// likely a bug in the implementation.
+	TransportError = qerr.TransportError
+	// ApplicationError is an application-defined error.
+	ApplicationError = qerr.ApplicationError
+	// VersionNegotiationError indicates a failure to negotiate a QUIC version.
 	VersionNegotiationError = qerr.VersionNegotiationError
-	StatelessResetError     = qerr.StatelessResetError
-	IdleTimeoutError        = qerr.IdleTimeoutError
-	HandshakeTimeoutError   = qerr.HandshakeTimeoutError
+	// StatelessResetError indicates a stateless reset was received.
+	// This can happen when the peer reboots, or when packets are misrouted.
+	// See section 10.3 of RFC 9000 for details.
+	StatelessResetError = qerr.StatelessResetError
+	// IdleTimeoutError indicates that the connection timed out because it was inactive for too long.
+	IdleTimeoutError = qerr.IdleTimeoutError
+	// HandshakeTimeoutError indicates that the connection timed out before completing the handshake.
+	HandshakeTimeoutError = qerr.HandshakeTimeoutError
 )
 
 type (
-	TransportErrorCode   = qerr.TransportErrorCode
+	// TransportErrorCode is a QUIC transport error code, see section 20 of RFC 9000.
+	TransportErrorCode = qerr.TransportErrorCode
+	// ApplicationErrorCode is an QUIC application error code.
 	ApplicationErrorCode = qerr.ApplicationErrorCode
-	StreamErrorCode      = qerr.StreamErrorCode
+	// StreamErrorCode is a QUIC stream error code. The meaning of the value is defined by the application.
+	StreamErrorCode = qerr.StreamErrorCode
 )
 
 const (
-	NoError                   = qerr.NoError
-	InternalError             = qerr.InternalError
-	ConnectionRefused         = qerr.ConnectionRefused
-	FlowControlError          = qerr.FlowControlError
-	StreamLimitError          = qerr.StreamLimitError
-	StreamStateError          = qerr.StreamStateError
-	FinalSizeError            = qerr.FinalSizeError
-	FrameEncodingError        = qerr.FrameEncodingError
-	TransportParameterError   = qerr.TransportParameterError
-	ConnectionIDLimitError    = qerr.ConnectionIDLimitError
-	ProtocolViolation         = qerr.ProtocolViolation
-	InvalidToken              = qerr.InvalidToken
+	// NoError is the NO_ERROR transport error code.
+	NoError = qerr.NoError
+	// InternalError is the INTERNAL_ERROR transport error code.
+	InternalError = qerr.InternalError
+	// ConnectionRefused is the CONNECTION_REFUSED transport error code.
+	ConnectionRefused = qerr.ConnectionRefused
+	// FlowControlError is the FLOW_CONTROL_ERROR transport error code.
+	FlowControlError = qerr.FlowControlError
+	// StreamLimitError is the STREAM_LIMIT_ERROR transport error code.
+	StreamLimitError = qerr.StreamLimitError
+	// StreamStateError is the STREAM_STATE_ERROR transport error code.
+	StreamStateError = qerr.StreamStateError
+	// FinalSizeError is the FINAL_SIZE_ERROR transport error code.
+	FinalSizeError = qerr.FinalSizeError
+	// FrameEncodingError is the FRAME_ENCODING_ERROR transport error code.
+	FrameEncodingError = qerr.FrameEncodingError
+	// TransportParameterError is the TRANSPORT_PARAMETER_ERROR transport error code.
+	TransportParameterError = qerr.TransportParameterError
+	// ConnectionIDLimitError is the CONNECTION_ID_LIMIT_ERROR transport error code.
+	ConnectionIDLimitError = qerr.ConnectionIDLimitError
+	// ProtocolViolation is the PROTOCOL_VIOLATION transport error code.
+	ProtocolViolation = qerr.ProtocolViolation
+	// InvalidToken is the INVALID_TOKEN transport error code.
+	InvalidToken = qerr.InvalidToken
+	// ApplicationErrorErrorCode is the APPLICATION_ERROR transport error code.
 	ApplicationErrorErrorCode = qerr.ApplicationErrorErrorCode
-	CryptoBufferExceeded      = qerr.CryptoBufferExceeded
-	KeyUpdateError            = qerr.KeyUpdateError
-	AEADLimitReached          = qerr.AEADLimitReached
-	NoViablePathError         = qerr.NoViablePathError
+	// CryptoBufferExceeded is the CRYPTO_BUFFER_EXCEEDED transport error code.
+	CryptoBufferExceeded = qerr.CryptoBufferExceeded
+	// KeyUpdateError is the KEY_UPDATE_ERROR transport error code.
+	KeyUpdateError = qerr.KeyUpdateError
+	// AEADLimitReached is the AEAD_LIMIT_REACHED transport error code.
+	AEADLimitReached = qerr.AEADLimitReached
+	// NoViablePathError is the NO_VIABLE_PATH_ERROR transport error code.
+	NoViablePathError = qerr.NoViablePathError
 )
 
 // A StreamError is used to signal stream cancellations.
diff --git a/http3/client.go b/http3/client.go
index 5e2e2612..7cd02bc3 100644
--- a/http3/client.go
+++ b/http3/client.go
@@ -318,7 +318,7 @@ func (c *ClientConn) sendRequestBody(str *RequestStream, body io.ReadCloser, con
 
 func (c *ClientConn) doRequest(req *http.Request, str *RequestStream) (*http.Response, error) {
 	trace := httptrace.ContextClientTrace(req.Context())
-	if err := str.SendRequestHeader(req); err != nil {
+	if err := str.sendRequestHeader(req); err != nil {
 		traceWroteRequest(trace, err)
 		return nil, err
 	}
diff --git a/http3/conn.go b/http3/conn.go
index d1ae3400..63ac9cbe 100644
--- a/http3/conn.go
+++ b/http3/conn.go
@@ -165,7 +165,7 @@ func (c *Conn) openRequestStream(
 	rsp := &http.Response{}
 	trace := httptrace.ContextClientTrace(ctx)
 	return newRequestStream(
-		newStream(hstr, c, func(r io.Reader, l uint64) error {
+		newStream(hstr, c, trace, func(r io.Reader, l uint64) error {
 			hdr, err := c.decodeTrailers(r, l, maxHeaderBytes)
 			if err != nil {
 				return err
@@ -179,7 +179,6 @@ func (c *Conn) openRequestStream(
 		disableCompression,
 		maxHeaderBytes,
 		rsp,
-		trace,
 	), nil
 }
 
diff --git a/http3/server.go b/http3/server.go
index e5794435..8d53a724 100644
--- a/http3/server.go
+++ b/http3/server.go
@@ -610,7 +610,7 @@ func (s *Server) handleRequest(conn *Conn, str datagramStream, decoder *qpack.De
 	if _, ok := req.Header["Content-Length"]; ok && req.ContentLength >= 0 {
 		contentLength = req.ContentLength
 	}
-	hstr := newStream(str, conn, nil)
+	hstr := newStream(str, conn, nil, nil)
 	body := newRequestBody(hstr, contentLength, conn.Context(), conn.ReceivedSettings(), conn.Settings)
 	req.Body = body
 
diff --git a/http3/stream.go b/http3/stream.go
index bcc627d6..a1dafa11 100644
--- a/http3/stream.go
+++ b/http3/stream.go
@@ -21,6 +21,7 @@ type datagramStream interface {
 	CancelWrite(quic.StreamErrorCode)
 	StreamID() quic.StreamID
 	Context() context.Context
+	SetDeadline(time.Time) error
 	SetReadDeadline(time.Time) error
 	SetWriteDeadline(time.Time) error
 	SendDatagram(b []byte) error
@@ -29,11 +30,13 @@ type datagramStream interface {
 	QUICStream() *quic.Stream
 }
 
-// A Stream is an HTTP/3 request stream.
+// A Stream is an HTTP/3 stream.
+//
 // When writing to and reading from the stream, data is framed in HTTP/3 DATA frames.
 type Stream struct {
 	datagramStream
-	conn *Conn
+	conn        *Conn
+	frameParser *frameParser
 
 	buf []byte // used as a temporary buffer when writing the HTTP/3 frame headers
 
@@ -43,12 +46,16 @@ type Stream struct {
 	parsedTrailer bool
 }
 
-func newStream(str datagramStream, conn *Conn, parseTrailer func(io.Reader, uint64) error) *Stream {
+func newStream(str datagramStream, conn *Conn, trace *httptrace.ClientTrace, parseTrailer func(io.Reader, uint64) error) *Stream {
 	return &Stream{
 		datagramStream: str,
 		conn:           conn,
 		buf:            make([]byte, 16),
 		parseTrailer:   parseTrailer,
+		frameParser: &frameParser{
+			closeConn: conn.CloseWithError,
+			r:         &tracingReader{Reader: str, trace: trace},
+		},
 	}
 }
 
@@ -121,12 +128,25 @@ func (s *Stream) StreamID() protocol.StreamID {
 	return s.datagramStream.StreamID()
 }
 
+func (s *Stream) SendDatagram(b []byte) error {
+	// TODO: reject if datagrams are not negotiated (yet)
+	return s.datagramStream.SendDatagram(b)
+}
+
+func (s *Stream) ReceiveDatagram(ctx context.Context) ([]byte, error) {
+	// TODO: reject if datagrams are not negotiated (yet)
+	return s.datagramStream.ReceiveDatagram(ctx)
+}
+
 // A RequestStream is a low-level abstraction representing an HTTP/3 request stream.
 // It decouples sending of the HTTP request from reading the HTTP response, allowing
 // the application to optimistically use the stream (and, for example, send datagrams)
 // before receiving the response.
+//
+// This is only needed for advanced use case, e.g. WebTransport and the various
+// MASQUE proxying protocols.
 type RequestStream struct {
-	*Stream
+	str *Stream
 
 	responseBody io.ReadCloser // set by ReadResponse
 
@@ -136,12 +156,10 @@ type RequestStream struct {
 	reqDone            chan<- struct{}
 	disableCompression bool
 	response           *http.Response
-	trace              *httptrace.ClientTrace
 
 	sentRequest   bool
 	requestedGzip bool
 	isConnect     bool
-	firstByte     bool
 }
 
 func newRequestStream(
@@ -152,33 +170,116 @@ func newRequestStream(
 	disableCompression bool,
 	maxHeaderBytes uint64,
 	rsp *http.Response,
-	trace *httptrace.ClientTrace,
 ) *RequestStream {
 	return &RequestStream{
-		Stream:             str,
+		str:                str,
 		requestWriter:      requestWriter,
 		reqDone:            reqDone,
 		decoder:            decoder,
 		disableCompression: disableCompression,
 		maxHeaderBytes:     maxHeaderBytes,
 		response:           rsp,
-		trace:              trace,
 	}
 }
 
+// Read reads data from the underlying stream.
+//
+// It can only be used after the request has been sent (using SendRequestHeader)
+// and the response has been consumed (using ReadResponse).
 func (s *RequestStream) Read(b []byte) (int, error) {
 	if s.responseBody == nil {
-		return 0, errors.New("http3: invalid use of RequestStream.Read: need to call ReadResponse first")
+		return 0, errors.New("http3: invalid use of RequestStream.Read before ReadResponse")
 	}
 	return s.responseBody.Read(b)
 }
 
+// StreamID returns the QUIC stream ID of the underlying QUIC stream.
+func (s *RequestStream) StreamID() protocol.StreamID {
+	return s.str.StreamID()
+}
+
+// Write writes data to the stream.
+//
+// It can only be used after the request has been sent (using SendRequestHeader).
+func (s *RequestStream) Write(b []byte) (int, error) {
+	if !s.sentRequest {
+		return 0, errors.New("http3: invalid use of RequestStream.Write before SendRequestHeader")
+	}
+	return s.str.Write(b)
+}
+
+// Close closes the send-direction of the stream.
+// It does not close the receive-direction of the stream.
+func (s *RequestStream) Close() error {
+	return s.str.Close()
+}
+
+// CancelRead aborts receiving on this stream.
+// See [quic.Stream.CancelRead] for more details.
+func (s *RequestStream) CancelRead(errorCode quic.StreamErrorCode) {
+	s.str.CancelRead(errorCode)
+}
+
+// CancelWrite aborts sending on this stream.
+// See [quic.Stream.CancelWrite] for more details.
+func (s *RequestStream) CancelWrite(errorCode quic.StreamErrorCode) {
+	s.str.CancelWrite(errorCode)
+}
+
+// Context returns a context derived from the underlying QUIC stream's context.
+// See [quic.Stream.Context] for more details.
+func (s *RequestStream) Context() context.Context {
+	return s.str.Context()
+}
+
+// SetReadDeadline sets the deadline for Read calls.
+func (s *RequestStream) SetReadDeadline(t time.Time) error {
+	return s.str.SetReadDeadline(t)
+}
+
+// SetWriteDeadline sets the deadline for Write calls.
+func (s *RequestStream) SetWriteDeadline(t time.Time) error {
+	return s.str.SetWriteDeadline(t)
+}
+
+// SetDeadline sets the read and write deadlines associated with the stream.
+// It is equivalent to calling both SetReadDeadline and SetWriteDeadline.
+func (s *RequestStream) SetDeadline(t time.Time) error {
+	return s.str.SetDeadline(t)
+}
+
+// SendDatagrams send a new HTTP Datagram (RFC 9297).
+//
+// It is only possible to send datagrams if the server enabled support for this extension.
+// It is recommended (though not required) to send the request before calling this method,
+// as the server might drop datagrams which it can't associate with an existing request.
+func (s *RequestStream) SendDatagram(b []byte) error {
+	return s.str.SendDatagram(b)
+}
+
+// ReceiveDatagram receives HTTP Datagrams (RFC 9297).
+//
+// It is only possible if support for HTTP Datagrams was enabled, using the EnableDatagram
+// option on the [Transport].
+func (s *RequestStream) ReceiveDatagram(ctx context.Context) ([]byte, error) {
+	return s.str.ReceiveDatagram(ctx)
+}
+
 // SendRequestHeader sends the HTTP request.
+//
+// It can only used for requests that don't have a request body.
 // It is invalid to call it more than once.
 // It is invalid to call it after Write has been called.
 func (s *RequestStream) SendRequestHeader(req *http.Request) error {
+	if req.Body != nil && req.Body != http.NoBody {
+		return errors.New("http3: invalid use of RequestStream.SendRequestHeader with a request that has a request body")
+	}
+	return s.sendRequestHeader(req)
+}
+
+func (s *RequestStream) sendRequestHeader(req *http.Request) error {
 	if s.sentRequest {
-		return errors.New("http3: invalid duplicate use of SendRequestHeader")
+		return errors.New("http3: invalid duplicate use of RequestStream.SendRequestHeader")
 	}
 	if !s.disableCompression && req.Method != http.MethodHead &&
 		req.Header.Get("Accept-Encoding") == "" && req.Header.Get("Range") == "" {
@@ -186,61 +287,57 @@ func (s *RequestStream) SendRequestHeader(req *http.Request) error {
 	}
 	s.isConnect = req.Method == http.MethodConnect
 	s.sentRequest = true
-	return s.requestWriter.WriteRequestHeader(s.datagramStream, req, s.requestedGzip)
+	return s.requestWriter.WriteRequestHeader(s.str.datagramStream, req, s.requestedGzip)
 }
 
 // ReadResponse reads the HTTP response from the stream.
+//
+// It must be called after sending the request (using SendRequestHeader).
 // It is invalid to call it more than once.
 // 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.datagramStream
-	fp := &frameParser{
-		closeConn: s.conn.CloseWithError,
-		r: &tracingReader{
-			Reader: qstr,
-			first:  &s.firstByte,
-			trace:  s.trace,
-		},
+	if !s.sentRequest {
+		return nil, errors.New("http3: invalid duplicate use of RequestStream.ReadResponse before SendRequestHeader")
 	}
-	frame, err := fp.ParseNext()
+	frame, err := s.str.frameParser.ParseNext()
 	if err != nil {
-		s.CancelRead(quic.StreamErrorCode(ErrCodeFrameError))
-		s.CancelWrite(quic.StreamErrorCode(ErrCodeFrameError))
+		s.str.CancelRead(quic.StreamErrorCode(ErrCodeFrameError))
+		s.str.CancelWrite(quic.StreamErrorCode(ErrCodeFrameError))
 		return nil, fmt.Errorf("http3: parsing frame failed: %w", err)
 	}
 	hf, ok := frame.(*headersFrame)
 	if !ok {
-		s.conn.CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameUnexpected), "expected first frame to be a HEADERS frame")
+		s.str.conn.CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameUnexpected), "expected first frame to be a HEADERS frame")
 		return nil, errors.New("http3: expected first frame to be a HEADERS frame")
 	}
 	if hf.Length > s.maxHeaderBytes {
-		s.CancelRead(quic.StreamErrorCode(ErrCodeFrameError))
-		s.CancelWrite(quic.StreamErrorCode(ErrCodeFrameError))
+		s.str.CancelRead(quic.StreamErrorCode(ErrCodeFrameError))
+		s.str.CancelWrite(quic.StreamErrorCode(ErrCodeFrameError))
 		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(qstr, headerBlock); err != nil {
-		s.CancelRead(quic.StreamErrorCode(ErrCodeRequestIncomplete))
-		s.CancelWrite(quic.StreamErrorCode(ErrCodeRequestIncomplete))
+	if _, err := io.ReadFull(s.str.datagramStream, headerBlock); err != nil {
+		s.str.CancelRead(quic.StreamErrorCode(ErrCodeRequestIncomplete))
+		s.str.CancelWrite(quic.StreamErrorCode(ErrCodeRequestIncomplete))
 		return nil, fmt.Errorf("http3: failed to read response headers: %w", err)
 	}
 	hfs, err := s.decoder.DecodeFull(headerBlock)
 	if err != nil {
 		// TODO: use the right error code
-		s.conn.CloseWithError(quic.ApplicationErrorCode(ErrCodeGeneralProtocolError), "")
+		s.str.conn.CloseWithError(quic.ApplicationErrorCode(ErrCodeGeneralProtocolError), "")
 		return nil, fmt.Errorf("http3: failed to decode response headers: %w", err)
 	}
 	res := s.response
 	if err := updateResponseFromHeaders(res, hfs); err != nil {
-		s.CancelRead(quic.StreamErrorCode(ErrCodeMessageError))
-		s.CancelWrite(quic.StreamErrorCode(ErrCodeMessageError))
+		s.str.CancelRead(quic.StreamErrorCode(ErrCodeMessageError))
+		s.str.CancelWrite(quic.StreamErrorCode(ErrCodeMessageError))
 		return nil, fmt.Errorf("http3: invalid response: %w", err)
 	}
 
 	// 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.str, 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
@@ -262,27 +359,17 @@ func (s *RequestStream) ReadResponse() (*http.Response, error) {
 	return res, nil
 }
 
-func (s *Stream) SendDatagram(b []byte) error {
-	// TODO: reject if datagrams are not negotiated (yet)
-	return s.datagramStream.SendDatagram(b)
-}
-
-func (s *Stream) ReceiveDatagram(ctx context.Context) ([]byte, error) {
-	// TODO: reject if datagrams are not negotiated (yet)
-	return s.datagramStream.ReceiveDatagram(ctx)
-}
-
 type tracingReader struct {
 	io.Reader
-	first *bool
-	trace *httptrace.ClientTrace
+	readFirst bool
+	trace     *httptrace.ClientTrace
 }
 
 func (r *tracingReader) Read(b []byte) (int, error) {
 	n, err := r.Reader.Read(b)
-	if n > 0 && r.first != nil && !*r.first {
+	if n > 0 && !r.readFirst {
 		traceGotFirstResponseByte(r.trace)
-		*r.first = true
+		r.readFirst = true
 	}
 	return n, err
 }
diff --git a/http3/transport.go b/http3/transport.go
index 93e1a9e3..dac84bb8 100644
--- a/http3/transport.go
+++ b/http3/transport.go
@@ -119,9 +119,6 @@ var (
 	_ io.Closer         = &Transport{}
 )
 
-// Deprecated: RoundTripper was renamed to Transport.
-type RoundTripper = Transport
-
 // ErrNoCachedConn is returned when Transport.OnlyCachedConn is set
 var ErrNoCachedConn = errors.New("http3: no cached connection was available")
 
diff --git a/internal/handshake/updatable_aead.go b/internal/handshake/updatable_aead.go
index 0aca46fe..415f85de 100644
--- a/internal/handshake/updatable_aead.go
+++ b/internal/handshake/updatable_aead.go
@@ -6,9 +6,7 @@ import (
 	"crypto/tls"
 	"encoding/binary"
 	"fmt"
-	"os"
-	"strconv"
-	"testing"
+	"sync/atomic"
 	"time"
 
 	"github.com/quic-go/quic-go/internal/protocol"
@@ -17,14 +15,15 @@ import (
 	"github.com/quic-go/quic-go/logging"
 )
 
-func keyUpdateInterval() uint64 {
-	// Reparsing the environment variable is not very performant, but it's only done in tests.
-	if testing.Testing() {
-		if v, err := strconv.ParseUint(os.Getenv("QUIC_GO_TEST_KEY_UPDATE_INTERVAL"), 10, 64); err == nil {
-			return v
-		}
-	}
-	return protocol.KeyUpdateInterval
+var keyUpdateInterval atomic.Uint64
+
+func init() {
+	keyUpdateInterval.Store(protocol.KeyUpdateInterval)
+}
+
+func SetKeyUpdateInterval(v uint64) (reset func()) {
+	old := keyUpdateInterval.Swap(v)
+	return func() { keyUpdateInterval.Store(old) }
 }
 
 // FirstKeyUpdateInterval is the maximum number of packets we send or receive before initiating the first key update.
@@ -302,11 +301,11 @@ func (a *updatableAEAD) shouldInitiateKeyUpdate() bool {
 			return true
 		}
 	}
-	if a.numRcvdWithCurrentKey >= keyUpdateInterval() {
+	if a.numRcvdWithCurrentKey >= keyUpdateInterval.Load() {
 		a.logger.Debugf("Received %d packets with current key phase. Initiating key update to the next key phase: %d", a.numRcvdWithCurrentKey, a.keyPhase+1)
 		return true
 	}
-	if a.numSentWithCurrentKey >= keyUpdateInterval() {
+	if a.numSentWithCurrentKey >= keyUpdateInterval.Load() {
 		a.logger.Debugf("Sent %d packets with current key phase. Initiating key update to the next key phase: %d", a.numSentWithCurrentKey, a.keyPhase+1)
 		return true
 	}
diff --git a/quicvarint/varint.go b/quicvarint/varint.go
index f095e298..0a19eaaf 100644
--- a/quicvarint/varint.go
+++ b/quicvarint/varint.go
@@ -1,6 +1,7 @@
 package quicvarint
 
 import (
+	"encoding/binary"
 	"fmt"
 	"io"
 )
@@ -74,23 +75,30 @@ func Parse(b []byte) (uint64 /* value */, int /* bytes consumed */, error) {
 	if len(b) == 0 {
 		return 0, 0, io.EOF
 	}
-	firstByte := b[0]
-	// the first two bits of the first byte encode the length
-	l := 1 << ((firstByte & 0xc0) >> 6)
-	if len(b) < l {
-		return 0, 0, io.ErrUnexpectedEOF
-	}
-	b0 := firstByte & (0xff - 0xc0)
-	if l == 1 {
-		return uint64(b0), 1, nil
-	}
-	if l == 2 {
-		return uint64(b[1]) + uint64(b0)<<8, 2, nil
-	}
-	if l == 4 {
-		return uint64(b[3]) + uint64(b[2])<<8 + uint64(b[1])<<16 + uint64(b0)<<24, 4, nil
+
+	first := b[0]
+	switch first >> 6 {
+	case 0: // 1-byte encoding: 00xxxxxx
+		return uint64(first & 0b00111111), 1, nil
+	case 1: // 2-byte encoding: 01xxxxxx
+		if len(b) < 2 {
+			return 0, 0, io.ErrUnexpectedEOF
+		}
+		return uint64(b[1]) | uint64(first&0b00111111)<<8, 2, nil
+	case 2: // 4-byte encoding: 10xxxxxx
+		if len(b) < 4 {
+			return 0, 0, io.ErrUnexpectedEOF
+		}
+		return uint64(b[3]) | uint64(b[2])<<8 | uint64(b[1])<<16 | uint64(first&0b00111111)<<24, 4, nil
+	case 3: // 8-byte encoding: 00xxxxxx
+		if len(b) < 8 {
+			return 0, 0, io.ErrUnexpectedEOF
+		}
+		// binary.BigEndian.Uint64 only reads the first 8 bytes. Passing the full slice avoids slicing overhead.
+		return binary.BigEndian.Uint64(b) & 0x3fffffffffffffff, 8, nil
 	}
-	return uint64(b[7]) + uint64(b[6])<<8 + uint64(b[5])<<16 + uint64(b[4])<<24 + uint64(b[3])<<32 + uint64(b[2])<<40 + uint64(b[1])<<48 + uint64(b0)<<56, 8, nil
+
+	panic("unreachable")
 }
 
 // Append appends i in the QUIC varint format.
diff --git a/server.go b/server.go
index 14ac8f81..f6bef2d8 100644
--- a/server.go
+++ b/server.go
@@ -343,11 +343,13 @@ func (s *baseServer) accept(ctx context.Context) (*Conn, error) {
 }
 
 func (s *baseServer) Close() error {
-	s.close(ErrServerClosed, true)
+	s.close(ErrServerClosed, false)
 	return nil
 }
 
-func (s *baseServer) close(e error, notifyOnClose bool) {
+// close closes the server. The Transport mutex must not be held while calling this method.
+// This method closes any handshaking connections which requires the tranpsort mutex.
+func (s *baseServer) close(e error, transportClose bool) {
 	s.closeMx.Lock()
 	if s.closeErr != nil {
 		s.closeMx.Unlock()
@@ -358,12 +360,25 @@ func (s *baseServer) close(e error, notifyOnClose bool) {
 	<-s.running
 	s.closeMx.Unlock()
 
-	if notifyOnClose {
+	if !transportClose {
 		s.onClose()
 	}
+
 	// wait until all handshakes in flight have terminated
 	s.handshakingCount.Wait()
 	close(s.stopAccepting)
+
+	if transportClose {
+		// if the transport is closing, drain the connQueue. All connections in the queue
+		// will be closed by the transport.
+		for {
+			select {
+			case <-s.connQueue:
+			default:
+				return
+			}
+		}
+	}
 }
 
 // Addr returns the server's network address
diff --git a/transport.go b/transport.go
index d0775ad7..2f105298 100644
--- a/transport.go
+++ b/transport.go
@@ -133,11 +133,10 @@ type Transport struct {
 	// Tracer.Close is called when the transport is closed.
 	Tracer *logging.Tracer
 
-	connMx      sync.Mutex
+	mutex       sync.Mutex
 	handlers    map[protocol.ConnectionID]packetHandler
 	resetTokens map[protocol.StatelessResetToken]packetHandler
 
-	mutex    sync.Mutex
 	initOnce sync.Once
 	initErr  error
 
@@ -320,9 +319,7 @@ func (t *Transport) doDial(
 		logger,
 		version,
 	)
-	t.connMx.Lock()
 	t.handlers[srcConnID] = conn
-	t.connMx.Unlock()
 	t.mutex.Unlock()
 
 	// The error channel needs to be buffered, as the run loop will continue running
@@ -459,8 +456,12 @@ func (t *Transport) runSendQueue() {
 }
 
 // Close stops listening for UDP datagrams on the Transport.Conn.
-// If any listener was started, it will be closed as well.
-// It is invalid to start new listeners or connections after that.
+// It abruptly terminates all existing connections, without sending a CONNECTION_CLOSE
+// to the peers. It is the application's responsibility to cleanly terminate existing
+// connections prior to calling Close.
+//
+// If a server was started, it will be closed as well.
+// It is not possible to start any new server or dial new connections after that.
 func (t *Transport) Close() error {
 	// avoid race condition if the transport is currently being initialized
 	t.init(false)
@@ -489,8 +490,6 @@ func (t *Transport) closeServer() {
 		t.closeErr = ErrServerClosed
 	}
 
-	t.connMx.Lock()
-	defer t.connMx.Unlock()
 	if len(t.handlers) == 0 {
 		t.maybeStopListening()
 	}
@@ -498,16 +497,24 @@ func (t *Transport) closeServer() {
 
 func (t *Transport) close(e error) {
 	t.mutex.Lock()
-	defer t.mutex.Unlock()
 
 	if t.closeErr != nil {
+		t.mutex.Unlock()
 		return
 	}
 
 	e = &errTransportClosed{err: e}
+	t.closeErr = e
+	server := t.server
+	t.server = nil
+	if server != nil {
+		t.mutex.Unlock()
+		server.close(e, true)
+		t.mutex.Lock()
+	}
 
+	// Close existing connections
 	var wg sync.WaitGroup
-	t.connMx.Lock()
 	for _, handler := range t.handlers {
 		wg.Add(1)
 		go func(handler packetHandler) {
@@ -515,16 +522,12 @@ func (t *Transport) close(e error) {
 			wg.Done()
 		}(handler)
 	}
-	t.connMx.Unlock()
+	t.mutex.Unlock() // closing connections requires releasing transport mutex
 	wg.Wait()
 
-	if t.server != nil {
-		t.server.close(e, false)
-	}
 	if t.Tracer != nil && t.Tracer.Close != nil {
 		t.Tracer.Close()
 	}
-	t.closeErr = e
 }
 
 // only print warnings about the UDP receive buffer size once
@@ -670,9 +673,9 @@ func (t *Transport) maybeHandleStatelessReset(data []byte) bool {
 	}
 
 	token := protocol.StatelessResetToken(data[len(data)-16:])
-	t.connMx.Lock()
+	t.mutex.Lock()
 	conn, ok := t.resetTokens[token]
-	t.connMx.Unlock()
+	t.mutex.Unlock()
 
 	if ok {
 		t.logger.Debugf("Received a stateless reset with token %#x. Closing connection.", token)
@@ -745,8 +748,8 @@ type packetHandlerMap Transport
 var _ connRunner = &packetHandlerMap{}
 
 func (h *packetHandlerMap) Add(id protocol.ConnectionID, handler packetHandler) bool /* was added */ {
-	h.connMx.Lock()
-	defer h.connMx.Unlock()
+	h.mutex.Lock()
+	defer h.mutex.Unlock()
 
 	if _, ok := h.handlers[id]; ok {
 		h.logger.Debugf("Not adding connection ID %s, as it already exists.", id)
@@ -758,27 +761,27 @@ func (h *packetHandlerMap) Add(id protocol.ConnectionID, handler packetHandler)
 }
 
 func (h *packetHandlerMap) Get(connID protocol.ConnectionID) (packetHandler, bool) {
-	h.connMx.Lock()
-	defer h.connMx.Unlock()
+	h.mutex.Lock()
+	defer h.mutex.Unlock()
 	handler, ok := h.handlers[connID]
 	return handler, ok
 }
 
 func (h *packetHandlerMap) AddResetToken(token protocol.StatelessResetToken, handler packetHandler) {
-	h.connMx.Lock()
+	h.mutex.Lock()
 	h.resetTokens[token] = handler
-	h.connMx.Unlock()
+	h.mutex.Unlock()
 }
 
 func (h *packetHandlerMap) RemoveResetToken(token protocol.StatelessResetToken) {
-	h.connMx.Lock()
+	h.mutex.Lock()
 	delete(h.resetTokens, token)
-	h.connMx.Unlock()
+	h.mutex.Unlock()
 }
 
 func (h *packetHandlerMap) AddWithConnID(clientDestConnID, newConnID protocol.ConnectionID, handler packetHandler) bool {
-	h.connMx.Lock()
-	defer h.connMx.Unlock()
+	h.mutex.Lock()
+	defer h.mutex.Unlock()
 
 	if _, ok := h.handlers[clientDestConnID]; ok {
 		h.logger.Debugf("Not adding connection ID %s for a new connection, as it already exists.", clientDestConnID)
@@ -791,9 +794,9 @@ func (h *packetHandlerMap) AddWithConnID(clientDestConnID, newConnID protocol.Co
 }
 
 func (h *packetHandlerMap) Remove(id protocol.ConnectionID) {
-	h.connMx.Lock()
+	h.mutex.Lock()
 	delete(h.handlers, id)
-	h.connMx.Unlock()
+	h.mutex.Unlock()
 	h.logger.Debugf("Removing connection ID %s.", id)
 }
 
@@ -819,25 +822,23 @@ func (h *packetHandlerMap) ReplaceWithClosed(ids []protocol.ConnectionID, connCl
 		handler = newClosedRemoteConn()
 	}
 
-	h.connMx.Lock()
+	h.mutex.Lock()
 	for _, id := range ids {
 		h.handlers[id] = handler
 	}
-	h.connMx.Unlock()
+	h.mutex.Unlock()
 	h.logger.Debugf("Replacing connection for connection IDs %s with a closed connection.", ids)
 
 	time.AfterFunc(expiry, func() {
-		h.connMx.Lock()
+		h.mutex.Lock()
 		for _, id := range ids {
 			delete(h.handlers, id)
 		}
 		if len(h.handlers) == 0 {
 			t := (*Transport)(h)
-			t.mutex.Lock()
 			t.maybeStopListening()
-			t.mutex.Unlock()
 		}
-		h.connMx.Unlock()
+		h.mutex.Unlock()
 		h.logger.Debugf("Removing connection IDs %s for a closed connection after it has been retired.", ids)
 	})
 }


Test patch:
diff --git a/http3/client_test.go b/http3/client_test.go
index 9dfe27f5..7386e5f8 100644
--- a/http3/client_test.go
+++ b/http3/client_test.go
@@ -61,7 +61,7 @@ func encodeResponse(t *testing.T, status int) []byte {
 	buf := &bytes.Buffer{}
 	rstr := NewMockDatagramStream(mockCtrl)
 	rstr.EXPECT().Write(gomock.Any()).Do(buf.Write).AnyTimes()
-	rw := newResponseWriter(newStream(rstr, nil, func(r io.Reader, u uint64) error { return nil }), nil, false, nil)
+	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()
@@ -326,7 +326,7 @@ func testClient1xxHandling(t *testing.T, numEarlyHints int, terminalStatus int,
 	var rspBuf bytes.Buffer
 	rstr := NewMockDatagramStream(gomock.NewController(t))
 	rstr.EXPECT().Write(gomock.Any()).Do(rspBuf.Write).AnyTimes()
-	rw := newResponseWriter(newStream(rstr, nil, func(r io.Reader, u uint64) error { return nil }), nil, false, nil)
+	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 {
@@ -405,7 +405,7 @@ func testClientGzip(t *testing.T,
 	var rspBuf bytes.Buffer
 	rstr := NewMockDatagramStream(gomock.NewController(t))
 	rstr.EXPECT().Write(gomock.Any()).Do(rspBuf.Write).AnyTimes()
-	rw := newResponseWriter(newStream(rstr, nil, func(r io.Reader, u uint64) error { return nil }), nil, false, nil)
+	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")
diff --git a/http3/mock_datagram_stream_test.go b/http3/mock_datagram_stream_test.go
index 7ca0ae63..f9fbf895 100644
--- a/http3/mock_datagram_stream_test.go
+++ b/http3/mock_datagram_stream_test.go
@@ -344,6 +344,44 @@ func (c *MockDatagramStreamSendDatagramCall) DoAndReturn(f func([]byte) error) *
 	return c
 }
 
+// SetDeadline mocks base method.
+func (m *MockDatagramStream) SetDeadline(arg0 time.Time) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "SetDeadline", arg0)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// SetDeadline indicates an expected call of SetDeadline.
+func (mr *MockDatagramStreamMockRecorder) SetDeadline(arg0 any) *MockDatagramStreamSetDeadlineCall {
+	mr.mock.ctrl.T.Helper()
+	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetDeadline", reflect.TypeOf((*MockDatagramStream)(nil).SetDeadline), arg0)
+	return &MockDatagramStreamSetDeadlineCall{Call: call}
+}
+
+// MockDatagramStreamSetDeadlineCall wrap *gomock.Call
+type MockDatagramStreamSetDeadlineCall struct {
+	*gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockDatagramStreamSetDeadlineCall) Return(arg0 error) *MockDatagramStreamSetDeadlineCall {
+	c.Call = c.Call.Return(arg0)
+	return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockDatagramStreamSetDeadlineCall) Do(f func(time.Time) error) *MockDatagramStreamSetDeadlineCall {
+	c.Call = c.Call.Do(f)
+	return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockDatagramStreamSetDeadlineCall) DoAndReturn(f func(time.Time) error) *MockDatagramStreamSetDeadlineCall {
+	c.Call = c.Call.DoAndReturn(f)
+	return c
+}
+
 // SetReadDeadline mocks base method.
 func (m *MockDatagramStream) SetReadDeadline(arg0 time.Time) error {
 	m.ctrl.T.Helper()
diff --git a/http3/response_writer_test.go b/http3/response_writer_test.go
index 07ebaad6..0247b6cb 100644
--- a/http3/response_writer_test.go
+++ b/http3/response_writer_test.go
@@ -61,7 +61,7 @@ func newTestResponseWriter(t *testing.T) *testResponseWriter {
 	str.EXPECT().Write(gomock.Any()).DoAndReturn(buf.Write).AnyTimes()
 	str.EXPECT().SetReadDeadline(gomock.Any()).Return(nil).AnyTimes()
 	str.EXPECT().SetWriteDeadline(gomock.Any()).Return(nil).AnyTimes()
-	rw := newResponseWriter(newStream(str, nil, func(r io.Reader, u uint64) error { return nil }), nil, false, nil)
+	rw := newResponseWriter(newStream(str, nil, nil, func(r io.Reader, u uint64) error { return nil }), nil, false, nil)
 	return &testResponseWriter{responseWriter: rw, buf: buf}
 }
 
diff --git a/http3/stream_test.go b/http3/stream_test.go
index 64ab050d..a6352a1d 100644
--- a/http3/stream_test.go
+++ b/http3/stream_test.go
@@ -8,6 +8,7 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"net/http/httptrace"
+	"strings"
 	"testing"
 	"time"
 
@@ -43,6 +44,7 @@ func TestStreamReadDataFrames(t *testing.T) {
 			nil,
 			0,
 		),
+		nil,
 		func(r io.Reader, u uint64) error { return nil },
 	)
 
@@ -91,6 +93,7 @@ func TestStreamInvalidFrame(t *testing.T) {
 	str := newStream(
 		qstr,
 		newConnection(context.Background(), clientConn, false, protocol.PerspectiveClient, nil, 0),
+		nil,
 		func(r io.Reader, u uint64) error { return nil },
 	)
 
@@ -112,7 +115,7 @@ func TestStreamWrite(t *testing.T) {
 	mockCtrl := gomock.NewController(t)
 	qstr := NewMockDatagramStream(mockCtrl)
 	qstr.EXPECT().Write(gomock.Any()).DoAndReturn(buf.Write).AnyTimes()
-	str := newStream(qstr, nil, func(r io.Reader, u uint64) error { return nil })
+	str := newStream(qstr, nil, nil, func(r io.Reader, u uint64) error { return nil })
 	str.Write([]byte("foo"))
 	str.Write([]byte("foobar"))
 
@@ -144,6 +147,7 @@ func TestRequestStream(t *testing.T) {
 		newStream(
 			qstr,
 			newConnection(context.Background(), clientConn, false, protocol.PerspectiveClient, nil, 0),
+			&httptrace.ClientTrace{},
 			func(r io.Reader, u uint64) error { return nil },
 		),
 		requestWriter,
@@ -152,17 +156,29 @@ func TestRequestStream(t *testing.T) {
 		true,
 		math.MaxUint64,
 		&http.Response{},
-		&httptrace.ClientTrace{},
 	)
 
-	_, err := str.Read(make([]byte, 100))
-	require.EqualError(t, err, "http3: invalid use of RequestStream.Read: need to call ReadResponse first")
+	_, err := str.Read([]byte{0})
+	require.EqualError(t, err, "http3: invalid use of RequestStream.Read before ReadResponse")
+	_, err = str.Write([]byte{0})
+	require.EqualError(t, err, "http3: invalid use of RequestStream.Write before SendRequestHeader")
+
+	// calling ReadResponse before SendRequestHeader is not valid
+	_, err = str.ReadResponse()
+	require.EqualError(t, err, "http3: invalid duplicate use of RequestStream.ReadResponse before SendRequestHeader")
+	// SendRequestHeader can't be used for requests that have a request body
+	require.EqualError(t,
+		str.SendRequestHeader(
+			httptest.NewRequest(http.MethodGet, "https://quic-go.net", strings.NewReader("foobar")),
+		),
+		"http3: invalid use of RequestStream.SendRequestHeader with a request that has a request body",
+	)
 
 	req := httptest.NewRequest(http.MethodGet, "https://quic-go.net", nil)
 	qstr.EXPECT().Write(gomock.Any()).AnyTimes()
 	require.NoError(t, str.SendRequestHeader(req))
 	// duplicate calls are not allowed
-	require.EqualError(t, str.SendRequestHeader(req), "http3: invalid duplicate use of SendRequestHeader")
+	require.EqualError(t, str.SendRequestHeader(req), "http3: invalid duplicate use of RequestStream.SendRequestHeader")
 
 	buf := bytes.NewBuffer(encodeResponse(t, 200))
 	buf.Write((&dataFrame{Length: 6}).Append(nil))
@@ -171,6 +187,7 @@ func TestRequestStream(t *testing.T) {
 	rsp, err := str.ReadResponse()
 	require.NoError(t, err)
 	require.Equal(t, http.StatusOK, rsp.StatusCode)
+
 	b := make([]byte, 10)
 	n, err := str.Read(b)
 	require.NoError(t, err)
diff --git a/integrationtests/self/handshake_test.go b/integrationtests/self/handshake_test.go
index c701c671..3e2ddf91 100644
--- a/integrationtests/self/handshake_test.go
+++ b/integrationtests/self/handshake_test.go
@@ -745,3 +745,69 @@ func TestNoPacketsSentWhenClientHelloFails(t *testing.T) {
 		// no packets received, as expected
 	}
 }
+
+func TestServerTransportClose(t *testing.T) {
+	tlsServerConf := getTLSConfig()
+	tr := &quic.Transport{Conn: newUDPConnLocalhost(t)}
+	server, err := tr.Listen(tlsServerConf, getQuicConfig(nil))
+	require.NoError(t, err)
+	defer server.Close()
+
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+	defer cancel()
+	// the first conn is accepted by the server...
+	conn1, err := quic.Dial(
+		ctx,
+		newUDPConnLocalhost(t),
+		server.Addr(),
+		getTLSClientConfig(),
+		getQuicConfig(&quic.Config{MaxIdleTimeout: scaleDuration(50 * time.Millisecond)}),
+	)
+	require.NoError(t, err)
+	// ...the second conn isn't, it remains in the server's accept queue
+	conn2, err := quic.Dial(
+		ctx,
+		newUDPConnLocalhost(t),
+		server.Addr(),
+		getTLSClientConfig(),
+		getQuicConfig(&quic.Config{MaxIdleTimeout: scaleDuration(50 * time.Millisecond)}),
+	)
+	require.NoError(t, err)
+
+	time.Sleep(scaleDuration(10 * time.Millisecond))
+
+	sconn, err := server.Accept(ctx)
+	require.NoError(t, err)
+	require.Equal(t, conn1.LocalAddr(), sconn.RemoteAddr())
+
+	// closing the Transport abruptly terminates connections
+	require.NoError(t, tr.Close())
+
+	select {
+	case <-sconn.Context().Done():
+		require.ErrorIs(t, context.Cause(sconn.Context()), quic.ErrTransportClosed)
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+
+	// no CONNECTION_CLOSE frame is sent to the peers
+	select {
+	case <-conn1.Context().Done():
+		require.ErrorIs(t, context.Cause(conn1.Context()), &quic.IdleTimeoutError{})
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+	select {
+	case <-conn2.Context().Done():
+		require.ErrorIs(t, context.Cause(conn1.Context()), &quic.IdleTimeoutError{})
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+
+	// Accept should error after the transport was closed
+	ctx, cancel = context.WithTimeout(context.Background(), time.Second)
+	defer cancel()
+	accepted, err := server.Accept(ctx)
+	require.ErrorIs(t, err, quic.ErrTransportClosed)
+	require.Nil(t, accepted)
+}
diff --git a/integrationtests/self/key_update_test.go b/integrationtests/self/key_update_test.go
index d70c1a8e..32a2d377 100644
--- a/integrationtests/self/key_update_test.go
+++ b/integrationtests/self/key_update_test.go
@@ -7,6 +7,7 @@ import (
 	"time"
 
 	"github.com/quic-go/quic-go"
+	"github.com/quic-go/quic-go/internal/handshake"
 	"github.com/quic-go/quic-go/internal/protocol"
 	"github.com/quic-go/quic-go/logging"
 
@@ -14,7 +15,8 @@ import (
 )
 
 func TestKeyUpdates(t *testing.T) {
-	t.Setenv("QUIC_GO_TEST_KEY_UPDATE_INTERVAL", "1") // update keys as frequently as possible
+	reset := handshake.SetKeyUpdateInterval(1) // update keys as frequently as possible
+	t.Cleanup(reset)
 
 	var sentHeaders []*logging.ShortHeader
 	var receivedHeaders []*logging.ShortHeader
diff --git a/internal/handshake/updatable_aead_test.go b/internal/handshake/updatable_aead_test.go
index 36e0638e..bfa9e331 100644
--- a/internal/handshake/updatable_aead_test.go
+++ b/internal/handshake/updatable_aead_test.go
@@ -330,7 +330,8 @@ func TestRejectFrequentKeyUpdates(t *testing.T) {
 }
 
 func setKeyUpdateIntervals(t *testing.T, firstKeyUpdateInterval, keyUpdateInterval uint64) {
-	t.Setenv("QUIC_GO_TEST_KEY_UPDATE_INTERVAL", fmt.Sprintf("%d", keyUpdateInterval))
+	reset := SetKeyUpdateInterval(keyUpdateInterval)
+	t.Cleanup(reset)
 
 	origFirstKeyUpdateInterval := FirstKeyUpdateInterval
 	FirstKeyUpdateInterval = firstKeyUpdateInterval
diff --git a/quicvarint/varint_test.go b/quicvarint/varint_test.go
index eef37033..1bc76e78 100644
--- a/quicvarint/varint_test.go
+++ b/quicvarint/varint_test.go
@@ -14,7 +14,7 @@ func TestLimits(t *testing.T) {
 	require.Equal(t, uint64(1<<62-1), uint64(Max))
 }
 
-func TestParsing(t *testing.T) {
+func TestRead(t *testing.T) {
 	tests := []struct {
 		name     string
 		input    []byte
@@ -38,6 +38,29 @@ func TestParsing(t *testing.T) {
 	}
 }
 
+func TestParse(t *testing.T) {
+	tests := []struct {
+		name          string
+		input         []byte
+		expectedValue uint64
+		expectedLen   int
+	}{
+		{"1 byte", []byte{0b00011001}, 25, 1},
+		{"2 byte", []byte{0b01111011, 0xbd}, 15293, 2},
+		{"4 byte", []byte{0b10011101, 0x7f, 0x3e, 0x7d}, 494878333, 4},
+		{"8 byte", []byte{0b11000010, 0x19, 0x7c, 0x5e, 0xff, 0x14, 0xe8, 0x8c}, 151288809941952652, 8},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			value, l, err := Parse(tt.input)
+			require.Equal(t, tt.expectedValue, value)
+			require.Equal(t, tt.expectedLen, l)
+			require.Nil(t, err)
+		})
+	}
+}
+
 func TestParsingFailures(t *testing.T) {
 	tests := []struct {
 		name        string
@@ -50,15 +73,27 @@ func TestParsingFailures(t *testing.T) {
 			expectedErr: io.EOF,
 		},
 		{
-			name:        "slice too short",
-			input:       Append(nil, maxVarInt2*10)[:3],
+			name:        "2-byte encoding: not enough bytes",
+			input:       []byte{0b01000001},
+			expectedErr: io.ErrUnexpectedEOF,
+		},
+		{
+			name:        "4-byte encoding: not enough bytes",
+			input:       []byte{0b10000000, 0x0, 0x0},
+			expectedErr: io.ErrUnexpectedEOF,
+		},
+		{
+			name:        "8-byte encoding: not enough bytes",
+			input:       []byte{0b11000000, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0},
 			expectedErr: io.ErrUnexpectedEOF,
 		},
 	}
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			_, _, err := Parse(tt.input)
+			value, l, err := Parse(tt.input)
+			require.Equal(t, uint64(0), value)
+			require.Equal(t, 0, l)
 			require.Equal(t, tt.expectedErr, err)
 		})
 	}