Repository: quic-go/quic-go
Pull request #4309 (2024-02-04 03:56:14)

unmap IPv4-mapped IPv6 addresses
Closes #4308.

As an alternative or addition to https://github.com/quic-go/quic-go/pull/4308 (issues #4304 and #4105). Perhaps this can better be dealt with at the boundary.


Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 95c258cf..194e51fa 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -1,15 +1,15 @@
 version: 2.1
 executors:
-  test-go121:
+  test-go122:
     docker:
-      - image: "cimg/go:1.21"
+      - image: "cimg/go:1.22"
     environment:
       runrace: true
       TIMESCALE_FACTOR: 3
  
 jobs:
   "test": &test
-    executor: test-go121
+    executor: test-go122
     steps:
       - checkout
       - run:
@@ -33,10 +33,10 @@ jobs:
       - run:
           name: "Run version negotiation tests with qlog"
           command: go run github.com/onsi/ginkgo/v2/ginkgo -v -randomize-all -trace integrationtests/versionnegotiation -- -qlog
-  go121:
+  go122:
     <<: *test
 
 workflows:
   workflow:
     jobs:
-      - go121
+      - go122
diff --git a/.clusterfuzzlite/Dockerfile b/.clusterfuzzlite/Dockerfile
index d27db151..d9fedb9a 100644
--- a/.clusterfuzzlite/Dockerfile
+++ b/.clusterfuzzlite/Dockerfile
@@ -3,7 +3,7 @@ FROM gcr.io/oss-fuzz-base/base-builder-go:v1
 ARG TARGETPLATFORM
 RUN echo "TARGETPLATFORM: ${TARGETPLATFORM}"
 
-ENV GOVERSION=1.21.5
+ENV GOVERSION=1.22.0
 
 RUN platform=$(echo ${TARGETPLATFORM} | tr '/' '-') && \
   filename="go${GOVERSION}.${platform}.tar.gz" && \
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..5ace4600
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,6 @@
+version: 2
+updates:
+  - package-ecosystem: "github-actions"
+    directory: "/"
+    schedule:
+      interval: "weekly"
diff --git a/.github/workflows/build-interop-docker.yml b/.github/workflows/build-interop-docker.yml
index 8aaac826..694e8eef 100644
--- a/.github/workflows/build-interop-docker.yml
+++ b/.github/workflows/build-interop-docker.yml
@@ -12,13 +12,13 @@ jobs:
     steps:
       - uses: actions/checkout@v4
       - name: Set up QEMU
-        uses: docker/setup-qemu-action@v2
+        uses: docker/setup-qemu-action@v3
       - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@v2
+        uses: docker/setup-buildx-action@v3
         with:
           platforms: linux/amd64,linux/arm64
       - name: Login to Docker Hub
-        uses: docker/login-action@v2
+        uses: docker/login-action@v3
         with:
           username: ${{ secrets.DOCKER_USERNAME }}
           password: ${{ secrets.DOCKER_PASSWORD }}
@@ -34,7 +34,7 @@ jobs:
             echo 'tag=latest' | tee -a $GITHUB_OUTPUT;
             echo 'gitref=${{ github.sha }}' | tee -a $GITHUB_OUTPUT;
           fi
-      - uses: docker/build-push-action@v4
+      - uses: docker/build-push-action@v5
         with:
           context: "{{defaultContext}}:interop"
           platforms: linux/amd64,linux/arm64
diff --git a/.github/workflows/cross-compile.yml b/.github/workflows/cross-compile.yml
index b419e998..30126dd3 100644
--- a/.github/workflows/cross-compile.yml
+++ b/.github/workflows/cross-compile.yml
@@ -4,7 +4,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        go: [ "1.21.x", "1.22.0-rc.2" ]
+        go: [ "1.21.x", "1.22.x" ]
     runs-on: ${{ fromJSON(vars['CROSS_COMPILE_RUNNER_UBUNTU'] || '"ubuntu-latest"') }}
     name: "Cross Compilation (Go ${{matrix.go}})"
     steps:
diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
index ece4d6b7..64c76999 100644
--- a/.github/workflows/integration.yml
+++ b/.github/workflows/integration.yml
@@ -6,7 +6,7 @@ jobs:
       fail-fast: false
       matrix:
         os: [ "ubuntu" ]
-        go: [ "1.21.x", "1.22.0-rc.2" ]
+        go: [ "1.21.x", "1.22.x" ]
         include:
           - os: "windows"
             go: "1.21.x"
@@ -59,7 +59,7 @@ jobs:
           go run github.com/onsi/ginkgo/v2/ginkgo -r -v -randomize-all -randomize-suites -trace integrationtests/self -- ${{ env.QLOGFLAG }}
       - name: save qlogs
         if: ${{ always() && env.DEBUG == 'true' }}
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         with:
           name: qlogs-${{ matrix.os }}-go${{ matrix.go }}
           path: integrationtests/self/*.qlog
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index e2fdf1bd..11dbf8b7 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -7,7 +7,7 @@ jobs:
       - uses: actions/checkout@v4
       - uses: actions/setup-go@v5
         with:
-          go-version: "1.21.x"
+          go-version: "1.22.x"
       - name: Check that no non-test files import Ginkgo or Gomega
         run: .github/workflows/no_ginkgo.sh
       - name: Check for //go:build ignore in .go files
@@ -38,7 +38,9 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        go: [  "1.21.x", "1.22.0-rc.2" ]
+        go: [  "1.21.x", "1.22.x" ]
+    env:
+      GOLANGCI_LINT_VERSION: v1.56.1
     name: golangci-lint (Go ${{ matrix.go }})
     steps:
       - uses: actions/checkout@v4
@@ -46,44 +48,44 @@ jobs:
         with:
           go-version: ${{ matrix.go }}
       - name: golangci-lint (Linux)
-        uses: golangci/golangci-lint-action@v3
+        uses: golangci/golangci-lint-action@v4
         with:
           skip-pkg-cache: true
           args: --timeout=3m
-          version: v1.55.2
+          version: ${{ env.GOLANGCI_LINT_VERSION }}
       - name: golangci-lint (Windows)
         if: success() || failure() # run this step even if the previous one failed
-        uses: golangci/golangci-lint-action@v3
+        uses: golangci/golangci-lint-action@v4
         env:
           GOOS: "windows"
         with:
           skip-pkg-cache: true
           args: --timeout=3m
-          version: v1.55.2
+          version: ${{ env.GOLANGCI_LINT_VERSION }}
       - name: golangci-lint (OSX)
         if: success() || failure() # run this step even if the previous one failed
-        uses: golangci/golangci-lint-action@v3
+        uses: golangci/golangci-lint-action@v4
         env:
           GOOS: "darwin"
         with:
           skip-pkg-cache: true
           args: --timeout=3m
-          version: v1.55.2
+          version: ${{ env.GOLANGCI_LINT_VERSION }}
       - name: golangci-lint (FreeBSD)
         if: success() || failure() # run this step even if the previous one failed
-        uses: golangci/golangci-lint-action@v3
+        uses: golangci/golangci-lint-action@v4
         env:
           GOOS: "freebsd"
         with:
           skip-pkg-cache: true
           args: --timeout=3m
-          version: v1.55.2
+          version: ${{ env.GOLANGCI_LINT_VERSION }}
       - name: golangci-lint (others)
         if: success() || failure() # run this step even if the previous one failed
-        uses: golangci/golangci-lint-action@v3
+        uses: golangci/golangci-lint-action@v4
         env:
           GOOS: "solaris" # some OS that we don't have any build tags for
         with:
           skip-pkg-cache: true
           args: --timeout=3m
-          version: v1.55.2
+          version: ${{ env.GOLANGCI_LINT_VERSION }}
diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml
index e1ee2832..f7953a2d 100644
--- a/.github/workflows/unit.yml
+++ b/.github/workflows/unit.yml
@@ -7,7 +7,7 @@ jobs:
       fail-fast: false
       matrix:
         os: [ "ubuntu", "windows", "macos" ]
-        go: [ "1.21.x", "1.22.0-rc.2" ]
+        go: [ "1.21.x", "1.22.x" ]
     runs-on: ${{ fromJSON(vars[format('UNIT_RUNNER_{0}', matrix.os)] || format('"{0}-latest"', matrix.os)) }}
     name: Unit tests (${{ matrix.os}}, Go ${{ matrix.go }})
     steps:
@@ -42,7 +42,8 @@ jobs:
           TIMESCALE_FACTOR: 20
         run: go run github.com/onsi/ginkgo/v2/ginkgo -r -v -race -randomize-all -randomize-suites -trace -skip-package integrationtests
       - name: Upload coverage to Codecov
-        uses: codecov/codecov-action@v3
+        uses: codecov/codecov-action@v4
         with:
           files: coverage.txt,coverage-root.txt
           env_vars: OS=${{ matrix.os }}, GO=${{ matrix.go }}
+          token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/README.md b/README.md
index 93f14dd9..faba82f3 100644
--- a/README.md
+++ b/README.md
@@ -226,6 +226,7 @@ http.Client{
 | [algernon](https://github.com/xyproto/algernon)           | Small self-contained pure-Go web server with Lua, Markdown, HTTP/2, QUIC, Redis and PostgreSQL support                                                            | ![GitHub Repo stars](https://img.shields.io/github/stars/xyproto/algernon?style=flat-square)        |
 | [caddy](https://github.com/caddyserver/caddy/)            | Fast, multi-platform web server with automatic HTTPS                                                                                                              | ![GitHub Repo stars](https://img.shields.io/github/stars/caddyserver/caddy?style=flat-square)       |
 | [cloudflared](https://github.com/cloudflare/cloudflared)  | A tunneling daemon that proxies traffic from the Cloudflare network to your origins                                                                               | ![GitHub Repo stars](https://img.shields.io/github/stars/cloudflare/cloudflared?style=flat-square)  |
+| [frp](https://github.com/fatedier/frp)                    | A fast reverse proxy to help you expose a local server behind a NAT or firewall to the internet                                                                   | ![GitHub Repo stars](https://img.shields.io/github/stars/fatedier/frp?style=flat-square)            |
 | [go-libp2p](https://github.com/libp2p/go-libp2p)          | libp2p implementation in Go, powering [Kubo](https://github.com/ipfs/kubo) (IPFS) and [Lotus](https://github.com/filecoin-project/lotus) (Filecoin), among others | ![GitHub Repo stars](https://img.shields.io/github/stars/libp2p/go-libp2p?style=flat-square)     |
 | [gost](https://github.com/go-gost/gost)                   | A simple security tunnel written in Go                                                                                                                        | ![GitHub Repo stars](https://img.shields.io/github/stars/go-gost/gost?style=flat-square)            |
 | [Hysteria](https://github.com/apernet/hysteria)           | A powerful, lightning fast and censorship resistant proxy                                                                                                         | ![GitHub Repo stars](https://img.shields.io/github/stars/apernet/hysteria?style=flat-square)        |
diff --git a/closed_conn.go b/closed_conn.go
index fbee319c..83338532 100644
--- a/closed_conn.go
+++ b/closed_conn.go
@@ -4,7 +4,6 @@ import (
 	"math/bits"
 	"net"
 
-	"github.com/quic-go/quic-go/internal/protocol"
 	"github.com/quic-go/quic-go/internal/utils"
 )
 
@@ -12,9 +11,8 @@ import (
 // When receiving packets for such a connection, we need to retransmit the packet containing the CONNECTION_CLOSE frame,
 // with an exponential backoff.
 type closedLocalConn struct {
-	counter     uint32
-	perspective protocol.Perspective
-	logger      utils.Logger
+	counter uint32
+	logger  utils.Logger
 
 	sendPacket func(net.Addr, packetInfo)
 }
@@ -22,11 +20,10 @@ type closedLocalConn struct {
 var _ packetHandler = &closedLocalConn{}
 
 // newClosedLocalConn creates a new closedLocalConn and runs it.
-func newClosedLocalConn(sendPacket func(net.Addr, packetInfo), pers protocol.Perspective, logger utils.Logger) packetHandler {
+func newClosedLocalConn(sendPacket func(net.Addr, packetInfo), logger utils.Logger) packetHandler {
 	return &closedLocalConn{
-		sendPacket:  sendPacket,
-		perspective: pers,
-		logger:      logger,
+		sendPacket: sendPacket,
+		logger:     logger,
 	}
 }
 
@@ -43,22 +40,18 @@ func (c *closedLocalConn) handlePacket(p receivedPacket) {
 
 func (c *closedLocalConn) destroy(error)                              {}
 func (c *closedLocalConn) closeWithTransportError(TransportErrorCode) {}
-func (c *closedLocalConn) getPerspective() protocol.Perspective       { return c.perspective }
 
 // A closedRemoteConn is a connection that was closed remotely.
 // For such a connection, we might receive reordered packets that were sent before the CONNECTION_CLOSE.
 // We can just ignore those packets.
-type closedRemoteConn struct {
-	perspective protocol.Perspective
-}
+type closedRemoteConn struct{}
 
 var _ packetHandler = &closedRemoteConn{}
 
-func newClosedRemoteConn(pers protocol.Perspective) packetHandler {
-	return &closedRemoteConn{perspective: pers}
+func newClosedRemoteConn() packetHandler {
+	return &closedRemoteConn{}
 }
 
 func (c *closedRemoteConn) handlePacket(receivedPacket)                {}
 func (c *closedRemoteConn) destroy(error)                              {}
 func (c *closedRemoteConn) closeWithTransportError(TransportErrorCode) {}
-func (c *closedRemoteConn) getPerspective() protocol.Perspective       { return c.perspective }
diff --git a/conn_id_generator.go b/conn_id_generator.go
index 9cf21d9a..d7be6540 100644
--- a/conn_id_generator.go
+++ b/conn_id_generator.go
@@ -19,7 +19,7 @@ type connIDGenerator struct {
 	getStatelessResetToken func(protocol.ConnectionID) protocol.StatelessResetToken
 	removeConnectionID     func(protocol.ConnectionID)
 	retireConnectionID     func(protocol.ConnectionID)
-	replaceWithClosed      func([]protocol.ConnectionID, protocol.Perspective, []byte)
+	replaceWithClosed      func([]protocol.ConnectionID, []byte)
 	queueControlFrame      func(wire.Frame)
 }
 
@@ -30,7 +30,7 @@ func newConnIDGenerator(
 	getStatelessResetToken func(protocol.ConnectionID) protocol.StatelessResetToken,
 	removeConnectionID func(protocol.ConnectionID),
 	retireConnectionID func(protocol.ConnectionID),
-	replaceWithClosed func([]protocol.ConnectionID, protocol.Perspective, []byte),
+	replaceWithClosed func([]protocol.ConnectionID, []byte),
 	queueControlFrame func(wire.Frame),
 	generator ConnectionIDGenerator,
 ) *connIDGenerator {
@@ -126,7 +126,7 @@ func (m *connIDGenerator) RemoveAll() {
 	}
 }
 
-func (m *connIDGenerator) ReplaceWithClosed(pers protocol.Perspective, connClose []byte) {
+func (m *connIDGenerator) ReplaceWithClosed(connClose []byte) {
 	connIDs := make([]protocol.ConnectionID, 0, len(m.activeSrcConnIDs)+1)
 	if m.initialClientDestConnID != nil {
 		connIDs = append(connIDs, *m.initialClientDestConnID)
@@ -134,5 +134,5 @@ func (m *connIDGenerator) ReplaceWithClosed(pers protocol.Perspective, connClose
 	for _, connID := range m.activeSrcConnIDs {
 		connIDs = append(connIDs, connID)
 	}
-	m.replaceWithClosed(connIDs, pers, connClose)
+	m.replaceWithClosed(connIDs, connClose)
 }
diff --git a/connection.go b/connection.go
index 387268fe..8c66cb0b 100644
--- a/connection.go
+++ b/connection.go
@@ -93,7 +93,7 @@ type connRunner interface {
 	GetStatelessResetToken(protocol.ConnectionID) protocol.StatelessResetToken
 	Retire(protocol.ConnectionID)
 	Remove(protocol.ConnectionID)
-	ReplaceWithClosed([]protocol.ConnectionID, protocol.Perspective, []byte)
+	ReplaceWithClosed([]protocol.ConnectionID, []byte)
 	AddResetToken(protocol.StatelessResetToken, packetHandler)
 	RemoveResetToken(protocol.StatelessResetToken)
 }
@@ -1632,7 +1632,7 @@ func (s *connection) handleCloseError(closeErr *closeError) {
 
 	// If this is a remote close we're done here
 	if closeErr.remote {
-		s.connIDGenerator.ReplaceWithClosed(s.perspective, nil)
+		s.connIDGenerator.ReplaceWithClosed(nil)
 		return
 	}
 	if closeErr.immediate {
@@ -1649,7 +1649,7 @@ func (s *connection) handleCloseError(closeErr *closeError) {
 	if err != nil {
 		s.logger.Debugf("Error sending CONNECTION_CLOSE: %s", err)
 	}
-	s.connIDGenerator.ReplaceWithClosed(s.perspective, connClosePacket)
+	s.connIDGenerator.ReplaceWithClosed(connClosePacket)
 }
 
 func (s *connection) dropEncryptionLevel(encLevel protocol.EncryptionLevel) error {
@@ -2379,10 +2379,6 @@ func (s *connection) RemoteAddr() net.Addr {
 	return s.conn.RemoteAddr()
 }
 
-func (s *connection) getPerspective() protocol.Perspective {
-	return s.perspective
-}
-
 func (s *connection) GetVersion() protocol.Version {
 	return s.version
 }
diff --git a/http3/client.go b/http3/client.go
index e6e3d68a..1f981509 100644
--- a/http3/client.go
+++ b/http3/client.go
@@ -73,6 +73,10 @@ var _ roundTripCloser = &client{}
 func newClient(hostname string, tlsConf *tls.Config, opts *roundTripperOpts, conf *quic.Config, dialer dialFunc) (roundTripCloser, error) {
 	if conf == nil {
 		conf = defaultQuicConfig.Clone()
+		conf.EnableDatagrams = opts.EnableDatagram
+	}
+	if opts.EnableDatagram && !conf.EnableDatagrams {
+		return nil, errors.New("HTTP Datagrams enabled, but QUIC Datagrams disabled")
 	}
 	if len(conf.Versions) == 0 {
 		conf = conf.Clone()
@@ -84,7 +88,6 @@ func newClient(hostname string, tlsConf *tls.Config, opts *roundTripperOpts, con
 	if conf.MaxIncomingStreams == 0 {
 		conf.MaxIncomingStreams = -1 // don't allow any bidirectional streams
 	}
-	conf.EnableDatagrams = opts.EnableDatagram
 	logger := utils.DefaultLogger.WithPrefix("h3 client")
 
 	if tlsConf == nil {
@@ -180,6 +183,8 @@ func (c *client) handleBidirectionalStreams(conn quic.EarlyConnection) {
 }
 
 func (c *client) handleUnidirectionalStreams(conn quic.EarlyConnection) {
+	var rcvdControlStream atomic.Bool
+
 	for {
 		str, err := conn.AcceptUniStream(context.Background())
 		if err != nil {
@@ -214,6 +219,11 @@ func (c *client) handleUnidirectionalStreams(conn quic.EarlyConnection) {
 				str.CancelRead(quic.StreamErrorCode(ErrCodeStreamCreationError))
 				return
 			}
+			// Only a single control stream is allowed.
+			if isFirstControlStr := rcvdControlStream.CompareAndSwap(false, true); !isFirstControlStr {
+				conn.CloseWithError(quic.ApplicationErrorCode(ErrCodeStreamCreationError), "duplicate control stream")
+				return
+			}
 			f, err := parseNextFrame(str, nil)
 			if err != nil {
 				conn.CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameError), "")
diff --git a/http3/frames.go b/http3/frames.go
index 454e5f94..90b22f6b 100644
--- a/http3/frames.go
+++ b/http3/frames.go
@@ -88,11 +88,18 @@ func (f *headersFrame) Append(b []byte) []byte {
 	return quicvarint.Append(b, f.Length)
 }
 
-const settingDatagram = 0x33
+const (
+	// Extended CONNECT, RFC 9220
+	settingExtendedConnect = 0x8
+	// HTTP Datagrams, RFC 9297
+	settingDatagram = 0x33
+)
 
 type settingsFrame struct {
-	Datagram bool
-	Other    map[uint64]uint64 // all settings that we don't explicitly recognize
+	Datagram        bool // HTTP Datagrams, RFC 9297
+	ExtendedConnect bool // Extended CONNECT, RFC 9220
+
+	Other map[uint64]uint64 // all settings that we don't explicitly recognize
 }
 
 func parseSettingsFrame(r io.Reader, l uint64) (*settingsFrame, error) {
@@ -108,7 +115,7 @@ func parseSettingsFrame(r io.Reader, l uint64) (*settingsFrame, error) {
 	}
 	frame := &settingsFrame{}
 	b := bytes.NewReader(buf)
-	var readDatagram bool
+	var readDatagram, readExtendedConnect bool
 	for b.Len() > 0 {
 		id, err := quicvarint.Read(b)
 		if err != nil { // should not happen. We allocated the whole frame already.
@@ -120,13 +127,22 @@ func parseSettingsFrame(r io.Reader, l uint64) (*settingsFrame, error) {
 		}
 
 		switch id {
+		case settingExtendedConnect:
+			if readExtendedConnect {
+				return nil, fmt.Errorf("duplicate setting: %d", id)
+			}
+			readExtendedConnect = true
+			if val != 0 && val != 1 {
+				return nil, fmt.Errorf("invalid value for SETTINGS_ENABLE_CONNECT_PROTOCOL: %d", val)
+			}
+			frame.ExtendedConnect = val == 1
 		case settingDatagram:
 			if readDatagram {
 				return nil, fmt.Errorf("duplicate setting: %d", id)
 			}
 			readDatagram = true
 			if val != 0 && val != 1 {
-				return nil, fmt.Errorf("invalid value for H3_DATAGRAM: %d", val)
+				return nil, fmt.Errorf("invalid value for SETTINGS_H3_DATAGRAM: %d", val)
 			}
 			frame.Datagram = val == 1
 		default:
@@ -151,11 +167,18 @@ func (f *settingsFrame) Append(b []byte) []byte {
 	if f.Datagram {
 		l += quicvarint.Len(settingDatagram) + quicvarint.Len(1)
 	}
+	if f.ExtendedConnect {
+		l += quicvarint.Len(settingExtendedConnect) + quicvarint.Len(1)
+	}
 	b = quicvarint.Append(b, uint64(l))
 	if f.Datagram {
 		b = quicvarint.Append(b, settingDatagram)
 		b = quicvarint.Append(b, 1)
 	}
+	if f.ExtendedConnect {
+		b = quicvarint.Append(b, settingExtendedConnect)
+		b = quicvarint.Append(b, 1)
+	}
 	for id, val := range f.Other {
 		b = quicvarint.Append(b, id)
 		b = quicvarint.Append(b, val)
diff --git a/http3/roundtrip.go b/http3/roundtrip.go
index d86b0bb2..86d3d3d0 100644
--- a/http3/roundtrip.go
+++ b/http3/roundtrip.go
@@ -50,9 +50,8 @@ type RoundTripper struct {
 	// If nil, reasonable default values will be used.
 	QuicConfig *quic.Config
 
-	// Enable support for HTTP/3 datagrams.
-	// If set to true, QuicConfig.EnableDatagram will be set.
-	// See https://datatracker.ietf.org/doc/html/rfc9297.
+	// Enable support for HTTP/3 datagrams (RFC 9297).
+	// If a QuicConfig is set, datagram support also needs to be enabled on the QUIC layer by setting EnableDatagrams.
 	EnableDatagrams bool
 
 	// Additional HTTP/3 settings.
diff --git a/http3/server.go b/http3/server.go
index 2d5437c9..cc8dd027 100644
--- a/http3/server.go
+++ b/http3/server.go
@@ -12,6 +12,7 @@ import (
 	"strconv"
 	"strings"
 	"sync"
+	"sync/atomic"
 	"time"
 
 	"github.com/quic-go/quic-go"
@@ -451,7 +452,11 @@ func (s *Server) handleConn(conn quic.Connection) error {
 	}
 	b := make([]byte, 0, 64)
 	b = quicvarint.Append(b, streamTypeControlStream) // stream type
-	b = (&settingsFrame{Datagram: s.EnableDatagrams, Other: s.AdditionalSettings}).Append(b)
+	b = (&settingsFrame{
+		Datagram:        s.EnableDatagrams,
+		ExtendedConnect: true,
+		Other:           s.AdditionalSettings,
+	}).Append(b)
 	str.Write(b)
 
 	go s.handleUnidirectionalStreams(conn)
@@ -494,6 +499,8 @@ func (s *Server) handleConn(conn quic.Connection) error {
 }
 
 func (s *Server) handleUnidirectionalStreams(conn quic.Connection) {
+	var rcvdControlStream atomic.Bool
+
 	for {
 		str, err := conn.AcceptUniStream(context.Background())
 		if err != nil {
@@ -527,6 +534,11 @@ func (s *Server) handleUnidirectionalStreams(conn quic.Connection) {
 				str.CancelRead(quic.StreamErrorCode(ErrCodeStreamCreationError))
 				return
 			}
+			// Only a single control stream is allowed.
+			if isFirstControlStr := rcvdControlStream.CompareAndSwap(false, true); !isFirstControlStr {
+				conn.CloseWithError(quic.ApplicationErrorCode(ErrCodeStreamCreationError), "duplicate control stream")
+				return
+			}
 			f, err := parseNextFrame(str, nil)
 			if err != nil {
 				conn.CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameError), "")
diff --git a/internal/handshake/aead.go b/internal/handshake/aead.go
index 693939ae..1baf5d6b 100644
--- a/internal/handshake/aead.go
+++ b/internal/handshake/aead.go
@@ -1,13 +1,12 @@
 package handshake
 
 import (
-	"crypto/cipher"
 	"encoding/binary"
 
 	"github.com/quic-go/quic-go/internal/protocol"
 )
 
-func createAEAD(suite *cipherSuite, trafficSecret []byte, v protocol.Version) cipher.AEAD {
+func createAEAD(suite *cipherSuite, trafficSecret []byte, v protocol.Version) *xorNonceAEAD {
 	keyLabel := hkdfLabelKeyV1
 	ivLabel := hkdfLabelIVV1
 	if v == protocol.Version2 {
@@ -20,28 +19,26 @@ func createAEAD(suite *cipherSuite, trafficSecret []byte, v protocol.Version) ci
 }
 
 type longHeaderSealer struct {
-	aead            cipher.AEAD
+	aead            *xorNonceAEAD
 	headerProtector headerProtector
-
-	// use a single slice to avoid allocations
-	nonceBuf []byte
+	nonceBuf        [8]byte
 }
 
 var _ LongHeaderSealer = &longHeaderSealer{}
 
-func newLongHeaderSealer(aead cipher.AEAD, headerProtector headerProtector) LongHeaderSealer {
+func newLongHeaderSealer(aead *xorNonceAEAD, headerProtector headerProtector) LongHeaderSealer {
+	if aead.NonceSize() != 8 {
+		panic("unexpected nonce size")
+	}
 	return &longHeaderSealer{
 		aead:            aead,
 		headerProtector: headerProtector,
-		nonceBuf:        make([]byte, aead.NonceSize()),
 	}
 }
 
 func (s *longHeaderSealer) Seal(dst, src []byte, pn protocol.PacketNumber, ad []byte) []byte {
-	binary.BigEndian.PutUint64(s.nonceBuf[len(s.nonceBuf)-8:], uint64(pn))
-	// The AEAD we're using here will be the qtls.aeadAESGCM13.
-	// It uses the nonce provided here and XOR it with the IV.
-	return s.aead.Seal(dst, s.nonceBuf, src, ad)
+	binary.BigEndian.PutUint64(s.nonceBuf[:], uint64(pn))
+	return s.aead.Seal(dst, s.nonceBuf[:], src, ad)
 }
 
 func (s *longHeaderSealer) EncryptHeader(sample []byte, firstByte *byte, pnBytes []byte) {
@@ -53,21 +50,23 @@ func (s *longHeaderSealer) Overhead() int {
 }
 
 type longHeaderOpener struct {
-	aead            cipher.AEAD
+	aead            *xorNonceAEAD
 	headerProtector headerProtector
 	highestRcvdPN   protocol.PacketNumber // highest packet number received (which could be successfully unprotected)
 
-	// use a single slice to avoid allocations
-	nonceBuf []byte
+	// use a single array to avoid allocations
+	nonceBuf [8]byte
 }
 
 var _ LongHeaderOpener = &longHeaderOpener{}
 
-func newLongHeaderOpener(aead cipher.AEAD, headerProtector headerProtector) LongHeaderOpener {
+func newLongHeaderOpener(aead *xorNonceAEAD, headerProtector headerProtector) LongHeaderOpener {
+	if aead.NonceSize() != 8 {
+		panic("unexpected nonce size")
+	}
 	return &longHeaderOpener{
 		aead:            aead,
 		headerProtector: headerProtector,
-		nonceBuf:        make([]byte, aead.NonceSize()),
 	}
 }
 
@@ -76,10 +75,8 @@ func (o *longHeaderOpener) DecodePacketNumber(wirePN protocol.PacketNumber, wire
 }
 
 func (o *longHeaderOpener) Open(dst, src []byte, pn protocol.PacketNumber, ad []byte) ([]byte, error) {
-	binary.BigEndian.PutUint64(o.nonceBuf[len(o.nonceBuf)-8:], uint64(pn))
-	// The AEAD we're using here will be the qtls.aeadAESGCM13.
-	// It uses the nonce provided here and XOR it with the IV.
-	dec, err := o.aead.Open(dst, o.nonceBuf, src, ad)
+	binary.BigEndian.PutUint64(o.nonceBuf[:], uint64(pn))
+	dec, err := o.aead.Open(dst, o.nonceBuf[:], src, ad)
 	if err == nil {
 		o.highestRcvdPN = max(o.highestRcvdPN, pn)
 	} else {
diff --git a/internal/handshake/cipher_suite.go b/internal/handshake/cipher_suite.go
index 265231f0..d8a381da 100644
--- a/internal/handshake/cipher_suite.go
+++ b/internal/handshake/cipher_suite.go
@@ -18,7 +18,7 @@ type cipherSuite struct {
 	ID     uint16
 	Hash   crypto.Hash
 	KeyLen int
-	AEAD   func(key, nonceMask []byte) cipher.AEAD
+	AEAD   func(key, nonceMask []byte) *xorNonceAEAD
 }
 
 func (s cipherSuite) IVLen() int { return aeadNonceLength }
@@ -36,7 +36,7 @@ func getCipherSuite(id uint16) *cipherSuite {
 	}
 }
 
-func aeadAESGCMTLS13(key, nonceMask []byte) cipher.AEAD {
+func aeadAESGCMTLS13(key, nonceMask []byte) *xorNonceAEAD {
 	if len(nonceMask) != aeadNonceLength {
 		panic("tls: internal error: wrong nonce length")
 	}
@@ -54,7 +54,7 @@ func aeadAESGCMTLS13(key, nonceMask []byte) cipher.AEAD {
 	return ret
 }
 
-func aeadChaCha20Poly1305(key, nonceMask []byte) cipher.AEAD {
+func aeadChaCha20Poly1305(key, nonceMask []byte) *xorNonceAEAD {
 	if len(nonceMask) != aeadNonceLength {
 		panic("tls: internal error: wrong nonce length")
 	}
diff --git a/internal/handshake/header_protector.go b/internal/handshake/header_protector.go
index 455f2507..2c5ee42f 100644
--- a/internal/handshake/header_protector.go
+++ b/internal/handshake/header_protector.go
@@ -37,7 +37,7 @@ func newHeaderProtector(suite *cipherSuite, trafficSecret []byte, isLongHeader b
 }
 
 type aesHeaderProtector struct {
-	mask         []byte
+	mask         [16]byte // AES always has a 16 byte block size
 	block        cipher.Block
 	isLongHeader bool
 }
@@ -52,7 +52,6 @@ func newAESHeaderProtector(suite *cipherSuite, trafficSecret []byte, isLongHeade
 	}
 	return &aesHeaderProtector{
 		block:        block,
-		mask:         make([]byte, block.BlockSize()),
 		isLongHeader: isLongHeader,
 	}
 }
@@ -69,7 +68,7 @@ func (p *aesHeaderProtector) apply(sample []byte, firstByte *byte, hdrBytes []by
 	if len(sample) != len(p.mask) {
 		panic("invalid sample size")
 	}
-	p.block.Encrypt(p.mask, sample)
+	p.block.Encrypt(p.mask[:], sample)
 	if p.isLongHeader {
 		*firstByte ^= p.mask[0] & 0xf
 	} else {
diff --git a/internal/handshake/hkdf.go b/internal/handshake/hkdf.go
index c4fd86c5..0caf1c8e 100644
--- a/internal/handshake/hkdf.go
+++ b/internal/handshake/hkdf.go
@@ -7,7 +7,7 @@ import (
 	"golang.org/x/crypto/hkdf"
 )
 
-// hkdfExpandLabel HKDF expands a label.
+// hkdfExpandLabel HKDF expands a label as defined in RFC 8446, section 7.1.
 // Since this implementation avoids using a cryptobyte.Builder, it is about 15% faster than the
 // hkdfExpandLabel in the standard library.
 func hkdfExpandLabel(hash crypto.Hash, secret, context []byte, label string, length int) []byte {
diff --git a/internal/qtls/cipher_suite.go b/internal/qtls/cipher_suite.go
index 16558f8b..32a921cd 100644
--- a/internal/qtls/cipher_suite.go
+++ b/internal/qtls/cipher_suite.go
@@ -1,23 +1,11 @@
 package qtls
 
 import (
-	"crypto"
-	"crypto/cipher"
 	"crypto/tls"
 	"fmt"
 	"unsafe"
 )
 
-type cipherSuiteTLS13 struct {
-	ID     uint16
-	KeyLen int
-	AEAD   func(key, fixedNonce []byte) cipher.AEAD
-	Hash   crypto.Hash
-}
-
-//go:linkname cipherSuiteTLS13ByID crypto/tls.cipherSuiteTLS13ByID
-func cipherSuiteTLS13ByID(id uint16) *cipherSuiteTLS13
-
 //go:linkname cipherSuitesTLS13 crypto/tls.cipherSuitesTLS13
 var cipherSuitesTLS13 []unsafe.Pointer
 
diff --git a/internal/qtls/client_session_cache.go b/internal/qtls/client_session_cache.go
index d81eb8c3..4acac9e2 100644
--- a/internal/qtls/client_session_cache.go
+++ b/internal/qtls/client_session_cache.go
@@ -1,12 +1,12 @@
-//go:build go1.21
-
 package qtls
 
 import (
 	"crypto/tls"
+	"sync"
 )
 
 type clientSessionCache struct {
+	mx      sync.Mutex
 	getData func(earlyData bool) []byte
 	setData func(data []byte, earlyData bool) (allowEarlyData bool)
 	wrapped tls.ClientSessionCache
@@ -14,7 +14,10 @@ type clientSessionCache struct {
 
 var _ tls.ClientSessionCache = &clientSessionCache{}
 
-func (c clientSessionCache) Put(key string, cs *tls.ClientSessionState) {
+func (c *clientSessionCache) Put(key string, cs *tls.ClientSessionState) {
+	c.mx.Lock()
+	defer c.mx.Unlock()
+
 	if cs == nil {
 		c.wrapped.Put(key, nil)
 		return
@@ -34,7 +37,10 @@ func (c clientSessionCache) Put(key string, cs *tls.ClientSessionState) {
 	c.wrapped.Put(key, newCS)
 }
 
-func (c clientSessionCache) Get(key string) (*tls.ClientSessionState, bool) {
+func (c *clientSessionCache) Get(key string) (*tls.ClientSessionState, bool) {
+	c.mx.Lock()
+	defer c.mx.Unlock()
+
 	cs, ok := c.wrapped.Get(key)
 	if !ok || cs == nil {
 		return cs, ok
diff --git a/interop/Dockerfile b/interop/Dockerfile
index 636b9c59..6c34fae6 100644
--- a/interop/Dockerfile
+++ b/interop/Dockerfile
@@ -5,7 +5,7 @@ RUN echo "TARGETPLATFORM: ${TARGETPLATFORM}"
 
 RUN apt-get update && apt-get install -y wget tar git
 
-ENV GOVERSION=1.21.4
+ENV GOVERSION=1.22.0
 
 RUN platform=$(echo ${TARGETPLATFORM} | tr '/' '-') && \
   filename="go${GOVERSION}.${platform}.tar.gz" && \
diff --git a/logging/connection_tracer.go b/logging/connection_tracer.go
index 0f2cbe04..7f54d6cd 100644
--- a/logging/connection_tracer.go
+++ b/logging/connection_tracer.go
@@ -27,9 +27,9 @@ type ConnectionTracer struct {
 	UpdatedCongestionState           func(CongestionState)
 	UpdatedPTOCount                  func(value uint32)
 	UpdatedKeyFromTLS                func(EncryptionLevel, Perspective)
-	UpdatedKey                       func(generation KeyPhase, remote bool)
+	UpdatedKey                       func(keyPhase KeyPhase, remote bool)
 	DroppedEncryptionLevel           func(EncryptionLevel)
-	DroppedKey                       func(generation KeyPhase)
+	DroppedKey                       func(keyPhase KeyPhase)
 	SetLossTimer                     func(TimerType, EncryptionLevel, time.Time)
 	LossTimerExpired                 func(TimerType, EncryptionLevel)
 	LossTimerCanceled                func()
diff --git a/oss-fuzz.sh b/oss-fuzz.sh
index f72fb1b0..22a577fe 100644
--- a/oss-fuzz.sh
+++ b/oss-fuzz.sh
@@ -3,12 +3,12 @@
 # Install Go manually, since oss-fuzz ships with an outdated Go version.
 # See https://github.com/google/oss-fuzz/pull/10643.
 export CXX="${CXX} -lresolv" # required by Go 1.20
-wget https://go.dev/dl/go1.21.5.linux-amd64.tar.gz \
+wget https://go.dev/dl/go1.22.0.linux-amd64.tar.gz \
   && mkdir temp-go \
   && rm -rf /root/.go/* \
-  && tar -C temp-go/ -xzf go1.21.5.linux-amd64.tar.gz \
+  && tar -C temp-go/ -xzf go1.22.0.linux-amd64.tar.gz \
   && mv temp-go/go/* /root/.go/ \
-  && rm -rf temp-go go1.21.5.linux-amd64.tar.gz
+  && rm -rf temp-go go1.22.0.linux-amd64.tar.gz
 
 (
 # fuzz qpack
diff --git a/packet_handler_map.go b/packet_handler_map.go
index c3283cfd..7840202c 100644
--- a/packet_handler_map.go
+++ b/packet_handler_map.go
@@ -129,7 +129,7 @@ func (h *packetHandlerMap) Add(id protocol.ConnectionID, handler packetHandler)
 	return true
 }
 
-func (h *packetHandlerMap) AddWithConnID(clientDestConnID, newConnID protocol.ConnectionID, fn func() (packetHandler, bool)) bool {
+func (h *packetHandlerMap) AddWithConnID(clientDestConnID, newConnID protocol.ConnectionID, handler packetHandler) bool {
 	h.mutex.Lock()
 	defer h.mutex.Unlock()
 
@@ -137,12 +137,8 @@ func (h *packetHandlerMap) AddWithConnID(clientDestConnID, newConnID protocol.Co
 		h.logger.Debugf("Not adding connection ID %s for a new connection, as it already exists.", clientDestConnID)
 		return false
 	}
-	conn, ok := fn()
-	if !ok {
-		return false
-	}
-	h.handlers[clientDestConnID] = conn
-	h.handlers[newConnID] = conn
+	h.handlers[clientDestConnID] = handler
+	h.handlers[newConnID] = handler
 	h.logger.Debugf("Adding connection IDs %s and %s for a new connection.", clientDestConnID, newConnID)
 	return true
 }
@@ -168,18 +164,17 @@ func (h *packetHandlerMap) Retire(id protocol.ConnectionID) {
 // Depending on which side closed the connection, we need to:
 // * remote close: absorb delayed packets
 // * local close: retransmit the CONNECTION_CLOSE packet, in case it was lost
-func (h *packetHandlerMap) ReplaceWithClosed(ids []protocol.ConnectionID, pers protocol.Perspective, connClosePacket []byte) {
+func (h *packetHandlerMap) ReplaceWithClosed(ids []protocol.ConnectionID, connClosePacket []byte) {
 	var handler packetHandler
 	if connClosePacket != nil {
 		handler = newClosedLocalConn(
 			func(addr net.Addr, info packetInfo) {
 				h.enqueueClosePacket(closePacket{payload: connClosePacket, addr: addr, info: info})
 			},
-			pers,
 			h.logger,
 		)
 	} else {
-		handler = newClosedRemoteConn(pers)
+		handler = newClosedRemoteConn()
 	}
 
 	h.mutex.Lock()
diff --git a/packet_packer.go b/packet_packer.go
index 5b055de7..e707734f 100644
--- a/packet_packer.go
+++ b/packet_packer.go
@@ -589,12 +589,11 @@ func (p *packetPacker) composeNextPacket(maxFrameSize protocol.ByteCount, onlyAc
 		return payload{}
 	}
 
-	pl := payload{streamFrames: make([]ackhandler.StreamFrame, 0, 1)}
-
 	hasData := p.framer.HasData()
 	hasRetransmission := p.retransmissionQueue.HasAppData()
 
 	var hasAck bool
+	var pl payload
 	if ackAllowed {
 		if ack := p.acks.GetAckFrame(protocol.Encryption1RTT, !hasRetransmission && !hasData); ack != nil {
 			pl.ack = ack
diff --git a/qlog/connection_tracer.go b/qlog/connection_tracer.go
index ae328a38..c73a3fe3 100644
--- a/qlog/connection_tracer.go
+++ b/qlog/connection_tracer.go
@@ -85,14 +85,14 @@ func NewConnectionTracer(w io.WriteCloser, p logging.Perspective, odcid protocol
 		UpdatedKeyFromTLS: func(encLevel protocol.EncryptionLevel, pers protocol.Perspective) {
 			t.UpdatedKeyFromTLS(encLevel, pers)
 		},
-		UpdatedKey: func(generation protocol.KeyPhase, remote bool) {
-			t.UpdatedKey(generation, remote)
+		UpdatedKey: func(keyPhase protocol.KeyPhase, remote bool) {
+			t.UpdatedKey(keyPhase, remote)
 		},
 		DroppedEncryptionLevel: func(encLevel protocol.EncryptionLevel) {
 			t.DroppedEncryptionLevel(encLevel)
 		},
-		DroppedKey: func(generation protocol.KeyPhase) {
-			t.DroppedKey(generation)
+		DroppedKey: func(keyPhase protocol.KeyPhase) {
+			t.DroppedKey(keyPhase)
 		},
 		SetLossTimer: func(tt logging.TimerType, encLevel protocol.EncryptionLevel, timeout time.Time) {
 			t.SetLossTimer(tt, encLevel, timeout)
@@ -389,14 +389,14 @@ func (t *connectionTracer) UpdatedKey(generation protocol.KeyPhase, remote bool)
 	}
 	now := time.Now()
 	t.recordEvent(now, &eventKeyUpdated{
-		Trigger:    trigger,
-		KeyType:    keyTypeClient1RTT,
-		Generation: generation,
+		Trigger:  trigger,
+		KeyType:  keyTypeClient1RTT,
+		KeyPhase: generation,
 	})
 	t.recordEvent(now, &eventKeyUpdated{
-		Trigger:    trigger,
-		KeyType:    keyTypeServer1RTT,
-		Generation: generation,
+		Trigger:  trigger,
+		KeyType:  keyTypeServer1RTT,
+		KeyPhase: generation,
 	})
 }
 
@@ -413,12 +413,12 @@ func (t *connectionTracer) DroppedEncryptionLevel(encLevel protocol.EncryptionLe
 func (t *connectionTracer) DroppedKey(generation protocol.KeyPhase) {
 	now := time.Now()
 	t.recordEvent(now, &eventKeyDiscarded{
-		KeyType:    encLevelToKeyType(protocol.Encryption1RTT, protocol.PerspectiveServer),
-		Generation: generation,
+		KeyType:  encLevelToKeyType(protocol.Encryption1RTT, protocol.PerspectiveServer),
+		KeyPhase: generation,
 	})
 	t.recordEvent(now, &eventKeyDiscarded{
-		KeyType:    encLevelToKeyType(protocol.Encryption1RTT, protocol.PerspectiveClient),
-		Generation: generation,
+		KeyType:  encLevelToKeyType(protocol.Encryption1RTT, protocol.PerspectiveClient),
+		KeyPhase: generation,
 	})
 }
 
diff --git a/qlog/event.go b/qlog/event.go
index e2ceca9f..d3d26601 100644
--- a/qlog/event.go
+++ b/qlog/event.go
@@ -345,9 +345,9 @@ func (e eventPacketLost) MarshalJSONObject(enc *gojay.Encoder) {
 }
 
 type eventKeyUpdated struct {
-	Trigger    keyUpdateTrigger
-	KeyType    keyType
-	Generation protocol.KeyPhase
+	Trigger  keyUpdateTrigger
+	KeyType  keyType
+	KeyPhase protocol.KeyPhase
 	// we don't log the keys here, so we don't need `old` and `new`.
 }
 
@@ -359,13 +359,13 @@ func (e eventKeyUpdated) MarshalJSONObject(enc *gojay.Encoder) {
 	enc.StringKey("trigger", e.Trigger.String())
 	enc.StringKey("key_type", e.KeyType.String())
 	if e.KeyType == keyTypeClient1RTT || e.KeyType == keyTypeServer1RTT {
-		enc.Uint64Key("generation", uint64(e.Generation))
+		enc.Uint64Key("key_phase", uint64(e.KeyPhase))
 	}
 }
 
 type eventKeyDiscarded struct {
-	KeyType    keyType
-	Generation protocol.KeyPhase
+	KeyType  keyType
+	KeyPhase protocol.KeyPhase
 }
 
 func (e eventKeyDiscarded) Category() category { return categorySecurity }
@@ -378,7 +378,7 @@ func (e eventKeyDiscarded) MarshalJSONObject(enc *gojay.Encoder) {
 	}
 	enc.StringKey("key_type", e.KeyType.String())
 	if e.KeyType == keyTypeClient1RTT || e.KeyType == keyTypeServer1RTT {
-		enc.Uint64Key("generation", uint64(e.Generation))
+		enc.Uint64Key("key_phase", uint64(e.KeyPhase))
 	}
 }
 
diff --git a/server.go b/server.go
index b96e0b8f..b3c85543 100644
--- a/server.go
+++ b/server.go
@@ -26,13 +26,12 @@ type packetHandler interface {
 	handlePacket(receivedPacket)
 	destroy(error)
 	closeWithTransportError(qerr.TransportErrorCode)
-	getPerspective() protocol.Perspective
 }
 
 type packetHandlerManager interface {
 	Get(protocol.ConnectionID) (packetHandler, bool)
 	GetByResetToken(protocol.StatelessResetToken) (packetHandler, bool)
-	AddWithConnID(protocol.ConnectionID, protocol.ConnectionID, func() (packetHandler, bool)) bool
+	AddWithConnID(destConnID, newConnID protocol.ConnectionID, h packetHandler) bool
 	Close(error)
 	connRunner
 }
@@ -41,8 +40,6 @@ type quicConn interface {
 	EarlyConnection
 	earlyConnReady() <-chan struct{}
 	handlePacket(receivedPacket)
-	GetVersion() protocol.Version
-	getPerspective() protocol.Perspective
 	run() error
 	destroy(error)
 	closeWithTransportError(TransportErrorCode)
@@ -101,7 +98,7 @@ type baseServer struct {
 		protocol.Version,
 	) quicConn
 
-	closeOnce sync.Once
+	closeMx   sync.Mutex
 	errorChan chan struct{} // is closed when the server is closed
 	closeErr  error
 	running   chan struct{} // closed as soon as run() returns
@@ -341,15 +338,19 @@ func (s *baseServer) Close() error {
 }
 
 func (s *baseServer) close(e error, notifyOnClose bool) {
-	s.closeOnce.Do(func() {
-		s.closeErr = e
-		close(s.errorChan)
+	s.closeMx.Lock()
+	if s.closeErr != nil {
+		s.closeMx.Unlock()
+		return
+	}
+	s.closeErr = e
+	close(s.errorChan)
+	<-s.running
+	s.closeMx.Unlock()
 
-		<-s.running
-		if notifyOnClose {
-			s.onClose()
-		}
-	})
+	if notifyOnClose {
+		s.onClose()
+	}
 }
 
 // Addr returns the server's network address
@@ -636,63 +637,68 @@ func (s *baseServer) handleInitialImpl(p receivedPacket, hdr *wire.Header) error
 	s.logger.Debugf("Changing connection ID to %s.", connID)
 	var conn quicConn
 	tracingID := nextConnTracingID()
-	if added := s.connHandler.AddWithConnID(hdr.DestConnectionID, connID, func() (packetHandler, bool) {
-		config := s.config
-		if s.config.GetConfigForClient != nil {
-			conf, err := s.config.GetConfigForClient(&ClientHelloInfo{RemoteAddr: p.remoteAddr})
-			if err != nil {
-				s.logger.Debugf("Rejecting new connection due to GetConfigForClient callback")
-				return nil, false
-			}
-			config = populateConfig(conf)
-		}
-		var tracer *logging.ConnectionTracer
-		if config.Tracer != nil {
-			// Use the same connection ID that is passed to the client's GetLogWriter callback.
-			connID := hdr.DestConnectionID
-			if origDestConnID.Len() > 0 {
-				connID = origDestConnID
-			}
-			tracer = config.Tracer(context.WithValue(context.Background(), ConnectionTracingKey, tracingID), protocol.PerspectiveServer, connID)
-		}
-		conn = s.newConn(
-			newSendConn(s.conn, p.remoteAddr, p.info, s.logger),
-			s.connHandler,
-			origDestConnID,
-			retrySrcConnID,
-			hdr.DestConnectionID,
-			hdr.SrcConnectionID,
-			connID,
-			s.connIDGenerator,
-			s.connHandler.GetStatelessResetToken(connID),
-			config,
-			s.tlsConf,
-			s.tokenGenerator,
-			clientAddrValidated,
-			tracer,
-			tracingID,
-			s.logger,
-			hdr.Version,
-		)
-		conn.handlePacket(p)
-
-		if q, ok := s.zeroRTTQueues[hdr.DestConnectionID]; ok {
-			for _, p := range q.packets {
-				conn.handlePacket(p)
-			}
+	config := s.config
+	if s.config.GetConfigForClient != nil {
+		conf, err := s.config.GetConfigForClient(&ClientHelloInfo{RemoteAddr: p.remoteAddr})
+		if err != nil {
+			s.logger.Debugf("Rejecting new connection due to GetConfigForClient callback")
 			delete(s.zeroRTTQueues, hdr.DestConnectionID)
+			select {
+			case s.connectionRefusedQueue <- rejectedPacket{receivedPacket: p, hdr: hdr}:
+			default:
+				// drop packet if we can't send out the CONNECTION_REFUSED fast enough
+				p.buffer.Release()
+			}
+			return nil
 		}
-
-		return conn, true
-	}); !added {
-		select {
-		case s.connectionRefusedQueue <- rejectedPacket{receivedPacket: p, hdr: hdr}:
-		default:
-			// drop packet if we can't send out the CONNECTION_REFUSED fast enough
-			p.buffer.Release()
-		}
+		config = populateConfig(conf)
+	}
+	var tracer *logging.ConnectionTracer
+	if config.Tracer != nil {
+		// Use the same connection ID that is passed to the client's GetLogWriter callback.
+		connID := hdr.DestConnectionID
+		if origDestConnID.Len() > 0 {
+			connID = origDestConnID
+		}
+		tracer = config.Tracer(context.WithValue(context.Background(), ConnectionTracingKey, tracingID), protocol.PerspectiveServer, connID)
+	}
+	conn = s.newConn(
+		newSendConn(s.conn, p.remoteAddr, p.info, s.logger),
+		s.connHandler,
+		origDestConnID,
+		retrySrcConnID,
+		hdr.DestConnectionID,
+		hdr.SrcConnectionID,
+		connID,
+		s.connIDGenerator,
+		s.connHandler.GetStatelessResetToken(connID),
+		config,
+		s.tlsConf,
+		s.tokenGenerator,
+		clientAddrValidated,
+		tracer,
+		tracingID,
+		s.logger,
+		hdr.Version,
+	)
+	conn.handlePacket(p)
+	// Adding the connection will fail if the client's chosen Destination Connection ID is already in use.
+	// This is very unlikely: Even if an attacker chooses a connection ID that's already in use,
+	// under normal circumstances the packet would just be routed to that connection.
+	// The only time this collision will occur if we receive the two Initial packets at the same time.
+	if added := s.connHandler.AddWithConnID(hdr.DestConnectionID, connID, conn); !added {
+		delete(s.zeroRTTQueues, hdr.DestConnectionID)
+		conn.closeWithTransportError(qerr.ConnectionRefused)
 		return nil
 	}
+	// Pass queued 0-RTT to the newly established connection.
+	if q, ok := s.zeroRTTQueues[hdr.DestConnectionID]; ok {
+		for _, p := range q.packets {
+			conn.handlePacket(p)
+		}
+		delete(s.zeroRTTQueues, hdr.DestConnectionID)
+	}
+
 	if clientAddrValidated {
 		s.numHandshakesValidated.Add(1)
 	} else {
diff --git a/sys_conn_oob.go b/sys_conn_oob.go
index 71a037d5..64d581c0 100644
--- a/sys_conn_oob.go
+++ b/sys_conn_oob.go
@@ -221,7 +221,7 @@ func (c *oobConn) ReadPacket() (receivedPacket, error) {
 				// 	unsigned int    ipi6_ifindex; /* send/recv interface index */
 				// };
 				if len(body) == 20 {
-					p.info.addr = netip.AddrFrom16(*(*[16]byte)(body[:16]))
+					p.info.addr = netip.AddrFrom16(*(*[16]byte)(body[:16])).Unmap()
 					p.info.ifIndex = binary.LittleEndian.Uint32(body[16:])
 				} else {
 					invalidCmsgOnceV6.Do(func() {
diff --git a/transport.go b/transport.go
index fdfa1b6b..443d6a97 100644
--- a/transport.go
+++ b/transport.go
@@ -426,13 +426,21 @@ func (t *Transport) handlePacket(p receivedPacket) {
 		return
 	}
 
-	if isStatelessReset := t.maybeHandleStatelessReset(p.data); isStatelessReset {
-		return
-	}
+	// If there's a connection associated with the connection ID, pass the packet there.
 	if handler, ok := t.handlerMap.Get(connID); ok {
 		handler.handlePacket(p)
 		return
 	}
+	// RFC 9000 section 10.3.1 requires that the stateless reset detection logic is run for both
+	// packets that cannot be associated with any connections, and for packets that can't be decrypted.
+	// We deviate from the RFC and ignore the latter: If a packet's connection ID is associated with an
+	// existing connection, it is dropped there if if it can't be decrypted.
+	// Stateless resets use random connection IDs, and at reasonable connection ID lengths collisions are
+	// exceedingly rare. In the unlikely event that a stateless reset is misrouted to an existing connection,
+	// it is to be expected that the next stateless reset will be correctly detected.
+	if isStatelessReset := t.maybeHandleStatelessReset(p.data); isStatelessReset {
+		return
+	}
 	if !wire.IsLongHeaderPacket(p.data[0]) {
 		t.maybeSendStatelessReset(p)
 		return


Test patch:
diff --git a/closed_conn_test.go b/closed_conn_test.go
index 2447a646..4bb59e90 100644
--- a/closed_conn_test.go
+++ b/closed_conn_test.go
@@ -3,7 +3,6 @@ package quic
 import (
 	"net"
 
-	"github.com/quic-go/quic-go/internal/protocol"
 	"github.com/quic-go/quic-go/internal/utils"
 
 	. "github.com/onsi/ginkgo/v2"
@@ -11,18 +10,9 @@ import (
 )
 
 var _ = Describe("Closed local connection", func() {
-	It("tells its perspective", func() {
-		conn := newClosedLocalConn(nil, protocol.PerspectiveClient, utils.DefaultLogger)
-		Expect(conn.getPerspective()).To(Equal(protocol.PerspectiveClient))
-	})
-
 	It("repeats the packet containing the CONNECTION_CLOSE frame", func() {
 		written := make(chan net.Addr, 1)
-		conn := newClosedLocalConn(
-			func(addr net.Addr, _ packetInfo) { written <- addr },
-			protocol.PerspectiveClient,
-			utils.DefaultLogger,
-		)
+		conn := newClosedLocalConn(func(addr net.Addr, _ packetInfo) { written <- addr }, utils.DefaultLogger)
 		addr := &net.UDPAddr{IP: net.IPv4(127, 1, 2, 3), Port: 1337}
 		for i := 1; i <= 20; i++ {
 			conn.handlePacket(receivedPacket{remoteAddr: addr})
diff --git a/conn_id_generator_test.go b/conn_id_generator_test.go
index 2252de84..c438158e 100644
--- a/conn_id_generator_test.go
+++ b/conn_id_generator_test.go
@@ -41,9 +41,7 @@ var _ = Describe("Connection ID Generator", func() {
 			connIDToToken,
 			func(c protocol.ConnectionID) { removedConnIDs = append(removedConnIDs, c) },
 			func(c protocol.ConnectionID) { retiredConnIDs = append(retiredConnIDs, c) },
-			func(cs []protocol.ConnectionID, _ protocol.Perspective, _ []byte) {
-				replacedWithClosed = append(replacedWithClosed, cs...)
-			},
+			func(cs []protocol.ConnectionID, _ []byte) { replacedWithClosed = append(replacedWithClosed, cs...) },
 			func(f wire.Frame) { queuedFrames = append(queuedFrames, f) },
 			&protocol.DefaultConnectionIDGenerator{ConnLen: initialConnID.Len()},
 		)
@@ -177,7 +175,7 @@ var _ = Describe("Connection ID Generator", func() {
 	It("replaces with a closed connection for all connection IDs", func() {
 		Expect(g.SetMaxActiveConnIDs(5)).To(Succeed())
 		Expect(queuedFrames).To(HaveLen(4))
-		g.ReplaceWithClosed(protocol.PerspectiveClient, []byte("foobar"))
+		g.ReplaceWithClosed([]byte("foobar"))
 		Expect(replacedWithClosed).To(HaveLen(6)) // initial conn ID, initial client dest conn id, and newly issued ones
 		Expect(replacedWithClosed).To(ContainElement(initialClientDestConnID))
 		Expect(replacedWithClosed).To(ContainElement(initialConnID))
diff --git a/connection_test.go b/connection_test.go
index eb8d2625..74e79618 100644
--- a/connection_test.go
+++ b/connection_test.go
@@ -76,7 +76,7 @@ var _ = Describe("Connection", func() {
 	}
 
 	expectReplaceWithClosed := func() {
-		connRunner.EXPECT().ReplaceWithClosed(gomock.Any(), gomock.Any(), gomock.Any()).Do(func(connIDs []protocol.ConnectionID, _ protocol.Perspective, _ []byte) {
+		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))
@@ -346,7 +346,7 @@ var _ = Describe("Connection", func() {
 				ErrorMessage: "foobar",
 			}
 			streamManager.EXPECT().CloseWithError(expectedErr)
-			connRunner.EXPECT().ReplaceWithClosed(gomock.Any(), gomock.Any(), gomock.Any()).Do(func(connIDs []protocol.ConnectionID, _ protocol.Perspective, _ []byte) {
+			connRunner.EXPECT().ReplaceWithClosed(gomock.Any(), gomock.Any()).Do(func(connIDs []protocol.ConnectionID, _ []byte) {
 				Expect(connIDs).To(ConsistOf(clientDestConnID, srcConnID))
 			})
 			cryptoSetup.EXPECT().Close()
@@ -375,7 +375,7 @@ var _ = Describe("Connection", func() {
 				ErrorMessage: "foobar",
 			}
 			streamManager.EXPECT().CloseWithError(testErr)
-			connRunner.EXPECT().ReplaceWithClosed(gomock.Any(), gomock.Any(), gomock.Any()).Do(func(connIDs []protocol.ConnectionID, _ protocol.Perspective, _ []byte) {
+			connRunner.EXPECT().ReplaceWithClosed(gomock.Any(), gomock.Any()).Do(func(connIDs []protocol.ConnectionID, _ []byte) {
 				Expect(connIDs).To(ConsistOf(clientDestConnID, srcConnID))
 			})
 			cryptoSetup.EXPECT().Close()
@@ -558,7 +558,7 @@ var _ = Describe("Connection", func() {
 			runConn()
 			cryptoSetup.EXPECT().Close()
 			streamManager.EXPECT().CloseWithError(gomock.Any())
-			connRunner.EXPECT().ReplaceWithClosed(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+			connRunner.EXPECT().ReplaceWithClosed(gomock.Any(), gomock.Any()).AnyTimes()
 			b, err := wire.AppendShortHeader(nil, srcConnID, 42, protocol.PacketNumberLen2, protocol.KeyPhaseOne)
 			Expect(err).ToNot(HaveOccurred())
 
@@ -2594,7 +2594,7 @@ var _ = Describe("Client Connection", func() {
 		// 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(), gomock.Any())
+		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()
@@ -2888,7 +2888,7 @@ var _ = Describe("Client Connection", func() {
 
 		expectClose := func(applicationClose, errored bool) {
 			if !closed && !errored {
-				connRunner.EXPECT().ReplaceWithClosed(gomock.Any(), gomock.Any(), gomock.Any())
+				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 {
diff --git a/http3/client_test.go b/http3/client_test.go
index ed40c4d1..cb63bf34 100644
--- a/http3/client_test.go
+++ b/http3/client_test.go
@@ -15,6 +15,7 @@ import (
 	"github.com/quic-go/quic-go"
 	mockquic "github.com/quic-go/quic-go/internal/mocks/quic"
 	"github.com/quic-go/quic-go/internal/protocol"
+	"github.com/quic-go/quic-go/internal/qerr"
 	"github.com/quic-go/quic-go/internal/utils"
 	"github.com/quic-go/quic-go/quicvarint"
 
@@ -442,7 +443,7 @@ var _ = Describe("Client", func() {
 			conn                 *mockquic.MockEarlyConnection
 			settingsFrameWritten chan struct{}
 		)
-		testDone := make(chan struct{})
+		testDone := make(chan struct{}, 1)
 
 		BeforeEach(func() {
 			settingsFrameWritten = make(chan struct{})
@@ -487,6 +488,35 @@ var _ = Describe("Client", func() {
 			time.Sleep(scaleDuration(20 * time.Millisecond)) // don't EXPECT any calls to conn.CloseWithError
 		})
 
+		It("rejects duplicate control streams", func() {
+			b := quicvarint.Append(nil, streamTypeControlStream)
+			b = (&settingsFrame{}).Append(b)
+			r1 := bytes.NewReader(b)
+			controlStr1 := mockquic.NewMockStream(mockCtrl)
+			controlStr1.EXPECT().Read(gomock.Any()).DoAndReturn(r1.Read).AnyTimes()
+			r2 := bytes.NewReader(b)
+			controlStr2 := mockquic.NewMockStream(mockCtrl)
+			controlStr2.EXPECT().Read(gomock.Any()).DoAndReturn(r2.Read).AnyTimes()
+			done := make(chan struct{})
+			conn.EXPECT().CloseWithError(qerr.ApplicationErrorCode(ErrCodeStreamCreationError), "duplicate control stream").Do(func(qerr.ApplicationErrorCode, string) error {
+				close(done)
+				return nil
+			})
+			conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
+				return controlStr1, nil
+			})
+			conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
+				return controlStr2, nil
+			})
+			conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
+				<-done
+				return nil, errors.New("test done")
+			})
+			_, err := cl.RoundTripOpt(req, RoundTripOpt{})
+			Expect(err).To(HaveOccurred())
+			Eventually(done).Should(BeClosed())
+		})
+
 		for _, t := range []uint64{streamTypeQPACKEncoderStream, streamTypeQPACKDecoderStream} {
 			streamType := t
 			name := "encoder"
diff --git a/http3/frames_test.go b/http3/frames_test.go
index 04e583d2..fbb1d752 100644
--- a/http3/frames_test.go
+++ b/http3/frames_test.go
@@ -127,8 +127,8 @@ var _ = Describe("Frames", func() {
 			}
 		})
 
-		Context("H3_DATAGRAM", func() {
-			It("reads the H3_DATAGRAM value", func() {
+		Context("HTTP Datagrams", func() {
+			It("reads the SETTINGS_H3_DATAGRAM value", func() {
 				settings := quicvarint.Append(nil, settingDatagram)
 				settings = quicvarint.Append(settings, 1)
 				data := quicvarint.Append(nil, 4) // type byte
@@ -141,7 +141,7 @@ var _ = Describe("Frames", func() {
 				Expect(sf.Datagram).To(BeTrue())
 			})
 
-			It("rejects duplicate H3_DATAGRAM entries", func() {
+			It("rejects duplicate SETTINGS_H3_DATAGRAM entries", func() {
 				settings := quicvarint.Append(nil, settingDatagram)
 				settings = quicvarint.Append(settings, 1)
 				settings = quicvarint.Append(settings, settingDatagram)
@@ -153,23 +153,67 @@ var _ = Describe("Frames", func() {
 				Expect(err).To(MatchError(fmt.Sprintf("duplicate setting: %d", settingDatagram)))
 			})
 
-			It("rejects invalid values for the H3_DATAGRAM entry", func() {
+			It("rejects invalid values for the SETTINGS_H3_DATAGRAM entry", func() {
 				settings := quicvarint.Append(nil, settingDatagram)
 				settings = quicvarint.Append(settings, 1337)
 				data := quicvarint.Append(nil, 4) // type byte
 				data = quicvarint.Append(data, uint64(len(settings)))
 				data = append(data, settings...)
 				_, err := parseNextFrame(bytes.NewReader(data), nil)
-				Expect(err).To(MatchError("invalid value for H3_DATAGRAM: 1337"))
+				Expect(err).To(MatchError("invalid value for SETTINGS_H3_DATAGRAM: 1337"))
 			})
 
-			It("writes the H3_DATAGRAM setting", func() {
+			It("writes the SETTINGS_H3_DATAGRAM setting", func() {
 				sf := &settingsFrame{Datagram: true}
 				frame, err := parseNextFrame(bytes.NewReader(sf.Append(nil)), nil)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(frame).To(Equal(sf))
 			})
 		})
+
+		Context("Extended Connect", func() {
+			It("reads the SETTINGS_ENABLE_CONNECT_PROTOCOL value", func() {
+				settings := quicvarint.Append(nil, settingExtendedConnect)
+				settings = quicvarint.Append(settings, 1)
+				data := quicvarint.Append(nil, 4) // type byte
+				data = quicvarint.Append(data, uint64(len(settings)))
+				data = append(data, settings...)
+				f, err := parseNextFrame(bytes.NewReader(data), nil)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(f).To(BeAssignableToTypeOf(&settingsFrame{}))
+				sf := f.(*settingsFrame)
+				Expect(sf.ExtendedConnect).To(BeTrue())
+			})
+
+			It("rejects duplicate SETTINGS_ENABLE_CONNECT_PROTOCOL entries", func() {
+				settings := quicvarint.Append(nil, settingExtendedConnect)
+				settings = quicvarint.Append(settings, 1)
+				settings = quicvarint.Append(settings, settingExtendedConnect)
+				settings = quicvarint.Append(settings, 1)
+				data := quicvarint.Append(nil, 4) // type byte
+				data = quicvarint.Append(data, uint64(len(settings)))
+				data = append(data, settings...)
+				_, err := parseNextFrame(bytes.NewReader(data), nil)
+				Expect(err).To(MatchError(fmt.Sprintf("duplicate setting: %d", settingExtendedConnect)))
+			})
+
+			It("rejects invalid values for the SETTINGS_ENABLE_CONNECT_PROTOCOL entry", func() {
+				settings := quicvarint.Append(nil, settingExtendedConnect)
+				settings = quicvarint.Append(settings, 1337)
+				data := quicvarint.Append(nil, 4) // type byte
+				data = quicvarint.Append(data, uint64(len(settings)))
+				data = append(data, settings...)
+				_, err := parseNextFrame(bytes.NewReader(data), nil)
+				Expect(err).To(MatchError("invalid value for SETTINGS_ENABLE_CONNECT_PROTOCOL: 1337"))
+			})
+
+			It("writes the SETTINGS_ENABLE_CONNECT_PROTOCOL setting", func() {
+				sf := &settingsFrame{ExtendedConnect: true}
+				frame, err := parseNextFrame(bytes.NewReader(sf.Append(nil)), nil)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(frame).To(Equal(sf))
+			})
+		})
 	})
 
 	Context("hijacking", func() {
diff --git a/http3/roundtrip_test.go b/http3/roundtrip_test.go
index 8b59a8bb..1ab767c9 100644
--- a/http3/roundtrip_test.go
+++ b/http3/roundtrip_test.go
@@ -99,6 +99,19 @@ var _ = Describe("RoundTripper", func() {
 			Expect(receivedConfig.HandshakeIdleTimeout).To(Equal(config.HandshakeIdleTimeout))
 		})
 
+		It("requires quic.Config.EnableDatagram if HTTP datagrams are enabled", func() {
+			rt.QuicConfig = &quic.Config{EnableDatagrams: false}
+			rt.Dial = func(_ context.Context, _ string, _ *tls.Config, config *quic.Config) (quic.EarlyConnection, error) {
+				return nil, errors.New("handshake error")
+			}
+			rt.EnableDatagrams = true
+			_, err := rt.RoundTrip(req)
+			Expect(err).To(MatchError("HTTP Datagrams enabled, but QUIC Datagrams disabled"))
+			rt.QuicConfig.EnableDatagrams = true
+			_, err = rt.RoundTrip(req)
+			Expect(err).To(MatchError("handshake error"))
+		})
+
 		It("uses the custom dialer, if provided", func() {
 			var dialed bool
 			dialer := func(_ context.Context, _ string, tlsCfgP *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) {
diff --git a/http3/server_test.go b/http3/server_test.go
index 1e517ff5..072d3e17 100644
--- a/http3/server_test.go
+++ b/http3/server_test.go
@@ -16,6 +16,7 @@ import (
 	"github.com/quic-go/quic-go"
 	mockquic "github.com/quic-go/quic-go/internal/mocks/quic"
 	"github.com/quic-go/quic-go/internal/protocol"
+	"github.com/quic-go/quic-go/internal/qerr"
 	"github.com/quic-go/quic-go/internal/testdata"
 	"github.com/quic-go/quic-go/internal/utils"
 	"github.com/quic-go/quic-go/quicvarint"
@@ -497,7 +498,7 @@ var _ = Describe("Server", func() {
 
 		Context("control stream handling", func() {
 			var conn *mockquic.MockEarlyConnection
-			testDone := make(chan struct{})
+			testDone := make(chan struct{}, 1)
 
 			BeforeEach(func() {
 				conn = mockquic.NewMockEarlyConnection(mockCtrl)
@@ -528,6 +529,34 @@ var _ = Describe("Server", func() {
 				time.Sleep(scaleDuration(20 * time.Millisecond)) // don't EXPECT any calls to conn.CloseWithError
 			})
 
+			It("rejects duplicate control streams", func() {
+				b := quicvarint.Append(nil, streamTypeControlStream)
+				b = (&settingsFrame{}).Append(b)
+				r1 := bytes.NewReader(b)
+				controlStr1 := mockquic.NewMockStream(mockCtrl)
+				controlStr1.EXPECT().Read(gomock.Any()).DoAndReturn(r1.Read).AnyTimes()
+				r2 := bytes.NewReader(b)
+				controlStr2 := mockquic.NewMockStream(mockCtrl)
+				controlStr2.EXPECT().Read(gomock.Any()).DoAndReturn(r2.Read).AnyTimes()
+				done := make(chan struct{})
+				conn.EXPECT().CloseWithError(qerr.ApplicationErrorCode(ErrCodeStreamCreationError), "duplicate control stream").Do(func(qerr.ApplicationErrorCode, string) error {
+					close(done)
+					return nil
+				})
+				conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
+					return controlStr1, nil
+				})
+				conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
+					return controlStr2, nil
+				})
+				conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
+					<-done
+					return nil, errors.New("test done")
+				})
+				s.handleConn(conn)
+				Eventually(done).Should(BeClosed())
+			})
+
 			for _, t := range []uint64{streamTypeQPACKEncoderStream, streamTypeQPACKDecoderStream} {
 				streamType := t
 				name := "encoder"
diff --git a/internal/handshake/aead_test.go b/internal/handshake/aead_test.go
index 4527e3e1..d2d58a02 100644
--- a/internal/handshake/aead_test.go
+++ b/internal/handshake/aead_test.go
@@ -33,8 +33,8 @@ var _ = Describe("Long Header AEAD", func() {
 						aead, err := cipher.NewGCM(block)
 						Expect(err).ToNot(HaveOccurred())
 
-						return newLongHeaderSealer(aead, newHeaderProtector(cs, hpKey, true, v)),
-							newLongHeaderOpener(aead, newHeaderProtector(cs, hpKey, true, v))
+						return newLongHeaderSealer(&xorNonceAEAD{aead: aead}, newHeaderProtector(cs, hpKey, true, v)),
+							newLongHeaderOpener(&xorNonceAEAD{aead: aead}, newHeaderProtector(cs, hpKey, true, v))
 					}
 
 					Context("message encryption", func() {
diff --git a/internal/handshake/hkdf_test.go b/internal/handshake/hkdf_test.go
index e79a6f1e..1cc16129 100644
--- a/internal/handshake/hkdf_test.go
+++ b/internal/handshake/hkdf_test.go
@@ -2,16 +2,67 @@ package handshake
 
 import (
 	"crypto"
+	"crypto/cipher"
+	"crypto/tls"
+	"testing"
+	_ "unsafe"
+
+	"golang.org/x/exp/rand"
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
 )
 
-var _ = Describe("Initial AEAD using AES-GCM", func() {
-	// Result generated by running in qtls:
-	// cipherSuiteTLS13ByID(TLS_AES_128_GCM_SHA256).expandLabel([]byte("secret"), []byte("context"), "label", 42)
-	It("gets the same results as qtls", func() {
-		expanded := hkdfExpandLabel(crypto.SHA256, []byte("secret"), []byte("context"), "label", 42)
-		Expect(expanded).To(Equal([]byte{0x78, 0x87, 0x6a, 0xb5, 0x84, 0xa2, 0x26, 0xb7, 0x8, 0x5a, 0x7b, 0x3a, 0x4c, 0xbb, 0x1e, 0xbc, 0x2f, 0x9b, 0x67, 0xd0, 0x6a, 0xa2, 0x24, 0xb4, 0x7d, 0x29, 0x3c, 0x7a, 0xce, 0xc7, 0xc3, 0x74, 0xcd, 0x59, 0x7a, 0xa8, 0x21, 0x5e, 0xe7, 0xca, 0x1, 0xda}))
-	})
+type cipherSuiteTLS13 struct {
+	ID     uint16
+	KeyLen int
+	AEAD   func(key, fixedNonce []byte) cipher.AEAD
+	Hash   crypto.Hash
+}
+
+//go:linkname cipherSuiteTLS13ByID crypto/tls.cipherSuiteTLS13ByID
+func cipherSuiteTLS13ByID(id uint16) *cipherSuiteTLS13
+
+//go:linkname expandLabel crypto/tls.(*cipherSuiteTLS13).expandLabel
+func expandLabel(cs *cipherSuiteTLS13, secret []byte, label string, context []byte, length int) []byte
+
+var _ = Describe("HKDF", func() {
+	DescribeTable("gets the same results as crypto/tls",
+		func(cipherSuite uint16, secret, context []byte, label string, length int) {
+			cs := cipherSuiteTLS13ByID(cipherSuite)
+			expected := expandLabel(cs, secret, label, context, length)
+			expanded := hkdfExpandLabel(cs.Hash, secret, context, label, length)
+			Expect(expanded).To(Equal(expected))
+		},
+		Entry("TLS_AES_128_GCM_SHA256", tls.TLS_AES_128_GCM_SHA256, []byte("secret"), []byte("context"), "label", 42),
+		Entry("TLS_AES_256_GCM_SHA384", tls.TLS_AES_256_GCM_SHA384, []byte("secret"), []byte("context"), "label", 100),
+		Entry("TLS_CHACHA20_POLY1305_SHA256", tls.TLS_CHACHA20_POLY1305_SHA256, []byte("secret"), []byte("context"), "label", 77),
+	)
 })
+
+func BenchmarkHKDFExpandLabelStandardLibrary(b *testing.B) {
+	b.Run("TLS_AES_128_GCM_SHA256", func(b *testing.B) { benchmarkHKDFExpandLabel(b, tls.TLS_AES_128_GCM_SHA256, true) })
+	b.Run("TLS_AES_256_GCM_SHA384", func(b *testing.B) { benchmarkHKDFExpandLabel(b, tls.TLS_AES_256_GCM_SHA384, true) })
+	b.Run("TLS_CHACHA20_POLY1305_SHA256", func(b *testing.B) { benchmarkHKDFExpandLabel(b, tls.TLS_CHACHA20_POLY1305_SHA256, true) })
+}
+
+func BenchmarkHKDFExpandLabelOptimized(b *testing.B) {
+	b.Run("TLS_AES_128_GCM_SHA256", func(b *testing.B) { benchmarkHKDFExpandLabel(b, tls.TLS_AES_128_GCM_SHA256, false) })
+	b.Run("TLS_AES_256_GCM_SHA384", func(b *testing.B) { benchmarkHKDFExpandLabel(b, tls.TLS_AES_256_GCM_SHA384, false) })
+	b.Run("TLS_CHACHA20_POLY1305_SHA256", func(b *testing.B) { benchmarkHKDFExpandLabel(b, tls.TLS_CHACHA20_POLY1305_SHA256, false) })
+}
+
+func benchmarkHKDFExpandLabel(b *testing.B, cipherSuite uint16, useStdLib bool) {
+	b.ReportAllocs()
+	cs := cipherSuiteTLS13ByID(cipherSuite)
+	secret := make([]byte, 32)
+	rand.Read(secret)
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		if useStdLib {
+			expandLabel(cs, secret, "label", []byte("context"), 42)
+		} else {
+			hkdfExpandLabel(cs.Hash, secret, []byte("context"), "label", 42)
+		}
+	}
+}
diff --git a/internal/handshake/initial_aead_test.go b/internal/handshake/initial_aead_test.go
index 2ce3497f..57388a4a 100644
--- a/internal/handshake/initial_aead_test.go
+++ b/internal/handshake/initial_aead_test.go
@@ -1,8 +1,11 @@
 package handshake
 
 import (
-	"crypto/rand"
+	"bytes"
 	"fmt"
+	"testing"
+
+	"golang.org/x/exp/rand"
 
 	"github.com/quic-go/quic-go/internal/protocol"
 
@@ -187,3 +190,59 @@ var _ = Describe("Initial AEAD using AES-GCM", func() {
 		})
 	}
 })
+
+func BenchmarkInitialAEADCreate(b *testing.B) {
+	b.ReportAllocs()
+	connID := protocol.ParseConnectionID([]byte{0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef})
+	for i := 0; i < b.N; i++ {
+		NewInitialAEAD(connID, protocol.PerspectiveServer, protocol.Version1)
+	}
+}
+
+func BenchmarkInitialAEAD(b *testing.B) {
+	connectionID := protocol.ParseConnectionID([]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 0xa, 0xb, 0xc, 0xd})
+	clientSealer, _ := NewInitialAEAD(connectionID, protocol.PerspectiveClient, protocol.Version1)
+	_, serverOpener := NewInitialAEAD(connectionID, protocol.PerspectiveServer, protocol.Version1)
+
+	r := rand.New(rand.NewSource(1))
+	packetData := make([]byte, 1200)
+	r.Read(packetData)
+	hdr := make([]byte, 50)
+	r.Read(hdr)
+	msg := clientSealer.Seal(nil, packetData, 42, hdr)
+	m, err := serverOpener.Open(nil, msg, 42, hdr)
+	if err != nil {
+		b.Fatalf("opening failed: %s", err)
+	}
+	if !bytes.Equal(m, packetData) {
+		b.Fatal("decrypted data doesn't match")
+	}
+
+	b.Run("opening 100 bytes", func(b *testing.B) {
+		benchmarkOpen(b, serverOpener, clientSealer.Seal(nil, packetData[:100], 42, hdr), hdr)
+	})
+	b.Run("opening 1200 bytes", func(b *testing.B) { benchmarkOpen(b, serverOpener, msg, hdr) })
+
+	b.Run("sealing 100 bytes", func(b *testing.B) { benchmarkSeal(b, clientSealer, packetData[:100], hdr) })
+	b.Run("sealing 1200 bytes", func(b *testing.B) { benchmarkSeal(b, clientSealer, packetData, hdr) })
+}
+
+func benchmarkOpen(b *testing.B, aead LongHeaderOpener, msg, hdr []byte) {
+	b.ReportAllocs()
+	dst := make([]byte, 0, 1500)
+	for i := 0; i < b.N; i++ {
+		dst = dst[:0]
+		if _, err := aead.Open(dst, msg, 42, hdr); err != nil {
+			b.Fatalf("opening failed: %s", err)
+		}
+	}
+}
+
+func benchmarkSeal(b *testing.B, aead LongHeaderSealer, msg, hdr []byte) {
+	b.ReportAllocs()
+	dst := make([]byte, 0, 1500)
+	for i := 0; i < b.N; i++ {
+		dst = dst[:0]
+		aead.Seal(dst, msg, protocol.PacketNumber(i), hdr)
+	}
+}
diff --git a/internal/qtls/qtls_test.go b/internal/qtls/qtls_test.go
index aadc118c..1007b82a 100644
--- a/internal/qtls/qtls_test.go
+++ b/internal/qtls/qtls_test.go
@@ -9,7 +9,7 @@ import (
 	. "github.com/onsi/gomega"
 )
 
-var _ = Describe("Go 1.21", func() {
+var _ = Describe("interface go crypto/tls", func() {
 	It("converts to tls.EncryptionLevel", func() {
 		Expect(ToTLSEncryptionLevel(protocol.EncryptionInitial)).To(Equal(tls.QUICEncryptionLevelInitial))
 		Expect(ToTLSEncryptionLevel(protocol.EncryptionHandshake)).To(Equal(tls.QUICEncryptionLevelHandshake))
diff --git a/mock_conn_runner_test.go b/mock_conn_runner_test.go
index b1fd19f4..cc119ddf 100644
--- a/mock_conn_runner_test.go
+++ b/mock_conn_runner_test.go
@@ -223,15 +223,15 @@ func (c *ConnRunnerRemoveResetTokenCall) DoAndReturn(f func(protocol.StatelessRe
 }
 
 // ReplaceWithClosed mocks base method.
-func (m *MockConnRunner) ReplaceWithClosed(arg0 []protocol.ConnectionID, arg1 protocol.Perspective, arg2 []byte) {
+func (m *MockConnRunner) ReplaceWithClosed(arg0 []protocol.ConnectionID, arg1 []byte) {
 	m.ctrl.T.Helper()
-	m.ctrl.Call(m, "ReplaceWithClosed", arg0, arg1, arg2)
+	m.ctrl.Call(m, "ReplaceWithClosed", arg0, arg1)
 }
 
 // ReplaceWithClosed indicates an expected call of ReplaceWithClosed.
-func (mr *MockConnRunnerMockRecorder) ReplaceWithClosed(arg0, arg1, arg2 any) *ConnRunnerReplaceWithClosedCall {
+func (mr *MockConnRunnerMockRecorder) ReplaceWithClosed(arg0, arg1 any) *ConnRunnerReplaceWithClosedCall {
 	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReplaceWithClosed", reflect.TypeOf((*MockConnRunner)(nil).ReplaceWithClosed), arg0, arg1, arg2)
+	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReplaceWithClosed", reflect.TypeOf((*MockConnRunner)(nil).ReplaceWithClosed), arg0, arg1)
 	return &ConnRunnerReplaceWithClosedCall{Call: call}
 }
 
@@ -247,13 +247,13 @@ func (c *ConnRunnerReplaceWithClosedCall) Return() *ConnRunnerReplaceWithClosedC
 }
 
 // Do rewrite *gomock.Call.Do
-func (c *ConnRunnerReplaceWithClosedCall) Do(f func([]protocol.ConnectionID, protocol.Perspective, []byte)) *ConnRunnerReplaceWithClosedCall {
+func (c *ConnRunnerReplaceWithClosedCall) Do(f func([]protocol.ConnectionID, []byte)) *ConnRunnerReplaceWithClosedCall {
 	c.Call = c.Call.Do(f)
 	return c
 }
 
 // DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *ConnRunnerReplaceWithClosedCall) DoAndReturn(f func([]protocol.ConnectionID, protocol.Perspective, []byte)) *ConnRunnerReplaceWithClosedCall {
+func (c *ConnRunnerReplaceWithClosedCall) DoAndReturn(f func([]protocol.ConnectionID, []byte)) *ConnRunnerReplaceWithClosedCall {
 	c.Call = c.Call.DoAndReturn(f)
 	return c
 }
diff --git a/mock_packet_handler_manager_test.go b/mock_packet_handler_manager_test.go
index e3f1e00c..6170981f 100644
--- a/mock_packet_handler_manager_test.go
+++ b/mock_packet_handler_manager_test.go
@@ -113,7 +113,7 @@ func (c *PacketHandlerManagerAddResetTokenCall) DoAndReturn(f func(protocol.Stat
 }
 
 // AddWithConnID mocks base method.
-func (m *MockPacketHandlerManager) AddWithConnID(arg0, arg1 protocol.ConnectionID, arg2 func() (packetHandler, bool)) bool {
+func (m *MockPacketHandlerManager) AddWithConnID(arg0, arg1 protocol.ConnectionID, arg2 packetHandler) bool {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "AddWithConnID", arg0, arg1, arg2)
 	ret0, _ := ret[0].(bool)
@@ -139,13 +139,13 @@ func (c *PacketHandlerManagerAddWithConnIDCall) Return(arg0 bool) *PacketHandler
 }
 
 // Do rewrite *gomock.Call.Do
-func (c *PacketHandlerManagerAddWithConnIDCall) Do(f func(protocol.ConnectionID, protocol.ConnectionID, func() (packetHandler, bool)) bool) *PacketHandlerManagerAddWithConnIDCall {
+func (c *PacketHandlerManagerAddWithConnIDCall) Do(f func(protocol.ConnectionID, protocol.ConnectionID, packetHandler) bool) *PacketHandlerManagerAddWithConnIDCall {
 	c.Call = c.Call.Do(f)
 	return c
 }
 
 // DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *PacketHandlerManagerAddWithConnIDCall) DoAndReturn(f func(protocol.ConnectionID, protocol.ConnectionID, func() (packetHandler, bool)) bool) *PacketHandlerManagerAddWithConnIDCall {
+func (c *PacketHandlerManagerAddWithConnIDCall) DoAndReturn(f func(protocol.ConnectionID, protocol.ConnectionID, packetHandler) bool) *PacketHandlerManagerAddWithConnIDCall {
 	c.Call = c.Call.DoAndReturn(f)
 	return c
 }
@@ -375,15 +375,15 @@ func (c *PacketHandlerManagerRemoveResetTokenCall) DoAndReturn(f func(protocol.S
 }
 
 // ReplaceWithClosed mocks base method.
-func (m *MockPacketHandlerManager) ReplaceWithClosed(arg0 []protocol.ConnectionID, arg1 protocol.Perspective, arg2 []byte) {
+func (m *MockPacketHandlerManager) ReplaceWithClosed(arg0 []protocol.ConnectionID, arg1 []byte) {
 	m.ctrl.T.Helper()
-	m.ctrl.Call(m, "ReplaceWithClosed", arg0, arg1, arg2)
+	m.ctrl.Call(m, "ReplaceWithClosed", arg0, arg1)
 }
 
 // ReplaceWithClosed indicates an expected call of ReplaceWithClosed.
-func (mr *MockPacketHandlerManagerMockRecorder) ReplaceWithClosed(arg0, arg1, arg2 any) *PacketHandlerManagerReplaceWithClosedCall {
+func (mr *MockPacketHandlerManagerMockRecorder) ReplaceWithClosed(arg0, arg1 any) *PacketHandlerManagerReplaceWithClosedCall {
 	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReplaceWithClosed", reflect.TypeOf((*MockPacketHandlerManager)(nil).ReplaceWithClosed), arg0, arg1, arg2)
+	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReplaceWithClosed", reflect.TypeOf((*MockPacketHandlerManager)(nil).ReplaceWithClosed), arg0, arg1)
 	return &PacketHandlerManagerReplaceWithClosedCall{Call: call}
 }
 
@@ -399,13 +399,13 @@ func (c *PacketHandlerManagerReplaceWithClosedCall) Return() *PacketHandlerManag
 }
 
 // Do rewrite *gomock.Call.Do
-func (c *PacketHandlerManagerReplaceWithClosedCall) Do(f func([]protocol.ConnectionID, protocol.Perspective, []byte)) *PacketHandlerManagerReplaceWithClosedCall {
+func (c *PacketHandlerManagerReplaceWithClosedCall) Do(f func([]protocol.ConnectionID, []byte)) *PacketHandlerManagerReplaceWithClosedCall {
 	c.Call = c.Call.Do(f)
 	return c
 }
 
 // DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *PacketHandlerManagerReplaceWithClosedCall) DoAndReturn(f func([]protocol.ConnectionID, protocol.Perspective, []byte)) *PacketHandlerManagerReplaceWithClosedCall {
+func (c *PacketHandlerManagerReplaceWithClosedCall) DoAndReturn(f func([]protocol.ConnectionID, []byte)) *PacketHandlerManagerReplaceWithClosedCall {
 	c.Call = c.Call.DoAndReturn(f)
 	return c
 }
diff --git a/mock_packet_handler_test.go b/mock_packet_handler_test.go
index a031c1f5..dec5937b 100644
--- a/mock_packet_handler_test.go
+++ b/mock_packet_handler_test.go
@@ -11,7 +11,6 @@ package quic
 import (
 	reflect "reflect"
 
-	protocol "github.com/quic-go/quic-go/internal/protocol"
 	qerr "github.com/quic-go/quic-go/internal/qerr"
 	gomock "go.uber.org/mock/gomock"
 )
@@ -111,44 +110,6 @@ func (c *PacketHandlerdestroyCall) DoAndReturn(f func(error)) *PacketHandlerdest
 	return c
 }
 
-// getPerspective mocks base method.
-func (m *MockPacketHandler) getPerspective() protocol.Perspective {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "getPerspective")
-	ret0, _ := ret[0].(protocol.Perspective)
-	return ret0
-}
-
-// getPerspective indicates an expected call of getPerspective.
-func (mr *MockPacketHandlerMockRecorder) getPerspective() *PacketHandlergetPerspectiveCall {
-	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "getPerspective", reflect.TypeOf((*MockPacketHandler)(nil).getPerspective))
-	return &PacketHandlergetPerspectiveCall{Call: call}
-}
-
-// PacketHandlergetPerspectiveCall wrap *gomock.Call
-type PacketHandlergetPerspectiveCall struct {
-	*gomock.Call
-}
-
-// Return rewrite *gomock.Call.Return
-func (c *PacketHandlergetPerspectiveCall) Return(arg0 protocol.Perspective) *PacketHandlergetPerspectiveCall {
-	c.Call = c.Call.Return(arg0)
-	return c
-}
-
-// Do rewrite *gomock.Call.Do
-func (c *PacketHandlergetPerspectiveCall) Do(f func() protocol.Perspective) *PacketHandlergetPerspectiveCall {
-	c.Call = c.Call.Do(f)
-	return c
-}
-
-// DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *PacketHandlergetPerspectiveCall) DoAndReturn(f func() protocol.Perspective) *PacketHandlergetPerspectiveCall {
-	c.Call = c.Call.DoAndReturn(f)
-	return c
-}
-
 // handlePacket mocks base method.
 func (m *MockPacketHandler) handlePacket(arg0 receivedPacket) {
 	m.ctrl.T.Helper()
diff --git a/mock_quic_conn_test.go b/mock_quic_conn_test.go
index 332f8e75..41fcadac 100644
--- a/mock_quic_conn_test.go
+++ b/mock_quic_conn_test.go
@@ -13,7 +13,6 @@ import (
 	net "net"
 	reflect "reflect"
 
-	protocol "github.com/quic-go/quic-go/internal/protocol"
 	qerr "github.com/quic-go/quic-go/internal/qerr"
 	gomock "go.uber.org/mock/gomock"
 )
@@ -233,44 +232,6 @@ func (c *QUICConnContextCall) DoAndReturn(f func() context.Context) *QUICConnCon
 	return c
 }
 
-// GetVersion mocks base method.
-func (m *MockQUICConn) GetVersion() protocol.Version {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "GetVersion")
-	ret0, _ := ret[0].(protocol.Version)
-	return ret0
-}
-
-// GetVersion indicates an expected call of GetVersion.
-func (mr *MockQUICConnMockRecorder) GetVersion() *QUICConnGetVersionCall {
-	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetVersion", reflect.TypeOf((*MockQUICConn)(nil).GetVersion))
-	return &QUICConnGetVersionCall{Call: call}
-}
-
-// QUICConnGetVersionCall wrap *gomock.Call
-type QUICConnGetVersionCall struct {
-	*gomock.Call
-}
-
-// Return rewrite *gomock.Call.Return
-func (c *QUICConnGetVersionCall) Return(arg0 protocol.Version) *QUICConnGetVersionCall {
-	c.Call = c.Call.Return(arg0)
-	return c
-}
-
-// Do rewrite *gomock.Call.Do
-func (c *QUICConnGetVersionCall) Do(f func() protocol.Version) *QUICConnGetVersionCall {
-	c.Call = c.Call.Do(f)
-	return c
-}
-
-// DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *QUICConnGetVersionCall) DoAndReturn(f func() protocol.Version) *QUICConnGetVersionCall {
-	c.Call = c.Call.DoAndReturn(f)
-	return c
-}
-
 // HandshakeComplete mocks base method.
 func (m *MockQUICConn) HandshakeComplete() <-chan struct{} {
 	m.ctrl.T.Helper()
@@ -766,44 +727,6 @@ func (c *QUICConnearlyConnReadyCall) DoAndReturn(f func() <-chan struct{}) *QUIC
 	return c
 }
 
-// getPerspective mocks base method.
-func (m *MockQUICConn) getPerspective() protocol.Perspective {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "getPerspective")
-	ret0, _ := ret[0].(protocol.Perspective)
-	return ret0
-}
-
-// getPerspective indicates an expected call of getPerspective.
-func (mr *MockQUICConnMockRecorder) getPerspective() *QUICConngetPerspectiveCall {
-	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "getPerspective", reflect.TypeOf((*MockQUICConn)(nil).getPerspective))
-	return &QUICConngetPerspectiveCall{Call: call}
-}
-
-// QUICConngetPerspectiveCall wrap *gomock.Call
-type QUICConngetPerspectiveCall struct {
-	*gomock.Call
-}
-
-// Return rewrite *gomock.Call.Return
-func (c *QUICConngetPerspectiveCall) Return(arg0 protocol.Perspective) *QUICConngetPerspectiveCall {
-	c.Call = c.Call.Return(arg0)
-	return c
-}
-
-// Do rewrite *gomock.Call.Do
-func (c *QUICConngetPerspectiveCall) Do(f func() protocol.Perspective) *QUICConngetPerspectiveCall {
-	c.Call = c.Call.Do(f)
-	return c
-}
-
-// DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *QUICConngetPerspectiveCall) DoAndReturn(f func() protocol.Perspective) *QUICConngetPerspectiveCall {
-	c.Call = c.Call.DoAndReturn(f)
-	return c
-}
-
 // handlePacket mocks base method.
 func (m *MockQUICConn) handlePacket(arg0 receivedPacket) {
 	m.ctrl.T.Helper()
diff --git a/packet_handler_map_test.go b/packet_handler_map_test.go
index d40c395f..d41108cd 100644
--- a/packet_handler_map_test.go
+++ b/packet_handler_map_test.go
@@ -59,18 +59,12 @@ var _ = Describe("Packet Handler Map", func() {
 
 	It("adds newly to-be-constructed handlers", func() {
 		m := newPacketHandlerMap(nil, nil, utils.DefaultLogger)
-		var called bool
 		connID1 := protocol.ParseConnectionID([]byte{1, 2, 3, 4})
 		connID2 := protocol.ParseConnectionID([]byte{4, 3, 2, 1})
-		Expect(m.AddWithConnID(connID1, connID2, func() (packetHandler, bool) {
-			called = true
-			return NewMockPacketHandler(mockCtrl), true
-		})).To(BeTrue())
-		Expect(called).To(BeTrue())
-		Expect(m.AddWithConnID(connID1, protocol.ParseConnectionID([]byte{1, 2, 3}), func() (packetHandler, bool) {
-			Fail("didn't expect the constructor to be executed")
-			return nil, false
-		})).To(BeFalse())
+		h := NewMockPacketHandler(mockCtrl)
+		Expect(m.AddWithConnID(connID1, connID2, h)).To(BeTrue())
+		// collision of the destination connection ID, this handler should not be added
+		Expect(m.AddWithConnID(connID1, protocol.ParseConnectionID([]byte{1, 2, 3}), nil)).To(BeFalse())
 	})
 
 	It("adds, gets and removes reset tokens", func() {
@@ -124,7 +118,7 @@ var _ = Describe("Packet Handler Map", func() {
 		handler := NewMockPacketHandler(mockCtrl)
 		connID := protocol.ParseConnectionID([]byte{4, 3, 2, 1})
 		Expect(m.Add(connID, handler)).To(BeTrue())
-		m.ReplaceWithClosed([]protocol.ConnectionID{connID}, protocol.PerspectiveClient, []byte("foobar"))
+		m.ReplaceWithClosed([]protocol.ConnectionID{connID}, []byte("foobar"))
 		h, ok := m.Get(connID)
 		Expect(ok).To(BeTrue())
 		Expect(h).ToNot(Equal(handler))
@@ -147,7 +141,7 @@ var _ = Describe("Packet Handler Map", func() {
 		handler := NewMockPacketHandler(mockCtrl)
 		connID := protocol.ParseConnectionID([]byte{4, 3, 2, 1})
 		Expect(m.Add(connID, handler)).To(BeTrue())
-		m.ReplaceWithClosed([]protocol.ConnectionID{connID}, protocol.PerspectiveClient, nil)
+		m.ReplaceWithClosed([]protocol.ConnectionID{connID}, nil)
 		h, ok := m.Get(connID)
 		Expect(ok).To(BeTrue())
 		Expect(h).ToNot(Equal(handler))
diff --git a/qlog/connection_tracer_test.go b/qlog/connection_tracer_test.go
index 1e879ad9..dd4b800f 100644
--- a/qlog/connection_tracer_test.go
+++ b/qlog/connection_tracer_test.go
@@ -722,7 +722,7 @@ var _ = Describe("Tracing", func() {
 			ev := entry.Event
 			Expect(ev).To(HaveKeyWithValue("key_type", "client_handshake_secret"))
 			Expect(ev).To(HaveKeyWithValue("trigger", "tls"))
-			Expect(ev).ToNot(HaveKey("generation"))
+			Expect(ev).ToNot(HaveKey("key_phase"))
 			Expect(ev).ToNot(HaveKey("old"))
 			Expect(ev).ToNot(HaveKey("new"))
 		})
@@ -735,7 +735,7 @@ var _ = Describe("Tracing", func() {
 			ev := entry.Event
 			Expect(ev).To(HaveKeyWithValue("key_type", "server_1rtt_secret"))
 			Expect(ev).To(HaveKeyWithValue("trigger", "tls"))
-			Expect(ev).To(HaveKeyWithValue("generation", float64(0)))
+			Expect(ev).To(HaveKeyWithValue("key_phase", float64(0)))
 			Expect(ev).ToNot(HaveKey("old"))
 			Expect(ev).ToNot(HaveKey("new"))
 		})
@@ -749,7 +749,7 @@ var _ = Describe("Tracing", func() {
 				Expect(entry.Time).To(BeTemporally("~", time.Now(), scaleDuration(10*time.Millisecond)))
 				Expect(entry.Name).To(Equal("security:key_updated"))
 				ev := entry.Event
-				Expect(ev).To(HaveKeyWithValue("generation", float64(1337)))
+				Expect(ev).To(HaveKeyWithValue("key_phase", float64(1337)))
 				Expect(ev).To(HaveKeyWithValue("trigger", "remote_update"))
 				Expect(ev).To(HaveKey("key_type"))
 				keyTypes = append(keyTypes, ev["key_type"].(string))
@@ -796,7 +796,7 @@ var _ = Describe("Tracing", func() {
 				Expect(entry.Time).To(BeTemporally("~", time.Now(), scaleDuration(10*time.Millisecond)))
 				Expect(entry.Name).To(Equal("security:key_discarded"))
 				ev := entry.Event
-				Expect(ev).To(HaveKeyWithValue("generation", float64(42)))
+				Expect(ev).To(HaveKeyWithValue("key_phase", float64(42)))
 				Expect(ev).ToNot(HaveKey("trigger"))
 				Expect(ev).To(HaveKey("key_type"))
 				keyTypes = append(keyTypes, ev["key_type"].(string))
diff --git a/server_test.go b/server_test.go
index cbc16c19..ee0ee8d2 100644
--- a/server_test.go
+++ b/server_test.go
@@ -282,17 +282,6 @@ var _ = Describe("Server", func() {
 				rand.Read(token[:])
 
 				var newConnID protocol.ConnectionID
-
-				phm.EXPECT().Get(connID)
-				phm.EXPECT().AddWithConnID(connID, gomock.Any(), gomock.Any()).DoAndReturn(func(_, c protocol.ConnectionID, fn func() (packetHandler, bool)) bool {
-					newConnID = c
-					phm.EXPECT().GetStatelessResetToken(gomock.Any()).DoAndReturn(func(c protocol.ConnectionID) protocol.StatelessResetToken {
-						newConnID = c
-						return token
-					})
-					_, ok := fn()
-					return ok
-				})
 				conn := NewMockQUICConn(mockCtrl)
 				serv.newConn = func(
 					_ sendConn,
@@ -320,7 +309,7 @@ var _ = Describe("Server", func() {
 					// make sure we're using a server-generated connection ID
 					Expect(srcConnID).ToNot(Equal(hdr.DestConnectionID))
 					Expect(srcConnID).ToNot(Equal(hdr.SrcConnectionID))
-					Expect(srcConnID).To(Equal(newConnID))
+					newConnID = srcConnID
 					Expect(tokenP).To(Equal(token))
 					conn.EXPECT().handlePacket(p)
 					conn.EXPECT().run().Do(func() error { close(run); return nil })
@@ -328,6 +317,12 @@ var _ = Describe("Server", func() {
 					conn.EXPECT().HandshakeComplete().Return(make(chan struct{}))
 					return conn
 				}
+				phm.EXPECT().Get(connID)
+				phm.EXPECT().GetStatelessResetToken(gomock.Any()).Return(token)
+				phm.EXPECT().AddWithConnID(connID, gomock.Any(), gomock.Any()).DoAndReturn(func(_, cid protocol.ConnectionID, h packetHandler) bool {
+					Expect(cid).To(Equal(newConnID))
+					return true
+				})
 
 				done := make(chan struct{})
 				go func() {
@@ -483,19 +478,6 @@ var _ = Describe("Server", func() {
 				rand.Read(token[:])
 
 				var newConnID protocol.ConnectionID
-				gomock.InOrder(
-					phm.EXPECT().Get(connID),
-					phm.EXPECT().AddWithConnID(protocol.ParseConnectionID([]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}), gomock.Any(), gomock.Any()).DoAndReturn(func(_, c protocol.ConnectionID, fn func() (packetHandler, bool)) bool {
-						newConnID = c
-						phm.EXPECT().GetStatelessResetToken(gomock.Any()).DoAndReturn(func(c protocol.ConnectionID) protocol.StatelessResetToken {
-							newConnID = c
-							return token
-						})
-						_, ok := fn()
-						return ok
-					}),
-				)
-
 				conn := NewMockQUICConn(mockCtrl)
 				serv.newConn = func(
 					_ sendConn,
@@ -523,7 +505,7 @@ var _ = Describe("Server", func() {
 					// make sure we're using a server-generated connection ID
 					Expect(srcConnID).ToNot(Equal(hdr.DestConnectionID))
 					Expect(srcConnID).ToNot(Equal(hdr.SrcConnectionID))
-					Expect(srcConnID).To(Equal(newConnID))
+					newConnID = srcConnID
 					Expect(tokenP).To(Equal(token))
 					conn.EXPECT().handlePacket(p)
 					conn.EXPECT().run().Do(func() error { close(run); return nil })
@@ -531,6 +513,14 @@ var _ = Describe("Server", func() {
 					conn.EXPECT().HandshakeComplete().Return(make(chan struct{}))
 					return conn
 				}
+				gomock.InOrder(
+					phm.EXPECT().Get(connID),
+					phm.EXPECT().GetStatelessResetToken(gomock.Any()).Return(token),
+					phm.EXPECT().AddWithConnID(connID, gomock.Any(), gomock.Any()).DoAndReturn(func(_, c protocol.ConnectionID, h packetHandler) bool {
+						Expect(c).To(Equal(newConnID))
+						return true
+					}),
+				)
 
 				done := make(chan struct{})
 				go func() {
@@ -553,11 +543,8 @@ var _ = Describe("Server", func() {
 				serv.maxNumHandshakesUnvalidated = 10000
 
 				phm.EXPECT().Get(gomock.Any()).AnyTimes()
-				phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_, _ protocol.ConnectionID, fn func() (packetHandler, bool)) bool {
-					phm.EXPECT().GetStatelessResetToken(gomock.Any())
-					_, ok := fn()
-					return ok
-				}).AnyTimes()
+				phm.EXPECT().GetStatelessResetToken(gomock.Any()).AnyTimes()
+				phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).Return(true).AnyTimes()
 
 				acceptConn := make(chan struct{})
 				var counter atomic.Uint32
@@ -615,7 +602,7 @@ var _ = Describe("Server", func() {
 			})
 
 			It("only creates a single connection for a duplicate Initial", func() {
-				var createdConn bool
+				done := make(chan struct{})
 				serv.newConn = func(
 					_ sendConn,
 					runner connRunner,
@@ -635,19 +622,20 @@ var _ = Describe("Server", func() {
 					_ utils.Logger,
 					_ protocol.Version,
 				) quicConn {
-					createdConn = true
-					return NewMockQUICConn(mockCtrl)
+					conn := NewMockQUICConn(mockCtrl)
+					conn.EXPECT().handlePacket(gomock.Any())
+					conn.EXPECT().closeWithTransportError(qerr.ConnectionRefused).Do(func(qerr.TransportErrorCode) {
+						close(done)
+					})
+					return conn
 				}
 
 				connID := protocol.ParseConnectionID([]byte{1, 2, 3, 4, 5, 6, 7, 8, 9})
 				p := getInitial(connID)
 				phm.EXPECT().Get(connID)
-				phm.EXPECT().AddWithConnID(connID, gomock.Any(), gomock.Any()).Return(false)
-				tracer.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
-				done := make(chan struct{})
-				conn.EXPECT().WriteTo(gomock.Any(), gomock.Any()).Do(func([]byte, net.Addr) (int, error) { close(done); return 0, nil })
+				phm.EXPECT().GetStatelessResetToken(gomock.Any())
+				phm.EXPECT().AddWithConnID(connID, gomock.Any(), gomock.Any()).Return(false) // connection ID collision
 				Expect(serv.handlePacketImpl(p)).To(BeTrue())
-				Expect(createdConn).To(BeFalse())
 				Eventually(done).Should(BeClosed())
 			})
 
@@ -657,11 +645,8 @@ var _ = Describe("Server", func() {
 				serv.maxNumHandshakesUnvalidated = limit
 
 				phm.EXPECT().Get(gomock.Any()).AnyTimes()
-				phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_, _ protocol.ConnectionID, fn func() (packetHandler, bool)) bool {
-					phm.EXPECT().GetStatelessResetToken(gomock.Any())
-					_, ok := fn()
-					return ok
-				}).AnyTimes()
+				phm.EXPECT().GetStatelessResetToken(gomock.Any()).AnyTimes()
+				phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).Return(true).AnyTimes()
 
 				handshakeChan := make(chan struct{})
 				connChan := make(chan *MockQUICConn, 1)
@@ -739,11 +724,8 @@ var _ = Describe("Server", func() {
 				serv.maxNumHandshakesUnvalidated = limit // same limit, but we check that we send CONNECTION_REFUSED and not Retry
 
 				phm.EXPECT().Get(gomock.Any()).AnyTimes()
-				phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_, _ protocol.ConnectionID, fn func() (packetHandler, bool)) bool {
-					phm.EXPECT().GetStatelessResetToken(gomock.Any())
-					_, ok := fn()
-					return ok
-				}).AnyTimes()
+				phm.EXPECT().GetStatelessResetToken(gomock.Any()).AnyTimes()
+				phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).Return(true).AnyTimes()
 
 				handshakeChan := make(chan struct{})
 				connChan := make(chan *MockQUICConn, 1)
@@ -841,10 +823,12 @@ var _ = Describe("Server", func() {
 
 				done := make(chan struct{})
 				phm.EXPECT().Get(gomock.Any())
-				phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).Do(func(_, _ protocol.ConnectionID, _ func() (packetHandler, bool)) bool {
+				phm.EXPECT().GetStatelessResetToken(gomock.Any())
+				phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_, _ protocol.ConnectionID, _ packetHandler) bool {
 					close(done)
-					return false
+					return true
 				})
+				phm.EXPECT().Remove(gomock.Any()).AnyTimes()
 				serv.handlePacket(packet)
 				Eventually(done).Should(BeClosed())
 			})
@@ -1059,11 +1043,8 @@ var _ = Describe("Server", func() {
 					return conn
 				}
 				phm.EXPECT().Get(gomock.Any())
-				phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_, _ protocol.ConnectionID, fn func() (packetHandler, bool)) bool {
-					phm.EXPECT().GetStatelessResetToken(gomock.Any())
-					_, ok := fn()
-					return ok
-				})
+				phm.EXPECT().GetStatelessResetToken(gomock.Any())
+				phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).Return(true)
 				serv.handleInitialImpl(
 					receivedPacket{buffer: getPacketBuffer()},
 					&wire.Header{DestConnectionID: protocol.ParseConnectionID([]byte{1, 2, 3, 4, 5, 6, 7, 8})},
@@ -1128,11 +1109,8 @@ var _ = Describe("Server", func() {
 					return conn
 				}
 				phm.EXPECT().Get(gomock.Any())
-				phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_, _ protocol.ConnectionID, fn func() (packetHandler, bool)) bool {
-					phm.EXPECT().GetStatelessResetToken(gomock.Any())
-					_, ok := fn()
-					return ok
-				})
+				phm.EXPECT().GetStatelessResetToken(gomock.Any())
+				phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).Return(true)
 				serv.handleInitialImpl(
 					receivedPacket{buffer: getPacketBuffer()},
 					&wire.Header{DestConnectionID: protocol.ParseConnectionID([]byte{1, 2, 3, 4, 5, 6, 7, 8})},
@@ -1146,10 +1124,6 @@ var _ = Describe("Server", func() {
 				serv.config = populateConfig(&Config{GetConfigForClient: func(*ClientHelloInfo) (*Config, error) { return nil, errors.New("rejected") }})
 
 				phm.EXPECT().Get(gomock.Any())
-				phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_, _ protocol.ConnectionID, fn func() (packetHandler, bool)) bool {
-					_, ok := fn()
-					return ok
-				})
 				done := make(chan struct{})
 				tracer.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
 				conn.EXPECT().WriteTo(gomock.Any(), gomock.Any()).DoAndReturn(func(b []byte, _ net.Addr) (int, error) {
@@ -1204,11 +1178,8 @@ var _ = Describe("Server", func() {
 					return conn
 				}
 				phm.EXPECT().Get(gomock.Any())
-				phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_, _ protocol.ConnectionID, fn func() (packetHandler, bool)) bool {
-					phm.EXPECT().GetStatelessResetToken(gomock.Any())
-					_, ok := fn()
-					return ok
-				})
+				phm.EXPECT().GetStatelessResetToken(gomock.Any())
+				phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).Return(true)
 				serv.handleInitialImpl(
 					receivedPacket{buffer: getPacketBuffer()},
 					&wire.Header{DestConnectionID: protocol.ParseConnectionID([]byte{1, 2, 3, 4, 5, 6, 7, 8})},
@@ -1277,11 +1248,8 @@ var _ = Describe("Server", func() {
 				return conn
 			}
 			phm.EXPECT().Get(gomock.Any())
-			phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_, _ protocol.ConnectionID, fn func() (packetHandler, bool)) bool {
-				phm.EXPECT().GetStatelessResetToken(gomock.Any())
-				_, ok := fn()
-				return ok
-			})
+			phm.EXPECT().GetStatelessResetToken(gomock.Any())
+			phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).Return(true)
 			serv.baseServer.handleInitialImpl(
 				receivedPacket{buffer: getPacketBuffer()},
 				&wire.Header{DestConnectionID: protocol.ParseConnectionID([]byte{1, 2, 3, 4, 5, 6, 7, 8})},
@@ -1326,11 +1294,8 @@ var _ = Describe("Server", func() {
 			}
 
 			phm.EXPECT().Get(gomock.Any()).AnyTimes()
-			phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_, _ protocol.ConnectionID, fn func() (packetHandler, bool)) bool {
-				phm.EXPECT().GetStatelessResetToken(gomock.Any())
-				_, ok := fn()
-				return ok
-			}).Times(protocol.MaxAcceptQueueSize)
+			phm.EXPECT().GetStatelessResetToken(gomock.Any()).Times(protocol.MaxAcceptQueueSize)
+			phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).Return(true).Times(protocol.MaxAcceptQueueSize)
 			for i := 0; i < protocol.MaxAcceptQueueSize; i++ {
 				conn := NewMockQUICConn(mockCtrl)
 				connChan <- conn
@@ -1339,11 +1304,8 @@ var _ = Describe("Server", func() {
 
 			Eventually(serv.baseServer.connQueue).Should(HaveLen(protocol.MaxAcceptQueueSize))
 
-			phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_, _ protocol.ConnectionID, fn func() (packetHandler, bool)) bool {
-				phm.EXPECT().GetStatelessResetToken(gomock.Any())
-				_, ok := fn()
-				return ok
-			})
+			phm.EXPECT().GetStatelessResetToken(gomock.Any())
+			phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).Return(true)
 			conn := NewMockQUICConn(mockCtrl)
 			conn.EXPECT().closeWithTransportError(ConnectionRefused)
 			connChan <- conn
@@ -1384,11 +1346,8 @@ var _ = Describe("Server", func() {
 			}
 
 			phm.EXPECT().Get(gomock.Any())
-			phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_, _ protocol.ConnectionID, fn func() (packetHandler, bool)) bool {
-				phm.EXPECT().GetStatelessResetToken(gomock.Any())
-				_, ok := fn()
-				return ok
-			})
+			phm.EXPECT().GetStatelessResetToken(gomock.Any())
+			phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).Return(true)
 			serv.baseServer.handlePacket(p)
 			// make sure there are no Write calls on the packet conn
 			time.Sleep(50 * time.Millisecond)
@@ -1405,7 +1364,6 @@ var _ = Describe("Server", func() {
 			Consistently(done).ShouldNot(BeClosed())
 
 			// make the go routine return
-			conn.EXPECT().getPerspective().MaxTimes(2) // initOnce for every conn ID
 			Expect(serv.Close()).To(Succeed())
 			Eventually(done).Should(BeClosed())
 		})
@@ -1432,7 +1390,7 @@ var _ = Describe("Server", func() {
 
 		AfterEach(func() {
 			tracer.EXPECT().Close()
-			tr.Close()
+			Expect(tr.Close()).To(Succeed())
 		})
 
 		It("passes packets to existing connections", func() {
@@ -1518,11 +1476,8 @@ var _ = Describe("Server", func() {
 			}
 
 			phm.EXPECT().Get(connID)
-			phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_, _ protocol.ConnectionID, fn func() (packetHandler, bool)) bool {
-				phm.EXPECT().GetStatelessResetToken(gomock.Any())
-				_, ok := fn()
-				return ok
-			})
+			phm.EXPECT().GetStatelessResetToken(gomock.Any())
+			phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).Return(true)
 			serv.handlePacket(initial)
 			Eventually(called).Should(BeClosed())
 		})
diff --git a/sys_conn_oob_test.go b/sys_conn_oob_test.go
index 40596421..a24480ab 100644
--- a/sys_conn_oob_test.go
+++ b/sys_conn_oob_test.go
@@ -235,7 +235,7 @@ var _ = Describe("OOB Conn Test", func() {
 			Eventually(packetChan).Should(Receive(&p))
 			Expect(utils.IsIPv4(p.remoteAddr.(*net.UDPAddr).IP)).To(BeTrue())
 			Expect(p.info).To(Not(BeNil()))
-			Expect(p.info.addr.Is4In6() || p.info.addr.Is4()).To(BeTrue())
+			Expect(p.info.addr.Is4()).To(BeTrue())
 			ip := p.info.addr.As4()
 			Expect(net.IP(ip[:])).To(Equal(ip4.To4()))
 
diff --git a/transport_test.go b/transport_test.go
index 2865ae94..aeeb1153 100644
--- a/transport_test.go
+++ b/transport_test.go
@@ -122,6 +122,27 @@ var _ = Describe("Transport", func() {
 		tr.Close()
 	})
 
+	It("closes transport concurrently with listener", func() {
+		// try 10 times to trigger race conditions
+		for i := 0; i < 10; i++ {
+			packetChan := make(chan packetToRead)
+			tr := &Transport{Conn: newMockPacketConn(packetChan)}
+			ln, err := tr.Listen(&tls.Config{}, nil)
+			Expect(err).ToNot(HaveOccurred())
+			ch := make(chan bool)
+			// Close transport and listener concurrently.
+			go func() {
+				ch <- true
+				Expect(ln.Close()).To(Succeed())
+				ch <- true
+			}()
+			<-ch
+			close(packetChan)
+			Expect(tr.Close()).To(Succeed())
+			<-ch
+		}
+	})
+
 	It("drops unparseable QUIC packets", func() {
 		addr := &net.UDPAddr{IP: net.IPv4(9, 8, 7, 6), Port: 1234}
 		packetChan := make(chan packetToRead)
@@ -205,7 +226,6 @@ var _ = Describe("Transport", func() {
 		b = append(b, token[:]...)
 		conn := NewMockPacketHandler(mockCtrl)
 		gomock.InOrder(
-			phm.EXPECT().GetByResetToken(token),
 			phm.EXPECT().Get(connID).Return(conn, true),
 			conn.EXPECT().handlePacket(gomock.Any()).Do(func(p receivedPacket) {
 				Expect(p.data).To(Equal(b))
@@ -223,7 +243,10 @@ var _ = Describe("Transport", func() {
 	It("handles stateless resets", func() {
 		connID := protocol.ParseConnectionID([]byte{2, 3, 4, 5})
 		packetChan := make(chan packetToRead)
-		tr := Transport{Conn: newMockPacketConn(packetChan)}
+		tr := Transport{
+			Conn:               newMockPacketConn(packetChan),
+			ConnectionIDLength: connID.Len(),
+		}
 		tr.init(true)
 		defer tr.Close()
 		phm := NewMockPacketHandlerManager(mockCtrl)
@@ -239,6 +262,7 @@ var _ = Describe("Transport", func() {
 		conn := NewMockPacketHandler(mockCtrl)
 		destroyed := make(chan struct{})
 		gomock.InOrder(
+			phm.EXPECT().Get(connID),
 			phm.EXPECT().GetByResetToken(token).Return(conn, true),
 			conn.EXPECT().destroy(gomock.Any()).Do(func(err error) {
 				Expect(err).To(MatchError(&StatelessResetError{Token: token}))
@@ -277,8 +301,8 @@ var _ = Describe("Transport", func() {
 		rand.Read(token[:])
 		written := make(chan struct{})
 		gomock.InOrder(
-			phm.EXPECT().GetByResetToken(gomock.Any()),
 			phm.EXPECT().Get(connID),
+			phm.EXPECT().GetByResetToken(gomock.Any()),
 			phm.EXPECT().GetStatelessResetToken(connID).Return(token),
 			conn.EXPECT().WriteTo(gomock.Any(), gomock.Any()).Do(func(b []byte, _ net.Addr) (int, error) {
 				defer close(written)