Repository: quic-go/quic-go
Pull request #4823 (2024-12-29 05:28:47)

migrate the connection tests away from Ginkgo
Part of #3652, and probably the most intricate part.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/quic-go/quic-go/pull/4823?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 83.80%. Comparing base [(`f45daa0`)](https://app.codecov.io/gh/quic-go/quic-go/commit/f45daa0c05137389468bec95ad0fd2f18daed556?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) to head [(`e1c6f5b`)](https://app.codecov.io/gh/quic-go/quic-go/commit/e1c6f5bcd7abc046365e7ca8b916db17f1992492?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go).

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


```diff
@@            Coverage Diff             @@
##           master    #4823      +/-   ##
==========================================
- Coverage   83.91%   83.80%   -0.11%     
==========================================
  Files         149      146       -3     
  Lines       16058    15711     -347     
==========================================
- Hits        13474    13166     -308     
+ Misses       2055     2038      -17     
+ Partials      529      507      -22     
```

</details>

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


Patch:
diff --git a/connection.go b/connection.go
index 4974d102..2b194701 100644
--- a/connection.go
+++ b/connection.go
@@ -974,7 +974,7 @@ func (s *connection) handleLongHeaderPacket(p receivedPacket, hdr *wire.Header)
 	// drop 0-RTT packets, if we are a client
 	if s.perspective == protocol.PerspectiveClient && hdr.Type == protocol.PacketType0RTT {
 		if s.tracer != nil && s.tracer.DroppedPacket != nil {
-			s.tracer.DroppedPacket(logging.PacketType0RTT, protocol.InvalidPacketNumber, p.Size(), logging.PacketDropKeyUnavailable)
+			s.tracer.DroppedPacket(logging.PacketType0RTT, protocol.InvalidPacketNumber, p.Size(), logging.PacketDropUnexpectedPacket)
 		}
 		return false
 	}
@@ -1084,14 +1084,6 @@ func (s *connection) handleRetryPacket(hdr *wire.Header, data []byte, rcvTime ti
 		return false
 	}
 
-	if s.logger.Debug() {
-		s.logger.Debugf("<- Received Retry:")
-		(&wire.ExtendedHeader{Header: *hdr}).Log(s.logger)
-		s.logger.Debugf("Switching destination connection ID to: %s", hdr.SrcConnectionID)
-	}
-	if s.tracer != nil && s.tracer.ReceivedRetry != nil {
-		s.tracer.ReceivedRetry(hdr)
-	}
 	newDestConnID := hdr.SrcConnectionID
 	s.receivedRetry = true
 	if err := s.sentPacketHandler.ResetForRetry(rcvTime); err != nil {
@@ -1103,6 +1095,16 @@ func (s *connection) handleRetryPacket(hdr *wire.Header, data []byte, rcvTime ti
 	s.cryptoStreamHandler.ChangeConnectionID(newDestConnID)
 	s.packer.SetToken(hdr.Token)
 	s.connIDManager.ChangeInitialConnID(newDestConnID)
+
+	if s.logger.Debug() {
+		s.logger.Debugf("<- Received Retry:")
+		(&wire.ExtendedHeader{Header: *hdr}).Log(s.logger)
+		s.logger.Debugf("Switching destination connection ID to: %s", hdr.SrcConnectionID)
+	}
+	if s.tracer != nil && s.tracer.ReceivedRetry != nil {
+		s.tracer.ReceivedRetry(hdr)
+	}
+
 	s.scheduleSending()
 	return true
 }
@@ -1351,7 +1353,10 @@ func (s *connection) handleFrame(
 		s.handlePathChallengeFrame(frame)
 	case *wire.PathResponseFrame:
 		// since we don't send PATH_CHALLENGEs, we don't expect PATH_RESPONSEs
-		err = errors.New("unexpected PATH_RESPONSE frame")
+		err = &qerr.TransportError{
+			ErrorCode:    qerr.ProtocolViolation,
+			ErrorMessage: "unexpected PATH_RESPONSE frame",
+		}
 	case *wire.NewTokenFrame:
 		err = s.handleNewTokenFrame(frame)
 	case *wire.NewConnectionIDFrame:
@@ -1452,9 +1457,7 @@ func (s *connection) handleStreamFrame(frame *wire.StreamFrame, rcvTime time.Tim
 	if err != nil {
 		return err
 	}
-	if str == nil {
-		// Stream is closed and already garbage collected
-		// ignore this StreamFrame
+	if str == nil { // stream was already closed and garbage collected
 		return nil
 	}
 	return str.handleStreamFrame(frame, rcvTime)
diff --git a/connection_logging.go b/connection_logging.go
index f75b39f6..a314a6cd 100644
--- a/connection_logging.go
+++ b/connection_logging.go
@@ -125,12 +125,7 @@ func (s *connection) logShortHeaderPacket(
 			ack = toLoggingAckFrame(ackFrame)
 		}
 		s.tracer.SentShortHeaderPacket(
-			&logging.ShortHeader{
-				DestConnectionID: destConnID,
-				PacketNumber:     pn,
-				PacketNumberLen:  pnLen,
-				KeyPhase:         kp,
-			},
+			&logging.ShortHeader{DestConnectionID: destConnID, PacketNumber: pn, PacketNumberLen: pnLen, KeyPhase: kp},
 			size,
 			ecn,
 			ack,


Test patch:
diff --git a/connection_logging_test.go b/connection_logging_test.go
index f64437f4..c8661806 100644
--- a/connection_logging_test.go
+++ b/connection_logging_test.go
@@ -1,52 +1,72 @@
 package quic
 
 import (
+	"testing"
+
 	"github.com/quic-go/quic-go/internal/wire"
 	"github.com/quic-go/quic-go/logging"
 
-	. "github.com/onsi/ginkgo/v2"
-	. "github.com/onsi/gomega"
+	"github.com/stretchr/testify/require"
 )
 
-var _ = Describe("CRYPTO frame", func() {
-	It("converts CRYPTO frames", func() {
-		f := toLoggingFrame(&wire.CryptoFrame{
-			Offset: 1234,
-			Data:   []byte("foobar"),
-		})
-		Expect(f).To(BeAssignableToTypeOf(&logging.CryptoFrame{}))
-		cf := f.(*logging.CryptoFrame)
-		Expect(cf.Offset).To(Equal(logging.ByteCount(1234)))
-		Expect(cf.Length).To(Equal(logging.ByteCount(6)))
+func TestConnectionLoggingCryptoFrame(t *testing.T) {
+	f := toLoggingFrame(&wire.CryptoFrame{
+		Offset: 1234,
+		Data:   []byte("foobar"),
 	})
+	require.Equal(t, &logging.CryptoFrame{
+		Offset: 1234,
+		Length: 6,
+	}, f)
+}
 
-	It("converts STREAM frames", func() {
-		f := toLoggingFrame(&wire.StreamFrame{
-			StreamID: 42,
-			Offset:   1234,
-			Data:     []byte("foo"),
-			Fin:      true,
-		})
-		Expect(f).To(BeAssignableToTypeOf(&logging.StreamFrame{}))
-		sf := f.(*logging.StreamFrame)
-		Expect(sf.StreamID).To(Equal(logging.StreamID(42)))
-		Expect(sf.Offset).To(Equal(logging.ByteCount(1234)))
-		Expect(sf.Length).To(Equal(logging.ByteCount(3)))
-		Expect(sf.Fin).To(BeTrue())
+func TestConnectionLoggingStreamFrame(t *testing.T) {
+	f := toLoggingFrame(&wire.StreamFrame{
+		StreamID: 42,
+		Offset:   1234,
+		Data:     []byte("foo"),
+		Fin:      true,
 	})
+	require.Equal(t, &logging.StreamFrame{
+		StreamID: 42,
+		Offset:   1234,
+		Length:   3,
+		Fin:      true,
+	}, f)
+}
 
-	It("converts DATAGRAM frames", func() {
-		f := toLoggingFrame(&wire.DatagramFrame{Data: []byte("foobar")})
-		Expect(f).To(BeAssignableToTypeOf(&logging.DatagramFrame{}))
-		df := f.(*logging.DatagramFrame)
-		Expect(df.Length).To(Equal(logging.ByteCount(6)))
-	})
+func TestConnectionLoggingAckFrame(t *testing.T) {
+	ack := &wire.AckFrame{
+		AckRanges: []wire.AckRange{
+			{Smallest: 1, Largest: 3},
+			{Smallest: 6, Largest: 7},
+		},
+		DelayTime: 42,
+		ECNCE:     123,
+		ECT0:      456,
+		ECT1:      789,
+	}
+	f := toLoggingFrame(ack)
+	// now modify the ACK range in the original frame
+	ack.AckRanges[0].Smallest = 2
+	require.Equal(t, &logging.AckFrame{
+		AckRanges: []wire.AckRange{
+			{Smallest: 1, Largest: 3}, // unchanged, since the ACK ranges were cloned
+			{Smallest: 6, Largest: 7},
+		},
+		DelayTime: 42,
+		ECNCE:     123,
+		ECT0:      456,
+		ECT1:      789,
+	}, f)
+}
 
-	It("converts other frames", func() {
-		f := toLoggingFrame(&wire.MaxDataFrame{MaximumData: 1234})
-		Expect(f).To(BeAssignableToTypeOf(&logging.MaxDataFrame{}))
-		Expect(f).ToNot(BeAssignableToTypeOf(&logging.MaxStreamDataFrame{}))
-		mdf := f.(*logging.MaxDataFrame)
-		Expect(mdf.MaximumData).To(Equal(logging.ByteCount(1234)))
-	})
-})
+func TestConnectionLoggingDatagramFrame(t *testing.T) {
+	f := toLoggingFrame(&wire.DatagramFrame{Data: []byte("foobar")})
+	require.Equal(t, &logging.DatagramFrame{Length: 6}, f)
+}
+
+func TestConnectionLoggingOtherFrames(t *testing.T) {
+	f := toLoggingFrame(&wire.MaxDataFrame{MaximumData: 1234})
+	require.Equal(t, &logging.MaxDataFrame{MaximumData: 1234}, f)
+}
diff --git a/connection_test.go b/connection_test.go
index ab8014ac..47b9e1ca 100644
--- a/connection_test.go
+++ b/connection_test.go
@@ -6,12 +6,10 @@ import (
 	"crypto/rand"
 	"crypto/tls"
 	"errors"
-	"fmt"
-	"io"
 	"net"
 	"net/netip"
-	"runtime/pprof"
-	"strings"
+	"strconv"
+	"testing"
 	"time"
 
 	"github.com/quic-go/quic-go/internal/ackhandler"
@@ -24,3237 +22,2713 @@ import (
 	"github.com/quic-go/quic-go/internal/utils"
 	"github.com/quic-go/quic-go/internal/wire"
 	"github.com/quic-go/quic-go/logging"
-	"github.com/quic-go/quic-go/testutils"
 
-	. "github.com/onsi/ginkgo/v2"
-	. "github.com/onsi/gomega"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 	"go.uber.org/mock/gomock"
 )
 
-func areConnsRunning() bool {
-	var b bytes.Buffer
-	pprof.Lookup("goroutine").WriteTo(&b, 1)
-	return strings.Contains(b.String(), "quic-go.(*connection).run")
-}
-
-var _ = Describe("Connection", func() {
-	var (
-		conn          *connection
-		connRunner    *MockConnRunner
-		mconn         *MockSendConn
-		streamManager *MockStreamManager
-		packer        *MockPacker
-		cryptoSetup   *mocks.MockCryptoSetup
-		tracer        *mocklogging.MockConnectionTracer
-		capabilities  connCapabilities
-	)
-	remoteAddr := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 1337}
-	localAddr := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 7331}
-	srcConnID := protocol.ParseConnectionID([]byte{1, 2, 3, 4, 5, 6, 7, 8})
-	destConnID := protocol.ParseConnectionID([]byte{8, 7, 6, 5, 4, 3, 2, 1})
-	clientDestConnID := protocol.ParseConnectionID([]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
-
-	getCoalescedPacket := func(pn protocol.PacketNumber, encLevel protocol.EncryptionLevel) *coalescedPacket {
-		buffer := getPacketBuffer()
-		buffer.Data = append(buffer.Data, []byte("foobar")...)
-		packet := &coalescedPacket{buffer: buffer}
-		if encLevel != protocol.Encryption1RTT {
-			var typ protocol.PacketType
-			switch encLevel {
-			case protocol.EncryptionInitial:
-				typ = protocol.PacketTypeInitial
-			case protocol.EncryptionHandshake:
-				typ = protocol.PacketTypeHandshake
-			}
-			packet.longHdrPackets = []*longHeaderPacket{{
-				header: &wire.ExtendedHeader{
-					Header:       wire.Header{Type: typ},
-					PacketNumber: pn,
-				},
-				length: 6, // foobar
-			}}
-		} else {
-			packet.shortHdrPacket = &shortHeaderPacket{
-				PacketNumber: pn,
-				Length:       6,
-			}
-		}
-		return packet
-	}
+type testConnectionOpt func(*connection)
 
-	expectReplaceWithClosed := func() {
-		connRunner.EXPECT().ReplaceWithClosed(gomock.Any(), gomock.Any()).Do(func(connIDs []protocol.ConnectionID, _ []byte) {
-			Expect(connIDs).To(ContainElement(srcConnID))
-			if len(connIDs) > 1 {
-				Expect(connIDs).To(ContainElement(clientDestConnID))
-			}
-		})
-	}
+func connectionOptCryptoSetup(cs *mocks.MockCryptoSetup) testConnectionOpt {
+	return func(conn *connection) { conn.cryptoStreamHandler = cs }
+}
 
-	expectAppendPacket := func(packer *MockPacker, p shortHeaderPacket, b []byte) *MockPackerAppendPacketCall {
-		return packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), Version1).DoAndReturn(func(buf *packetBuffer, _ protocol.ByteCount, _ time.Time, _ protocol.Version) (shortHeaderPacket, error) {
-			buf.Data = append(buf.Data, b...)
-			return p, nil
-		})
-	}
+func connectionOptStreamManager(sm *MockStreamManager) testConnectionOpt {
+	return func(conn *connection) { conn.streamsMap = sm }
+}
 
-	enableGSO := func() { capabilities = connCapabilities{GSO: true} }
-
-	BeforeEach(func() {
-		Eventually(areConnsRunning).Should(BeFalse())
-
-		connRunner = NewMockConnRunner(mockCtrl)
-		mconn = NewMockSendConn(mockCtrl)
-		mconn.EXPECT().capabilities().DoAndReturn(func() connCapabilities { return capabilities }).AnyTimes()
-		mconn.EXPECT().RemoteAddr().Return(remoteAddr).AnyTimes()
-		mconn.EXPECT().LocalAddr().Return(localAddr).AnyTimes()
-		tokenGenerator := handshake.NewTokenGenerator([32]byte{0xa, 0xb, 0xc})
-		var tr *logging.ConnectionTracer
-		tr, tracer = mocklogging.NewMockConnectionTracer(mockCtrl)
-		tracer.EXPECT().NegotiatedVersion(gomock.Any(), gomock.Any(), gomock.Any()).MaxTimes(1)
-		tracer.EXPECT().SentTransportParameters(gomock.Any())
-		tracer.EXPECT().UpdatedKeyFromTLS(gomock.Any(), gomock.Any()).AnyTimes()
-		tracer.EXPECT().UpdatedCongestionState(gomock.Any())
-		ctx, cancel := context.WithCancelCause(context.Background())
-		conn = newConnection(
-			ctx,
-			cancel,
-			mconn,
-			connRunner,
-			protocol.ConnectionID{},
-			nil,
-			clientDestConnID,
-			destConnID,
-			srcConnID,
-			&protocol.DefaultConnectionIDGenerator{},
-			protocol.StatelessResetToken{},
-			populateConfig(&Config{DisablePathMTUDiscovery: true}),
-			&tls.Config{},
-			tokenGenerator,
-			false,
-			tr,
-			utils.DefaultLogger,
-			protocol.Version1,
-		).(*connection)
-		streamManager = NewMockStreamManager(mockCtrl)
-		conn.streamsMap = streamManager
-		packer = NewMockPacker(mockCtrl)
-		conn.packer = packer
-		cryptoSetup = mocks.NewMockCryptoSetup(mockCtrl)
-		conn.cryptoStreamHandler = cryptoSetup
-		conn.handshakeComplete = true
-		conn.idleTimeout = time.Hour
-	})
+func connectionOptConnFlowController(cfc *mocks.MockConnectionFlowController) testConnectionOpt {
+	return func(conn *connection) { conn.connFlowController = cfc }
+}
 
-	AfterEach(func() {
-		Eventually(areConnsRunning).Should(BeFalse())
-		capabilities = connCapabilities{}
-	})
+func connectionOptTracer(tr *logging.ConnectionTracer) testConnectionOpt {
+	return func(conn *connection) { conn.tracer = tr }
+}
 
-	Context("frame handling", func() {
-		Context("handling STREAM frames", func() {
-			It("passes STREAM frames to the stream", func() {
-				f := &wire.StreamFrame{
-					StreamID: 5,
-					Data:     []byte{0xde, 0xca, 0xfb, 0xad},
-				}
-				str := NewMockReceiveStreamI(mockCtrl)
-				now := time.Now()
-				str.EXPECT().handleStreamFrame(f, now)
-				streamManager.EXPECT().GetOrOpenReceiveStream(protocol.StreamID(5)).Return(str, nil)
-				Expect(conn.handleStreamFrame(f, now)).To(Succeed())
-			})
-
-			It("returns errors", func() {
-				testErr := errors.New("test err")
-				f := &wire.StreamFrame{
-					StreamID: 5,
-					Data:     []byte{0xde, 0xca, 0xfb, 0xad},
-				}
-				str := NewMockReceiveStreamI(mockCtrl)
-				now := time.Now()
-				str.EXPECT().handleStreamFrame(f, now).Return(testErr)
-				streamManager.EXPECT().GetOrOpenReceiveStream(protocol.StreamID(5)).Return(str, nil)
-				Expect(conn.handleStreamFrame(f, now)).To(MatchError(testErr))
-			})
-
-			It("ignores STREAM frames for closed streams", func() {
-				streamManager.EXPECT().GetOrOpenReceiveStream(protocol.StreamID(5)).Return(nil, nil) // for closed streams, the streamManager returns nil
-				Expect(conn.handleStreamFrame(&wire.StreamFrame{
-					StreamID: 5,
-					Data:     []byte("foobar"),
-				}, time.Now())).To(Succeed())
-			})
-		})
+func connectionOptSentPacketHandler(sph ackhandler.SentPacketHandler) testConnectionOpt {
+	return func(conn *connection) { conn.sentPacketHandler = sph }
+}
 
-		Context("handling ACK frames", func() {
-			It("informs the SentPacketHandler about ACKs", func() {
-				f := &wire.AckFrame{AckRanges: []wire.AckRange{{Smallest: 2, Largest: 3}}}
-				sph := mockackhandler.NewMockSentPacketHandler(mockCtrl)
-				sph.EXPECT().ReceivedAck(f, protocol.EncryptionHandshake, gomock.Any())
-				conn.sentPacketHandler = sph
-				err := conn.handleAckFrame(f, protocol.EncryptionHandshake)
-				Expect(err).ToNot(HaveOccurred())
-			})
-		})
+func connectionOptReceivedPacketHandler(rph ackhandler.ReceivedPacketHandler) testConnectionOpt {
+	return func(conn *connection) { conn.receivedPacketHandler = rph }
+}
 
-		Context("handling RESET_STREAM frames", func() {
-			It("closes the streams for writing", func() {
-				f := &wire.ResetStreamFrame{
-					StreamID:  555,
-					ErrorCode: 42,
-					FinalSize: 0x1337,
-				}
-				str := NewMockReceiveStreamI(mockCtrl)
-				streamManager.EXPECT().GetOrOpenReceiveStream(protocol.StreamID(555)).Return(str, nil)
-				now := time.Now()
-				str.EXPECT().handleResetStreamFrame(f, now)
-				Expect(conn.handleResetStreamFrame(f, now)).To(Succeed())
-			})
-
-			It("returns errors", func() {
-				f := &wire.ResetStreamFrame{
-					StreamID:  7,
-					FinalSize: 0x1337,
-				}
-				testErr := errors.New("flow control violation")
-				str := NewMockReceiveStreamI(mockCtrl)
-				streamManager.EXPECT().GetOrOpenReceiveStream(protocol.StreamID(7)).Return(str, nil)
-				now := time.Now()
-				str.EXPECT().handleResetStreamFrame(f, now).Return(testErr)
-				Expect(conn.handleResetStreamFrame(f, now)).To(MatchError(testErr))
-			})
-
-			It("ignores RESET_STREAM frames for closed streams", func() {
-				streamManager.EXPECT().GetOrOpenReceiveStream(protocol.StreamID(3)).Return(nil, nil)
-				Expect(conn.handleFrame(&wire.ResetStreamFrame{
-					StreamID:  3,
-					ErrorCode: 42,
-				}, protocol.Encryption1RTT, protocol.ConnectionID{}, time.Now())).To(Succeed())
-			})
-		})
+func connectionOptUnpacker(u unpacker) testConnectionOpt {
+	return func(conn *connection) { conn.unpacker = u }
+}
 
-		Context("handling MAX_DATA and MAX_STREAM_DATA frames", func() {
-			var connFC *mocks.MockConnectionFlowController
+func connectionOptSender(s sender) testConnectionOpt {
+	return func(conn *connection) { conn.sendQueue = s }
+}
 
-			BeforeEach(func() {
-				connFC = mocks.NewMockConnectionFlowController(mockCtrl)
-				conn.connFlowController = connFC
-			})
+func connectionOptHandshakeConfirmed() testConnectionOpt {
+	return func(conn *connection) {
+		conn.handshakeComplete = true
+		conn.handshakeConfirmed = true
+	}
+}
 
-			It("updates the flow control window of a stream", func() {
-				f := &wire.MaxStreamDataFrame{
-					StreamID:          12345,
-					MaximumStreamData: 0x1337,
-				}
-				str := NewMockSendStreamI(mockCtrl)
-				streamManager.EXPECT().GetOrOpenSendStream(protocol.StreamID(12345)).Return(str, nil)
-				str.EXPECT().updateSendWindow(protocol.ByteCount(0x1337))
-				Expect(conn.handleMaxStreamDataFrame(f)).To(Succeed())
-			})
-
-			It("updates the flow control window of the connection", func() {
-				offset := protocol.ByteCount(0x800000)
-				connFC.EXPECT().UpdateSendWindow(offset)
-				conn.handleMaxDataFrame(&wire.MaxDataFrame{MaximumData: offset})
-			})
-
-			It("ignores MAX_STREAM_DATA frames for a closed stream", func() {
-				streamManager.EXPECT().GetOrOpenSendStream(protocol.StreamID(10)).Return(nil, nil)
-				Expect(conn.handleFrame(&wire.MaxStreamDataFrame{
-					StreamID:          10,
-					MaximumStreamData: 1337,
-				}, protocol.Encryption1RTT, protocol.ConnectionID{}, time.Now())).To(Succeed())
-			})
-		})
+func connectionOptRTTStats(s *utils.RTTStats) testConnectionOpt {
+	return func(conn *connection) { conn.rttStats = s }
+}
 
-		Context("handling MAX_STREAM_ID frames", func() {
-			It("passes the frame to the streamsMap", func() {
-				f := &wire.MaxStreamsFrame{
-					Type:         protocol.StreamTypeUni,
-					MaxStreamNum: 10,
-				}
-				streamManager.EXPECT().HandleMaxStreamsFrame(f)
-				conn.handleMaxStreamsFrame(f)
-			})
-		})
+func connectionOptRetrySrcConnID(rcid protocol.ConnectionID) testConnectionOpt {
+	return func(conn *connection) { conn.retrySrcConnID = &rcid }
+}
 
-		Context("handling STOP_SENDING frames", func() {
-			It("passes the frame to the stream", func() {
-				f := &wire.StopSendingFrame{
-					StreamID:  5,
-					ErrorCode: 10,
-				}
-				str := NewMockSendStreamI(mockCtrl)
-				streamManager.EXPECT().GetOrOpenSendStream(protocol.StreamID(5)).Return(str, nil)
-				str.EXPECT().handleStopSendingFrame(f)
-				err := conn.handleStopSendingFrame(f)
-				Expect(err).ToNot(HaveOccurred())
-			})
-
-			It("ignores STOP_SENDING frames for a closed stream", func() {
-				streamManager.EXPECT().GetOrOpenSendStream(protocol.StreamID(3)).Return(nil, nil)
-				Expect(conn.handleFrame(&wire.StopSendingFrame{
-					StreamID:  3,
-					ErrorCode: 1337,
-				}, protocol.Encryption1RTT, protocol.ConnectionID{}, time.Now())).To(Succeed())
-			})
-		})
+type testConnection struct {
+	conn       *connection
+	connRunner *MockConnRunner
+	sendConn   *MockSendConn
+	packer     *MockPacker
+	destConnID protocol.ConnectionID
+	srcConnID  protocol.ConnectionID
+}
 
-		It("handles NEW_CONNECTION_ID frames", func() {
-			connID := protocol.ParseConnectionID([]byte{1, 2, 3, 4})
-			Expect(conn.handleFrame(&wire.NewConnectionIDFrame{
-				SequenceNumber: 10,
-				ConnectionID:   connID,
-			}, protocol.Encryption1RTT, protocol.ConnectionID{}, time.Now())).To(Succeed())
-			Expect(conn.connIDManager.queue.Back().Value.ConnectionID).To(Equal(connID))
-		})
+func newServerTestConnection(
+	t *testing.T,
+	mockCtrl *gomock.Controller,
+	config *Config,
+	gso bool,
+	opts ...testConnectionOpt,
+) *testConnection {
+	if mockCtrl == nil {
+		mockCtrl = gomock.NewController(t)
+	}
+	remoteAddr := &net.UDPAddr{IP: net.IPv4(1, 2, 3, 4), Port: 4321}
+	localAddr := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 1234}
+	connRunner := NewMockConnRunner(mockCtrl)
+	sendConn := NewMockSendConn(mockCtrl)
+	sendConn.EXPECT().capabilities().Return(connCapabilities{GSO: gso}).AnyTimes()
+	sendConn.EXPECT().RemoteAddr().Return(remoteAddr).AnyTimes()
+	sendConn.EXPECT().LocalAddr().Return(localAddr).AnyTimes()
+	packer := NewMockPacker(mockCtrl)
+	b := make([]byte, 12)
+	rand.Read(b)
+	origDestConnID := protocol.ParseConnectionID(b[:6])
+	srcConnID := protocol.ParseConnectionID(b[6:12])
+	ctx, cancel := context.WithCancelCause(context.Background())
+	if config == nil {
+		config = &Config{DisablePathMTUDiscovery: true}
+	}
+	conn := newConnection(
+		ctx,
+		cancel,
+		sendConn,
+		connRunner,
+		origDestConnID,
+		nil,
+		protocol.ConnectionID{},
+		protocol.ConnectionID{},
+		srcConnID,
+		&protocol.DefaultConnectionIDGenerator{},
+		protocol.StatelessResetToken{},
+		populateConfig(config),
+		&tls.Config{},
+		handshake.NewTokenGenerator(handshake.TokenProtectorKey{}),
+		false,
+		nil,
+		utils.DefaultLogger,
+		protocol.Version1,
+	).(*connection)
+	conn.packer = packer
+	for _, opt := range opts {
+		opt(conn)
+	}
+	return &testConnection{
+		conn:       conn,
+		connRunner: connRunner,
+		sendConn:   sendConn,
+		packer:     packer,
+		destConnID: origDestConnID,
+		srcConnID:  srcConnID,
+	}
+}
 
-		It("handles PING frames", func() {
-			err := conn.handleFrame(&wire.PingFrame{}, protocol.Encryption1RTT, protocol.ConnectionID{}, time.Now())
-			Expect(err).NotTo(HaveOccurred())
-		})
+func newClientTestConnection(
+	t *testing.T,
+	mockCtrl *gomock.Controller,
+	config *Config,
+	enable0RTT bool,
+	opts ...testConnectionOpt,
+) *testConnection {
+	if mockCtrl == nil {
+		mockCtrl = gomock.NewController(t)
+	}
+	remoteAddr := &net.UDPAddr{IP: net.IPv4(1, 2, 3, 4), Port: 4321}
+	localAddr := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 1234}
+	connRunner := NewMockConnRunner(mockCtrl)
+	sendConn := NewMockSendConn(mockCtrl)
+	sendConn.EXPECT().capabilities().Return(connCapabilities{}).AnyTimes()
+	sendConn.EXPECT().RemoteAddr().Return(remoteAddr).AnyTimes()
+	sendConn.EXPECT().LocalAddr().Return(localAddr).AnyTimes()
+	packer := NewMockPacker(mockCtrl)
+	b := make([]byte, 12)
+	rand.Read(b)
+	destConnID := protocol.ParseConnectionID(b[:6])
+	srcConnID := protocol.ParseConnectionID(b[6:12])
+	if config == nil {
+		config = &Config{DisablePathMTUDiscovery: true}
+	}
+	conn := newClientConnection(
+		context.Background(),
+		sendConn,
+		connRunner,
+		destConnID,
+		srcConnID,
+		&protocol.DefaultConnectionIDGenerator{},
+		populateConfig(config),
+		&tls.Config{ServerName: "quic-go.net"},
+		0,
+		enable0RTT,
+		false,
+		nil,
+		utils.DefaultLogger,
+		protocol.Version1,
+	).(*connection)
+	conn.packer = packer
+	for _, opt := range opts {
+		opt(conn)
+	}
+	return &testConnection{
+		conn:       conn,
+		connRunner: connRunner,
+		sendConn:   sendConn,
+		packer:     packer,
+		destConnID: destConnID,
+		srcConnID:  srcConnID,
+	}
+}
 
-		It("rejects PATH_RESPONSE frames", func() {
-			err := conn.handleFrame(&wire.PathResponseFrame{Data: [8]byte{1, 2, 3, 4, 5, 6, 7, 8}}, protocol.Encryption1RTT, protocol.ConnectionID{}, time.Now())
-			Expect(err).To(MatchError("unexpected PATH_RESPONSE frame"))
-		})
+func TestConnectionHandleReceiveStreamFrames(t *testing.T) {
+	const streamID protocol.StreamID = 5
+	now := time.Now()
+	connID := protocol.ConnectionID{}
+	f := &wire.StreamFrame{StreamID: streamID, Data: []byte("foobar")}
+	rsf := &wire.ResetStreamFrame{StreamID: streamID, ErrorCode: 42, FinalSize: 1337}
+	sdbf := &wire.StreamDataBlockedFrame{StreamID: streamID, MaximumStreamData: 1337}
+
+	t.Run("for existing and new streams", func(t *testing.T) {
+		mockCtrl := gomock.NewController(t)
+		streamsMap := NewMockStreamManager(mockCtrl)
+		tc := newServerTestConnection(t, mockCtrl, nil, false, connectionOptStreamManager(streamsMap))
+		str := NewMockReceiveStreamI(mockCtrl)
+		// STREAM frame
+		streamsMap.EXPECT().GetOrOpenReceiveStream(streamID).Return(str, nil)
+		str.EXPECT().handleStreamFrame(f, now)
+		require.NoError(t, tc.conn.handleFrame(f, protocol.Encryption1RTT, connID, now))
+		// RESET_STREAM frame
+		streamsMap.EXPECT().GetOrOpenReceiveStream(streamID).Return(str, nil)
+		str.EXPECT().handleResetStreamFrame(rsf, now)
+		require.NoError(t, tc.conn.handleFrame(rsf, protocol.Encryption1RTT, connID, now))
+		// STREAM_DATA_BLOCKED frames are not passed to the stream
+		require.NoError(t, tc.conn.handleFrame(sdbf, protocol.Encryption1RTT, connID, now))
+	})
 
-		It("handles PATH_CHALLENGE frames", func() {
-			data := [8]byte{1, 2, 3, 4, 5, 6, 7, 8}
-			err := conn.handleFrame(&wire.PathChallengeFrame{Data: data}, protocol.Encryption1RTT, protocol.ConnectionID{}, time.Now())
-			Expect(err).ToNot(HaveOccurred())
-			frames, _ := conn.framer.AppendControlFrames(nil, 1000, time.Now(), protocol.Version1)
-			Expect(frames).To(Equal([]ackhandler.Frame{{Frame: &wire.PathResponseFrame{Data: data}}}))
-		})
+	t.Run("for closed streams", func(t *testing.T) {
+		mockCtrl := gomock.NewController(t)
+		streamsMap := NewMockStreamManager(mockCtrl)
+		tc := newServerTestConnection(t, mockCtrl, nil, false, connectionOptStreamManager(streamsMap))
+		// STREAM frame
+		streamsMap.EXPECT().GetOrOpenReceiveStream(streamID).Return(nil, nil)
+		require.NoError(t, tc.conn.handleFrame(f, protocol.Encryption1RTT, connID, now))
+		// RESET_STREAM frame
+		streamsMap.EXPECT().GetOrOpenReceiveStream(streamID).Return(nil, nil)
+		require.NoError(t, tc.conn.handleFrame(rsf, protocol.Encryption1RTT, connID, now))
+		// STREAM_DATA_BLOCKED frames are not passed to the stream
+		// TODO(#4822): validate stream ID of STREAM_DATA_BLOCKED frames
+		require.NoError(t, tc.conn.handleFrame(sdbf, protocol.Encryption1RTT, connID, now))
+	})
 
-		It("rejects NEW_TOKEN frames", func() {
-			err := conn.handleNewTokenFrame(&wire.NewTokenFrame{})
-			Expect(err).To(HaveOccurred())
-			Expect(err).To(BeAssignableToTypeOf(&qerr.TransportError{}))
-			Expect(err.(*qerr.TransportError).ErrorCode).To(Equal(qerr.ProtocolViolation))
-		})
+	t.Run("for invalid streams", func(t *testing.T) {
+		mockCtrl := gomock.NewController(t)
+		streamsMap := NewMockStreamManager(mockCtrl)
+		tc := newServerTestConnection(t, mockCtrl, nil, false, connectionOptStreamManager(streamsMap))
+		testErr := errors.New("test err")
+		// STREAM frame
+		streamsMap.EXPECT().GetOrOpenReceiveStream(streamID).Return(nil, testErr)
+		require.ErrorIs(t, tc.conn.handleFrame(f, protocol.Encryption1RTT, connID, now), testErr)
+		// RESET_STREAM frame
+		streamsMap.EXPECT().GetOrOpenReceiveStream(streamID).Return(nil, testErr)
+		require.ErrorIs(t, tc.conn.handleFrame(rsf, protocol.Encryption1RTT, connID, now), testErr)
+		// STREAM_DATA_BLOCKED frames are not passed to the stream
+		// TODO(#4822): validate stream ID of STREAM_DATA_BLOCKED frames
+		require.NoError(t, tc.conn.handleFrame(sdbf, protocol.Encryption1RTT, connID, now))
+	})
+}
 
-		It("handles BLOCKED frames", func() {
-			err := conn.handleFrame(&wire.DataBlockedFrame{}, protocol.Encryption1RTT, protocol.ConnectionID{}, time.Now())
-			Expect(err).NotTo(HaveOccurred())
-		})
+func TestConnectionHandleSendStreamFrames(t *testing.T) {
+	const streamID protocol.StreamID = 3
+	now := time.Now()
+	connID := protocol.ConnectionID{}
+	ss := &wire.StopSendingFrame{StreamID: streamID, ErrorCode: 42}
+	msd := &wire.MaxStreamDataFrame{StreamID: streamID, MaximumStreamData: 1337}
+
+	t.Run("for existing and new streams", func(t *testing.T) {
+		mockCtrl := gomock.NewController(t)
+		streamsMap := NewMockStreamManager(mockCtrl)
+		tc := newServerTestConnection(t, mockCtrl, nil, false, connectionOptStreamManager(streamsMap))
+		str := NewMockSendStreamI(mockCtrl)
+		// STOP_SENDING frame
+		streamsMap.EXPECT().GetOrOpenSendStream(streamID).Return(str, nil)
+		str.EXPECT().handleStopSendingFrame(ss)
+		require.NoError(t, tc.conn.handleFrame(ss, protocol.Encryption1RTT, connID, now))
+		// MAX_STREAM_DATA frame
+		streamsMap.EXPECT().GetOrOpenSendStream(streamID).Return(str, nil)
+		str.EXPECT().updateSendWindow(msd.MaximumStreamData)
+		require.NoError(t, tc.conn.handleFrame(msd, protocol.Encryption1RTT, connID, now))
+	})
 
-		It("handles STREAM_BLOCKED frames", func() {
-			err := conn.handleFrame(&wire.StreamDataBlockedFrame{}, protocol.Encryption1RTT, protocol.ConnectionID{}, time.Now())
-			Expect(err).NotTo(HaveOccurred())
-		})
+	t.Run("for closed streams", func(t *testing.T) {
+		mockCtrl := gomock.NewController(t)
+		streamsMap := NewMockStreamManager(mockCtrl)
+		tc := newServerTestConnection(t, mockCtrl, nil, false, connectionOptStreamManager(streamsMap))
+		// STOP_SENDING frame
+		streamsMap.EXPECT().GetOrOpenSendStream(streamID).Return(nil, nil)
+		require.NoError(t, tc.conn.handleFrame(ss, protocol.Encryption1RTT, connID, now))
+		// MAX_STREAM_DATA frame
+		streamsMap.EXPECT().GetOrOpenSendStream(streamID).Return(nil, nil)
+		require.NoError(t, tc.conn.handleFrame(msd, protocol.Encryption1RTT, connID, now))
+	})
 
-		It("handles STREAMS_BLOCKED frames", func() {
-			err := conn.handleFrame(&wire.StreamsBlockedFrame{}, protocol.Encryption1RTT, protocol.ConnectionID{}, time.Now())
-			Expect(err).NotTo(HaveOccurred())
-		})
+	t.Run("for invalid streams", func(t *testing.T) {
+		mockCtrl := gomock.NewController(t)
+		streamsMap := NewMockStreamManager(mockCtrl)
+		tc := newServerTestConnection(t, mockCtrl, nil, false, connectionOptStreamManager(streamsMap))
+		testErr := errors.New("test err")
+		// STOP_SENDING frame
+		streamsMap.EXPECT().GetOrOpenSendStream(streamID).Return(nil, testErr)
+		require.ErrorIs(t, tc.conn.handleFrame(ss, protocol.Encryption1RTT, connID, now), testErr)
+		// MAX_STREAM_DATA frame
+		streamsMap.EXPECT().GetOrOpenSendStream(streamID).Return(nil, testErr)
+		require.ErrorIs(t, tc.conn.handleFrame(msd, protocol.Encryption1RTT, connID, now), testErr)
+	})
+}
 
-		It("handles CONNECTION_CLOSE frames, with a transport error code", func() {
-			expectedErr := &qerr.TransportError{
-				Remote:       true,
-				ErrorCode:    qerr.StreamLimitError,
-				ErrorMessage: "foobar",
-			}
-			streamManager.EXPECT().CloseWithError(expectedErr)
-			connRunner.EXPECT().ReplaceWithClosed(gomock.Any(), gomock.Any()).Do(func(connIDs []protocol.ConnectionID, _ []byte) {
-				Expect(connIDs).To(ConsistOf(clientDestConnID, srcConnID))
-			})
-			cryptoSetup.EXPECT().Close()
-			gomock.InOrder(
-				tracer.EXPECT().ClosedConnection(expectedErr),
-				tracer.EXPECT().Close(),
-			)
+func TestConnectionHandleStreamNumFrames(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	streamsMap := NewMockStreamManager(mockCtrl)
+	tc := newServerTestConnection(t, mockCtrl, nil, false, connectionOptStreamManager(streamsMap))
+	now := time.Now()
+	connID := protocol.ConnectionID{}
+	// MAX_STREAMS frame
+	msf := &wire.MaxStreamsFrame{Type: protocol.StreamTypeBidi, MaxStreamNum: 10}
+	streamsMap.EXPECT().HandleMaxStreamsFrame(msf)
+	require.NoError(t, tc.conn.handleFrame(msf, protocol.Encryption1RTT, connID, now))
+	// STREAMS_BLOCKED frame
+	tc.conn.handleFrame(&wire.StreamsBlockedFrame{Type: protocol.StreamTypeBidi, StreamLimit: 1}, protocol.Encryption1RTT, connID, now)
+}
 
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				Expect(conn.run()).To(MatchError(expectedErr))
-			}()
-			Expect(conn.handleFrame(&wire.ConnectionCloseFrame{
-				ErrorCode:    uint64(qerr.StreamLimitError),
-				ReasonPhrase: "foobar",
-			}, protocol.Encryption1RTT, protocol.ConnectionID{}, time.Now())).To(Succeed())
-			Eventually(conn.Context().Done()).Should(BeClosed())
-		})
+func TestConnectionHandleConnectionFlowControlFrames(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	connFC := mocks.NewMockConnectionFlowController(mockCtrl)
+	tc := newServerTestConnection(t, mockCtrl, nil, false, connectionOptConnFlowController(connFC))
+	now := time.Now()
+	connID := protocol.ConnectionID{}
+	// MAX_DATA frame
+	connFC.EXPECT().UpdateSendWindow(protocol.ByteCount(1337))
+	require.NoError(t, tc.conn.handleFrame(&wire.MaxDataFrame{MaximumData: 1337}, protocol.Encryption1RTT, connID, now))
+	// DATA_BLOCKED frame
+	require.NoError(t, tc.conn.handleFrame(&wire.DataBlockedFrame{MaximumData: 1337}, protocol.Encryption1RTT, connID, now))
+}
 
-		It("handles CONNECTION_CLOSE frames, with an application error code", func() {
-			testErr := &qerr.ApplicationError{
-				Remote:       true,
-				ErrorCode:    0x1337,
-				ErrorMessage: "foobar",
-			}
-			streamManager.EXPECT().CloseWithError(testErr)
-			connRunner.EXPECT().ReplaceWithClosed(gomock.Any(), gomock.Any()).Do(func(connIDs []protocol.ConnectionID, _ []byte) {
-				Expect(connIDs).To(ConsistOf(clientDestConnID, srcConnID))
-			})
-			cryptoSetup.EXPECT().Close()
-			gomock.InOrder(
-				tracer.EXPECT().ClosedConnection(testErr),
-				tracer.EXPECT().Close(),
-			)
+func TestConnectionOpenStreams(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	streamsMap := NewMockStreamManager(mockCtrl)
+	tc := newServerTestConnection(t, mockCtrl, nil, false, connectionOptStreamManager(streamsMap))
+
+	// using OpenStream
+	mstr := NewMockStreamI(mockCtrl)
+	streamsMap.EXPECT().OpenStream().Return(mstr, nil)
+	str, err := tc.conn.OpenStream()
+	require.NoError(t, err)
+	require.Equal(t, mstr, str)
+
+	// using OpenStreamSync
+	streamsMap.EXPECT().OpenStreamSync(context.Background()).Return(mstr, nil)
+	str, err = tc.conn.OpenStreamSync(context.Background())
+	require.NoError(t, err)
+	require.Equal(t, mstr, str)
+
+	// using OpenUniStream
+	streamsMap.EXPECT().OpenUniStream().Return(mstr, nil)
+	ustr, err := tc.conn.OpenUniStream()
+	require.NoError(t, err)
+	require.Equal(t, mstr, ustr)
+
+	// using OpenUniStreamSync
+	streamsMap.EXPECT().OpenUniStreamSync(context.Background()).Return(mstr, nil)
+	ustr, err = tc.conn.OpenUniStreamSync(context.Background())
+	require.NoError(t, err)
+	require.Equal(t, mstr, ustr)
+}
 
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				Expect(conn.run()).To(MatchError(testErr))
-			}()
-			ccf := &wire.ConnectionCloseFrame{
-				ErrorCode:          0x1337,
-				ReasonPhrase:       "foobar",
-				IsApplicationError: true,
-			}
-			Expect(conn.handleFrame(ccf, protocol.Encryption1RTT, protocol.ConnectionID{}, time.Now())).To(Succeed())
-			Eventually(conn.Context().Done()).Should(BeClosed())
-			Expect(context.Cause(conn.Context())).To(MatchError(testErr))
-		})
+func TestConnectionAcceptStreams(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	streamsMap := NewMockStreamManager(mockCtrl)
+	tc := newServerTestConnection(t, mockCtrl, nil, false, connectionOptStreamManager(streamsMap))
+
+	// bidirectional streams
+	ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+	defer cancel()
+	mstr := NewMockStreamI(mockCtrl)
+	streamsMap.EXPECT().AcceptStream(ctx).Return(mstr, nil)
+	str, err := tc.conn.AcceptStream(ctx)
+	require.NoError(t, err)
+	require.Equal(t, mstr, str)
+
+	// unidirectional streams
+	streamsMap.EXPECT().AcceptUniStream(ctx).Return(mstr, nil)
+	ustr, err := tc.conn.AcceptUniStream(ctx)
+	require.NoError(t, err)
+	require.Equal(t, mstr, ustr)
+}
 
-		It("errors on HANDSHAKE_DONE frames", func() {
-			Expect(conn.handleHandshakeDoneFrame()).To(MatchError(&qerr.TransportError{
-				ErrorCode:    qerr.ProtocolViolation,
-				ErrorMessage: "received a HANDSHAKE_DONE frame",
-			}))
+func TestConnectionServerInvalidFrames(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	tc := newServerTestConnection(t, mockCtrl, nil, false)
+
+	for _, test := range []struct {
+		Name  string
+		Frame wire.Frame
+	}{
+		{Name: "NEW_TOKEN", Frame: &wire.NewTokenFrame{Token: []byte("foobar")}},
+		{Name: "HANDSHAKE_DONE", Frame: &wire.HandshakeDoneFrame{}},
+		{Name: "PATH_RESPONSE", Frame: &wire.PathResponseFrame{Data: [8]byte{1, 2, 3, 4, 5, 6, 7, 8}}},
+	} {
+		t.Run(test.Name, func(t *testing.T) {
+			require.ErrorIs(t,
+				tc.conn.handleFrame(test.Frame, protocol.Encryption1RTT, protocol.ConnectionID{}, time.Now()),
+				&qerr.TransportError{ErrorCode: qerr.ProtocolViolation},
+			)
 		})
-	})
+	}
+}
 
-	Context("closing", func() {
-		var (
-			runErr         chan error
-			expectedRunErr error
-		)
+func TestConnectionTransportError(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+	tc := newServerTestConnection(t, mockCtrl, nil, false, connectionOptTracer(tr))
+	errChan := make(chan error, 1)
+	expectedErr := &qerr.TransportError{
+		ErrorCode:    1337,
+		FrameType:    42,
+		ErrorMessage: "test error",
+	}
+	tc.connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
+	b := getPacketBuffer()
+	b.Data = append(b.Data, []byte("connection close")...)
+	tc.packer.EXPECT().PackConnectionClose(expectedErr, gomock.Any(), protocol.Version1).Return(&coalescedPacket{buffer: b}, nil)
+	tc.sendConn.EXPECT().Write([]byte("connection close"), gomock.Any(), gomock.Any())
+	tc.connRunner.EXPECT().ReplaceWithClosed(gomock.Any(), gomock.Any()).AnyTimes()
+	gomock.InOrder(
+		tracer.EXPECT().ClosedConnection(expectedErr),
+		tracer.EXPECT().Close(),
+	)
 
-		BeforeEach(func() {
-			runErr = make(chan error, 1)
-			expectedRunErr = nil
-		})
+	go func() { errChan <- tc.conn.run() }()
+	tc.conn.closeLocal(expectedErr)
 
-		AfterEach(func() {
-			if expectedRunErr != nil {
-				Eventually(runErr).Should(Receive(MatchError(expectedRunErr)))
-			} else {
-				Eventually(runErr).Should(Receive())
-			}
-		})
+	select {
+	case err := <-errChan:
+		require.ErrorIs(t, err, expectedErr)
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
 
-		runConn := func() {
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				runErr <- conn.run()
-			}()
-			Eventually(areConnsRunning).Should(BeTrue())
-		}
+	// further calls to CloseWithError don't do anything
+	tc.conn.CloseWithError(42, "another error")
+}
 
-		It("shuts down without error", func() {
-			conn.handshakeComplete = true
-			runConn()
-			streamManager.EXPECT().CloseWithError(&qerr.ApplicationError{})
-			expectReplaceWithClosed()
-			cryptoSetup.EXPECT().Close()
-			buffer := getPacketBuffer()
-			buffer.Data = append(buffer.Data, []byte("connection close")...)
-			packer.EXPECT().PackApplicationClose(gomock.Any(), gomock.Any(), conn.version).DoAndReturn(func(e *qerr.ApplicationError, _ protocol.ByteCount, _ protocol.Version) (*coalescedPacket, error) {
-				Expect(e.ErrorCode).To(BeEquivalentTo(qerr.NoError))
-				Expect(e.ErrorMessage).To(BeEmpty())
-				return &coalescedPacket{buffer: buffer}, nil
-			})
-			mconn.EXPECT().Write([]byte("connection close"), gomock.Any(), gomock.Any())
-			gomock.InOrder(
-				tracer.EXPECT().ClosedConnection(gomock.Any()).Do(func(e error) {
-					var appErr *ApplicationError
-					Expect(errors.As(e, &appErr)).To(BeTrue())
-					Expect(appErr.Remote).To(BeFalse())
-					Expect(appErr.ErrorCode).To(BeZero())
-				}),
-				tracer.EXPECT().Close(),
-			)
-			conn.CloseWithError(0, "")
-			Eventually(areConnsRunning).Should(BeFalse())
-			Expect(conn.Context().Done()).To(BeClosed())
-		})
+func TestConnectionApplicationClose(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+	tc := newServerTestConnection(t, mockCtrl, nil, false, connectionOptTracer(tr))
+	errChan := make(chan error, 1)
+	expectedErr := &qerr.ApplicationError{
+		ErrorCode:    1337,
+		ErrorMessage: "test error",
+	}
+	tc.connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
+	b := getPacketBuffer()
+	b.Data = append(b.Data, []byte("connection close")...)
+	tc.packer.EXPECT().PackApplicationClose(expectedErr, gomock.Any(), protocol.Version1).Return(&coalescedPacket{buffer: b}, nil)
+	tc.sendConn.EXPECT().Write([]byte("connection close"), gomock.Any(), gomock.Any())
+	tc.connRunner.EXPECT().ReplaceWithClosed(gomock.Any(), gomock.Any()).AnyTimes()
+	gomock.InOrder(
+		tracer.EXPECT().ClosedConnection(expectedErr),
+		tracer.EXPECT().Close(),
+	)
 
-		It("Clears any pending receivedPackets", func() {
-			conn.handshakeComplete = true
-			runConn()
-			streamManager.EXPECT().CloseWithError(&qerr.ApplicationError{})
-			expectReplaceWithClosed()
-			cryptoSetup.EXPECT().Close()
-			packer.EXPECT().PackApplicationClose(gomock.Any(), gomock.Any(), conn.version).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil)
-			mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
-			gomock.InOrder(
-				tracer.EXPECT().ClosedConnection(gomock.Any()).Do(func(e error) {
-					// Send any old packet. It should get dropped.
-					conn.handlePacket(receivedPacket{
-						rcvTime:    time.Now(),
-						remoteAddr: &net.UDPAddr{},
-						buffer:     getPacketBuffer(),
-						data:       []byte("foobar"),
-					})
-
-					var appErr *ApplicationError
-					Expect(errors.As(e, &appErr)).To(BeTrue())
-					Expect(appErr.Remote).To(BeFalse())
-					Expect(appErr.ErrorCode).To(BeZero())
-				}),
-				tracer.EXPECT().Close(),
-			)
-			conn.CloseWithError(0, "")
-			Eventually(areConnsRunning).Should(BeFalse())
-			Expect(conn.Context().Done()).To(BeClosed())
-			Expect(len(conn.receivedPackets)).To(BeZero())
-		})
+	go func() { errChan <- tc.conn.run() }()
+	tc.conn.CloseWithError(1337, "test error")
 
-		It("only closes once", func() {
-			runConn()
-			streamManager.EXPECT().CloseWithError(gomock.Any())
-			expectReplaceWithClosed()
-			cryptoSetup.EXPECT().Close()
-			packer.EXPECT().PackApplicationClose(gomock.Any(), gomock.Any(), conn.version).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil)
-			mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
-			tracer.EXPECT().ClosedConnection(gomock.Any())
-			tracer.EXPECT().Close()
-			conn.CloseWithError(0, "")
-			conn.CloseWithError(0, "")
-			Eventually(areConnsRunning).Should(BeFalse())
-			Expect(conn.Context().Done()).To(BeClosed())
-		})
+	select {
+	case err := <-errChan:
+		require.ErrorIs(t, err, expectedErr)
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
 
-		It("closes with an error", func() {
-			runConn()
-			expectedErr := &qerr.ApplicationError{
-				ErrorCode:    0x1337,
-				ErrorMessage: "test error",
-			}
-			streamManager.EXPECT().CloseWithError(expectedErr)
-			expectReplaceWithClosed()
-			cryptoSetup.EXPECT().Close()
-			packer.EXPECT().PackApplicationClose(expectedErr, gomock.Any(), conn.version).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil)
-			mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
-			gomock.InOrder(
-				tracer.EXPECT().ClosedConnection(expectedErr),
-				tracer.EXPECT().Close(),
-			)
-			conn.CloseWithError(0x1337, "test error")
-			Eventually(areConnsRunning).Should(BeFalse())
-			Expect(conn.Context().Done()).To(BeClosed())
-			Expect(context.Cause(conn.Context())).To(MatchError(expectedErr))
-		})
+	// further calls to CloseWithError don't do anything
+	tc.conn.CloseWithError(42, "another error")
+}
 
-		It("includes the frame type in transport-level close frames", func() {
-			runConn()
-			expectedErr := &qerr.TransportError{
-				ErrorCode:    0x1337,
-				FrameType:    0x42,
-				ErrorMessage: "test error",
-			}
-			streamManager.EXPECT().CloseWithError(expectedErr)
-			expectReplaceWithClosed()
-			cryptoSetup.EXPECT().Close()
-			packer.EXPECT().PackConnectionClose(expectedErr, gomock.Any(), conn.version).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil)
-			mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
-			gomock.InOrder(
-				tracer.EXPECT().ClosedConnection(expectedErr),
-				tracer.EXPECT().Close(),
-			)
-			conn.closeLocal(expectedErr)
-			Eventually(areConnsRunning).Should(BeFalse())
-			Expect(conn.Context().Done()).To(BeClosed())
-		})
+func TestConnectionStatelessReset(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+	tc := newServerTestConnection(t, mockCtrl, nil, false, connectionOptTracer(tr))
+	errChan := make(chan error, 1)
+	tc.connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
+	gomock.InOrder(
+		tracer.EXPECT().ClosedConnection(&StatelessResetError{}),
+		tracer.EXPECT().Close(),
+	)
 
-		It("destroys the connection", func() {
-			runConn()
-			testErr := errors.New("close")
-			streamManager.EXPECT().CloseWithError(gomock.Any())
-			connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
-			cryptoSetup.EXPECT().Close()
-			// don't EXPECT any calls to mconn.Write()
-			gomock.InOrder(
-				tracer.EXPECT().ClosedConnection(gomock.Any()).Do(func(e error) {
-					var transportErr *TransportError
-					Expect(errors.As(e, &transportErr)).To(BeTrue())
-					Expect(transportErr.Remote).To(BeFalse())
-					Expect(transportErr.ErrorCode).To(Equal(qerr.InternalError))
-				}),
-				tracer.EXPECT().Close(),
-			)
-			conn.destroy(testErr)
-			Eventually(areConnsRunning).Should(BeFalse())
-			expectedRunErr = &qerr.TransportError{
-				ErrorCode:    qerr.InternalError,
-				ErrorMessage: testErr.Error(),
-			}
-		})
+	go func() { errChan <- tc.conn.run() }()
+	tc.conn.destroy(&StatelessResetError{})
 
-		It("doesn't send any more packets after receiving a CONNECTION_CLOSE", func() {
-			unpacker := NewMockUnpacker(mockCtrl)
-			conn.handshakeConfirmed = true
-			conn.unpacker = unpacker
-			runConn()
-			cryptoSetup.EXPECT().Close()
-			streamManager.EXPECT().CloseWithError(gomock.Any())
-			connRunner.EXPECT().ReplaceWithClosed(gomock.Any(), gomock.Any()).AnyTimes()
-			b, err := wire.AppendShortHeader(nil, srcConnID, 42, protocol.PacketNumberLen2, protocol.KeyPhaseOne)
-			Expect(err).ToNot(HaveOccurred())
-
-			unpacker.EXPECT().UnpackShortHeader(gomock.Any(), gomock.Any()).DoAndReturn(func(time.Time, []byte) (protocol.PacketNumber, protocol.PacketNumberLen, protocol.KeyPhaseBit, []byte, error) {
-				b, err := (&wire.ConnectionCloseFrame{ErrorCode: uint64(qerr.StreamLimitError)}).Append(nil, conn.version)
-				Expect(err).ToNot(HaveOccurred())
-				return 3, protocol.PacketNumberLen2, protocol.KeyPhaseOne, b, nil
-			})
-			gomock.InOrder(
-				tracer.EXPECT().ReceivedShortHeaderPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()),
-				tracer.EXPECT().ClosedConnection(gomock.Any()),
-				tracer.EXPECT().Close(),
-			)
-			// don't EXPECT any calls to packer.PackPacket()
-			conn.handlePacket(receivedPacket{
-				rcvTime:    time.Now(),
-				remoteAddr: &net.UDPAddr{},
-				buffer:     getPacketBuffer(),
-				data:       b,
-			})
-			// Consistently(pack).ShouldNot(Receive())
-			Eventually(conn.Context().Done()).Should(BeClosed())
-		})
+	select {
+	case err := <-errChan:
+		require.ErrorIs(t, err, &StatelessResetError{})
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+}
 
-		It("closes when the sendQueue encounters an error", func() {
-			conn.handshakeConfirmed = true
-			sconn := NewMockSendConn(mockCtrl)
-			sconn.EXPECT().capabilities().AnyTimes()
-			sconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any()).Return(io.ErrClosedPipe).AnyTimes()
-			conn.sendQueue = newSendQueue(sconn)
-			sph := mockackhandler.NewMockSentPacketHandler(mockCtrl)
-			sph.EXPECT().GetLossDetectionTimeout().Return(time.Now().Add(time.Hour)).AnyTimes()
-			sph.EXPECT().ECNMode(true).Return(protocol.ECT1).AnyTimes()
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
-			// only expect a single SentPacket() call
-			sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-			tracer.EXPECT().SentShortHeaderPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-			tracer.EXPECT().ClosedConnection(gomock.Any())
-			tracer.EXPECT().Close()
-			streamManager.EXPECT().CloseWithError(gomock.Any())
-			connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
-			cryptoSetup.EXPECT().Close()
-			conn.sentPacketHandler = sph
-			expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 1}, []byte("foobar"))
-			packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), conn.version).Return(shortHeaderPacket{}, errNothingToPack).AnyTimes()
-			runConn()
-			conn.queueControlFrame(&wire.PingFrame{})
-			conn.scheduleSending()
-			Eventually(conn.Context().Done()).Should(BeClosed())
-		})
+func getLongHeaderPacket(t *testing.T, extHdr *wire.ExtendedHeader, data []byte) receivedPacket {
+	t.Helper()
+	b, err := extHdr.Append(nil, protocol.Version1)
+	require.NoError(t, err)
+	return receivedPacket{
+		data:    append(b, data...),
+		buffer:  getPacketBuffer(),
+		rcvTime: time.Now(),
+	}
+}
 
-		It("closes due to a stateless reset", func() {
-			runConn()
-			gomock.InOrder(
-				tracer.EXPECT().ClosedConnection(gomock.Any()).Do(func(e error) {
-					Expect(errors.Is(e, &qerr.StatelessResetError{})).To(BeTrue())
-				}),
-				tracer.EXPECT().Close(),
-			)
-			streamManager.EXPECT().CloseWithError(gomock.Any())
-			connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
-			cryptoSetup.EXPECT().Close()
-			conn.destroy(&StatelessResetError{})
-		})
-	})
+func getShortHeaderPacket(t *testing.T, connID protocol.ConnectionID, pn protocol.PacketNumber, data []byte) receivedPacket {
+	t.Helper()
+	b, err := wire.AppendShortHeader(nil, connID, pn, protocol.PacketNumberLen2, protocol.KeyPhaseOne)
+	require.NoError(t, err)
+	return receivedPacket{
+		data:    append(b, data...),
+		buffer:  getPacketBuffer(),
+		rcvTime: time.Now(),
+	}
+}
 
-	Context("receiving packets", func() {
-		var unpacker *MockUnpacker
+func TestConnectionServerInvalidPackets(t *testing.T) {
+	t.Run("Retry", func(t *testing.T) {
+		mockCtrl := gomock.NewController(t)
+		tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+		tc := newServerTestConnection(t, mockCtrl, nil, false, connectionOptTracer(tr))
+
+		p := getLongHeaderPacket(t, &wire.ExtendedHeader{Header: wire.Header{
+			Type:             protocol.PacketTypeRetry,
+			DestConnectionID: tc.conn.origDestConnID,
+			SrcConnectionID:  tc.srcConnID,
+			Version:          tc.conn.version,
+			Token:            []byte("foobar"),
+		}}, make([]byte, 16) /* Retry integrity tag */)
+		tracer.EXPECT().DroppedPacket(logging.PacketTypeRetry, protocol.InvalidPacketNumber, p.Size(), logging.PacketDropUnexpectedPacket)
+		require.False(t, tc.conn.handlePacketImpl(p))
+	})
 
-		BeforeEach(func() {
-			unpacker = NewMockUnpacker(mockCtrl)
-			conn.unpacker = unpacker
-		})
+	t.Run("version negotiation", func(t *testing.T) {
+		mockCtrl := gomock.NewController(t)
+		tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+		tc := newServerTestConnection(t, mockCtrl, nil, false, connectionOptTracer(tr))
 
-		getShortHeaderPacket := func(connID protocol.ConnectionID, pn protocol.PacketNumber, data []byte) receivedPacket {
-			b, err := wire.AppendShortHeader(nil, connID, pn, protocol.PacketNumberLen2, protocol.KeyPhaseOne)
-			Expect(err).ToNot(HaveOccurred())
-			return receivedPacket{
-				data:    append(b, data...),
-				buffer:  getPacketBuffer(),
-				rcvTime: time.Now(),
-			}
-		}
+		b := wire.ComposeVersionNegotiation(
+			protocol.ArbitraryLenConnectionID(tc.srcConnID.Bytes()),
+			protocol.ArbitraryLenConnectionID(tc.conn.origDestConnID.Bytes()),
+			[]Version{Version1},
+		)
+		tracer.EXPECT().DroppedPacket(logging.PacketTypeVersionNegotiation, protocol.InvalidPacketNumber, protocol.ByteCount(len(b)), logging.PacketDropUnexpectedPacket)
+		require.False(t, tc.conn.handlePacketImpl(receivedPacket{data: b, buffer: getPacketBuffer()}))
+	})
 
-		getLongHeaderPacket := func(extHdr *wire.ExtendedHeader, data []byte) receivedPacket {
-			b, err := extHdr.Append(nil, conn.version)
-			Expect(err).ToNot(HaveOccurred())
-			return receivedPacket{
-				data:    append(b, data...),
-				buffer:  getPacketBuffer(),
-				rcvTime: time.Now(),
-			}
-		}
+	t.Run("unsupported version", func(t *testing.T) {
+		mockCtrl := gomock.NewController(t)
+		tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+		tc := newServerTestConnection(t, mockCtrl, nil, false, connectionOptTracer(tr))
 
-		It("drops Retry packets", func() {
-			p := getLongHeaderPacket(&wire.ExtendedHeader{Header: wire.Header{
-				Type:             protocol.PacketTypeRetry,
-				DestConnectionID: destConnID,
-				SrcConnectionID:  srcConnID,
-				Version:          conn.version,
-				Token:            []byte("foobar"),
-			}}, make([]byte, 16) /* Retry integrity tag */)
-			tracer.EXPECT().DroppedPacket(logging.PacketTypeRetry, protocol.InvalidPacketNumber, p.Size(), logging.PacketDropUnexpectedPacket)
-			Expect(conn.handlePacketImpl(p)).To(BeFalse())
-		})
+		p := getLongHeaderPacket(t, &wire.ExtendedHeader{
+			Header:          wire.Header{Type: protocol.PacketTypeHandshake, Version: 1234},
+			PacketNumberLen: protocol.PacketNumberLen2,
+		}, nil)
+		tracer.EXPECT().DroppedPacket(logging.PacketTypeNotDetermined, protocol.InvalidPacketNumber, p.Size(), logging.PacketDropUnsupportedVersion)
+		require.False(t, tc.conn.handlePacketImpl(p))
+	})
 
-		It("drops Version Negotiation packets", func() {
-			b := wire.ComposeVersionNegotiation(
-				protocol.ArbitraryLenConnectionID(srcConnID.Bytes()),
-				protocol.ArbitraryLenConnectionID(destConnID.Bytes()),
-				conn.config.Versions,
-			)
-			tracer.EXPECT().DroppedPacket(logging.PacketTypeVersionNegotiation, protocol.InvalidPacketNumber, protocol.ByteCount(len(b)), logging.PacketDropUnexpectedPacket)
-			Expect(conn.handlePacketImpl(receivedPacket{
-				data:   b,
-				buffer: getPacketBuffer(),
-			})).To(BeFalse())
-		})
+	t.Run("invalid header", func(t *testing.T) {
+		mockCtrl := gomock.NewController(t)
+		tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+		tc := newServerTestConnection(t, mockCtrl, nil, false, connectionOptTracer(tr))
 
-		It("drops packets for which header decryption fails", func() {
-			p := getLongHeaderPacket(&wire.ExtendedHeader{
-				Header: wire.Header{
-					Type:    protocol.PacketTypeHandshake,
-					Version: conn.version,
-				},
-				PacketNumberLen: protocol.PacketNumberLen2,
-			}, nil)
-			p.data[0] ^= 0x40 // unset the QUIC bit
-			tracer.EXPECT().DroppedPacket(logging.PacketTypeNotDetermined, protocol.InvalidPacketNumber, p.Size(), logging.PacketDropHeaderParseError)
-			Expect(conn.handlePacketImpl(p)).To(BeFalse())
-		})
+		p := getLongHeaderPacket(t, &wire.ExtendedHeader{
+			Header:          wire.Header{Type: protocol.PacketTypeHandshake, Version: Version1},
+			PacketNumberLen: protocol.PacketNumberLen2,
+		}, nil)
+		p.data[0] ^= 0x40 // unset the QUIC bit
+		tracer.EXPECT().DroppedPacket(logging.PacketTypeNotDetermined, protocol.InvalidPacketNumber, p.Size(), logging.PacketDropHeaderParseError)
+		require.False(t, tc.conn.handlePacketImpl(p))
+	})
+}
 
-		It("drops packets for which the version is unsupported", func() {
-			p := getLongHeaderPacket(&wire.ExtendedHeader{
-				Header: wire.Header{
-					Type:    protocol.PacketTypeHandshake,
-					Version: conn.version + 1,
-				},
-				PacketNumberLen: protocol.PacketNumberLen2,
-			}, nil)
-			tracer.EXPECT().DroppedPacket(logging.PacketTypeNotDetermined, protocol.InvalidPacketNumber, p.Size(), logging.PacketDropUnsupportedVersion)
-			Expect(conn.handlePacketImpl(p)).To(BeFalse())
-		})
+func TestConnectionClientDrop0RTT(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+	tc := newClientTestConnection(t, mockCtrl, nil, false, connectionOptTracer(tr))
+
+	p := getLongHeaderPacket(t, &wire.ExtendedHeader{
+		Header:          wire.Header{Type: protocol.PacketType0RTT, Length: 2, Version: protocol.Version1},
+		PacketNumberLen: protocol.PacketNumberLen2,
+	}, nil)
+	tracer.EXPECT().DroppedPacket(logging.PacketType0RTT, protocol.InvalidPacketNumber, p.Size(), logging.PacketDropUnexpectedPacket)
+	require.False(t, tc.conn.handlePacketImpl(p))
+}
 
-		It("drops packets with an unsupported version", func() {
-			origSupportedVersions := make([]protocol.Version, len(protocol.SupportedVersions))
-			copy(origSupportedVersions, protocol.SupportedVersions)
-			defer func() {
-				protocol.SupportedVersions = origSupportedVersions
-			}()
-
-			protocol.SupportedVersions = append(protocol.SupportedVersions, conn.version+1)
-			p := getLongHeaderPacket(&wire.ExtendedHeader{
-				Header: wire.Header{
-					Type:             protocol.PacketTypeHandshake,
-					DestConnectionID: destConnID,
-					SrcConnectionID:  srcConnID,
-					Version:          conn.version + 1,
-				},
-				PacketNumberLen: protocol.PacketNumberLen2,
-			}, nil)
-			tracer.EXPECT().DroppedPacket(logging.PacketTypeHandshake, protocol.InvalidPacketNumber, p.Size(), logging.PacketDropUnexpectedVersion)
-			Expect(conn.handlePacketImpl(p)).To(BeFalse())
-		})
+func TestConnectionUnpacking(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	rph := mockackhandler.NewMockReceivedPacketHandler(mockCtrl)
+	unpacker := NewMockUnpacker(mockCtrl)
+	tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+	tc := newServerTestConnection(t,
+		mockCtrl,
+		nil,
+		false,
+		connectionOptReceivedPacketHandler(rph),
+		connectionOptUnpacker(unpacker),
+		connectionOptTracer(tr),
+	)
 
-		It("informs the ReceivedPacketHandler about non-ack-eliciting packets", func() {
-			hdr := &wire.ExtendedHeader{
-				Header: wire.Header{
-					Type:             protocol.PacketTypeInitial,
-					DestConnectionID: srcConnID,
-					Version:          protocol.Version1,
-					Length:           1,
-				},
-				PacketNumber:    0x37,
-				PacketNumberLen: protocol.PacketNumberLen1,
-			}
-			unpackedHdr := *hdr
-			unpackedHdr.PacketNumber = 0x1337
-			packet := getLongHeaderPacket(hdr, nil)
-			packet.ecn = protocol.ECNCE
-			rcvTime := time.Now().Add(-10 * time.Second)
-			unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).Return(&unpackedPacket{
-				encryptionLevel: protocol.EncryptionInitial,
-				hdr:             &unpackedHdr,
-				data:            []byte{0}, // one PADDING frame
-			}, nil)
-			rph := mockackhandler.NewMockReceivedPacketHandler(mockCtrl)
-			gomock.InOrder(
-				rph.EXPECT().IsPotentiallyDuplicate(protocol.PacketNumber(0x1337), protocol.EncryptionInitial),
-				rph.EXPECT().ReceivedPacket(protocol.PacketNumber(0x1337), protocol.ECNCE, protocol.EncryptionInitial, rcvTime, false),
-			)
-			conn.receivedPacketHandler = rph
-			packet.rcvTime = rcvTime
-			tracer.EXPECT().StartedConnection(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-			tracer.EXPECT().ReceivedLongHeaderPacket(gomock.Any(), gomock.Any(), logging.ECNCE, []logging.Frame{})
-			Expect(conn.handlePacketImpl(packet)).To(BeTrue())
-		})
+	// receive a long header packet
+	hdr := &wire.ExtendedHeader{
+		Header: wire.Header{
+			Type:             protocol.PacketTypeInitial,
+			DestConnectionID: tc.srcConnID,
+			Version:          protocol.Version1,
+			Length:           1,
+		},
+		PacketNumber:    0x37,
+		PacketNumberLen: protocol.PacketNumberLen1,
+	}
+	unpackedHdr := *hdr
+	unpackedHdr.PacketNumber = 0x1337
+	packet := getLongHeaderPacket(t, hdr, nil)
+	packet.ecn = protocol.ECNCE
+	rcvTime := time.Now().Add(-10 * time.Second)
+	packet.rcvTime = rcvTime
+	unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).Return(&unpackedPacket{
+		encryptionLevel: protocol.EncryptionInitial,
+		hdr:             &unpackedHdr,
+		data:            []byte{0}, // one PADDING frame
+	}, nil)
+	gomock.InOrder(
+		rph.EXPECT().IsPotentiallyDuplicate(protocol.PacketNumber(0x1337), protocol.EncryptionInitial),
+		rph.EXPECT().ReceivedPacket(protocol.PacketNumber(0x1337), protocol.ECNCE, protocol.EncryptionInitial, rcvTime, false),
+	)
 
-		It("informs the ReceivedPacketHandler about ack-eliciting packets", func() {
-			rcvTime := time.Now().Add(-10 * time.Second)
-			b, err := (&wire.PingFrame{}).Append(nil, conn.version)
-			Expect(err).ToNot(HaveOccurred())
-			packet := getShortHeaderPacket(srcConnID, 0x37, nil)
-			packet.ecn = protocol.ECT1
-			unpacker.EXPECT().UnpackShortHeader(rcvTime, gomock.Any()).Return(protocol.PacketNumber(0x1337), protocol.PacketNumberLen2, protocol.KeyPhaseZero, b, nil)
-			rph := mockackhandler.NewMockReceivedPacketHandler(mockCtrl)
-			gomock.InOrder(
-				rph.EXPECT().IsPotentiallyDuplicate(protocol.PacketNumber(0x1337), protocol.Encryption1RTT),
-				rph.EXPECT().ReceivedPacket(protocol.PacketNumber(0x1337), protocol.ECT1, protocol.Encryption1RTT, rcvTime, true),
-			)
-			conn.receivedPacketHandler = rph
-			packet.rcvTime = rcvTime
-			tracer.EXPECT().ReceivedShortHeaderPacket(
-				&logging.ShortHeader{DestConnectionID: srcConnID, PacketNumber: 0x1337, PacketNumberLen: 2, KeyPhase: protocol.KeyPhaseZero},
-				protocol.ByteCount(len(packet.data)),
-				logging.ECT1,
-				[]logging.Frame{&logging.PingFrame{}},
-			)
-			Expect(conn.handlePacketImpl(packet)).To(BeTrue())
-		})
+	tracer.EXPECT().NegotiatedVersion(gomock.Any(), gomock.Any(), gomock.Any())
+	tracer.EXPECT().StartedConnection(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
+	tracer.EXPECT().ReceivedLongHeaderPacket(gomock.Any(), gomock.Any(), logging.ECNCE, []logging.Frame{})
+	require.True(t, tc.conn.handlePacketImpl(packet))
+	require.True(t, mockCtrl.Satisfied())
+
+	// receive a duplicate of this packet
+	packet = getLongHeaderPacket(t, hdr, nil)
+	rph.EXPECT().IsPotentiallyDuplicate(protocol.PacketNumber(0x1337), protocol.EncryptionInitial).Return(true)
+	unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).Return(&unpackedPacket{
+		encryptionLevel: protocol.EncryptionInitial,
+		hdr:             &unpackedHdr,
+		data:            []byte{0}, // one PADDING frame
+	}, nil)
+	tracer.EXPECT().DroppedPacket(logging.PacketTypeInitial, protocol.PacketNumber(0x1337), protocol.ByteCount(len(packet.data)), logging.PacketDropDuplicate)
+	require.False(t, tc.conn.handlePacketImpl(packet))
+	require.True(t, mockCtrl.Satisfied())
+
+	// receive a short header packet
+	packet = getShortHeaderPacket(t, tc.srcConnID, 0x37, nil)
+	packet.ecn = protocol.ECT1
+	packet.rcvTime = rcvTime
+	gomock.InOrder(
+		rph.EXPECT().IsPotentiallyDuplicate(protocol.PacketNumber(0x1337), protocol.Encryption1RTT),
+		rph.EXPECT().ReceivedPacket(protocol.PacketNumber(0x1337), protocol.ECT1, protocol.Encryption1RTT, rcvTime, false),
+	)
+	unpacker.EXPECT().UnpackShortHeader(gomock.Any(), gomock.Any()).Return(
+		protocol.PacketNumber(0x1337), protocol.PacketNumberLen2, protocol.KeyPhaseZero, []byte{0} /* PADDING */, nil,
+	)
+	tracer.EXPECT().ReceivedShortHeaderPacket(gomock.Any(), gomock.Any(), logging.ECT1, []logging.Frame{})
+	require.True(t, tc.conn.handlePacketImpl(packet))
+}
 
-		It("drops duplicate packets", func() {
-			packet := getShortHeaderPacket(srcConnID, 0x37, nil)
-			unpacker.EXPECT().UnpackShortHeader(gomock.Any(), gomock.Any()).Return(protocol.PacketNumber(0x1337), protocol.PacketNumberLen2, protocol.KeyPhaseOne, []byte("foobar"), nil)
-			rph := mockackhandler.NewMockReceivedPacketHandler(mockCtrl)
-			rph.EXPECT().IsPotentiallyDuplicate(protocol.PacketNumber(0x1337), protocol.Encryption1RTT).Return(true)
-			conn.receivedPacketHandler = rph
-			tracer.EXPECT().DroppedPacket(logging.PacketType1RTT, protocol.PacketNumber(0x1337), protocol.ByteCount(len(packet.data)), logging.PacketDropDuplicate)
-			Expect(conn.handlePacketImpl(packet)).To(BeFalse())
-		})
+func TestConnectionUnpackCoalescedPacket(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	rph := mockackhandler.NewMockReceivedPacketHandler(mockCtrl)
+	unpacker := NewMockUnpacker(mockCtrl)
+	tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+	tc := newServerTestConnection(t,
+		mockCtrl,
+		nil,
+		false,
+		connectionOptReceivedPacketHandler(rph),
+		connectionOptUnpacker(unpacker),
+		connectionOptTracer(tr),
+	)
+	hdr1 := &wire.ExtendedHeader{
+		Header: wire.Header{
+			Type:             protocol.PacketTypeInitial,
+			DestConnectionID: tc.srcConnID,
+			Version:          protocol.Version1,
+			Length:           1,
+		},
+		PacketNumber:    37,
+		PacketNumberLen: protocol.PacketNumberLen1,
+	}
+	hdr2 := &wire.ExtendedHeader{
+		Header: wire.Header{
+			Type:             protocol.PacketTypeHandshake,
+			DestConnectionID: tc.srcConnID,
+			Version:          protocol.Version1,
+			Length:           1,
+		},
+		PacketNumber:    38,
+		PacketNumberLen: protocol.PacketNumberLen1,
+	}
+	// add a packet with a different source connection ID
+	incorrectSrcConnID := protocol.ParseConnectionID([]byte{0xa, 0xb, 0xc})
+	hdr3 := &wire.ExtendedHeader{
+		Header: wire.Header{
+			Type:             protocol.PacketTypeHandshake,
+			DestConnectionID: incorrectSrcConnID,
+			Version:          protocol.Version1,
+			Length:           1,
+		},
+		PacketNumber:    0x42,
+		PacketNumberLen: protocol.PacketNumberLen1,
+	}
+	unpackedHdr1 := *hdr1
+	unpackedHdr1.PacketNumber = 1337
+	unpackedHdr2 := *hdr2
+	unpackedHdr2.PacketNumber = 1338
+
+	packet := getLongHeaderPacket(t, hdr1, nil)
+	packet2 := getLongHeaderPacket(t, hdr2, nil)
+	packet3 := getLongHeaderPacket(t, hdr3, nil)
+	packet.data = append(packet.data, packet2.data...)
+	packet.data = append(packet.data, packet3.data...)
+	packet.ecn = protocol.ECT1
+	rcvTime := time.Now()
+	packet.rcvTime = rcvTime
+
+	unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).Return(&unpackedPacket{
+		encryptionLevel: protocol.EncryptionInitial,
+		hdr:             &unpackedHdr1,
+		data:            []byte{0}, // one PADDING frame
+	}, nil)
+	unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).Return(&unpackedPacket{
+		encryptionLevel: protocol.EncryptionHandshake,
+		hdr:             &unpackedHdr2,
+		data:            []byte{1}, // one PING frame
+	}, nil)
+	gomock.InOrder(
+		rph.EXPECT().IsPotentiallyDuplicate(protocol.PacketNumber(1337), protocol.EncryptionInitial),
+		rph.EXPECT().ReceivedPacket(protocol.PacketNumber(1337), protocol.ECT1, protocol.EncryptionInitial, rcvTime, false),
+		rph.EXPECT().IsPotentiallyDuplicate(protocol.PacketNumber(1338), protocol.EncryptionHandshake),
+		rph.EXPECT().ReceivedPacket(protocol.PacketNumber(1338), protocol.ECT1, protocol.EncryptionHandshake, rcvTime, true),
+	)
+	tracer.EXPECT().NegotiatedVersion(gomock.Any(), gomock.Any(), gomock.Any())
+	tracer.EXPECT().StartedConnection(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
+	tracer.EXPECT().DroppedEncryptionLevel(protocol.EncryptionInitial)
+	rph.EXPECT().DropPackets(protocol.EncryptionInitial)
+	gomock.InOrder(
+		tracer.EXPECT().ReceivedLongHeaderPacket(gomock.Any(), gomock.Any(), logging.ECT1, []logging.Frame{}),
+		tracer.EXPECT().ReceivedLongHeaderPacket(gomock.Any(), gomock.Any(), logging.ECT1, []logging.Frame{&wire.PingFrame{}}),
+		tracer.EXPECT().DroppedPacket(logging.PacketTypeNotDetermined, protocol.InvalidPacketNumber, protocol.ByteCount(len(packet3.data)), logging.PacketDropUnknownConnectionID),
+	)
+	require.True(t, tc.conn.handlePacketImpl(packet))
+}
 
-		It("drops a packet when unpacking fails", func() {
-			unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).Return(nil, handshake.ErrDecryptionFailed)
-			streamManager.EXPECT().CloseWithError(gomock.Any())
-			cryptoSetup.EXPECT().Close()
-			packer.EXPECT().PackConnectionClose(gomock.Any(), gomock.Any(), conn.version).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil)
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				conn.run()
-			}()
-			expectReplaceWithClosed()
-			p := getLongHeaderPacket(&wire.ExtendedHeader{
-				Header: wire.Header{
-					Type:             protocol.PacketTypeHandshake,
-					DestConnectionID: srcConnID,
-					Version:          conn.version,
-					Length:           2 + 6,
-				},
-				PacketNumber:    0x1337,
-				PacketNumberLen: protocol.PacketNumberLen2,
-			}, []byte("foobar"))
-			tracer.EXPECT().DroppedPacket(logging.PacketTypeHandshake, protocol.InvalidPacketNumber, p.Size(), logging.PacketDropPayloadDecryptError)
-			conn.handlePacket(p)
-			Consistently(conn.Context().Done()).ShouldNot(BeClosed())
-			// make the go routine return
-			tracer.EXPECT().ClosedConnection(gomock.Any())
-			tracer.EXPECT().Close()
-			mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
-			conn.closeLocal(errors.New("close"))
-			Eventually(conn.Context().Done()).Should(BeClosed())
-		})
+func TestConnectionUnpackFailuresFatal(t *testing.T) {
+	t.Run("other errors", func(t *testing.T) {
+		require.ErrorIs(t,
+			testConnectionUnpackFailureFatal(t, &qerr.TransportError{ErrorCode: qerr.ConnectionIDLimitError}),
+			&qerr.TransportError{ErrorCode: qerr.ConnectionIDLimitError},
+		)
+	})
 
-		It("processes multiple received packets before sending one", func() {
-			conn.creationTime = time.Now()
-			var pn protocol.PacketNumber
-			unpacker.EXPECT().UnpackShortHeader(gomock.Any(), gomock.Any()).DoAndReturn(func(rcvTime time.Time, data []byte) (protocol.PacketNumber, protocol.PacketNumberLen, protocol.KeyPhaseBit, []byte, error) {
-				pn++
-				return pn, protocol.PacketNumberLen2, protocol.KeyPhaseZero, []byte{0} /* PADDING frame */, nil
-			}).Times(3)
-			tracer.EXPECT().ReceivedShortHeaderPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(3)
-			packer.EXPECT().PackCoalescedPacket(false, gomock.Any(), gomock.Any(), conn.version) // only expect a single call
-
-			for i := 0; i < 3; i++ {
-				conn.handlePacket(getShortHeaderPacket(srcConnID, 0x1337+protocol.PacketNumber(i), []byte("foobar")))
-			}
+	t.Run("invalid reserved bits", func(t *testing.T) {
+		require.ErrorIs(t,
+			testConnectionUnpackFailureFatal(t, wire.ErrInvalidReservedBits),
+			&qerr.TransportError{ErrorCode: qerr.ProtocolViolation},
+		)
+	})
+}
 
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				conn.run()
-			}()
-			Consistently(conn.Context().Done()).ShouldNot(BeClosed())
-
-			// make the go routine return
-			streamManager.EXPECT().CloseWithError(gomock.Any())
-			cryptoSetup.EXPECT().Close()
-			packer.EXPECT().PackConnectionClose(gomock.Any(), gomock.Any(), conn.version).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil)
-			expectReplaceWithClosed()
-			tracer.EXPECT().ClosedConnection(gomock.Any())
-			tracer.EXPECT().Close()
-			mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
-			conn.closeLocal(errors.New("close"))
-			Eventually(conn.Context().Done()).Should(BeClosed())
-		})
+func testConnectionUnpackFailureFatal(t *testing.T, unpackErr error) error {
+	mockCtrl := gomock.NewController(t)
+	unpacker := NewMockUnpacker(mockCtrl)
+	tc := newServerTestConnection(t,
+		mockCtrl,
+		nil,
+		false,
+		connectionOptUnpacker(unpacker),
+	)
 
-		It("doesn't processes multiple received packets before sending one before handshake completion", func() {
-			conn.handshakeComplete = false
-			conn.creationTime = time.Now()
-			var pn protocol.PacketNumber
-			unpacker.EXPECT().UnpackShortHeader(gomock.Any(), gomock.Any()).DoAndReturn(func(rcvTime time.Time, data []byte) (protocol.PacketNumber, protocol.PacketNumberLen, protocol.KeyPhaseBit, []byte, error) {
-				pn++
-				return pn, protocol.PacketNumberLen4, protocol.KeyPhaseZero, []byte{0} /* PADDING frame */, nil
-			}).Times(3)
-			tracer.EXPECT().ReceivedShortHeaderPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(3)
-			packer.EXPECT().PackCoalescedPacket(false, gomock.Any(), gomock.Any(), conn.version).Times(3)
-
-			for i := 0; i < 3; i++ {
-				conn.handlePacket(getShortHeaderPacket(srcConnID, 0x1337+protocol.PacketNumber(i), []byte("foobar")))
-			}
+	tc.connRunner.EXPECT().ReplaceWithClosed(gomock.Any(), gomock.Any())
+	unpacker.EXPECT().UnpackShortHeader(gomock.Any(), gomock.Any()).Return(protocol.PacketNumber(0), protocol.PacketNumberLen(0), protocol.KeyPhaseBit(0), nil, unpackErr)
+	tc.packer.EXPECT().PackConnectionClose(gomock.Any(), gomock.Any(), protocol.Version1).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil)
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
+
+	tc.sendConn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
+	tc.conn.handlePacket(getShortHeaderPacket(t, tc.srcConnID, 0x42, nil))
+
+	select {
+	case err := <-errChan:
+		require.Error(t, err)
+		return err
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+	return nil
+}
 
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				conn.run()
-			}()
-			Consistently(conn.Context().Done()).ShouldNot(BeClosed())
-
-			// make the go routine return
-			streamManager.EXPECT().CloseWithError(gomock.Any())
-			cryptoSetup.EXPECT().Close()
-			packer.EXPECT().PackConnectionClose(gomock.Any(), gomock.Any(), conn.version).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil)
-			expectReplaceWithClosed()
-			tracer.EXPECT().ClosedConnection(gomock.Any())
-			tracer.EXPECT().Close()
-			mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
-			conn.closeLocal(errors.New("close"))
-			Eventually(conn.Context().Done()).Should(BeClosed())
-		})
+func TestConnectionUnpackFailureDropped(t *testing.T) {
+	t.Run("keys dropped", func(t *testing.T) {
+		testConnectionUnpackFailureDropped(t, handshake.ErrKeysDropped, logging.PacketDropKeyUnavailable)
+	})
 
-		It("closes the connection when unpacking fails because the reserved bits were incorrect", func() {
-			unpacker.EXPECT().UnpackShortHeader(gomock.Any(), gomock.Any()).Return(protocol.PacketNumber(0), protocol.PacketNumberLen(0), protocol.KeyPhaseBit(0), nil, wire.ErrInvalidReservedBits)
-			streamManager.EXPECT().CloseWithError(gomock.Any())
-			cryptoSetup.EXPECT().Close()
-			packer.EXPECT().PackConnectionClose(gomock.Any(), gomock.Any(), conn.version).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil)
-			done := make(chan struct{})
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				err := conn.run()
-				Expect(err).To(HaveOccurred())
-				Expect(err).To(BeAssignableToTypeOf(&qerr.TransportError{}))
-				Expect(err.(*qerr.TransportError).ErrorCode).To(Equal(qerr.ProtocolViolation))
-				close(done)
-			}()
-			expectReplaceWithClosed()
-			mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
-			packet := getShortHeaderPacket(srcConnID, 0x42, nil)
-			tracer.EXPECT().ClosedConnection(gomock.Any())
-			tracer.EXPECT().Close()
-			conn.handlePacket(packet)
-			Eventually(conn.Context().Done()).Should(BeClosed())
-		})
+	t.Run("decryption failed", func(t *testing.T) {
+		testConnectionUnpackFailureDropped(t, handshake.ErrDecryptionFailed, logging.PacketDropPayloadDecryptError)
+	})
 
-		It("ignores packets when unpacking the header fails", func() {
-			testErr := &headerParseError{errors.New("test error")}
-			unpacker.EXPECT().UnpackShortHeader(gomock.Any(), gomock.Any()).Return(protocol.PacketNumber(0), protocol.PacketNumberLen(0), protocol.KeyPhaseBit(0), nil, testErr)
-			streamManager.EXPECT().CloseWithError(gomock.Any())
-			cryptoSetup.EXPECT().Close()
-			runErr := make(chan error)
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				runErr <- conn.run()
-			}()
-			expectReplaceWithClosed()
-			tracer.EXPECT().DroppedPacket(logging.PacketType1RTT, protocol.InvalidPacketNumber, gomock.Any(), logging.PacketDropHeaderParseError)
-			conn.handlePacket(getShortHeaderPacket(srcConnID, 0x42, nil))
-			Consistently(runErr).ShouldNot(Receive())
-			// make the go routine return
-			packer.EXPECT().PackApplicationClose(gomock.Any(), gomock.Any(), conn.version).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil)
-			tracer.EXPECT().ClosedConnection(gomock.Any())
-			tracer.EXPECT().Close()
-			mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
-			conn.CloseWithError(0, "")
-			Eventually(conn.Context().Done()).Should(BeClosed())
-		})
+	t.Run("header parse error", func(t *testing.T) {
+		testErr := errors.New("foo")
+		testConnectionUnpackFailureDropped(t, &headerParseError{err: testErr}, logging.PacketDropHeaderParseError)
+	})
+}
 
-		It("closes the connection when unpacking fails because of an error other than a decryption error", func() {
-			unpacker.EXPECT().UnpackShortHeader(gomock.Any(), gomock.Any()).Return(protocol.PacketNumber(0), protocol.PacketNumberLen(0), protocol.KeyPhaseBit(0), nil, &qerr.TransportError{ErrorCode: qerr.ConnectionIDLimitError})
-			streamManager.EXPECT().CloseWithError(gomock.Any())
-			cryptoSetup.EXPECT().Close()
-			packer.EXPECT().PackConnectionClose(gomock.Any(), gomock.Any(), conn.version).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil)
-			done := make(chan struct{})
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				err := conn.run()
-				Expect(err).To(HaveOccurred())
-				Expect(err).To(BeAssignableToTypeOf(&qerr.TransportError{}))
-				Expect(err.(*qerr.TransportError).ErrorCode).To(Equal(qerr.ConnectionIDLimitError))
-				close(done)
-			}()
-			expectReplaceWithClosed()
-			mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
-			tracer.EXPECT().ClosedConnection(gomock.Any())
-			tracer.EXPECT().Close()
-			conn.handlePacket(getShortHeaderPacket(srcConnID, 0x42, nil))
-			Eventually(conn.Context().Done()).Should(BeClosed())
-		})
+func testConnectionUnpackFailureDropped(t *testing.T, unpackErr error, packetDropReason logging.PacketDropReason) {
+	mockCtrl := gomock.NewController(t)
+	unpacker := NewMockUnpacker(mockCtrl)
+	tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+	tc := newServerTestConnection(t,
+		mockCtrl,
+		nil,
+		false,
+		connectionOptUnpacker(unpacker),
+		connectionOptTracer(tr),
+	)
 
-		It("ignores packets with a different source connection ID", func() {
-			hdr1 := &wire.ExtendedHeader{
-				Header: wire.Header{
-					Type:             protocol.PacketTypeInitial,
-					DestConnectionID: destConnID,
-					SrcConnectionID:  srcConnID,
-					Length:           1,
-					Version:          conn.version,
-				},
-				PacketNumberLen: protocol.PacketNumberLen1,
-				PacketNumber:    1,
-			}
-			hdr2 := &wire.ExtendedHeader{
-				Header: wire.Header{
-					Type:             protocol.PacketTypeInitial,
-					DestConnectionID: destConnID,
-					SrcConnectionID:  protocol.ParseConnectionID([]byte{0xde, 0xad, 0xbe, 0xef}),
-					Length:           1,
-					Version:          conn.version,
-				},
-				PacketNumberLen: protocol.PacketNumberLen1,
-				PacketNumber:    2,
-			}
-			Expect(srcConnID).ToNot(Equal(hdr2.SrcConnectionID))
-			// Send one packet, which might change the connection ID.
-			// only EXPECT one call to the unpacker
-			unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).Return(&unpackedPacket{
-				encryptionLevel: protocol.Encryption1RTT,
-				hdr:             hdr1,
-				data:            []byte{0}, // one PADDING frame
-			}, nil)
-			p1 := getLongHeaderPacket(hdr1, nil)
-			tracer.EXPECT().StartedConnection(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-			tracer.EXPECT().ReceivedLongHeaderPacket(gomock.Any(), protocol.ByteCount(len(p1.data)), gomock.Any(), gomock.Any())
-			Expect(conn.handlePacketImpl(p1)).To(BeTrue())
-			// The next packet has to be ignored, since the source connection ID doesn't match.
-			p2 := getLongHeaderPacket(hdr2, nil)
-			tracer.EXPECT().DroppedPacket(logging.PacketTypeInitial, protocol.InvalidPacketNumber, protocol.ByteCount(len(p2.data)), logging.PacketDropUnknownConnectionID)
-			Expect(conn.handlePacketImpl(p2)).To(BeFalse())
-		})
+	unpacker.EXPECT().UnpackShortHeader(gomock.Any(), gomock.Any()).Return(protocol.PacketNumber(0), protocol.PacketNumberLen(0), protocol.KeyPhaseBit(0), nil, unpackErr)
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
 
-		It("queues undecryptable packets", func() {
-			conn.handshakeComplete = false
-			hdr := &wire.ExtendedHeader{
-				Header: wire.Header{
-					Type:             protocol.PacketTypeHandshake,
-					DestConnectionID: destConnID,
-					SrcConnectionID:  srcConnID,
-					Length:           1,
-					Version:          conn.version,
-				},
-				PacketNumberLen: protocol.PacketNumberLen1,
-				PacketNumber:    1,
-			}
-			unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).Return(nil, handshake.ErrKeysNotYetAvailable)
-			packet := getLongHeaderPacket(hdr, nil)
-			tracer.EXPECT().BufferedPacket(logging.PacketTypeHandshake, packet.Size())
-			Expect(conn.handlePacketImpl(packet)).To(BeFalse())
-			Expect(conn.undecryptablePackets).To(Equal([]receivedPacket{packet}))
-		})
+	done := make(chan struct{})
+	tracer.EXPECT().DroppedPacket(gomock.Any(), protocol.InvalidPacketNumber, gomock.Any(), packetDropReason).Do(
+		func(logging.PacketType, protocol.PacketNumber, protocol.ByteCount, logging.PacketDropReason) {
+			close(done)
+		},
+	)
+	tc.conn.handlePacket(getShortHeaderPacket(t, tc.srcConnID, 0x42, nil))
+	select {
+	case <-done:
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
 
-		Context("updating the remote address", func() {
-			It("doesn't support connection migration", func() {
-				unpacker.EXPECT().UnpackShortHeader(gomock.Any(), gomock.Any()).Return(protocol.PacketNumber(10), protocol.PacketNumberLen2, protocol.KeyPhaseZero, []byte{0} /* one PADDING frame */, nil)
-				packet := getShortHeaderPacket(srcConnID, 0x42, nil)
-				packet.remoteAddr = &net.IPAddr{IP: net.IPv4(192, 168, 0, 100)}
-				tracer.EXPECT().ReceivedShortHeaderPacket(gomock.Any(), protocol.ByteCount(len(packet.data)), gomock.Any(), gomock.Any())
-				Expect(conn.handlePacketImpl(packet)).To(BeTrue())
-			})
-		})
+	// test teardown
+	tracer.EXPECT().ClosedConnection(gomock.Any())
+	tracer.EXPECT().Close()
+	tc.connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
+	tc.conn.destroy(nil)
+	select {
+	case <-errChan:
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+}
 
-		Context("coalesced packets", func() {
-			BeforeEach(func() {
-				tracer.EXPECT().StartedConnection(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).MaxTimes(1)
-			})
-
-			getPacketWithLength := func(connID protocol.ConnectionID, length protocol.ByteCount) (int /* header length */, receivedPacket) {
-				hdr := &wire.ExtendedHeader{
-					Header: wire.Header{
-						Type:             protocol.PacketTypeHandshake,
-						DestConnectionID: connID,
-						SrcConnectionID:  destConnID,
-						Version:          protocol.Version1,
-						Length:           length,
-					},
-					PacketNumberLen: protocol.PacketNumberLen3,
-				}
-				hdrLen := hdr.GetLength(conn.version)
-				b := make([]byte, 1)
-				rand.Read(b)
-				packet := getLongHeaderPacket(hdr, bytes.Repeat(b, int(length)-3))
-				return int(hdrLen), packet
-			}
+func TestConnectionMaxUnprocessedPackets(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+	tc := newServerTestConnection(t, mockCtrl, nil, false, connectionOptTracer(tr))
+	done := make(chan struct{})
 
-			It("cuts packets to the right length", func() {
-				hdrLen, packet := getPacketWithLength(srcConnID, 456)
-				unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).DoAndReturn(func(_ *wire.Header, data []byte) (*unpackedPacket, error) {
-					Expect(data).To(HaveLen(hdrLen + 456 - 3))
-					return &unpackedPacket{
-						encryptionLevel: protocol.EncryptionHandshake,
-						data:            []byte{0},
-						hdr:             &wire.ExtendedHeader{Header: wire.Header{}},
-					}, nil
-				})
-				cryptoSetup.EXPECT().DiscardInitialKeys()
-				tracer.EXPECT().DroppedEncryptionLevel(protocol.EncryptionInitial)
-				tracer.EXPECT().ReceivedLongHeaderPacket(gomock.Any(), protocol.ByteCount(len(packet.data)), gomock.Any(), gomock.Any())
-				Expect(conn.handlePacketImpl(packet)).To(BeTrue())
-			})
-
-			It("handles coalesced packets", func() {
-				hdrLen1, packet1 := getPacketWithLength(srcConnID, 456)
-				packet1.ecn = protocol.ECT1
-				unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).DoAndReturn(func(_ *wire.Header, data []byte) (*unpackedPacket, error) {
-					Expect(data).To(HaveLen(hdrLen1 + 456 - 3))
-					return &unpackedPacket{
-						encryptionLevel: protocol.EncryptionHandshake,
-						data:            []byte{0},
-						hdr: &wire.ExtendedHeader{
-							PacketNumber: 1,
-							Header:       wire.Header{SrcConnectionID: destConnID},
-						},
-					}, nil
-				})
-				hdrLen2, packet2 := getPacketWithLength(srcConnID, 123)
-				unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).DoAndReturn(func(_ *wire.Header, data []byte) (*unpackedPacket, error) {
-					Expect(data).To(HaveLen(hdrLen2 + 123 - 3))
-					return &unpackedPacket{
-						encryptionLevel: protocol.EncryptionHandshake,
-						data:            []byte{0},
-						hdr: &wire.ExtendedHeader{
-							PacketNumber: 2,
-							Header:       wire.Header{SrcConnectionID: destConnID},
-						},
-					}, nil
-				})
-				tracer.EXPECT().DroppedEncryptionLevel(protocol.EncryptionInitial).AnyTimes()
-				cryptoSetup.EXPECT().DiscardInitialKeys().AnyTimes()
-				gomock.InOrder(
-					tracer.EXPECT().ReceivedLongHeaderPacket(gomock.Any(), protocol.ByteCount(len(packet1.data)), logging.ECT1, gomock.Any()),
-					tracer.EXPECT().ReceivedLongHeaderPacket(gomock.Any(), protocol.ByteCount(len(packet2.data)), logging.ECT1, gomock.Any()),
-				)
-				packet1.data = append(packet1.data, packet2.data...)
-				Expect(conn.handlePacketImpl(packet1)).To(BeTrue())
-			})
-
-			It("works with undecryptable packets", func() {
-				conn.handshakeComplete = false
-				hdrLen1, packet1 := getPacketWithLength(srcConnID, 456)
-				hdrLen2, packet2 := getPacketWithLength(srcConnID, 123)
-				gomock.InOrder(
-					unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).Return(nil, handshake.ErrKeysNotYetAvailable),
-					unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).DoAndReturn(func(_ *wire.Header, data []byte) (*unpackedPacket, error) {
-						Expect(data).To(HaveLen(hdrLen2 + 123 - 3))
-						return &unpackedPacket{
-							encryptionLevel: protocol.EncryptionHandshake,
-							data:            []byte{0},
-							hdr:             &wire.ExtendedHeader{Header: wire.Header{}},
-						}, nil
-					}),
-				)
-				tracer.EXPECT().DroppedEncryptionLevel(protocol.EncryptionInitial).AnyTimes()
-				cryptoSetup.EXPECT().DiscardInitialKeys().AnyTimes()
-				gomock.InOrder(
-					tracer.EXPECT().BufferedPacket(gomock.Any(), protocol.ByteCount(len(packet1.data))),
-					tracer.EXPECT().ReceivedLongHeaderPacket(gomock.Any(), protocol.ByteCount(len(packet2.data)), gomock.Any(), gomock.Any()),
-				)
-				packet1.data = append(packet1.data, packet2.data...)
-				Expect(conn.handlePacketImpl(packet1)).To(BeTrue())
-
-				Expect(conn.undecryptablePackets).To(HaveLen(1))
-				Expect(conn.undecryptablePackets[0].data).To(HaveLen(hdrLen1 + 456 - 3))
-			})
-
-			It("ignores coalesced packet parts if the destination connection IDs don't match", func() {
-				wrongConnID := protocol.ParseConnectionID([]byte{0xde, 0xad, 0xbe, 0xef})
-				Expect(srcConnID).ToNot(Equal(wrongConnID))
-				hdrLen1, packet1 := getPacketWithLength(srcConnID, 456)
-				unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).DoAndReturn(func(_ *wire.Header, data []byte) (*unpackedPacket, error) {
-					Expect(data).To(HaveLen(hdrLen1 + 456 - 3))
-					return &unpackedPacket{
-						encryptionLevel: protocol.EncryptionHandshake,
-						data:            []byte{0},
-						hdr:             &wire.ExtendedHeader{Header: wire.Header{}},
-					}, nil
-				})
-				_, packet2 := getPacketWithLength(wrongConnID, 123)
-				tracer.EXPECT().DroppedEncryptionLevel(protocol.EncryptionInitial).AnyTimes()
-				cryptoSetup.EXPECT().DiscardInitialKeys().AnyTimes()
-				// don't EXPECT any more calls to unpacker.UnpackLongHeader()
-				gomock.InOrder(
-					tracer.EXPECT().ReceivedLongHeaderPacket(gomock.Any(), protocol.ByteCount(len(packet1.data)), gomock.Any(), gomock.Any()),
-					tracer.EXPECT().DroppedPacket(gomock.Any(), protocol.InvalidPacketNumber, protocol.ByteCount(len(packet2.data)), logging.PacketDropUnknownConnectionID),
-				)
-				packet1.data = append(packet1.data, packet2.data...)
-				Expect(conn.handlePacketImpl(packet1)).To(BeTrue())
-			})
-		})
+	for i := protocol.PacketNumber(0); i < protocol.MaxConnUnprocessedPackets; i++ {
+		// nothing here should block
+		tc.conn.handlePacket(receivedPacket{data: []byte("foobar")})
+	}
+	tracer.EXPECT().DroppedPacket(logging.PacketTypeNotDetermined, protocol.InvalidPacketNumber, logging.ByteCount(6), logging.PacketDropDOSPrevention).Do(func(logging.PacketType, logging.PacketNumber, logging.ByteCount, logging.PacketDropReason) {
+		close(done)
 	})
+	tc.conn.handlePacket(receivedPacket{data: []byte("foobar")})
+	select {
+	case <-done:
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+}
 
-	Context("sending packets", func() {
-		var (
-			connDone chan struct{}
-			sender   *MockSender
-			sph      *mockackhandler.MockSentPacketHandler
-		)
-
-		BeforeEach(func() {
-			sender = NewMockSender(mockCtrl)
-			sender.EXPECT().Run()
-			sender.EXPECT().WouldBlock().AnyTimes()
-			conn.sendQueue = sender
-			connDone = make(chan struct{})
-			sph = mockackhandler.NewMockSentPacketHandler(mockCtrl)
-			conn.sentPacketHandler = sph
-		})
-
-		AfterEach(func() {
-			streamManager.EXPECT().CloseWithError(gomock.Any())
-			sph.EXPECT().ECNMode(gomock.Any()).Return(protocol.ECNCE).MaxTimes(1)
-			packer.EXPECT().PackApplicationClose(gomock.Any(), gomock.Any(), conn.version).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil)
-			expectReplaceWithClosed()
-			cryptoSetup.EXPECT().Close()
-			mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
-			tracer.EXPECT().ClosedConnection(gomock.Any())
-			tracer.EXPECT().Close()
-			sender.EXPECT().Close()
-			conn.CloseWithError(0, "")
-			Eventually(conn.Context().Done()).Should(BeClosed())
-			Eventually(connDone).Should(BeClosed())
-		})
+func TestConnectionRemoteClose(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	mockStreamManager := NewMockStreamManager(mockCtrl)
+	tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+	tc := newServerTestConnection(t,
+		mockCtrl,
+		nil,
+		false,
+		connectionOptStreamManager(mockStreamManager),
+		connectionOptTracer(tr),
+	)
+	expectedErr := &qerr.TransportError{ErrorCode: qerr.StreamLimitError, Remote: true}
+	tc.connRunner.EXPECT().ReplaceWithClosed(gomock.Any(), gomock.Any())
+	streamErrChan := make(chan error, 1)
+	mockStreamManager.EXPECT().CloseWithError(gomock.Any()).Do(func(e error) { streamErrChan <- e })
+	tracerErrChan := make(chan error, 1)
+	tracer.EXPECT().ClosedConnection(gomock.Any()).Do(func(e error) { tracerErrChan <- e })
+	tracer.EXPECT().Close()
+
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
+
+	tc.conn.handleFrame(&wire.ConnectionCloseFrame{
+		ErrorCode:    uint64(qerr.StreamLimitError),
+		ReasonPhrase: "foobar",
+	}, protocol.Encryption1RTT, protocol.ConnectionID{}, time.Now())
+
+	select {
+	case err := <-errChan:
+		require.ErrorIs(t, err, expectedErr)
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+	select {
+	case err := <-tracerErrChan:
+		require.ErrorIs(t, err, expectedErr)
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+	select {
+	case err := <-streamErrChan:
+		require.ErrorIs(t, err, expectedErr)
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+}
 
-		runConn := func() {
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				conn.run()
-				close(connDone)
-			}()
-		}
+func TestConnectionIdleTimeoutDuringHandshake(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+	tc := newServerTestConnection(t,
+		mockCtrl,
+		&Config{HandshakeIdleTimeout: scaleDuration(25 * time.Millisecond)},
+		false,
+		connectionOptTracer(tr),
+	)
+	tc.packer.EXPECT().PackCoalescedPacket(false, gomock.Any(), gomock.Any(), protocol.Version1).AnyTimes()
+	tc.connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
+	gomock.InOrder(
+		tracer.EXPECT().ClosedConnection(&IdleTimeoutError{}),
+		tracer.EXPECT().Close(),
+	)
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
+	select {
+	case err := <-errChan:
+		require.ErrorIs(t, err, &IdleTimeoutError{})
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+}
 
-		It("sends packets", func() {
-			conn.handshakeConfirmed = true
-			sph.EXPECT().TimeUntilSend().AnyTimes()
-			sph.EXPECT().GetLossDetectionTimeout().AnyTimes()
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
-			sph.EXPECT().ECNMode(true).Return(protocol.ECNNon).AnyTimes()
-			sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-			runConn()
-			p := shortHeaderPacket{
-				DestConnID:      protocol.ParseConnectionID([]byte{1, 2, 3}),
-				PacketNumber:    1337,
-				PacketNumberLen: protocol.PacketNumberLen3,
-				KeyPhase:        protocol.KeyPhaseOne,
-			}
-			expectAppendPacket(packer, p, []byte("foobar"))
-			packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), conn.version).Return(shortHeaderPacket{}, errNothingToPack).AnyTimes()
-			sent := make(chan struct{})
-			sender.EXPECT().WouldBlock().AnyTimes()
-			sender.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any()).Do(func(*packetBuffer, uint16, protocol.ECN) { close(sent) })
-			tracer.EXPECT().SentShortHeaderPacket(&logging.ShortHeader{
-				DestConnectionID: p.DestConnID,
-				PacketNumber:     p.PacketNumber,
-				PacketNumberLen:  p.PacketNumberLen,
-				KeyPhase:         p.KeyPhase,
-			}, gomock.Any(), gomock.Any(), nil, []logging.Frame{})
-			conn.scheduleSending()
-			Eventually(sent).Should(BeClosed())
-		})
+func TestConnectionHandshakeIdleTimeout(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+	tc := newServerTestConnection(t,
+		mockCtrl,
+		&Config{HandshakeIdleTimeout: scaleDuration(25 * time.Millisecond)},
+		false,
+		connectionOptTracer(tr),
+		func(c *connection) { c.creationTime = time.Now().Add(-10 * time.Second) },
+	)
+	tc.packer.EXPECT().PackCoalescedPacket(false, gomock.Any(), gomock.Any(), protocol.Version1).AnyTimes()
+	tc.connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
+	gomock.InOrder(
+		tracer.EXPECT().ClosedConnection(&HandshakeTimeoutError{}),
+		tracer.EXPECT().Close(),
+	)
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
+	select {
+	case err := <-errChan:
+		require.ErrorIs(t, err, &HandshakeTimeoutError{})
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+}
 
-		It("doesn't send packets if there's nothing to send", func() {
-			conn.handshakeConfirmed = true
-			sph.EXPECT().GetLossDetectionTimeout().AnyTimes()
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
-			sph.EXPECT().ECNMode(true).AnyTimes()
-			runConn()
-			packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), conn.version).Return(shortHeaderPacket{}, errNothingToPack).AnyTimes()
-			conn.scheduleSending()
-			time.Sleep(50 * time.Millisecond) // make sure there are no calls to mconn.Write()
-		})
+func TestConnectionTransportParameters(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+	streamManager := NewMockStreamManager(mockCtrl)
+	connFC := mocks.NewMockConnectionFlowController(mockCtrl)
+	tc := newServerTestConnection(t,
+		mockCtrl,
+		nil,
+		false,
+		connectionOptTracer(tr),
+		connectionOptStreamManager(streamManager),
+		connectionOptConnFlowController(connFC),
+	)
+	tracer.EXPECT().ReceivedTransportParameters(gomock.Any())
+	params := &wire.TransportParameters{
+		MaxIdleTimeout:                90 * time.Second,
+		InitialMaxStreamDataBidiLocal: 0x5000,
+		InitialMaxData:                0x5000,
+		ActiveConnectionIDLimit:       3,
+		// marshaling always sets it to this value
+		MaxUDPPayloadSize:               protocol.MaxPacketBufferSize,
+		OriginalDestinationConnectionID: tc.destConnID,
+	}
+	streamManager.EXPECT().UpdateLimits(params)
+	connFC.EXPECT().UpdateSendWindow(params.InitialMaxData)
+	require.NoError(t, tc.conn.handleTransportParameters(params))
+}
 
-		It("sends ACK only packets", func() {
-			sph.EXPECT().TimeUntilSend().AnyTimes()
-			sph.EXPECT().GetLossDetectionTimeout().AnyTimes()
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAck)
-			sph.EXPECT().ECNMode(gomock.Any()).Return(protocol.ECT1).AnyTimes()
-			done := make(chan struct{})
-			packer.EXPECT().PackCoalescedPacket(true, gomock.Any(), gomock.Any(), conn.version).Do(func(bool, protocol.ByteCount, time.Time, protocol.Version) (*coalescedPacket, error) {
-				close(done)
-				return nil, nil
-			})
-			runConn()
-			conn.scheduleSending()
-			Eventually(done).Should(BeClosed())
-		})
+func TestConnectionTransportParameterValidationFailureServer(t *testing.T) {
+	tc := newServerTestConnection(t, nil, nil, false)
+	err := tc.conn.handleTransportParameters(&wire.TransportParameters{
+		InitialSourceConnectionID: protocol.ParseConnectionID([]byte{1, 2, 3, 4}),
+	})
+	assert.ErrorIs(t, err, &qerr.TransportError{ErrorCode: qerr.TransportParameterError})
+	assert.ErrorContains(t, err, "expected initial_source_connection_id to equal")
+}
 
-		It("adds a BLOCKED frame when it is connection-level flow control blocked", func() {
-			conn.handshakeConfirmed = true
-			sph.EXPECT().TimeUntilSend().AnyTimes()
-			sph.EXPECT().GetLossDetectionTimeout().AnyTimes()
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
-			sph.EXPECT().ECNMode(gomock.Any()).AnyTimes()
-			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(gomock.Any()).Do(func(t time.Time) protocol.ByteCount {
-				Expect(t).To(BeTemporally("~", time.Now(), time.Second))
-				return 0
-			})
-			expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 13}, []byte("foobar"))
-			packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), conn.version).Return(shortHeaderPacket{}, errNothingToPack).AnyTimes()
-			conn.connFlowController = fc
-			runConn()
-			sent := make(chan struct{})
-			sender.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any()).Do(func(*packetBuffer, uint16, protocol.ECN) { close(sent) })
-			tracer.EXPECT().SentShortHeaderPacket(gomock.Any(), gomock.Any(), gomock.Any(), nil, []logging.Frame{})
-			conn.scheduleSending()
-			Eventually(sent).Should(BeClosed())
-			frames, _ := conn.framer.AppendControlFrames(nil, 1000, time.Now(), protocol.Version1)
-			Expect(frames).To(Equal([]ackhandler.Frame{{Frame: &logging.DataBlockedFrame{MaximumData: 1337}}}))
+func TestConnectionTransportParameterValidationFailureClient(t *testing.T) {
+	t.Run("initial_source_connection_id", func(t *testing.T) {
+		tc := newClientTestConnection(t, nil, nil, false)
+		err := tc.conn.handleTransportParameters(&wire.TransportParameters{
+			InitialSourceConnectionID: protocol.ParseConnectionID([]byte{1, 2, 3, 4}),
 		})
+		assert.ErrorIs(t, err, &qerr.TransportError{ErrorCode: qerr.TransportParameterError})
+		assert.ErrorContains(t, err, "expected initial_source_connection_id to equal")
+	})
 
-		It("doesn't send when the SentPacketHandler doesn't allow it", func() {
-			sph.EXPECT().GetLossDetectionTimeout().AnyTimes()
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendNone).AnyTimes()
-			sph.EXPECT().TimeUntilSend().AnyTimes()
-			runConn()
-			conn.scheduleSending()
-			time.Sleep(50 * time.Millisecond)
+	t.Run("original_destination_connection_id", func(t *testing.T) {
+		tc := newClientTestConnection(t, nil, nil, false)
+		err := tc.conn.handleTransportParameters(&wire.TransportParameters{
+			InitialSourceConnectionID:       tc.destConnID,
+			OriginalDestinationConnectionID: protocol.ParseConnectionID([]byte{1, 2, 3, 4}),
 		})
+		assert.ErrorIs(t, err, &qerr.TransportError{ErrorCode: qerr.TransportParameterError})
+		assert.ErrorContains(t, err, "expected original_destination_connection_id to equal")
+	})
 
-		for _, enc := range []protocol.EncryptionLevel{protocol.EncryptionInitial, protocol.EncryptionHandshake, protocol.Encryption1RTT} {
-			encLevel := enc
-
-			Context(fmt.Sprintf("sending %s probe packets", encLevel), func() {
-				var sendMode ackhandler.SendMode
-				var getFrame func(protocol.ByteCount, protocol.Version) wire.Frame
-
-				BeforeEach(func() {
-					switch encLevel {
-					case protocol.EncryptionInitial:
-						sendMode = ackhandler.SendPTOInitial
-						getFrame = conn.retransmissionQueue.GetInitialFrame
-					case protocol.EncryptionHandshake:
-						sendMode = ackhandler.SendPTOHandshake
-						getFrame = conn.retransmissionQueue.GetHandshakeFrame
-					case protocol.Encryption1RTT:
-						sendMode = ackhandler.SendPTOAppData
-						getFrame = conn.retransmissionQueue.GetAppDataFrame
-					}
-				})
-
-				It("sends a probe packet", func() {
-					sph.EXPECT().GetLossDetectionTimeout().AnyTimes()
-					sph.EXPECT().TimeUntilSend().AnyTimes()
-					sph.EXPECT().SendMode(gomock.Any()).Return(sendMode)
-					sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendNone)
-					sph.EXPECT().QueueProbePacket(encLevel)
-					sph.EXPECT().ECNMode(gomock.Any())
-					p := getCoalescedPacket(123, encLevel)
-					packer.EXPECT().MaybePackProbePacket(encLevel, gomock.Any(), gomock.Any(), conn.version).Return(p, nil)
-					sph.EXPECT().SentPacket(gomock.Any(), protocol.PacketNumber(123), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-					conn.sentPacketHandler = sph
-					runConn()
-					sent := make(chan struct{})
-					sender.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any()).Do(func(*packetBuffer, uint16, protocol.ECN) { close(sent) })
-					if encLevel == protocol.Encryption1RTT {
-						tracer.EXPECT().SentShortHeaderPacket(gomock.Any(), p.shortHdrPacket.Length, gomock.Any(), gomock.Any(), gomock.Any())
-					} else {
-						tracer.EXPECT().SentLongHeaderPacket(gomock.Any(), p.longHdrPackets[0].length, gomock.Any(), gomock.Any(), gomock.Any())
-					}
-					conn.scheduleSending()
-					Eventually(sent).Should(BeClosed())
-				})
-
-				It("sends a PING as a probe packet", func() {
-					sph.EXPECT().GetLossDetectionTimeout().AnyTimes()
-					sph.EXPECT().TimeUntilSend().AnyTimes()
-					sph.EXPECT().SendMode(gomock.Any()).Return(sendMode)
-					sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendNone)
-					sph.EXPECT().ECNMode(gomock.Any()).Return(protocol.ECT0)
-					sph.EXPECT().QueueProbePacket(encLevel).Return(false)
-					p := getCoalescedPacket(123, encLevel)
-					packer.EXPECT().MaybePackProbePacket(encLevel, gomock.Any(), gomock.Any(), conn.version).Return(p, nil)
-					sph.EXPECT().SentPacket(gomock.Any(), protocol.PacketNumber(123), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-					runConn()
-					sent := make(chan struct{})
-					sender.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any()).Do(func(*packetBuffer, uint16, protocol.ECN) { close(sent) })
-					if encLevel == protocol.Encryption1RTT {
-						tracer.EXPECT().SentShortHeaderPacket(gomock.Any(), p.shortHdrPacket.Length, logging.ECT0, gomock.Any(), gomock.Any())
-					} else {
-						tracer.EXPECT().SentLongHeaderPacket(gomock.Any(), p.longHdrPackets[0].length, logging.ECT0, gomock.Any(), gomock.Any())
-					}
-					conn.scheduleSending()
-					Eventually(sent).Should(BeClosed())
-					// We're using a mock packet packer in this test.
-					// We therefore need to test separately that the PING was actually queued.
-					Expect(getFrame(1000, protocol.Version1)).To(BeAssignableToTypeOf(&wire.PingFrame{}))
-				})
-			})
+	t.Run("retry_source_connection_id if no retry", func(t *testing.T) {
+		tc := newClientTestConnection(t, nil, nil, false)
+		rcid := protocol.ParseConnectionID([]byte{1, 2, 3, 4})
+		params := &wire.TransportParameters{
+			InitialSourceConnectionID:       tc.destConnID,
+			OriginalDestinationConnectionID: tc.destConnID,
+			RetrySourceConnectionID:         &rcid,
 		}
+		err := tc.conn.handleTransportParameters(params)
+		assert.ErrorIs(t, err, &qerr.TransportError{ErrorCode: qerr.TransportParameterError})
+		assert.ErrorContains(t, err, "received retry_source_connection_id, although no Retry was performed")
 	})
 
-	Context("packet pacing", func() {
-		var (
-			sph    *mockackhandler.MockSentPacketHandler
-			sender *MockSender
+	t.Run("retry_source_connection_id missing", func(t *testing.T) {
+		tc := newClientTestConnection(t,
+			nil,
+			nil,
+			false,
+			connectionOptRetrySrcConnID(protocol.ParseConnectionID([]byte{0xde, 0xad, 0xbe, 0xef})),
 		)
+		params := &wire.TransportParameters{
+			InitialSourceConnectionID:       tc.destConnID,
+			OriginalDestinationConnectionID: tc.destConnID,
+		}
+		err := tc.conn.handleTransportParameters(params)
+		assert.ErrorIs(t, err, &qerr.TransportError{ErrorCode: qerr.TransportParameterError})
+		assert.ErrorContains(t, err, "missing retry_source_connection_id")
+	})
 
-		BeforeEach(func() {
-			tracer.EXPECT().SentShortHeaderPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
-			sph = mockackhandler.NewMockSentPacketHandler(mockCtrl)
-			sph.EXPECT().GetLossDetectionTimeout().AnyTimes()
-			conn.handshakeConfirmed = true
-			conn.handshakeComplete = true
-			conn.sentPacketHandler = sph
-			sender = NewMockSender(mockCtrl)
-			sender.EXPECT().Run()
-			conn.sendQueue = sender
-			streamManager.EXPECT().CloseWithError(gomock.Any())
-		})
+	t.Run("retry_source_connection_id incorrect", func(t *testing.T) {
+		tc := newClientTestConnection(t,
+			nil,
+			nil,
+			false,
+			connectionOptRetrySrcConnID(protocol.ParseConnectionID([]byte{0xde, 0xad, 0xbe, 0xef})),
+		)
+		wrongCID := protocol.ParseConnectionID([]byte{1, 2, 3, 4})
+		params := &wire.TransportParameters{
+			InitialSourceConnectionID:       tc.destConnID,
+			OriginalDestinationConnectionID: tc.destConnID,
+			RetrySourceConnectionID:         &wrongCID,
+		}
+		err := tc.conn.handleTransportParameters(params)
+		assert.ErrorIs(t, err, &qerr.TransportError{ErrorCode: qerr.TransportParameterError})
+		assert.ErrorContains(t, err, "expected retry_source_connection_id to equal")
+	})
+}
 
-		AfterEach(func() {
-			// make the go routine return
-			sph.EXPECT().ECNMode(gomock.Any()).MaxTimes(1)
-			packer.EXPECT().PackApplicationClose(gomock.Any(), gomock.Any(), conn.version).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil)
-			expectReplaceWithClosed()
-			cryptoSetup.EXPECT().Close()
-			mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
-			tracer.EXPECT().ClosedConnection(gomock.Any())
-			tracer.EXPECT().Close()
-			sender.EXPECT().Close()
-			conn.CloseWithError(0, "")
-			Eventually(conn.Context().Done()).Should(BeClosed())
-		})
+func TestConnectionHandshakeServer(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	cs := mocks.NewMockCryptoSetup(mockCtrl)
+	unpacker := NewMockUnpacker(mockCtrl)
+	tc := newServerTestConnection(
+		t,
+		mockCtrl,
+		nil,
+		false,
+		connectionOptCryptoSetup(cs),
+		connectionOptUnpacker(unpacker),
+	)
 
-		It("sends multiple packets one by one immediately", func() {
-			sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(2)
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).Times(2)
-			sph.EXPECT().ECNMode(gomock.Any()).Times(2)
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendPacingLimited)
-			sph.EXPECT().TimeUntilSend().Return(time.Now().Add(time.Hour))
-			expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 10}, []byte("packet10"))
-			expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 11}, []byte("packet11"))
-			sender.EXPECT().WouldBlock().AnyTimes()
-			sender.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any()).Do(func(b *packetBuffer, _ uint16, _ protocol.ECN) {
-				Expect(b.Data).To(Equal([]byte("packet10")))
-			})
-			sender.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any()).Do(func(b *packetBuffer, _ uint16, _ protocol.ECN) {
-				Expect(b.Data).To(Equal([]byte("packet11")))
-			})
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				conn.run()
-			}()
-			conn.scheduleSending()
-			time.Sleep(50 * time.Millisecond) // make sure that only 2 packets are sent
-		})
+	// the state transition is driven by processing of a CRYPTO frame
+	hdr := &wire.ExtendedHeader{
+		Header:          wire.Header{Type: protocol.PacketTypeHandshake, Version: protocol.Version1},
+		PacketNumberLen: protocol.PacketNumberLen2,
+	}
+	data, err := (&wire.CryptoFrame{Data: []byte("foobar")}).Append(nil, protocol.Version1)
+	require.NoError(t, err)
+
+	cs.EXPECT().DiscardInitialKeys()
+	tc.connRunner.EXPECT().Retire(gomock.Any())
+	gomock.InOrder(
+		cs.EXPECT().StartHandshake(gomock.Any()),
+		cs.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent}),
+		unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).Return(
+			&unpackedPacket{hdr: hdr, encryptionLevel: protocol.EncryptionHandshake, data: data}, nil,
+		),
+		cs.EXPECT().HandleMessage([]byte("foobar"), protocol.EncryptionHandshake),
+		cs.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventHandshakeComplete}),
+		cs.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent}),
+		cs.EXPECT().SetHandshakeConfirmed(),
+		cs.EXPECT().GetSessionTicket().Return([]byte("session ticket"), nil),
+	)
+	tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(shortHeaderPacket{}, errNothingToPack).AnyTimes()
+
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
+	p := getLongHeaderPacket(t, hdr, nil)
+	tc.conn.handlePacket(receivedPacket{data: p.data, buffer: p.buffer, rcvTime: time.Now()})
+
+	select {
+	case <-tc.conn.HandshakeComplete():
+	case <-tc.conn.Context().Done():
+		t.Fatal("connection context done")
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
 
-		It("sends multiple packets one by one immediately, with GSO", func() {
-			enableGSO()
-			sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(2)
-			sph.EXPECT().ECNMode(true).Return(protocol.ECT1).Times(4)
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).Times(3)
-			payload1 := make([]byte, conn.maxPacketSize())
-			rand.Read(payload1)
-			payload2 := make([]byte, conn.maxPacketSize())
-			rand.Read(payload2)
-			expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 10}, payload1)
-			expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 11}, payload2)
-			packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(shortHeaderPacket{}, errNothingToPack)
-			sender.EXPECT().WouldBlock().AnyTimes()
-			sender.EXPECT().Send(gomock.Any(), uint16(conn.maxPacketSize()), gomock.Any()).Do(func(b *packetBuffer, _ uint16, _ protocol.ECN) {
-				Expect(b.Data).To(Equal(append(payload1, payload2...)))
-			})
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				conn.run()
-			}()
-			conn.scheduleSending()
-			time.Sleep(50 * time.Millisecond) // make sure that only 2 packets are sent
-		})
+	var foundSessionTicket, foundHandshakeDone, foundNewToken bool
+	frames, _ := tc.conn.framer.AppendControlFrames(nil, protocol.MaxByteCount, time.Now(), protocol.Version1)
+	for _, frame := range frames {
+		switch f := frame.Frame.(type) {
+		case *wire.CryptoFrame:
+			assert.Equal(t, []byte("session ticket"), f.Data)
+			foundSessionTicket = true
+		case *wire.HandshakeDoneFrame:
+			foundHandshakeDone = true
+		case *wire.NewTokenFrame:
+			assert.NotEmpty(t, f.Token)
+			foundNewToken = true
+		}
+	}
+	assert.True(t, foundSessionTicket)
+	assert.True(t, foundHandshakeDone)
+	assert.True(t, foundNewToken)
+
+	// test teardown
+	cs.EXPECT().Close()
+	tc.connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
+	tc.conn.destroy(nil)
+	select {
+	case err := <-errChan:
+		require.NoError(t, err)
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+}
 
-		It("stops appending packets when a smaller packet is packed, with GSO", func() {
-			enableGSO()
-			sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(3)
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).Times(3)
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendNone)
-			sph.EXPECT().ECNMode(true).Times(4)
-			payload1 := make([]byte, conn.maxPacketSize())
-			rand.Read(payload1)
-			payload2 := make([]byte, conn.maxPacketSize()-1)
-			rand.Read(payload2)
-			payload3 := make([]byte, conn.maxPacketSize())
-			rand.Read(payload3)
-			expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 10}, payload1)
-			expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 11}, payload2)
-			expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 12}, payload3)
-			sender.EXPECT().WouldBlock().AnyTimes()
-			sender.EXPECT().Send(gomock.Any(), uint16(conn.maxPacketSize()), gomock.Any()).Do(func(b *packetBuffer, _ uint16, _ protocol.ECN) {
-				Expect(b.Data).To(Equal(append(payload1, payload2...)))
-			})
-			sender.EXPECT().Send(gomock.Any(), uint16(conn.maxPacketSize()), gomock.Any()).Do(func(b *packetBuffer, _ uint16, _ protocol.ECN) {
-				Expect(b.Data).To(Equal(payload3))
-			})
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				conn.run()
-			}()
-			conn.scheduleSending()
-			time.Sleep(50 * time.Millisecond) // make sure that only 2 packets are sent
-		})
+func TestConnectionHandshakeClient(t *testing.T) {
+	t.Run("without preferred address", func(t *testing.T) {
+		testConnectionHandshakeClient(t, false)
+	})
+	t.Run("with preferred address", func(t *testing.T) {
+		testConnectionHandshakeClient(t, true)
+	})
+}
 
-		It("stops appending packets when the ECN marking changes, with GSO", func() {
-			enableGSO()
-			sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(3)
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).Times(3)
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendNone)
-			sph.EXPECT().ECNMode(true).Return(protocol.ECT1).Times(2)
-			sph.EXPECT().ECNMode(true).Return(protocol.ECT0).Times(2)
-			payload1 := make([]byte, conn.maxPacketSize())
-			rand.Read(payload1)
-			payload2 := make([]byte, conn.maxPacketSize())
-			rand.Read(payload2)
-			payload3 := make([]byte, conn.maxPacketSize())
-			rand.Read(payload3)
-			expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 10}, payload1)
-			expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 11}, payload2)
-			expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 11}, payload3)
-			sender.EXPECT().WouldBlock().AnyTimes()
-			sender.EXPECT().Send(gomock.Any(), uint16(conn.maxPacketSize()), gomock.Any()).Do(func(b *packetBuffer, _ uint16, _ protocol.ECN) {
-				Expect(b.Data).To(Equal(append(payload1, payload2...)))
-			})
-			sender.EXPECT().Send(gomock.Any(), uint16(conn.maxPacketSize()), gomock.Any()).Do(func(b *packetBuffer, _ uint16, _ protocol.ECN) {
-				Expect(b.Data).To(Equal(payload3))
-			})
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				conn.run()
-			}()
-			conn.scheduleSending()
-			time.Sleep(50 * time.Millisecond) // make sure that only 2 packets are sent
-		})
+func testConnectionHandshakeClient(t *testing.T, usePreferredAddress bool) {
+	mockCtrl := gomock.NewController(t)
+	cs := mocks.NewMockCryptoSetup(mockCtrl)
+	unpacker := NewMockUnpacker(mockCtrl)
+	tc := newClientTestConnection(t, mockCtrl, nil, false, connectionOptCryptoSetup(cs), connectionOptUnpacker(unpacker))
+	tc.sendConn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+
+	// the state transition is driven by processing of a CRYPTO frame
+	hdr := &wire.ExtendedHeader{
+		Header:          wire.Header{Type: protocol.PacketTypeHandshake, Version: protocol.Version1},
+		PacketNumberLen: protocol.PacketNumberLen2,
+	}
+	data, err := (&wire.CryptoFrame{Data: []byte("foobar")}).Append(nil, protocol.Version1)
+	require.NoError(t, err)
 
-		It("sends multiple packets, when the pacer allows immediate sending", func() {
-			sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).Times(2)
-			sph.EXPECT().ECNMode(gomock.Any()).Times(2)
-			expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 10}, []byte("packet10"))
-			packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), conn.version).Return(shortHeaderPacket{}, errNothingToPack)
-			sender.EXPECT().WouldBlock().AnyTimes()
-			sender.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any())
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				conn.run()
-			}()
-			conn.scheduleSending()
-			time.Sleep(50 * time.Millisecond) // make sure that only 1 packet is sent
-		})
+	tp := &wire.TransportParameters{
+		OriginalDestinationConnectionID: tc.destConnID,
+		MaxIdleTimeout:                  time.Hour,
+	}
+	preferredAddressConnID := protocol.ParseConnectionID([]byte{10, 8, 6, 4})
+	preferredAddressResetToken := protocol.StatelessResetToken{16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1}
+	if usePreferredAddress {
+		tp.PreferredAddress = &wire.PreferredAddress{
+			IPv4:                netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), 42),
+			IPv6:                netip.AddrPortFrom(netip.AddrFrom16([16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}), 13),
+			ConnectionID:        preferredAddressConnID,
+			StatelessResetToken: preferredAddressResetToken,
+		}
+	}
 
-		It("allows an ACK to be sent when pacing limited", func() {
-			sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-			sph.EXPECT().TimeUntilSend().Return(time.Now().Add(time.Hour))
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendPacingLimited)
-			sph.EXPECT().ECNMode(gomock.Any())
-			packer.EXPECT().PackAckOnlyPacket(gomock.Any(), gomock.Any(), conn.version).Return(shortHeaderPacket{PacketNumber: 123}, getPacketBuffer(), nil)
-
-			sender.EXPECT().WouldBlock().AnyTimes()
-			sender.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any())
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				conn.run()
-			}()
-			conn.scheduleSending()
-			time.Sleep(50 * time.Millisecond) // make sure that only 1 packet is sent
-		})
+	packedFirstPacket := make(chan struct{})
+	gomock.InOrder(
+		cs.EXPECT().StartHandshake(gomock.Any()),
+		cs.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent}),
+		tc.packer.EXPECT().PackCoalescedPacket(false, gomock.Any(), gomock.Any(), protocol.Version1).DoAndReturn(
+			func(b bool, bc protocol.ByteCount, t time.Time, v protocol.Version) (*coalescedPacket, error) {
+				close(packedFirstPacket)
+				return &coalescedPacket{buffer: getPacketBuffer(), longHdrPackets: []*longHeaderPacket{{header: hdr}}}, nil
+			},
+		),
+		// initial keys are dropped when the first handshake packet is sent
+		cs.EXPECT().DiscardInitialKeys(),
+		// no more data to send
+		unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).Return(
+			&unpackedPacket{hdr: hdr, encryptionLevel: protocol.EncryptionHandshake, data: data}, nil,
+		),
+		cs.EXPECT().HandleMessage([]byte("foobar"), protocol.EncryptionHandshake),
+		cs.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventReceivedTransportParameters, TransportParameters: tp}),
+		cs.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventHandshakeComplete}),
+		cs.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent}),
+	)
+	tc.packer.EXPECT().PackCoalescedPacket(false, gomock.Any(), gomock.Any(), protocol.Version1).Return(nil, nil).AnyTimes()
 
-		// when becoming congestion limited, at some point the SendMode will change from SendAny to SendAck
-		// we shouldn't send the ACK in the same run
-		It("doesn't send an ACK right after becoming congestion limited", func() {
-			sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny)
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAck)
-			sph.EXPECT().ECNMode(gomock.Any()).Times(2)
-			expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 100}, []byte("packet100"))
-			sender.EXPECT().WouldBlock().AnyTimes()
-			sender.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any())
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				conn.run()
-			}()
-			conn.scheduleSending()
-			time.Sleep(50 * time.Millisecond) // make sure that only 1 packet is sent
-		})
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
 
-		It("paces packets", func() {
-			pacingDelay := scaleDuration(100 * time.Millisecond)
-			gomock.InOrder(
-				sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny),
-				sph.EXPECT().ECNMode(gomock.Any()),
-				expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 100}, []byte("packet100")),
-				sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()),
-				sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendPacingLimited),
-				sph.EXPECT().TimeUntilSend().Return(time.Now().Add(pacingDelay)),
-				sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny),
-				sph.EXPECT().ECNMode(gomock.Any()),
-				expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 101}, []byte("packet101")),
-				sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()),
-				sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendPacingLimited),
-				sph.EXPECT().TimeUntilSend().Return(time.Now().Add(time.Hour)),
-			)
-			written := make(chan struct{}, 2)
-			sender.EXPECT().WouldBlock().AnyTimes()
-			sender.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(*packetBuffer, uint16, protocol.ECN) { written <- struct{}{} }).Times(2)
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				conn.run()
-			}()
-			conn.scheduleSending()
-			Eventually(written).Should(HaveLen(1))
-			Consistently(written, pacingDelay/2).Should(HaveLen(1))
-			Eventually(written, 2*pacingDelay).Should(HaveLen(2))
-		})
+	select {
+	case <-packedFirstPacket:
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
 
-		It("sends multiple packets at once", func() {
-			sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(3)
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).Times(3)
-			sph.EXPECT().ECNMode(gomock.Any()).Times(3)
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendPacingLimited)
-			sph.EXPECT().TimeUntilSend().Return(time.Now().Add(time.Hour))
-			for pn := protocol.PacketNumber(1000); pn < 1003; pn++ {
-				expectAppendPacket(packer, shortHeaderPacket{PacketNumber: pn}, []byte("packet"))
-			}
-			written := make(chan struct{}, 3)
-			sender.EXPECT().WouldBlock().AnyTimes()
-			sender.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(*packetBuffer, uint16, protocol.ECN) { written <- struct{}{} }).Times(3)
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				conn.run()
-			}()
-			conn.scheduleSending()
-			Eventually(written).Should(HaveLen(3))
-		})
+	p := getLongHeaderPacket(t, hdr, nil)
+	tc.conn.handlePacket(receivedPacket{data: p.data, buffer: p.buffer, rcvTime: time.Now()})
 
-		for _, withGSO := range []bool{false, true} {
-			It(fmt.Sprintf("doesn't try to send if the send queue is full: %t", withGSO), func() {
-				if withGSO {
-					enableGSO()
-				}
-				available := make(chan struct{}, 1)
-				sender.EXPECT().WouldBlock().Return(true)
-				sender.EXPECT().Available().Return(available)
-				go func() {
-					defer GinkgoRecover()
-					cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-					cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-					conn.run()
-				}()
-				conn.scheduleSending()
-				time.Sleep(scaleDuration(50 * time.Millisecond))
-
-				written := make(chan struct{})
-				sender.EXPECT().WouldBlock().AnyTimes()
-				sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-				sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
-				sph.EXPECT().ECNMode(gomock.Any()).AnyTimes()
-				expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 1000}, []byte("packet1000"))
-				packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), conn.version).Return(shortHeaderPacket{}, errNothingToPack)
-				sender.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(*packetBuffer, uint16, protocol.ECN) { close(written) })
-				available <- struct{}{}
-				Eventually(written).Should(BeClosed())
-			})
-		}
+	select {
+	case <-tc.conn.HandshakeComplete():
+	case <-tc.conn.Context().Done():
+		t.Fatal("connection context done")
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
 
-		It("stops sending when there are new packets to receive", func() {
-			sender.EXPECT().WouldBlock().AnyTimes()
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				conn.run()
-			}()
-
-			written := make(chan struct{})
-			sender.EXPECT().WouldBlock().AnyTimes()
-			sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Do(func(time.Time, protocol.PacketNumber, protocol.PacketNumber, []ackhandler.StreamFrame, []ackhandler.Frame, protocol.EncryptionLevel, protocol.ECN, protocol.ByteCount, bool) {
-				sph.EXPECT().ReceivedBytes(gomock.Any())
-				conn.handlePacket(receivedPacket{buffer: getPacketBuffer()})
-			})
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
-			sph.EXPECT().ECNMode(gomock.Any()).AnyTimes()
-			expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 10}, []byte("packet10"))
-			packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), conn.version).Return(shortHeaderPacket{}, errNothingToPack)
-			sender.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(*packetBuffer, uint16, protocol.ECN) { close(written) })
-
-			conn.scheduleSending()
-			time.Sleep(scaleDuration(50 * time.Millisecond))
-
-			Eventually(written).Should(BeClosed())
-		})
+	require.True(t, mockCtrl.Satisfied())
+	// the handshake isn't confirmed until we receive a HANDSHAKE_DONE frame from the server
+
+	data, err = (&wire.HandshakeDoneFrame{}).Append(nil, protocol.Version1)
+	require.NoError(t, err)
+	done := make(chan struct{})
+	tc.packer.EXPECT().PackCoalescedPacket(false, gomock.Any(), gomock.Any(), protocol.Version1).Return(nil, nil).AnyTimes()
+	gomock.InOrder(
+		unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).Return(
+			&unpackedPacket{hdr: hdr, encryptionLevel: protocol.Encryption1RTT, data: data}, nil,
+		),
+		cs.EXPECT().SetHandshakeConfirmed(),
+		tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+			func(buf *packetBuffer, _ protocol.ByteCount, _ time.Time, _ protocol.Version) (shortHeaderPacket, error) {
+				close(done)
+				return shortHeaderPacket{}, errNothingToPack
+			},
+		),
+	)
+	tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(shortHeaderPacket{}, errNothingToPack).AnyTimes()
+	p = getLongHeaderPacket(t, hdr, nil)
+	tc.conn.handlePacket(receivedPacket{data: p.data, buffer: p.buffer, rcvTime: time.Now()})
+
+	select {
+	case <-done:
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
 
-		It("stops sending when the send queue is full", func() {
-			sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny)
-			sph.EXPECT().ECNMode(gomock.Any())
-			expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 1000}, []byte("packet1000"))
-			written := make(chan struct{}, 1)
-			sender.EXPECT().WouldBlock()
-			sender.EXPECT().WouldBlock().Return(true).Times(2)
-			sender.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(*packetBuffer, uint16, protocol.ECN) { written <- struct{}{} })
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				conn.run()
-			}()
-			available := make(chan struct{}, 1)
-			sender.EXPECT().Available().Return(available)
-			conn.scheduleSending()
-			Eventually(written).Should(Receive())
-			time.Sleep(scaleDuration(50 * time.Millisecond))
-
-			// now make room in the send queue
-			sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
-			sph.EXPECT().ECNMode(gomock.Any()).AnyTimes()
-			sender.EXPECT().WouldBlock().AnyTimes()
-			expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 1001}, []byte("packet1001"))
-			packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), conn.version).Return(shortHeaderPacket{}, errNothingToPack)
-			sender.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(*packetBuffer, uint16, protocol.ECN) { written <- struct{}{} })
-			available <- struct{}{}
-			Eventually(written).Should(Receive())
-
-			// The send queue is not full any more. Sending on the available channel should have no effect.
-			available <- struct{}{}
-			time.Sleep(scaleDuration(50 * time.Millisecond))
-		})
+	if usePreferredAddress {
+		tc.connRunner.EXPECT().AddResetToken(preferredAddressResetToken, gomock.Any())
+	}
+	nextConnID := tc.conn.connIDManager.Get()
+	if usePreferredAddress {
+		require.Equal(t, preferredAddressConnID, nextConnID)
+	}
 
-		It("doesn't set a pacing timer when there is no data to send", func() {
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
-			sph.EXPECT().ECNMode(gomock.Any()).AnyTimes()
-			sender.EXPECT().WouldBlock().AnyTimes()
-			packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), conn.version).Return(shortHeaderPacket{}, errNothingToPack)
-			// don't EXPECT any calls to mconn.Write()
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				conn.run()
-			}()
-			conn.scheduleSending() // no packet will get sent
-			time.Sleep(50 * time.Millisecond)
-		})
+	// test teardown
+	cs.EXPECT().Close()
+	tc.connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
+	if usePreferredAddress {
+		tc.connRunner.EXPECT().RemoveResetToken(preferredAddressResetToken)
+	}
+	tc.conn.destroy(nil)
+	select {
+	case err := <-errChan:
+		require.NoError(t, err)
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+}
 
-		It("sends a Path MTU probe packet", func() {
-			mtuDiscoverer := NewMockMTUDiscoverer(mockCtrl)
-			conn.mtuDiscoverer = mtuDiscoverer
-			conn.config.DisablePathMTUDiscovery = false
-			sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny)
-			sph.EXPECT().ECNMode(true)
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendNone)
-			written := make(chan struct{}, 1)
-			sender.EXPECT().WouldBlock().AnyTimes()
-			sender.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(*packetBuffer, uint16, protocol.ECN) { written <- struct{}{} })
-			mtuDiscoverer.EXPECT().ShouldSendProbe(gomock.Any()).Return(true)
-			ping := ackhandler.Frame{Frame: &wire.PingFrame{}}
-			mtuDiscoverer.EXPECT().GetPing().Return(ping, protocol.ByteCount(1234))
-			packer.EXPECT().PackMTUProbePacket(ping, protocol.ByteCount(1234), conn.version).Return(shortHeaderPacket{PacketNumber: 1}, getPacketBuffer(), nil)
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				conn.run()
-			}()
-			conn.scheduleSending()
-			Eventually(written).Should(Receive())
-			mtuDiscoverer.EXPECT().CurrentSize().Return(protocol.ByteCount(1234))
-		})
-	})
+func TestConnection0RTTTransportParameters(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	cs := mocks.NewMockCryptoSetup(mockCtrl)
+	unpacker := NewMockUnpacker(mockCtrl)
+	tc := newClientTestConnection(t, mockCtrl, nil, false, connectionOptCryptoSetup(cs), connectionOptUnpacker(unpacker))
+	tc.sendConn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+
+	// the state transition is driven by processing of a CRYPTO frame
+	hdr := &wire.ExtendedHeader{
+		Header:          wire.Header{Type: protocol.PacketTypeHandshake, Version: protocol.Version1},
+		PacketNumberLen: protocol.PacketNumberLen2,
+	}
+	data, err := (&wire.CryptoFrame{Data: []byte("foobar")}).Append(nil, protocol.Version1)
+	require.NoError(t, err)
+
+	restored := &wire.TransportParameters{
+		ActiveConnectionIDLimit:        3,
+		InitialMaxData:                 0x5000,
+		InitialMaxStreamDataBidiLocal:  0x5000,
+		InitialMaxStreamDataBidiRemote: 1000,
+		InitialMaxStreamDataUni:        1000,
+		MaxBidiStreamNum:               500,
+		MaxUniStreamNum:                500,
+	}
+	new := *restored
+	new.MaxBidiStreamNum-- // the server is not allowed to reduce the limit
+	new.OriginalDestinationConnectionID = tc.destConnID
+
+	packedFirstPacket := make(chan struct{})
+	gomock.InOrder(
+		cs.EXPECT().StartHandshake(gomock.Any()),
+		cs.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventRestoredTransportParameters, TransportParameters: restored}),
+		cs.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent}),
+		tc.packer.EXPECT().PackCoalescedPacket(false, gomock.Any(), gomock.Any(), protocol.Version1).DoAndReturn(
+			func(b bool, bc protocol.ByteCount, t time.Time, v protocol.Version) (*coalescedPacket, error) {
+				close(packedFirstPacket)
+				return &coalescedPacket{buffer: getPacketBuffer(), longHdrPackets: []*longHeaderPacket{{header: hdr}}}, nil
+			},
+		),
+		// initial keys are dropped when the first handshake packet is sent
+		cs.EXPECT().DiscardInitialKeys(),
+		// no more data to send
+		unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).Return(
+			&unpackedPacket{hdr: hdr, encryptionLevel: protocol.EncryptionHandshake, data: data}, nil,
+		),
+		cs.EXPECT().HandleMessage([]byte("foobar"), protocol.EncryptionHandshake),
+		cs.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventReceivedTransportParameters, TransportParameters: &new}),
+		cs.EXPECT().ConnectionState().Return(handshake.ConnectionState{Used0RTT: true}),
+		// cs.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent}),
+		cs.EXPECT().Close(),
+	)
+	tc.packer.EXPECT().PackCoalescedPacket(false, gomock.Any(), gomock.Any(), protocol.Version1).Return(nil, nil).AnyTimes()
+	tc.packer.EXPECT().PackConnectionClose(gomock.Any(), gomock.Any(), protocol.Version1).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil)
+	tc.connRunner.EXPECT().ReplaceWithClosed(gomock.Any(), gomock.Any())
 
-	Context("scheduling sending", func() {
-		var sender *MockSender
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
 
-		BeforeEach(func() {
-			sender = NewMockSender(mockCtrl)
-			sender.EXPECT().WouldBlock().AnyTimes()
-			sender.EXPECT().Run()
-			conn.sendQueue = sender
-			conn.handshakeConfirmed = true
-		})
+	select {
+	case <-packedFirstPacket:
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
 
-		AfterEach(func() {
-			// make the go routine return
-			expectReplaceWithClosed()
-			streamManager.EXPECT().CloseWithError(gomock.Any())
-			packer.EXPECT().PackApplicationClose(gomock.Any(), gomock.Any(), conn.version).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil)
-			cryptoSetup.EXPECT().Close()
-			mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
-			sender.EXPECT().Close()
-			tracer.EXPECT().ClosedConnection(gomock.Any())
-			tracer.EXPECT().Close()
-			conn.CloseWithError(0, "")
-			Eventually(conn.Context().Done()).Should(BeClosed())
-		})
+	p := getLongHeaderPacket(t, hdr, nil)
+	tc.conn.handlePacket(receivedPacket{data: p.data, buffer: p.buffer, rcvTime: time.Now()})
 
-		It("sends when scheduleSending is called", func() {
-			sph := mockackhandler.NewMockSentPacketHandler(mockCtrl)
-			sph.EXPECT().GetLossDetectionTimeout().AnyTimes()
-			sph.EXPECT().TimeUntilSend().AnyTimes()
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
-			sph.EXPECT().ECNMode(gomock.Any()).AnyTimes()
-
-			sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-			conn.sentPacketHandler = sph
-			expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 1}, []byte("packet1"))
-			packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), conn.version).Return(shortHeaderPacket{}, errNothingToPack)
-
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				conn.run()
-			}()
-			// don't EXPECT any calls to mconn.Write()
-			time.Sleep(50 * time.Millisecond)
-			// only EXPECT calls after scheduleSending is called
-			written := make(chan struct{})
-			sender.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any()).Do(func(*packetBuffer, uint16, protocol.ECN) { close(written) })
-			tracer.EXPECT().SentShortHeaderPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
-			conn.scheduleSending()
-			Eventually(written).Should(BeClosed())
-		})
+	select {
+	case err := <-errChan:
+		require.ErrorIs(t, err, &qerr.TransportError{ErrorCode: qerr.ProtocolViolation})
+		require.ErrorContains(t, err, "server sent reduced limits after accepting 0-RTT data")
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+}
 
-		It("sets the timer to the ack timer", func() {
-			expectAppendPacket(packer, shortHeaderPacket{PacketNumber: 1234}, []byte("packet1234"))
-			packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), conn.version).Return(shortHeaderPacket{}, errNothingToPack)
-			sph := mockackhandler.NewMockSentPacketHandler(mockCtrl)
-			sph.EXPECT().GetLossDetectionTimeout().AnyTimes()
-			sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
-			sph.EXPECT().ECNMode(gomock.Any()).AnyTimes()
-			sph.EXPECT().SentPacket(gomock.Any(), protocol.PacketNumber(1234), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-			conn.sentPacketHandler = sph
-			rph := mockackhandler.NewMockReceivedPacketHandler(mockCtrl)
-			rph.EXPECT().GetAlarmTimeout().Return(time.Now().Add(10 * time.Millisecond))
-			// make the run loop wait
-			rph.EXPECT().GetAlarmTimeout().Return(time.Now().Add(time.Hour)).MaxTimes(1)
-			conn.receivedPacketHandler = rph
-
-			written := make(chan struct{})
-			sender.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any()).Do(func(*packetBuffer, uint16, protocol.ECN) { close(written) })
-			tracer.EXPECT().SentShortHeaderPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				conn.run()
-			}()
-			Eventually(written).Should(BeClosed())
-		})
+func TestConnectionReceivePrioritization(t *testing.T) {
+	t.Run("handshake complete", func(t *testing.T) {
+		counter := testConnectionReceivePrioritization(t, true)
+		require.Equal(t, 10, counter)
 	})
 
-	It("sends coalesced packets before the handshake is confirmed", func() {
-		conn.handshakeComplete = false
-		conn.handshakeConfirmed = false
-		sph := mockackhandler.NewMockSentPacketHandler(mockCtrl)
-		conn.sentPacketHandler = sph
-		buffer := getPacketBuffer()
-		buffer.Data = append(buffer.Data, []byte("foobar")...)
-		packer.EXPECT().PackCoalescedPacket(false, gomock.Any(), gomock.Any(), conn.version).Return(&coalescedPacket{
-			buffer: buffer,
-			longHdrPackets: []*longHeaderPacket{
-				{
-					header: &wire.ExtendedHeader{
-						Header:       wire.Header{Type: protocol.PacketTypeInitial},
-						PacketNumber: 13,
-					},
-					length: 123,
-				},
-				{
-					header: &wire.ExtendedHeader{
-						Header:       wire.Header{Type: protocol.PacketTypeHandshake},
-						PacketNumber: 37,
-					},
-					length: 1234,
-				},
-			},
-		}, nil)
-		packer.EXPECT().PackCoalescedPacket(false, gomock.Any(), gomock.Any(), conn.version).AnyTimes()
-
-		sph.EXPECT().GetLossDetectionTimeout().AnyTimes()
-		sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
-		sph.EXPECT().ECNMode(false).Return(protocol.ECT1).AnyTimes()
-		sph.EXPECT().TimeUntilSend().Return(time.Now()).AnyTimes()
-		gomock.InOrder(
-			sph.EXPECT().SentPacket(gomock.Any(), protocol.PacketNumber(13), gomock.Any(), gomock.Any(), gomock.Any(), protocol.EncryptionInitial, protocol.ECT1, protocol.ByteCount(123), gomock.Any()),
-			sph.EXPECT().SentPacket(gomock.Any(), protocol.PacketNumber(37), gomock.Any(), gomock.Any(), gomock.Any(), protocol.EncryptionHandshake, protocol.ECT1, protocol.ByteCount(1234), gomock.Any()),
-		)
-		gomock.InOrder(
-			tracer.EXPECT().SentLongHeaderPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Do(func(hdr *wire.ExtendedHeader, _ protocol.ByteCount, _ logging.ECN, _ *wire.AckFrame, _ []logging.Frame) {
-				Expect(hdr.Type).To(Equal(protocol.PacketTypeInitial))
-			}),
-			tracer.EXPECT().SentLongHeaderPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Do(func(hdr *wire.ExtendedHeader, _ protocol.ByteCount, _ logging.ECN, _ *wire.AckFrame, _ []logging.Frame) {
-				Expect(hdr.Type).To(Equal(protocol.PacketTypeHandshake))
-			}),
-		)
-
-		sent := make(chan struct{})
-		mconn.EXPECT().Write([]byte("foobar"), uint16(0), protocol.ECT1).Do(func([]byte, uint16, protocol.ECN) error { close(sent); return nil })
-
-		go func() {
-			defer GinkgoRecover()
-			cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-			cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-			conn.run()
-		}()
-
-		conn.scheduleSending()
-		Eventually(sent).Should(BeClosed())
-
-		// make sure the go routine returns
-		streamManager.EXPECT().CloseWithError(gomock.Any())
-		expectReplaceWithClosed()
-		packer.EXPECT().PackApplicationClose(gomock.Any(), gomock.Any(), conn.version).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil)
-		cryptoSetup.EXPECT().Close()
-		mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
-		tracer.EXPECT().ClosedConnection(gomock.Any())
-		tracer.EXPECT().Close()
-		conn.CloseWithError(0, "")
-		Eventually(conn.Context().Done()).Should(BeClosed())
+	// before handshake completion, we trigger packing of a new packet every time we receive a packet
+	t.Run("handshake not complete", func(t *testing.T) {
+		counter := testConnectionReceivePrioritization(t, false)
+		require.Equal(t, 1, counter)
 	})
+}
 
-	It("cancels the HandshakeComplete context when the handshake completes", func() {
-		packer.EXPECT().PackCoalescedPacket(false, gomock.Any(), gomock.Any(), conn.version).AnyTimes()
-		sph := mockackhandler.NewMockSentPacketHandler(mockCtrl)
-		conn.sentPacketHandler = sph
-		tracer.EXPECT().DroppedEncryptionLevel(protocol.EncryptionHandshake)
-		tracer.EXPECT().ChoseALPN(gomock.Any())
-		sph.EXPECT().GetLossDetectionTimeout().AnyTimes()
-		sph.EXPECT().TimeUntilSend().AnyTimes()
-		sph.EXPECT().SendMode(gomock.Any()).AnyTimes()
-		sph.EXPECT().DropPackets(protocol.EncryptionHandshake)
-		sph.EXPECT().SetHandshakeConfirmed()
-		connRunner.EXPECT().Retire(clientDestConnID)
-		cryptoSetup.EXPECT().SetHandshakeConfirmed()
-		cryptoSetup.EXPECT().GetSessionTicket()
-		cryptoSetup.EXPECT().ConnectionState()
-		handshakeCtx := conn.HandshakeComplete()
-		Consistently(handshakeCtx).ShouldNot(BeClosed())
-		Expect(conn.handleHandshakeComplete()).To(Succeed())
-		Eventually(handshakeCtx).Should(BeClosed())
-	})
+func testConnectionReceivePrioritization(t *testing.T, handshakeComplete bool) int {
+	mockCtrl := gomock.NewController(t)
+	unpacker := NewMockUnpacker(mockCtrl)
+	opts := []testConnectionOpt{connectionOptUnpacker(unpacker)}
+	if handshakeComplete {
+		opts = append(opts, connectionOptHandshakeConfirmed())
+	}
+	tc := newServerTestConnection(t,
+		mockCtrl,
+		nil,
+		false,
+		opts...,
+	)
 
-	It("sends a session ticket when the handshake completes", func() {
-		const size = protocol.MaxPostHandshakeCryptoFrameSize * 3 / 2
-		packer.EXPECT().PackCoalescedPacket(false, gomock.Any(), gomock.Any(), conn.version).AnyTimes()
-		connRunner.EXPECT().Retire(clientDestConnID)
-		conn.sentPacketHandler.DropPackets(protocol.EncryptionInitial)
-		tracer.EXPECT().DroppedEncryptionLevel(protocol.EncryptionHandshake)
-		tracer.EXPECT().ChoseALPN(gomock.Any())
-		cryptoSetup.EXPECT().SetHandshakeConfirmed()
-		cryptoSetup.EXPECT().GetSessionTicket().Return(make([]byte, size), nil)
-		cryptoSetup.EXPECT().ConnectionState()
-
-		handshakeCtx := conn.HandshakeComplete()
-		Consistently(handshakeCtx).ShouldNot(BeClosed())
-		Expect(conn.handleHandshakeComplete()).To(Succeed())
-		var frames []ackhandler.Frame
-		Eventually(func() []ackhandler.Frame {
-			frames, _ = conn.framer.AppendControlFrames(nil, protocol.MaxByteCount, time.Now(), protocol.Version1)
-			return frames
-		}).ShouldNot(BeEmpty())
-		var count int
-		var s int
-		for _, f := range frames {
-			if cf, ok := f.Frame.(*wire.CryptoFrame); ok {
-				count++
-				s += len(cf.Data)
-				Expect(f.Frame.Length(conn.version)).To(BeNumerically("<=", protocol.MaxPostHandshakeCryptoFrameSize))
+	var counter int
+	var packedFirst bool
+	done := make(chan struct{})
+	unpacker.EXPECT().UnpackShortHeader(gomock.Any(), gomock.Any()).DoAndReturn(
+		func(rcvTime time.Time, data []byte) (protocol.PacketNumber, protocol.PacketNumberLen, protocol.KeyPhaseBit, []byte, error) {
+			if !packedFirst {
+				counter++
 			}
-		}
-		Expect(size).To(BeEquivalentTo(s))
-	})
+			return protocol.PacketNumber(counter), protocol.PacketNumberLen2, protocol.KeyPhaseZero, []byte{0, 1} /* PADDING, PING */, nil
+		},
+	).AnyTimes()
+	switch handshakeComplete {
+	case false:
+		tc.packer.EXPECT().PackCoalescedPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+			func(b bool, bc protocol.ByteCount, t time.Time, v protocol.Version) (*coalescedPacket, error) {
+				if !packedFirst {
+					packedFirst = true
+					close(done)
+				}
+				return nil, nil
+			},
+		).AnyTimes()
+	case true:
+		tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+			func(b *packetBuffer, bc protocol.ByteCount, t time.Time, v protocol.Version) (shortHeaderPacket, error) {
+				if !packedFirst {
+					packedFirst = true
+					close(done)
+				}
+				return shortHeaderPacket{}, errNothingToPack
+			},
+		).AnyTimes()
+	}
 
-	It("doesn't cancel the HandshakeComplete context when the handshake fails", func() {
-		packer.EXPECT().PackCoalescedPacket(false, gomock.Any(), gomock.Any(), conn.version).AnyTimes()
-		streamManager.EXPECT().CloseWithError(gomock.Any())
-		expectReplaceWithClosed()
-		packer.EXPECT().PackConnectionClose(gomock.Any(), gomock.Any(), conn.version).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil)
-		cryptoSetup.EXPECT().Close()
-		tracer.EXPECT().ClosedConnection(gomock.Any())
-		tracer.EXPECT().Close()
-		go func() {
-			defer GinkgoRecover()
-			cryptoSetup.EXPECT().StartHandshake(gomock.Any())
-			cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-			conn.run()
-		}()
-		handshakeCtx := conn.HandshakeComplete()
-		Consistently(handshakeCtx).ShouldNot(BeClosed())
-		mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
-		conn.closeLocal(errors.New("handshake error"))
-		Consistently(handshakeCtx).ShouldNot(BeClosed())
-		Eventually(conn.Context().Done()).Should(BeClosed())
-	})
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
 
-	It("sends a HANDSHAKE_DONE frame when the handshake completes", func() {
-		sph := mockackhandler.NewMockSentPacketHandler(mockCtrl)
-		sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
-		sph.EXPECT().ECNMode(gomock.Any()).AnyTimes()
-		sph.EXPECT().GetLossDetectionTimeout().AnyTimes()
-		sph.EXPECT().TimeUntilSend().AnyTimes()
-		sph.EXPECT().SetHandshakeConfirmed()
-		sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-		mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
-		tracer.EXPECT().SentShortHeaderPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-		tracer.EXPECT().ChoseALPN(gomock.Any())
-		conn.sentPacketHandler = sph
-		done := make(chan struct{})
-		connRunner.EXPECT().Retire(clientDestConnID)
-		packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), conn.version).DoAndReturn(func(_ *packetBuffer, _ protocol.ByteCount, _ time.Time, v protocol.Version) (shortHeaderPacket, error) {
-			frames, _ := conn.framer.AppendControlFrames(nil, protocol.MaxByteCount, time.Now(), v)
-			Expect(frames).ToNot(BeEmpty())
-			Expect(frames[0].Frame).To(BeEquivalentTo(&wire.HandshakeDoneFrame{}))
-			defer close(done)
-			return shortHeaderPacket{}, nil
-		})
-		packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), conn.version).Return(shortHeaderPacket{}, errNothingToPack).AnyTimes()
-		tracer.EXPECT().DroppedEncryptionLevel(protocol.EncryptionHandshake)
-		sph.EXPECT().DropPackets(protocol.EncryptionHandshake)
-		go func() {
-			defer GinkgoRecover()
-			cryptoSetup.EXPECT().StartHandshake(gomock.Any())
-			cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventHandshakeComplete})
-			cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-			cryptoSetup.EXPECT().SetHandshakeConfirmed()
-			cryptoSetup.EXPECT().GetSessionTicket()
-			cryptoSetup.EXPECT().ConnectionState()
-			mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
-			Expect(conn.handleHandshakeComplete()).To(Succeed())
-			conn.run()
-		}()
-		Eventually(done).Should(BeClosed())
-		// make sure the go routine returns
-		streamManager.EXPECT().CloseWithError(gomock.Any())
-		expectReplaceWithClosed()
-		packer.EXPECT().PackApplicationClose(gomock.Any(), gomock.Any(), conn.version).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil)
-		cryptoSetup.EXPECT().Close()
-		tracer.EXPECT().ClosedConnection(gomock.Any())
-		tracer.EXPECT().Close()
-		conn.CloseWithError(0, "")
-		Eventually(conn.Context().Done()).Should(BeClosed())
-	})
+	for i := 0; i < 10; i++ {
+		tc.conn.handlePacket(getShortHeaderPacket(t, tc.srcConnID, protocol.PacketNumber(i), []byte("foobar")))
+	}
 
-	It("doesn't return a run error when closing", func() {
-		done := make(chan struct{})
-		go func() {
-			defer GinkgoRecover()
-			cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-			cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-			Expect(conn.run()).To(Succeed())
-			close(done)
-		}()
-		streamManager.EXPECT().CloseWithError(gomock.Any())
-		cryptoSetup.EXPECT().Close()
-		connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
-		tracer.EXPECT().ClosedConnection(gomock.Any())
-		tracer.EXPECT().Close()
-		conn.destroy(nil)
-		Eventually(done).Should(BeClosed())
-		Expect(context.Cause(conn.Context())).To(MatchError(context.Canceled))
-	})
+	select {
+	case <-done:
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
 
-	It("passes errors to the connection runner", func() {
-		testErr := errors.New("handshake error")
-		expectedErr := &qerr.ApplicationError{
-			ErrorCode:    0x1337,
-			ErrorMessage: testErr.Error(),
-		}
-		done := make(chan struct{})
-		go func() {
-			defer GinkgoRecover()
-			cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-			cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-			err := conn.run()
-			Expect(err).To(MatchError(expectedErr))
-			close(done)
-		}()
-		streamManager.EXPECT().CloseWithError(gomock.Any())
-		expectReplaceWithClosed()
-		packer.EXPECT().PackApplicationClose(gomock.Any(), gomock.Any(), conn.version).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil)
-		cryptoSetup.EXPECT().Close()
-		mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
-		tracer.EXPECT().ClosedConnection(gomock.Any())
-		tracer.EXPECT().Close()
-		Expect(conn.CloseWithError(0x1337, testErr.Error())).To(Succeed())
-		Eventually(done).Should(BeClosed())
-		Expect(context.Cause(conn.Context())).To(MatchError(expectedErr))
-	})
+	// test teardown
+	tc.connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
+	tc.conn.destroy(nil)
+	select {
+	case err := <-errChan:
+		require.NoError(t, err)
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
 
-	Context("transport parameters", func() {
-		It("processes transport parameters received from the client", func() {
-			params := &wire.TransportParameters{
-				MaxIdleTimeout:                90 * time.Second,
-				InitialMaxStreamDataBidiLocal: 0x5000,
-				InitialMaxData:                0x5000,
-				ActiveConnectionIDLimit:       3,
-				// marshaling always sets it to this value
-				MaxUDPPayloadSize:         protocol.MaxPacketBufferSize,
-				InitialSourceConnectionID: destConnID,
-			}
-			streamManager.EXPECT().UpdateLimits(params)
+	return counter
+}
 
-			packer.EXPECT().PackCoalescedPacket(false, gomock.Any(), gomock.Any(), conn.version).MaxTimes(3)
-			Expect(conn.earlyConnReady()).ToNot(BeClosed())
-			tracer.EXPECT().ReceivedTransportParameters(params)
-			conn.handleTransportParameters(params)
-			Expect(conn.earlyConnReady()).To(BeClosed())
-		})
-	})
+func TestConnectionPacketBuffering(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	unpacker := NewMockUnpacker(mockCtrl)
+	cs := mocks.NewMockCryptoSetup(mockCtrl)
+	tracer, tr := mocklogging.NewMockConnectionTracer(mockCtrl)
+	tc := newServerTestConnection(t,
+		mockCtrl,
+		nil,
+		false,
+		connectionOptUnpacker(unpacker),
+		connectionOptCryptoSetup(cs),
+		connectionOptTracer(tracer),
+	)
 
-	Context("keep-alives", func() {
-		setRemoteIdleTimeout := func(t time.Duration) {
-			streamManager.EXPECT().UpdateLimits(gomock.Any())
-			tracer.EXPECT().ReceivedTransportParameters(gomock.Any())
-			conn.handleTransportParameters(&wire.TransportParameters{
-				MaxIdleTimeout:            t,
-				InitialSourceConnectionID: destConnID,
-			})
-		}
+	tr.EXPECT().NegotiatedVersion(gomock.Any(), gomock.Any(), gomock.Any())
+	tr.EXPECT().StartedConnection(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
+	tr.EXPECT().DroppedEncryptionLevel(gomock.Any())
+	cs.EXPECT().DiscardInitialKeys()
 
-		runConn := func() {
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				conn.run()
-			}()
-		}
+	hdr1 := wire.ExtendedHeader{
+		Header: wire.Header{
+			Type:             protocol.PacketTypeHandshake,
+			DestConnectionID: tc.srcConnID,
+			SrcConnectionID:  tc.destConnID,
+			Length:           8,
+			Version:          protocol.Version1,
+		},
+		PacketNumberLen: protocol.PacketNumberLen1,
+		PacketNumber:    1,
+	}
+	hdr2 := hdr1
+	hdr2.PacketNumber = 2
+	cs.EXPECT().StartHandshake(gomock.Any())
+	buffered := make(chan struct{})
+	gomock.InOrder(
+		cs.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent}),
+		unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).Return(nil, handshake.ErrKeysNotYetAvailable),
+		tr.EXPECT().BufferedPacket(logging.PacketTypeHandshake, gomock.Any()),
+		unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).Return(nil, handshake.ErrKeysNotYetAvailable),
+		tr.EXPECT().BufferedPacket(logging.PacketTypeHandshake, gomock.Any()).Do(
+			func(logging.PacketType, logging.ByteCount) { close(buffered) },
+		),
+	)
 
-		BeforeEach(func() {
-			conn.config.MaxIdleTimeout = 30 * time.Second
-			conn.config.KeepAlivePeriod = 15 * time.Second
-			conn.receivedPacketHandler.ReceivedPacket(0, protocol.ECNNon, protocol.EncryptionHandshake, time.Now(), true)
-		})
+	tc.conn.handlePacket(getLongHeaderPacket(t, &hdr1, []byte("packet1")))
+	tc.conn.handlePacket(getLongHeaderPacket(t, &hdr2, []byte("packet2")))
 
-		AfterEach(func() {
-			// make the go routine return
-			expectReplaceWithClosed()
-			streamManager.EXPECT().CloseWithError(gomock.Any())
-			packer.EXPECT().PackApplicationClose(gomock.Any(), gomock.Any(), conn.version).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil)
-			cryptoSetup.EXPECT().Close()
-			mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
-			tracer.EXPECT().ClosedConnection(gomock.Any())
-			tracer.EXPECT().Close()
-			conn.CloseWithError(0, "")
-			Eventually(conn.Context().Done()).Should(BeClosed())
-		})
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
 
-		It("sends a PING as a keep-alive after half the idle timeout", func() {
-			setRemoteIdleTimeout(5 * time.Second)
-			conn.lastPacketReceivedTime = time.Now().Add(-5 * time.Second / 2)
-			sent := make(chan struct{})
-			packer.EXPECT().PackCoalescedPacket(false, gomock.Any(), gomock.Any(), conn.version).Do(func(bool, protocol.ByteCount, time.Time, protocol.Version) (*coalescedPacket, error) {
-				close(sent)
-				return nil, nil
-			})
-			runConn()
-			Eventually(sent).Should(BeClosed())
-		})
+	select {
+	case <-buffered:
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
 
-		It("doesn't send a PING packet if keep-alive is disabled", func() {
-			setRemoteIdleTimeout(5 * time.Second)
-			conn.config.KeepAlivePeriod = 0
-			conn.lastPacketReceivedTime = time.Now().Add(-time.Second * 5 / 2)
-			runConn()
-			// don't EXPECT() any calls to mconn.Write()
-			time.Sleep(50 * time.Millisecond)
-		})
+	// Now send another packet.
+	// In reality, this packet would contain a CRYPTO frame that advances the TLS handshake
+	// such that new keys become available.
+	var packets []string
+	hdr3 := hdr1
+	hdr3.PacketNumber = 3
+	tc.packer.EXPECT().PackCoalescedPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
+	unpacked := make(chan struct{})
+	cs.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventReceivedReadKeys})
+	cs.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
+
+	gomock.InOrder(
+		// packet 3 contains a CRYPTO frame and triggers the keys to become available
+		unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).DoAndReturn(
+			func(hdr *wire.Header, data []byte) (*unpackedPacket, error) {
+				packets = append(packets, string(data[len(data)-7:]))
+				cf := &wire.CryptoFrame{Data: []byte("foobar")}
+				b, _ := cf.Append(nil, protocol.Version1)
+				return &unpackedPacket{hdr: &hdr3, encryptionLevel: protocol.EncryptionHandshake, data: b}, nil
+			},
+		),
+		cs.EXPECT().HandleMessage(gomock.Any(), gomock.Any()),
+		tr.EXPECT().ReceivedLongHeaderPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()),
+		// packet 1 dequeued from the buffer
+		unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).DoAndReturn(
+			func(hdr *wire.Header, data []byte) (*unpackedPacket, error) {
+				packets = append(packets, string(data[len(data)-7:]))
+				return &unpackedPacket{hdr: &hdr1, encryptionLevel: protocol.EncryptionHandshake, data: []byte{0} /* PADDING */}, nil
+			},
+		),
+		tr.EXPECT().ReceivedLongHeaderPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()),
+		// packet 2 dequeued from the buffer
+		unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).DoAndReturn(
+			func(hdr *wire.Header, data []byte) (*unpackedPacket, error) {
+				packets = append(packets, string(data[len(data)-7:]))
+				close(unpacked)
+				return &unpackedPacket{hdr: &hdr2, encryptionLevel: protocol.EncryptionHandshake, data: []byte{0} /* PADDING */}, nil
+			},
+		),
+		tr.EXPECT().ReceivedLongHeaderPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()),
+	)
 
-		It("doesn't send a PING if the handshake isn't completed yet", func() {
-			conn.config.HandshakeIdleTimeout = time.Hour
-			conn.handshakeComplete = false
-			// Needs to be shorter than our idle timeout.
-			// Otherwise we'll try to send a CONNECTION_CLOSE.
-			conn.lastPacketReceivedTime = time.Now().Add(-20 * time.Second)
-			runConn()
-			// don't EXPECT() any calls to mconn.Write()
-			time.Sleep(50 * time.Millisecond)
-		})
+	tc.conn.handlePacket(getLongHeaderPacket(t, &hdr3, []byte("packet3")))
 
-		It("send PING as keep-alive earliest after 1.5 times the PTO", func() {
-			conn.config.KeepAlivePeriod = time.Microsecond
-			pto := conn.rttStats.PTO(true)
-			conn.lastPacketReceivedTime = time.Now()
-			sentPingTimeChan := make(chan time.Time)
-			packer.EXPECT().PackCoalescedPacket(false, gomock.Any(), gomock.Any(), conn.version).Do(func(bool, protocol.ByteCount, time.Time, protocol.Version) (*coalescedPacket, error) {
-				sentPingTimeChan <- time.Now()
-				return nil, nil
-			})
-			runConn()
-			sentPingTime := <-sentPingTimeChan
-			Expect(sentPingTime.Sub(conn.lastPacketReceivedTime)).To(BeNumerically(">", pto*3/2))
-		})
-	})
+	select {
+	case <-unpacked:
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
 
-	Context("timeouts", func() {
-		BeforeEach(func() {
-			streamManager.EXPECT().CloseWithError(gomock.Any())
-		})
+	// packet3 triggered the keys to become available
+	// packet1 and packet2 are processed from the buffer in order
+	require.Equal(t, []string{"packet3", "packet1", "packet2"}, packets)
+
+	// test teardown
+	tc.connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
+	cs.EXPECT().Close()
+	tr.EXPECT().ClosedConnection(gomock.Any())
+	tr.EXPECT().Close()
+	tc.conn.destroy(nil)
+	select {
+	case err := <-errChan:
+		require.NoError(t, err)
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+}
 
-		It("times out due to no network activity", func() {
-			connRunner.EXPECT().Remove(gomock.Any()).Times(2)
-			conn.lastPacketReceivedTime = time.Now().Add(-time.Hour)
-			done := make(chan struct{})
-			cryptoSetup.EXPECT().Close()
-			gomock.InOrder(
-				tracer.EXPECT().ClosedConnection(gomock.Any()).Do(func(e error) {
-					Expect(e).To(MatchError(&qerr.IdleTimeoutError{}))
-				}),
-				tracer.EXPECT().Close(),
-			)
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				err := conn.run()
-				nerr, ok := err.(net.Error)
-				Expect(ok).To(BeTrue())
-				Expect(nerr.Timeout()).To(BeTrue())
-				Expect(err).To(MatchError(qerr.ErrIdleTimeout))
-				close(done)
-			}()
-			Eventually(done).Should(BeClosed())
-		})
+func TestConnectionPacketPacing(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	sph := mockackhandler.NewMockSentPacketHandler(mockCtrl)
+	sender := NewMockSender(mockCtrl)
+
+	// set a fixed RTT, so that the idle timeout doesn't interfere with this test
+	var rttStats utils.RTTStats
+	rttStats.UpdateRTT(10*time.Second, 0)
+
+	tc := newServerTestConnection(t,
+		mockCtrl,
+		nil,
+		false,
+		connectionOptSentPacketHandler(sph),
+		connectionOptSender(sender),
+		connectionOptHandshakeConfirmed(),
+		connectionOptRTTStats(&rttStats),
+	)
+	sender.EXPECT().Run()
+
+	step := scaleDuration(50 * time.Millisecond)
+
+	sph.EXPECT().GetLossDetectionTimeout().Return(time.Now().Add(time.Hour)).AnyTimes()
+	gomock.InOrder(
+		// 1. allow 2 packets to be sent
+		sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny),
+		sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()),
+		sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny),
+		sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()),
+		sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendPacingLimited),
+		// 2. become pacing limited for 25ms
+		sph.EXPECT().TimeUntilSend().DoAndReturn(func() time.Time { return time.Now().Add(step) }),
+		// 3. send another packet
+		sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny),
+		sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()),
+		sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendPacingLimited),
+		// 4. become pacing limited for 25ms...
+		sph.EXPECT().TimeUntilSend().DoAndReturn(func() time.Time { return time.Now().Add(step) }),
+		// ... but this time we're still pacing limited when waking up.
+		// In this case, we can only send an ACK.
+		sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendPacingLimited),
+		// 5. stop the test by becoming pacing limited forever
+		sph.EXPECT().TimeUntilSend().Return(time.Now().Add(time.Hour)),
+		sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()),
+	)
+	sph.EXPECT().ECNMode(gomock.Any()).AnyTimes()
+	for i := 0; i < 3; i++ {
+		tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), Version1).DoAndReturn(
+			func(buf *packetBuffer, _ protocol.ByteCount, _ time.Time, _ protocol.Version) (shortHeaderPacket, error) {
+				buf.Data = append(buf.Data, []byte("packet"+strconv.Itoa(i+1))...)
+				return shortHeaderPacket{PacketNumber: protocol.PacketNumber(i + 1)}, nil
+			},
+		)
+	}
+	tc.packer.EXPECT().PackAckOnlyPacket(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+		func(_ protocol.ByteCount, _ time.Time, _ protocol.Version) (shortHeaderPacket, *packetBuffer, error) {
+			buf := getPacketBuffer()
+			buf.Data = []byte("ack")
+			return shortHeaderPacket{PacketNumber: 1}, buf, nil
+		},
+	)
+	sender.EXPECT().WouldBlock().AnyTimes()
 
-		It("times out due to non-completed handshake", func() {
-			conn.handshakeComplete = false
-			conn.creationTime = time.Now().Add(-2 * protocol.DefaultHandshakeIdleTimeout).Add(-time.Second)
-			connRunner.EXPECT().Remove(gomock.Any()).Times(2)
-			cryptoSetup.EXPECT().Close()
-			gomock.InOrder(
-				tracer.EXPECT().ClosedConnection(gomock.Any()).Do(func(e error) {
-					Expect(e).To(MatchError(&HandshakeTimeoutError{}))
-				}),
-				tracer.EXPECT().Close(),
-			)
-			done := make(chan struct{})
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				err := conn.run()
-				nerr, ok := err.(net.Error)
-				Expect(ok).To(BeTrue())
-				Expect(nerr.Timeout()).To(BeTrue())
-				Expect(err).To(MatchError(qerr.ErrHandshakeTimeout))
-				close(done)
-			}()
-			Eventually(done).Should(BeClosed())
-		})
+	type sentPacket struct {
+		time time.Time
+		data []byte
+	}
+	sendChan := make(chan sentPacket, 10)
+	sender.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any()).Do(func(b *packetBuffer, _ uint16, _ protocol.ECN) {
+		sendChan <- sentPacket{time: time.Now(), data: b.Data}
+	}).Times(4)
+
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
+	tc.conn.scheduleSending()
+
+	var times []time.Time
+	for i := 0; i < 3; i++ {
+		select {
+		case b := <-sendChan:
+			require.Equal(t, []byte("packet"+strconv.Itoa(i+1)), b.data)
+			times = append(times, b.time)
+		case <-time.After(scaleDuration(time.Second)):
+			t.Fatal("timeout")
+		}
+	}
+	select {
+	case b := <-sendChan:
+		require.Equal(t, []byte("ack"), b.data)
+		times = append(times, b.time)
+	case <-time.After(scaleDuration(time.Second)):
+		t.Fatal("timeout")
+	}
 
-		It("does not use the idle timeout before the handshake complete", func() {
-			conn.handshakeComplete = false
-			conn.config.HandshakeIdleTimeout = 9999 * time.Second
-			conn.config.MaxIdleTimeout = 9999 * time.Second
-			conn.lastPacketReceivedTime = time.Now().Add(-time.Minute)
-			packer.EXPECT().PackApplicationClose(gomock.Any(), gomock.Any(), conn.version).DoAndReturn(func(e *qerr.ApplicationError, _ protocol.ByteCount, _ protocol.Version) (*coalescedPacket, error) {
-				Expect(e.ErrorCode).To(BeZero())
-				return &coalescedPacket{buffer: getPacketBuffer()}, nil
-			})
-			gomock.InOrder(
-				tracer.EXPECT().ClosedConnection(gomock.Any()).Do(func(e error) {
-					idleTimeout := &IdleTimeoutError{}
-					handshakeTimeout := &HandshakeTimeoutError{}
-					Expect(errors.As(e, &idleTimeout)).To(BeFalse())
-					Expect(errors.As(e, &handshakeTimeout)).To(BeFalse())
-				}),
-				tracer.EXPECT().Close(),
-			)
-			// the handshake timeout is irrelevant here, since it depends on the time the connection was created,
-			// and not on the last network activity
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				conn.run()
-			}()
-			Consistently(conn.Context().Done()).ShouldNot(BeClosed())
-			// make the go routine return
-			expectReplaceWithClosed()
-			cryptoSetup.EXPECT().Close()
-			mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
-			conn.CloseWithError(0, "")
-			Eventually(conn.Context().Done()).Should(BeClosed())
-		})
+	require.InDelta(t, times[0].Sub(times[1]).Seconds(), 0, scaleDuration(10*time.Millisecond).Seconds())
+	require.InDelta(t, times[2].Sub(times[1]).Seconds(), step.Seconds(), scaleDuration(20*time.Millisecond).Seconds())
+	require.InDelta(t, times[3].Sub(times[2]).Seconds(), step.Seconds(), scaleDuration(20*time.Millisecond).Seconds())
+
+	time.Sleep(scaleDuration(step)) // make sure that no more packets are sent
+	require.True(t, mockCtrl.Satisfied())
+
+	// test teardown
+	sender.EXPECT().Close()
+	tc.connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
+	tc.conn.destroy(nil)
+	select {
+	case <-sendChan:
+		t.Fatal("should not have sent any more packets")
+	case err := <-errChan:
+		require.NoError(t, err)
+	case <-time.After(3 * time.Second):
+		t.Fatal("timeout")
+	}
+}
 
-		It("closes the connection due to the idle timeout before handshake", func() {
-			conn.config.HandshakeIdleTimeout = scaleDuration(25 * time.Millisecond)
-			packer.EXPECT().PackCoalescedPacket(false, gomock.Any(), gomock.Any(), conn.version).AnyTimes()
-			connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
-			cryptoSetup.EXPECT().Close()
-			gomock.InOrder(
-				tracer.EXPECT().ClosedConnection(gomock.Any()).Do(func(e error) {
-					Expect(e).To(MatchError(&IdleTimeoutError{}))
-				}),
-				tracer.EXPECT().Close(),
-			)
-			done := make(chan struct{})
-			conn.handshakeComplete = false
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				cryptoSetup.EXPECT().GetSessionTicket().MaxTimes(1)
-				err := conn.run()
-				nerr, ok := err.(net.Error)
-				Expect(ok).To(BeTrue())
-				Expect(nerr.Timeout()).To(BeTrue())
-				Expect(err).To(MatchError(qerr.ErrIdleTimeout))
-				close(done)
-			}()
-			Eventually(done).Should(BeClosed())
-		})
+func TestConnectionIdleTimeout(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	var rttStats utils.RTTStats
+	rttStats.UpdateRTT(time.Millisecond, 0)
+	sph := mockackhandler.NewMockSentPacketHandler(mockCtrl)
+	tc := newServerTestConnection(t,
+		mockCtrl,
+		&Config{MaxIdleTimeout: time.Second},
+		false,
+		connectionOptHandshakeConfirmed(),
+		connectionOptSentPacketHandler(sph),
+		connectionOptRTTStats(&rttStats),
+	)
+	// the idle timeout is set when the transport parameters are received
+	idleTimeout := scaleDuration(50 * time.Millisecond)
+	require.NoError(t, tc.conn.handleTransportParameters(&wire.TransportParameters{
+		MaxIdleTimeout: idleTimeout,
+	}))
+
+	sph.EXPECT().GetLossDetectionTimeout().AnyTimes()
+	sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
+	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
+	sph.EXPECT().ECNMode(gomock.Any()).AnyTimes()
+	var lastSendTime time.Time
+	tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+		func(buf *packetBuffer, _ protocol.ByteCount, _ time.Time, _ protocol.Version) (shortHeaderPacket, error) {
+			buf.Data = append(buf.Data, []byte("foobar")...)
+			lastSendTime = time.Now()
+			return shortHeaderPacket{Frames: []ackhandler.Frame{{Frame: &wire.PingFrame{}}}, Length: 6}, nil
+		},
+	)
+	tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(shortHeaderPacket{}, errNothingToPack)
+	tc.sendConn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
+	tc.connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
+
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
+	tc.conn.scheduleSending()
+
+	select {
+	case err := <-errChan:
+		require.ErrorIs(t, err, &IdleTimeoutError{})
+		require.NotZero(t, lastSendTime)
+		require.InDelta(t,
+			time.Since(lastSendTime).Seconds(),
+			idleTimeout.Seconds(),
+			scaleDuration(10*time.Millisecond).Seconds(),
+		)
+	case <-time.After(3 * time.Second):
+		t.Fatal("timeout")
+	}
+}
 
-		It("closes the connection due to the idle timeout after handshake", func() {
-			conn.sentPacketHandler.DropPackets(protocol.EncryptionInitial)
-			packer.EXPECT().PackCoalescedPacket(false, gomock.Any(), gomock.Any(), conn.version).AnyTimes()
-			gomock.InOrder(
-				connRunner.EXPECT().Retire(clientDestConnID),
-				connRunner.EXPECT().Remove(gomock.Any()),
-			)
-			cryptoSetup.EXPECT().Close()
-			gomock.InOrder(
-				tracer.EXPECT().ChoseALPN(gomock.Any()),
-				tracer.EXPECT().DroppedEncryptionLevel(protocol.EncryptionHandshake),
-				tracer.EXPECT().ClosedConnection(gomock.Any()).Do(func(e error) {
-					Expect(e).To(MatchError(&IdleTimeoutError{}))
-				}),
-				tracer.EXPECT().Close(),
-			)
-			conn.idleTimeout = 0
-			done := make(chan struct{})
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventHandshakeComplete})
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				cryptoSetup.EXPECT().GetSessionTicket().MaxTimes(1)
-				cryptoSetup.EXPECT().SetHandshakeConfirmed().MaxTimes(1)
-				cryptoSetup.EXPECT().ConnectionState()
-				Expect(conn.handleHandshakeComplete()).To(Succeed())
-				err := conn.run()
-				nerr, ok := err.(net.Error)
-				Expect(ok).To(BeTrue())
-				Expect(nerr.Timeout()).To(BeTrue())
-				Expect(err).To(MatchError(qerr.ErrIdleTimeout))
-				close(done)
-			}()
-			Eventually(done).Should(BeClosed())
-		})
+func TestConnectionKeepAlive(t *testing.T) {
+	t.Run("enabled", func(t *testing.T) {
+		testConnectionKeepAlive(t, true, true)
+	})
 
-		It("doesn't time out when it just sent a packet", func() {
-			conn.lastPacketReceivedTime = time.Now().Add(-time.Hour)
-			conn.firstAckElicitingPacketAfterIdleSentTime = time.Now().Add(-time.Second)
-			conn.idleTimeout = 30 * time.Second
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				conn.run()
-			}()
-			Consistently(conn.Context().Done()).ShouldNot(BeClosed())
-			// make the go routine return
-			packer.EXPECT().PackApplicationClose(gomock.Any(), gomock.Any(), conn.version).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil)
-			expectReplaceWithClosed()
-			cryptoSetup.EXPECT().Close()
-			mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
-			tracer.EXPECT().ClosedConnection(gomock.Any())
-			tracer.EXPECT().Close()
-			conn.CloseWithError(0, "")
-			Eventually(conn.Context().Done()).Should(BeClosed())
-		})
+	t.Run("disabled", func(t *testing.T) {
+		testConnectionKeepAlive(t, false, false)
+	})
+}
 
-		It("times out earliest after 3 times the PTO", func() {
-			packer.EXPECT().PackCoalescedPacket(false, gomock.Any(), gomock.Any(), conn.version).AnyTimes()
-			connRunner.EXPECT().Retire(gomock.Any()).AnyTimes()
-			connRunner.EXPECT().Remove(gomock.Any()).Times(2)
-			cryptoSetup.EXPECT().Close()
-			closeTimeChan := make(chan time.Time)
-			tracer.EXPECT().ClosedConnection(gomock.Any()).Do(func(e error) {
-				Expect(e).To(MatchError(&IdleTimeoutError{}))
-				closeTimeChan <- time.Now()
-			})
-			tracer.EXPECT().Close()
-			conn.idleTimeout = time.Millisecond
-			done := make(chan struct{})
-			pto := conn.rttStats.PTO(true)
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				cryptoSetup.EXPECT().GetSessionTicket().MaxTimes(1)
-				cryptoSetup.EXPECT().SetHandshakeConfirmed().MaxTimes(1)
-				conn.run()
+func testConnectionKeepAlive(t *testing.T, enable, expectKeepAlive bool) {
+	var keepAlivePeriod time.Duration
+	if enable {
+		keepAlivePeriod = time.Second
+	}
+
+	mockCtrl := gomock.NewController(t)
+	unpacker := NewMockUnpacker(mockCtrl)
+	var rttStats utils.RTTStats
+	rttStats.UpdateRTT(time.Millisecond, 0)
+	tc := newServerTestConnection(t,
+		mockCtrl,
+		&Config{MaxIdleTimeout: time.Second, KeepAlivePeriod: keepAlivePeriod},
+		false,
+		connectionOptUnpacker(unpacker),
+		connectionOptHandshakeConfirmed(),
+		connectionOptRTTStats(&rttStats),
+	)
+	// the idle timeout is set when the transport parameters are received
+	idleTimeout := scaleDuration(50 * time.Millisecond)
+	require.NoError(t, tc.conn.handleTransportParameters(&wire.TransportParameters{
+		MaxIdleTimeout: idleTimeout,
+	}))
+
+	// Receive a packet. This starts the keep-alive timer.
+	buf := getPacketBuffer()
+	var err error
+	buf.Data, err = wire.AppendShortHeader(buf.Data, tc.srcConnID, 1, protocol.PacketNumberLen1, protocol.KeyPhaseZero)
+	require.NoError(t, err)
+	buf.Data = append(buf.Data, []byte("packet")...)
+
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
+
+	var unpackTime, packTime time.Time
+	done := make(chan struct{})
+	unpacker.EXPECT().UnpackShortHeader(gomock.Any(), gomock.Any()).DoAndReturn(
+		func(t time.Time, bytes []byte) (protocol.PacketNumber, protocol.PacketNumberLen, protocol.KeyPhaseBit, []byte, error) {
+			unpackTime = time.Now()
+			return protocol.PacketNumber(1), protocol.PacketNumberLen1, protocol.KeyPhaseZero, []byte{0} /* PADDING */, nil
+		},
+	)
+	tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(shortHeaderPacket{}, errNothingToPack)
+
+	switch expectKeepAlive {
+	case true:
+		// record the time of the keep-alive is sent
+		tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+			func(buffer *packetBuffer, count protocol.ByteCount, t time.Time, version protocol.Version) (shortHeaderPacket, error) {
+				packTime = time.Now()
 				close(done)
-			}()
-			closeTime := <-closeTimeChan
-			Expect(closeTime.Sub(conn.lastPacketReceivedTime)).To(BeNumerically(">", pto*3))
-			Eventually(done).Should(BeClosed())
-		})
-	})
+				return shortHeaderPacket{}, errNothingToPack
+			},
+		)
+		tc.conn.handlePacket(receivedPacket{data: buf.Data, buffer: buf, rcvTime: time.Now()})
+		select {
+		case <-done:
+			// the keep-alive packet should be sent after half the idle timeout
+			diff := packTime.Sub(unpackTime)
+			require.InDelta(t, diff.Seconds(), idleTimeout.Seconds()/2, scaleDuration(10*time.Millisecond).Seconds())
+		case <-time.After(idleTimeout):
+			t.Fatal("timeout")
+		}
+	case false: // if keep-alives are disabled, the connection will run into an idle timeout
+		tc.connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
+		tc.conn.handlePacket(receivedPacket{data: buf.Data, buffer: buf, rcvTime: time.Now()})
+		select {
+		case <-time.After(3 * time.Second):
+			t.Fatal("timeout")
+		case <-time.After(idleTimeout):
+		}
+	}
 
-	It("stores up to MaxConnUnprocessedPackets packets", func() {
-		done := make(chan struct{})
-		tracer.EXPECT().DroppedPacket(logging.PacketTypeNotDetermined, protocol.InvalidPacketNumber, logging.ByteCount(6), logging.PacketDropDOSPrevention).Do(func(logging.PacketType, logging.PacketNumber, logging.ByteCount, logging.PacketDropReason) {
-			close(done)
-		})
-		// Nothing here should block
-		for i := protocol.PacketNumber(0); i < protocol.MaxConnUnprocessedPackets+1; i++ {
-			conn.handlePacket(receivedPacket{data: []byte("foobar")})
+	// test teardown
+	if expectKeepAlive {
+		tc.connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
+		tc.conn.destroy(nil)
+	}
+	select {
+	case err := <-errChan:
+		if expectKeepAlive {
+			require.NoError(t, err)
+		} else {
+			require.ErrorIs(t, err, &IdleTimeoutError{})
 		}
-		Eventually(done).Should(BeClosed())
-	})
+	case <-time.After(3 * time.Second):
+		t.Fatal("timeout")
+	}
+}
 
-	Context("getting streams", func() {
-		It("opens streams", func() {
-			mstr := NewMockStreamI(mockCtrl)
-			streamManager.EXPECT().OpenStream().Return(mstr, nil)
-			str, err := conn.OpenStream()
-			Expect(err).ToNot(HaveOccurred())
-			Expect(str).To(Equal(mstr))
-		})
+func TestConnectionACKTimer(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	rph := mockackhandler.NewMockReceivedPacketHandler(mockCtrl)
+	sph := mockackhandler.NewMockSentPacketHandler(mockCtrl)
+	rttStats := &utils.RTTStats{}
+	rttStats.UpdateRTT(10*time.Second, 0)
+	tc := newServerTestConnection(t,
+		mockCtrl,
+		&Config{MaxIdleTimeout: time.Second},
+		false,
+		connectionOptHandshakeConfirmed(),
+		connectionOptReceivedPacketHandler(rph),
+		connectionOptSentPacketHandler(sph),
+		connectionOptRTTStats(rttStats),
+	)
+	alarmTimeout := scaleDuration(50 * time.Millisecond)
+
+	sph.EXPECT().GetLossDetectionTimeout().AnyTimes()
+	sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
+	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+	sph.EXPECT().ECNMode(gomock.Any()).AnyTimes()
+	rph.EXPECT().GetAlarmTimeout().Return(time.Now().Add(time.Hour))
+	tc.sendConn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+
+	var times []time.Time
+	done := make(chan struct{}, 5)
+	var calls []any
+	for i := 0; i < 2; i++ {
+		calls = append(calls, tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+			func(buf *packetBuffer, _ protocol.ByteCount, _ time.Time, _ protocol.Version) (shortHeaderPacket, error) {
+				buf.Data = append(buf.Data, []byte("foobar")...)
+				times = append(times, time.Now())
+				return shortHeaderPacket{Frames: []ackhandler.Frame{{Frame: &wire.PingFrame{}}}, Length: 6}, nil
+			},
+		))
+		calls = append(calls, tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+			func(buf *packetBuffer, _ protocol.ByteCount, _ time.Time, _ protocol.Version) (shortHeaderPacket, error) {
+				done <- struct{}{}
+				return shortHeaderPacket{}, errNothingToPack
+			},
+		))
+		if i == 0 {
+			calls = append(calls, rph.EXPECT().GetAlarmTimeout().Return(time.Now().Add(alarmTimeout)))
+		} else {
+			calls = append(calls, rph.EXPECT().GetAlarmTimeout().Return(time.Now().Add(time.Hour)).MaxTimes(1))
+		}
+	}
+	gomock.InOrder(calls...)
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
+	tc.conn.scheduleSending()
+
+	for i := 0; i < 2; i++ {
+		select {
+		case <-done:
+		case <-time.After(3 * time.Second):
+			t.Fatal("timeout")
+		}
+	}
 
-		It("opens streams synchronously", func() {
-			mstr := NewMockStreamI(mockCtrl)
-			streamManager.EXPECT().OpenStreamSync(context.Background()).Return(mstr, nil)
-			str, err := conn.OpenStreamSync(context.Background())
-			Expect(err).ToNot(HaveOccurred())
-			Expect(str).To(Equal(mstr))
-		})
+	assert.Len(t, times, 2)
+	require.InDelta(t, times[1].Sub(times[0]).Seconds(), alarmTimeout.Seconds(), scaleDuration(10*time.Millisecond).Seconds())
+
+	// test teardown
+	tc.connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
+	tc.conn.destroy(nil)
+	select {
+	case err := <-errChan:
+		require.NoError(t, err)
+	case <-time.After(3 * time.Second):
+		t.Fatal("timeout")
+	}
+}
 
-		It("opens unidirectional streams", func() {
-			mstr := NewMockSendStreamI(mockCtrl)
-			streamManager.EXPECT().OpenUniStream().Return(mstr, nil)
-			str, err := conn.OpenUniStream()
-			Expect(err).ToNot(HaveOccurred())
-			Expect(str).To(Equal(mstr))
-		})
+// Send a GSO batch, until we have no more data to send.
+func TestConnectionGSOBatch(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	sph := mockackhandler.NewMockSentPacketHandler(mockCtrl)
+	tc := newServerTestConnection(t,
+		mockCtrl,
+		nil,
+		true,
+		connectionOptHandshakeConfirmed(),
+		connectionOptSentPacketHandler(sph),
+	)
 
-		It("opens unidirectional streams synchronously", func() {
-			mstr := NewMockSendStreamI(mockCtrl)
-			streamManager.EXPECT().OpenUniStreamSync(context.Background()).Return(mstr, nil)
-			str, err := conn.OpenUniStreamSync(context.Background())
-			Expect(err).ToNot(HaveOccurred())
-			Expect(str).To(Equal(mstr))
-		})
+	// allow packets to be sent
+	sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
+	sph.EXPECT().TimeUntilSend().Return(time.Time{}).AnyTimes()
+	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+	sph.EXPECT().GetLossDetectionTimeout().Return(time.Time{}).AnyTimes()
+	sph.EXPECT().ECNMode(gomock.Any()).Return(protocol.ECT1).AnyTimes()
+
+	maxPacketSize := tc.conn.maxPacketSize()
+	var expectedData []byte
+	for i := 0; i < 4; i++ {
+		data := bytes.Repeat([]byte{byte(i)}, int(maxPacketSize))
+		expectedData = append(expectedData, data...)
+
+		tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+			func(buffer *packetBuffer, count protocol.ByteCount, t time.Time, version protocol.Version) (shortHeaderPacket, error) {
+				buffer.Data = append(buffer.Data, data...)
+				return shortHeaderPacket{PacketNumber: protocol.PacketNumber(i)}, nil
+			},
+		)
+	}
+	done := make(chan struct{})
+	tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(shortHeaderPacket{}, errNothingToPack)
+	tc.sendConn.EXPECT().Write(expectedData, uint16(maxPacketSize), protocol.ECT1).DoAndReturn(
+		func([]byte, uint16, protocol.ECN) error { close(done); return nil },
+	)
 
-		It("accepts streams", func() {
-			ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
-			defer cancel()
-			mstr := NewMockStreamI(mockCtrl)
-			streamManager.EXPECT().AcceptStream(ctx).Return(mstr, nil)
-			str, err := conn.AcceptStream(ctx)
-			Expect(err).ToNot(HaveOccurred())
-			Expect(str).To(Equal(mstr))
-		})
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
+	tc.conn.scheduleSending()
 
-		It("accepts unidirectional streams", func() {
-			ctx, cancel := context.WithTimeout(context.Background(), time.Second)
-			defer cancel()
-			mstr := NewMockReceiveStreamI(mockCtrl)
-			streamManager.EXPECT().AcceptUniStream(ctx).Return(mstr, nil)
-			str, err := conn.AcceptUniStream(ctx)
-			Expect(err).ToNot(HaveOccurred())
-			Expect(str).To(Equal(mstr))
-		})
-	})
+	select {
+	case <-done:
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
 
-	Context("datagrams", func() {
-		It("doesn't allow datagrams if the peer didn't enable support", func() {
-			conn.peerParams = &wire.TransportParameters{MaxDatagramFrameSize: 0}
-			Expect(conn.SendDatagram(make([]byte, 200))).To(MatchError("datagram support disabled"))
-		})
+	// test teardown
+	tc.connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
+	tc.conn.destroy(nil)
+	select {
+	case err := <-errChan:
+		require.NoError(t, err)
+	case <-time.After(3 * time.Second):
+		t.Fatal("timeout")
+	}
+}
 
-		It("sends a datagram", func() {
-			conn.peerParams = &wire.TransportParameters{MaxDatagramFrameSize: 1000}
-			Expect(conn.SendDatagram([]byte("foobar"))).To(Succeed())
-			f := conn.datagramQueue.Peek()
-			Expect(f).ToNot(BeNil())
-			Expect(f.Data).To(Equal([]byte("foobar")))
-		})
+// Send a GSO batch, until a packet smaller than the maximum size is packed
+func TestConnectionGSOBatchPacketSize(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	sph := mockackhandler.NewMockSentPacketHandler(mockCtrl)
+	tc := newServerTestConnection(t,
+		mockCtrl,
+		nil,
+		true,
+		connectionOptHandshakeConfirmed(),
+		connectionOptSentPacketHandler(sph),
+	)
 
-		It("says when a datagram is too big", func() {
-			conn.peerParams = &wire.TransportParameters{MaxDatagramFrameSize: 1000}
-			err := conn.SendDatagram(make([]byte, 2000))
-			Expect(err).To(HaveOccurred())
-			Expect(err).To(BeAssignableToTypeOf(&DatagramTooLargeError{}))
-			derr := err.(*DatagramTooLargeError)
-			Expect(derr.MaxDatagramPayloadSize).To(BeNumerically("<", 1000))
-			Expect(conn.SendDatagram(make([]byte, derr.MaxDatagramPayloadSize))).To(Succeed())
-		})
+	// allow packets to be sent
+	sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
+	sph.EXPECT().TimeUntilSend().Return(time.Time{}).AnyTimes()
+	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+	sph.EXPECT().GetLossDetectionTimeout().Return(time.Time{}).AnyTimes()
+	sph.EXPECT().ECNMode(gomock.Any()).Return(protocol.ECT1).AnyTimes()
+
+	maxPacketSize := tc.conn.maxPacketSize()
+	var expectedData []byte
+	var calls []any
+	for i := 0; i < 4; i++ {
+		var data []byte
+		if i == 3 {
+			data = bytes.Repeat([]byte{byte(i)}, int(maxPacketSize-1))
+		} else {
+			data = bytes.Repeat([]byte{byte(i)}, int(maxPacketSize))
+		}
+		expectedData = append(expectedData, data...)
 
-		It("receives datagrams", func() {
-			conn.config.EnableDatagrams = true
-			conn.datagramQueue.HandleDatagramFrame(&wire.DatagramFrame{Data: []byte("foobar")})
-			data, err := conn.ReceiveDatagram(context.Background())
-			Expect(err).ToNot(HaveOccurred())
-			Expect(data).To(Equal([]byte("foobar")))
-		})
-	})
+		calls = append(calls, tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+			func(buffer *packetBuffer, count protocol.ByteCount, t time.Time, version protocol.Version) (shortHeaderPacket, error) {
+				buffer.Data = append(buffer.Data, data...)
+				return shortHeaderPacket{PacketNumber: protocol.PacketNumber(10 + i)}, nil
+			},
+		))
+	}
+	// The smaller (fourth) packet concluded this GSO batch, but the send loop will immediately start composing the next batch.
+	// We therefore send a "foobar", so we can check that we're actually generating two GSO batches.
+	calls = append(calls,
+		tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+			func(buffer *packetBuffer, count protocol.ByteCount, t time.Time, version protocol.Version) (shortHeaderPacket, error) {
+				buffer.Data = append(buffer.Data, []byte("foobar")...)
+				return shortHeaderPacket{PacketNumber: protocol.PacketNumber(14)}, nil
+			},
+		),
+	)
+	calls = append(calls,
+		tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(shortHeaderPacket{}, errNothingToPack),
+	)
+	gomock.InOrder(calls...)
+
+	done := make(chan struct{})
+	gomock.InOrder(
+		tc.sendConn.EXPECT().Write(expectedData, uint16(maxPacketSize), protocol.ECT1),
+		tc.sendConn.EXPECT().Write([]byte("foobar"), uint16(maxPacketSize), protocol.ECT1).DoAndReturn(
+			func([]byte, uint16, protocol.ECN) error { close(done); return nil },
+		),
+	)
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
+	tc.conn.scheduleSending()
+	select {
+	case <-done:
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
 
-	It("returns the local address", func() {
-		Expect(conn.LocalAddr()).To(Equal(localAddr))
-	})
+	// test teardown
+	tc.connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
+	tc.conn.destroy(nil)
+	select {
+	case err := <-errChan:
+		require.NoError(t, err)
+	case <-time.After(3 * time.Second):
+		t.Fatal("timeout")
+	}
+}
 
-	It("returns the remote address", func() {
-		Expect(conn.RemoteAddr()).To(Equal(remoteAddr))
-	})
-})
-
-var _ = Describe("Client Connection", func() {
-	var (
-		conn        *connection
-		connRunner  *MockConnRunner
-		packer      *MockPacker
-		mconn       *MockSendConn
-		cryptoSetup *mocks.MockCryptoSetup
-		tracer      *mocklogging.MockConnectionTracer
-		tlsConf     *tls.Config
-		quicConf    *Config
-	)
-	srcConnID := protocol.ParseConnectionID([]byte{1, 2, 3, 4, 5, 6, 7, 8})
-	destConnID := protocol.ParseConnectionID([]byte{8, 7, 6, 5, 4, 3, 2, 1})
-
-	getPacket := func(hdr *wire.ExtendedHeader, data []byte) receivedPacket {
-		b, err := hdr.Append(nil, conn.version)
-		Expect(err).ToNot(HaveOccurred())
-		return receivedPacket{
-			rcvTime: time.Now(),
-			data:    append(b, data...),
-			buffer:  getPacketBuffer(),
-		}
+func TestConnectionGSOBatchECN(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	sph := mockackhandler.NewMockSentPacketHandler(mockCtrl)
+	tc := newServerTestConnection(t,
+		mockCtrl,
+		nil,
+		true,
+		connectionOptHandshakeConfirmed(),
+		connectionOptSentPacketHandler(sph),
+	)
+
+	// allow packets to be sent
+	ecnMode := protocol.ECT1
+	sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
+	sph.EXPECT().TimeUntilSend().Return(time.Time{}).AnyTimes()
+	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+	sph.EXPECT().GetLossDetectionTimeout().Return(time.Time{}).AnyTimes()
+	sph.EXPECT().ECNMode(gomock.Any()).DoAndReturn(func(bool) protocol.ECN { return ecnMode }).AnyTimes()
+
+	// 3. Send a GSO batch, until the ECN marking changes.
+	var expectedData []byte
+	var calls []any
+	maxPacketSize := tc.conn.maxPacketSize()
+	for i := 0; i < 3; i++ {
+		data := bytes.Repeat([]byte{byte(i)}, int(maxPacketSize))
+		expectedData = append(expectedData, data...)
+
+		calls = append(calls, tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+			func(buffer *packetBuffer, count protocol.ByteCount, t time.Time, version protocol.Version) (shortHeaderPacket, error) {
+				buffer.Data = append(buffer.Data, data...)
+				if i == 2 {
+					ecnMode = protocol.ECNCE
+				}
+				return shortHeaderPacket{PacketNumber: protocol.PacketNumber(20 + i)}, nil
+			},
+		))
 	}
+	// The smaller (fourth) packet concluded this GSO batch, but the send loop will immediately start composing the next batch.
+	// We therefore send a "foobar", so we can check that we're actually generating two GSO batches.
+	calls = append(calls,
+		tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+			func(buffer *packetBuffer, count protocol.ByteCount, t time.Time, version protocol.Version) (shortHeaderPacket, error) {
+				buffer.Data = append(buffer.Data, []byte("foobar")...)
+				return shortHeaderPacket{PacketNumber: protocol.PacketNumber(24)}, nil
+			},
+		),
+	)
+	calls = append(calls,
+		tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(shortHeaderPacket{}, errNothingToPack),
+	)
+	gomock.InOrder(calls...)
 
-	BeforeEach(func() {
-		quicConf = populateConfig(&Config{})
-		tlsConf = nil
-	})
+	done3 := make(chan struct{})
+	tc.sendConn.EXPECT().Write(expectedData, uint16(maxPacketSize), protocol.ECT1)
+	// TODO(#4829): check that the correct ECN marking is used
+	tc.sendConn.EXPECT().Write([]byte("foobar"), uint16(maxPacketSize), gomock.Any()).DoAndReturn(
+		func([]byte, uint16, protocol.ECN) error { close(done3); return nil },
+	)
 
-	JustBeforeEach(func() {
-		Eventually(areConnsRunning).Should(BeFalse())
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
+	tc.conn.scheduleSending()
 
-		mconn = NewMockSendConn(mockCtrl)
-		mconn.EXPECT().capabilities().AnyTimes()
-		mconn.EXPECT().RemoteAddr().Return(&net.UDPAddr{}).AnyTimes()
-		mconn.EXPECT().LocalAddr().Return(&net.UDPAddr{}).AnyTimes()
-		mconn.EXPECT().capabilities().AnyTimes()
-		if tlsConf == nil {
-			tlsConf = &tls.Config{}
-		}
-		connRunner = NewMockConnRunner(mockCtrl)
-		var tr *logging.ConnectionTracer
-		tr, tracer = mocklogging.NewMockConnectionTracer(mockCtrl)
-		tracer.EXPECT().NegotiatedVersion(gomock.Any(), gomock.Any(), gomock.Any()).MaxTimes(1)
-		tracer.EXPECT().SentTransportParameters(gomock.Any())
-		tracer.EXPECT().UpdatedKeyFromTLS(gomock.Any(), gomock.Any()).AnyTimes()
-		tracer.EXPECT().UpdatedCongestionState(gomock.Any())
-		conn = newClientConnection(
-			context.Background(),
-			mconn,
-			connRunner,
-			destConnID,
-			protocol.ParseConnectionID([]byte{1, 2, 3, 4, 5, 6, 7, 8}),
-			&protocol.DefaultConnectionIDGenerator{},
-			quicConf,
-			tlsConf,
-			42, // initial packet number
-			false,
-			false,
-			tr,
-			utils.DefaultLogger,
-			protocol.Version1,
-		).(*connection)
-		packer = NewMockPacker(mockCtrl)
-		conn.packer = packer
-		cryptoSetup = mocks.NewMockCryptoSetup(mockCtrl)
-		conn.cryptoStreamHandler = cryptoSetup
-		conn.sentFirstPacket = true
-	})
+	select {
+	case <-done3:
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
 
-	It("changes the connection ID when receiving the first packet from the server", func() {
-		unpacker := NewMockUnpacker(mockCtrl)
-		unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).DoAndReturn(func(hdr *wire.Header, data []byte) (*unpackedPacket, error) {
-			return &unpackedPacket{
-				encryptionLevel: protocol.Encryption1RTT,
-				hdr:             &wire.ExtendedHeader{Header: *hdr},
-				data:            []byte{0}, // one PADDING frame
-			}, nil
-		})
-		conn.unpacker = unpacker
-		done := make(chan struct{})
-		packer.EXPECT().PackCoalescedPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Do(func(bool, protocol.ByteCount, time.Time, protocol.Version) (*coalescedPacket, error) {
-			close(done)
-			return nil, nil
-		})
-		newConnID := protocol.ParseConnectionID([]byte{1, 3, 3, 7, 1, 3, 3, 7})
-		p := getPacket(&wire.ExtendedHeader{
-			Header: wire.Header{
-				Type:             protocol.PacketTypeHandshake,
-				SrcConnectionID:  newConnID,
-				DestConnectionID: srcConnID,
-				Length:           2 + 6,
-				Version:          conn.version,
-			},
-			PacketNumberLen: protocol.PacketNumberLen2,
-		}, []byte("foobar"))
-		tracer.EXPECT().ReceivedLongHeaderPacket(gomock.Any(), p.Size(), gomock.Any(), []logging.Frame{})
-		Expect(conn.handlePacketImpl(p)).To(BeTrue())
-		go func() {
-			defer GinkgoRecover()
-			cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-			cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-			conn.run()
-		}()
-		Eventually(done).Should(BeClosed())
-		// make sure the go routine returns
-		packer.EXPECT().PackApplicationClose(gomock.Any(), gomock.Any(), conn.version).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil)
-		cryptoSetup.EXPECT().Close()
-		connRunner.EXPECT().ReplaceWithClosed([]protocol.ConnectionID{srcConnID}, gomock.Any())
-		mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any()).MaxTimes(1)
-		tracer.EXPECT().ClosedConnection(gomock.Any())
-		tracer.EXPECT().Close()
-		conn.CloseWithError(0, "")
-		Eventually(conn.Context().Done()).Should(BeClosed())
-		time.Sleep(200 * time.Millisecond)
-	})
+	// test teardown
+	tc.connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
+	tc.conn.destroy(nil)
+	select {
+	case err := <-errChan:
+		require.NoError(t, err)
+	case <-time.After(3 * time.Second):
+		t.Fatal("timeout")
+	}
+}
 
-	It("continues accepting Long Header packets after using a new connection ID", func() {
-		unpacker := NewMockUnpacker(mockCtrl)
-		conn.unpacker = unpacker
-		connRunner.EXPECT().AddResetToken(gomock.Any(), gomock.Any())
-		conn.connIDManager.SetHandshakeComplete()
-		conn.handleNewConnectionIDFrame(&wire.NewConnectionIDFrame{
-			SequenceNumber: 1,
-			ConnectionID:   protocol.ParseConnectionID([]byte{1, 2, 3, 4, 5}),
-		})
-		Expect(conn.connIDManager.Get()).To(Equal(protocol.ParseConnectionID([]byte{1, 2, 3, 4, 5})))
-		// now receive a packet with the original source connection ID
-		unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).DoAndReturn(func(hdr *wire.Header, _ []byte) (*unpackedPacket, error) {
-			return &unpackedPacket{
-				hdr:             &wire.ExtendedHeader{Header: *hdr},
-				data:            []byte{0},
-				encryptionLevel: protocol.EncryptionHandshake,
-			}, nil
-		})
-		hdr := &wire.Header{
-			Type:             protocol.PacketTypeHandshake,
-			DestConnectionID: srcConnID,
-			SrcConnectionID:  destConnID,
-		}
-		tracer.EXPECT().ReceivedLongHeaderPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-		Expect(conn.handleLongHeaderPacket(receivedPacket{buffer: getPacketBuffer()}, hdr)).To(BeTrue())
+func TestConnectionPTOProbePackets(t *testing.T) {
+	t.Run("Initial", func(t *testing.T) {
+		testConnectionPTOProbePackets(t, protocol.EncryptionInitial)
 	})
-
-	It("handles HANDSHAKE_DONE frames", func() {
-		conn.peerParams = &wire.TransportParameters{}
-		sph := mockackhandler.NewMockSentPacketHandler(mockCtrl)
-		conn.sentPacketHandler = sph
-		tracer.EXPECT().DroppedEncryptionLevel(protocol.EncryptionHandshake)
-		sph.EXPECT().DropPackets(protocol.EncryptionHandshake)
-		sph.EXPECT().SetHandshakeConfirmed()
-		cryptoSetup.EXPECT().SetHandshakeConfirmed()
-		Expect(conn.handleHandshakeDoneFrame()).To(Succeed())
+	t.Run("Handshake", func(t *testing.T) {
+		testConnectionPTOProbePackets(t, protocol.EncryptionHandshake)
 	})
-
-	It("interprets an ACK for 1-RTT packets as confirmation of the handshake", func() {
-		conn.peerParams = &wire.TransportParameters{}
-		sph := mockackhandler.NewMockSentPacketHandler(mockCtrl)
-		conn.sentPacketHandler = sph
-		ack := &wire.AckFrame{AckRanges: []wire.AckRange{{Smallest: 1, Largest: 3}}}
-		tracer.EXPECT().DroppedEncryptionLevel(protocol.EncryptionHandshake)
-		sph.EXPECT().ReceivedAck(ack, protocol.Encryption1RTT, gomock.Any()).Return(true, nil)
-		sph.EXPECT().DropPackets(protocol.EncryptionHandshake)
-		sph.EXPECT().SetHandshakeConfirmed()
-		cryptoSetup.EXPECT().SetLargest1RTTAcked(protocol.PacketNumber(3))
-		cryptoSetup.EXPECT().SetHandshakeConfirmed()
-		Expect(conn.handleAckFrame(ack, protocol.Encryption1RTT)).To(Succeed())
+	t.Run("1-RTT", func(t *testing.T) {
+		testConnectionPTOProbePackets(t, protocol.Encryption1RTT)
 	})
+}
 
-	It("doesn't send a CONNECTION_CLOSE when no packet was sent", func() {
-		conn.sentFirstPacket = false
-		tracer.EXPECT().ClosedConnection(gomock.Any())
-		tracer.EXPECT().Close()
-		running := make(chan struct{})
-		cryptoSetup.EXPECT().StartHandshake(gomock.Any()).Do(func(context.Context) error {
-			close(running)
-			conn.closeLocal(errors.New("early error"))
-			return nil
-		})
-		cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-		cryptoSetup.EXPECT().Close()
-		connRunner.EXPECT().Remove(gomock.Any())
-		go func() {
-			defer GinkgoRecover()
-			conn.run()
-		}()
-		Eventually(running).Should(BeClosed())
-		Eventually(areConnsRunning).Should(BeFalse())
-	})
+func testConnectionPTOProbePackets(t *testing.T, encLevel protocol.EncryptionLevel) {
+	mockCtrl := gomock.NewController(t)
+	sph := mockackhandler.NewMockSentPacketHandler(mockCtrl)
+	tc := newServerTestConnection(t,
+		mockCtrl,
+		nil,
+		false,
+		connectionOptSentPacketHandler(sph),
+	)
 
-	Context("handling tokens", func() {
-		var tokenStore TokenStore
+	var sendMode ackhandler.SendMode
+	switch encLevel {
+	case protocol.EncryptionInitial:
+		sendMode = ackhandler.SendPTOInitial
+	case protocol.EncryptionHandshake:
+		sendMode = ackhandler.SendPTOHandshake
+	case protocol.Encryption1RTT:
+		sendMode = ackhandler.SendPTOAppData
+	}
 
-		BeforeEach(func() {
-			tlsConf = &tls.Config{ServerName: "server"}
-			tokenStore = NewLRUTokenStore(10, 10)
-			quicConf.TokenStore = tokenStore
-		})
+	sph.EXPECT().GetLossDetectionTimeout().AnyTimes()
+	sph.EXPECT().TimeUntilSend().AnyTimes()
+	sph.EXPECT().SendMode(gomock.Any()).Return(sendMode)
+	sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendNone)
+	sph.EXPECT().ECNMode(gomock.Any())
+	sph.EXPECT().QueueProbePacket(encLevel).Return(false)
+	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
+
+	tc.packer.EXPECT().MaybePackProbePacket(encLevel, gomock.Any(), gomock.Any(), protocol.Version1).DoAndReturn(
+		func(encLevel protocol.EncryptionLevel, maxSize protocol.ByteCount, t time.Time, version protocol.Version) (*coalescedPacket, error) {
+			return &coalescedPacket{
+				buffer:         getPacketBuffer(),
+				shortHdrPacket: &shortHeaderPacket{PacketNumber: 1},
+			}, nil
+		},
+	)
+	done := make(chan struct{})
+	tc.sendConn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any()).Do(
+		func([]byte, uint16, protocol.ECN) error { close(done); return nil },
+	)
 
-		It("handles NEW_TOKEN frames", func() {
-			Expect(conn.handleNewTokenFrame(&wire.NewTokenFrame{Token: []byte("foobar")})).To(Succeed())
-			Expect(tokenStore.Pop("server")).To(Equal(&ClientToken{data: []byte("foobar")}))
-		})
-	})
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
+	tc.conn.scheduleSending()
 
-	Context("handling Version Negotiation", func() {
-		getVNP := func(versions ...protocol.Version) receivedPacket {
-			b := wire.ComposeVersionNegotiation(
-				protocol.ArbitraryLenConnectionID(srcConnID.Bytes()),
-				protocol.ArbitraryLenConnectionID(destConnID.Bytes()),
-				versions,
-			)
-			return receivedPacket{
-				rcvTime: time.Now(),
-				data:    b,
-				buffer:  getPacketBuffer(),
-			}
-		}
+	select {
+	case <-done:
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
 
-		It("closes and returns the right error", func() {
-			sph := mockackhandler.NewMockSentPacketHandler(mockCtrl)
-			conn.sentPacketHandler = sph
-			sph.EXPECT().ReceivedBytes(gomock.Any())
-			sph.EXPECT().PeekPacketNumber(protocol.EncryptionInitial).Return(protocol.PacketNumber(128), protocol.PacketNumberLen4)
-			conn.config.Versions = []protocol.Version{1234, 4321}
-			errChan := make(chan error, 1)
-			start := make(chan struct{})
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().DoAndReturn(func() handshake.Event {
-					<-start
-					return handshake.Event{Kind: handshake.EventNoEvent}
-				})
-				errChan <- conn.run()
-			}()
-			connRunner.EXPECT().Remove(srcConnID)
-			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)),
-				))
-			})
-			cryptoSetup.EXPECT().Close()
-			Expect(conn.handlePacketImpl(getVNP(4321, 1337))).To(BeFalse())
-			close(start)
-			var err error
-			Eventually(errChan).Should(Receive(&err))
-			Expect(err).To(HaveOccurred())
-			Expect(err).To(BeAssignableToTypeOf(&errCloseForRecreating{}))
-			recreateErr := err.(*errCloseForRecreating)
-			Expect(recreateErr.nextVersion).To(Equal(protocol.Version(4321)))
-			Expect(recreateErr.nextPacketNumber).To(Equal(protocol.PacketNumber(128)))
-		})
+	// test teardown
+	tc.connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
+	tc.conn.destroy(nil)
+	select {
+	case err := <-errChan:
+		require.NoError(t, err)
+	case <-time.After(3 * time.Second):
+		t.Fatal("timeout")
+	}
+}
 
-		It("closes when no matching version is found", func() {
-			errChan := make(chan error, 1)
-			packer.EXPECT().PackCoalescedPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).MaxTimes(1)
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
-				errChan <- conn.run()
-			}()
-			connRunner.EXPECT().Remove(srcConnID).MaxTimes(1)
-			gomock.InOrder(
-				tracer.EXPECT().ReceivedVersionNegotiationPacket(gomock.Any(), gomock.Any(), gomock.Any()),
-				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.Version(12345678)))
-				}),
-				tracer.EXPECT().Close(),
-			)
-			cryptoSetup.EXPECT().Close()
-			Expect(conn.handlePacketImpl(getVNP(12345678))).To(BeFalse())
-			var err error
-			Eventually(errChan).Should(Receive(&err))
-			Expect(err).To(HaveOccurred())
-			Expect(err).ToNot(BeAssignableToTypeOf(errCloseForRecreating{}))
-			Expect(err.Error()).To(ContainSubstring("no compatible QUIC version found"))
-		})
+func TestConnectionCongestionControl(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	sph := mockackhandler.NewMockSentPacketHandler(mockCtrl)
+	tc := newServerTestConnection(t,
+		mockCtrl,
+		nil,
+		false,
+		connectionOptHandshakeConfirmed(),
+		connectionOptSentPacketHandler(sph),
+	)
 
-		It("ignores Version Negotiation packets that offer the current version", func() {
-			p := getVNP(conn.version)
-			tracer.EXPECT().DroppedPacket(logging.PacketTypeVersionNegotiation, protocol.InvalidPacketNumber, p.Size(), logging.PacketDropUnexpectedVersion)
-			Expect(conn.handlePacketImpl(p)).To(BeFalse())
-		})
+	sph.EXPECT().TimeUntilSend().AnyTimes()
+	sph.EXPECT().GetLossDetectionTimeout().AnyTimes()
+	sph.EXPECT().ECNMode(true).AnyTimes()
+	sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).Times(2)
+	sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAck)
+	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(2)
+	// Since we're already sending out packets, we don't expect any calls to PackAckOnlyPacket
+	for i := 0; i < 2; i++ {
+		tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+			func(buffer *packetBuffer, count protocol.ByteCount, t time.Time, version protocol.Version) (shortHeaderPacket, error) {
+				buffer.Data = append(buffer.Data, []byte("foobar")...)
+				return shortHeaderPacket{PacketNumber: protocol.PacketNumber(i)}, nil
+			},
+		)
+	}
+	tc.sendConn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
+	done1 := make(chan struct{})
+	tc.sendConn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any()).Do(
+		func([]byte, uint16, protocol.ECN) error { close(done1); return nil },
+	)
 
-		It("ignores unparseable Version Negotiation packets", func() {
-			p := getVNP(conn.version)
-			p.data = p.data[:len(p.data)-2]
-			tracer.EXPECT().DroppedPacket(logging.PacketTypeVersionNegotiation, protocol.InvalidPacketNumber, p.Size(), logging.PacketDropHeaderParseError)
-			Expect(conn.handlePacketImpl(p)).To(BeFalse())
-		})
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
+	tc.conn.scheduleSending()
+	select {
+	case <-done1:
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+	require.True(t, mockCtrl.Satisfied())
+
+	// Now that we're congestion limited, we can only send an ack-only packet
+	done2 := make(chan struct{})
+	sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAck)
+	tc.packer.EXPECT().PackAckOnlyPacket(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+		func(protocol.ByteCount, time.Time, protocol.Version) (shortHeaderPacket, *packetBuffer, error) {
+			close(done2)
+			return shortHeaderPacket{}, nil, errNothingToPack
+		},
+	)
+	tc.conn.scheduleSending()
+	select {
+	case <-done2:
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+	require.True(t, mockCtrl.Satisfied())
+
+	// If the send mode is "none", we can't even send an ack-only packet
+	sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendNone)
+	tc.conn.scheduleSending()
+	time.Sleep(scaleDuration(10 * time.Millisecond)) // make sure there are no calls to the packer
+
+	// test teardown
+	tc.connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
+	tc.conn.destroy(nil)
+	select {
+	case err := <-errChan:
+		require.NoError(t, err)
+	case <-time.After(3 * time.Second):
+		t.Fatal("timeout")
+	}
+}
+
+func TestConnectionSendQueue(t *testing.T) {
+	t.Run("with GSO", func(t *testing.T) {
+		testConnectionSendQueue(t, true)
+	})
+	t.Run("without GSO", func(t *testing.T) {
+		testConnectionSendQueue(t, false)
 	})
+}
 
-	Context("handling Retry", func() {
-		origDestConnID := protocol.ParseConnectionID([]byte{8, 7, 6, 5, 4, 3, 2, 1})
+func testConnectionSendQueue(t *testing.T, enableGSO bool) {
+	mockCtrl := gomock.NewController(t)
+	sph := mockackhandler.NewMockSentPacketHandler(mockCtrl)
+	sender := NewMockSender(mockCtrl)
+	tc := newServerTestConnection(t,
+		mockCtrl,
+		nil,
+		enableGSO,
+		connectionOptSender(sender),
+		connectionOptHandshakeConfirmed(),
+		connectionOptSentPacketHandler(sph),
+	)
 
-		var retryHdr *wire.ExtendedHeader
+	sender.EXPECT().Run().MaxTimes(1)
+	sender.EXPECT().WouldBlock()
+	sender.EXPECT().WouldBlock().Return(true).Times(2)
+	available := make(chan struct{})
+	blocked := make(chan struct{})
+	sender.EXPECT().Available().DoAndReturn(
+		func() <-chan struct{} {
+			close(blocked)
+			return available
+		},
+	)
+	sph.EXPECT().GetLossDetectionTimeout().AnyTimes()
+	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
+	sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
+	sph.EXPECT().ECNMode(gomock.Any()).AnyTimes()
+	tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(
+		shortHeaderPacket{PacketNumber: protocol.PacketNumber(1)}, nil,
+	)
+	sender.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any())
 
-		JustBeforeEach(func() {
-			retryHdr = &wire.ExtendedHeader{
-				Header: wire.Header{
-					Type:             protocol.PacketTypeRetry,
-					SrcConnectionID:  protocol.ParseConnectionID([]byte{0xde, 0xad, 0xbe, 0xef}),
-					DestConnectionID: protocol.ParseConnectionID([]byte{1, 2, 3, 4, 5, 6, 7, 8}),
-					Token:            []byte("foobar"),
-					Version:          conn.version,
-				},
-			}
-		})
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
+	tc.conn.scheduleSending()
 
-		getRetryTag := func(hdr *wire.ExtendedHeader) []byte {
-			b, err := hdr.Append(nil, conn.version)
-			Expect(err).ToNot(HaveOccurred())
-			return handshake.GetRetryIntegrityTag(b, origDestConnID, hdr.Version)[:]
-		}
+	select {
+	case <-blocked:
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+	require.True(t, mockCtrl.Satisfied())
+
+	// now make room in the send queue
+	sender.EXPECT().WouldBlock().AnyTimes()
+	unblocked := make(chan struct{})
+	tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+		func(*packetBuffer, protocol.ByteCount, time.Time, protocol.Version) (shortHeaderPacket, error) {
+			close(unblocked)
+			return shortHeaderPacket{}, errNothingToPack
+		},
+	)
+	available <- struct{}{}
+	select {
+	case <-unblocked:
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
 
-		It("handles Retry packets", func() {
-			now := time.Now()
-			sph := mockackhandler.NewMockSentPacketHandler(mockCtrl)
-			conn.sentPacketHandler = sph
-			sph.EXPECT().ResetForRetry(now)
-			sph.EXPECT().ReceivedBytes(gomock.Any())
-			cryptoSetup.EXPECT().ChangeConnectionID(protocol.ParseConnectionID([]byte{0xde, 0xad, 0xbe, 0xef}))
-			packer.EXPECT().SetToken([]byte("foobar"))
-			tracer.EXPECT().ReceivedRetry(gomock.Any()).Do(func(hdr *wire.Header) {
-				Expect(hdr.DestConnectionID).To(Equal(retryHdr.DestConnectionID))
-				Expect(hdr.SrcConnectionID).To(Equal(retryHdr.SrcConnectionID))
-				Expect(hdr.Token).To(Equal(retryHdr.Token))
-			})
-			p := getPacket(retryHdr, getRetryTag(retryHdr))
-			p.rcvTime = now
-			Expect(conn.handlePacketImpl(p)).To(BeTrue())
-		})
+	// test teardown
+	sender.EXPECT().Close()
+	tc.connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
+	tc.conn.destroy(nil)
+	select {
+	case err := <-errChan:
+		require.NoError(t, err)
+	case <-time.After(3 * time.Second):
+		t.Fatal("timeout")
+	}
+}
 
-		It("ignores Retry packets after receiving a regular packet", func() {
-			conn.receivedFirstPacket = true
-			p := getPacket(retryHdr, getRetryTag(retryHdr))
-			tracer.EXPECT().DroppedPacket(logging.PacketTypeRetry, protocol.InvalidPacketNumber, p.Size(), logging.PacketDropUnexpectedPacket)
-			Expect(conn.handlePacketImpl(p)).To(BeFalse())
-		})
+func getVersionNegotiationPacket(src, dest protocol.ConnectionID, versions []protocol.Version) receivedPacket {
+	b := wire.ComposeVersionNegotiation(
+		protocol.ArbitraryLenConnectionID(src.Bytes()),
+		protocol.ArbitraryLenConnectionID(dest.Bytes()),
+		versions,
+	)
+	return receivedPacket{
+		rcvTime: time.Now(),
+		data:    b,
+		buffer:  getPacketBuffer(),
+	}
+}
 
-		It("ignores Retry packets if the server didn't change the connection ID", func() {
-			retryHdr.SrcConnectionID = destConnID
-			p := getPacket(retryHdr, getRetryTag(retryHdr))
-			tracer.EXPECT().DroppedPacket(logging.PacketTypeRetry, protocol.InvalidPacketNumber, p.Size(), logging.PacketDropUnexpectedPacket)
-			Expect(conn.handlePacketImpl(p)).To(BeFalse())
-		})
+func TestConnectionVersionNegotiation(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+	tc := newClientTestConnection(t,
+		mockCtrl,
+		nil,
+		false,
+		connectionOptTracer(tr),
+	)
 
-		It("ignores Retry packets with the a wrong Integrity tag", func() {
-			tag := getRetryTag(retryHdr)
-			tag[0]++
-			p := getPacket(retryHdr, tag)
-			tracer.EXPECT().DroppedPacket(logging.PacketTypeRetry, protocol.InvalidPacketNumber, p.Size(), logging.PacketDropPayloadDecryptError)
-			Expect(conn.handlePacketImpl(p)).To(BeFalse())
-		})
-	})
+	tc.packer.EXPECT().PackCoalescedPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
+	var tracerVersions []logging.Version
+	gomock.InOrder(
+		tracer.EXPECT().ReceivedVersionNegotiationPacket(gomock.Any(), gomock.Any(), gomock.Any()).Do(func(_, _ protocol.ArbitraryLenConnectionID, versions []logging.Version) {
+			tracerVersions = versions
+		}),
+		tracer.EXPECT().NegotiatedVersion(protocol.Version2, gomock.Any(), gomock.Any()),
+		tc.connRunner.EXPECT().Remove(gomock.Any()),
+	)
 
-	Context("transport parameters", func() {
-		var (
-			closed     bool
-			errChan    chan error
-			paramsChan chan *wire.TransportParameters
-		)
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
+	tc.conn.handlePacket(getVersionNegotiationPacket(
+		tc.destConnID,
+		tc.srcConnID,
+		[]protocol.Version{1234, protocol.Version2},
+	))
+
+	select {
+	case err := <-errChan:
+		var rerr *errCloseForRecreating
+		require.ErrorAs(t, err, &rerr)
+		require.Equal(t, rerr.nextVersion, protocol.Version2)
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+	require.Contains(t, tracerVersions, protocol.Version(1234))
+	require.Contains(t, tracerVersions, protocol.Version2)
+}
 
-		JustBeforeEach(func() {
-			errChan = make(chan error, 1)
-			paramsChan = make(chan *wire.TransportParameters, 1)
-			closed = false
-			packer.EXPECT().PackCoalescedPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
-			go func() {
-				defer GinkgoRecover()
-				cryptoSetup.EXPECT().StartHandshake(gomock.Any()).MaxTimes(1)
-				// This is not 100% what would happen in reality.
-				// The run loop calls NextEvent once when it starts up (to send out the ClientHello),
-				// and then again every time a CRYPTO frame is handled.
-				// Injecting a CRYPTO frame is not straightforward though,
-				// so we inject the transport parameters on the first call to NextEvent.
-				params := <-paramsChan
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{
-					Kind:                handshake.EventReceivedTransportParameters,
-					TransportParameters: params,
-				})
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventHandshakeComplete}).MaxTimes(1)
-				cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent}).MaxTimes(1).Do(func() {
-					defer GinkgoRecover()
-					Expect(conn.handleHandshakeComplete()).To(Succeed())
-				})
-				tracer.EXPECT().ChoseALPN(gomock.Any()).MaxTimes(1)
-				cryptoSetup.EXPECT().ConnectionState().MaxTimes(1)
-				errChan <- conn.run()
-				close(errChan)
-			}()
-		})
+func TestConnectionVersionNegotiationNoMatch(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+	tc := newClientTestConnection(t,
+		mockCtrl,
+		&Config{Versions: []protocol.Version{protocol.Version1}},
+		false,
+		connectionOptTracer(tr),
+	)
 
-		expectClose := func(applicationClose, errored bool) {
-			if !closed && !errored {
-				connRunner.EXPECT().ReplaceWithClosed(gomock.Any(), gomock.Any())
-				if applicationClose {
-					packer.EXPECT().PackApplicationClose(gomock.Any(), gomock.Any(), conn.version).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil).MaxTimes(1)
-				} else {
-					packer.EXPECT().PackConnectionClose(gomock.Any(), gomock.Any(), conn.version).Return(&coalescedPacket{buffer: getPacketBuffer()}, nil).MaxTimes(1)
-				}
-				cryptoSetup.EXPECT().Close()
-				mconn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any())
-				gomock.InOrder(
-					tracer.EXPECT().ClosedConnection(gomock.Any()),
-					tracer.EXPECT().Close(),
-				)
-			}
-			closed = true
-		}
+	tc.packer.EXPECT().PackCoalescedPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
+	var tracerVersions []logging.Version
+	tracer.EXPECT().ReceivedVersionNegotiationPacket(gomock.Any(), gomock.Any(), gomock.Any()).Do(
+		func(_, _ protocol.ArbitraryLenConnectionID, versions []logging.Version) { tracerVersions = versions },
+	)
+	tracer.EXPECT().ClosedConnection(gomock.Any())
+	tracer.EXPECT().Close()
+	tc.connRunner.EXPECT().Remove(gomock.Any())
+
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
+	tc.conn.handlePacket(getVersionNegotiationPacket(
+		tc.destConnID,
+		tc.srcConnID,
+		[]protocol.Version{protocol.Version2},
+	))
+
+	select {
+	case err := <-errChan:
+		var verr *VersionNegotiationError
+		require.ErrorAs(t, err, &verr)
+		require.Contains(t, verr.Theirs, protocol.Version2)
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+	require.Contains(t, tracerVersions, protocol.Version2)
+}
 
-		AfterEach(func() {
-			conn.CloseWithError(0, "")
-			Eventually(conn.Context().Done()).Should(BeClosed())
-			Eventually(errChan).Should(BeClosed())
-		})
+func TestConnectionVersionNegotiationInvalidPackets(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+	tc := newClientTestConnection(t,
+		mockCtrl,
+		nil,
+		false,
+		connectionOptTracer(tr),
+	)
 
-		It("uses the preferred_address connection ID", func() {
-			params := &wire.TransportParameters{
-				OriginalDestinationConnectionID: destConnID,
-				InitialSourceConnectionID:       destConnID,
-				PreferredAddress: &wire.PreferredAddress{
-					IPv4:                netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), 42),
-					IPv6:                netip.AddrPortFrom(netip.AddrFrom16([16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}), 13),
-					ConnectionID:        protocol.ParseConnectionID([]byte{1, 2, 3, 4}),
-					StatelessResetToken: protocol.StatelessResetToken{16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1},
-				},
-			}
-			packer.EXPECT().PackCoalescedPacket(false, gomock.Any(), gomock.Any(), conn.version).MaxTimes(1)
-			processed := make(chan struct{})
-			tracer.EXPECT().ReceivedTransportParameters(params).Do(func(*wire.TransportParameters) { close(processed) })
-			paramsChan <- params
-			Eventually(processed).Should(BeClosed())
-			// make sure the connection ID is not retired
-			cf, _ := conn.framer.AppendControlFrames(nil, protocol.MaxByteCount, time.Now(), protocol.Version1)
-			Expect(cf).To(BeEmpty())
-			connRunner.EXPECT().AddResetToken(protocol.StatelessResetToken{16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1}, conn)
-			Expect(conn.connIDManager.Get()).To(Equal(protocol.ParseConnectionID([]byte{1, 2, 3, 4})))
-			// shut down
-			connRunner.EXPECT().RemoveResetToken(protocol.StatelessResetToken{16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1})
-			expectClose(true, false)
-		})
+	// offers the current version
+	tracer.EXPECT().DroppedPacket(logging.PacketTypeVersionNegotiation, gomock.Any(), gomock.Any(), logging.PacketDropUnexpectedVersion)
+	vnp := getVersionNegotiationPacket(
+		tc.destConnID,
+		tc.srcConnID,
+		[]protocol.Version{1234, protocol.Version1},
+	)
+	require.False(t, tc.conn.handlePacketImpl(vnp))
+	require.True(t, mockCtrl.Satisfied())
 
-		It("uses the minimum of the peers' idle timeouts", func() {
-			conn.config.MaxIdleTimeout = 19 * time.Second
-			params := &wire.TransportParameters{
-				OriginalDestinationConnectionID: destConnID,
-				InitialSourceConnectionID:       destConnID,
-				MaxIdleTimeout:                  18 * time.Second,
-			}
-			processed := make(chan struct{})
-			tracer.EXPECT().ReceivedTransportParameters(params).Do(func(*wire.TransportParameters) { close(processed) })
-			paramsChan <- params
-			Eventually(processed).Should(BeClosed())
-			// close first
-			expectClose(true, false)
-			conn.CloseWithError(0, "")
-			// then check. Avoids race condition when accessing idleTimeout
-			Expect(conn.idleTimeout).To(Equal(18 * time.Second))
-		})
+	// unparseable, since it's missing 2 bytes
+	tracer.EXPECT().DroppedPacket(logging.PacketTypeVersionNegotiation, gomock.Any(), gomock.Any(), logging.PacketDropHeaderParseError)
+	vnp.data = vnp.data[:len(vnp.data)-2]
+	require.False(t, tc.conn.handlePacketImpl(vnp))
+}
 
-		It("errors if the transport parameters contain a wrong initial_source_connection_id", func() {
-			conn.handshakeDestConnID = protocol.ParseConnectionID([]byte{0xde, 0xad, 0xbe, 0xef})
-			params := &wire.TransportParameters{
-				OriginalDestinationConnectionID: destConnID,
-				InitialSourceConnectionID:       protocol.ParseConnectionID([]byte{0xde, 0xca, 0xfb, 0xad}),
-				StatelessResetToken:             &protocol.StatelessResetToken{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16},
-			}
-			expectClose(false, true)
-			processed := make(chan struct{})
-			tracer.EXPECT().ReceivedTransportParameters(params).Do(func(*wire.TransportParameters) { close(processed) })
-			paramsChan <- params
-			Eventually(processed).Should(BeClosed())
-			Eventually(errChan).Should(Receive(MatchError(&qerr.TransportError{
-				ErrorCode:    qerr.TransportParameterError,
-				ErrorMessage: "expected initial_source_connection_id to equal deadbeef, is decafbad",
-			})))
-		})
+func getRetryPacket(t *testing.T, src, dest, origDest protocol.ConnectionID, token []byte) receivedPacket {
+	hdr := wire.Header{
+		Type:             protocol.PacketTypeRetry,
+		SrcConnectionID:  src,
+		DestConnectionID: dest,
+		Token:            token,
+		Version:          protocol.Version1,
+	}
+	b, err := (&wire.ExtendedHeader{Header: hdr}).Append(nil, protocol.Version1)
+	require.NoError(t, err)
+	tag := handshake.GetRetryIntegrityTag(b, origDest, protocol.Version1)
+	b = append(b, tag[:]...)
+	return receivedPacket{
+		rcvTime: time.Now(),
+		data:    b,
+		buffer:  getPacketBuffer(),
+	}
+}
 
-		It("errors if the transport parameters don't contain the retry_source_connection_id, if a Retry was performed", func() {
-			rcid := protocol.ParseConnectionID([]byte{0xde, 0xad, 0xbe, 0xef})
-			conn.retrySrcConnID = &rcid
-			params := &wire.TransportParameters{
-				OriginalDestinationConnectionID: destConnID,
-				InitialSourceConnectionID:       destConnID,
-				StatelessResetToken:             &protocol.StatelessResetToken{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16},
-			}
-			expectClose(false, true)
-			processed := make(chan struct{})
-			tracer.EXPECT().ReceivedTransportParameters(params).Do(func(*wire.TransportParameters) { close(processed) })
-			paramsChan <- params
-			Eventually(processed).Should(BeClosed())
-			Eventually(errChan).Should(Receive(MatchError(&qerr.TransportError{
-				ErrorCode:    qerr.TransportParameterError,
-				ErrorMessage: "missing retry_source_connection_id",
-			})))
-		})
+func TestConnectionRetryDrops(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+	unpacker := NewMockUnpacker(mockCtrl)
+	tc := newClientTestConnection(t,
+		mockCtrl,
+		nil,
+		false,
+		connectionOptTracer(tr),
+		connectionOptUnpacker(unpacker),
+	)
 
-		It("errors if the transport parameters contain the wrong retry_source_connection_id, if a Retry was performed", func() {
-			rcid := protocol.ParseConnectionID([]byte{0xde, 0xad, 0xbe, 0xef})
-			rcid2 := protocol.ParseConnectionID([]byte{0xde, 0xad, 0xc0, 0xde})
-			conn.retrySrcConnID = &rcid
-			params := &wire.TransportParameters{
-				OriginalDestinationConnectionID: destConnID,
-				InitialSourceConnectionID:       destConnID,
-				RetrySourceConnectionID:         &rcid2,
-				StatelessResetToken:             &protocol.StatelessResetToken{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16},
-			}
-			expectClose(false, true)
-			processed := make(chan struct{})
-			tracer.EXPECT().ReceivedTransportParameters(params).Do(func(*wire.TransportParameters) { close(processed) })
-			paramsChan <- params
-			Eventually(processed).Should(BeClosed())
-			Eventually(errChan).Should(Receive(MatchError(&qerr.TransportError{
-				ErrorCode:    qerr.TransportParameterError,
-				ErrorMessage: "expected retry_source_connection_id to equal deadbeef, is deadc0de",
-			})))
-		})
+	newConnID := protocol.ParseConnectionID([]byte{0xde, 0xad, 0xbe, 0xef})
 
-		It("errors if the transport parameters contain the retry_source_connection_id, if no Retry was performed", func() {
-			rcid := protocol.ParseConnectionID([]byte{0xde, 0xad, 0xc0, 0xde})
-			params := &wire.TransportParameters{
-				OriginalDestinationConnectionID: destConnID,
-				InitialSourceConnectionID:       destConnID,
-				RetrySourceConnectionID:         &rcid,
-				StatelessResetToken:             &protocol.StatelessResetToken{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16},
-			}
-			expectClose(false, true)
-			processed := make(chan struct{})
-			tracer.EXPECT().ReceivedTransportParameters(params).Do(func(*wire.TransportParameters) { close(processed) })
-			paramsChan <- params
-			Eventually(processed).Should(BeClosed())
-			Eventually(errChan).Should(Receive(MatchError(&qerr.TransportError{
-				ErrorCode:    qerr.TransportParameterError,
-				ErrorMessage: "received retry_source_connection_id, although no Retry was performed",
-			})))
-		})
+	// invalid integrity tag
+	tracer.EXPECT().DroppedPacket(logging.PacketTypeRetry, gomock.Any(), gomock.Any(), logging.PacketDropPayloadDecryptError)
+	retry := getRetryPacket(t, newConnID, tc.srcConnID, tc.destConnID, []byte("foobar"))
+	retry.data[len(retry.data)-1]++
+	require.False(t, tc.conn.handlePacketImpl(retry))
+	require.True(t, mockCtrl.Satisfied())
 
-		It("errors if the transport parameters contain a wrong original_destination_connection_id", func() {
-			conn.origDestConnID = protocol.ParseConnectionID([]byte{0xde, 0xad, 0xbe, 0xef})
-			params := &wire.TransportParameters{
-				OriginalDestinationConnectionID: protocol.ParseConnectionID([]byte{0xde, 0xca, 0xfb, 0xad}),
-				InitialSourceConnectionID:       conn.handshakeDestConnID,
-				StatelessResetToken:             &protocol.StatelessResetToken{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16},
-			}
-			expectClose(false, true)
-			processed := make(chan struct{})
-			tracer.EXPECT().ReceivedTransportParameters(params).Do(func(*wire.TransportParameters) { close(processed) })
-			paramsChan <- params
-			Eventually(processed).Should(BeClosed())
-			Eventually(errChan).Should(Receive(MatchError(&qerr.TransportError{
-				ErrorCode:    qerr.TransportParameterError,
-				ErrorMessage: "expected original_destination_connection_id to equal deadbeef, is decafbad",
-			})))
-		})
+	// receive a retry that doesn't change the connection ID
+	tracer.EXPECT().DroppedPacket(logging.PacketTypeRetry, gomock.Any(), gomock.Any(), logging.PacketDropUnexpectedPacket)
+	retry = getRetryPacket(t, tc.destConnID, tc.srcConnID, tc.destConnID, []byte("foobar"))
+	require.False(t, tc.conn.handlePacketImpl(retry))
+}
 
-		It("errors if the transport parameters contain reduced limits after knowing 0-RTT data is accepted by the server", func() {
-			conn.perspective = protocol.PerspectiveClient
-			conn.peerParams = &wire.TransportParameters{
-				ActiveConnectionIDLimit:        3,
-				InitialMaxData:                 0x5000,
-				InitialMaxStreamDataBidiLocal:  0x5000,
-				InitialMaxStreamDataBidiRemote: 1000,
-				InitialMaxStreamDataUni:        1000,
-				MaxBidiStreamNum:               500,
-				MaxUniStreamNum:                500,
-			}
-			params := &wire.TransportParameters{
-				OriginalDestinationConnectionID: destConnID,
-				InitialSourceConnectionID:       destConnID,
-				ActiveConnectionIDLimit:         3,
-				InitialMaxData:                  0x5000,
-				InitialMaxStreamDataBidiLocal:   0x5000,
-				InitialMaxStreamDataBidiRemote:  1000,
-				InitialMaxStreamDataUni:         1000,
-				MaxBidiStreamNum:                300,
-				MaxUniStreamNum:                 300,
-			}
-			expectClose(false, true)
-			processed := make(chan struct{})
-			tracer.EXPECT().ReceivedTransportParameters(params).Do(func(*wire.TransportParameters) { close(processed) })
-			cryptoSetup.EXPECT().ConnectionState().Return(handshake.ConnectionState{Used0RTT: true})
-			paramsChan <- params
-			Eventually(processed).Should(BeClosed())
-			Eventually(errChan).Should(Receive(MatchError(&qerr.TransportError{
-				ErrorCode:    qerr.ProtocolViolation,
-				ErrorMessage: "server sent reduced limits after accepting 0-RTT data",
-			})))
-		})
+func TestConnectionRetryAfterReceivedPacket(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+	unpacker := NewMockUnpacker(mockCtrl)
+	tc := newClientTestConnection(t,
+		mockCtrl,
+		nil,
+		false,
+		connectionOptTracer(tr),
+		connectionOptUnpacker(unpacker),
+	)
+
+	// receive a regular packet
+	tracer.EXPECT().NegotiatedVersion(gomock.Any(), gomock.Any(), gomock.Any())
+	tracer.EXPECT().ReceivedLongHeaderPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
+	regular := getPacketWithPacketType(t, tc.srcConnID, protocol.PacketTypeInitial, 200)
+	unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).Return(
+		&unpackedPacket{
+			hdr:             &wire.ExtendedHeader{Header: wire.Header{Type: protocol.PacketTypeInitial}},
+			encryptionLevel: protocol.EncryptionInitial,
+		}, nil,
+	)
+	require.True(t, tc.conn.handlePacketImpl(receivedPacket{
+		data:    regular,
+		buffer:  getPacketBuffer(),
+		rcvTime: time.Now(),
+	}))
+
+	// receive a retry
+	retry := getRetryPacket(t, tc.destConnID, tc.srcConnID, tc.destConnID, []byte("foobar"))
+	tracer.EXPECT().DroppedPacket(logging.PacketTypeRetry, gomock.Any(), gomock.Any(), logging.PacketDropUnexpectedPacket)
+	require.False(t, tc.conn.handlePacketImpl(retry))
+}
+
+func TestConnectionConnectionIDChanges(t *testing.T) {
+	t.Run("with retry", func(t *testing.T) {
+		testConnectionConnectionIDChanges(t, true)
+	})
+	t.Run("without retry", func(t *testing.T) {
+		testConnectionConnectionIDChanges(t, false)
 	})
+}
 
-	Context("handling potentially injected packets", func() {
-		var unpacker *MockUnpacker
+func testConnectionConnectionIDChanges(t *testing.T, sendRetry bool) {
+	makeInitialPacket := func(t *testing.T, hdr *wire.ExtendedHeader) []byte {
+		t.Helper()
+		data, err := hdr.Append(nil, protocol.Version1)
+		require.NoError(t, err)
+		data = append(data, make([]byte, hdr.Length-protocol.ByteCount(hdr.PacketNumberLen))...)
+		return data
+	}
 
-		getPacket := func(extHdr *wire.ExtendedHeader, data []byte) receivedPacket {
-			b, err := extHdr.Append(nil, conn.version)
-			Expect(err).ToNot(HaveOccurred())
-			return receivedPacket{
-				data:   append(b, data...),
-				buffer: getPacketBuffer(),
-			}
-		}
+	mockCtrl := gomock.NewController(t)
+	tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+	unpacker := NewMockUnpacker(mockCtrl)
+	tc := newClientTestConnection(t,
+		mockCtrl,
+		nil,
+		false,
+		connectionOptTracer(tr),
+		connectionOptUnpacker(unpacker),
+	)
 
-		// Convert an already packed raw packet into a receivedPacket
-		wrapPacket := func(packet []byte) receivedPacket {
-			return receivedPacket{
-				data:   packet,
-				buffer: getPacketBuffer(),
-			}
+	dstConnID := tc.destConnID
+	b := make([]byte, 3*10)
+	rand.Read(b)
+	newConnID := protocol.ParseConnectionID(b[:11])
+	newConnID2 := protocol.ParseConnectionID(b[11:20])
+
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
+
+	tracer.EXPECT().NegotiatedVersion(gomock.Any(), gomock.Any(), gomock.Any())
+	tc.packer.EXPECT().PackCoalescedPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
+
+	require.Equal(t, dstConnID, tc.conn.connIDManager.Get())
+
+	var retryConnID protocol.ConnectionID
+	if sendRetry {
+		retryConnID = protocol.ParseConnectionID(b[20:30])
+		hdrChan := make(chan *wire.Header)
+		tracer.EXPECT().ReceivedRetry(gomock.Any()).Do(func(hdr *wire.Header) { hdrChan <- hdr })
+		tc.packer.EXPECT().SetToken([]byte("foobar"))
+
+		tc.conn.handlePacket(getRetryPacket(t, retryConnID, tc.srcConnID, tc.destConnID, []byte("foobar")))
+		select {
+		case hdr := <-hdrChan:
+			assert.Equal(t, retryConnID, hdr.SrcConnectionID)
+			assert.Equal(t, []byte("foobar"), hdr.Token)
+			require.Equal(t, retryConnID, tc.conn.connIDManager.Get())
+		case <-time.After(time.Second):
+			t.Fatal("timeout")
 		}
+	}
 
-		// Illustrates that attacker may inject an Initial packet with a different
-		// source connection ID, causing endpoint to ignore a subsequent real Initial packets.
-		It("ignores Initial packets with a different source connection ID", func() {
-			// Modified from test "ignores packets with a different source connection ID"
-			unpacker = NewMockUnpacker(mockCtrl)
-			conn.unpacker = unpacker
-
-			hdr1 := &wire.ExtendedHeader{
-				Header: wire.Header{
-					Type:             protocol.PacketTypeInitial,
-					DestConnectionID: destConnID,
-					SrcConnectionID:  srcConnID,
-					Length:           1,
-					Version:          conn.version,
-				},
-				PacketNumberLen: protocol.PacketNumberLen1,
-				PacketNumber:    1,
-			}
-			hdr2 := &wire.ExtendedHeader{
-				Header: wire.Header{
-					Type:             protocol.PacketTypeInitial,
-					DestConnectionID: destConnID,
-					SrcConnectionID:  protocol.ParseConnectionID([]byte{0xde, 0xad, 0xbe, 0xef}),
-					Length:           1,
-					Version:          conn.version,
-				},
-				PacketNumberLen: protocol.PacketNumberLen1,
-				PacketNumber:    2,
-			}
-			Expect(hdr2.SrcConnectionID).ToNot(Equal(srcConnID))
-			// Send one packet, which might change the connection ID.
-			// only EXPECT one call to the unpacker
-			unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).Return(&unpackedPacket{
+	// Send the first packet. The server changes the connection ID to newConnID.
+	hdr1 := wire.ExtendedHeader{
+		Header: wire.Header{
+			SrcConnectionID:  newConnID,
+			DestConnectionID: tc.srcConnID,
+			Type:             protocol.PacketTypeInitial,
+			Length:           200,
+			Version:          protocol.Version1,
+		},
+		PacketNumber:    1,
+		PacketNumberLen: protocol.PacketNumberLen2,
+	}
+	hdr2 := hdr1
+	hdr2.SrcConnectionID = newConnID2
+
+	receivedFirst := make(chan struct{})
+	gomock.InOrder(
+		unpacker.EXPECT().UnpackLongHeader(gomock.Any(), gomock.Any()).Return(
+			&unpackedPacket{
+				hdr:             &hdr1,
 				encryptionLevel: protocol.EncryptionInitial,
-				hdr:             hdr1,
-				data:            []byte{0}, // one PADDING frame
-			}, nil)
-			tracer.EXPECT().ReceivedLongHeaderPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-			Expect(conn.handlePacketImpl(getPacket(hdr1, nil))).To(BeTrue())
-			// The next packet has to be ignored, since the source connection ID doesn't match.
-			tracer.EXPECT().DroppedPacket(gomock.Any(), protocol.InvalidPacketNumber, gomock.Any(), gomock.Any())
-			Expect(conn.handlePacketImpl(getPacket(hdr2, nil))).To(BeFalse())
-		})
+			}, nil,
+		),
+		tracer.EXPECT().ReceivedLongHeaderPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Do(
+			func(*wire.ExtendedHeader, protocol.ByteCount, protocol.ECN, []logging.Frame) { close(receivedFirst) },
+		),
+	)
 
-		It("ignores 0-RTT packets", func() {
-			p := getPacket(&wire.ExtendedHeader{
-				Header: wire.Header{
-					Type:             protocol.PacketType0RTT,
-					DestConnectionID: srcConnID,
-					Length:           2 + 6,
-					Version:          conn.version,
-				},
-				PacketNumber:    0x42,
-				PacketNumberLen: protocol.PacketNumberLen2,
-			}, []byte("foobar"))
-			tracer.EXPECT().DroppedPacket(logging.PacketType0RTT, protocol.InvalidPacketNumber, p.Size(), gomock.Any())
-			Expect(conn.handlePacketImpl(p)).To(BeFalse())
-		})
+	tc.conn.handlePacket(receivedPacket{data: makeInitialPacket(t, &hdr1), buffer: getPacketBuffer(), rcvTime: time.Now()})
 
-		// Illustrates that an injected Initial with an ACK frame for an unsent packet causes
-		// the connection to immediately break down
-		It("fails on Initial-level ACK for unsent packet", func() {
-			ack := &wire.AckFrame{AckRanges: []wire.AckRange{{Smallest: 2, Largest: 2}}}
-			initialPacket := testutils.ComposeInitialPacket(destConnID, srcConnID, destConnID, nil, []wire.Frame{ack}, protocol.PerspectiveServer, conn.version)
-			tracer.EXPECT().ReceivedLongHeaderPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-			Expect(conn.handlePacketImpl(wrapPacket(initialPacket))).To(BeFalse())
-		})
+	select {
+	case <-receivedFirst:
+		require.Equal(t, newConnID, tc.conn.connIDManager.Get())
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
 
-		// Illustrates that an injected Initial with a CONNECTION_CLOSE frame causes
-		// the connection to immediately break down
-		It("fails on Initial-level CONNECTION_CLOSE frame", func() {
-			connCloseFrame := &wire.ConnectionCloseFrame{
-				IsApplicationError: true,
-				ReasonPhrase:       "mitm attacker",
-			}
-			initialPacket := testutils.ComposeInitialPacket(destConnID, srcConnID, destConnID, nil, []wire.Frame{connCloseFrame}, protocol.PerspectiveServer, conn.version)
-			tracer.EXPECT().ReceivedLongHeaderPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-			Expect(conn.handlePacketImpl(wrapPacket(initialPacket))).To(BeTrue())
-		})
+	// Send the second packet. We refuse to accept it, because the connection ID is changed again.
+	dropped := make(chan struct{})
+	tracer.EXPECT().DroppedPacket(logging.PacketTypeInitial, gomock.Any(), gomock.Any(), logging.PacketDropUnknownConnectionID).Do(
+		func(logging.PacketType, protocol.PacketNumber, protocol.ByteCount, logging.PacketDropReason) {
+			close(dropped)
+		},
+	)
 
-		// Illustrates that attacker who injects a Retry packet and changes the connection ID
-		// can cause subsequent real Initial packets to be ignored
-		It("ignores Initial packets which use original source id, after accepting a Retry", func() {
-			sph := mockackhandler.NewMockSentPacketHandler(mockCtrl)
-			conn.sentPacketHandler = sph
-			sph.EXPECT().ReceivedBytes(gomock.Any()).Times(2)
-			sph.EXPECT().ResetForRetry(gomock.Any())
-			newSrcConnID := protocol.ParseConnectionID([]byte{0xde, 0xad, 0xbe, 0xef})
-			cryptoSetup.EXPECT().ChangeConnectionID(newSrcConnID)
-			packer.EXPECT().SetToken([]byte("foobar"))
-
-			tracer.EXPECT().ReceivedRetry(gomock.Any())
-			conn.handlePacketImpl(wrapPacket(testutils.ComposeRetryPacket(newSrcConnID, destConnID, destConnID, []byte("foobar"), conn.version)))
-			initialPacket := testutils.ComposeInitialPacket(conn.connIDManager.Get(), srcConnID, conn.connIDManager.Get(), nil, nil, protocol.PerspectiveServer, conn.version)
-			tracer.EXPECT().DroppedPacket(gomock.Any(), protocol.InvalidPacketNumber, gomock.Any(), gomock.Any())
-			Expect(conn.handlePacketImpl(wrapPacket(initialPacket))).To(BeFalse())
-		})
+	tc.conn.handlePacket(receivedPacket{data: makeInitialPacket(t, &hdr2), buffer: getPacketBuffer(), rcvTime: time.Now()})
+	select {
+	case <-dropped:
+		// the connection ID should not have changed
+		require.Equal(t, newConnID, tc.conn.connIDManager.Get())
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+
+	// test teardown
+	tracer.EXPECT().ClosedConnection(gomock.Any())
+	tracer.EXPECT().Close()
+	tc.connRunner.EXPECT().Remove(gomock.Any())
+	tc.conn.destroy(nil)
+	select {
+	case err := <-errChan:
+		require.NoError(t, err)
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+}
+
+// When the connection is closed before sending the first packet,
+// we don't send a CONNECTION_CLOSE.
+// This can happen if there's something wrong the tls.Config, and
+// crypto/tls refuses to start the handshake.
+func TestConnectionEarlyClose(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	tr, tracer := mocklogging.NewMockConnectionTracer(mockCtrl)
+	cryptoSetup := mocks.NewMockCryptoSetup(mockCtrl)
+	tc := newClientTestConnection(t,
+		mockCtrl,
+		nil,
+		false,
+		connectionOptTracer(tr),
+		connectionOptCryptoSetup(cryptoSetup),
+	)
+
+	tc.conn.sentFirstPacket = false
+	tracer.EXPECT().ClosedConnection(gomock.Any())
+	tracer.EXPECT().Close()
+	cryptoSetup.EXPECT().StartHandshake(gomock.Any()).Do(func(context.Context) error {
+		tc.conn.closeLocal(errors.New("early error"))
+		return nil
 	})
-})
+	cryptoSetup.EXPECT().NextEvent().Return(handshake.Event{Kind: handshake.EventNoEvent})
+	cryptoSetup.EXPECT().Close()
+	tc.connRunner.EXPECT().Remove(gomock.Any())
+
+	errChan := make(chan error, 1)
+	go func() { errChan <- tc.conn.run() }()
+
+	select {
+	case err := <-errChan:
+		require.Error(t, err)
+		require.ErrorContains(t, err, "early error")
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+}
diff --git a/connection_timer_test.go b/connection_timer_test.go
index 24ee1563..d850fc43 100644
--- a/connection_timer_test.go
+++ b/connection_timer_test.go
@@ -1,62 +1,60 @@
 package quic
 
 import (
+	"testing"
 	"time"
 
-	. "github.com/onsi/ginkgo/v2"
-	. "github.com/onsi/gomega"
+	"github.com/stretchr/testify/require"
 )
 
 func (t *connectionTimer) Deadline() time.Time { return t.timer.Deadline() }
 
-var _ = Describe("Timer", func() {
-	It("sets an idle timeout", func() {
-		now := time.Now()
-		t := newTimer()
-		t.SetTimer(now.Add(time.Hour), time.Time{}, time.Time{}, time.Time{})
-		Expect(t.Deadline()).To(Equal(now.Add(time.Hour)))
-	})
+func TestConnectionTimerModes(t *testing.T) {
+	now := time.Now()
 
-	It("sets an ACK timer", func() {
-		now := time.Now()
-		t := newTimer()
-		t.SetTimer(now.Add(time.Hour), now.Add(time.Minute), time.Time{}, time.Time{})
-		Expect(t.Deadline()).To(Equal(now.Add(time.Minute)))
+	t.Run("idle timeout", func(t *testing.T) {
+		timer := newTimer()
+		timer.SetTimer(now.Add(time.Hour), time.Time{}, time.Time{}, time.Time{})
+		require.Equal(t, now.Add(time.Hour), timer.Deadline())
 	})
 
-	It("sets a loss timer", func() {
-		now := time.Now()
-		t := newTimer()
-		t.SetTimer(now.Add(time.Hour), now.Add(time.Minute), now.Add(time.Second), time.Time{})
-		Expect(t.Deadline()).To(Equal(now.Add(time.Second)))
+	t.Run("ACK timer", func(t *testing.T) {
+		timer := newTimer()
+		timer.SetTimer(now.Add(time.Hour), now.Add(time.Minute), time.Time{}, time.Time{})
+		require.Equal(t, now.Add(time.Minute), timer.Deadline())
 	})
 
-	It("sets a pacing timer", func() {
-		now := time.Now()
-		t := newTimer()
-		t.SetTimer(now.Add(time.Hour), now.Add(time.Minute), now.Add(time.Second), now.Add(time.Millisecond))
-		Expect(t.Deadline()).To(Equal(now.Add(time.Millisecond)))
+	t.Run("loss timer", func(t *testing.T) {
+		timer := newTimer()
+		timer.SetTimer(now.Add(time.Hour), now.Add(time.Minute), now.Add(time.Second), time.Time{})
+		require.Equal(t, now.Add(time.Second), timer.Deadline())
 	})
 
-	It("doesn't reset to an earlier time", func() {
-		now := time.Now()
-		t := newTimer()
-		t.SetTimer(now.Add(time.Hour), now.Add(time.Minute), time.Time{}, time.Time{})
-		Expect(t.Deadline()).To(Equal(now.Add(time.Minute)))
-		t.SetRead()
-
-		t.SetTimer(now.Add(time.Hour), now.Add(time.Minute), time.Time{}, time.Time{})
-		Expect(t.Deadline()).To(Equal(now.Add(time.Hour)))
-	})
-
-	It("allows the pacing timer to be set to send immediately", func() {
-		now := time.Now()
-		t := newTimer()
-		t.SetTimer(now.Add(time.Hour), now.Add(time.Minute), time.Time{}, time.Time{})
-		Expect(t.Deadline()).To(Equal(now.Add(time.Minute)))
-		t.SetRead()
-
-		t.SetTimer(now.Add(time.Hour), now.Add(time.Minute), time.Time{}, deadlineSendImmediately)
-		Expect(t.Deadline()).To(Equal(deadlineSendImmediately))
+	t.Run("pacing timer", func(t *testing.T) {
+		timer := newTimer()
+		timer.SetTimer(now.Add(time.Hour), now.Add(time.Minute), now.Add(time.Second), now.Add(time.Millisecond))
+		require.Equal(t, now.Add(time.Millisecond), timer.Deadline())
 	})
-})
+}
+
+func TestConnectionTimerReset(t *testing.T) {
+	now := time.Now()
+	timer := newTimer()
+	timer.SetTimer(now.Add(time.Hour), now.Add(time.Minute), time.Time{}, time.Time{})
+	require.Equal(t, now.Add(time.Minute), timer.Deadline())
+	timer.SetRead()
+
+	timer.SetTimer(now.Add(time.Hour), now.Add(time.Minute), time.Time{}, time.Time{})
+	require.Equal(t, now.Add(time.Hour), timer.Deadline())
+}
+
+func TestConnectionTimerSendImmediately(t *testing.T) {
+	now := time.Now()
+	timer := newTimer()
+	timer.SetTimer(now.Add(time.Hour), now.Add(time.Minute), time.Time{}, time.Time{})
+	require.Equal(t, now.Add(time.Minute), timer.Deadline())
+	timer.SetRead()
+
+	timer.SetTimer(now.Add(time.Hour), now.Add(time.Minute), time.Time{}, deadlineSendImmediately)
+	require.Equal(t, deadlineSendImmediately, timer.Deadline())
+}
diff --git a/quic_suite_test.go b/quic_suite_test.go
index 1b4e3b67..4744b7c3 100644
--- a/quic_suite_test.go
+++ b/quic_suite_test.go
@@ -2,6 +2,7 @@ package quic
 
 import (
 	"bytes"
+	"fmt"
 	"io"
 	"log"
 	"net"
@@ -59,6 +60,12 @@ func newUPDConnLocalhost(t testing.TB) *net.UDPConn {
 	return conn
 }
 
+func areConnsRunning() bool {
+	var b bytes.Buffer
+	pprof.Lookup("goroutine").WriteTo(&b, 1)
+	return strings.Contains(b.String(), "quic-go.(*connection).run")
+}
+
 func areServersRunning() bool {
 	var b bytes.Buffer
 	pprof.Lookup("goroutine").WriteTo(&b, 1)
@@ -76,3 +83,19 @@ var _ = AfterEach(func() {
 	Eventually(areServersRunning).Should(BeFalse())
 	Eventually(areTransportsRunning()).Should(BeFalse())
 })
+
+func TestMain(m *testing.M) {
+	status := m.Run()
+	if status != 0 {
+		os.Exit(status)
+	}
+	if areConnsRunning() {
+		fmt.Println("stray connection goroutines found")
+		os.Exit(1)
+	}
+	if areTransportsRunning() {
+		fmt.Println("stray transport goroutines found")
+		os.Exit(1)
+	}
+	os.Exit(status)
+}
diff --git a/transport_test.go b/transport_test.go
index d0e1a90f..a114fa27 100644
--- a/transport_test.go
+++ b/transport_test.go
@@ -65,7 +65,7 @@ func getPacketWithPacketType(t *testing.T, connID protocol.ConnectionID, typ pro
 		PacketNumberLen: protocol.PacketNumberLen2,
 	}).Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	return b
+	return append(b, bytes.Repeat([]byte{42}, int(length)-2)...)
 }
 
 func TestTransportPacketHandling(t *testing.T) {