Repository: quic-go/quic-go
Pull request #4587 (2024-07-20 23:17:20)

http3: implement server idle timeout support
This update introduces the ability for an HTTP/3 server to enforce an idle timeout on connections. This timeout will trigger when no new requests are received on a connection, irrespective of any PING frames received at the QUIC level.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/quic-go/quic-go/pull/4587?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 `32.55814%` with `29 lines` in your changes missing coverage. Please review.
> Project coverage is 84.87%. Comparing base [(`7825250`)](https://app.codecov.io/gh/quic-go/quic-go/commit/7825250d3fd53afde42f09171979a95dc6802e37?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) to head [(`19e8726`)](https://app.codecov.io/gh/quic-go/quic-go/commit/19e87268a0bd5ff6e3ef7b279ceda5bc1e69d473?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](https://app.codecov.io/gh/quic-go/quic-go/pull/4587?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) | Patch % | Lines |
|---|---|---|
| [http3/server.go](https://app.codecov.io/gh/quic-go/quic-go/pull/4587?src=pr&el=tree&filepath=http3%2Fserver.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go#diff-aHR0cDMvc2VydmVyLmdv) | 32.56% | [26 Missing and 3 partials :warning: ](https://app.codecov.io/gh/quic-go/quic-go/pull/4587?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    #4587      +/-   ##
==========================================
- Coverage   85.04%   84.87%   -0.17%     
==========================================
  Files         154      154              
  Lines       14874    14902      +28     
==========================================
- Hits        12649    12648       -1     
- Misses       1716     1741      +25     
- Partials      509      513       +4     
```

</details>

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

marten-seemann: Is there anything analogous in HTTP/1.1 or HTTP/2?
rs: Both `http.Server` and `http2.Server` have the exact same feature.
rs: @marten-seemann what's your take on this PR?
marten-seemann: I'll review this after the IETF week.
rs: I'll give it a try. I'm not familiar with the test framework in use.
marten-seemann: > I'll give it a try. I'm not familiar with the test framework in use.

Sorry for that. We're planning to move away from Ginkgo (https://github.com/quic-go/quic-go/issues/3652), but it's a lot of work...

What I'm imagining is a test in `integrationtests/self/http_test.go`, where the server applies a short idle timeout. We can then verify that the client's second request fails (or is sent on a newly established connection). lmk if you run into any issues.
rs: @marten-seemann I added an integration test, but I’m a little perplexed. The client connection being closed by the server should not be surfaced to the client’s caller, in my opinion. Isn’t this a bug in the client implementation?
marten-seemann: > @marten-seemann I added an integration test, but I’m a little perplexed. The client connection being closed by the server should not be surfaced to the client’s caller, in my opinion. Isn’t this a bug in the client implementation?

Good question. I think there might be a bug. We have some redialing logic here: https://github.com/quic-go/quic-go/blob/42f04d4e02205ee796d1fdec12bd2b46166e7cde/http3/roundtrip.go#L173-L187
But it doesn't take into account the HTTP/3 error at all. Maybe we need to allow automatic redialing on H3_NO_ERRORs? What do you think would be the right behavior?
rs: I think any error or closing of the connection should be hidden from the client if it does not directly impact an existing stream.

I don’t think it should auto-redial on close/error. Otherwise, a long-running client would keep redialing connections to domains it interacted with once, without any guarantee it will interact again soon.

Rather, on stream creation, if the connection is unhealthy but still in the pool, it should then be redialed.

By the way, regarding the code you highlighted, I don’t understand how the connection would be closed if the returned error did not result in the connection being closed. Wouldn’t this lead to leaked connections being kept alive if keepalive is enabled at the QUIC level?
marten-seemann: > I think any error or closing of the connection should be hidden from the client if it does not directly impact an existing stream.

That depends on the type of error. If it's a protocol violation, either at the QUIC or the HTTP layer, we should definitely surface this error. The only errors we should hide are errors that are expected to happen in normal operation, such as idle timeouts, stateless resets, etc.



> I don’t think it should auto-redial on close/error. Otherwise, a long-running client would keep redialing connections to domains it interacted with once, without any guarantee it will interact again soon.
> 
> Rather, on stream creation, if the connection is unhealthy but still in the pool, it should then be redialed.

That's how the code works. In the code I linked above, calling `RoundTrip` on a connection that has seen an idle timeout (for example), would return a `net.Error.Timeout` error, and we'd dial a new connection right away. All we need to do is extend this logic to also cover H3_NO_ERROR errors.
rs: Sorry if I was not clear. Let me rephrase. By an error not impacting an existing stream, I mean an error that occurred before the stream was created. If the client discovers it later (i.e., at the next stream creation), it may retry without surfacing the error to the caller, but only if it is guaranteed that the request wasn’t written on the previous connection.

The existing condition to retry the request on `net.Timeout` is, in my opinion, dangerous. You have no way of knowing if the request was received by the server and then timed out, or if the connection had already timed out. This runs the risk of performing the request twice, which in some cases might be unexpected and dangerous if the request is not idempotent.

Regarding my question on the `removeClient`, can you please help me understand how the removed connection is ensured to be closed?

marten-seemann: I think I agree with your argument. I opened #4612.

What do we still need to do here to get this PR across the finish line?
rs: To me, the PR is good to go.

Let me know if I should open another issue or if there is another way to discuss `removeClient` behavior. I believe there is a potential issue here that is different from the issue you just opened. I might be missing something, so I’d love to pick your brain on this.

Patch:
diff --git a/.gitignore b/.gitignore
index 3cc06f24..b454729d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@ main
 mockgen_tmp.go
 *.qtr
 *.qlog
+*.sqlog
 *.txt
 race.[0-9]*
 
diff --git a/connection.go b/connection.go
index 9198c154..9007f727 100644
--- a/connection.go
+++ b/connection.go
@@ -140,7 +140,6 @@ type connection struct {
 	receivedPacketHandler ackhandler.ReceivedPacketHandler
 	retransmissionQueue   *retransmissionQueue
 	framer                *framer
-	windowUpdateQueue     *windowUpdateQueue
 	connFlowController    flowcontrol.ConnectionFlowController
 	tokenStoreKey         string                    // only set for the client
 	tokenGenerator        *handshake.TokenGenerator // only set for the server
@@ -152,9 +151,9 @@ type connection struct {
 
 	maxPayloadSizeEstimate atomic.Uint32
 
-	initialStream       cryptoStream
-	handshakeStream     cryptoStream
-	oneRTTStream        cryptoStream // only set for the server
+	initialStream       *cryptoStream
+	handshakeStream     *cryptoStream
+	oneRTTStream        *cryptoStream // only set for the server
 	cryptoStreamHandler cryptoStreamHandler
 
 	receivedPackets  chan receivedPacket
@@ -472,6 +471,7 @@ func (s *connection) preSetup() {
 	s.streamsMap = newStreamsMap(
 		s.ctx,
 		s,
+		s.queueControlFrame,
 		s.newFlowController,
 		uint64(s.config.MaxIncomingStreams),
 		uint64(s.config.MaxIncomingUniStreams),
@@ -487,7 +487,6 @@ func (s *connection) preSetup() {
 	s.lastPacketReceivedTime = now
 	s.creationTime = now
 
-	s.windowUpdateQueue = newWindowUpdateQueue(s.connFlowController, s.framer.QueueControlFrame)
 	s.datagramQueue = newDatagramQueue(s.scheduleSending, s.logger)
 	s.connState.Version = s.version
 }
@@ -1675,10 +1674,8 @@ func (s *connection) dropEncryptionLevel(encLevel protocol.EncryptionLevel) erro
 		s.cryptoStreamHandler.DiscardInitialKeys()
 	case protocol.Encryption0RTT:
 		s.streamsMap.ResetFor0RTT()
-		if err := s.connFlowController.Reset(); err != nil {
-			return err
-		}
-		return s.framer.Handle0RTTRejection()
+		s.framer.Handle0RTTRejection()
+		return s.connFlowController.Reset()
 	}
 	return s.cryptoStreamManager.Drop(encLevel)
 }
@@ -1876,7 +1873,9 @@ func (s *connection) sendPackets(now time.Time) error {
 	if isBlocked, offset := s.connFlowController.IsNewlyBlocked(); isBlocked {
 		s.framer.QueueControlFrame(&wire.DataBlockedFrame{MaximumData: offset})
 	}
-	s.windowUpdateQueue.QueueAll()
+	if offset := s.connFlowController.GetWindowUpdate(); offset > 0 {
+		s.framer.QueueControlFrame(&wire.MaxDataFrame{MaximumData: offset})
+	}
 	if cf := s.cryptoStreamManager.GetPostHandshakeData(protocol.MaxPostHandshakeCryptoFrameSize); cf != nil {
 		s.queueControlFrame(cf)
 	}
@@ -2246,13 +2245,13 @@ func (s *connection) queueControlFrame(f wire.Frame) {
 	s.scheduleSending()
 }
 
-func (s *connection) onHasStreamWindowUpdate(id protocol.StreamID, str receiveStreamI) {
-	s.windowUpdateQueue.AddStream(id, str)
+func (s *connection) onHasStreamData(id protocol.StreamID, str sendStreamI) {
+	s.framer.AddActiveStream(id, str)
 	s.scheduleSending()
 }
 
-func (s *connection) onHasStreamData(id protocol.StreamID, str sendStreamI) {
-	s.framer.AddActiveStream(id, str)
+func (s *connection) onHasStreamControlFrame(id protocol.StreamID, str streamControlFrameGetter) {
+	s.framer.AddStreamWithControlFrames(id, str)
 	s.scheduleSending()
 }
 
@@ -2261,7 +2260,6 @@ func (s *connection) onStreamCompleted(id protocol.StreamID) {
 		s.closeLocal(err)
 	}
 	s.framer.RemoveActiveStream(id)
-	s.windowUpdateQueue.RemoveStream(id)
 }
 
 func (s *connection) onMTUIncreased(mtu protocol.ByteCount) {
diff --git a/crypto_stream.go b/crypto_stream.go
index 55700eaa..9a387baa 100644
--- a/crypto_stream.go
+++ b/crypto_stream.go
@@ -2,25 +2,13 @@ package quic
 
 import (
 	"fmt"
-	"io"
 
 	"github.com/quic-go/quic-go/internal/protocol"
 	"github.com/quic-go/quic-go/internal/qerr"
 	"github.com/quic-go/quic-go/internal/wire"
 )
 
-type cryptoStream interface {
-	// for receiving data
-	HandleCryptoFrame(*wire.CryptoFrame) error
-	GetCryptoData() []byte
-	Finish() error
-	// for sending data
-	io.Writer
-	HasData() bool
-	PopCryptoFrame(protocol.ByteCount) *wire.CryptoFrame
-}
-
-type cryptoStreamImpl struct {
+type cryptoStream struct {
 	queue frameSorter
 
 	highestOffset protocol.ByteCount
@@ -30,11 +18,11 @@ type cryptoStreamImpl struct {
 	writeBuf    []byte
 }
 
-func newCryptoStream() cryptoStream {
-	return &cryptoStreamImpl{queue: *newFrameSorter()}
+func newCryptoStream() *cryptoStream {
+	return &cryptoStream{queue: *newFrameSorter()}
 }
 
-func (s *cryptoStreamImpl) HandleCryptoFrame(f *wire.CryptoFrame) error {
+func (s *cryptoStream) HandleCryptoFrame(f *wire.CryptoFrame) error {
 	highestOffset := f.Offset + protocol.ByteCount(len(f.Data))
 	if maxOffset := highestOffset; maxOffset > protocol.MaxCryptoStreamOffset {
 		return &qerr.TransportError{
@@ -59,12 +47,12 @@ func (s *cryptoStreamImpl) HandleCryptoFrame(f *wire.CryptoFrame) error {
 }
 
 // GetCryptoData retrieves data that was received in CRYPTO frames
-func (s *cryptoStreamImpl) GetCryptoData() []byte {
+func (s *cryptoStream) GetCryptoData() []byte {
 	_, data, _ := s.queue.Pop()
 	return data
 }
 
-func (s *cryptoStreamImpl) Finish() error {
+func (s *cryptoStream) Finish() error {
 	if s.queue.HasMoreData() {
 		return &qerr.TransportError{
 			ErrorCode:    qerr.ProtocolViolation,
@@ -76,16 +64,16 @@ func (s *cryptoStreamImpl) Finish() error {
 }
 
 // Writes writes data that should be sent out in CRYPTO frames
-func (s *cryptoStreamImpl) Write(p []byte) (int, error) {
+func (s *cryptoStream) Write(p []byte) (int, error) {
 	s.writeBuf = append(s.writeBuf, p...)
 	return len(p), nil
 }
 
-func (s *cryptoStreamImpl) HasData() bool {
+func (s *cryptoStream) HasData() bool {
 	return len(s.writeBuf) > 0
 }
 
-func (s *cryptoStreamImpl) PopCryptoFrame(maxLen protocol.ByteCount) *wire.CryptoFrame {
+func (s *cryptoStream) PopCryptoFrame(maxLen protocol.ByteCount) *wire.CryptoFrame {
 	f := &wire.CryptoFrame{Offset: s.writeOffset}
 	n := min(f.MaxDataLen(maxLen), protocol.ByteCount(len(s.writeBuf)))
 	f.Data = s.writeBuf[:n]
diff --git a/crypto_stream_manager.go b/crypto_stream_manager.go
index b29eb408..d70b9b00 100644
--- a/crypto_stream_manager.go
+++ b/crypto_stream_manager.go
@@ -8,15 +8,15 @@ import (
 )
 
 type cryptoStreamManager struct {
-	initialStream   cryptoStream
-	handshakeStream cryptoStream
-	oneRTTStream    cryptoStream
+	initialStream   *cryptoStream
+	handshakeStream *cryptoStream
+	oneRTTStream    *cryptoStream
 }
 
 func newCryptoStreamManager(
-	initialStream cryptoStream,
-	handshakeStream cryptoStream,
-	oneRTTStream cryptoStream,
+	initialStream *cryptoStream,
+	handshakeStream *cryptoStream,
+	oneRTTStream *cryptoStream,
 ) *cryptoStreamManager {
 	return &cryptoStreamManager{
 		initialStream:   initialStream,
@@ -26,7 +26,7 @@ func newCryptoStreamManager(
 }
 
 func (m *cryptoStreamManager) HandleCryptoFrame(frame *wire.CryptoFrame, encLevel protocol.EncryptionLevel) error {
-	var str cryptoStream
+	var str *cryptoStream
 	//nolint:exhaustive // CRYPTO frames cannot be sent in 0-RTT packets.
 	switch encLevel {
 	case protocol.EncryptionInitial:
@@ -42,7 +42,7 @@ func (m *cryptoStreamManager) HandleCryptoFrame(frame *wire.CryptoFrame, encLeve
 }
 
 func (m *cryptoStreamManager) GetCryptoData(encLevel protocol.EncryptionLevel) []byte {
-	var str cryptoStream
+	var str *cryptoStream
 	//nolint:exhaustive // CRYPTO frames cannot be sent in 0-RTT packets.
 	switch encLevel {
 	case protocol.EncryptionInitial:
diff --git a/framer.go b/framer.go
index a9db80d6..e162f6b8 100644
--- a/framer.go
+++ b/framer.go
@@ -1,7 +1,7 @@
 package quic
 
 import (
-	"errors"
+	"slices"
 	"sync"
 
 	"github.com/quic-go/quic-go/internal/ackhandler"
@@ -16,11 +16,20 @@ const (
 	maxControlFrames = 16 << 10
 )
 
+// This is the largest possible size of a stream-related control frame
+// (which is the RESET_STREAM frame).
+const maxStreamControlFrameSize = 25
+
+type streamControlFrameGetter interface {
+	getControlFrame() (_ ackhandler.Frame, ok, hasMore bool)
+}
+
 type framer struct {
 	mutex sync.Mutex
 
-	activeStreams map[protocol.StreamID]sendStreamI
-	streamQueue   ringbuffer.RingBuffer[protocol.StreamID]
+	activeStreams            map[protocol.StreamID]sendStreamI
+	streamQueue              ringbuffer.RingBuffer[protocol.StreamID]
+	streamsWithControlFrames map[protocol.StreamID]streamControlFrameGetter
 
 	controlFrameMutex          sync.Mutex
 	controlFrames              []wire.Frame
@@ -29,7 +38,10 @@ type framer struct {
 }
 
 func newFramer() *framer {
-	return &framer{activeStreams: make(map[protocol.StreamID]sendStreamI)}
+	return &framer{
+		activeStreams:            make(map[protocol.StreamID]sendStreamI),
+		streamsWithControlFrames: make(map[protocol.StreamID]streamControlFrameGetter),
+	}
 }
 
 func (f *framer) HasData() bool {
@@ -41,7 +53,7 @@ func (f *framer) HasData() bool {
 	}
 	f.controlFrameMutex.Lock()
 	defer f.controlFrameMutex.Unlock()
-	return len(f.controlFrames) > 0 || len(f.pathResponses) > 0
+	return len(f.streamsWithControlFrames) > 0 || len(f.controlFrames) > 0 || len(f.pathResponses) > 0
 }
 
 func (f *framer) QueueControlFrame(frame wire.Frame) {
@@ -82,6 +94,29 @@ func (f *framer) AppendControlFrames(frames []ackhandler.Frame, maxLen protocol.
 		}
 	}
 
+	// add stream-related control frames
+	for id, str := range f.streamsWithControlFrames {
+	start:
+		remainingLen := maxLen - length
+		if remainingLen <= maxStreamControlFrameSize {
+			break
+		}
+		fr, ok, hasMore := str.getControlFrame()
+		if !hasMore {
+			delete(f.streamsWithControlFrames, id)
+		}
+		if !ok {
+			continue
+		}
+		frames = append(frames, fr)
+		length += fr.Frame.Length(v)
+		if hasMore {
+			// It is rare that a stream has more than one control frame to queue.
+			// We don't want to spawn another loop for just to cover that case.
+			goto start
+		}
+	}
+
 	for len(f.controlFrames) > 0 {
 		frame := f.controlFrames[len(f.controlFrames)-1]
 		frameLen := frame.Length(v)
@@ -92,6 +127,7 @@ func (f *framer) AppendControlFrames(frames []ackhandler.Frame, maxLen protocol.
 		length += frameLen
 		f.controlFrames = f.controlFrames[:len(f.controlFrames)-1]
 	}
+
 	return frames, length
 }
 
@@ -113,6 +149,14 @@ func (f *framer) AddActiveStream(id protocol.StreamID, str sendStreamI) {
 	f.mutex.Unlock()
 }
 
+func (f *framer) AddStreamWithControlFrames(id protocol.StreamID, str streamControlFrameGetter) {
+	f.controlFrameMutex.Lock()
+	if _, ok := f.streamsWithControlFrames[id]; !ok {
+		f.streamsWithControlFrames[id] = str
+	}
+	f.controlFrameMutex.Unlock()
+}
+
 // RemoveActiveStream is called when a stream completes.
 func (f *framer) RemoveActiveStream(id protocol.StreamID) {
 	f.mutex.Lock()
@@ -127,7 +171,7 @@ func (f *framer) AppendStreamFrames(frames []ackhandler.StreamFrame, maxLen prot
 	startLen := len(frames)
 	var length protocol.ByteCount
 	f.mutex.Lock()
-	// pop STREAM frames, until less than MinStreamFrameSize bytes are left in the packet
+	// pop STREAM frames, until less than 128 bytes are left in the packet
 	numActiveStreams := f.streamQueue.Len()
 	for i := 0; i < numActiveStreams; i++ {
 		if protocol.MinStreamFrameSize+length > maxLen {
@@ -153,7 +197,7 @@ func (f *framer) AppendStreamFrames(frames []ackhandler.StreamFrame, maxLen prot
 			delete(f.activeStreams, id)
 		}
 		// The frame can be "nil"
-		// * if the receiveStream was canceled after it said it had data
+		// * if the stream was canceled after it said it had data
 		// * the remaining size doesn't allow us to add another STREAM frame
 		if !ok {
 			continue
@@ -171,11 +215,12 @@ func (f *framer) AppendStreamFrames(frames []ackhandler.StreamFrame, maxLen prot
 	return frames, length
 }
 
-func (f *framer) Handle0RTTRejection() error {
+func (f *framer) Handle0RTTRejection() {
 	f.mutex.Lock()
 	defer f.mutex.Unlock()
-
 	f.controlFrameMutex.Lock()
+	defer f.controlFrameMutex.Unlock()
+
 	f.streamQueue.Clear()
 	for id := range f.activeStreams {
 		delete(f.activeStreams, id)
@@ -183,16 +228,13 @@ func (f *framer) Handle0RTTRejection() error {
 	var j int
 	for i, frame := range f.controlFrames {
 		switch frame.(type) {
-		case *wire.MaxDataFrame, *wire.MaxStreamDataFrame, *wire.MaxStreamsFrame:
-			return errors.New("didn't expect MAX_DATA / MAX_STREAM_DATA / MAX_STREAMS frame to be sent in 0-RTT")
-		case *wire.DataBlockedFrame, *wire.StreamDataBlockedFrame, *wire.StreamsBlockedFrame:
+		case *wire.MaxDataFrame, *wire.MaxStreamDataFrame, *wire.MaxStreamsFrame,
+			*wire.DataBlockedFrame, *wire.StreamDataBlockedFrame, *wire.StreamsBlockedFrame:
 			continue
 		default:
 			f.controlFrames[j] = f.controlFrames[i]
 			j++
 		}
 	}
-	f.controlFrames = f.controlFrames[:j]
-	f.controlFrameMutex.Unlock()
-	return nil
+	f.controlFrames = slices.Delete(f.controlFrames, j, len(f.controlFrames))
 }
diff --git a/http3/client.go b/http3/client.go
index 23ac088f..e60acffe 100644
--- a/http3/client.go
+++ b/http3/client.go
@@ -88,6 +88,7 @@ func (c *SingleDestinationRoundTripper) init() {
 		c.EnableDatagrams,
 		protocol.PerspectiveClient,
 		c.Logger,
+		0,
 	)
 	// send the SETTINGs frame, using 0-RTT data, if possible
 	go func() {
diff --git a/http3/conn.go b/http3/conn.go
index df7fb282..0fd9412f 100644
--- a/http3/conn.go
+++ b/http3/conn.go
@@ -7,6 +7,7 @@ import (
 	"net"
 	"sync"
 	"sync/atomic"
+	"time"
 
 	"github.com/quic-go/quic-go"
 	"github.com/quic-go/quic-go/internal/protocol"
@@ -51,6 +52,9 @@ type connection struct {
 
 	settings         *Settings
 	receivedSettings chan struct{}
+
+	idleTimeout time.Duration
+	idleTimer   *time.Timer
 }
 
 func newConnection(
@@ -59,17 +63,27 @@ func newConnection(
 	enableDatagrams bool,
 	perspective protocol.Perspective,
 	logger *slog.Logger,
+	idleTimeout time.Duration,
 ) *connection {
-	return &connection{
+	c := &connection{
 		ctx:              ctx,
 		Connection:       quicConn,
 		perspective:      perspective,
 		logger:           logger,
+		idleTimeout:      idleTimeout,
 		enableDatagrams:  enableDatagrams,
 		decoder:          qpack.NewDecoder(func(hf qpack.HeaderField) {}),
 		receivedSettings: make(chan struct{}),
 		streams:          make(map[protocol.StreamID]*datagrammer),
 	}
+	if idleTimeout > 0 {
+		c.idleTimer = time.AfterFunc(idleTimeout, c.onIdleTimer)
+	}
+	return c
+}
+
+func (c *connection) onIdleTimer() {
+	c.CloseWithError(quic.ApplicationErrorCode(ErrCodeNoError), "idle timeout")
 }
 
 func (c *connection) clearStream(id quic.StreamID) {
@@ -77,6 +91,9 @@ func (c *connection) clearStream(id quic.StreamID) {
 	defer c.streamMx.Unlock()
 
 	delete(c.streams, id)
+	if c.idleTimeout > 0 && len(c.streams) == 0 {
+		c.idleTimer.Reset(c.idleTimeout)
+	}
 }
 
 func (c *connection) openRequestStream(
@@ -109,12 +126,24 @@ func (c *connection) acceptStream(ctx context.Context) (quic.Stream, *datagramme
 		strID := str.StreamID()
 		c.streamMx.Lock()
 		c.streams[strID] = datagrams
+		if c.idleTimeout > 0 {
+			if len(c.streams) == 1 {
+				c.idleTimer.Stop()
+			}
+		}
 		c.streamMx.Unlock()
 		str = newStateTrackingStream(str, c, datagrams)
 	}
 	return str, datagrams, nil
 }
 
+func (c *connection) CloseWithError(code quic.ApplicationErrorCode, msg string) error {
+	if c.idleTimer != nil {
+		c.idleTimer.Stop()
+	}
+	return c.Connection.CloseWithError(code, msg)
+}
+
 func (c *connection) HandleUnidirectionalStreams(hijack func(StreamType, quic.ConnectionTracingID, quic.ReceiveStream, error) (hijacked bool)) {
 	var (
 		rcvdControlStr      atomic.Bool
diff --git a/http3/server.go b/http3/server.go
index cf52c174..9e7cd644 100644
--- a/http3/server.go
+++ b/http3/server.go
@@ -198,6 +198,12 @@ type Server struct {
 	// In that case, the stream type will not be set.
 	UniStreamHijacker func(StreamType, quic.ConnectionTracingID, quic.ReceiveStream, error) (hijacked bool)
 
+	// IdleTimeout specifies how long until idle clients connection should be
+	// closed. Idle refers only to the HTTP/3 layer, activity at the QUIC layer
+	// like PING frames are not considered.
+	// If zero or negative, there is no timeout.
+	IdleTimeout time.Duration
+
 	// ConnContext optionally specifies a function that modifies the context used for a new connection c.
 	// The provided ctx has a ServerContextKey value.
 	ConnContext func(ctx context.Context, c quic.Connection) context.Context
@@ -479,8 +485,10 @@ func (s *Server) handleConn(conn quic.Connection) error {
 		s.EnableDatagrams,
 		protocol.PerspectiveServer,
 		s.Logger,
+		s.IdleTimeout,
 	)
 	go hconn.HandleUnidirectionalStreams(s.UniStreamHijacker)
+
 	// Process all requests immediately.
 	// It's the client's responsibility to decide which requests are eligible for 0-RTT.
 	for {
diff --git a/internal/handshake/crypto_setup.go b/internal/handshake/crypto_setup.go
index 9347eef6..0fb75dc8 100644
--- a/internal/handshake/crypto_setup.go
+++ b/internal/handshake/crypto_setup.go
@@ -624,8 +624,7 @@ func (h *cryptoSetup) ConnectionState() ConnectionState {
 }
 
 func wrapError(err error) error {
-	// alert 80 is an internal error
-	if alertErr := tls.AlertError(0); errors.As(err, &alertErr) && alertErr != 80 {
+	if alertErr := tls.AlertError(0); errors.As(err, &alertErr) {
 		return qerr.NewLocalCryptoError(uint8(alertErr), err)
 	}
 	return &qerr.TransportError{ErrorCode: qerr.InternalError, ErrorMessage: err.Error()}
diff --git a/internal/mocks/logging/connection_tracer.go b/internal/mocks/logging/connection_tracer.go
index bd3a39c1..7d7f8c3a 100644
--- a/internal/mocks/logging/connection_tracer.go
+++ b/internal/mocks/logging/connection_tracer.go
@@ -20,7 +20,7 @@ func NewMockConnectionTracer(ctrl *gomock.Controller) (*logging.ConnectionTracer
 		StartedConnection: func(local, remote net.Addr, srcConnID, destConnID logging.ConnectionID) {
 			t.StartedConnection(local, remote, srcConnID, destConnID)
 		},
-		NegotiatedVersion: func(chosen logging.VersionNumber, clientVersions, serverVersions []logging.VersionNumber) {
+		NegotiatedVersion: func(chosen logging.Version, clientVersions, serverVersions []logging.Version) {
 			t.NegotiatedVersion(chosen, clientVersions, serverVersions)
 		},
 		ClosedConnection: func(e error) {
@@ -41,7 +41,7 @@ func NewMockConnectionTracer(ctrl *gomock.Controller) (*logging.ConnectionTracer
 		SentShortHeaderPacket: func(hdr *logging.ShortHeader, size logging.ByteCount, ecn logging.ECN, ack *logging.AckFrame, frames []logging.Frame) {
 			t.SentShortHeaderPacket(hdr, size, ecn, ack, frames)
 		},
-		ReceivedVersionNegotiationPacket: func(dest, src logging.ArbitraryLenConnectionID, versions []logging.VersionNumber) {
+		ReceivedVersionNegotiationPacket: func(dest, src logging.ArbitraryLenConnectionID, versions []logging.Version) {
 			t.ReceivedVersionNegotiationPacket(dest, src, versions)
 		},
 		ReceivedRetry: func(hdr *logging.Header) {
diff --git a/internal/mocks/logging/mockgen.go b/internal/mocks/logging/mockgen.go
index 3759aa1a..69f1969e 100644
--- a/internal/mocks/logging/mockgen.go
+++ b/internal/mocks/logging/mockgen.go
@@ -12,7 +12,7 @@ import (
 //go:generate sh -c "go run go.uber.org/mock/mockgen -typed -build_flags=\"-tags=gomock\" -package internal -destination internal/tracer.go github.com/quic-go/quic-go/internal/mocks/logging Tracer"
 type Tracer interface {
 	SentPacket(net.Addr, *logging.Header, logging.ByteCount, []logging.Frame)
-	SentVersionNegotiationPacket(_ net.Addr, dest, src logging.ArbitraryLenConnectionID, _ []logging.VersionNumber)
+	SentVersionNegotiationPacket(_ net.Addr, dest, src logging.ArbitraryLenConnectionID, _ []logging.Version)
 	DroppedPacket(net.Addr, logging.PacketType, logging.ByteCount, logging.PacketDropReason)
 	Debug(name, msg string)
 	Close()
@@ -21,14 +21,14 @@ type Tracer interface {
 //go:generate sh -c "go run go.uber.org/mock/mockgen -typed -build_flags=\"-tags=gomock\" -package internal -destination internal/connection_tracer.go github.com/quic-go/quic-go/internal/mocks/logging ConnectionTracer"
 type ConnectionTracer interface {
 	StartedConnection(local, remote net.Addr, srcConnID, destConnID logging.ConnectionID)
-	NegotiatedVersion(chosen logging.VersionNumber, clientVersions, serverVersions []logging.VersionNumber)
+	NegotiatedVersion(chosen logging.Version, clientVersions, serverVersions []logging.Version)
 	ClosedConnection(error)
 	SentTransportParameters(*logging.TransportParameters)
 	ReceivedTransportParameters(*logging.TransportParameters)
 	RestoredTransportParameters(parameters *logging.TransportParameters) // for 0-RTT
 	SentLongHeaderPacket(*logging.ExtendedHeader, logging.ByteCount, logging.ECN, *logging.AckFrame, []logging.Frame)
 	SentShortHeaderPacket(*logging.ShortHeader, logging.ByteCount, logging.ECN, *logging.AckFrame, []logging.Frame)
-	ReceivedVersionNegotiationPacket(dest, src logging.ArbitraryLenConnectionID, _ []logging.VersionNumber)
+	ReceivedVersionNegotiationPacket(dest, src logging.ArbitraryLenConnectionID, _ []logging.Version)
 	ReceivedRetry(*logging.Header)
 	ReceivedLongHeaderPacket(*logging.ExtendedHeader, logging.ByteCount, logging.ECN, []logging.Frame)
 	ReceivedShortHeaderPacket(*logging.ShortHeader, logging.ByteCount, logging.ECN, []logging.Frame)
diff --git a/internal/mocks/logging/tracer.go b/internal/mocks/logging/tracer.go
index a4b081bf..482c707a 100644
--- a/internal/mocks/logging/tracer.go
+++ b/internal/mocks/logging/tracer.go
@@ -19,7 +19,7 @@ func NewMockTracer(ctrl *gomock.Controller) (*logging.Tracer, *MockTracer) {
 		SentPacket: func(remote net.Addr, hdr *logging.Header, size logging.ByteCount, frames []logging.Frame) {
 			t.SentPacket(remote, hdr, size, frames)
 		},
-		SentVersionNegotiationPacket: func(remote net.Addr, dest, src logging.ArbitraryLenConnectionID, versions []logging.VersionNumber) {
+		SentVersionNegotiationPacket: func(remote net.Addr, dest, src logging.ArbitraryLenConnectionID, versions []logging.Version) {
 			t.SentVersionNegotiationPacket(remote, dest, src, versions)
 		},
 		DroppedPacket: func(remote net.Addr, typ logging.PacketType, size logging.ByteCount, reason logging.PacketDropReason) {
diff --git a/internal/mocks/mockgen.go b/internal/mocks/mockgen.go
index b736631d..58b511d8 100644
--- a/internal/mocks/mockgen.go
+++ b/internal/mocks/mockgen.go
@@ -13,7 +13,3 @@ package mocks
 //go:generate sh -c "go run go.uber.org/mock/mockgen -typed -build_flags=\"-tags=gomock\" -package mocks -destination connection_flow_controller.go github.com/quic-go/quic-go/internal/flowcontrol ConnectionFlowController"
 //go:generate sh -c "go run go.uber.org/mock/mockgen -typed -build_flags=\"-tags=gomock\" -package mockackhandler -destination ackhandler/sent_packet_handler.go github.com/quic-go/quic-go/internal/ackhandler SentPacketHandler"
 //go:generate sh -c "go run go.uber.org/mock/mockgen -typed -build_flags=\"-tags=gomock\" -package mockackhandler -destination ackhandler/received_packet_handler.go github.com/quic-go/quic-go/internal/ackhandler ReceivedPacketHandler"
-
-// The following command produces a warning message on OSX, however, it still generates the correct mock file.
-// See https://github.com/golang/mock/issues/339 for details.
-//go:generate sh -c "go run go.uber.org/mock/mockgen -typed -build_flags=\"-tags=gomock\" -package mocktls -destination tls/client_session_cache.go crypto/tls ClientSessionCache"
diff --git a/internal/mocks/tls/client_session_cache.go b/internal/mocks/tls/client_session_cache.go
deleted file mode 100644
index 5e2cf97f..00000000
--- a/internal/mocks/tls/client_session_cache.go
+++ /dev/null
@@ -1,115 +0,0 @@
-// Code generated by MockGen. DO NOT EDIT.
-// Source: crypto/tls (interfaces: ClientSessionCache)
-//
-// Generated by this command:
-//
-//	mockgen -typed -build_flags=-tags=gomock -package mocktls -destination tls/client_session_cache.go crypto/tls ClientSessionCache
-//
-
-// Package mocktls is a generated GoMock package.
-package mocktls
-
-import (
-	tls "crypto/tls"
-	reflect "reflect"
-
-	gomock "go.uber.org/mock/gomock"
-)
-
-// MockClientSessionCache is a mock of ClientSessionCache interface.
-type MockClientSessionCache struct {
-	ctrl     *gomock.Controller
-	recorder *MockClientSessionCacheMockRecorder
-}
-
-// MockClientSessionCacheMockRecorder is the mock recorder for MockClientSessionCache.
-type MockClientSessionCacheMockRecorder struct {
-	mock *MockClientSessionCache
-}
-
-// NewMockClientSessionCache creates a new mock instance.
-func NewMockClientSessionCache(ctrl *gomock.Controller) *MockClientSessionCache {
-	mock := &MockClientSessionCache{ctrl: ctrl}
-	mock.recorder = &MockClientSessionCacheMockRecorder{mock}
-	return mock
-}
-
-// EXPECT returns an object that allows the caller to indicate expected use.
-func (m *MockClientSessionCache) EXPECT() *MockClientSessionCacheMockRecorder {
-	return m.recorder
-}
-
-// Get mocks base method.
-func (m *MockClientSessionCache) Get(arg0 string) (*tls.ClientSessionState, bool) {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "Get", arg0)
-	ret0, _ := ret[0].(*tls.ClientSessionState)
-	ret1, _ := ret[1].(bool)
-	return ret0, ret1
-}
-
-// Get indicates an expected call of Get.
-func (mr *MockClientSessionCacheMockRecorder) Get(arg0 any) *MockClientSessionCacheGetCall {
-	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockClientSessionCache)(nil).Get), arg0)
-	return &MockClientSessionCacheGetCall{Call: call}
-}
-
-// MockClientSessionCacheGetCall wrap *gomock.Call
-type MockClientSessionCacheGetCall struct {
-	*gomock.Call
-}
-
-// Return rewrite *gomock.Call.Return
-func (c *MockClientSessionCacheGetCall) Return(arg0 *tls.ClientSessionState, arg1 bool) *MockClientSessionCacheGetCall {
-	c.Call = c.Call.Return(arg0, arg1)
-	return c
-}
-
-// Do rewrite *gomock.Call.Do
-func (c *MockClientSessionCacheGetCall) Do(f func(string) (*tls.ClientSessionState, bool)) *MockClientSessionCacheGetCall {
-	c.Call = c.Call.Do(f)
-	return c
-}
-
-// DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MockClientSessionCacheGetCall) DoAndReturn(f func(string) (*tls.ClientSessionState, bool)) *MockClientSessionCacheGetCall {
-	c.Call = c.Call.DoAndReturn(f)
-	return c
-}
-
-// Put mocks base method.
-func (m *MockClientSessionCache) Put(arg0 string, arg1 *tls.ClientSessionState) {
-	m.ctrl.T.Helper()
-	m.ctrl.Call(m, "Put", arg0, arg1)
-}
-
-// Put indicates an expected call of Put.
-func (mr *MockClientSessionCacheMockRecorder) Put(arg0, arg1 any) *MockClientSessionCachePutCall {
-	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Put", reflect.TypeOf((*MockClientSessionCache)(nil).Put), arg0, arg1)
-	return &MockClientSessionCachePutCall{Call: call}
-}
-
-// MockClientSessionCachePutCall wrap *gomock.Call
-type MockClientSessionCachePutCall struct {
-	*gomock.Call
-}
-
-// Return rewrite *gomock.Call.Return
-func (c *MockClientSessionCachePutCall) Return() *MockClientSessionCachePutCall {
-	c.Call = c.Call.Return()
-	return c
-}
-
-// Do rewrite *gomock.Call.Do
-func (c *MockClientSessionCachePutCall) Do(f func(string, *tls.ClientSessionState)) *MockClientSessionCachePutCall {
-	c.Call = c.Call.Do(f)
-	return c
-}
-
-// DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MockClientSessionCachePutCall) DoAndReturn(f func(string, *tls.ClientSessionState)) *MockClientSessionCachePutCall {
-	c.Call = c.Call.DoAndReturn(f)
-	return c
-}
diff --git a/internal/wire/header.go b/internal/wire/header.go
index 1274e83d..678a04a2 100644
--- a/internal/wire/header.go
+++ b/internal/wire/header.go
@@ -1,7 +1,6 @@
 package wire
 
 import (
-	"bytes"
 	"encoding/binary"
 	"errors"
 	"fmt"
@@ -40,37 +39,27 @@ func ParseConnectionID(data []byte, shortHeaderConnIDLen int) (protocol.Connecti
 // https://datatracker.ietf.org/doc/html/rfc8999#section-5.1.
 // This function should only be called on Long Header packets for which we don't support the version.
 func ParseArbitraryLenConnectionIDs(data []byte) (bytesParsed int, dest, src protocol.ArbitraryLenConnectionID, _ error) {
-	r := bytes.NewReader(data)
-	remaining := r.Len()
-	src, dest, err := parseArbitraryLenConnectionIDs(r)
-	return remaining - r.Len(), src, dest, err
-}
-
-func parseArbitraryLenConnectionIDs(r *bytes.Reader) (dest, src protocol.ArbitraryLenConnectionID, _ error) {
-	r.Seek(5, io.SeekStart) // skip first byte and version field
-	destConnIDLen, err := r.ReadByte()
-	if err != nil {
-		return nil, nil, err
+	startLen := len(data)
+	if len(data) < 6 {
+		return 0, nil, nil, io.EOF
 	}
+	data = data[5:] // skip first byte and version field
+	destConnIDLen := data[0]
+	data = data[1:]
 	destConnID := make(protocol.ArbitraryLenConnectionID, destConnIDLen)
-	if _, err := io.ReadFull(r, destConnID); err != nil {
-		if err == io.ErrUnexpectedEOF {
-			err = io.EOF
-		}
-		return nil, nil, err
+	if len(data) < int(destConnIDLen)+1 {
+		return 0, nil, nil, io.EOF
 	}
-	srcConnIDLen, err := r.ReadByte()
-	if err != nil {
-		return nil, nil, err
+	copy(destConnID, data)
+	data = data[destConnIDLen:]
+	srcConnIDLen := data[0]
+	data = data[1:]
+	if len(data) < int(srcConnIDLen) {
+		return 0, nil, nil, io.EOF
 	}
 	srcConnID := make(protocol.ArbitraryLenConnectionID, srcConnIDLen)
-	if _, err := io.ReadFull(r, srcConnID); err != nil {
-		if err == io.ErrUnexpectedEOF {
-			err = io.EOF
-		}
-		return nil, nil, err
-	}
-	return destConnID, srcConnID, nil
+	copy(srcConnID, data)
+	return startLen - len(data) + int(srcConnIDLen), destConnID, srcConnID, nil
 }
 
 func IsPotentialQUICPacket(firstByte byte) bool {
diff --git a/interop/utils/logging.go b/interop/utils/logging.go
index fa076ef4..7e718965 100644
--- a/interop/utils/logging.go
+++ b/interop/utils/logging.go
@@ -39,7 +39,7 @@ func NewQLOGConnectionTracer(_ context.Context, p logging.Perspective, connID qu
 			log.Fatalf("failed to create qlog dir %s: %v", qlogDir, err)
 		}
 	}
-	path := fmt.Sprintf("%s/%s.qlog", strings.TrimRight(qlogDir, "/"), connID)
+	path := fmt.Sprintf("%s/%s.sqlog", strings.TrimRight(qlogDir, "/"), connID)
 	f, err := os.Create(path)
 	if err != nil {
 		log.Printf("Failed to create qlog file %s: %s", path, err.Error())
diff --git a/logging/connection_tracer.go b/logging/connection_tracer.go
index a354bbd9..96bf4617 100644
--- a/logging/connection_tracer.go
+++ b/logging/connection_tracer.go
@@ -8,14 +8,14 @@ import (
 // A ConnectionTracer records events.
 type ConnectionTracer struct {
 	StartedConnection                func(local, remote net.Addr, srcConnID, destConnID ConnectionID)
-	NegotiatedVersion                func(chosen VersionNumber, clientVersions, serverVersions []VersionNumber)
+	NegotiatedVersion                func(chosen Version, clientVersions, serverVersions []Version)
 	ClosedConnection                 func(error)
 	SentTransportParameters          func(*TransportParameters)
 	ReceivedTransportParameters      func(*TransportParameters)
 	RestoredTransportParameters      func(parameters *TransportParameters) // for 0-RTT
 	SentLongHeaderPacket             func(*ExtendedHeader, ByteCount, ECN, *AckFrame, []Frame)
 	SentShortHeaderPacket            func(*ShortHeader, ByteCount, ECN, *AckFrame, []Frame)
-	ReceivedVersionNegotiationPacket func(dest, src ArbitraryLenConnectionID, _ []VersionNumber)
+	ReceivedVersionNegotiationPacket func(dest, src ArbitraryLenConnectionID, _ []Version)
 	ReceivedRetry                    func(*Header)
 	ReceivedLongHeaderPacket         func(*ExtendedHeader, ByteCount, ECN, []Frame)
 	ReceivedShortHeaderPacket        func(*ShortHeader, ByteCount, ECN, []Frame)
@@ -57,7 +57,7 @@ func NewMultiplexedConnectionTracer(tracers ...*ConnectionTracer) *ConnectionTra
 				}
 			}
 		},
-		NegotiatedVersion: func(chosen VersionNumber, clientVersions, serverVersions []VersionNumber) {
+		NegotiatedVersion: func(chosen Version, clientVersions, serverVersions []Version) {
 			for _, t := range tracers {
 				if t.NegotiatedVersion != nil {
 					t.NegotiatedVersion(chosen, clientVersions, serverVersions)
@@ -106,7 +106,7 @@ func NewMultiplexedConnectionTracer(tracers ...*ConnectionTracer) *ConnectionTra
 				}
 			}
 		},
-		ReceivedVersionNegotiationPacket: func(dest, src ArbitraryLenConnectionID, versions []VersionNumber) {
+		ReceivedVersionNegotiationPacket: func(dest, src ArbitraryLenConnectionID, versions []Version) {
 			for _, t := range tracers {
 				if t.ReceivedVersionNegotiationPacket != nil {
 					t.ReceivedVersionNegotiationPacket(dest, src, versions)
diff --git a/logging/interface.go b/logging/interface.go
index a618a189..254911bd 100644
--- a/logging/interface.go
+++ b/logging/interface.go
@@ -37,7 +37,10 @@ type (
 	// The StreamType is the type of the stream (unidirectional or bidirectional).
 	StreamType = protocol.StreamType
 	// The VersionNumber is the QUIC version.
+	// Deprecated: use Version instead.
 	VersionNumber = protocol.Version
+	// The Version is the QUIC version.
+	Version = protocol.Version
 
 	// The Header is the QUIC packet header, before removing header protection.
 	Header = wire.Header
@@ -72,27 +75,27 @@ const (
 
 const (
 	// KeyPhaseZero is key phase bit 0
-	KeyPhaseZero KeyPhaseBit = protocol.KeyPhaseZero
+	KeyPhaseZero = protocol.KeyPhaseZero
 	// KeyPhaseOne is key phase bit 1
-	KeyPhaseOne KeyPhaseBit = protocol.KeyPhaseOne
+	KeyPhaseOne = protocol.KeyPhaseOne
 )
 
 const (
 	// PerspectiveServer is used for a QUIC server
-	PerspectiveServer Perspective = protocol.PerspectiveServer
+	PerspectiveServer = protocol.PerspectiveServer
 	// PerspectiveClient is used for a QUIC client
-	PerspectiveClient Perspective = protocol.PerspectiveClient
+	PerspectiveClient = protocol.PerspectiveClient
 )
 
 const (
 	// EncryptionInitial is the Initial encryption level
-	EncryptionInitial EncryptionLevel = protocol.EncryptionInitial
+	EncryptionInitial = protocol.EncryptionInitial
 	// EncryptionHandshake is the Handshake encryption level
-	EncryptionHandshake EncryptionLevel = protocol.EncryptionHandshake
+	EncryptionHandshake = protocol.EncryptionHandshake
 	// Encryption1RTT is the 1-RTT encryption level
-	Encryption1RTT EncryptionLevel = protocol.Encryption1RTT
+	Encryption1RTT = protocol.Encryption1RTT
 	// Encryption0RTT is the 0-RTT encryption level
-	Encryption0RTT EncryptionLevel = protocol.Encryption0RTT
+	Encryption0RTT = protocol.Encryption0RTT
 )
 
 const (
diff --git a/logging/tracer.go b/logging/tracer.go
index edd85dba..625a809e 100644
--- a/logging/tracer.go
+++ b/logging/tracer.go
@@ -5,7 +5,7 @@ import "net"
 // A Tracer traces events.
 type Tracer struct {
 	SentPacket                   func(net.Addr, *Header, ByteCount, []Frame)
-	SentVersionNegotiationPacket func(_ net.Addr, dest, src ArbitraryLenConnectionID, _ []VersionNumber)
+	SentVersionNegotiationPacket func(_ net.Addr, dest, src ArbitraryLenConnectionID, _ []Version)
 	DroppedPacket                func(net.Addr, PacketType, ByteCount, PacketDropReason)
 	Debug                        func(name, msg string)
 	Close                        func()
@@ -27,7 +27,7 @@ func NewMultiplexedTracer(tracers ...*Tracer) *Tracer {
 				}
 			}
 		},
-		SentVersionNegotiationPacket: func(remote net.Addr, dest, src ArbitraryLenConnectionID, versions []VersionNumber) {
+		SentVersionNegotiationPacket: func(remote net.Addr, dest, src ArbitraryLenConnectionID, versions []Version) {
 			for _, t := range tracers {
 				if t.SentVersionNegotiationPacket != nil {
 					t.SentVersionNegotiationPacket(remote, dest, src, versions)
diff --git a/metrics/tracer.go b/metrics/tracer.go
index 41245bbd..293291db 100644
--- a/metrics/tracer.go
+++ b/metrics/tracer.go
@@ -108,7 +108,7 @@ func NewTracerWithRegisterer(registerer prometheus.Registerer) *logging.Tracer {
 			*tags = append(*tags, reason)
 			connsRejected.WithLabelValues(*tags...).Inc()
 		},
-		SentVersionNegotiationPacket: func(addr net.Addr, _, _ logging.ArbitraryLenConnectionID, _ []logging.VersionNumber) {
+		SentVersionNegotiationPacket: func(addr net.Addr, _, _ logging.ArbitraryLenConnectionID, _ []logging.Version) {
 			tags := getStringSlice()
 			defer putStringSlice(tags)
 
diff --git a/mockgen.go b/mockgen.go
index 1a0c2905..65ec465a 100644
--- a/mockgen.go
+++ b/mockgen.go
@@ -14,9 +14,6 @@ type Sender = sender
 //go:generate sh -c "go run go.uber.org/mock/mockgen -typed -build_flags=\"-tags=gomock\" -package quic -self_package github.com/quic-go/quic-go -destination mock_stream_internal_test.go github.com/quic-go/quic-go StreamI"
 type StreamI = streamI
 
-//go:generate sh -c "go run go.uber.org/mock/mockgen -typed -build_flags=\"-tags=gomock\" -package quic -self_package github.com/quic-go/quic-go -destination mock_crypto_stream_test.go github.com/quic-go/quic-go CryptoStream"
-type CryptoStream = cryptoStream
-
 //go:generate sh -c "go run go.uber.org/mock/mockgen -typed -build_flags=\"-tags=gomock\" -package quic -self_package github.com/quic-go/quic-go -destination mock_receive_stream_internal_test.go github.com/quic-go/quic-go ReceiveStreamI"
 type ReceiveStreamI = receiveStreamI
 
@@ -26,6 +23,9 @@ type SendStreamI = sendStreamI
 //go:generate sh -c "go run go.uber.org/mock/mockgen -typed -build_flags=\"-tags=gomock\" -package quic -self_package github.com/quic-go/quic-go -destination mock_stream_sender_test.go github.com/quic-go/quic-go StreamSender"
 type StreamSender = streamSender
 
+//go:generate sh -c "go run go.uber.org/mock/mockgen -typed -build_flags=\"-tags=gomock\" -package quic -self_package github.com/quic-go/quic-go -destination mock_stream_control_frame_getter_test.go github.com/quic-go/quic-go StreamControlFrameGetter"
+type StreamControlFrameGetter = streamControlFrameGetter
+
 //go:generate sh -c "go run go.uber.org/mock/mockgen -typed -build_flags=\"-tags=gomock\" -package quic -self_package github.com/quic-go/quic-go -destination mock_frame_source_test.go github.com/quic-go/quic-go FrameSource"
 type FrameSource = frameSource
 
diff --git a/packet_packer.go b/packet_packer.go
index e707734f..f505f16a 100644
--- a/packet_packer.go
+++ b/packet_packer.go
@@ -121,8 +121,8 @@ type packetPacker struct {
 	perspective protocol.Perspective
 	cryptoSetup sealingManager
 
-	initialStream   cryptoStream
-	handshakeStream cryptoStream
+	initialStream   *cryptoStream
+	handshakeStream *cryptoStream
 
 	token []byte
 
@@ -141,7 +141,7 @@ var _ packer = &packetPacker{}
 func newPacketPacker(
 	srcConnID protocol.ConnectionID,
 	getDestConnID func() protocol.ConnectionID,
-	initialStream, handshakeStream cryptoStream,
+	initialStream, handshakeStream *cryptoStream,
 	packetNumberManager packetNumberManager,
 	retransmissionQueue *retransmissionQueue,
 	cryptoSetup sealingManager,
@@ -482,7 +482,7 @@ func (p *packetPacker) maybeGetCryptoPacket(maxPacketSize protocol.ByteCount, en
 		return nil, payload{}
 	}
 
-	var s cryptoStream
+	var s *cryptoStream
 	var handler ackhandler.FrameHandler
 	var hasRetransmission bool
 	//nolint:exhaustive // Initial and Handshake are the only two encryption levels here.
diff --git a/qlog/connection_tracer.go b/qlog/connection_tracer.go
index 3ea0ce11..b696c2c7 100644
--- a/qlog/connection_tracer.go
+++ b/qlog/connection_tracer.go
@@ -39,7 +39,7 @@ func NewConnectionTracer(w io.WriteCloser, p logging.Perspective, odcid protocol
 		StartedConnection: func(local, remote net.Addr, srcConnID, destConnID logging.ConnectionID) {
 			t.StartedConnection(local, remote, srcConnID, destConnID)
 		},
-		NegotiatedVersion: func(chosen logging.VersionNumber, clientVersions, serverVersions []logging.VersionNumber) {
+		NegotiatedVersion: func(chosen logging.Version, clientVersions, serverVersions []logging.Version) {
 			t.NegotiatedVersion(chosen, clientVersions, serverVersions)
 		},
 		ClosedConnection:            func(e error) { t.ClosedConnection(e) },
@@ -61,7 +61,7 @@ func NewConnectionTracer(w io.WriteCloser, p logging.Perspective, odcid protocol
 		ReceivedRetry: func(hdr *wire.Header) {
 			t.ReceivedRetry(hdr)
 		},
-		ReceivedVersionNegotiationPacket: func(dest, src logging.ArbitraryLenConnectionID, versions []logging.VersionNumber) {
+		ReceivedVersionNegotiationPacket: func(dest, src logging.ArbitraryLenConnectionID, versions []logging.Version) {
 			t.ReceivedVersionNegotiationPacket(dest, src, versions)
 		},
 		BufferedPacket: func(pt logging.PacketType, size protocol.ByteCount) {
@@ -147,24 +147,24 @@ func (t *connectionTracer) StartedConnection(local, remote net.Addr, srcConnID,
 	})
 }
 
-func (t *connectionTracer) NegotiatedVersion(chosen logging.VersionNumber, client, server []logging.VersionNumber) {
-	var clientVersions, serverVersions []versionNumber
+func (t *connectionTracer) NegotiatedVersion(chosen logging.Version, client, server []logging.Version) {
+	var clientVersions, serverVersions []version
 	if len(client) > 0 {
-		clientVersions = make([]versionNumber, len(client))
+		clientVersions = make([]version, len(client))
 		for i, v := range client {
-			clientVersions[i] = versionNumber(v)
+			clientVersions[i] = version(v)
 		}
 	}
 	if len(server) > 0 {
-		serverVersions = make([]versionNumber, len(server))
+		serverVersions = make([]version, len(server))
 		for i, v := range server {
-			serverVersions[i] = versionNumber(v)
+			serverVersions[i] = version(v)
 		}
 	}
 	t.recordEvent(time.Now(), &eventVersionNegotiated{
 		clientVersions: clientVersions,
 		serverVersions: serverVersions,
-		chosenVersion:  versionNumber(chosen),
+		chosenVersion:  version(chosen),
 	})
 }
 
@@ -313,10 +313,10 @@ func (t *connectionTracer) ReceivedRetry(hdr *wire.Header) {
 	})
 }
 
-func (t *connectionTracer) ReceivedVersionNegotiationPacket(dest, src logging.ArbitraryLenConnectionID, versions []logging.VersionNumber) {
-	ver := make([]versionNumber, len(versions))
+func (t *connectionTracer) ReceivedVersionNegotiationPacket(dest, src logging.ArbitraryLenConnectionID, versions []logging.Version) {
+	ver := make([]version, len(versions))
 	for i, v := range versions {
-		ver[i] = versionNumber(v)
+		ver[i] = version(v)
 	}
 	t.recordEvent(time.Now(), &eventVersionNegotiationReceived{
 		Header: packetHeaderVersionNegotiation{
diff --git a/qlog/event.go b/qlog/event.go
index 55fc3d5e..54134699 100644
--- a/qlog/event.go
+++ b/qlog/event.go
@@ -36,7 +36,7 @@ func (e event) MarshalJSONObject(enc *gojay.Encoder) {
 	enc.ObjectKey("data", e.eventDetails)
 }
 
-type versions []versionNumber
+type versions []version
 
 func (v versions) IsNil() bool { return false }
 func (v versions) MarshalJSONArray(enc *gojay.Encoder) {
@@ -85,8 +85,8 @@ func (e eventConnectionStarted) MarshalJSONObject(enc *gojay.Encoder) {
 }
 
 type eventVersionNegotiated struct {
-	clientVersions, serverVersions []versionNumber
-	chosenVersion                  versionNumber
+	clientVersions, serverVersions []version
+	chosenVersion                  version
 }
 
 func (e eventVersionNegotiated) Category() category { return categoryTransport }
@@ -220,7 +220,7 @@ func (e eventRetryReceived) MarshalJSONObject(enc *gojay.Encoder) {
 
 type eventVersionNegotiationReceived struct {
 	Header            packetHeaderVersionNegotiation
-	SupportedVersions []versionNumber
+	SupportedVersions []version
 }
 
 func (e eventVersionNegotiationReceived) Category() category { return categoryTransport }
@@ -234,7 +234,7 @@ func (e eventVersionNegotiationReceived) MarshalJSONObject(enc *gojay.Encoder) {
 
 type eventVersionNegotiationSent struct {
 	Header            packetHeaderVersionNegotiation
-	SupportedVersions []versionNumber
+	SupportedVersions []version
 }
 
 func (e eventVersionNegotiationSent) Category() category { return categoryTransport }
diff --git a/qlog/packet_header.go b/qlog/packet_header.go
index 53c9f2b0..d2c57ba6 100644
--- a/qlog/packet_header.go
+++ b/qlog/packet_header.go
@@ -43,7 +43,7 @@ type packetHeader struct {
 	KeyPhaseBit  logging.KeyPhaseBit
 	PacketNumber logging.PacketNumber
 
-	Version          logging.VersionNumber
+	Version          logging.Version
 	SrcConnectionID  logging.ConnectionID
 	DestConnectionID logging.ConnectionID
 
@@ -76,7 +76,7 @@ func (h packetHeader) MarshalJSONObject(enc *gojay.Encoder) {
 		enc.Int64Key("packet_number", int64(h.PacketNumber))
 	}
 	if h.Version != 0 {
-		enc.StringKey("version", versionNumber(h.Version).String())
+		enc.StringKey("version", version(h.Version).String())
 	}
 	if h.PacketType != logging.PacketType1RTT {
 		enc.IntKey("scil", h.SrcConnectionID.Len())
diff --git a/qlog/qlog_dir.go b/qlog/qlog_dir.go
index 5ea5e3ae..1c7e97fa 100644
--- a/qlog/qlog_dir.go
+++ b/qlog/qlog_dir.go
@@ -19,7 +19,7 @@ func DefaultTracer(ctx context.Context, p logging.Perspective, connID logging.Co
 }
 
 // DefaultConnectionTracer creates a qlog file in the qlog directory specified by the QLOGDIR environment variable.
-// File names are <odcid>_<perspective>.qlog.
+// File names are <odcid>_<perspective>.sqlog.
 // Returns nil if QLOGDIR is not set.
 func DefaultConnectionTracer(_ context.Context, p logging.Perspective, connID logging.ConnectionID) *logging.ConnectionTracer {
 	var label string
@@ -33,7 +33,7 @@ func DefaultConnectionTracer(_ context.Context, p logging.Perspective, connID lo
 }
 
 // qlogDirTracer creates a qlog file in the qlog directory specified by the QLOGDIR environment variable.
-// File names are <odcid>_<label>.qlog.
+// File names are <odcid>_<label>.sqlog.
 // Returns nil if QLOGDIR is not set.
 func qlogDirTracer(p logging.Perspective, connID logging.ConnectionID, label string) *logging.ConnectionTracer {
 	qlogDir := os.Getenv("QLOGDIR")
@@ -45,7 +45,7 @@ func qlogDirTracer(p logging.Perspective, connID logging.ConnectionID, label str
 			log.Fatalf("failed to create qlog dir %s: %v", qlogDir, err)
 		}
 	}
-	path := fmt.Sprintf("%s/%s_%s.qlog", strings.TrimRight(qlogDir, "/"), connID, label)
+	path := fmt.Sprintf("%s/%s_%s.sqlog", strings.TrimRight(qlogDir, "/"), connID, label)
 	f, err := os.Create(path)
 	if err != nil {
 		log.Printf("Failed to create qlog file %s: %s", path, err.Error())
diff --git a/qlog/trace.go b/qlog/trace.go
index c183368d..4c3d33c6 100644
--- a/qlog/trace.go
+++ b/qlog/trace.go
@@ -43,8 +43,8 @@ type topLevel struct {
 
 func (topLevel) IsNil() bool { return false }
 func (l topLevel) MarshalJSONObject(enc *gojay.Encoder) {
-	enc.StringKey("qlog_format", "NDJSON")
-	enc.StringKey("qlog_version", "draft-02")
+	enc.StringKey("qlog_format", "JSON-SEQ")
+	enc.StringKey("qlog_version", "0.3")
 	enc.StringKeyOmitEmpty("title", "quic-go qlog")
 	enc.ObjectKey("configuration", configuration{Version: quicGoVersion})
 	enc.ObjectKey("trace", l.trace)
diff --git a/qlog/tracer.go b/qlog/tracer.go
index 562f7a26..df364282 100644
--- a/qlog/tracer.go
+++ b/qlog/tracer.go
@@ -28,10 +28,10 @@ func NewTracer(w io.WriteCloser) *logging.Tracer {
 				Frames: fs,
 			})
 		},
-		SentVersionNegotiationPacket: func(_ net.Addr, dest, src logging.ArbitraryLenConnectionID, versions []logging.VersionNumber) {
-			ver := make([]versionNumber, len(versions))
+		SentVersionNegotiationPacket: func(_ net.Addr, dest, src logging.ArbitraryLenConnectionID, versions []logging.Version) {
+			ver := make([]version, len(versions))
 			for i, v := range versions {
-				ver[i] = versionNumber(v)
+				ver[i] = version(v)
 			}
 			wr.RecordEvent(time.Now(), &eventVersionNegotiationSent{
 				Header: packetHeaderVersionNegotiation{
diff --git a/qlog/types.go b/qlog/types.go
index 20f10594..25dfc710 100644
--- a/qlog/types.go
+++ b/qlog/types.go
@@ -64,9 +64,9 @@ func (c category) String() string {
 	}
 }
 
-type versionNumber protocol.Version
+type version protocol.Version
 
-func (v versionNumber) String() string {
+func (v version) String() string {
 	return fmt.Sprintf("%x", uint32(v))
 }
 
diff --git a/qlog/writer.go b/qlog/writer.go
index 10cb6499..661c54e8 100644
--- a/qlog/writer.go
+++ b/qlog/writer.go
@@ -12,6 +12,13 @@ import (
 
 const eventChanSize = 50
 
+const recordSeparator = 0x1e
+
+func writeRecordSeparator(w io.Writer) error {
+	_, err := w.Write([]byte{recordSeparator})
+	return err
+}
+
 type writer struct {
 	w io.WriteCloser
 
@@ -44,6 +51,9 @@ func (w *writer) Run() {
 	defer close(w.runStopped)
 	buf := &bytes.Buffer{}
 	enc := gojay.NewEncoder(buf)
+	if err := writeRecordSeparator(buf); err != nil {
+		panic(fmt.Sprintf("qlog encoding into a bytes.Buffer failed: %s", err))
+	}
 	if err := enc.Encode(&topLevel{trace: *w.tr}); err != nil {
 		panic(fmt.Sprintf("qlog encoding into a bytes.Buffer failed: %s", err))
 	}
@@ -58,6 +68,10 @@ func (w *writer) Run() {
 		if w.encodeErr != nil { // if encoding failed, just continue draining the event channel
 			continue
 		}
+		if err := writeRecordSeparator(w.w); err != nil {
+			w.encodeErr = err
+			continue
+		}
 		if err := enc.Encode(ev); err != nil {
 			w.encodeErr = err
 			continue
diff --git a/receive_stream.go b/receive_stream.go
index 8f5764b0..80340923 100644
--- a/receive_stream.go
+++ b/receive_stream.go
@@ -6,6 +6,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/quic-go/quic-go/internal/ackhandler"
 	"github.com/quic-go/quic-go/internal/flowcontrol"
 	"github.com/quic-go/quic-go/internal/protocol"
 	"github.com/quic-go/quic-go/internal/qerr"
@@ -19,7 +20,6 @@ type receiveStreamI interface {
 	handleStreamFrame(*wire.StreamFrame) error
 	handleResetStreamFrame(*wire.ResetStreamFrame) error
 	closeForShutdown(error)
-	getWindowUpdate() protocol.ByteCount
 }
 
 type receiveStream struct {
@@ -37,6 +37,9 @@ type receiveStream struct {
 	readPosInFrame     int
 	currentFrameIsLast bool // is the currentFrame the last frame on this stream
 
+	queuedStopSending   bool
+	queuedMaxStreamData bool
+
 	// Set once we read the io.EOF or the cancellation error.
 	// Note that for local cancellations, this doesn't necessarily mean that we know the final offset yet.
 	errorRead           bool
@@ -54,8 +57,9 @@ type receiveStream struct {
 }
 
 var (
-	_ ReceiveStream  = &receiveStream{}
-	_ receiveStreamI = &receiveStream{}
+	_ ReceiveStream            = &receiveStream{}
+	_ receiveStreamI           = &receiveStream{}
+	_ streamControlFrameGetter = &receiveStream{}
 )
 
 func newReceiveStream(
@@ -87,13 +91,16 @@ func (s *receiveStream) Read(p []byte) (int, error) {
 	defer func() { <-s.readOnce }()
 
 	s.mutex.Lock()
-	n, err := s.readImpl(p)
+	queuedNewControlFrame, n, err := s.readImpl(p)
 	completed := s.isNewlyCompleted()
 	s.mutex.Unlock()
 
 	if completed {
 		s.sender.onStreamCompleted(s.streamID)
 	}
+	if queuedNewControlFrame {
+		s.sender.onHasStreamControlFrame(s.streamID, s)
+	}
 	return n, err
 }
 
@@ -118,19 +125,20 @@ func (s *receiveStream) isNewlyCompleted() bool {
 	return false
 }
 
-func (s *receiveStream) readImpl(p []byte) (int, error) {
+func (s *receiveStream) readImpl(p []byte) (bool, int, error) {
 	if s.currentFrameIsLast && s.currentFrame == nil {
 		s.errorRead = true
-		return 0, io.EOF
+		return false, 0, io.EOF
 	}
 	if s.cancelledRemotely || s.cancelledLocally {
 		s.errorRead = true
-		return 0, s.cancelErr
+		return false, 0, s.cancelErr
 	}
 	if s.closeForShutdownErr != nil {
-		return 0, s.closeForShutdownErr
+		return false, 0, s.closeForShutdownErr
 	}
 
+	var queuedNewControlFrame bool
 	var bytesRead int
 	var deadlineTimer *utils.Timer
 	for bytesRead < len(p) {
@@ -138,23 +146,23 @@ func (s *receiveStream) readImpl(p []byte) (int, error) {
 			s.dequeueNextFrame()
 		}
 		if s.currentFrame == nil && bytesRead > 0 {
-			return bytesRead, s.closeForShutdownErr
+			return queuedNewControlFrame, bytesRead, s.closeForShutdownErr
 		}
 
 		for {
 			// Stop waiting on errors
 			if s.closeForShutdownErr != nil {
-				return bytesRead, s.closeForShutdownErr
+				return queuedNewControlFrame, bytesRead, s.closeForShutdownErr
 			}
 			if s.cancelledRemotely || s.cancelledLocally {
 				s.errorRead = true
-				return 0, s.cancelErr
+				return queuedNewControlFrame, 0, s.cancelErr
 			}
 
 			deadline := s.deadline
 			if !deadline.IsZero() {
 				if !time.Now().Before(deadline) {
-					return bytesRead, errDeadline
+					return queuedNewControlFrame, bytesRead, errDeadline
 				}
 				if deadlineTimer == nil {
 					deadlineTimer = utils.NewTimer()
@@ -184,10 +192,10 @@ func (s *receiveStream) readImpl(p []byte) (int, error) {
 		}
 
 		if bytesRead > len(p) {
-			return bytesRead, fmt.Errorf("BUG: bytesRead (%d) > len(p) (%d) in stream.Read", bytesRead, len(p))
+			return queuedNewControlFrame, bytesRead, fmt.Errorf("BUG: bytesRead (%d) > len(p) (%d) in stream.Read", bytesRead, len(p))
 		}
 		if s.readPosInFrame > len(s.currentFrame) {
-			return bytesRead, fmt.Errorf("BUG: readPosInFrame (%d) > frame.DataLen (%d) in stream.Read", s.readPosInFrame, len(s.currentFrame))
+			return queuedNewControlFrame, bytesRead, fmt.Errorf("BUG: readPosInFrame (%d) > frame.DataLen (%d) in stream.Read", s.readPosInFrame, len(s.currentFrame))
 		}
 
 		m := copy(p[bytesRead:], s.currentFrame[s.readPosInFrame:])
@@ -197,8 +205,9 @@ func (s *receiveStream) readImpl(p []byte) (int, error) {
 		// when a RESET_STREAM was received, the flow controller was already
 		// informed about the final byteOffset for this stream
 		if !s.cancelledRemotely {
-			if queueWindowUpdate := s.flowController.AddBytesRead(protocol.ByteCount(m)); queueWindowUpdate {
-				s.sender.onHasStreamWindowUpdate(s.streamID, s)
+			if queueMaxStreamData := s.flowController.AddBytesRead(protocol.ByteCount(m)); queueMaxStreamData {
+				s.queuedMaxStreamData = true
+				queuedNewControlFrame = true
 			}
 		}
 
@@ -208,10 +217,10 @@ func (s *receiveStream) readImpl(p []byte) (int, error) {
 				s.currentFrameDone()
 			}
 			s.errorRead = true
-			return bytesRead, io.EOF
+			return queuedNewControlFrame, bytesRead, io.EOF
 		}
 	}
-	return bytesRead, nil
+	return queuedNewControlFrame, bytesRead, nil
 }
 
 func (s *receiveStream) dequeueNextFrame() {
@@ -227,30 +236,31 @@ func (s *receiveStream) dequeueNextFrame() {
 
 func (s *receiveStream) CancelRead(errorCode StreamErrorCode) {
 	s.mutex.Lock()
-	s.cancelReadImpl(errorCode)
+	queuedNewControlFrame := s.cancelReadImpl(errorCode)
 	completed := s.isNewlyCompleted()
 	s.mutex.Unlock()
 
+	if queuedNewControlFrame {
+		s.sender.onHasStreamControlFrame(s.streamID, s)
+	}
 	if completed {
 		s.flowController.Abandon()
 		s.sender.onStreamCompleted(s.streamID)
 	}
 }
 
-func (s *receiveStream) cancelReadImpl(errorCode qerr.StreamErrorCode) {
+func (s *receiveStream) cancelReadImpl(errorCode qerr.StreamErrorCode) (queuedNewControlFrame bool) {
 	if s.cancelledLocally { // duplicate call to CancelRead
-		return
+		return false
 	}
 	s.cancelledLocally = true
 	if s.errorRead || s.cancelledRemotely {
-		return
+		return false
 	}
+	s.queuedStopSending = true
 	s.cancelErr = &StreamError{StreamID: s.streamID, ErrorCode: errorCode, Remote: false}
 	s.signalRead()
-	s.sender.queueControlFrame(&wire.StopSendingFrame{
-		StreamID:  s.streamID,
-		ErrorCode: errorCode,
-	})
+	return true
 }
 
 func (s *receiveStream) handleStreamFrame(frame *wire.StreamFrame) error {
@@ -320,6 +330,26 @@ func (s *receiveStream) handleResetStreamFrameImpl(frame *wire.ResetStreamFrame)
 	return nil
 }
 
+func (s *receiveStream) getControlFrame() (_ ackhandler.Frame, ok, hasMore bool) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+
+	if !s.queuedStopSending && !s.queuedMaxStreamData {
+		return ackhandler.Frame{}, false, false
+	}
+	if s.queuedStopSending {
+		s.queuedStopSending = false
+		return ackhandler.Frame{
+			Frame: &wire.StopSendingFrame{StreamID: s.streamID, ErrorCode: s.cancelErr.ErrorCode},
+		}, true, s.queuedMaxStreamData
+	}
+
+	s.queuedMaxStreamData = false
+	return ackhandler.Frame{
+		Frame: &wire.MaxStreamDataFrame{StreamID: s.streamID, MaximumStreamData: s.flowController.GetWindowUpdate()},
+	}, true, false
+}
+
 func (s *receiveStream) SetReadDeadline(t time.Time) error {
 	s.mutex.Lock()
 	s.deadline = t
@@ -338,10 +368,6 @@ func (s *receiveStream) closeForShutdown(err error) {
 	s.signalRead()
 }
 
-func (s *receiveStream) getWindowUpdate() protocol.ByteCount {
-	return s.flowController.GetWindowUpdate()
-}
-
 // signalRead performs a non-blocking send on the readChan
 func (s *receiveStream) signalRead() {
 	select {
diff --git a/send_stream.go b/send_stream.go
index c89424e4..a7bb2cd6 100644
--- a/send_stream.go
+++ b/send_stream.go
@@ -37,9 +37,12 @@ type sendStream struct {
 
 	writeOffset protocol.ByteCount
 
-	cancelWriteErr      error
+	cancelWriteErr      *StreamError
 	closeForShutdownErr error
 
+	queuedResetStreamFrame bool
+	queuedBlockedFrame     bool
+
 	finishedWriting bool // set once Close() is called
 	finSent         bool // set when a STREAM_FRAME with FIN bit has been sent
 	// Set when the application knows about the cancellation.
@@ -59,8 +62,9 @@ type sendStream struct {
 }
 
 var (
-	_ SendStream  = &sendStream{}
-	_ sendStreamI = &sendStream{}
+	_ SendStream               = &sendStream{}
+	_ sendStreamI              = &sendStream{}
+	_ streamControlFrameGetter = &sendStream{}
 )
 
 func newSendStream(
@@ -215,12 +219,15 @@ func (s *sendStream) canBufferStreamFrame() bool {
 // maxBytes is the maximum length this frame (including frame header) will have.
 func (s *sendStream) popStreamFrame(maxBytes protocol.ByteCount, v protocol.Version) (af ackhandler.StreamFrame, ok, hasMore bool) {
 	s.mutex.Lock()
-	f, hasMoreData := s.popNewOrRetransmittedStreamFrame(maxBytes, v)
+	f, hasMoreData, queuedControlFrame := s.popNewOrRetransmittedStreamFrame(maxBytes, v)
 	if f != nil {
 		s.numOutstandingFrames++
 	}
 	s.mutex.Unlock()
 
+	if queuedControlFrame {
+		s.sender.onHasStreamControlFrame(s.streamID, s)
+	}
 	if f == nil {
 		return ackhandler.StreamFrame{}, false, hasMoreData
 	}
@@ -230,20 +237,20 @@ func (s *sendStream) popStreamFrame(maxBytes protocol.ByteCount, v protocol.Vers
 	}, true, hasMoreData
 }
 
-func (s *sendStream) popNewOrRetransmittedStreamFrame(maxBytes protocol.ByteCount, v protocol.Version) (*wire.StreamFrame, bool /* has more data to send */) {
+func (s *sendStream) popNewOrRetransmittedStreamFrame(maxBytes protocol.ByteCount, v protocol.Version) (_ *wire.StreamFrame, hasMoreData, queuedControlFrame bool) {
 	if s.cancelWriteErr != nil || s.closeForShutdownErr != nil {
-		return nil, false
+		return nil, false, false
 	}
 
 	if len(s.retransmissionQueue) > 0 {
 		f, hasMoreRetransmissions := s.maybeGetRetransmission(maxBytes, v)
 		if f != nil || hasMoreRetransmissions {
 			if f == nil {
-				return nil, true
+				return nil, true, false
 			}
 			// We always claim that we have more data to send.
 			// This might be incorrect, in which case there'll be a spurious call to popStreamFrame in the future.
-			return f, true
+			return f, true, false
 		}
 	}
 
@@ -255,21 +262,18 @@ func (s *sendStream) popNewOrRetransmittedStreamFrame(maxBytes protocol.ByteCoun
 				Offset:         s.writeOffset,
 				DataLenPresent: true,
 				Fin:            true,
-			}, false
+			}, false, false
 		}
-		return nil, false
+		return nil, false, false
 	}
 
 	sendWindow := s.flowController.SendWindowSize()
 	if sendWindow == 0 {
 		if s.flowController.IsNewlyBlocked() {
-			s.sender.queueControlFrame(&wire.StreamDataBlockedFrame{
-				StreamID:          s.streamID,
-				MaximumStreamData: s.writeOffset,
-			})
-			return nil, false
+			s.queuedBlockedFrame = true
+			return nil, false, true
 		}
-		return nil, true
+		return nil, true, false
 	}
 
 	f, hasMoreData := s.popNewStreamFrame(maxBytes, sendWindow, v)
@@ -281,7 +285,7 @@ func (s *sendStream) popNewOrRetransmittedStreamFrame(maxBytes protocol.ByteCoun
 	if f.Fin {
 		s.finSent = true
 	}
-	return f, hasMoreData
+	return f, hasMoreData, false
 }
 
 func (s *sendStream) popNewStreamFrame(maxBytes, sendWindow protocol.ByteCount, v protocol.Version) (*wire.StreamFrame, bool) {
@@ -442,14 +446,11 @@ func (s *sendStream) cancelWriteImpl(errorCode qerr.StreamErrorCode, remote bool
 	s.numOutstandingFrames = 0
 	s.retransmissionQueue = nil
 	newlyCompleted := s.isNewlyCompleted()
+	s.queuedResetStreamFrame = true
 	s.mutex.Unlock()
 
 	s.signalWrite()
-	s.sender.queueControlFrame(&wire.ResetStreamFrame{
-		StreamID:  s.streamID,
-		FinalSize: s.writeOffset,
-		ErrorCode: errorCode,
-	})
+	s.sender.onHasStreamControlFrame(s.streamID, s)
 	if newlyCompleted {
 		s.sender.onStreamCompleted(s.streamID)
 	}
@@ -472,6 +473,26 @@ func (s *sendStream) handleStopSendingFrame(frame *wire.StopSendingFrame) {
 	s.cancelWriteImpl(frame.ErrorCode, true)
 }
 
+func (s *sendStream) getControlFrame() (_ ackhandler.Frame, ok, hasMore bool) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+
+	if !s.queuedBlockedFrame && !s.queuedResetStreamFrame {
+		return ackhandler.Frame{}, false, false
+	}
+	if s.queuedBlockedFrame {
+		s.queuedBlockedFrame = false
+		return ackhandler.Frame{
+			Frame: &wire.StreamDataBlockedFrame{StreamID: s.streamID, MaximumStreamData: s.writeOffset},
+		}, true, s.queuedResetStreamFrame
+	}
+	// RESET_STREAM frame
+	s.queuedResetStreamFrame = false
+	return ackhandler.Frame{
+		Frame: &wire.ResetStreamFrame{StreamID: s.streamID, FinalSize: s.writeOffset, ErrorCode: s.cancelWriteErr.ErrorCode},
+	}, true, false
+}
+
 func (s *sendStream) Context() context.Context {
 	return s.ctx
 }
diff --git a/stream.go b/stream.go
index 673956ed..1ed26323 100644
--- a/stream.go
+++ b/stream.go
@@ -24,9 +24,8 @@ var errDeadline net.Error = &deadlineError{}
 
 // The streamSender is notified by the stream about various events.
 type streamSender interface {
-	queueControlFrame(wire.Frame)
 	onHasStreamData(protocol.StreamID, sendStreamI)
-	onHasStreamWindowUpdate(protocol.StreamID, receiveStreamI)
+	onHasStreamControlFrame(protocol.StreamID, streamControlFrameGetter)
 	// must be called without holding the mutex that is acquired by closeForShutdown
 	onStreamCompleted(protocol.StreamID)
 }
@@ -35,14 +34,17 @@ type streamSender interface {
 // This is necessary in order to keep track when both halves have been completed.
 type uniStreamSender struct {
 	streamSender
-	onStreamCompletedImpl func()
+	onStreamCompletedImpl       func()
+	onHasStreamControlFrameImpl func(protocol.StreamID, streamControlFrameGetter)
 }
 
-func (s *uniStreamSender) queueControlFrame(f wire.Frame) { s.streamSender.queueControlFrame(f) }
 func (s *uniStreamSender) onHasStreamData(id protocol.StreamID, str sendStreamI) {
 	s.streamSender.onHasStreamData(id, str)
 }
 func (s *uniStreamSender) onStreamCompleted(protocol.StreamID) { s.onStreamCompletedImpl() }
+func (s *uniStreamSender) onHasStreamControlFrame(id protocol.StreamID, str streamControlFrameGetter) {
+	s.onHasStreamControlFrameImpl(id, str)
+}
 
 var _ streamSender = &uniStreamSender{}
 
@@ -52,7 +54,6 @@ type streamI interface {
 	// for receiving
 	handleStreamFrame(*wire.StreamFrame) error
 	handleResetStreamFrame(*wire.ResetStreamFrame) error
-	getWindowUpdate() protocol.ByteCount
 	// for sending
 	hasData() bool
 	handleStopSendingFrame(*wire.StopSendingFrame)
@@ -78,7 +79,10 @@ type stream struct {
 	sendStreamCompleted    bool
 }
 
-var _ Stream = &stream{}
+var (
+	_ Stream                   = &stream{}
+	_ streamControlFrameGetter = &receiveStream{}
+)
 
 // newStream creates a new Stream
 func newStream(
@@ -96,6 +100,9 @@ func newStream(
 			s.checkIfCompleted()
 			s.completedMutex.Unlock()
 		},
+		onHasStreamControlFrameImpl: func(id protocol.StreamID, str streamControlFrameGetter) {
+			sender.onHasStreamControlFrame(streamID, s)
+		},
 	}
 	s.sendStream = *newSendStream(ctx, streamID, senderForSendStream, flowController)
 	senderForReceiveStream := &uniStreamSender{
@@ -106,6 +113,9 @@ func newStream(
 			s.checkIfCompleted()
 			s.completedMutex.Unlock()
 		},
+		onHasStreamControlFrameImpl: func(id protocol.StreamID, str streamControlFrameGetter) {
+			sender.onHasStreamControlFrame(streamID, s)
+		},
 	}
 	s.receiveStream = *newReceiveStream(streamID, senderForReceiveStream, flowController)
 	return s
@@ -121,6 +131,14 @@ func (s *stream) Close() error {
 	return s.sendStream.Close()
 }
 
+func (s *stream) getControlFrame() (_ ackhandler.Frame, ok, hasMore bool) {
+	f, ok, _ := s.sendStream.getControlFrame()
+	if ok {
+		return f, true, true
+	}
+	return s.receiveStream.getControlFrame()
+}
+
 func (s *stream) SetDeadline(t time.Time) error {
 	_ = s.SetReadDeadline(t)  // SetReadDeadline never errors
 	_ = s.SetWriteDeadline(t) // SetWriteDeadline never errors
diff --git a/streams_map.go b/streams_map.go
index 041636c3..0ce91287 100644
--- a/streams_map.go
+++ b/streams_map.go
@@ -62,6 +62,7 @@ type streamsMap struct {
 	maxIncomingUniStreams  uint64
 
 	sender            streamSender
+	queueControlFrame func(wire.Frame)
 	newFlowController func(protocol.StreamID) flowcontrol.StreamFlowController
 
 	mutex               sync.Mutex
@@ -77,14 +78,16 @@ var _ streamManager = &streamsMap{}
 func newStreamsMap(
 	ctx context.Context,
 	sender streamSender,
+	queueControlFrame func(wire.Frame),
 	newFlowController func(protocol.StreamID) flowcontrol.StreamFlowController,
 	maxIncomingBidiStreams uint64,
 	maxIncomingUniStreams uint64,
 	perspective protocol.Perspective,
-) streamManager {
+) *streamsMap {
 	m := &streamsMap{
 		ctx:                    ctx,
 		perspective:            perspective,
+		queueControlFrame:      queueControlFrame,
 		newFlowController:      newFlowController,
 		maxIncomingBidiStreams: maxIncomingBidiStreams,
 		maxIncomingUniStreams:  maxIncomingUniStreams,
@@ -101,7 +104,7 @@ func (m *streamsMap) initMaps() {
 			id := num.StreamID(protocol.StreamTypeBidi, m.perspective)
 			return newStream(m.ctx, id, m.sender, m.newFlowController(id))
 		},
-		m.sender.queueControlFrame,
+		m.queueControlFrame,
 	)
 	m.incomingBidiStreams = newIncomingStreamsMap(
 		protocol.StreamTypeBidi,
@@ -110,7 +113,7 @@ func (m *streamsMap) initMaps() {
 			return newStream(m.ctx, id, m.sender, m.newFlowController(id))
 		},
 		m.maxIncomingBidiStreams,
-		m.sender.queueControlFrame,
+		m.queueControlFrame,
 	)
 	m.outgoingUniStreams = newOutgoingStreamsMap(
 		protocol.StreamTypeUni,
@@ -118,7 +121,7 @@ func (m *streamsMap) initMaps() {
 			id := num.StreamID(protocol.StreamTypeUni, m.perspective)
 			return newSendStream(m.ctx, id, m.sender, m.newFlowController(id))
 		},
-		m.sender.queueControlFrame,
+		m.queueControlFrame,
 	)
 	m.incomingUniStreams = newIncomingStreamsMap(
 		protocol.StreamTypeUni,
@@ -127,7 +130,7 @@ func (m *streamsMap) initMaps() {
 			return newReceiveStream(id, m.sender, m.newFlowController(id))
 		},
 		m.maxIncomingUniStreams,
-		m.sender.queueControlFrame,
+		m.queueControlFrame,
 	)
 }
 
diff --git a/window_update_queue.go b/window_update_queue.go
deleted file mode 100644
index 4ae79f90..00000000
--- a/window_update_queue.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package quic
-
-import (
-	"sync"
-
-	"github.com/quic-go/quic-go/internal/flowcontrol"
-	"github.com/quic-go/quic-go/internal/protocol"
-	"github.com/quic-go/quic-go/internal/wire"
-)
-
-type windowUpdateQueue struct {
-	mutex sync.Mutex
-
-	queue map[protocol.StreamID]receiveStreamI
-
-	connFlowController flowcontrol.ConnectionFlowController
-	callback           func(wire.Frame)
-}
-
-func newWindowUpdateQueue(
-	connFC flowcontrol.ConnectionFlowController,
-	cb func(wire.Frame),
-) *windowUpdateQueue {
-	return &windowUpdateQueue{
-		queue:              make(map[protocol.StreamID]receiveStreamI),
-		connFlowController: connFC,
-		callback:           cb,
-	}
-}
-
-func (q *windowUpdateQueue) AddStream(id protocol.StreamID, str receiveStreamI) {
-	q.mutex.Lock()
-	q.queue[id] = str
-	q.mutex.Unlock()
-}
-
-func (q *windowUpdateQueue) RemoveStream(id protocol.StreamID) {
-	q.mutex.Lock()
-	delete(q.queue, id)
-	q.mutex.Unlock()
-}
-
-func (q *windowUpdateQueue) QueueAll() {
-	q.mutex.Lock()
-	// queue a connection-level window update
-	if offset := q.connFlowController.GetWindowUpdate(); offset > 0 {
-		q.callback(&wire.MaxDataFrame{MaximumData: offset})
-	}
-	// queue all stream-level window updates
-	for id, str := range q.queue {
-		delete(q.queue, id)
-		offset := str.getWindowUpdate()
-		if offset == 0 { // can happen if we received a final offset, right after queueing the window update
-			continue
-		}
-		q.callback(&wire.MaxStreamDataFrame{
-			StreamID:          id,
-			MaximumStreamData: offset,
-		})
-	}
-	q.mutex.Unlock()
-}


Test patch:
diff --git a/connection_test.go b/connection_test.go
index 80ed555c..561b9a8f 100644
--- a/connection_test.go
+++ b/connection_test.go
@@ -1284,6 +1284,7 @@ var _ = Describe("Connection", func() {
 			sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
 			fc := mocks.NewMockConnectionFlowController(mockCtrl)
 			fc.EXPECT().IsNewlyBlocked().Return(true, protocol.ByteCount(1337))
+			fc.EXPECT().GetWindowUpdate()
 			expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 13}, []byte("foobar"))
 			packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), conn.version).Return(shortHeaderPacket{}, errNothingToPack).AnyTimes()
 			conn.connFlowController = fc
@@ -2764,7 +2765,7 @@ var _ = Describe("Client Connection", func() {
 				errChan <- conn.run()
 			}()
 			connRunner.EXPECT().Remove(srcConnID)
-			tracer.EXPECT().ReceivedVersionNegotiationPacket(gomock.Any(), gomock.Any(), gomock.Any()).Do(func(_, _ protocol.ArbitraryLenConnectionID, versions []logging.VersionNumber) {
+			tracer.EXPECT().ReceivedVersionNegotiationPacket(gomock.Any(), gomock.Any(), gomock.Any()).Do(func(_, _ protocol.ArbitraryLenConnectionID, versions []logging.Version) {
 				Expect(versions).To(And(
 					ContainElement(protocol.Version(4321)),
 					ContainElement(protocol.Version(1337)),
@@ -2797,7 +2798,7 @@ var _ = Describe("Client Connection", func() {
 				tracer.EXPECT().ClosedConnection(gomock.Any()).Do(func(e error) {
 					var vnErr *VersionNegotiationError
 					Expect(errors.As(e, &vnErr)).To(BeTrue())
-					Expect(vnErr.Theirs).To(ContainElement(logging.VersionNumber(12345678)))
+					Expect(vnErr.Theirs).To(ContainElement(logging.Version(12345678)))
 				}),
 				tracer.EXPECT().Close(),
 			)
diff --git a/crypto_stream_manager_test.go b/crypto_stream_manager_test.go
index 5d32d016..1f09c13d 100644
--- a/crypto_stream_manager_test.go
+++ b/crypto_stream_manager_test.go
@@ -12,49 +12,39 @@ var _ = Describe("Crypto Stream Manager", func() {
 	var (
 		csm *cryptoStreamManager
 
-		initialStream   *MockCryptoStream
-		handshakeStream *MockCryptoStream
-		oneRTTStream    *MockCryptoStream
+		initialStream   *cryptoStream
+		handshakeStream *cryptoStream
+		oneRTTStream    *cryptoStream
 	)
 
 	BeforeEach(func() {
-		initialStream = NewMockCryptoStream(mockCtrl)
-		handshakeStream = NewMockCryptoStream(mockCtrl)
-		oneRTTStream = NewMockCryptoStream(mockCtrl)
+		initialStream = newCryptoStream()
+		handshakeStream = newCryptoStream()
+		oneRTTStream = newCryptoStream()
 		csm = newCryptoStreamManager(initialStream, handshakeStream, oneRTTStream)
 	})
 
 	It("passes messages to the initial stream", func() {
 		cf := &wire.CryptoFrame{Data: []byte("foobar")}
-		initialStream.EXPECT().HandleCryptoFrame(cf)
-		initialStream.EXPECT().GetCryptoData().Return([]byte("foobar"))
 		Expect(csm.HandleCryptoFrame(cf, protocol.EncryptionInitial)).To(Succeed())
 		Expect(csm.GetCryptoData(protocol.EncryptionInitial)).To(Equal([]byte("foobar")))
 	})
 
 	It("passes messages to the handshake stream", func() {
 		cf := &wire.CryptoFrame{Data: []byte("foobar")}
-		handshakeStream.EXPECT().HandleCryptoFrame(cf)
-		handshakeStream.EXPECT().GetCryptoData().Return([]byte("foobar"))
 		Expect(csm.HandleCryptoFrame(cf, protocol.EncryptionHandshake)).To(Succeed())
 		Expect(csm.GetCryptoData(protocol.EncryptionHandshake)).To(Equal([]byte("foobar")))
 	})
 
 	It("passes messages to the 1-RTT stream", func() {
 		cf := &wire.CryptoFrame{Data: []byte("foobar")}
-		oneRTTStream.EXPECT().HandleCryptoFrame(cf)
-		oneRTTStream.EXPECT().GetCryptoData().Return([]byte("foobar"))
 		Expect(csm.HandleCryptoFrame(cf, protocol.Encryption1RTT)).To(Succeed())
 		Expect(csm.GetCryptoData(protocol.Encryption1RTT)).To(Equal([]byte("foobar")))
 	})
 
 	It("processes all messages", func() {
-		cf := &wire.CryptoFrame{Data: []byte("foobar")}
-		handshakeStream.EXPECT().HandleCryptoFrame(cf)
-		handshakeStream.EXPECT().GetCryptoData().Return([]byte("foo"))
-		handshakeStream.EXPECT().GetCryptoData().Return([]byte("bar"))
-		handshakeStream.EXPECT().GetCryptoData()
-		Expect(csm.HandleCryptoFrame(cf, protocol.EncryptionHandshake)).To(Succeed())
+		Expect(csm.HandleCryptoFrame(&wire.CryptoFrame{Data: []byte("bar"), Offset: 3}, protocol.EncryptionHandshake)).To(Succeed())
+		Expect(csm.HandleCryptoFrame(&wire.CryptoFrame{Data: []byte("foo")}, protocol.EncryptionHandshake)).To(Succeed())
 		var data []byte
 		for {
 			b := csm.GetCryptoData(protocol.EncryptionHandshake)
@@ -73,12 +63,16 @@ var _ = Describe("Crypto Stream Manager", func() {
 	})
 
 	It("drops Initial", func() {
-		initialStream.EXPECT().Finish()
-		Expect(csm.Drop(protocol.EncryptionInitial)).To(Succeed())
+		Expect(initialStream.HandleCryptoFrame(&wire.CryptoFrame{Data: []byte("foo")})).To(Succeed())
+		err := csm.Drop(protocol.EncryptionInitial)
+		Expect(err).To(HaveOccurred())
+		Expect(err.Error()).To(ContainSubstring("encryption level changed, but crypto stream has more data to read"))
 	})
 
 	It("drops Handshake", func() {
-		handshakeStream.EXPECT().Finish()
-		Expect(csm.Drop(protocol.EncryptionHandshake)).To(Succeed())
+		Expect(handshakeStream.HandleCryptoFrame(&wire.CryptoFrame{Data: []byte("foo")})).To(Succeed())
+		err := csm.Drop(protocol.EncryptionHandshake)
+		Expect(err).To(HaveOccurred())
+		Expect(err.Error()).To(ContainSubstring("encryption level changed, but crypto stream has more data to read"))
 	})
 })
diff --git a/crypto_stream_test.go b/crypto_stream_test.go
index e5ea9186..7a5eb9df 100644
--- a/crypto_stream_test.go
+++ b/crypto_stream_test.go
@@ -12,11 +12,9 @@ import (
 )
 
 var _ = Describe("Crypto Stream", func() {
-	var str cryptoStream
+	var str *cryptoStream
 
-	BeforeEach(func() {
-		str = newCryptoStream()
-	})
+	BeforeEach(func() { str = newCryptoStream() })
 
 	Context("handling incoming data", func() {
 		It("handles in-order CRYPTO frames", func() {
diff --git a/framer_test.go b/framer_test.go
index 7ef62f5e..a8cf8328 100644
--- a/framer_test.go
+++ b/framer_test.go
@@ -36,16 +36,16 @@ var _ = Describe("Framer", func() {
 
 	Context("handling control frames", func() {
 		It("adds control frames", func() {
-			mdf := &wire.MaxDataFrame{MaximumData: 0x42}
+			pc := &wire.PathChallengeFrame{Data: [8]byte{1, 2, 3, 4, 6, 7, 8}}
 			msf := &wire.MaxStreamsFrame{MaxStreamNum: 0x1337}
-			framer.QueueControlFrame(mdf)
+			framer.QueueControlFrame(pc)
 			framer.QueueControlFrame(msf)
 			frames, length := framer.AppendControlFrames(nil, 1000, protocol.Version1)
 			Expect(frames).To(HaveLen(2))
 			fs := []wire.Frame{frames[0].Frame, frames[1].Frame}
-			Expect(fs).To(ContainElement(mdf))
+			Expect(fs).To(ContainElement(pc))
 			Expect(fs).To(ContainElement(msf))
-			Expect(length).To(Equal(mdf.Length(version) + msf.Length(version)))
+			Expect(length).To(Equal(pc.Length(version) + msf.Length(version)))
 		})
 
 		It("says if it has data", func() {
@@ -60,13 +60,43 @@ var _ = Describe("Framer", func() {
 
 		It("appends to the slice given", func() {
 			ping := &wire.PingFrame{}
-			mdf := &wire.MaxDataFrame{MaximumData: 0x42}
-			framer.QueueControlFrame(mdf)
+			pc := &wire.PathChallengeFrame{Data: [8]byte{1, 2, 3, 4, 6, 7, 8}}
+			framer.QueueControlFrame(pc)
 			frames, length := framer.AppendControlFrames([]ackhandler.Frame{{Frame: ping}}, 1000, protocol.Version1)
 			Expect(frames).To(HaveLen(2))
 			Expect(frames[0].Frame).To(Equal(ping))
-			Expect(frames[1].Frame).To(Equal(mdf))
-			Expect(length).To(Equal(mdf.Length(version)))
+			Expect(frames[1].Frame).To(Equal(pc))
+			Expect(length).To(Equal(pc.Length(version)))
+		})
+
+		It("adds stream-related control frames", func() {
+			ping := &wire.PingFrame{}
+			framer.QueueControlFrame(ping)
+			str := NewMockStreamControlFrameGetter(mockCtrl)
+			framer.AddStreamWithControlFrames(10, str)
+			mdf1 := &wire.MaxStreamDataFrame{MaximumStreamData: 1337}
+			mdf2 := &wire.MaxStreamDataFrame{MaximumStreamData: 1338}
+			str.EXPECT().getControlFrame().Return(ackhandler.Frame{Frame: mdf1}, true, true)
+			str.EXPECT().getControlFrame().Return(ackhandler.Frame{Frame: mdf2}, true, false)
+			frames, l := framer.AppendControlFrames(nil, protocol.MaxByteCount, protocol.Version1)
+			Expect(frames).To(HaveLen(3))
+			Expect(frames[0].Frame).To(Equal(mdf1))
+			Expect(frames[1].Frame).To(Equal(mdf2))
+			Expect(frames[2].Frame).To(Equal(ping))
+			Expect(l).To(Equal(ping.Length(protocol.Version1) + mdf1.Length(protocol.Version1) + mdf2.Length(protocol.Version1)))
+		})
+
+		It("doesn't enqueue more stream-related control frames if there are less than 25 bytes left", func() {
+			str := NewMockStreamControlFrameGetter(mockCtrl)
+			framer.AddStreamWithControlFrames(10, str)
+			mdf1 := &wire.MaxStreamDataFrame{MaximumStreamData: 1337}
+			str.EXPECT().getControlFrame().Return(ackhandler.Frame{Frame: mdf1}, true, true).AnyTimes()
+			frames, l := framer.AppendControlFrames(nil, 100, protocol.Version1)
+			Expect(l).To(Equal(protocol.ByteCount(len(frames)) * mdf1.Length(protocol.Version1)))
+			Expect(l).To(And(
+				BeNumerically(">", 100-maxStreamControlFrameSize),
+				BeNumerically("<=", 100),
+			))
 		})
 
 		It("adds the right number of frames", func() {
@@ -102,7 +132,7 @@ var _ = Describe("Framer", func() {
 			for _, f := range frames {
 				framer.QueueControlFrame(f)
 			}
-			Expect(framer.Handle0RTTRejection()).To(Succeed())
+			framer.Handle0RTTRejection()
 			fs, length := framer.AppendControlFrames(nil, protocol.MaxByteCount, protocol.Version1)
 			Expect(fs).To(HaveLen(2))
 			Expect(length).To(Equal(ping.Length(version) + ncid.Length(version)))
@@ -211,6 +241,8 @@ var _ = Describe("Framer", func() {
 			Expect(frames).To(HaveLen(1))
 			Expect(frames[0].Frame).To(Equal(f2))
 			Expect(framer.HasData()).To(BeFalse())
+			framer.AddStreamWithControlFrames(id1, nil)
+			Expect(framer.HasData()).To(BeTrue())
 		})
 
 		It("appends to a frame slice", func() {
@@ -424,7 +456,7 @@ var _ = Describe("Framer", func() {
 
 		It("drops all STREAM frames when 0-RTT is rejected", func() {
 			framer.AddActiveStream(id1, stream1)
-			Expect(framer.Handle0RTTRejection()).To(Succeed())
+			framer.Handle0RTTRejection()
 			fs, length := framer.AppendStreamFrames(nil, protocol.MaxByteCount, protocol.Version1)
 			Expect(fs).To(BeEmpty())
 			Expect(length).To(BeZero())
diff --git a/http3/conn_test.go b/http3/conn_test.go
index 4be8b7ae..1b1ff257 100644
--- a/http3/conn_test.go
+++ b/http3/conn_test.go
@@ -29,6 +29,7 @@ var _ = Describe("Connection", func() {
 				false,
 				protocol.PerspectiveServer,
 				nil,
+				0,
 			)
 			b := quicvarint.Append(nil, streamTypeControlStream)
 			b = (&settingsFrame{
@@ -62,6 +63,7 @@ var _ = Describe("Connection", func() {
 				false,
 				protocol.PerspectiveServer,
 				nil,
+				0,
 			)
 			b := quicvarint.Append(nil, streamTypeControlStream)
 			b = (&settingsFrame{}).Append(b)
@@ -104,6 +106,7 @@ var _ = Describe("Connection", func() {
 					false,
 					protocol.PerspectiveClient,
 					nil,
+					0,
 				)
 				buf := bytes.NewBuffer(quicvarint.Append(nil, streamType))
 				str := mockquic.NewMockStream(mockCtrl)
@@ -133,6 +136,7 @@ var _ = Describe("Connection", func() {
 					false,
 					protocol.PerspectiveClient,
 					nil,
+					0,
 				)
 				buf := bytes.NewBuffer(quicvarint.Append(nil, streamType))
 				str1 := mockquic.NewMockStream(mockCtrl)
@@ -169,6 +173,7 @@ var _ = Describe("Connection", func() {
 				false,
 				protocol.PerspectiveServer,
 				nil,
+				0,
 			)
 			buf := bytes.NewBuffer(quicvarint.Append(nil, 0x1337))
 			str := mockquic.NewMockStream(mockCtrl)
@@ -195,6 +200,7 @@ var _ = Describe("Connection", func() {
 				false,
 				protocol.PerspectiveServer,
 				nil,
+				0,
 			)
 			b := quicvarint.Append(nil, streamTypeControlStream)
 			b = (&dataFrame{}).Append(b)
@@ -226,6 +232,7 @@ var _ = Describe("Connection", func() {
 				false,
 				protocol.PerspectiveServer,
 				nil,
+				0,
 			)
 			b := quicvarint.Append(nil, streamTypeControlStream)
 			b = (&settingsFrame{}).Append(b)
@@ -264,6 +271,7 @@ var _ = Describe("Connection", func() {
 					false,
 					pers.Opposite(),
 					nil,
+					0,
 				)
 				buf := bytes.NewBuffer(quicvarint.Append(nil, streamTypePushStream))
 				controlStr := mockquic.NewMockStream(mockCtrl)
@@ -294,6 +302,7 @@ var _ = Describe("Connection", func() {
 				true,
 				protocol.PerspectiveClient,
 				nil,
+				0,
 			)
 			b := quicvarint.Append(nil, streamTypeControlStream)
 			b = (&settingsFrame{Datagram: true}).Append(b)
@@ -333,6 +342,7 @@ var _ = Describe("Connection", func() {
 				true,
 				protocol.PerspectiveClient,
 				nil,
+				0,
 			)
 			b := quicvarint.Append(nil, streamTypeControlStream)
 			b = (&settingsFrame{Datagram: true}).Append(b)
diff --git a/http3/http_stream_test.go b/http3/http_stream_test.go
index a4c889e2..59f68279 100644
--- a/http3/http_stream_test.go
+++ b/http3/http_stream_test.go
@@ -43,7 +43,7 @@ var _ = Describe("Stream", func() {
 				errorCbCalled = true
 				return nil
 			}).AnyTimes()
-			str = newStream(qstr, newConnection(context.Background(), conn, false, protocol.PerspectiveClient, nil), nil)
+			str = newStream(qstr, newConnection(context.Background(), conn, false, protocol.PerspectiveClient, nil, 0), nil)
 		})
 
 		It("reads DATA frames in a single run", func() {
@@ -171,7 +171,7 @@ var _ = Describe("Request Stream", func() {
 		requestWriter := newRequestWriter()
 		conn := mockquic.NewMockEarlyConnection(mockCtrl)
 		str = newRequestStream(
-			newStream(qstr, newConnection(context.Background(), conn, false, protocol.PerspectiveClient, nil), nil),
+			newStream(qstr, newConnection(context.Background(), conn, false, protocol.PerspectiveClient, nil, 0), nil),
 			requestWriter,
 			make(chan struct{}),
 			qpack.NewDecoder(func(qpack.HeaderField) {}),
diff --git a/http3/server_test.go b/http3/server_test.go
index 7e2138d5..f7a4c9ce 100644
--- a/http3/server_test.go
+++ b/http3/server_test.go
@@ -148,7 +148,7 @@ var _ = Describe("Server", func() {
 			qconn.EXPECT().LocalAddr().AnyTimes()
 			qconn.EXPECT().ConnectionState().Return(quic.ConnectionState{}).AnyTimes()
 			qconn.EXPECT().Context().Return(context.Background()).AnyTimes()
-			conn = newConnection(context.Background(), qconn, false, protocol.PerspectiveServer, nil)
+			conn = newConnection(context.Background(), qconn, false, protocol.PerspectiveServer, nil, 0)
 		})
 
 		It("calls the HTTP handler function", func() {
diff --git a/integrationtests/self/handshake_test.go b/integrationtests/self/handshake_test.go
index 0df8f993..e4a53991 100644
--- a/integrationtests/self/handshake_test.go
+++ b/integrationtests/self/handshake_test.go
@@ -305,6 +305,28 @@ var _ = Describe("Handshake tests", func() {
 		checkContextFromChan(tracerContextChan, false)
 	})
 
+	It("fails the handshake when tls.Config.GetConfigForClient errors", func() {
+		laddr, err := net.ResolveUDPAddr("udp", "localhost:0")
+		Expect(err).ToNot(HaveOccurred())
+		udpConn, err := net.ListenUDP("udp", laddr)
+		Expect(err).ToNot(HaveOccurred())
+		tr := &quic.Transport{Conn: udpConn}
+		addTracer(tr)
+		defer tr.Close()
+		tlsConf := &tls.Config{}
+		tlsConf.GetConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) {
+			return nil, errors.New("nope")
+		}
+		ln, err := tr.Listen(tlsConf, getQuicConfig(nil))
+		Expect(err).ToNot(HaveOccurred())
+		defer ln.Close()
+
+		_, err = quic.DialAddr(context.Background(), ln.Addr().String(), getTLSClientConfig(), getQuicConfig(nil))
+		var transportErr *quic.TransportError
+		Expect(errors.As(err, &transportErr)).To(BeTrue())
+		Expect(transportErr.ErrorCode.IsCryptoError()).To(BeTrue())
+	})
+
 	Context("using different cipher suites", func() {
 		for n, id := range map[string]uint16{
 			"TLS_AES_128_GCM_SHA256":       tls.TLS_AES_128_GCM_SHA256,
@@ -881,10 +903,10 @@ var _ = Describe("Handshake tests", func() {
 			tlsConf,
 			nil,
 		)
-		Expect(err).To(MatchError(&qerr.TransportError{
-			ErrorCode:    qerr.InternalError,
-			ErrorMessage: "tls: invalid NextProtos value",
-		}))
+		var transportErr *quic.TransportError
+		Expect(errors.As(err, &transportErr)).To(BeTrue())
+		Expect(transportErr.ErrorCode.IsCryptoError()).To(BeTrue())
+		Expect(err.Error()).To(ContainSubstring("tls: invalid NextProtos value"))
 		Consistently(packetChan).ShouldNot(Receive())
 		ln.Close()
 		Eventually(done).Should(BeClosed())
diff --git a/integrationtests/self/http_test.go b/integrationtests/self/http_test.go
index e2cf5fb2..cd97896b 100644
--- a/integrationtests/self/http_test.go
+++ b/integrationtests/self/http_test.go
@@ -128,6 +128,18 @@ var _ = Describe("HTTP tests", func() {
 		client = &http.Client{Transport: rt}
 	})
 
+	It("closes the connection after idle timeout", func() {
+		server.IdleTimeout = 100 * time.Millisecond
+		_, err := client.Get(fmt.Sprintf("https://localhost:%d/hello", port))
+		Expect(err).ToNot(HaveOccurred())
+
+		time.Sleep(150 * time.Millisecond)
+
+		_, err = client.Get(fmt.Sprintf("https://localhost:%d/hello", port))
+		Expect(err).ToNot(MatchError("idle timeout"))
+		server.IdleTimeout = 0
+	})
+
 	It("downloads a hello", func() {
 		resp, err := client.Get(fmt.Sprintf("https://localhost:%d/hello", port))
 		Expect(err).ToNot(HaveOccurred())
diff --git a/integrationtests/self/qlog_dir_test.go b/integrationtests/self/qlog_dir_test.go
index b81366e5..2adedcba 100644
--- a/integrationtests/self/qlog_dir_test.go
+++ b/integrationtests/self/qlog_dir_test.go
@@ -78,9 +78,10 @@ var _ = Describe("qlog dir tests", Serial, func() {
 		Expect(len(childs)).To(Equal(2))
 		odcids := make([]string, 0)
 		vantagePoints := make([]string, 0)
-		qlogFileNameRegexp := regexp.MustCompile(`^([0-f]+)_(client|server).qlog$`)
+		qlogFileNameRegexp := regexp.MustCompile(`^([0-f]+)_(client|server).sqlog$`)
 		for _, child := range childs {
 			matches := qlogFileNameRegexp.FindStringSubmatch(child.Name())
+			Expect(matches).To(HaveLen(3))
 			odcids = append(odcids, matches[1])
 			vantagePoints = append(vantagePoints, matches[2])
 		}
diff --git a/integrationtests/versionnegotiation/handshake_test.go b/integrationtests/versionnegotiation/handshake_test.go
index 6385dacb..13e6ebeb 100644
--- a/integrationtests/versionnegotiation/handshake_test.go
+++ b/integrationtests/versionnegotiation/handshake_test.go
@@ -24,14 +24,14 @@ type versioner interface {
 type result struct {
 	loggedVersions                 bool
 	receivedVersionNegotiation     bool
-	chosen                         logging.VersionNumber
-	clientVersions, serverVersions []logging.VersionNumber
+	chosen                         logging.Version
+	clientVersions, serverVersions []logging.Version
 }
 
 func newVersionNegotiationTracer() (*result, *logging.ConnectionTracer) {
 	r := &result{}
 	return r, &logging.ConnectionTracer{
-		NegotiatedVersion: func(chosen logging.VersionNumber, clientVersions, serverVersions []logging.VersionNumber) {
+		NegotiatedVersion: func(chosen logging.Version, clientVersions, serverVersions []logging.Version) {
 			if r.loggedVersions {
 				Fail("only expected one call to NegotiatedVersions")
 			}
@@ -40,7 +40,7 @@ func newVersionNegotiationTracer() (*result, *logging.ConnectionTracer) {
 			r.clientVersions = clientVersions
 			r.serverVersions = serverVersions
 		},
-		ReceivedVersionNegotiationPacket: func(dest, src logging.ArbitraryLenConnectionID, _ []logging.VersionNumber) {
+		ReceivedVersionNegotiationPacket: func(dest, src logging.ArbitraryLenConnectionID, _ []logging.Version) {
 			r.receivedVersionNegotiation = true
 		},
 	}
diff --git a/internal/handshake/crypto_setup_test.go b/internal/handshake/crypto_setup_test.go
index aa57f281..18edceb1 100644
--- a/internal/handshake/crypto_setup_test.go
+++ b/internal/handshake/crypto_setup_test.go
@@ -7,11 +7,11 @@ import (
 	"crypto/tls"
 	"crypto/x509"
 	"crypto/x509/pkix"
+	"errors"
 	"math/big"
 	"net"
 	"time"
 
-	mocktls "github.com/quic-go/quic-go/internal/mocks/tls"
 	"github.com/quic-go/quic-go/internal/protocol"
 	"github.com/quic-go/quic-go/internal/qerr"
 	"github.com/quic-go/quic-go/internal/testdata"
@@ -20,7 +20,6 @@ import (
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
-	"go.uber.org/mock/gomock"
 )
 
 const (
@@ -28,6 +27,29 @@ const (
 	typeNewSessionTicket = 4
 )
 
+type mockClientSessionCache struct {
+	cache tls.ClientSessionCache
+	puts  chan *tls.ClientSessionState
+}
+
+var _ tls.ClientSessionCache = &mockClientSessionCache{}
+
+func newMockClientSessionCache() *mockClientSessionCache {
+	return &mockClientSessionCache{
+		puts:  make(chan *tls.ClientSessionState, 1),
+		cache: tls.NewLRUClientSessionCache(1),
+	}
+}
+
+func (m *mockClientSessionCache) Get(sessionKey string) (session *tls.ClientSessionState, ok bool) {
+	return m.cache.Get(sessionKey)
+}
+
+func (m *mockClientSessionCache) Put(sessionKey string, cs *tls.ClientSessionState) {
+	m.puts <- cs
+	m.cache.Put(sessionKey, cs)
+}
+
 var _ = Describe("Crypto Setup TLS", func() {
 	generateCert := func() tls.Certificate {
 		priv, err := rsa.GenerateKey(rand.Reader, 2048)
@@ -75,10 +97,11 @@ var _ = Describe("Crypto Setup TLS", func() {
 			protocol.Version1,
 		)
 
-		Expect(cl.StartHandshake(context.Background())).To(MatchError(&qerr.TransportError{
-			ErrorCode:    qerr.InternalError,
-			ErrorMessage: "tls: invalid NextProtos value",
-		}))
+		var terr *qerr.TransportError
+		err := cl.StartHandshake(context.Background())
+		Expect(errors.As(err, &terr)).To(BeTrue())
+		Expect(terr.ErrorCode).To(BeEquivalentTo(0x100 + 0x50))
+		Expect(err.Error()).To(ContainSubstring("tls: invalid NextProtos value"))
 	})
 
 	It("errors when a message is received at the wrong encryption level", func() {
@@ -345,14 +368,7 @@ var _ = Describe("Crypto Setup TLS", func() {
 			})
 
 			It("uses session resumption", func() {
-				csc := mocktls.NewMockClientSessionCache(mockCtrl)
-				var state *tls.ClientSessionState
-				receivedSessionTicket := make(chan struct{})
-				csc.EXPECT().Get(gomock.Any())
-				csc.EXPECT().Put(gomock.Any(), gomock.Any()).Do(func(_ string, css *tls.ClientSessionState) {
-					state = css
-					close(receivedSessionTicket)
-				})
+				csc := newMockClientSessionCache()
 				clientConf.ClientSessionCache = csc
 				const serverRTT = 25 * time.Millisecond // RTT as measured by the server. Should be restored.
 				const clientRTT = 30 * time.Millisecond // RTT as measured by the client. Should be restored.
@@ -366,12 +382,10 @@ var _ = Describe("Crypto Setup TLS", func() {
 				)
 				Expect(clientErr).ToNot(HaveOccurred())
 				Expect(serverErr).ToNot(HaveOccurred())
-				Eventually(receivedSessionTicket).Should(BeClosed())
+				Eventually(csc.puts).Should(Receive())
 				Expect(server.ConnectionState().DidResume).To(BeFalse())
 				Expect(client.ConnectionState().DidResume).To(BeFalse())
 
-				csc.EXPECT().Get(gomock.Any()).Return(state, true)
-				csc.EXPECT().Put(gomock.Any(), gomock.Any()).MaxTimes(1)
 				clientRTTStats := &utils.RTTStats{}
 				serverRTTStats := &utils.RTTStats{}
 				client, _, clientErr, server, _, serverErr = handshakeWithTLSConf(
@@ -382,7 +396,7 @@ var _ = Describe("Crypto Setup TLS", func() {
 				)
 				Expect(clientErr).ToNot(HaveOccurred())
 				Expect(serverErr).ToNot(HaveOccurred())
-				Eventually(receivedSessionTicket).Should(BeClosed())
+				Eventually(csc.puts).Should(Receive())
 				Expect(server.ConnectionState().DidResume).To(BeTrue())
 				Expect(client.ConnectionState().DidResume).To(BeTrue())
 				Expect(clientRTTStats.SmoothedRTT()).To(Equal(clientRTT))
@@ -390,14 +404,7 @@ var _ = Describe("Crypto Setup TLS", func() {
 			})
 
 			It("doesn't use session resumption if the server disabled it", func() {
-				csc := mocktls.NewMockClientSessionCache(mockCtrl)
-				var state *tls.ClientSessionState
-				receivedSessionTicket := make(chan struct{})
-				csc.EXPECT().Get(gomock.Any())
-				csc.EXPECT().Put(gomock.Any(), gomock.Any()).Do(func(_ string, css *tls.ClientSessionState) {
-					state = css
-					close(receivedSessionTicket)
-				})
+				csc := newMockClientSessionCache()
 				clientConf.ClientSessionCache = csc
 				client, _, clientErr, server, _, serverErr := handshakeWithTLSConf(
 					clientConf, serverConf,
@@ -407,12 +414,11 @@ var _ = Describe("Crypto Setup TLS", func() {
 				)
 				Expect(clientErr).ToNot(HaveOccurred())
 				Expect(serverErr).ToNot(HaveOccurred())
-				Eventually(receivedSessionTicket).Should(BeClosed())
+				Eventually(csc.puts).Should(Receive())
 				Expect(server.ConnectionState().DidResume).To(BeFalse())
 				Expect(client.ConnectionState().DidResume).To(BeFalse())
 
 				serverConf.SessionTicketsDisabled = true
-				csc.EXPECT().Get(gomock.Any()).Return(state, true)
 				client, _, clientErr, server, _, serverErr = handshakeWithTLSConf(
 					clientConf, serverConf,
 					&utils.RTTStats{}, &utils.RTTStats{},
@@ -421,20 +427,13 @@ var _ = Describe("Crypto Setup TLS", func() {
 				)
 				Expect(clientErr).ToNot(HaveOccurred())
 				Expect(serverErr).ToNot(HaveOccurred())
-				Eventually(receivedSessionTicket).Should(BeClosed())
+				Consistently(csc.puts, 25*time.Millisecond).ShouldNot(Receive())
 				Expect(server.ConnectionState().DidResume).To(BeFalse())
 				Expect(client.ConnectionState().DidResume).To(BeFalse())
 			})
 
 			It("uses 0-RTT", func() {
-				csc := mocktls.NewMockClientSessionCache(mockCtrl)
-				var state *tls.ClientSessionState
-				receivedSessionTicket := make(chan struct{})
-				csc.EXPECT().Get(gomock.Any())
-				csc.EXPECT().Put(gomock.Any(), gomock.Any()).Do(func(_ string, css *tls.ClientSessionState) {
-					state = css
-					close(receivedSessionTicket)
-				})
+				csc := newMockClientSessionCache()
 				clientConf.ClientSessionCache = csc
 				const serverRTT = 25 * time.Millisecond // RTT as measured by the server. Should be restored.
 				const clientRTT = 30 * time.Millisecond // RTT as measured by the client. Should be restored.
@@ -450,13 +449,10 @@ var _ = Describe("Crypto Setup TLS", func() {
 				)
 				Expect(clientErr).ToNot(HaveOccurred())
 				Expect(serverErr).ToNot(HaveOccurred())
-				Eventually(receivedSessionTicket).Should(BeClosed())
+				Eventually(csc.puts).Should(Receive())
 				Expect(server.ConnectionState().DidResume).To(BeFalse())
 				Expect(client.ConnectionState().DidResume).To(BeFalse())
 
-				csc.EXPECT().Get(gomock.Any()).Return(state, true)
-				csc.EXPECT().Put(gomock.Any(), gomock.Any()).MaxTimes(1)
-
 				clientRTTStats := &utils.RTTStats{}
 				serverRTTStats := &utils.RTTStats{}
 				client, clientEvents, clientErr, server, serverEvents, serverErr := handshakeWithTLSConf(
@@ -501,14 +497,7 @@ var _ = Describe("Crypto Setup TLS", func() {
 			})
 
 			It("rejects 0-RTT, when the transport parameters changed", func() {
-				csc := mocktls.NewMockClientSessionCache(mockCtrl)
-				var state *tls.ClientSessionState
-				receivedSessionTicket := make(chan struct{})
-				csc.EXPECT().Get(gomock.Any())
-				csc.EXPECT().Put(gomock.Any(), gomock.Any()).Do(func(_ string, css *tls.ClientSessionState) {
-					state = css
-					close(receivedSessionTicket)
-				})
+				csc := newMockClientSessionCache()
 				clientConf.ClientSessionCache = csc
 				const clientRTT = 30 * time.Millisecond // RTT as measured by the client. Should be restored.
 				clientOrigRTTStats := newRTTStatsWithRTT(clientRTT)
@@ -522,13 +511,10 @@ var _ = Describe("Crypto Setup TLS", func() {
 				)
 				Expect(clientErr).ToNot(HaveOccurred())
 				Expect(serverErr).ToNot(HaveOccurred())
-				Eventually(receivedSessionTicket).Should(BeClosed())
+				Eventually(csc.puts).Should(Receive())
 				Expect(server.ConnectionState().DidResume).To(BeFalse())
 				Expect(client.ConnectionState().DidResume).To(BeFalse())
 
-				csc.EXPECT().Get(gomock.Any()).Return(state, true)
-				csc.EXPECT().Put(gomock.Any(), gomock.Any()).MaxTimes(1)
-
 				clientRTTStats := &utils.RTTStats{}
 				client, clientEvents, clientErr, server, _, serverErr := handshakeWithTLSConf(
 					clientConf, serverConf,
diff --git a/internal/wire/header_test.go b/internal/wire/header_test.go
index 64fdba56..aad1eef0 100644
--- a/internal/wire/header_test.go
+++ b/internal/wire/header_test.go
@@ -575,3 +575,39 @@ func BenchmarkParseRetry(b *testing.B) {
 		}
 	}
 }
+
+func BenchmarkArbitraryHeaderParsing(b *testing.B) {
+	b.Run("dest 8/ src 10", func(b *testing.B) { benchmarkArbitraryHeaderParsing(b, 8, 10) })
+	b.Run("dest 20 / src 20", func(b *testing.B) { benchmarkArbitraryHeaderParsing(b, 20, 20) })
+	b.Run("dest 100 / src 150", func(b *testing.B) { benchmarkArbitraryHeaderParsing(b, 100, 150) })
+}
+
+func benchmarkArbitraryHeaderParsing(b *testing.B, destLen, srcLen int) {
+	destConnID := make([]byte, destLen)
+	rand.Read(destConnID)
+	srcConnID := make([]byte, srcLen)
+	rand.Read(srcConnID)
+	buf := []byte{0x80, 1, 2, 3, 4}
+	buf = append(buf, uint8(destLen))
+	buf = append(buf, destConnID...)
+	buf = append(buf, uint8(srcLen))
+	buf = append(buf, srcConnID...)
+
+	b.ResetTimer()
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		parsed, d, s, err := ParseArbitraryLenConnectionIDs(buf)
+		if err != nil {
+			b.Fatal(err)
+		}
+		if parsed != len(buf) {
+			b.Fatal("expected to parse entire slice")
+		}
+		if !bytes.Equal(destConnID, d.Bytes()) {
+			b.Fatalf("destination connection IDs don't match: %v vs %v", destConnID, d.Bytes())
+		}
+		if !bytes.Equal(srcConnID, s.Bytes()) {
+			b.Fatalf("source connection IDs don't match: %v vs %v", srcConnID, s.Bytes())
+		}
+	}
+}
diff --git a/logging/multiplex_test.go b/logging/multiplex_test.go
index 7c519f38..cf78dcf1 100644
--- a/logging/multiplex_test.go
+++ b/logging/multiplex_test.go
@@ -52,7 +52,7 @@ var _ = Describe("Tracing", func() {
 				remote := &net.UDPAddr{IP: net.IPv4(4, 3, 2, 1)}
 				src := ArbitraryLenConnectionID{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}
 				dest := ArbitraryLenConnectionID{1, 2, 3, 4}
-				versions := []VersionNumber{1, 2, 3}
+				versions := []Version{1, 2, 3}
 				tr1.EXPECT().SentVersionNegotiationPacket(remote, dest, src, versions)
 				tr2.EXPECT().SentVersionNegotiationPacket(remote, dest, src, versions)
 				tracer.SentVersionNegotiationPacket(remote, dest, src, versions)
@@ -161,9 +161,9 @@ var _ = Describe("Tracing", func() {
 		It("traces the ReceivedVersionNegotiationPacket event", func() {
 			src := ArbitraryLenConnectionID{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}
 			dest := ArbitraryLenConnectionID{1, 2, 3, 4}
-			tr1.EXPECT().ReceivedVersionNegotiationPacket(dest, src, []VersionNumber{1337})
-			tr2.EXPECT().ReceivedVersionNegotiationPacket(dest, src, []VersionNumber{1337})
-			tracer.ReceivedVersionNegotiationPacket(dest, src, []VersionNumber{1337})
+			tr1.EXPECT().ReceivedVersionNegotiationPacket(dest, src, []Version{1337})
+			tr2.EXPECT().ReceivedVersionNegotiationPacket(dest, src, []Version{1337})
+			tracer.ReceivedVersionNegotiationPacket(dest, src, []Version{1337})
 		})
 
 		It("traces the ReceivedRetry event", func() {
diff --git a/mock_crypto_stream_test.go b/mock_crypto_stream_test.go
deleted file mode 100644
index bd762d64..00000000
--- a/mock_crypto_stream_test.go
+++ /dev/null
@@ -1,270 +0,0 @@
-// Code generated by MockGen. DO NOT EDIT.
-// Source: github.com/quic-go/quic-go (interfaces: CryptoStream)
-//
-// Generated by this command:
-//
-//	mockgen -typed -build_flags=-tags=gomock -package quic -self_package github.com/quic-go/quic-go -destination mock_crypto_stream_test.go github.com/quic-go/quic-go CryptoStream
-//
-
-// Package quic is a generated GoMock package.
-package quic
-
-import (
-	reflect "reflect"
-
-	protocol "github.com/quic-go/quic-go/internal/protocol"
-	wire "github.com/quic-go/quic-go/internal/wire"
-	gomock "go.uber.org/mock/gomock"
-)
-
-// MockCryptoStream is a mock of CryptoStream interface.
-type MockCryptoStream struct {
-	ctrl     *gomock.Controller
-	recorder *MockCryptoStreamMockRecorder
-}
-
-// MockCryptoStreamMockRecorder is the mock recorder for MockCryptoStream.
-type MockCryptoStreamMockRecorder struct {
-	mock *MockCryptoStream
-}
-
-// NewMockCryptoStream creates a new mock instance.
-func NewMockCryptoStream(ctrl *gomock.Controller) *MockCryptoStream {
-	mock := &MockCryptoStream{ctrl: ctrl}
-	mock.recorder = &MockCryptoStreamMockRecorder{mock}
-	return mock
-}
-
-// EXPECT returns an object that allows the caller to indicate expected use.
-func (m *MockCryptoStream) EXPECT() *MockCryptoStreamMockRecorder {
-	return m.recorder
-}
-
-// Finish mocks base method.
-func (m *MockCryptoStream) Finish() error {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "Finish")
-	ret0, _ := ret[0].(error)
-	return ret0
-}
-
-// Finish indicates an expected call of Finish.
-func (mr *MockCryptoStreamMockRecorder) Finish() *MockCryptoStreamFinishCall {
-	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Finish", reflect.TypeOf((*MockCryptoStream)(nil).Finish))
-	return &MockCryptoStreamFinishCall{Call: call}
-}
-
-// MockCryptoStreamFinishCall wrap *gomock.Call
-type MockCryptoStreamFinishCall struct {
-	*gomock.Call
-}
-
-// Return rewrite *gomock.Call.Return
-func (c *MockCryptoStreamFinishCall) Return(arg0 error) *MockCryptoStreamFinishCall {
-	c.Call = c.Call.Return(arg0)
-	return c
-}
-
-// Do rewrite *gomock.Call.Do
-func (c *MockCryptoStreamFinishCall) Do(f func() error) *MockCryptoStreamFinishCall {
-	c.Call = c.Call.Do(f)
-	return c
-}
-
-// DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MockCryptoStreamFinishCall) DoAndReturn(f func() error) *MockCryptoStreamFinishCall {
-	c.Call = c.Call.DoAndReturn(f)
-	return c
-}
-
-// GetCryptoData mocks base method.
-func (m *MockCryptoStream) GetCryptoData() []byte {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "GetCryptoData")
-	ret0, _ := ret[0].([]byte)
-	return ret0
-}
-
-// GetCryptoData indicates an expected call of GetCryptoData.
-func (mr *MockCryptoStreamMockRecorder) GetCryptoData() *MockCryptoStreamGetCryptoDataCall {
-	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCryptoData", reflect.TypeOf((*MockCryptoStream)(nil).GetCryptoData))
-	return &MockCryptoStreamGetCryptoDataCall{Call: call}
-}
-
-// MockCryptoStreamGetCryptoDataCall wrap *gomock.Call
-type MockCryptoStreamGetCryptoDataCall struct {
-	*gomock.Call
-}
-
-// Return rewrite *gomock.Call.Return
-func (c *MockCryptoStreamGetCryptoDataCall) Return(arg0 []byte) *MockCryptoStreamGetCryptoDataCall {
-	c.Call = c.Call.Return(arg0)
-	return c
-}
-
-// Do rewrite *gomock.Call.Do
-func (c *MockCryptoStreamGetCryptoDataCall) Do(f func() []byte) *MockCryptoStreamGetCryptoDataCall {
-	c.Call = c.Call.Do(f)
-	return c
-}
-
-// DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MockCryptoStreamGetCryptoDataCall) DoAndReturn(f func() []byte) *MockCryptoStreamGetCryptoDataCall {
-	c.Call = c.Call.DoAndReturn(f)
-	return c
-}
-
-// HandleCryptoFrame mocks base method.
-func (m *MockCryptoStream) HandleCryptoFrame(arg0 *wire.CryptoFrame) error {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "HandleCryptoFrame", arg0)
-	ret0, _ := ret[0].(error)
-	return ret0
-}
-
-// HandleCryptoFrame indicates an expected call of HandleCryptoFrame.
-func (mr *MockCryptoStreamMockRecorder) HandleCryptoFrame(arg0 any) *MockCryptoStreamHandleCryptoFrameCall {
-	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleCryptoFrame", reflect.TypeOf((*MockCryptoStream)(nil).HandleCryptoFrame), arg0)
-	return &MockCryptoStreamHandleCryptoFrameCall{Call: call}
-}
-
-// MockCryptoStreamHandleCryptoFrameCall wrap *gomock.Call
-type MockCryptoStreamHandleCryptoFrameCall struct {
-	*gomock.Call
-}
-
-// Return rewrite *gomock.Call.Return
-func (c *MockCryptoStreamHandleCryptoFrameCall) Return(arg0 error) *MockCryptoStreamHandleCryptoFrameCall {
-	c.Call = c.Call.Return(arg0)
-	return c
-}
-
-// Do rewrite *gomock.Call.Do
-func (c *MockCryptoStreamHandleCryptoFrameCall) Do(f func(*wire.CryptoFrame) error) *MockCryptoStreamHandleCryptoFrameCall {
-	c.Call = c.Call.Do(f)
-	return c
-}
-
-// DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MockCryptoStreamHandleCryptoFrameCall) DoAndReturn(f func(*wire.CryptoFrame) error) *MockCryptoStreamHandleCryptoFrameCall {
-	c.Call = c.Call.DoAndReturn(f)
-	return c
-}
-
-// HasData mocks base method.
-func (m *MockCryptoStream) HasData() bool {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "HasData")
-	ret0, _ := ret[0].(bool)
-	return ret0
-}
-
-// HasData indicates an expected call of HasData.
-func (mr *MockCryptoStreamMockRecorder) HasData() *MockCryptoStreamHasDataCall {
-	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasData", reflect.TypeOf((*MockCryptoStream)(nil).HasData))
-	return &MockCryptoStreamHasDataCall{Call: call}
-}
-
-// MockCryptoStreamHasDataCall wrap *gomock.Call
-type MockCryptoStreamHasDataCall struct {
-	*gomock.Call
-}
-
-// Return rewrite *gomock.Call.Return
-func (c *MockCryptoStreamHasDataCall) Return(arg0 bool) *MockCryptoStreamHasDataCall {
-	c.Call = c.Call.Return(arg0)
-	return c
-}
-
-// Do rewrite *gomock.Call.Do
-func (c *MockCryptoStreamHasDataCall) Do(f func() bool) *MockCryptoStreamHasDataCall {
-	c.Call = c.Call.Do(f)
-	return c
-}
-
-// DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MockCryptoStreamHasDataCall) DoAndReturn(f func() bool) *MockCryptoStreamHasDataCall {
-	c.Call = c.Call.DoAndReturn(f)
-	return c
-}
-
-// PopCryptoFrame mocks base method.
-func (m *MockCryptoStream) PopCryptoFrame(arg0 protocol.ByteCount) *wire.CryptoFrame {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "PopCryptoFrame", arg0)
-	ret0, _ := ret[0].(*wire.CryptoFrame)
-	return ret0
-}
-
-// PopCryptoFrame indicates an expected call of PopCryptoFrame.
-func (mr *MockCryptoStreamMockRecorder) PopCryptoFrame(arg0 any) *MockCryptoStreamPopCryptoFrameCall {
-	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PopCryptoFrame", reflect.TypeOf((*MockCryptoStream)(nil).PopCryptoFrame), arg0)
-	return &MockCryptoStreamPopCryptoFrameCall{Call: call}
-}
-
-// MockCryptoStreamPopCryptoFrameCall wrap *gomock.Call
-type MockCryptoStreamPopCryptoFrameCall struct {
-	*gomock.Call
-}
-
-// Return rewrite *gomock.Call.Return
-func (c *MockCryptoStreamPopCryptoFrameCall) Return(arg0 *wire.CryptoFrame) *MockCryptoStreamPopCryptoFrameCall {
-	c.Call = c.Call.Return(arg0)
-	return c
-}
-
-// Do rewrite *gomock.Call.Do
-func (c *MockCryptoStreamPopCryptoFrameCall) Do(f func(protocol.ByteCount) *wire.CryptoFrame) *MockCryptoStreamPopCryptoFrameCall {
-	c.Call = c.Call.Do(f)
-	return c
-}
-
-// DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MockCryptoStreamPopCryptoFrameCall) DoAndReturn(f func(protocol.ByteCount) *wire.CryptoFrame) *MockCryptoStreamPopCryptoFrameCall {
-	c.Call = c.Call.DoAndReturn(f)
-	return c
-}
-
-// Write mocks base method.
-func (m *MockCryptoStream) Write(arg0 []byte) (int, error) {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "Write", arg0)
-	ret0, _ := ret[0].(int)
-	ret1, _ := ret[1].(error)
-	return ret0, ret1
-}
-
-// Write indicates an expected call of Write.
-func (mr *MockCryptoStreamMockRecorder) Write(arg0 any) *MockCryptoStreamWriteCall {
-	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Write", reflect.TypeOf((*MockCryptoStream)(nil).Write), arg0)
-	return &MockCryptoStreamWriteCall{Call: call}
-}
-
-// MockCryptoStreamWriteCall wrap *gomock.Call
-type MockCryptoStreamWriteCall struct {
-	*gomock.Call
-}
-
-// Return rewrite *gomock.Call.Return
-func (c *MockCryptoStreamWriteCall) Return(arg0 int, arg1 error) *MockCryptoStreamWriteCall {
-	c.Call = c.Call.Return(arg0, arg1)
-	return c
-}
-
-// Do rewrite *gomock.Call.Do
-func (c *MockCryptoStreamWriteCall) Do(f func([]byte) (int, error)) *MockCryptoStreamWriteCall {
-	c.Call = c.Call.Do(f)
-	return c
-}
-
-// DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MockCryptoStreamWriteCall) DoAndReturn(f func([]byte) (int, error)) *MockCryptoStreamWriteCall {
-	c.Call = c.Call.DoAndReturn(f)
-	return c
-}
diff --git a/mock_receive_stream_internal_test.go b/mock_receive_stream_internal_test.go
index c2faf380..793cdfe0 100644
--- a/mock_receive_stream_internal_test.go
+++ b/mock_receive_stream_internal_test.go
@@ -229,44 +229,6 @@ func (c *MockReceiveStreamIcloseForShutdownCall) DoAndReturn(f func(error)) *Moc
 	return c
 }
 
-// getWindowUpdate mocks base method.
-func (m *MockReceiveStreamI) getWindowUpdate() protocol.ByteCount {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "getWindowUpdate")
-	ret0, _ := ret[0].(protocol.ByteCount)
-	return ret0
-}
-
-// getWindowUpdate indicates an expected call of getWindowUpdate.
-func (mr *MockReceiveStreamIMockRecorder) getWindowUpdate() *MockReceiveStreamIgetWindowUpdateCall {
-	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "getWindowUpdate", reflect.TypeOf((*MockReceiveStreamI)(nil).getWindowUpdate))
-	return &MockReceiveStreamIgetWindowUpdateCall{Call: call}
-}
-
-// MockReceiveStreamIgetWindowUpdateCall wrap *gomock.Call
-type MockReceiveStreamIgetWindowUpdateCall struct {
-	*gomock.Call
-}
-
-// Return rewrite *gomock.Call.Return
-func (c *MockReceiveStreamIgetWindowUpdateCall) Return(arg0 protocol.ByteCount) *MockReceiveStreamIgetWindowUpdateCall {
-	c.Call = c.Call.Return(arg0)
-	return c
-}
-
-// Do rewrite *gomock.Call.Do
-func (c *MockReceiveStreamIgetWindowUpdateCall) Do(f func() protocol.ByteCount) *MockReceiveStreamIgetWindowUpdateCall {
-	c.Call = c.Call.Do(f)
-	return c
-}
-
-// DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MockReceiveStreamIgetWindowUpdateCall) DoAndReturn(f func() protocol.ByteCount) *MockReceiveStreamIgetWindowUpdateCall {
-	c.Call = c.Call.DoAndReturn(f)
-	return c
-}
-
 // handleResetStreamFrame mocks base method.
 func (m *MockReceiveStreamI) handleResetStreamFrame(arg0 *wire.ResetStreamFrame) error {
 	m.ctrl.T.Helper()
diff --git a/mock_stream_control_frame_getter_test.go b/mock_stream_control_frame_getter_test.go
new file mode 100644
index 00000000..2fad6e7c
--- /dev/null
+++ b/mock_stream_control_frame_getter_test.go
@@ -0,0 +1,80 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/quic-go/quic-go (interfaces: StreamControlFrameGetter)
+//
+// Generated by this command:
+//
+//	mockgen -typed -build_flags=-tags=gomock -package quic -self_package github.com/quic-go/quic-go -destination mock_stream_control_frame_getter_test.go github.com/quic-go/quic-go StreamControlFrameGetter
+//
+
+// Package quic is a generated GoMock package.
+package quic
+
+import (
+	reflect "reflect"
+
+	ackhandler "github.com/quic-go/quic-go/internal/ackhandler"
+	gomock "go.uber.org/mock/gomock"
+)
+
+// MockStreamControlFrameGetter is a mock of StreamControlFrameGetter interface.
+type MockStreamControlFrameGetter struct {
+	ctrl     *gomock.Controller
+	recorder *MockStreamControlFrameGetterMockRecorder
+}
+
+// MockStreamControlFrameGetterMockRecorder is the mock recorder for MockStreamControlFrameGetter.
+type MockStreamControlFrameGetterMockRecorder struct {
+	mock *MockStreamControlFrameGetter
+}
+
+// NewMockStreamControlFrameGetter creates a new mock instance.
+func NewMockStreamControlFrameGetter(ctrl *gomock.Controller) *MockStreamControlFrameGetter {
+	mock := &MockStreamControlFrameGetter{ctrl: ctrl}
+	mock.recorder = &MockStreamControlFrameGetterMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockStreamControlFrameGetter) EXPECT() *MockStreamControlFrameGetterMockRecorder {
+	return m.recorder
+}
+
+// getControlFrame mocks base method.
+func (m *MockStreamControlFrameGetter) getControlFrame() (ackhandler.Frame, bool, bool) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "getControlFrame")
+	ret0, _ := ret[0].(ackhandler.Frame)
+	ret1, _ := ret[1].(bool)
+	ret2, _ := ret[2].(bool)
+	return ret0, ret1, ret2
+}
+
+// getControlFrame indicates an expected call of getControlFrame.
+func (mr *MockStreamControlFrameGetterMockRecorder) getControlFrame() *MockStreamControlFrameGettergetControlFrameCall {
+	mr.mock.ctrl.T.Helper()
+	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "getControlFrame", reflect.TypeOf((*MockStreamControlFrameGetter)(nil).getControlFrame))
+	return &MockStreamControlFrameGettergetControlFrameCall{Call: call}
+}
+
+// MockStreamControlFrameGettergetControlFrameCall wrap *gomock.Call
+type MockStreamControlFrameGettergetControlFrameCall struct {
+	*gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockStreamControlFrameGettergetControlFrameCall) Return(arg0 ackhandler.Frame, arg1, arg2 bool) *MockStreamControlFrameGettergetControlFrameCall {
+	c.Call = c.Call.Return(arg0, arg1, arg2)
+	return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockStreamControlFrameGettergetControlFrameCall) Do(f func() (ackhandler.Frame, bool, bool)) *MockStreamControlFrameGettergetControlFrameCall {
+	c.Call = c.Call.Do(f)
+	return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockStreamControlFrameGettergetControlFrameCall) DoAndReturn(f func() (ackhandler.Frame, bool, bool)) *MockStreamControlFrameGettergetControlFrameCall {
+	c.Call = c.Call.DoAndReturn(f)
+	return c
+}
diff --git a/mock_stream_internal_test.go b/mock_stream_internal_test.go
index 212256d8..a27fbed8 100644
--- a/mock_stream_internal_test.go
+++ b/mock_stream_internal_test.go
@@ -458,44 +458,6 @@ func (c *MockStreamIcloseForShutdownCall) DoAndReturn(f func(error)) *MockStream
 	return c
 }
 
-// getWindowUpdate mocks base method.
-func (m *MockStreamI) getWindowUpdate() protocol.ByteCount {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "getWindowUpdate")
-	ret0, _ := ret[0].(protocol.ByteCount)
-	return ret0
-}
-
-// getWindowUpdate indicates an expected call of getWindowUpdate.
-func (mr *MockStreamIMockRecorder) getWindowUpdate() *MockStreamIgetWindowUpdateCall {
-	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "getWindowUpdate", reflect.TypeOf((*MockStreamI)(nil).getWindowUpdate))
-	return &MockStreamIgetWindowUpdateCall{Call: call}
-}
-
-// MockStreamIgetWindowUpdateCall wrap *gomock.Call
-type MockStreamIgetWindowUpdateCall struct {
-	*gomock.Call
-}
-
-// Return rewrite *gomock.Call.Return
-func (c *MockStreamIgetWindowUpdateCall) Return(arg0 protocol.ByteCount) *MockStreamIgetWindowUpdateCall {
-	c.Call = c.Call.Return(arg0)
-	return c
-}
-
-// Do rewrite *gomock.Call.Do
-func (c *MockStreamIgetWindowUpdateCall) Do(f func() protocol.ByteCount) *MockStreamIgetWindowUpdateCall {
-	c.Call = c.Call.Do(f)
-	return c
-}
-
-// DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MockStreamIgetWindowUpdateCall) DoAndReturn(f func() protocol.ByteCount) *MockStreamIgetWindowUpdateCall {
-	c.Call = c.Call.DoAndReturn(f)
-	return c
-}
-
 // handleResetStreamFrame mocks base method.
 func (m *MockStreamI) handleResetStreamFrame(arg0 *wire.ResetStreamFrame) error {
 	m.ctrl.T.Helper()
diff --git a/mock_stream_sender_test.go b/mock_stream_sender_test.go
index 2b608345..08906151 100644
--- a/mock_stream_sender_test.go
+++ b/mock_stream_sender_test.go
@@ -13,7 +13,6 @@ import (
 	reflect "reflect"
 
 	protocol "github.com/quic-go/quic-go/internal/protocol"
-	wire "github.com/quic-go/quic-go/internal/wire"
 	gomock "go.uber.org/mock/gomock"
 )
 
@@ -40,74 +39,74 @@ func (m *MockStreamSender) EXPECT() *MockStreamSenderMockRecorder {
 	return m.recorder
 }
 
-// onHasStreamData mocks base method.
-func (m *MockStreamSender) onHasStreamData(arg0 protocol.StreamID, arg1 sendStreamI) {
+// onHasStreamControlFrame mocks base method.
+func (m *MockStreamSender) onHasStreamControlFrame(arg0 protocol.StreamID, arg1 streamControlFrameGetter) {
 	m.ctrl.T.Helper()
-	m.ctrl.Call(m, "onHasStreamData", arg0, arg1)
+	m.ctrl.Call(m, "onHasStreamControlFrame", arg0, arg1)
 }
 
-// onHasStreamData indicates an expected call of onHasStreamData.
-func (mr *MockStreamSenderMockRecorder) onHasStreamData(arg0, arg1 any) *MockStreamSenderonHasStreamDataCall {
+// onHasStreamControlFrame indicates an expected call of onHasStreamControlFrame.
+func (mr *MockStreamSenderMockRecorder) onHasStreamControlFrame(arg0, arg1 any) *MockStreamSenderonHasStreamControlFrameCall {
 	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "onHasStreamData", reflect.TypeOf((*MockStreamSender)(nil).onHasStreamData), arg0, arg1)
-	return &MockStreamSenderonHasStreamDataCall{Call: call}
+	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "onHasStreamControlFrame", reflect.TypeOf((*MockStreamSender)(nil).onHasStreamControlFrame), arg0, arg1)
+	return &MockStreamSenderonHasStreamControlFrameCall{Call: call}
 }
 
-// MockStreamSenderonHasStreamDataCall wrap *gomock.Call
-type MockStreamSenderonHasStreamDataCall struct {
+// MockStreamSenderonHasStreamControlFrameCall wrap *gomock.Call
+type MockStreamSenderonHasStreamControlFrameCall struct {
 	*gomock.Call
 }
 
 // Return rewrite *gomock.Call.Return
-func (c *MockStreamSenderonHasStreamDataCall) Return() *MockStreamSenderonHasStreamDataCall {
+func (c *MockStreamSenderonHasStreamControlFrameCall) Return() *MockStreamSenderonHasStreamControlFrameCall {
 	c.Call = c.Call.Return()
 	return c
 }
 
 // Do rewrite *gomock.Call.Do
-func (c *MockStreamSenderonHasStreamDataCall) Do(f func(protocol.StreamID, sendStreamI)) *MockStreamSenderonHasStreamDataCall {
+func (c *MockStreamSenderonHasStreamControlFrameCall) Do(f func(protocol.StreamID, streamControlFrameGetter)) *MockStreamSenderonHasStreamControlFrameCall {
 	c.Call = c.Call.Do(f)
 	return c
 }
 
 // DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MockStreamSenderonHasStreamDataCall) DoAndReturn(f func(protocol.StreamID, sendStreamI)) *MockStreamSenderonHasStreamDataCall {
+func (c *MockStreamSenderonHasStreamControlFrameCall) DoAndReturn(f func(protocol.StreamID, streamControlFrameGetter)) *MockStreamSenderonHasStreamControlFrameCall {
 	c.Call = c.Call.DoAndReturn(f)
 	return c
 }
 
-// onHasStreamWindowUpdate mocks base method.
-func (m *MockStreamSender) onHasStreamWindowUpdate(arg0 protocol.StreamID, arg1 receiveStreamI) {
+// onHasStreamData mocks base method.
+func (m *MockStreamSender) onHasStreamData(arg0 protocol.StreamID, arg1 sendStreamI) {
 	m.ctrl.T.Helper()
-	m.ctrl.Call(m, "onHasStreamWindowUpdate", arg0, arg1)
+	m.ctrl.Call(m, "onHasStreamData", arg0, arg1)
 }
 
-// onHasStreamWindowUpdate indicates an expected call of onHasStreamWindowUpdate.
-func (mr *MockStreamSenderMockRecorder) onHasStreamWindowUpdate(arg0, arg1 any) *MockStreamSenderonHasStreamWindowUpdateCall {
+// onHasStreamData indicates an expected call of onHasStreamData.
+func (mr *MockStreamSenderMockRecorder) onHasStreamData(arg0, arg1 any) *MockStreamSenderonHasStreamDataCall {
 	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "onHasStreamWindowUpdate", reflect.TypeOf((*MockStreamSender)(nil).onHasStreamWindowUpdate), arg0, arg1)
-	return &MockStreamSenderonHasStreamWindowUpdateCall{Call: call}
+	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "onHasStreamData", reflect.TypeOf((*MockStreamSender)(nil).onHasStreamData), arg0, arg1)
+	return &MockStreamSenderonHasStreamDataCall{Call: call}
 }
 
-// MockStreamSenderonHasStreamWindowUpdateCall wrap *gomock.Call
-type MockStreamSenderonHasStreamWindowUpdateCall struct {
+// MockStreamSenderonHasStreamDataCall wrap *gomock.Call
+type MockStreamSenderonHasStreamDataCall struct {
 	*gomock.Call
 }
 
 // Return rewrite *gomock.Call.Return
-func (c *MockStreamSenderonHasStreamWindowUpdateCall) Return() *MockStreamSenderonHasStreamWindowUpdateCall {
+func (c *MockStreamSenderonHasStreamDataCall) Return() *MockStreamSenderonHasStreamDataCall {
 	c.Call = c.Call.Return()
 	return c
 }
 
 // Do rewrite *gomock.Call.Do
-func (c *MockStreamSenderonHasStreamWindowUpdateCall) Do(f func(protocol.StreamID, receiveStreamI)) *MockStreamSenderonHasStreamWindowUpdateCall {
+func (c *MockStreamSenderonHasStreamDataCall) Do(f func(protocol.StreamID, sendStreamI)) *MockStreamSenderonHasStreamDataCall {
 	c.Call = c.Call.Do(f)
 	return c
 }
 
 // DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MockStreamSenderonHasStreamWindowUpdateCall) DoAndReturn(f func(protocol.StreamID, receiveStreamI)) *MockStreamSenderonHasStreamWindowUpdateCall {
+func (c *MockStreamSenderonHasStreamDataCall) DoAndReturn(f func(protocol.StreamID, sendStreamI)) *MockStreamSenderonHasStreamDataCall {
 	c.Call = c.Call.DoAndReturn(f)
 	return c
 }
@@ -147,39 +146,3 @@ func (c *MockStreamSenderonStreamCompletedCall) DoAndReturn(f func(protocol.Stre
 	c.Call = c.Call.DoAndReturn(f)
 	return c
 }
-
-// queueControlFrame mocks base method.
-func (m *MockStreamSender) queueControlFrame(arg0 wire.Frame) {
-	m.ctrl.T.Helper()
-	m.ctrl.Call(m, "queueControlFrame", arg0)
-}
-
-// queueControlFrame indicates an expected call of queueControlFrame.
-func (mr *MockStreamSenderMockRecorder) queueControlFrame(arg0 any) *MockStreamSenderqueueControlFrameCall {
-	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "queueControlFrame", reflect.TypeOf((*MockStreamSender)(nil).queueControlFrame), arg0)
-	return &MockStreamSenderqueueControlFrameCall{Call: call}
-}
-
-// MockStreamSenderqueueControlFrameCall wrap *gomock.Call
-type MockStreamSenderqueueControlFrameCall struct {
-	*gomock.Call
-}
-
-// Return rewrite *gomock.Call.Return
-func (c *MockStreamSenderqueueControlFrameCall) Return() *MockStreamSenderqueueControlFrameCall {
-	c.Call = c.Call.Return()
-	return c
-}
-
-// Do rewrite *gomock.Call.Do
-func (c *MockStreamSenderqueueControlFrameCall) Do(f func(wire.Frame)) *MockStreamSenderqueueControlFrameCall {
-	c.Call = c.Call.Do(f)
-	return c
-}
-
-// DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MockStreamSenderqueueControlFrameCall) DoAndReturn(f func(wire.Frame)) *MockStreamSenderqueueControlFrameCall {
-	c.Call = c.Call.DoAndReturn(f)
-	return c
-}
diff --git a/packet_packer_test.go b/packet_packer_test.go
index 7fbf1b37..c676b2b4 100644
--- a/packet_packer_test.go
+++ b/packet_packer_test.go
@@ -31,8 +31,8 @@ var _ = Describe("Packet packer", func() {
 		datagramQueue       *datagramQueue
 		framer              *MockFrameSource
 		ackFramer           *MockAckFrameSource
-		initialStream       *MockCryptoStream
-		handshakeStream     *MockCryptoStream
+		initialStream       *cryptoStream
+		handshakeStream     *cryptoStream
 		sealingManager      *MockSealingManager
 		pnManager           *mockackhandler.MockSentPacketHandler
 	)
@@ -86,8 +86,8 @@ var _ = Describe("Packet packer", func() {
 		retransmissionQueue = newRetransmissionQueue()
 		mockSender := NewMockStreamSender(mockCtrl)
 		mockSender.EXPECT().onHasStreamData(gomock.Any(), gomock.Any()).AnyTimes()
-		initialStream = NewMockCryptoStream(mockCtrl)
-		handshakeStream = NewMockCryptoStream(mockCtrl)
+		initialStream = newCryptoStream()
+		handshakeStream = newCryptoStream()
 		framer = NewMockFrameSource(mockCtrl)
 		ackFramer = NewMockAckFrameSource(mockCtrl)
 		sealingManager = NewMockSealingManager(mockCtrl)
@@ -268,9 +268,7 @@ var _ = Describe("Packet packer", func() {
 				sealingManager.EXPECT().GetInitialSealer().Return(nil, nil).AnyTimes()
 				sealingManager.EXPECT().GetHandshakeSealer().Return(nil, nil).AnyTimes()
 				sealingManager.EXPECT().Get1RTTSealer().Return(nil, handshake.ErrKeysNotYetAvailable).AnyTimes()
-				initialStream.EXPECT().HasData().AnyTimes()
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionInitial, true).AnyTimes()
-				handshakeStream.EXPECT().HasData().AnyTimes()
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionHandshake, true).AnyTimes()
 				ackFramer.EXPECT().GetAckFrame(protocol.Encryption1RTT, true).AnyTimes()
 			})
@@ -682,7 +680,6 @@ var _ = Describe("Packet packer", func() {
 				sealingManager.EXPECT().GetHandshakeSealer().Return(sealer, nil)
 				sealingManager.EXPECT().Get1RTTSealer().Return(nil, handshake.ErrKeysNotYetAvailable)
 				packer.retransmissionQueue.addHandshake(&wire.PingFrame{})
-				handshakeStream.EXPECT().HasData()
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionHandshake, false)
 				packet, err := packer.PackCoalescedPacket(false, maxPacketSize, protocol.Version1)
 				Expect(err).ToNot(HaveOccurred())
@@ -887,13 +884,8 @@ var _ = Describe("Packet packer", func() {
 			It("sets the length", func() {
 				pnManager.EXPECT().PeekPacketNumber(protocol.EncryptionHandshake).Return(protocol.PacketNumber(0x42), protocol.PacketNumberLen2)
 				pnManager.EXPECT().PopPacketNumber(protocol.EncryptionHandshake).Return(protocol.PacketNumber(0x42))
-				f := &wire.CryptoFrame{
-					Offset: 0x1337,
-					Data:   []byte("foobar"),
-				}
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionHandshake, false)
-				handshakeStream.EXPECT().HasData().Return(true).AnyTimes()
-				handshakeStream.EXPECT().PopCryptoFrame(gomock.Any()).Return(f)
+				handshakeStream.Write([]byte("foobar"))
 				sealingManager.EXPECT().GetInitialSealer().Return(nil, handshake.ErrKeysDropped)
 				sealingManager.EXPECT().GetHandshakeSealer().Return(getSealer(), nil)
 				sealingManager.EXPECT().Get1RTTSealer().Return(nil, handshake.ErrKeysNotYetAvailable)
@@ -913,10 +905,7 @@ var _ = Describe("Packet packer", func() {
 				sealingManager.EXPECT().Get0RTTSealer().Return(nil, handshake.ErrKeysNotYetAvailable)
 				sealingManager.EXPECT().Get1RTTSealer().Return(nil, handshake.ErrKeysNotYetAvailable)
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionInitial, false)
-				initialStream.EXPECT().HasData().Return(true).Times(2)
-				initialStream.EXPECT().PopCryptoFrame(gomock.Any()).DoAndReturn(func(size protocol.ByteCount) *wire.CryptoFrame {
-					return &wire.CryptoFrame{Offset: 0x42, Data: []byte("initial")}
-				})
+				initialStream.Write([]byte("initial"))
 				p, err := packer.PackCoalescedPacket(false, maxPacketSize, protocol.Version1)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(p.buffer.Len()).To(BeNumerically(">=", protocol.MinInitialPacketSize))
@@ -932,21 +921,13 @@ var _ = Describe("Packet packer", func() {
 			})
 
 			It("packs a maximum size Handshake packet", func() {
-				var f *wire.CryptoFrame
 				pnManager.EXPECT().PeekPacketNumber(protocol.EncryptionHandshake).Return(protocol.PacketNumber(0x42), protocol.PacketNumberLen2)
 				pnManager.EXPECT().PopPacketNumber(protocol.EncryptionHandshake).Return(protocol.PacketNumber(0x42))
 				sealingManager.EXPECT().GetInitialSealer().Return(getSealer(), nil)
 				sealingManager.EXPECT().GetHandshakeSealer().Return(getSealer(), nil)
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionInitial, true)
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionHandshake, false)
-				initialStream.EXPECT().HasData()
-				handshakeStream.EXPECT().HasData().Return(true).Times(2)
-				handshakeStream.EXPECT().PopCryptoFrame(gomock.Any()).DoAndReturn(func(size protocol.ByteCount) *wire.CryptoFrame {
-					f = &wire.CryptoFrame{Offset: 0x1337}
-					f.Data = bytes.Repeat([]byte{'f'}, int(size-f.Length(protocol.Version1)-1))
-					Expect(f.Length(protocol.Version1)).To(Equal(size))
-					return f
-				})
+				handshakeStream.Write(bytes.Repeat([]byte{'f'}, 2000))
 				p, err := packer.PackCoalescedPacket(false, maxPacketSize, protocol.Version1)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(p.longHdrPackets).To(HaveLen(1))
@@ -965,14 +946,8 @@ var _ = Describe("Packet packer", func() {
 				sealingManager.EXPECT().Get1RTTSealer().Return(nil, handshake.ErrKeysNotYetAvailable)
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionInitial, false)
 				// don't EXPECT any calls for a Handshake ACK frame
-				initialStream.EXPECT().HasData().Return(true).Times(2)
-				initialStream.EXPECT().PopCryptoFrame(gomock.Any()).DoAndReturn(func(size protocol.ByteCount) *wire.CryptoFrame {
-					return &wire.CryptoFrame{Offset: 0x42, Data: []byte("initial")}
-				})
-				handshakeStream.EXPECT().HasData().Return(true).Times(2)
-				handshakeStream.EXPECT().PopCryptoFrame(gomock.Any()).DoAndReturn(func(size protocol.ByteCount) *wire.CryptoFrame {
-					return &wire.CryptoFrame{Offset: 0x1337, Data: []byte("handshake")}
-				})
+				initialStream.Write([]byte("initial"))
+				handshakeStream.Write([]byte("handshake"))
 				p, err := packer.PackCoalescedPacket(false, maxPacketSize, protocol.Version1)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(p.buffer.Len()).To(BeEquivalentTo(maxPacketSize))
@@ -1001,11 +976,7 @@ var _ = Describe("Packet packer", func() {
 				sealingManager.EXPECT().Get1RTTSealer().Return(nil, handshake.ErrKeysNotYetAvailable)
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionInitial, false)
 				// don't EXPECT any calls for a Handshake ACK frame
-				initialStream.EXPECT().HasData().Return(true).Times(2)
-				initialStream.EXPECT().PopCryptoFrame(gomock.Any()).DoAndReturn(func(size protocol.ByteCount) *wire.CryptoFrame {
-					return &wire.CryptoFrame{Offset: 0x42, Data: []byte("initial")}
-				})
-				handshakeStream.EXPECT().HasData()
+				initialStream.Write([]byte("initial"))
 				packer.retransmissionQueue.addHandshake(&wire.PingFrame{})
 				p, err := packer.PackCoalescedPacket(false, maxPacketSize, protocol.Version1)
 				Expect(err).ToNot(HaveOccurred())
@@ -1034,8 +1005,6 @@ var _ = Describe("Packet packer", func() {
 				sealingManager.EXPECT().GetHandshakeSealer().Return(getSealer(), nil)
 				sealingManager.EXPECT().Get1RTTSealer().Return(nil, handshake.ErrKeysNotYetAvailable)
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionInitial, gomock.Any())
-				initialStream.EXPECT().HasData()
-				handshakeStream.EXPECT().HasData()
 				packer.retransmissionQueue.addInitial(&wire.PingFrame{})
 				packer.retransmissionQueue.addHandshake(&wire.PingFrame{})
 				p, err := packer.PackCoalescedPacket(false, maxPacketSize, protocol.Version1)
@@ -1064,10 +1033,7 @@ var _ = Describe("Packet packer", func() {
 				sealingManager.EXPECT().GetHandshakeSealer().Return(nil, handshake.ErrKeysNotYetAvailable)
 				sealingManager.EXPECT().Get1RTTSealer().Return(getSealer(), nil)
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionInitial, false)
-				initialStream.EXPECT().HasData().Return(true).Times(2)
-				initialStream.EXPECT().PopCryptoFrame(gomock.Any()).DoAndReturn(func(size protocol.ByteCount) *wire.CryptoFrame {
-					return &wire.CryptoFrame{Offset: 0x42, Data: []byte("initial")}
-				})
+				initialStream.Write([]byte("initial"))
 				expectAppendControlFrames()
 				expectAppendStreamFrames()
 				framer.EXPECT().HasData().Return(true)
@@ -1103,10 +1069,7 @@ var _ = Describe("Packet packer", func() {
 				framer.EXPECT().HasData().Return(true)
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionInitial, false)
 				// don't EXPECT any calls for a Handshake ACK frame
-				initialStream.EXPECT().HasData().Return(true).Times(2)
-				initialStream.EXPECT().PopCryptoFrame(gomock.Any()).DoAndReturn(func(size protocol.ByteCount) *wire.CryptoFrame {
-					return &wire.CryptoFrame{Offset: 0x42, Data: []byte("initial")}
-				})
+				initialStream.Write([]byte("initial"))
 				expectAppendControlFrames()
 				expectAppendStreamFrames(ackhandler.StreamFrame{Frame: &wire.StreamFrame{Data: []byte("foobar")}})
 				p, err := packer.PackCoalescedPacket(false, maxPacketSize, protocol.Version1)
@@ -1140,10 +1103,7 @@ var _ = Describe("Packet packer", func() {
 				framer.EXPECT().HasData().Return(true)
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionHandshake, false)
 				// don't EXPECT any calls for a 1-RTT ACK frame
-				handshakeStream.EXPECT().HasData().Return(true).Times(2)
-				handshakeStream.EXPECT().PopCryptoFrame(gomock.Any()).DoAndReturn(func(size protocol.ByteCount) *wire.CryptoFrame {
-					return &wire.CryptoFrame{Offset: 0x1337, Data: []byte("handshake")}
-				})
+				handshakeStream.Write([]byte("handshake"))
 				expectAppendControlFrames()
 				expectAppendStreamFrames(ackhandler.StreamFrame{Frame: &wire.StreamFrame{Data: []byte("foobar")}})
 				p, err := packer.PackCoalescedPacket(false, maxPacketSize, protocol.Version1)
@@ -1171,20 +1131,16 @@ var _ = Describe("Packet packer", func() {
 				sealingManager.EXPECT().GetHandshakeSealer().Return(getSealer(), nil)
 				// don't EXPECT any calls to GetHandshakeSealer and Get1RTTSealer
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionHandshake, false)
-				handshakeStream.EXPECT().HasData().Return(true).Times(2)
-				handshakeStream.EXPECT().PopCryptoFrame(gomock.Any()).DoAndReturn(func(size protocol.ByteCount) *wire.CryptoFrame {
-					s := size - protocol.MinCoalescedPacketSize
-					f := &wire.CryptoFrame{Offset: 0x1337}
-					f.Data = bytes.Repeat([]byte{'f'}, int(s-f.Length(protocol.Version1)-1))
-					Expect(f.Length(protocol.Version1)).To(Equal(s))
-					return f
-				})
+				handshakeStream.Write(bytes.Repeat([]byte{'f'}, int(maxPacketSize-protocol.MinCoalescedPacketSize-(&wire.CryptoFrame{}).Length(protocol.Version1))))
 				p, err := packer.PackCoalescedPacket(false, maxPacketSize, protocol.Version1)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(p.longHdrPackets).To(HaveLen(1))
 				Expect(p.shortHdrPacket).To(BeNil())
 				Expect(p.longHdrPackets[0].EncryptionLevel()).To(Equal(protocol.EncryptionHandshake))
-				Expect(len(p.buffer.Data)).To(BeEquivalentTo(maxPacketSize - protocol.MinCoalescedPacketSize))
+				Expect(len(p.buffer.Data)).To(And(
+					BeNumerically("<", maxPacketSize),
+					BeNumerically(">", maxPacketSize-protocol.MinCoalescedPacketSize),
+				))
 				parsePacket(p.buffer.Data)
 			})
 
@@ -1196,7 +1152,6 @@ var _ = Describe("Packet packer", func() {
 				sealingManager.EXPECT().GetHandshakeSealer().Return(sealer, nil)
 				sealingManager.EXPECT().Get1RTTSealer().Return(nil, handshake.ErrKeysNotYetAvailable)
 				packer.retransmissionQueue.addHandshake(&wire.PingFrame{})
-				handshakeStream.EXPECT().HasData()
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionHandshake, false)
 				packet, err := packer.PackCoalescedPacket(false, maxPacketSize, protocol.Version1)
 				Expect(err).ToNot(HaveOccurred())
@@ -1235,7 +1190,6 @@ var _ = Describe("Packet packer", func() {
 				sealingManager.EXPECT().GetHandshakeSealer().Return(nil, handshake.ErrKeysNotYetAvailable)
 				sealingManager.EXPECT().Get1RTTSealer().Return(nil, handshake.ErrKeysNotYetAvailable)
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionInitial, false)
-				initialStream.EXPECT().HasData()
 				p, err := packer.PackCoalescedPacket(false, maxPacketSize, protocol.Version1)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(p.longHdrPackets).To(HaveLen(1))
@@ -1248,7 +1202,6 @@ var _ = Describe("Packet packer", func() {
 			It("sends an Initial packet containing only an ACK", func() {
 				ack := &wire.AckFrame{AckRanges: []wire.AckRange{{Smallest: 10, Largest: 20}}}
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionInitial, true).Return(ack)
-				initialStream.EXPECT().HasData().Times(2)
 				sealingManager.EXPECT().GetInitialSealer().Return(getSealer(), nil)
 				sealingManager.EXPECT().GetHandshakeSealer().Return(nil, handshake.ErrKeysNotYetAvailable)
 				sealingManager.EXPECT().Get1RTTSealer().Return(nil, handshake.ErrKeysNotYetAvailable)
@@ -1264,7 +1217,6 @@ var _ = Describe("Packet packer", func() {
 				sealingManager.EXPECT().GetInitialSealer().Return(getSealer(), nil)
 				sealingManager.EXPECT().GetHandshakeSealer().Return(nil, handshake.ErrKeysNotYetAvailable)
 				sealingManager.EXPECT().Get1RTTSealer().Return(nil, handshake.ErrKeysNotYetAvailable)
-				initialStream.EXPECT().HasData()
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionInitial, true)
 				p, err := packer.PackCoalescedPacket(false, maxPacketSize, protocol.Version1)
 				Expect(err).ToNot(HaveOccurred())
@@ -1275,8 +1227,6 @@ var _ = Describe("Packet packer", func() {
 				ack := &wire.AckFrame{AckRanges: []wire.AckRange{{Smallest: 10, Largest: 20}}}
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionInitial, true)
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionHandshake, true).Return(ack)
-				initialStream.EXPECT().HasData()
-				handshakeStream.EXPECT().HasData().Times(2)
 				sealingManager.EXPECT().GetInitialSealer().Return(getSealer(), nil)
 				sealingManager.EXPECT().GetHandshakeSealer().Return(getSealer(), nil)
 				sealingManager.EXPECT().Get1RTTSealer().Return(nil, handshake.ErrKeysNotYetAvailable)
@@ -1294,7 +1244,6 @@ var _ = Describe("Packet packer", func() {
 				It(fmt.Sprintf("pads Initial packets to the required minimum packet size, for the %s", perspective), func() {
 					token := []byte("initial token")
 					packer.SetToken(token)
-					f := &wire.CryptoFrame{Data: []byte("foobar")}
 					pnManager.EXPECT().PeekPacketNumber(protocol.EncryptionInitial).Return(protocol.PacketNumber(0x42), protocol.PacketNumberLen2)
 					pnManager.EXPECT().PopPacketNumber(protocol.EncryptionInitial).Return(protocol.PacketNumber(0x42))
 					sealingManager.EXPECT().GetInitialSealer().Return(getSealer(), nil)
@@ -1302,8 +1251,7 @@ var _ = Describe("Packet packer", func() {
 					sealingManager.EXPECT().Get0RTTSealer().Return(nil, handshake.ErrKeysNotYetAvailable)
 					sealingManager.EXPECT().Get1RTTSealer().Return(nil, handshake.ErrKeysNotYetAvailable)
 					ackFramer.EXPECT().GetAckFrame(protocol.EncryptionInitial, false)
-					initialStream.EXPECT().HasData().Return(true).Times(2)
-					initialStream.EXPECT().PopCryptoFrame(gomock.Any()).Return(f)
+					initialStream.Write([]byte("foobar"))
 					packer.perspective = protocol.PerspectiveClient
 					p, err := packer.PackCoalescedPacket(false, maxPacketSize, protocol.Version1)
 					Expect(err).ToNot(HaveOccurred())
@@ -1318,7 +1266,6 @@ var _ = Describe("Packet packer", func() {
 			}
 
 			It("adds an ACK frame", func() {
-				f := &wire.CryptoFrame{Data: []byte("foobar")}
 				ack := &wire.AckFrame{AckRanges: []wire.AckRange{{Smallest: 42, Largest: 1337}}}
 				pnManager.EXPECT().PeekPacketNumber(protocol.EncryptionInitial).Return(protocol.PacketNumber(0x42), protocol.PacketNumberLen2)
 				pnManager.EXPECT().PopPacketNumber(protocol.EncryptionInitial).Return(protocol.PacketNumber(0x42))
@@ -1327,8 +1274,7 @@ var _ = Describe("Packet packer", func() {
 				sealingManager.EXPECT().Get0RTTSealer().Return(nil, handshake.ErrKeysNotYetAvailable)
 				sealingManager.EXPECT().Get1RTTSealer().Return(nil, handshake.ErrKeysNotYetAvailable)
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionInitial, false).Return(ack)
-				initialStream.EXPECT().HasData().Return(true).Times(2)
-				initialStream.EXPECT().PopCryptoFrame(gomock.Any()).Return(f)
+				initialStream.Write([]byte("foobar"))
 				packer.perspective = protocol.PerspectiveClient
 				p, err := packer.PackCoalescedPacket(false, maxPacketSize, protocol.Version1)
 				Expect(err).ToNot(HaveOccurred())
@@ -1349,7 +1295,6 @@ var _ = Describe("Packet packer", func() {
 					retransmissionQueue.addInitial(f)
 					sealingManager.EXPECT().GetInitialSealer().Return(getSealer(), nil)
 					ackFramer.EXPECT().GetAckFrame(protocol.EncryptionInitial, false)
-					initialStream.EXPECT().HasData()
 					pnManager.EXPECT().PeekPacketNumber(protocol.EncryptionInitial).Return(protocol.PacketNumber(0x42), protocol.PacketNumberLen2)
 					pnManager.EXPECT().PopPacketNumber(protocol.EncryptionInitial).Return(protocol.PacketNumber(0x42))
 
@@ -1371,7 +1316,6 @@ var _ = Describe("Packet packer", func() {
 					retransmissionQueue.addInitial(&wire.PingFrame{})
 					sealingManager.EXPECT().GetInitialSealer().Return(getSealer(), nil)
 					ackFramer.EXPECT().GetAckFrame(protocol.EncryptionInitial, false)
-					initialStream.EXPECT().HasData()
 					pnManager.EXPECT().PeekPacketNumber(protocol.EncryptionInitial).Return(protocol.PacketNumber(0x42), protocol.PacketNumberLen1)
 					pnManager.EXPECT().PopPacketNumber(protocol.EncryptionInitial).Return(protocol.PacketNumber(0x42))
 
@@ -1395,7 +1339,6 @@ var _ = Describe("Packet packer", func() {
 				retransmissionQueue.addHandshake(f)
 				sealingManager.EXPECT().GetHandshakeSealer().Return(getSealer(), nil)
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionHandshake, false)
-				handshakeStream.EXPECT().HasData()
 				pnManager.EXPECT().PeekPacketNumber(protocol.EncryptionHandshake).Return(protocol.PacketNumber(0x42), protocol.PacketNumberLen2)
 				pnManager.EXPECT().PopPacketNumber(protocol.EncryptionHandshake).Return(protocol.PacketNumber(0x42))
 
@@ -1416,7 +1359,6 @@ var _ = Describe("Packet packer", func() {
 				retransmissionQueue.addHandshake(f)
 				sealingManager.EXPECT().GetHandshakeSealer().Return(getSealer(), nil)
 				ackFramer.EXPECT().GetAckFrame(protocol.EncryptionHandshake, false)
-				handshakeStream.EXPECT().HasData()
 				pnManager.EXPECT().PeekPacketNumber(protocol.EncryptionHandshake).Return(protocol.PacketNumber(0x42), protocol.PacketNumberLen2)
 				pnManager.EXPECT().PopPacketNumber(protocol.EncryptionHandshake).Return(protocol.PacketNumber(0x42))
 
diff --git a/qlog/connection_tracer_test.go b/qlog/connection_tracer_test.go
index a6207c77..ef21904f 100644
--- a/qlog/connection_tracer_test.go
+++ b/qlog/connection_tracer_test.go
@@ -2,7 +2,6 @@ package qlog
 
 import (
 	"bytes"
-	"encoding/json"
 	"io"
 	"net"
 	"net/netip"
@@ -36,7 +35,7 @@ func exportAndParse(buf *bytes.Buffer) []entry {
 	m := make(map[string]interface{})
 	line, err := buf.ReadBytes('\n')
 	Expect(err).ToNot(HaveOccurred())
-	Expect(json.Unmarshal(line, &m)).To(Succeed())
+	Expect(unmarshal(line, &m)).To(Succeed())
 	Expect(m).To(HaveKey("trace"))
 	var entries []entry
 	trace := m["trace"].(map[string]interface{})
@@ -50,7 +49,7 @@ func exportAndParse(buf *bytes.Buffer) []entry {
 		line, err := buf.ReadBytes('\n')
 		Expect(err).ToNot(HaveOccurred())
 		ev := make(map[string]interface{})
-		Expect(json.Unmarshal(line, &ev)).To(Succeed())
+		Expect(unmarshal(line, &ev)).To(Succeed())
 		Expect(ev).To(HaveLen(3))
 		Expect(ev).To(HaveKey("time"))
 		Expect(ev).To(HaveKey("name"))
@@ -89,8 +88,8 @@ var _ = Describe("Tracing", func() {
 		tracer.Close()
 
 		m := make(map[string]interface{})
-		Expect(json.Unmarshal(buf.Bytes(), &m)).To(Succeed())
-		Expect(m).To(HaveKeyWithValue("qlog_version", "draft-02"))
+		Expect(unmarshal(buf.Bytes(), &m)).To(Succeed())
+		Expect(m).To(HaveKeyWithValue("qlog_version", "0.3"))
 		Expect(m).To(HaveKey("title"))
 		Expect(m).To(HaveKey("trace"))
 		trace := m["trace"].(map[string]interface{})
@@ -141,7 +140,7 @@ var _ = Describe("Tracing", func() {
 		})
 
 		It("records the version, if version negotiation happened", func() {
-			tracer.NegotiatedVersion(0x1337, []logging.VersionNumber{1, 2, 3}, []logging.VersionNumber{4, 5, 6})
+			tracer.NegotiatedVersion(0x1337, []logging.Version{1, 2, 3}, []logging.Version{4, 5, 6})
 			tracer.Close()
 			entry := exportAndParseSingle(buf)
 			Expect(entry.Time).To(BeTemporally("~", time.Now(), scaleDuration(10*time.Millisecond)))
diff --git a/qlog/qlog_suite_test.go b/qlog/qlog_suite_test.go
index 53ebb363..984211da 100644
--- a/qlog/qlog_suite_test.go
+++ b/qlog/qlog_suite_test.go
@@ -26,6 +26,13 @@ func scaleDuration(t time.Duration) time.Duration {
 	return time.Duration(scaleFactor) * t
 }
 
+func unmarshal(data []byte, v interface{}) error {
+	if data[0] == recordSeparator {
+		data = data[1:]
+	}
+	return json.Unmarshal(data, v)
+}
+
 func checkEncoding(data []byte, expected map[string]interface{}) {
 	// unmarshal the data
 	m := make(map[string]interface{})
diff --git a/qlog/tracer_test.go b/qlog/tracer_test.go
index 8aa8faa5..04e79b65 100644
--- a/qlog/tracer_test.go
+++ b/qlog/tracer_test.go
@@ -2,7 +2,6 @@ package qlog
 
 import (
 	"bytes"
-	"encoding/json"
 	"net"
 	"time"
 
@@ -28,12 +27,12 @@ var _ = Describe("Tracing", func() {
 		tracer.Close()
 
 		m := make(map[string]interface{})
-		Expect(json.Unmarshal(buf.Bytes(), &m)).To(Succeed())
-		Expect(m).To(HaveKeyWithValue("qlog_version", "draft-02"))
+		Expect(unmarshal(buf.Bytes(), &m)).To(Succeed())
+		Expect(m).To(HaveKeyWithValue("qlog_version", "0.3"))
 		Expect(m).To(HaveKey("title"))
 		Expect(m).To(HaveKey("trace"))
 		trace := m["trace"].(map[string]interface{})
-		Expect(trace).To(HaveKey(("common_fields")))
+		Expect(trace).To(HaveKey("common_fields"))
 		commonFields := trace["common_fields"].(map[string]interface{})
 		Expect(commonFields).ToNot(HaveKey("ODCID"))
 		Expect(commonFields).ToNot(HaveKey("group_id"))
diff --git a/quicvarint/varint_test.go b/quicvarint/varint_test.go
index 104b0e62..6665f2ec 100644
--- a/quicvarint/varint_test.go
+++ b/quicvarint/varint_test.go
@@ -313,3 +313,24 @@ func benchmarkParse(b *testing.B, inputs []benchmarkValue) {
 		}
 	}
 }
+
+func BenchmarkAppend(b *testing.B) {
+	b.Run("1-byte", func(b *testing.B) { benchmarkAppend(b, randomValues(min(b.N, 1024), maxVarInt1)) })
+	b.Run("2-byte", func(b *testing.B) { benchmarkAppend(b, randomValues(min(b.N, 1024), maxVarInt2)) })
+	b.Run("4-byte", func(b *testing.B) { benchmarkAppend(b, randomValues(min(b.N, 1024), maxVarInt4)) })
+	b.Run("8-byte", func(b *testing.B) { benchmarkAppend(b, randomValues(min(b.N, 1024), maxVarInt8)) })
+}
+
+func benchmarkAppend(b *testing.B, inputs []benchmarkValue) {
+	buf := make([]byte, 8)
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		buf = buf[:0]
+		index := i % 1024
+		buf = Append(buf, inputs[index].v)
+
+		if !bytes.Equal(buf, inputs[index].b) {
+			b.Fatalf("expected to write %v, wrote %v", inputs[i].b, buf)
+		}
+	}
+}
diff --git a/receive_stream_test.go b/receive_stream_test.go
index 605ec147..a01931aa 100644
--- a/receive_stream_test.go
+++ b/receive_stream_test.go
@@ -49,8 +49,7 @@ var _ = Describe("Receive Stream", func() {
 				Offset: 0,
 				Data:   []byte{0xDE, 0xAD, 0xBE, 0xEF},
 			}
-			err := str.handleStreamFrame(&frame)
-			Expect(err).ToNot(HaveOccurred())
+			Expect(str.handleStreamFrame(&frame)).To(Succeed())
 			b := make([]byte, 4)
 			n, err := strWithTimeout.Read(b)
 			Expect(err).ToNot(HaveOccurred())
@@ -66,8 +65,7 @@ var _ = Describe("Receive Stream", func() {
 				Offset: 0,
 				Data:   []byte{0xDE, 0xAD, 0xBE, 0xEF},
 			}
-			err := str.handleStreamFrame(&frame)
-			Expect(err).ToNot(HaveOccurred())
+			Expect(str.handleStreamFrame(&frame)).To(Succeed())
 			b := make([]byte, 2)
 			n, err := strWithTimeout.Read(b)
 			Expect(err).ToNot(HaveOccurred())
@@ -79,6 +77,25 @@ var _ = Describe("Receive Stream", func() {
 			Expect(b).To(Equal([]byte{0xBE, 0xEF}))
 		})
 
+		It("queues a flow control update", func() {
+			mockFC.EXPECT().UpdateHighestReceived(protocol.ByteCount(4), false)
+			mockFC.EXPECT().AddBytesRead(protocol.ByteCount(3)).Return(true)
+			frame := wire.StreamFrame{
+				Offset: 0,
+				Data:   []byte{0xde, 0xad, 0xbe, 0xef},
+			}
+			Expect(str.handleStreamFrame(&frame)).To(Succeed())
+			mockSender.EXPECT().onHasStreamControlFrame(streamID, str)
+			n, err := strWithTimeout.Read(make([]byte, 3))
+			Expect(err).ToNot(HaveOccurred())
+			Expect(n).To(Equal(3))
+			mockFC.EXPECT().GetWindowUpdate().Return(protocol.ByteCount(1337))
+			f, ok, hasMore := str.getControlFrame()
+			Expect(ok).To(BeTrue())
+			Expect(f.Frame).To(Equal(&wire.MaxStreamDataFrame{StreamID: streamID, MaximumStreamData: 1337}))
+			Expect(hasMore).To(BeFalse())
+		})
+
 		It("reads all data available", func() {
 			mockFC.EXPECT().UpdateHighestReceived(protocol.ByteCount(2), false)
 			mockFC.EXPECT().UpdateHighestReceived(protocol.ByteCount(4), false)
@@ -477,7 +494,7 @@ var _ = Describe("Receive Stream", func() {
 	Context("stream cancellations", func() {
 		Context("canceling read", func() {
 			It("unblocks Read", func() {
-				mockSender.EXPECT().queueControlFrame(gomock.Any())
+				mockSender.EXPECT().onHasStreamControlFrame(gomock.Any(), gomock.Any())
 				done := make(chan struct{})
 				go func() {
 					defer GinkgoRecover()
@@ -495,7 +512,7 @@ var _ = Describe("Receive Stream", func() {
 			})
 
 			It("doesn't allow further calls to Read", func() {
-				mockSender.EXPECT().queueControlFrame(gomock.Any())
+				mockSender.EXPECT().onHasStreamControlFrame(gomock.Any(), gomock.Any())
 				str.CancelRead(1234)
 				_, err := strWithTimeout.Read([]byte{0})
 				Expect(err).To(Equal(&StreamError{
@@ -506,7 +523,7 @@ var _ = Describe("Receive Stream", func() {
 			})
 
 			It("does nothing when CancelRead is called twice", func() {
-				mockSender.EXPECT().queueControlFrame(gomock.Any())
+				mockSender.EXPECT().onHasStreamControlFrame(gomock.Any(), gomock.Any())
 				str.CancelRead(1234)
 				str.CancelRead(1234)
 				_, err := strWithTimeout.Read([]byte{0})
@@ -518,11 +535,15 @@ var _ = Describe("Receive Stream", func() {
 			})
 
 			It("queues a STOP_SENDING frame", func() {
-				mockSender.EXPECT().queueControlFrame(&wire.StopSendingFrame{
+				mockSender.EXPECT().onHasStreamControlFrame(gomock.Any(), gomock.Any())
+				str.CancelRead(1234)
+				f, ok, hasMore := str.getControlFrame()
+				Expect(ok).To(BeTrue())
+				Expect(f.Frame).To(Equal(&wire.StopSendingFrame{
 					StreamID:  streamID,
 					ErrorCode: 1234,
-				})
-				str.CancelRead(1234)
+				}))
+				Expect(hasMore).To(BeFalse())
 			})
 
 			It("doesn't send a STOP_SENDING frame, if the FIN was already read", func() {
@@ -568,7 +589,7 @@ var _ = Describe("Receive Stream", func() {
 					Fin:  true,
 				})).To(Succeed())
 				mockFC.EXPECT().Abandon()
-				mockSender.EXPECT().queueControlFrame(gomock.Any())
+				mockSender.EXPECT().onHasStreamControlFrame(gomock.Any(), gomock.Any())
 				mockSender.EXPECT().onStreamCompleted(streamID)
 				str.CancelRead(1234)
 				// read the error
@@ -578,7 +599,7 @@ var _ = Describe("Receive Stream", func() {
 			})
 
 			It("completes the stream when receiving the Fin after the stream was canceled", func() {
-				mockSender.EXPECT().queueControlFrame(gomock.Any())
+				mockSender.EXPECT().onHasStreamControlFrame(gomock.Any(), gomock.Any())
 				str.CancelRead(1234)
 				gomock.InOrder(
 					mockFC.EXPECT().UpdateHighestReceived(protocol.ByteCount(1000), true),
@@ -592,7 +613,7 @@ var _ = Describe("Receive Stream", func() {
 			})
 
 			It("handles duplicate FinBits after the stream was canceled", func() {
-				mockSender.EXPECT().queueControlFrame(gomock.Any())
+				mockSender.EXPECT().onHasStreamControlFrame(gomock.Any(), gomock.Any())
 				str.CancelRead(1234)
 				gomock.InOrder(
 					mockFC.EXPECT().UpdateHighestReceived(protocol.ByteCount(1000), true),
@@ -692,7 +713,7 @@ var _ = Describe("Receive Stream", func() {
 
 			It("handles RESET_STREAM after CancelRead", func() {
 				mockFC.EXPECT().Abandon()
-				mockSender.EXPECT().queueControlFrame(gomock.Any())
+				mockSender.EXPECT().onHasStreamControlFrame(gomock.Any(), gomock.Any())
 				str.CancelRead(1234)
 				mockFC.EXPECT().UpdateHighestReceived(protocol.ByteCount(42), true)
 				mockSender.EXPECT().onStreamCompleted(streamID)
@@ -708,21 +729,14 @@ var _ = Describe("Receive Stream", func() {
 		})
 	})
 
-	Context("flow control", func() {
-		It("errors when a STREAM frame causes a flow control violation", func() {
-			testErr := errors.New("flow control violation")
-			mockFC.EXPECT().UpdateHighestReceived(protocol.ByteCount(8), false).Return(testErr)
-			frame := wire.StreamFrame{
-				Offset: 2,
-				Data:   []byte("foobar"),
-			}
-			err := str.handleStreamFrame(&frame)
-			Expect(err).To(MatchError(testErr))
-		})
-
-		It("gets a window update", func() {
-			mockFC.EXPECT().GetWindowUpdate().Return(protocol.ByteCount(0x100))
-			Expect(str.getWindowUpdate()).To(Equal(protocol.ByteCount(0x100)))
-		})
+	It("errors when a STREAM frame causes a flow control violation", func() {
+		testErr := errors.New("flow control violation")
+		mockFC.EXPECT().UpdateHighestReceived(protocol.ByteCount(8), false).Return(testErr)
+		frame := wire.StreamFrame{
+			Offset: 2,
+			Data:   []byte("foobar"),
+		}
+		err := str.handleStreamFrame(&frame)
+		Expect(err).To(MatchError(testErr))
 	})
 })
diff --git a/send_stream_test.go b/send_stream_test.go
index 4c8df1c4..6279e9ff 100644
--- a/send_stream_test.go
+++ b/send_stream_test.go
@@ -328,10 +328,6 @@ var _ = Describe("Send Stream", func() {
 				mockFC.EXPECT().AddBytesSent(protocol.ByteCount(3))
 				mockFC.EXPECT().SendWindowSize().Return(protocol.ByteCount(0))
 				mockFC.EXPECT().IsNewlyBlocked().Return(true)
-				mockSender.EXPECT().queueControlFrame(&wire.StreamDataBlockedFrame{
-					StreamID:          streamID,
-					MaximumStreamData: 3,
-				})
 				done := make(chan struct{})
 				go func() {
 					defer GinkgoRecover()
@@ -345,9 +341,17 @@ var _ = Describe("Send Stream", func() {
 				Expect(ok).To(BeTrue())
 				Expect(hasMoreData).To(BeTrue())
 				Expect(f.Frame.Data).To(HaveLen(3))
+				mockSender.EXPECT().onHasStreamControlFrame(gomock.Any(), gomock.Any())
 				_, ok, hasMoreData = str.popStreamFrame(1000, protocol.Version1)
 				Expect(ok).To(BeFalse())
 				Expect(hasMoreData).To(BeFalse())
+				cf, ok, hasMore := str.getControlFrame()
+				Expect(ok).To(BeTrue())
+				Expect(cf.Frame).To(Equal(&wire.StreamDataBlockedFrame{
+					StreamID:          streamID,
+					MaximumStreamData: 3,
+				}))
+				Expect(hasMore).To(BeFalse())
 				// make the Write go routine return
 				str.closeForShutdown(nil)
 				Eventually(done).Should(BeClosed())
@@ -686,22 +690,26 @@ var _ = Describe("Send Stream", func() {
 		Context("canceling writing", func() {
 			It("queues a RESET_STREAM frame", func() {
 				gomock.InOrder(
-					mockSender.EXPECT().queueControlFrame(&wire.ResetStreamFrame{
-						StreamID:  streamID,
-						FinalSize: 1234,
-						ErrorCode: 9876,
-					}),
+					mockSender.EXPECT().onHasStreamControlFrame(streamID, gomock.Any()),
 					mockSender.EXPECT().onStreamCompleted(streamID),
 				)
 				str.writeOffset = 1234
 				str.CancelWrite(9876)
+				cf, ok, hasMore := str.getControlFrame()
+				Expect(ok).To(BeTrue())
+				Expect(cf.Frame).To(Equal(&wire.ResetStreamFrame{
+					StreamID:  streamID,
+					FinalSize: 1234,
+					ErrorCode: 9876,
+				}))
+				Expect(hasMore).To(BeFalse())
 			})
 
 			// This test is inherently racy, as it tests a concurrent call to Write() and CancelRead().
 			// A single successful run of this test therefore doesn't mean a lot,
 			// for reliable results it has to be run many times.
 			It("returns a nil error when the whole slice has been sent out", func() {
-				mockSender.EXPECT().queueControlFrame(gomock.Any()).MaxTimes(1)
+				mockSender.EXPECT().onHasStreamControlFrame(gomock.Any(), gomock.Any()).MaxTimes(1)
 				mockSender.EXPECT().onHasStreamData(streamID, str).MaxTimes(1)
 				mockSender.EXPECT().onStreamCompleted(streamID).MaxTimes(1)
 				mockFC.EXPECT().SendWindowSize().Return(protocol.MaxByteCount).MaxTimes(1)
@@ -724,7 +732,7 @@ var _ = Describe("Send Stream", func() {
 			})
 
 			It("unblocks Write", func() {
-				mockSender.EXPECT().queueControlFrame(gomock.Any())
+				mockSender.EXPECT().onHasStreamControlFrame(gomock.Any(), gomock.Any())
 				mockSender.EXPECT().onHasStreamData(streamID, str)
 				mockFC.EXPECT().SendWindowSize().Return(protocol.MaxByteCount)
 				mockFC.EXPECT().AddBytesSent(gomock.Any())
@@ -752,7 +760,7 @@ var _ = Describe("Send Stream", func() {
 			})
 
 			It("doesn't pop STREAM frames after being canceled", func() {
-				mockSender.EXPECT().queueControlFrame(gomock.Any())
+				mockSender.EXPECT().onHasStreamControlFrame(gomock.Any(), gomock.Any())
 				mockSender.EXPECT().onHasStreamData(streamID, str)
 				mockFC.EXPECT().SendWindowSize().Return(protocol.MaxByteCount)
 				mockFC.EXPECT().AddBytesSent(gomock.Any())
@@ -776,7 +784,7 @@ var _ = Describe("Send Stream", func() {
 			})
 
 			It("doesn't pop STREAM frames after being canceled, for large writes", func() {
-				mockSender.EXPECT().queueControlFrame(gomock.Any())
+				mockSender.EXPECT().onHasStreamControlFrame(gomock.Any(), gomock.Any())
 				mockSender.EXPECT().onHasStreamData(streamID, str)
 				mockFC.EXPECT().SendWindowSize().Return(protocol.MaxByteCount)
 				mockFC.EXPECT().AddBytesSent(gomock.Any())
@@ -805,7 +813,7 @@ var _ = Describe("Send Stream", func() {
 			})
 
 			It("ignores acknowledgements for STREAM frames after it was cancelled", func() {
-				mockSender.EXPECT().queueControlFrame(gomock.Any())
+				mockSender.EXPECT().onHasStreamControlFrame(gomock.Any(), gomock.Any())
 				mockSender.EXPECT().onHasStreamData(streamID, str)
 				mockFC.EXPECT().SendWindowSize().Return(protocol.MaxByteCount)
 				mockFC.EXPECT().AddBytesSent(gomock.Any())
@@ -826,7 +834,7 @@ var _ = Describe("Send Stream", func() {
 			})
 
 			It("cancels the context", func() {
-				mockSender.EXPECT().queueControlFrame(gomock.Any())
+				mockSender.EXPECT().onHasStreamControlFrame(gomock.Any(), gomock.Any())
 				mockSender.EXPECT().onStreamCompleted(gomock.Any())
 				Expect(str.Context().Done()).ToNot(BeClosed())
 				str.CancelWrite(1234)
@@ -836,7 +844,7 @@ var _ = Describe("Send Stream", func() {
 			})
 
 			It("doesn't allow further calls to Write", func() {
-				mockSender.EXPECT().queueControlFrame(gomock.Any())
+				mockSender.EXPECT().onHasStreamControlFrame(gomock.Any(), gomock.Any())
 				mockSender.EXPECT().onStreamCompleted(gomock.Any())
 				str.CancelWrite(1234)
 				_, err := strWithTimeout.Write([]byte("foobar"))
@@ -848,43 +856,54 @@ var _ = Describe("Send Stream", func() {
 			})
 
 			It("only cancels once", func() {
-				mockSender.EXPECT().queueControlFrame(&wire.ResetStreamFrame{StreamID: streamID, ErrorCode: 1234})
+				mockSender.EXPECT().onHasStreamControlFrame(streamID, gomock.Any())
 				mockSender.EXPECT().onStreamCompleted(gomock.Any())
 				str.CancelWrite(1234)
 				str.CancelWrite(4321)
+				cf, ok, hasMore := str.getControlFrame()
+				Expect(ok).To(BeTrue())
+				Expect(cf.Frame).To(Equal(&wire.ResetStreamFrame{StreamID: streamID, ErrorCode: 1234}))
+				Expect(hasMore).To(BeFalse())
+				cf, ok, hasMore = str.getControlFrame()
+				Expect(ok).To(BeFalse())
+				Expect(cf.Frame).To(BeNil())
+				Expect(hasMore).To(BeFalse())
 			})
 
 			It("queues a RESET_STREAM frame, even if the stream was already closed", func() {
 				mockSender.EXPECT().onHasStreamData(streamID, str)
-				mockSender.EXPECT().queueControlFrame(gomock.Any()).Do(func(f wire.Frame) {
-					Expect(f).To(BeAssignableToTypeOf(&wire.ResetStreamFrame{}))
-				})
+				mockSender.EXPECT().onHasStreamControlFrame(streamID, str)
 				mockSender.EXPECT().onStreamCompleted(gomock.Any())
 				Expect(str.Close()).To(Succeed())
 				// don't EXPECT any calls to queueControlFrame
 				str.CancelWrite(123)
+				f, ok, hasMore := str.getControlFrame()
+				Expect(ok).To(BeTrue())
+				Expect(f.Frame).To(BeAssignableToTypeOf(&wire.ResetStreamFrame{}))
+				Expect(hasMore).To(BeFalse())
 			})
 		})
 
 		Context("receiving STOP_SENDING frames", func() {
 			It("queues a RESET_STREAM frames, and copies the error code from the STOP_SENDING frame", func() {
-				mockSender.EXPECT().queueControlFrame(&wire.ResetStreamFrame{
-					StreamID:  streamID,
-					ErrorCode: 101,
-				})
+				mockSender.EXPECT().onHasStreamControlFrame(streamID, str)
 				// Don't EXPECT calls to onStreamCompleted.
 				// The application needs to learn about the cancellation first.
 				str.handleStopSendingFrame(&wire.StopSendingFrame{
 					StreamID:  streamID,
 					ErrorCode: 101,
 				})
+				f, ok, hasMore := str.getControlFrame()
+				Expect(ok).To(BeTrue())
+				Expect(f.Frame).To(Equal(&wire.ResetStreamFrame{
+					StreamID:  streamID,
+					ErrorCode: 101,
+				}))
+				Expect(hasMore).To(BeFalse())
 			})
 
 			It("discards the stream when CancelWrite is called after receiving STOP_SENDING", func() {
-				mockSender.EXPECT().queueControlFrame(&wire.ResetStreamFrame{
-					StreamID:  streamID,
-					ErrorCode: 101,
-				})
+				mockSender.EXPECT().onHasStreamControlFrame(streamID, str)
 				str.handleStopSendingFrame(&wire.StopSendingFrame{
 					StreamID:  streamID,
 					ErrorCode: 101,
@@ -896,7 +915,7 @@ var _ = Describe("Send Stream", func() {
 
 			It("unblocks Write", func() {
 				mockSender.EXPECT().onHasStreamData(streamID, str)
-				mockSender.EXPECT().queueControlFrame(gomock.Any())
+				mockSender.EXPECT().onHasStreamControlFrame(gomock.Any(), gomock.Any())
 				done := make(chan struct{})
 				go func() {
 					defer GinkgoRecover()
@@ -918,7 +937,7 @@ var _ = Describe("Send Stream", func() {
 			})
 
 			It("doesn't allow further calls to Write", func() {
-				mockSender.EXPECT().queueControlFrame(gomock.Any())
+				mockSender.EXPECT().onHasStreamControlFrame(gomock.Any(), gomock.Any())
 				str.handleStopSendingFrame(&wire.StopSendingFrame{
 					StreamID:  streamID,
 					ErrorCode: 123,
@@ -933,7 +952,7 @@ var _ = Describe("Send Stream", func() {
 			})
 
 			It("handles Close after STOP_SENDING", func() {
-				mockSender.EXPECT().queueControlFrame(gomock.Any())
+				mockSender.EXPECT().onHasStreamControlFrame(gomock.Any(), gomock.Any())
 				str.handleStopSendingFrame(&wire.StopSendingFrame{
 					StreamID:  streamID,
 					ErrorCode: 123,
@@ -948,7 +967,7 @@ var _ = Describe("Send Stream", func() {
 				_, ok, _ := str.popStreamFrame(protocol.MaxByteCount, protocol.Version1)
 				Expect(ok).To(BeTrue())
 				gomock.InOrder(
-					mockSender.EXPECT().queueControlFrame(gomock.Any()),
+					mockSender.EXPECT().onHasStreamControlFrame(gomock.Any(), gomock.Any()),
 					mockSender.EXPECT().onStreamCompleted(gomock.Any()),
 				)
 				str.handleStopSendingFrame(&wire.StopSendingFrame{
@@ -961,7 +980,7 @@ var _ = Describe("Send Stream", func() {
 				mockSender.EXPECT().onHasStreamData(gomock.Any(), gomock.Any())
 				str.Close()
 				gomock.InOrder(
-					mockSender.EXPECT().queueControlFrame(gomock.Any()),
+					mockSender.EXPECT().onHasStreamControlFrame(gomock.Any(), gomock.Any()),
 					mockSender.EXPECT().onStreamCompleted(gomock.Any()),
 				)
 				str.handleStopSendingFrame(&wire.StopSendingFrame{
@@ -1075,7 +1094,7 @@ var _ = Describe("Send Stream", func() {
 			Eventually(done).Should(BeClosed())
 			Expect(f).ToNot(BeNil())
 			gomock.InOrder(
-				mockSender.EXPECT().queueControlFrame(gomock.Any()),
+				mockSender.EXPECT().onHasStreamControlFrame(gomock.Any(), gomock.Any()),
 				mockSender.EXPECT().onStreamCompleted(streamID),
 			)
 			str.CancelWrite(9876)
diff --git a/streams_map_incoming_test.go b/streams_map_incoming_test.go
index b5abba51..04fca4f5 100644
--- a/streams_map_incoming_test.go
+++ b/streams_map_incoming_test.go
@@ -12,7 +12,6 @@ import (
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
-	"go.uber.org/mock/gomock"
 )
 
 type mockGenericStream struct {
@@ -34,10 +33,10 @@ func (s *mockGenericStream) updateSendWindow(limit protocol.ByteCount) {
 
 var _ = Describe("Streams Map (incoming)", func() {
 	var (
-		m              *incomingStreamsMap[*mockGenericStream]
-		newItemCounter int
-		mockSender     *MockStreamSender
-		maxNumStreams  uint64
+		m                   *incomingStreamsMap[*mockGenericStream]
+		newItemCounter      int
+		maxNumStreams       uint64
+		queuedControlFrames []wire.Frame
 	)
 	streamType := []protocol.StreamType{protocol.StreamTypeUni, protocol.StreamTypeUni}[rand.Intn(2)]
 
@@ -53,8 +52,8 @@ var _ = Describe("Streams Map (incoming)", func() {
 	BeforeEach(func() { maxNumStreams = 5 })
 
 	JustBeforeEach(func() {
+		queuedControlFrames = []wire.Frame{}
 		newItemCounter = 0
-		mockSender = NewMockStreamSender(mockCtrl)
 		m = newIncomingStreamsMap(
 			streamType,
 			func(num protocol.StreamNum) *mockGenericStream {
@@ -62,7 +61,7 @@ var _ = Describe("Streams Map (incoming)", func() {
 				return &mockGenericStream{num: num}
 			},
 			maxNumStreams,
-			mockSender.queueControlFrame,
+			func(f wire.Frame) { queuedControlFrames = append(queuedControlFrames, f) },
 		)
 	})
 
@@ -171,7 +170,6 @@ var _ = Describe("Streams Map (incoming)", func() {
 	})
 
 	It("deletes streams", func() {
-		mockSender.EXPECT().queueControlFrame(gomock.Any())
 		_, err := m.GetOrOpenStream(1)
 		Expect(err).ToNot(HaveOccurred())
 		str, err := m.AcceptStream(context.Background())
@@ -191,7 +189,6 @@ var _ = Describe("Streams Map (incoming)", func() {
 		Expect(err).ToNot(HaveOccurred())
 		Expect(str.num).To(Equal(protocol.StreamNum(1)))
 		// when accepting this stream, it will get deleted, and a MAX_STREAMS frame is queued
-		mockSender.EXPECT().queueControlFrame(gomock.Any())
 		str, err = m.AcceptStream(context.Background())
 		Expect(err).ToNot(HaveOccurred())
 		Expect(str.num).To(Equal(protocol.StreamNum(2)))
@@ -206,7 +203,6 @@ var _ = Describe("Streams Map (incoming)", func() {
 		Expect(err).ToNot(HaveOccurred())
 		Expect(str).To(BeNil())
 		// when accepting this stream, it will get deleted, and a MAX_STREAMS frame is queued
-		mockSender.EXPECT().queueControlFrame(gomock.Any())
 		str, err = m.AcceptStream(context.Background())
 		Expect(err).ToNot(HaveOccurred())
 		Expect(str).ToNot(BeNil())
@@ -227,18 +223,17 @@ var _ = Describe("Streams Map (incoming)", func() {
 			_, err := m.AcceptStream(context.Background())
 			Expect(err).ToNot(HaveOccurred())
 		}
-		mockSender.EXPECT().queueControlFrame(gomock.Any()).Do(func(f wire.Frame) {
-			msf := f.(*wire.MaxStreamsFrame)
-			Expect(msf.Type).To(BeEquivalentTo(streamType))
-			Expect(msf.MaxStreamNum).To(Equal(protocol.StreamNum(maxNumStreams + 1)))
-			checkFrameSerialization(f)
-		})
+		Expect(queuedControlFrames).To(BeEmpty())
 		Expect(m.DeleteStream(3)).To(Succeed())
-		mockSender.EXPECT().queueControlFrame(gomock.Any()).Do(func(f wire.Frame) {
-			Expect(f.(*wire.MaxStreamsFrame).MaxStreamNum).To(Equal(protocol.StreamNum(maxNumStreams + 2)))
-			checkFrameSerialization(f)
-		})
+		Expect(queuedControlFrames).To(HaveLen(1))
+		msf := queuedControlFrames[0].(*wire.MaxStreamsFrame)
+		Expect(msf.Type).To(BeEquivalentTo(streamType))
+		Expect(msf.MaxStreamNum).To(Equal(protocol.StreamNum(maxNumStreams + 1)))
+		checkFrameSerialization(msf)
 		Expect(m.DeleteStream(4)).To(Succeed())
+		Expect(queuedControlFrames).To(HaveLen(2))
+		Expect(queuedControlFrames[1].(*wire.MaxStreamsFrame).MaxStreamNum).To(Equal(protocol.StreamNum(maxNumStreams + 2)))
+		checkFrameSerialization(queuedControlFrames[1])
 	})
 
 	Context("using high stream limits", func() {
@@ -253,19 +248,19 @@ var _ = Describe("Streams Map (incoming)", func() {
 				_, err := m.AcceptStream(context.Background())
 				Expect(err).ToNot(HaveOccurred())
 			}
-			mockSender.EXPECT().queueControlFrame(gomock.Any()).Do(func(f wire.Frame) {
-				Expect(f.(*wire.MaxStreamsFrame).MaxStreamNum).To(Equal(protocol.MaxStreamCount - 1))
-				checkFrameSerialization(f)
-			})
+			Expect(queuedControlFrames).To(BeEmpty())
 			Expect(m.DeleteStream(4)).To(Succeed())
-			mockSender.EXPECT().queueControlFrame(gomock.Any()).Do(func(f wire.Frame) {
-				Expect(f.(*wire.MaxStreamsFrame).MaxStreamNum).To(Equal(protocol.MaxStreamCount))
-				checkFrameSerialization(f)
-			})
+			Expect(queuedControlFrames).To(HaveLen(1))
+			Expect(queuedControlFrames[0].(*wire.MaxStreamsFrame).MaxStreamNum).To(Equal(protocol.MaxStreamCount - 1))
+			checkFrameSerialization(queuedControlFrames[0])
 			Expect(m.DeleteStream(3)).To(Succeed())
+			Expect(queuedControlFrames).To(HaveLen(2))
+			Expect(queuedControlFrames[1].(*wire.MaxStreamsFrame).MaxStreamNum).To(Equal(protocol.MaxStreamCount))
+			checkFrameSerialization(queuedControlFrames[1])
 			// at this point, we can't increase the stream limit any further, so no more MAX_STREAMS frames will be sent
 			Expect(m.DeleteStream(2)).To(Succeed())
 			Expect(m.DeleteStream(1)).To(Succeed())
+			Expect(queuedControlFrames).To(HaveLen(2))
 		})
 	})
 
diff --git a/streams_map_outgoing_test.go b/streams_map_outgoing_test.go
index 3ae33717..7c5c4af1 100644
--- a/streams_map_outgoing_test.go
+++ b/streams_map_outgoing_test.go
@@ -15,14 +15,13 @@ import (
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
-	"go.uber.org/mock/gomock"
 )
 
 var _ = Describe("Streams Map (outgoing)", func() {
 	var (
-		m          *outgoingStreamsMap[*mockGenericStream]
-		newStr     func(num protocol.StreamNum) *mockGenericStream
-		mockSender *MockStreamSender
+		m                   *outgoingStreamsMap[*mockGenericStream]
+		newStr              func(num protocol.StreamNum) *mockGenericStream
+		queuedControlFrames []wire.Frame
 	)
 
 	const streamType = 42
@@ -37,11 +36,15 @@ var _ = Describe("Streams Map (outgoing)", func() {
 	}
 
 	BeforeEach(func() {
+		queuedControlFrames = []wire.Frame{}
 		newStr = func(num protocol.StreamNum) *mockGenericStream {
 			return &mockGenericStream{num: num}
 		}
-		mockSender = NewMockStreamSender(mockCtrl)
-		m = newOutgoingStreamsMap[*mockGenericStream](streamType, newStr, mockSender.queueControlFrame)
+		m = newOutgoingStreamsMap[*mockGenericStream](
+			streamType,
+			newStr,
+			func(f wire.Frame) { queuedControlFrames = append(queuedControlFrames, f) },
+		)
 	})
 
 	Context("no stream ID limit", func() {
@@ -130,7 +133,6 @@ var _ = Describe("Streams Map (outgoing)", func() {
 
 	Context("with stream ID limits", func() {
 		It("errors when no stream can be opened immediately", func() {
-			mockSender.EXPECT().queueControlFrame(gomock.Any())
 			_, err := m.OpenStream()
 			expectTooManyStreamsError(err)
 		})
@@ -143,7 +145,6 @@ var _ = Describe("Streams Map (outgoing)", func() {
 		})
 
 		It("blocks until a stream can be opened synchronously", func() {
-			mockSender.EXPECT().queueControlFrame(gomock.Any())
 			done := make(chan struct{})
 			go func() {
 				defer GinkgoRecover()
@@ -159,7 +160,6 @@ var _ = Describe("Streams Map (outgoing)", func() {
 		})
 
 		It("unblocks when the context is canceled", func() {
-			mockSender.EXPECT().queueControlFrame(gomock.Any())
 			ctx, cancel := context.WithCancel(context.Background())
 			done := make(chan struct{})
 			go func() {
@@ -181,7 +181,6 @@ var _ = Describe("Streams Map (outgoing)", func() {
 		})
 
 		It("opens streams in the right order", func() {
-			mockSender.EXPECT().queueControlFrame(gomock.Any()).AnyTimes()
 			done1 := make(chan struct{})
 			go func() {
 				defer GinkgoRecover()
@@ -210,7 +209,6 @@ var _ = Describe("Streams Map (outgoing)", func() {
 		})
 
 		It("opens streams in the right order, when one of the contexts is canceled", func() {
-			mockSender.EXPECT().queueControlFrame(gomock.Any()).AnyTimes()
 			done1 := make(chan struct{})
 			go func() {
 				defer GinkgoRecover()
@@ -249,7 +247,6 @@ var _ = Describe("Streams Map (outgoing)", func() {
 		})
 
 		It("unblocks multiple OpenStreamSync calls at the same time", func() {
-			mockSender.EXPECT().queueControlFrame(gomock.Any()).AnyTimes()
 			done := make(chan struct{})
 			go func() {
 				defer GinkgoRecover()
@@ -282,7 +279,6 @@ var _ = Describe("Streams Map (outgoing)", func() {
 		})
 
 		It("returns an error for OpenStream while an OpenStreamSync call is blocking", func() {
-			mockSender.EXPECT().queueControlFrame(gomock.Any()).MaxTimes(2)
 			openedSync := make(chan struct{})
 			go func() {
 				defer GinkgoRecover()
@@ -322,7 +318,6 @@ var _ = Describe("Streams Map (outgoing)", func() {
 		})
 
 		It("stops opening synchronously when it is closed", func() {
-			mockSender.EXPECT().queueControlFrame(gomock.Any())
 			testErr := errors.New("test error")
 			done := make(chan struct{})
 			go func() {
@@ -355,33 +350,31 @@ var _ = Describe("Streams Map (outgoing)", func() {
 				Expect(err).ToNot(HaveOccurred())
 			}
 
-			mockSender.EXPECT().queueControlFrame(gomock.Any()).Do(func(f wire.Frame) {
-				bf := f.(*wire.StreamsBlockedFrame)
-				Expect(bf.Type).To(BeEquivalentTo(streamType))
-				Expect(bf.StreamLimit).To(BeEquivalentTo(6))
-			})
+			Expect(queuedControlFrames).To(BeEmpty())
 			_, err := m.OpenStream()
 			Expect(err).To(MatchError(&StreamLimitReachedError{}))
+			Expect(queuedControlFrames).To(HaveLen(1))
+			bf := queuedControlFrames[0].(*wire.StreamsBlockedFrame)
+			Expect(bf.Type).To(BeEquivalentTo(streamType))
+			Expect(bf.StreamLimit).To(BeEquivalentTo(6))
 		})
 
 		It("only sends one STREAMS_BLOCKED frame for one stream ID", func() {
 			m.SetMaxStream(1)
-			mockSender.EXPECT().queueControlFrame(gomock.Any()).Do(func(f wire.Frame) {
-				Expect(f.(*wire.StreamsBlockedFrame).StreamLimit).To(BeEquivalentTo(1))
-			})
 			_, err := m.OpenStream()
 			Expect(err).ToNot(HaveOccurred())
+			Expect(queuedControlFrames).To(BeEmpty())
 			// try to open a stream twice, but expect only one STREAMS_BLOCKED to be sent
 			_, err = m.OpenStream()
 			expectTooManyStreamsError(err)
+			Expect(queuedControlFrames).To(HaveLen(1))
+			Expect(queuedControlFrames[0].(*wire.StreamsBlockedFrame).StreamLimit).To(BeEquivalentTo(1))
 			_, err = m.OpenStream()
 			expectTooManyStreamsError(err)
+			Expect(queuedControlFrames).To(HaveLen(1))
 		})
 
 		It("queues a STREAMS_BLOCKED frame when there more streams waiting for OpenStreamSync than MAX_STREAMS allows", func() {
-			mockSender.EXPECT().queueControlFrame(gomock.Any()).Do(func(f wire.Frame) {
-				Expect(f.(*wire.StreamsBlockedFrame).StreamLimit).To(BeEquivalentTo(0))
-			})
 			done := make(chan struct{}, 2)
 			go func() {
 				defer GinkgoRecover()
@@ -396,13 +389,14 @@ var _ = Describe("Streams Map (outgoing)", func() {
 				done <- struct{}{}
 			}()
 			waitForEnqueued(2)
+			Expect(queuedControlFrames).To(HaveLen(1))
+			Expect(queuedControlFrames[0].(*wire.StreamsBlockedFrame).StreamLimit).To(BeEquivalentTo(0))
 
-			mockSender.EXPECT().queueControlFrame(gomock.Any()).Do(func(f wire.Frame) {
-				Expect(f.(*wire.StreamsBlockedFrame).StreamLimit).To(BeEquivalentTo(1))
-			})
 			m.SetMaxStream(1)
 			Eventually(done).Should(Receive())
 			Consistently(done).ShouldNot(Receive())
+			Expect(queuedControlFrames).To(HaveLen(2))
+			Expect(queuedControlFrames[1].(*wire.StreamsBlockedFrame).StreamLimit).To(BeEquivalentTo(1))
 			m.SetMaxStream(2)
 			Eventually(done).Should(Receive())
 		})
@@ -414,10 +408,6 @@ var _ = Describe("Streams Map (outgoing)", func() {
 			const n = 100
 			fmt.Fprintf(GinkgoWriter, "Opening %d streams concurrently.\n", n)
 
-			var blockedAt []protocol.StreamNum
-			mockSender.EXPECT().queueControlFrame(gomock.Any()).Do(func(f wire.Frame) {
-				blockedAt = append(blockedAt, f.(*wire.StreamsBlockedFrame).StreamLimit)
-			}).AnyTimes()
 			done := make(map[int]chan struct{})
 			for i := 1; i <= n; i++ {
 				c := make(chan struct{})
@@ -456,6 +446,10 @@ var _ = Describe("Streams Map (outgoing)", func() {
 					Expect(str.num).To(Equal(protocol.StreamNum(n + 1)))
 				}
 			}
+			var blockedAt []protocol.StreamNum
+			for _, f := range queuedControlFrames {
+				blockedAt = append(blockedAt, f.(*wire.StreamsBlockedFrame).StreamLimit)
+			}
 			Expect(blockedAt).To(Equal(limits))
 		})
 
@@ -464,11 +458,6 @@ var _ = Describe("Streams Map (outgoing)", func() {
 			const n = 100
 			fmt.Fprintf(GinkgoWriter, "Opening %d streams concurrently.\n", n)
 
-			var blockedAt []protocol.StreamNum
-			mockSender.EXPECT().queueControlFrame(gomock.Any()).Do(func(f wire.Frame) {
-				blockedAt = append(blockedAt, f.(*wire.StreamsBlockedFrame).StreamLimit)
-			}).AnyTimes()
-
 			ctx, cancel := context.WithCancel(context.Background())
 			streamsToCancel := make(map[protocol.StreamNum]struct{}) // used as a set
 			for i := 0; i < 10; i++ {
@@ -537,6 +526,10 @@ var _ = Describe("Streams Map (outgoing)", func() {
 					Expect(streamIDs[i]).To(Equal(i + 1))
 				}
 			}
+			var blockedAt []protocol.StreamNum
+			for _, f := range queuedControlFrames {
+				blockedAt = append(blockedAt, f.(*wire.StreamsBlockedFrame).StreamLimit)
+			}
 			Expect(blockedAt).To(Equal(limits))
 		})
 	})
diff --git a/streams_map_test.go b/streams_map_test.go
index 6e1f1c4e..bbd29fab 100644
--- a/streams_map_test.go
+++ b/streams_map_test.go
@@ -14,7 +14,6 @@ import (
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
-	"go.uber.org/mock/gomock"
 )
 
 func (e streamError) TestError() error {
@@ -72,8 +71,9 @@ var _ = Describe("Streams Map", func() {
 
 		Context(perspective.String(), func() {
 			var (
-				m          *streamsMap
-				mockSender *MockStreamSender
+				m                   *streamsMap
+				mockSender          *MockStreamSender
+				queuedControlFrames []wire.Frame
 			)
 
 			const (
@@ -89,8 +89,17 @@ var _ = Describe("Streams Map", func() {
 			}
 
 			BeforeEach(func() {
+				queuedControlFrames = []wire.Frame{}
 				mockSender = NewMockStreamSender(mockCtrl)
-				m = newStreamsMap(context.Background(), mockSender, newFlowController, MaxBidiStreamNum, MaxUniStreamNum, perspective).(*streamsMap)
+				m = newStreamsMap(
+					context.Background(),
+					mockSender,
+					func(f wire.Frame) { queuedControlFrames = append(queuedControlFrames, f) },
+					newFlowController,
+					MaxBidiStreamNum,
+					MaxUniStreamNum,
+					perspective,
+				)
 			})
 
 			Context("opening", func() {
@@ -140,10 +149,7 @@ var _ = Describe("Streams Map", func() {
 			})
 
 			Context("deleting", func() {
-				BeforeEach(func() {
-					mockSender.EXPECT().queueControlFrame(gomock.Any()).AnyTimes()
-					allowUnlimitedStreams()
-				})
+				BeforeEach(func() { allowUnlimitedStreams() })
 
 				It("deletes outgoing bidirectional streams", func() {
 					id := ids.firstOutgoingBidiStream
@@ -339,7 +345,6 @@ var _ = Describe("Streams Map", func() {
 			})
 
 			It("processes the parameter for outgoing streams", func() {
-				mockSender.EXPECT().queueControlFrame(gomock.Any())
 				_, err := m.OpenStream()
 				expectTooManyStreamsError(err)
 				m.UpdateLimits(&wire.TransportParameters{
@@ -347,7 +352,6 @@ var _ = Describe("Streams Map", func() {
 					MaxUniStreamNum:  8,
 				})
 
-				mockSender.EXPECT().queueControlFrame(gomock.Any()).Times(2)
 				// test we can only 5 bidirectional streams
 				for i := 0; i < 5; i++ {
 					str, err := m.OpenStream()
@@ -364,6 +368,7 @@ var _ = Describe("Streams Map", func() {
 				}
 				_, err = m.OpenUniStream()
 				expectTooManyStreamsError(err)
+				Expect(queuedControlFrames).To(HaveLen(3))
 			})
 
 			if perspective == protocol.PerspectiveClient {
@@ -399,10 +404,6 @@ var _ = Describe("Streams Map", func() {
 			}
 
 			Context("handling MAX_STREAMS frames", func() {
-				BeforeEach(func() {
-					mockSender.EXPECT().queueControlFrame(gomock.Any()).AnyTimes()
-				})
-
 				It("processes IDs for outgoing bidirectional streams", func() {
 					_, err := m.OpenStream()
 					expectTooManyStreamsError(err)
@@ -438,11 +439,13 @@ var _ = Describe("Streams Map", func() {
 					Expect(err).ToNot(HaveOccurred())
 					_, err = m.AcceptStream(context.Background())
 					Expect(err).ToNot(HaveOccurred())
-					mockSender.EXPECT().queueControlFrame(&wire.MaxStreamsFrame{
-						Type:         protocol.StreamTypeBidi,
-						MaxStreamNum: MaxBidiStreamNum + 1,
-					})
 					Expect(m.DeleteStream(ids.firstIncomingBidiStream)).To(Succeed())
+					Expect(queuedControlFrames).To(Equal([]wire.Frame{
+						&wire.MaxStreamsFrame{
+							Type:         protocol.StreamTypeBidi,
+							MaxStreamNum: MaxBidiStreamNum + 1,
+						},
+					}))
 				})
 
 				It("sends a MAX_STREAMS frame for unidirectional streams", func() {
@@ -450,11 +453,13 @@ var _ = Describe("Streams Map", func() {
 					Expect(err).ToNot(HaveOccurred())
 					_, err = m.AcceptUniStream(context.Background())
 					Expect(err).ToNot(HaveOccurred())
-					mockSender.EXPECT().queueControlFrame(&wire.MaxStreamsFrame{
-						Type:         protocol.StreamTypeUni,
-						MaxStreamNum: MaxUniStreamNum + 1,
-					})
 					Expect(m.DeleteStream(ids.firstIncomingUniStream)).To(Succeed())
+					Expect(queuedControlFrames).To(Equal([]wire.Frame{
+						&wire.MaxStreamsFrame{
+							Type:         protocol.StreamTypeUni,
+							MaxStreamNum: MaxUniStreamNum + 1,
+						},
+					}))
 				})
 			})
 
@@ -477,7 +482,6 @@ var _ = Describe("Streams Map", func() {
 
 			if perspective == protocol.PerspectiveClient {
 				It("resets for 0-RTT", func() {
-					mockSender.EXPECT().queueControlFrame(gomock.Any()).AnyTimes()
 					m.ResetFor0RTT()
 					// make sure that calls to open / accept streams fail
 					_, err := m.OpenStream()
diff --git a/window_update_queue_test.go b/window_update_queue_test.go
deleted file mode 100644
index 7f9400e9..00000000
--- a/window_update_queue_test.go
+++ /dev/null
@@ -1,105 +0,0 @@
-package quic
-
-import (
-	"github.com/quic-go/quic-go/internal/mocks"
-	"github.com/quic-go/quic-go/internal/protocol"
-	"github.com/quic-go/quic-go/internal/wire"
-
-	. "github.com/onsi/ginkgo/v2"
-	. "github.com/onsi/gomega"
-)
-
-var _ = Describe("Window Update Queue", func() {
-	var (
-		q            *windowUpdateQueue
-		connFC       *mocks.MockConnectionFlowController
-		queuedFrames []wire.Frame
-	)
-
-	BeforeEach(func() {
-		connFC = mocks.NewMockConnectionFlowController(mockCtrl)
-		queuedFrames = queuedFrames[:0]
-		q = newWindowUpdateQueue(connFC, func(f wire.Frame) {
-			queuedFrames = append(queuedFrames, f)
-		})
-	})
-
-	It("adds stream offsets and gets MAX_STREAM_DATA frames", func() {
-		connFC.EXPECT().GetWindowUpdate().Return(protocol.ByteCount(0)).AnyTimes()
-		stream1 := NewMockStreamI(mockCtrl)
-		stream1.EXPECT().getWindowUpdate().Return(protocol.ByteCount(10))
-		stream3 := NewMockStreamI(mockCtrl)
-		stream3.EXPECT().getWindowUpdate().Return(protocol.ByteCount(30))
-		q.AddStream(3, stream3)
-		q.AddStream(1, stream1)
-		q.QueueAll()
-		Expect(queuedFrames).To(ContainElement(&wire.MaxStreamDataFrame{StreamID: 1, MaximumStreamData: 10}))
-		Expect(queuedFrames).To(ContainElement(&wire.MaxStreamDataFrame{StreamID: 3, MaximumStreamData: 30}))
-	})
-
-	It("deletes the entry after getting the MAX_STREAM_DATA frame", func() {
-		connFC.EXPECT().GetWindowUpdate().Return(protocol.ByteCount(0)).AnyTimes()
-		stream10 := NewMockStreamI(mockCtrl)
-		stream10.EXPECT().getWindowUpdate().Return(protocol.ByteCount(100))
-		q.AddStream(10, stream10)
-		q.QueueAll()
-		Expect(queuedFrames).To(HaveLen(1))
-		q.QueueAll()
-		Expect(queuedFrames).To(HaveLen(1))
-	})
-
-	It("doesn't queue a MAX_STREAM_DATA for a closed stream", func() {
-		connFC.EXPECT().GetWindowUpdate().Return(protocol.ByteCount(0)).AnyTimes()
-		stream12 := NewMockStreamI(mockCtrl)
-		q.AddStream(12, stream12)
-		q.RemoveStream(12)
-		q.QueueAll()
-		Expect(queuedFrames).To(BeEmpty())
-	})
-
-	It("removes closed streams from the queue", func() {
-		connFC.EXPECT().GetWindowUpdate().Return(protocol.ByteCount(0)).AnyTimes()
-		stream12 := NewMockStreamI(mockCtrl)
-		q.AddStream(12, stream12)
-		q.RemoveStream(12)
-		q.QueueAll()
-		Expect(queuedFrames).To(BeEmpty())
-	})
-
-	It("doesn't queue a MAX_STREAM_DATA if the flow controller returns an offset of 0", func() {
-		connFC.EXPECT().GetWindowUpdate().Return(protocol.ByteCount(0))
-		stream5 := NewMockStreamI(mockCtrl)
-		stream5.EXPECT().getWindowUpdate().Return(protocol.ByteCount(0))
-		q.AddStream(5, stream5)
-		q.QueueAll()
-		Expect(queuedFrames).To(BeEmpty())
-	})
-
-	It("removes streams for which the flow controller returns an offset of 0 from the queue", func() {
-		connFC.EXPECT().GetWindowUpdate().Return(protocol.ByteCount(0)).AnyTimes()
-		stream5 := NewMockStreamI(mockCtrl)
-		stream5.EXPECT().getWindowUpdate().Return(protocol.ByteCount(0))
-		q.AddStream(5, stream5)
-		q.QueueAll()
-		Expect(queuedFrames).To(BeEmpty())
-		// don't EXPECT any further calls to GetOrOpenReveiveStream and to getWindowUpdate
-		q.QueueAll()
-		Expect(queuedFrames).To(BeEmpty())
-	})
-
-	It("queues MAX_DATA frames", func() {
-		connFC.EXPECT().GetWindowUpdate().Return(protocol.ByteCount(0x1337))
-		q.QueueAll()
-		Expect(queuedFrames).To(Equal([]wire.Frame{&wire.MaxDataFrame{MaximumData: 0x1337}}))
-	})
-
-	It("deduplicates", func() {
-		connFC.EXPECT().GetWindowUpdate().Return(protocol.ByteCount(0))
-		stream10 := NewMockStreamI(mockCtrl)
-		stream10.EXPECT().getWindowUpdate().Return(protocol.ByteCount(200))
-		q.AddStream(10, stream10)
-		q.AddStream(10, stream10)
-		q.QueueAll()
-		Expect(queuedFrames).To(Equal([]wire.Frame{&wire.MaxStreamDataFrame{StreamID: 10, MaximumStreamData: 200}}))
-	})
-})