Repository: quic-go/quic-go
Pull request #4008 (2023-07-31 21:34:37)

protocol: add string representation for ECN values
For #3999.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/quic-go/quic-go/pull/4008?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
> Merging [#4008](https://app.codecov.io/gh/quic-go/quic-go/pull/4008?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (220b363) into [master](https://app.codecov.io/gh/quic-go/quic-go/commit/f3a0ce1599732cb56b958c2f0903074b62a7e9bf?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (f3a0ce1) will **increase** coverage by `0.01%`.
> The diff coverage is `100.00%`.

```diff
@@            Coverage Diff             @@
##           master    #4008      +/-   ##
==========================================
+ Coverage   82.76%   82.77%   +0.01%     
==========================================
  Files         147      147              
  Lines       14744    14756      +12     
==========================================
+ Hits        12202    12214      +12     
  Misses       2041     2041              
  Partials      501      501              
```


| [Files Changed](https://app.codecov.io/gh/quic-go/quic-go/pull/4008?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [internal/protocol/protocol.go](https://app.codecov.io/gh/quic-go/quic-go/pull/4008?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-aW50ZXJuYWwvcHJvdG9jb2wvcHJvdG9jb2wuZ28=) | `100.00% <100.00%> (ø)` | |



Patch:
diff --git a/.github/workflows/cross-compile.yml b/.github/workflows/cross-compile.yml
index 042953f8..46309b0a 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.20.x", "1.21.0-rc.3" ]
+        go: [ "1.20.x", "1.21.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 362a24ec..0b8fa904 100644
--- a/.github/workflows/integration.yml
+++ b/.github/workflows/integration.yml
@@ -5,12 +5,18 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        go: [ "1.20.x", "1.21.0-rc.3" ]
-    runs-on: ${{ fromJSON(vars['INTEGRATION_RUNNER_UBUNTU'] || '"ubuntu-latest"') }}
+        os: [ "ubuntu" ]
+        go: [ "1.20.x", "1.21.x" ]
+        include:
+          - os: "windows"
+            go: "1.21.x"
+          - os: "macos"
+            go: "1.21.x"
+    runs-on: ${{ fromJSON(vars[format('INTEGRATION_RUNNER_{0}', matrix.os)] || format('"{0}-latest"', matrix.os)) }}
     env:
       DEBUG: false # set this to true to export qlogs and save them as artifacts
       TIMESCALE_FACTOR: 3
-    name: Integration Tests (Go ${{ matrix.go }})
+    name: Integration Tests (${{ matrix.os }}, Go ${{ matrix.go }})
     steps:
       - uses: actions/checkout@v3
       - uses: actions/setup-go@v3
@@ -37,7 +43,7 @@ jobs:
           QUIC_GO_ENABLE_GSO: true
         run: go run github.com/onsi/ginkgo/v2/ginkgo -r -v -randomize-all -randomize-suites -trace integrationtests/self -- -version=1 ${{ env.QLOGFLAG }}
       - name: Run tests (32 bit)
-        if: success() || failure() # run this step even if the previous one failed
+        if: ${{ matrix.os != 'macos' && (success() || failure()) }} # run this step even if the previous one failed
         env:
           GOARCH: 386
         run: |
diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml
index fb21749f..c9eff032 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.20.x", "1.21.0-rc.3" ]
+        go: [ "1.20.x", "1.21.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:
diff --git a/README.md b/README.md
index 3eaf0583..a793f984 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,7 @@
 
 [![PkgGoDev](https://pkg.go.dev/badge/github.com/quic-go/quic-go)](https://pkg.go.dev/github.com/quic-go/quic-go)
 [![Code Coverage](https://img.shields.io/codecov/c/github/quic-go/quic-go/master.svg?style=flat-square)](https://codecov.io/gh/quic-go/quic-go/)
+[![Fuzzing Status](https://oss-fuzz-build-logs.storage.googleapis.com/badges/quic-go.svg)](https://bugs.chromium.org/p/oss-fuzz/issues/list?sort=-opened&can=1&q=proj:quic-go)
 
 quic-go is an implementation of the QUIC protocol ([RFC 9000](https://datatracker.ietf.org/doc/html/rfc9000), [RFC 9001](https://datatracker.ietf.org/doc/html/rfc9001), [RFC 9002](https://datatracker.ietf.org/doc/html/rfc9002)) in Go. It has support for HTTP/3 ([RFC 9114](https://datatracker.ietf.org/doc/html/rfc9114)), including QPACK ([RFC 9204](https://datatracker.ietf.org/doc/html/rfc9204)).
 
@@ -32,7 +33,7 @@ go func() {
     // ... error handling
     // handle the connection, usually in a new Go routine
   }
-}
+}()
 ```
 
 The listener `ln` can now be used to accept incoming QUIC connections by (repeatedly) calling the `Accept` method (see below for more information on the `quic.Connection`).
diff --git a/client.go b/client.go
index 4dfacb50..61cd7526 100644
--- a/client.go
+++ b/client.go
@@ -55,11 +55,11 @@ func DialAddr(ctx context.Context, addr string, tlsConf *tls.Config, conf *Confi
 	if err != nil {
 		return nil, err
 	}
-	dl, err := setupTransport(udpConn, tlsConf, true)
+	tr, err := setupTransport(udpConn, tlsConf, true)
 	if err != nil {
 		return nil, err
 	}
-	return dl.Dial(ctx, udpAddr, tlsConf, conf)
+	return tr.dial(ctx, udpAddr, addr, tlsConf, conf, false)
 }
 
 // DialAddrEarly establishes a new 0-RTT QUIC connection to a server.
@@ -73,13 +73,13 @@ func DialAddrEarly(ctx context.Context, addr string, tlsConf *tls.Config, conf *
 	if err != nil {
 		return nil, err
 	}
-	dl, err := setupTransport(udpConn, tlsConf, true)
+	tr, err := setupTransport(udpConn, tlsConf, true)
 	if err != nil {
 		return nil, err
 	}
-	conn, err := dl.DialEarly(ctx, udpAddr, tlsConf, conf)
+	conn, err := tr.dial(ctx, udpAddr, addr, tlsConf, conf, true)
 	if err != nil {
-		dl.Close()
+		tr.Close()
 		return nil, err
 	}
 	return conn, nil
@@ -163,12 +163,6 @@ func dial(
 }
 
 func newClient(sendConn sendConn, connIDGenerator ConnectionIDGenerator, config *Config, tlsConf *tls.Config, onClose func(), use0RTT bool) (*client, error) {
-	if tlsConf == nil {
-		tlsConf = &tls.Config{}
-	} else {
-		tlsConf = tlsConf.Clone()
-	}
-
 	srcConnID, err := connIDGenerator.GenerateConnectionID()
 	if err != nil {
 		return nil, err
diff --git a/connection.go b/connection.go
index b17f3ce8..877f2d03 100644
--- a/connection.go
+++ b/connection.go
@@ -243,7 +243,7 @@ var newConnection = func(
 		handshakeDestConnID: destConnID,
 		srcConnIDLen:        srcConnID.Len(),
 		tokenGenerator:      tokenGenerator,
-		oneRTTStream:        newCryptoStream(),
+		oneRTTStream:        newCryptoStream(true),
 		perspective:         protocol.PerspectiveServer,
 		tracer:              tracer,
 		logger:              logger,
@@ -391,7 +391,7 @@ var newClientConnection = func(
 		s.logger,
 	)
 	s.mtuDiscoverer = newMTUDiscoverer(s.rttStats, getMaxPacketSize(s.conn.RemoteAddr()), s.sentPacketHandler.SetMaxDatagramSize)
-	oneRTTStream := newCryptoStream()
+	oneRTTStream := newCryptoStream(true)
 	params := &wire.TransportParameters{
 		InitialMaxStreamDataBidiRemote: protocol.ByteCount(s.config.InitialStreamReceiveWindow),
 		InitialMaxStreamDataBidiLocal:  protocol.ByteCount(s.config.InitialStreamReceiveWindow),
@@ -447,8 +447,8 @@ var newClientConnection = func(
 }
 
 func (s *connection) preSetup() {
-	s.initialStream = newCryptoStream()
-	s.handshakeStream = newCryptoStream()
+	s.initialStream = newCryptoStream(false)
+	s.handshakeStream = newCryptoStream(false)
 	s.sendQueue = newSendQueue(s.conn)
 	s.retransmissionQueue = newRetransmissionQueue()
 	s.frameParser = wire.NewFrameParser(s.config.EnableDatagrams)
diff --git a/crypto_stream.go b/crypto_stream.go
index 4be2a07a..5ce2125d 100644
--- a/crypto_stream.go
+++ b/crypto_stream.go
@@ -30,10 +30,17 @@ type cryptoStreamImpl struct {
 
 	writeOffset protocol.ByteCount
 	writeBuf    []byte
+
+	// Reassemble TLS handshake messages before returning them from GetCryptoData.
+	// This is only needed because crypto/tls doesn't correctly handle post-handshake messages.
+	onlyCompleteMsg bool
 }
 
-func newCryptoStream() cryptoStream {
-	return &cryptoStreamImpl{queue: newFrameSorter()}
+func newCryptoStream(onlyCompleteMsg bool) cryptoStream {
+	return &cryptoStreamImpl{
+		queue:           newFrameSorter(),
+		onlyCompleteMsg: onlyCompleteMsg,
+	}
 }
 
 func (s *cryptoStreamImpl) HandleCryptoFrame(f *wire.CryptoFrame) error {
@@ -71,6 +78,20 @@ func (s *cryptoStreamImpl) HandleCryptoFrame(f *wire.CryptoFrame) error {
 
 // GetCryptoData retrieves data that was received in CRYPTO frames
 func (s *cryptoStreamImpl) GetCryptoData() []byte {
+	if s.onlyCompleteMsg {
+		if len(s.msgBuf) < 4 {
+			return nil
+		}
+		msgLen := 4 + int(s.msgBuf[1])<<16 + int(s.msgBuf[2])<<8 + int(s.msgBuf[3])
+		if len(s.msgBuf) < msgLen {
+			return nil
+		}
+		msg := make([]byte, msgLen)
+		copy(msg, s.msgBuf[:msgLen])
+		s.msgBuf = s.msgBuf[msgLen:]
+		return msg
+	}
+
 	b := s.msgBuf
 	s.msgBuf = nil
 	return b
diff --git a/fuzzing/handshake/fuzz.go b/fuzzing/handshake/fuzz.go
index 2c5c1259..be34c22d 100644
--- a/fuzzing/handshake/fuzz.go
+++ b/fuzzing/handshake/fuzz.go
@@ -17,6 +17,7 @@ import (
 	"github.com/quic-go/quic-go/fuzzing/internal/helper"
 	"github.com/quic-go/quic-go/internal/handshake"
 	"github.com/quic-go/quic-go/internal/protocol"
+	"github.com/quic-go/quic-go/internal/qtls"
 	"github.com/quic-go/quic-go/internal/utils"
 	"github.com/quic-go/quic-go/internal/wire"
 )
@@ -84,33 +85,6 @@ func (m messageType) String() string {
 	}
 }
 
-func appendSuites(suites []uint16, rand uint8) []uint16 {
-	const (
-		s1 = tls.TLS_AES_128_GCM_SHA256
-		s2 = tls.TLS_AES_256_GCM_SHA384
-		s3 = tls.TLS_CHACHA20_POLY1305_SHA256
-	)
-	switch rand % 4 {
-	default:
-		return suites
-	case 1:
-		return append(suites, s1)
-	case 2:
-		return append(suites, s2)
-	case 3:
-		return append(suites, s3)
-	}
-}
-
-// consumes 2 bits
-func getSuites(rand uint8) []uint16 {
-	suites := make([]uint16, 0, 3)
-	for i := 1; i <= 3; i++ {
-		suites = appendSuites(suites, rand>>i%4)
-	}
-	return suites
-}
-
 // consumes 3 bits
 func getClientAuth(rand uint8) tls.ClientAuthType {
 	switch rand {
@@ -147,6 +121,7 @@ func getTransportParameters(seed uint8) *wire.TransportParameters {
 	const maxVarInt = math.MaxUint64 / 4
 	r := mrand.New(mrand.NewSource(int64(seed)))
 	return &wire.TransportParameters{
+		ActiveConnectionIDLimit:        2,
 		InitialMaxData:                 protocol.ByteCount(r.Int63n(maxVarInt)),
 		InitialMaxStreamDataBidiLocal:  protocol.ByteCount(r.Int63n(maxVarInt)),
 		InitialMaxStreamDataBidiRemote: protocol.ByteCount(r.Int63n(maxVarInt)),
@@ -206,14 +181,26 @@ func runHandshake(runConfig [confLen]byte, messageConfig uint8, clientConf *tls.
 		SessionTicketKey: sessionTicketKey,
 	}
 
+	// This sets the cipher suite for both client and server.
+	// The way crypto/tls is designed doesn't allow us to set different cipher suites for client and server.
+	resetCipherSuite := func() {}
+	switch (runConfig[0] >> 6) % 4 {
+	case 0:
+		resetCipherSuite = qtls.SetCipherSuite(tls.TLS_AES_128_GCM_SHA256)
+	case 1:
+		resetCipherSuite = qtls.SetCipherSuite(tls.TLS_AES_256_GCM_SHA384)
+	case 3:
+		resetCipherSuite = qtls.SetCipherSuite(tls.TLS_CHACHA20_POLY1305_SHA256)
+	default:
+	}
+	defer resetCipherSuite()
+
 	enable0RTTClient := helper.NthBit(runConfig[0], 0)
 	enable0RTTServer := helper.NthBit(runConfig[0], 1)
 	sendPostHandshakeMessageToClient := helper.NthBit(runConfig[0], 3)
 	sendPostHandshakeMessageToServer := helper.NthBit(runConfig[0], 4)
 	sendSessionTicket := helper.NthBit(runConfig[0], 5)
-	clientConf.CipherSuites = getSuites(runConfig[0] >> 6)
 	serverConf.ClientAuth = getClientAuth(runConfig[1] & 0b00000111)
-	serverConf.CipherSuites = getSuites(runConfig[1] >> 6)
 	serverConf.SessionTicketsDisabled = helper.NthBit(runConfig[1], 3)
 	if helper.NthBit(runConfig[2], 0) {
 		clientConf.RootCAs = x509.NewCertPool()
@@ -302,6 +289,7 @@ func runHandshake(runConfig [confLen]byte, messageConfig uint8, clientConf *tls.
 	if err := client.StartHandshake(); err != nil {
 		log.Fatal(err)
 	}
+	defer client.Close()
 
 	server := handshake.NewCryptoSetupServer(
 		protocol.ConnectionID{},
@@ -318,12 +306,13 @@ func runHandshake(runConfig [confLen]byte, messageConfig uint8, clientConf *tls.
 	if err := server.StartHandshake(); err != nil {
 		log.Fatal(err)
 	}
+	defer server.Close()
 
 	var clientHandshakeComplete, serverHandshakeComplete bool
 	for {
+		var processedEvent bool
 	clientLoop:
 		for {
-			var processedEvent bool
 			ev := client.NextEvent()
 			//nolint:exhaustive // only need to process a few events
 			switch ev.Kind {
@@ -334,11 +323,16 @@ func runHandshake(runConfig [confLen]byte, messageConfig uint8, clientConf *tls.
 				break clientLoop
 			case handshake.EventWriteInitialData, handshake.EventWriteHandshakeData:
 				msg := ev.Data
+				encLevel := protocol.EncryptionInitial
+				if ev.Kind == handshake.EventWriteHandshakeData {
+					encLevel = protocol.EncryptionHandshake
+				}
 				if msg[0] == messageToReplace {
 					fmt.Printf("replacing %s message to the server with %s at %s\n", messageType(msg[0]), messageType(data[0]), messageToReplaceEncLevel)
 					msg = data
+					encLevel = messageToReplaceEncLevel
 				}
-				if err := server.HandleMessage(msg, messageToReplaceEncLevel); err != nil {
+				if err := server.HandleMessage(msg, encLevel); err != nil {
 					return 1
 				}
 			case handshake.EventHandshakeComplete:
@@ -347,9 +341,9 @@ func runHandshake(runConfig [confLen]byte, messageConfig uint8, clientConf *tls.
 			processedEvent = true
 		}
 
+		processedEvent = false
 	serverLoop:
 		for {
-			var processedEvent bool
 			ev := server.NextEvent()
 			//nolint:exhaustive // only need to process a few events
 			switch ev.Kind {
@@ -359,12 +353,17 @@ func runHandshake(runConfig [confLen]byte, messageConfig uint8, clientConf *tls.
 				}
 				break serverLoop
 			case handshake.EventWriteInitialData, handshake.EventWriteHandshakeData:
+				encLevel := protocol.EncryptionInitial
+				if ev.Kind == handshake.EventWriteHandshakeData {
+					encLevel = protocol.EncryptionHandshake
+				}
 				msg := ev.Data
 				if msg[0] == messageToReplace {
 					fmt.Printf("replacing %s message to the client with %s at %s\n", messageType(msg[0]), messageType(data[0]), messageToReplaceEncLevel)
 					msg = data
+					encLevel = messageToReplaceEncLevel
 				}
-				if err := client.HandleMessage(msg, messageToReplaceEncLevel); err != nil {
+				if err := client.HandleMessage(msg, encLevel); err != nil {
 					return 1
 				}
 			case handshake.EventHandshakeComplete:
@@ -409,10 +408,13 @@ func runHandshake(runConfig [confLen]byte, messageConfig uint8, clientConf *tls.
 		}
 		client.HandleMessage(ticket, protocol.Encryption1RTT)
 	}
+
 	if sendPostHandshakeMessageToClient {
+		fmt.Println("sending post handshake message to the client at", messageToReplaceEncLevel)
 		client.HandleMessage(data, messageToReplaceEncLevel)
 	}
 	if sendPostHandshakeMessageToServer {
+		fmt.Println("sending post handshake message to the server at", messageToReplaceEncLevel)
 		server.HandleMessage(data, messageToReplaceEncLevel)
 	}
 
diff --git a/go.mod b/go.mod
index 2f2a0ff0..106c90fd 100644
--- a/go.mod
+++ b/go.mod
@@ -8,7 +8,7 @@ require (
 	github.com/onsi/ginkgo/v2 v2.9.5
 	github.com/onsi/gomega v1.27.6
 	github.com/quic-go/qpack v0.4.0
-	github.com/quic-go/qtls-go1-20 v0.3.1
+	github.com/quic-go/qtls-go1-20 v0.3.2
 	golang.org/x/crypto v0.4.0
 	golang.org/x/exp v0.0.0-20221205204356-47842c84f3db
 	golang.org/x/net v0.10.0
diff --git a/go.sum b/go.sum
index c0ed9606..f28b844c 100644
--- a/go.sum
+++ b/go.sum
@@ -90,8 +90,8 @@ github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7q
 github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
 github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A=
-github.com/quic-go/qtls-go1-20 v0.3.1 h1:O4BLOM3hwfVF3AcktIylQXyl7Yi2iBNVy5QsV+ySxbg=
-github.com/quic-go/qtls-go1-20 v0.3.1/go.mod h1:X9Nh97ZL80Z+bX/gUXMbipO6OxdiDi58b/fMC9mAL+k=
+github.com/quic-go/qtls-go1-20 v0.3.2 h1:rRgN3WfnKbyik4dBV8A6girlJVxGand/d+jVKbQq5GI=
+github.com/quic-go/qtls-go1-20 v0.3.2/go.mod h1:X9Nh97ZL80Z+bX/gUXMbipO6OxdiDi58b/fMC9mAL+k=
 github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
 github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY=
diff --git a/http3/error_codes.go b/http3/error_codes.go
index 67b215d8..e8428d0a 100644
--- a/http3/error_codes.go
+++ b/http3/error_codes.go
@@ -26,7 +26,7 @@ const (
 	ErrCodeMessageError         ErrCode = 0x10e
 	ErrCodeConnectError         ErrCode = 0x10f
 	ErrCodeVersionFallback      ErrCode = 0x110
-	ErrCodeDatagramError        ErrCode = 0x4a1268
+	ErrCodeDatagramError        ErrCode = 0x33
 )
 
 func (e ErrCode) String() string {
diff --git a/http3/frames.go b/http3/frames.go
index cdd97bc5..454e5f94 100644
--- a/http3/frames.go
+++ b/http3/frames.go
@@ -88,7 +88,7 @@ func (f *headersFrame) Append(b []byte) []byte {
 	return quicvarint.Append(b, f.Length)
 }
 
-const settingDatagram = 0xffd277
+const settingDatagram = 0x33
 
 type settingsFrame struct {
 	Datagram bool
diff --git a/http3/roundtrip.go b/http3/roundtrip.go
index 066b762b..bed42103 100644
--- a/http3/roundtrip.go
+++ b/http3/roundtrip.go
@@ -52,7 +52,7 @@ type RoundTripper struct {
 
 	// Enable support for HTTP/3 datagrams.
 	// If set to true, QuicConfig.EnableDatagram will be set.
-	// See https://www.ietf.org/archive/id/draft-schinazi-masque-h3-datagram-02.html.
+	// See https://datatracker.ietf.org/doc/html/rfc9297.
 	EnableDatagrams bool
 
 	// Additional HTTP/3 settings.
diff --git a/http3/server.go b/http3/server.go
index b90c850c..8d39f374 100644
--- a/http3/server.go
+++ b/http3/server.go
@@ -31,12 +31,8 @@ var (
 	}
 )
 
-const (
-	// NextProtoH3Draft29 is the ALPN protocol negotiated during the TLS handshake, for QUIC draft 29.
-	NextProtoH3Draft29 = "h3-29"
-	// NextProtoH3 is the ALPN protocol negotiated during the TLS handshake, for QUIC v1 and v2.
-	NextProtoH3 = "h3"
-)
+// NextProtoH3 is the ALPN protocol negotiated during the TLS handshake, for QUIC v1 and v2.
+const NextProtoH3 = "h3"
 
 // StreamType is the stream type of a unidirectional stream.
 type StreamType uint64
@@ -176,7 +172,7 @@ type Server struct {
 
 	// EnableDatagrams enables support for HTTP/3 datagrams.
 	// If set to true, QuicConfig.EnableDatagram will be set.
-	// See https://datatracker.ietf.org/doc/html/draft-ietf-masque-h3-datagram-07.
+	// See https://datatracker.ietf.org/doc/html/rfc9297.
 	EnableDatagrams bool
 
 	// MaxHeaderBytes controls the maximum number of bytes the server will
diff --git a/internal/handshake/cipher_suite.go b/internal/handshake/cipher_suite.go
index 608d5ea0..265231f0 100644
--- a/internal/handshake/cipher_suite.go
+++ b/internal/handshake/cipher_suite.go
@@ -30,7 +30,7 @@ func getCipherSuite(id uint16) *cipherSuite {
 	case tls.TLS_CHACHA20_POLY1305_SHA256:
 		return &cipherSuite{ID: tls.TLS_CHACHA20_POLY1305_SHA256, Hash: crypto.SHA256, KeyLen: 32, AEAD: aeadChaCha20Poly1305}
 	case tls.TLS_AES_256_GCM_SHA384:
-		return &cipherSuite{ID: tls.TLS_AES_256_GCM_SHA384, Hash: crypto.SHA256, KeyLen: 32, AEAD: aeadAESGCMTLS13}
+		return &cipherSuite{ID: tls.TLS_AES_256_GCM_SHA384, Hash: crypto.SHA384, KeyLen: 32, AEAD: aeadAESGCMTLS13}
 	default:
 		panic(fmt.Sprintf("unknown cypher suite: %d", id))
 	}
diff --git a/internal/handshake/crypto_setup.go b/internal/handshake/crypto_setup.go
index 543e70e0..332dbd7e 100644
--- a/internal/handshake/crypto_setup.go
+++ b/internal/handshake/crypto_setup.go
@@ -7,6 +7,7 @@ import (
 	"errors"
 	"fmt"
 	"net"
+	"strings"
 	"sync"
 	"sync/atomic"
 	"time"
@@ -356,10 +357,15 @@ func (h *cryptoSetup) getDataForSessionTicket() []byte {
 // Due to limitations in crypto/tls, it's only possible to generate a single session ticket per connection.
 // It is only valid for the server.
 func (h *cryptoSetup) GetSessionTicket() ([]byte, error) {
-	if h.tlsConf.SessionTicketsDisabled {
-		return nil, nil
-	}
-	if err := h.conn.SendSessionTicket(h.allow0RTT); err != nil {
+	if err := qtls.SendSessionTicket(h.conn, h.allow0RTT); err != nil {
+		// Session tickets might be disabled by tls.Config.SessionTicketsDisabled.
+		// We can't check h.tlsConfig here, since the actual config might have been obtained from
+		// the GetConfigForClient callback.
+		// See https://github.com/golang/go/issues/62032.
+		// Once that issue is resolved, this error assertion can be removed.
+		if strings.Contains(err.Error(), "session ticket keys unavailable") {
+			return nil, nil
+		}
 		return nil, err
 	}
 	ev := h.conn.NextEvent()
@@ -658,8 +664,9 @@ func (h *cryptoSetup) ConnectionState() ConnectionState {
 }
 
 func wrapError(err error) error {
+	// alert 80 is an internal error
 	if alertErr := qtls.AlertError(0); errors.As(err, &alertErr) && alertErr != 80 {
-		return qerr.NewLocalCryptoError(uint8(alertErr), err.Error())
+		return qerr.NewLocalCryptoError(uint8(alertErr), err)
 	}
 	return &qerr.TransportError{ErrorCode: qerr.InternalError, ErrorMessage: err.Error()}
 }
diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go
index 98bc9ffb..b8104882 100644
--- a/internal/protocol/protocol.go
+++ b/internal/protocol/protocol.go
@@ -43,6 +43,21 @@ const (
 	ECNCE             // 11
 )
 
+func (e ECN) String() string {
+	switch e {
+	case ECNNon:
+		return "Not-ECT"
+	case ECT1:
+		return "ECT(1)"
+	case ECT0:
+		return "ECT(0)"
+	case ECNCE:
+		return "CE"
+	default:
+		return fmt.Sprintf("invalid ECN value: %d", e)
+	}
+}
+
 // A ByteCount in QUIC
 type ByteCount int64
 
diff --git a/internal/qerr/errors.go b/internal/qerr/errors.go
index 26ea3445..2d8511f7 100644
--- a/internal/qerr/errors.go
+++ b/internal/qerr/errors.go
@@ -17,15 +17,16 @@ type TransportError struct {
 	FrameType    uint64
 	ErrorCode    TransportErrorCode
 	ErrorMessage string
+	error        error // only set for local errors, sometimes
 }
 
 var _ error = &TransportError{}
 
 // NewLocalCryptoError create a new TransportError instance for a crypto error
-func NewLocalCryptoError(tlsAlert uint8, errorMessage string) *TransportError {
+func NewLocalCryptoError(tlsAlert uint8, err error) *TransportError {
 	return &TransportError{
-		ErrorCode:    0x100 + TransportErrorCode(tlsAlert),
-		ErrorMessage: errorMessage,
+		ErrorCode: 0x100 + TransportErrorCode(tlsAlert),
+		error:     err,
 	}
 }
 
@@ -35,6 +36,9 @@ func (e *TransportError) Error() string {
 		str += fmt.Sprintf(" (frame type: %#x)", e.FrameType)
 	}
 	msg := e.ErrorMessage
+	if len(msg) == 0 && e.error != nil {
+		msg = e.error.Error()
+	}
 	if len(msg) == 0 {
 		msg = e.ErrorCode.Message()
 	}
@@ -48,6 +52,10 @@ func (e *TransportError) Is(target error) bool {
 	return target == net.ErrClosed
 }
 
+func (e *TransportError) Unwrap() error {
+	return e.error
+}
+
 // An ApplicationErrorCode is an application-defined error code.
 type ApplicationErrorCode uint64
 
diff --git a/internal/qtls/go120.go b/internal/qtls/go120.go
index 595e6e66..3b50441c 100644
--- a/internal/qtls/go120.go
+++ b/internal/qtls/go120.go
@@ -139,3 +139,7 @@ func SetCipherSuite(id uint16) (reset func()) {
 		cipherSuitesModified = false
 	}
 }
+
+func SendSessionTicket(c *QUICConn, allow0RTT bool) error {
+	return c.SendSessionTicket(allow0RTT)
+}
diff --git a/internal/qtls/go121.go b/internal/qtls/go121.go
index 1137556e..4aebc4a1 100644
--- a/internal/qtls/go121.go
+++ b/internal/qtls/go121.go
@@ -11,12 +11,13 @@ import (
 )
 
 type (
-	QUICConn            = tls.QUICConn
-	QUICConfig          = tls.QUICConfig
-	QUICEvent           = tls.QUICEvent
-	QUICEventKind       = tls.QUICEventKind
-	QUICEncryptionLevel = tls.QUICEncryptionLevel
-	AlertError          = tls.AlertError
+	QUICConn                 = tls.QUICConn
+	QUICConfig               = tls.QUICConfig
+	QUICEvent                = tls.QUICEvent
+	QUICEventKind            = tls.QUICEventKind
+	QUICEncryptionLevel      = tls.QUICEncryptionLevel
+	QUICSessionTicketOptions = tls.QUICSessionTicketOptions
+	AlertError               = tls.AlertError
 )
 
 const (
@@ -152,3 +153,9 @@ func findExtraData(extras [][]byte) []byte {
 	}
 	return nil
 }
+
+func SendSessionTicket(c *QUICConn, allow0RTT bool) error {
+	return c.SendSessionTicket(tls.QUICSessionTicketOptions{
+		EarlyData: allow0RTT,
+	})
+}
diff --git a/mockgen.go b/mockgen.go
index eb700864..221c1367 100644
--- a/mockgen.go
+++ b/mockgen.go
@@ -5,6 +5,9 @@ package quic
 //go:generate sh -c "go run github.com/golang/mock/mockgen -build_flags=\"-tags=gomock\" -package quic -self_package github.com/quic-go/quic-go -destination mock_send_conn_test.go github.com/quic-go/quic-go SendConn"
 type SendConn = sendConn
 
+//go:generate sh -c "go run github.com/golang/mock/mockgen -build_flags=\"-tags=gomock\" -package quic -self_package github.com/quic-go/quic-go -destination mock_raw_conn_test.go github.com/quic-go/quic-go RawConn"
+type RawConn = rawConn
+
 //go:generate sh -c "go run github.com/golang/mock/mockgen -build_flags=\"-tags=gomock\" -package quic -self_package github.com/quic-go/quic-go -destination mock_sender_test.go github.com/quic-go/quic-go Sender"
 type Sender = sender
 
diff --git a/packet_handler_map.go b/packet_handler_map.go
index 2a16773c..e0f0567d 100644
--- a/packet_handler_map.go
+++ b/packet_handler_map.go
@@ -26,9 +26,9 @@ type connCapabilities struct {
 // rawConn is a connection that allow reading of a receivedPackeh.
 type rawConn interface {
 	ReadPacket() (receivedPacket, error)
-	// The size parameter is used for GSO.
-	// If GSO is not support, len(b) must be equal to size.
-	WritePacket(b []byte, size uint16, addr net.Addr, oob []byte) (int, error)
+	// WritePacket writes a packet on the wire.
+	// If GSO is enabled, it's the caller's responsibility to set the correct control message.
+	WritePacket(b []byte, addr net.Addr, oob []byte) (int, error)
 	LocalAddr() net.Addr
 	SetReadDeadline(time.Time) error
 	io.Closer
diff --git a/send_conn.go b/send_conn.go
index 4e7007fa..d8ddbc87 100644
--- a/send_conn.go
+++ b/send_conn.go
@@ -1,10 +1,12 @@
 package quic
 
 import (
+	"fmt"
 	"math"
 	"net"
 
 	"github.com/quic-go/quic-go/internal/protocol"
+	"github.com/quic-go/quic-go/internal/utils"
 )
 
 // A sendConn allows sending using a simple Write() on a non-connected packet conn.
@@ -20,61 +22,86 @@ type sendConn interface {
 type sconn struct {
 	rawConn
 
+	localAddr  net.Addr
 	remoteAddr net.Addr
-	info       packetInfo
-	oob        []byte
+
+	logger utils.Logger
+
+	info packetInfo
+	oob  []byte
+	// If GSO enabled, and we receive a GSO error for this remote address, GSO is disabled.
+	gotGSOError bool
 }
 
 var _ sendConn = &sconn{}
 
-func newSendConn(c rawConn, remote net.Addr) *sconn {
-	sc := &sconn{
-		rawConn:    c,
-		remoteAddr: remote,
-	}
-	if c.capabilities().GSO {
-		// add 32 bytes, so we can add the UDP_SEGMENT msg
-		sc.oob = make([]byte, 0, 32)
+func newSendConn(c rawConn, remote net.Addr, info packetInfo, logger utils.Logger) *sconn {
+	localAddr := c.LocalAddr()
+	if info.addr.IsValid() {
+		if udpAddr, ok := localAddr.(*net.UDPAddr); ok {
+			addrCopy := *udpAddr
+			addrCopy.IP = info.addr.AsSlice()
+			localAddr = &addrCopy
+		}
 	}
-	return sc
-}
 
-func newSendConnWithPacketInfo(c rawConn, remote net.Addr, info packetInfo) *sconn {
 	oob := info.OOB()
-	if c.capabilities().GSO {
-		// add 32 bytes, so we can add the UDP_SEGMENT msg
-		l := len(oob)
-		oob = append(oob, make([]byte, 32)...)
-		oob = oob[:l]
-	}
+	// add 32 bytes, so we can add the UDP_SEGMENT msg
+	l := len(oob)
+	oob = append(oob, make([]byte, 32)...)
+	oob = oob[:l]
 	return &sconn{
 		rawConn:    c,
+		localAddr:  localAddr,
 		remoteAddr: remote,
 		info:       info,
 		oob:        oob,
+		logger:     logger,
 	}
 }
 
 func (c *sconn) Write(p []byte, size protocol.ByteCount) error {
+	if !c.capabilities().GSO {
+		if protocol.ByteCount(len(p)) != size {
+			panic(fmt.Sprintf("inconsistent packet size (%d vs %d)", len(p), size))
+		}
+		_, err := c.WritePacket(p, c.remoteAddr, c.oob)
+		return err
+	}
+	// GSO is supported. Append the control message and send.
 	if size > math.MaxUint16 {
 		panic("size overflow")
 	}
-	_, err := c.WritePacket(p, uint16(size), c.remoteAddr, c.oob)
+	_, err := c.WritePacket(p, c.remoteAddr, appendUDPSegmentSizeMsg(c.oob, uint16(size)))
+	if err != nil && isGSOError(err) {
+		// disable GSO for future calls
+		c.gotGSOError = true
+		if c.logger.Debug() {
+			c.logger.Debugf("GSO failed when sending to %s", c.remoteAddr)
+		}
+		// send out the packets one by one
+		for len(p) > 0 {
+			l := len(p)
+			if l > int(size) {
+				l = int(size)
+			}
+			if _, err := c.WritePacket(p[:l], c.remoteAddr, c.oob); err != nil {
+				return err
+			}
+			p = p[l:]
+		}
+		return nil
+	}
 	return err
 }
 
-func (c *sconn) RemoteAddr() net.Addr {
-	return c.remoteAddr
-}
-
-func (c *sconn) LocalAddr() net.Addr {
-	addr := c.rawConn.LocalAddr()
-	if c.info.addr.IsValid() {
-		if udpAddr, ok := addr.(*net.UDPAddr); ok {
-			addrCopy := *udpAddr
-			addrCopy.IP = c.info.addr.AsSlice()
-			addr = &addrCopy
-		}
+func (c *sconn) capabilities() connCapabilities {
+	capabilities := c.rawConn.capabilities()
+	if capabilities.GSO {
+		capabilities.GSO = !c.gotGSOError
 	}
-	return addr
+	return capabilities
 }
+
+func (c *sconn) RemoteAddr() net.Addr { return c.remoteAddr }
+func (c *sconn) LocalAddr() net.Addr  { return c.localAddr }
diff --git a/server.go b/server.go
index 0f8219e3..c06228c9 100644
--- a/server.go
+++ b/server.go
@@ -632,7 +632,7 @@ func (s *baseServer) handleInitialImpl(p receivedPacket, hdr *wire.Header) error
 			tracer = config.Tracer(context.WithValue(context.Background(), ConnectionTracingKey, tracingID), protocol.PerspectiveServer, connID)
 		}
 		conn = s.newConn(
-			newSendConnWithPacketInfo(s.conn, p.remoteAddr, p.info),
+			newSendConn(s.conn, p.remoteAddr, p.info, s.logger),
 			s.connHandler,
 			origDestConnID,
 			retrySrcConnID,
@@ -742,7 +742,7 @@ func (s *baseServer) sendRetry(remoteAddr net.Addr, hdr *wire.Header, info packe
 	if s.tracer != nil {
 		s.tracer.SentPacket(remoteAddr, &replyHdr.Header, protocol.ByteCount(len(buf.Data)), nil)
 	}
-	_, err = s.conn.WritePacket(buf.Data, uint16(len(buf.Data)), remoteAddr, info.OOB())
+	_, err = s.conn.WritePacket(buf.Data, remoteAddr, info.OOB())
 	return err
 }
 
@@ -841,7 +841,7 @@ func (s *baseServer) sendError(remoteAddr net.Addr, hdr *wire.Header, sealer han
 	if s.tracer != nil {
 		s.tracer.SentPacket(remoteAddr, &replyHdr.Header, protocol.ByteCount(len(b.Data)), []logging.Frame{ccf})
 	}
-	_, err = s.conn.WritePacket(b.Data, uint16(len(b.Data)), remoteAddr, info.OOB())
+	_, err = s.conn.WritePacket(b.Data, remoteAddr, info.OOB())
 	return err
 }
 
@@ -879,7 +879,7 @@ func (s *baseServer) maybeSendVersionNegotiationPacket(p receivedPacket) {
 	if s.tracer != nil {
 		s.tracer.SentVersionNegotiationPacket(p.remoteAddr, src, dest, s.config.Versions)
 	}
-	if _, err := s.conn.WritePacket(data, uint16(len(data)), p.remoteAddr, p.info.OOB()); err != nil {
+	if _, err := s.conn.WritePacket(data, p.remoteAddr, p.info.OOB()); err != nil {
 		s.logger.Debugf("Error sending Version Negotiation: %s", err)
 	}
 }
diff --git a/sys_conn.go b/sys_conn.go
index 414472e7..f2224e4c 100644
--- a/sys_conn.go
+++ b/sys_conn.go
@@ -1,7 +1,6 @@
 package quic
 
 import (
-	"fmt"
 	"log"
 	"net"
 	"os"
@@ -105,10 +104,7 @@ func (c *basicConn) ReadPacket() (receivedPacket, error) {
 	}, nil
 }
 
-func (c *basicConn) WritePacket(b []byte, packetSize uint16, addr net.Addr, _ []byte) (n int, err error) {
-	if uint16(len(b)) != packetSize {
-		panic(fmt.Sprintf("inconsistent length. got: %d. expected %d", packetSize, len(b)))
-	}
+func (c *basicConn) WritePacket(b []byte, addr net.Addr, _ []byte) (n int, err error) {
 	return c.PacketConn.WriteTo(b, addr)
 }
 
diff --git a/sys_conn_df_linux.go b/sys_conn_df_linux.go
index 199f6347..f09eaa5d 100644
--- a/sys_conn_df_linux.go
+++ b/sys_conn_df_linux.go
@@ -4,11 +4,7 @@ package quic
 
 import (
 	"errors"
-	"log"
-	"os"
-	"strconv"
 	"syscall"
-	"unsafe"
 
 	"golang.org/x/sys/unix"
 
@@ -38,43 +34,9 @@ func setDF(rawConn syscall.RawConn) (bool, error) {
 	return true, nil
 }
 
-func maybeSetGSO(rawConn syscall.RawConn) bool {
-	enable, _ := strconv.ParseBool(os.Getenv("QUIC_GO_ENABLE_GSO"))
-	if !enable {
-		return false
-	}
-
-	var setErr error
-	if err := rawConn.Control(func(fd uintptr) {
-		setErr = unix.SetsockoptInt(int(fd), syscall.IPPROTO_UDP, unix.UDP_SEGMENT, 1)
-	}); err != nil {
-		setErr = err
-	}
-	if setErr != nil {
-		log.Println("failed to enable GSO")
-		return false
-	}
-	return true
-}
-
 func isSendMsgSizeErr(err error) bool {
 	// https://man7.org/linux/man-pages/man7/udp.7.html
 	return errors.Is(err, unix.EMSGSIZE)
 }
 
-func isRecvMsgSizeErr(err error) bool { return false }
-
-func appendUDPSegmentSizeMsg(b []byte, size uint16) []byte {
-	startLen := len(b)
-	const dataLen = 2 // payload is a uint16
-	b = append(b, make([]byte, unix.CmsgSpace(dataLen))...)
-	h := (*unix.Cmsghdr)(unsafe.Pointer(&b[startLen]))
-	h.Level = syscall.IPPROTO_UDP
-	h.Type = unix.UDP_SEGMENT
-	h.SetLen(unix.CmsgLen(dataLen))
-
-	// UnixRights uses the private `data` method, but I *think* this achieves the same goal.
-	offset := startLen + unix.CmsgSpace(0)
-	*(*uint16)(unsafe.Pointer(&b[offset])) = size
-	return b
-}
+func isRecvMsgSizeErr(error) bool { return false }
diff --git a/sys_conn_helper_darwin.go b/sys_conn_helper_darwin.go
index bf735f0f..758cf778 100644
--- a/sys_conn_helper_darwin.go
+++ b/sys_conn_helper_darwin.go
@@ -5,6 +5,7 @@ package quic
 import (
 	"encoding/binary"
 	"net/netip"
+	"syscall"
 
 	"golang.org/x/sys/unix"
 )
@@ -29,3 +30,5 @@ func parseIPv4PktInfo(body []byte) (ip netip.Addr, ifIndex uint32, ok bool) {
 	}
 	return netip.AddrFrom4(*(*[4]byte)(body[8:12])), binary.LittleEndian.Uint32(body), true
 }
+
+func isGSOSupported(syscall.RawConn) bool { return false }
diff --git a/sys_conn_helper_freebsd.go b/sys_conn_helper_freebsd.go
index fe5a7c20..a2baae3b 100644
--- a/sys_conn_helper_freebsd.go
+++ b/sys_conn_helper_freebsd.go
@@ -4,6 +4,7 @@ package quic
 
 import (
 	"net/netip"
+	"syscall"
 
 	"golang.org/x/sys/unix"
 )
@@ -24,3 +25,5 @@ func parseIPv4PktInfo(body []byte) (ip netip.Addr, _ uint32, ok bool) {
 	}
 	return netip.AddrFrom4(*(*[4]byte)(body)), 0, true
 }
+
+func isGSOSupported(syscall.RawConn) bool { return false }
diff --git a/sys_conn_helper_linux.go b/sys_conn_helper_linux.go
index 61224eaa..5bda2286 100644
--- a/sys_conn_helper_linux.go
+++ b/sys_conn_helper_linux.go
@@ -4,12 +4,25 @@ package quic
 
 import (
 	"encoding/binary"
+	"errors"
 	"net/netip"
+	"os"
+	"strconv"
 	"syscall"
+	"unsafe"
 
 	"golang.org/x/sys/unix"
 )
 
+var gsoDisabled bool
+
+func init() {
+	disabled, err := strconv.ParseBool(os.Getenv("QUIC_GO_DISABLE_GSO"))
+	if err == nil {
+		gsoDisabled = disabled
+	}
+}
+
 const (
 	msgTypeIPTOS = unix.IP_TOS
 	ipv4PKTINFO  = unix.IP_PKTINFO
@@ -48,3 +61,45 @@ func parseIPv4PktInfo(body []byte) (ip netip.Addr, ifIndex uint32, ok bool) {
 	}
 	return netip.AddrFrom4(*(*[4]byte)(body[8:12])), binary.LittleEndian.Uint32(body), true
 }
+
+// isGSOSupported tests if the kernel supports GSO.
+// Sending with GSO might still fail later on, if the interface doesn't support it (see isGSOError).
+func isGSOSupported(conn syscall.RawConn) bool {
+	if gsoDisabled {
+		return false
+	}
+	var serr error
+	if err := conn.Control(func(fd uintptr) {
+		_, serr = unix.GetsockoptInt(int(fd), unix.IPPROTO_UDP, unix.UDP_SEGMENT)
+	}); err != nil {
+		return false
+	}
+	return serr == nil
+}
+
+func appendUDPSegmentSizeMsg(b []byte, size uint16) []byte {
+	startLen := len(b)
+	const dataLen = 2 // payload is a uint16
+	b = append(b, make([]byte, unix.CmsgSpace(dataLen))...)
+	h := (*unix.Cmsghdr)(unsafe.Pointer(&b[startLen]))
+	h.Level = syscall.IPPROTO_UDP
+	h.Type = unix.UDP_SEGMENT
+	h.SetLen(unix.CmsgLen(dataLen))
+
+	// UnixRights uses the private `data` method, but I *think* this achieves the same goal.
+	offset := startLen + unix.CmsgSpace(0)
+	*(*uint16)(unsafe.Pointer(&b[offset])) = size
+	return b
+}
+
+func isGSOError(err error) bool {
+	var serr *os.SyscallError
+	if errors.As(err, &serr) {
+		// EIO is returned by udp_send_skb() if the device driver does not have tx checksums enabled,
+		// which is a hard requirement of UDP_SEGMENT. See:
+		// https://git.kernel.org/pub/scm/docs/man-pages/man-pages.git/tree/man7/udp.7?id=806eabd74910447f21005160e90957bde4db0183#n228
+		// https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/net/ipv4/udp.c?h=v6.2&id=c9c3395d5e3dcc6daee66c6908354d47bf98cb0c#n942
+		return serr.Err == unix.EIO
+	}
+	return false
+}
diff --git a/sys_conn_helper_nonlinux.go b/sys_conn_helper_nonlinux.go
index 80b795c3..cace82d5 100644
--- a/sys_conn_helper_nonlinux.go
+++ b/sys_conn_helper_nonlinux.go
@@ -4,3 +4,6 @@ package quic
 
 func forceSetReceiveBuffer(c any, bytes int) error { return nil }
 func forceSetSendBuffer(c any, bytes int) error    { return nil }
+
+func appendUDPSegmentSizeMsg([]byte, uint16) []byte { return nil }
+func isGSOError(error) bool                         { return false }
diff --git a/sys_conn_no_gso.go b/sys_conn_no_gso.go
deleted file mode 100644
index 6f6a8c91..00000000
--- a/sys_conn_no_gso.go
+++ /dev/null
@@ -1,8 +0,0 @@
-//go:build darwin || freebsd
-
-package quic
-
-import "syscall"
-
-func maybeSetGSO(_ syscall.RawConn) bool                { return false }
-func appendUDPSegmentSizeMsg(_ []byte, _ uint16) []byte { return nil }
diff --git a/sys_conn_oob.go b/sys_conn_oob.go
index 84d5e7e6..4026a7b3 100644
--- a/sys_conn_oob.go
+++ b/sys_conn_oob.go
@@ -5,7 +5,6 @@ package quic
 import (
 	"encoding/binary"
 	"errors"
-	"fmt"
 	"log"
 	"net"
 	"net/netip"
@@ -128,10 +127,6 @@ func newConn(c OOBCapablePacketConn, supportsDF bool) (*oobConn, error) {
 		bc = ipv4.NewPacketConn(c)
 	}
 
-	// Try enabling GSO.
-	// This will only succeed on Linux, and only for kernels > 4.18.
-	supportsGSO := maybeSetGSO(rawConn)
-
 	msgs := make([]ipv4.Message, batchSize)
 	for i := range msgs {
 		// preallocate the [][]byte
@@ -142,9 +137,11 @@ func newConn(c OOBCapablePacketConn, supportsDF bool) (*oobConn, error) {
 		batchConn:            bc,
 		messages:             msgs,
 		readPos:              batchSize,
+		cap: connCapabilities{
+			DF:  supportsDF,
+			GSO: isGSOSupported(rawConn),
+		},
 	}
-	oobConn.cap.DF = supportsDF
-	oobConn.cap.GSO = supportsGSO
 	for i := 0; i < batchSize; i++ {
 		oobConn.messages[i].OOB = make([]byte, oobBufferSize)
 	}
@@ -231,17 +228,9 @@ func (c *oobConn) ReadPacket() (receivedPacket, error) {
 }
 
 // WritePacket writes a new packet.
-// If the connection supports GSO (and we activated GSO support before),
-// it appends the UDP_SEGMENT size message to oob.
-// Callers are advised to make sure that oob has a sufficient capacity,
-// such that appending the UDP_SEGMENT size message doesn't cause an allocation.
-func (c *oobConn) WritePacket(b []byte, packetSize uint16, addr net.Addr, oob []byte) (n int, err error) {
-	if c.cap.GSO {
-		oob = appendUDPSegmentSizeMsg(oob, packetSize)
-	} else if uint16(len(b)) != packetSize {
-		panic(fmt.Sprintf("inconsistent length. got: %d. expected %d", packetSize, len(b)))
-	}
-	n, _, err = c.OOBCapablePacketConn.WriteMsgUDP(b, oob, addr.(*net.UDPAddr))
+// If the connection supports GSO, it's the caller's responsibility to append the right control mesage.
+func (c *oobConn) WritePacket(b []byte, addr net.Addr, oob []byte) (int, error) {
+	n, _, err := c.OOBCapablePacketConn.WriteMsgUDP(b, oob, addr.(*net.UDPAddr))
 	return n, err
 }
 
diff --git a/transport.go b/transport.go
index 290b647f..ae44e3da 100644
--- a/transport.go
+++ b/transport.go
@@ -148,24 +148,15 @@ func (t *Transport) ListenEarly(tlsConf *tls.Config, conf *Config) (*EarlyListen
 
 // Dial dials a new connection to a remote host (not using 0-RTT).
 func (t *Transport) Dial(ctx context.Context, addr net.Addr, tlsConf *tls.Config, conf *Config) (Connection, error) {
-	if err := validateConfig(conf); err != nil {
-		return nil, err
-	}
-	conf = populateConfig(conf)
-	if err := t.init(t.isSingleUse); err != nil {
-		return nil, err
-	}
-	var onClose func()
-	if t.isSingleUse {
-		onClose = func() { t.Close() }
-	}
-	tlsConf = tlsConf.Clone()
-	tlsConf.MinVersion = tls.VersionTLS13
-	return dial(ctx, newSendConn(t.conn, addr), t.connIDGenerator, t.handlerMap, tlsConf, conf, onClose, false)
+	return t.dial(ctx, addr, "", tlsConf, conf, false)
 }
 
 // DialEarly dials a new connection, attempting to use 0-RTT if possible.
 func (t *Transport) DialEarly(ctx context.Context, addr net.Addr, tlsConf *tls.Config, conf *Config) (EarlyConnection, error) {
+	return t.dial(ctx, addr, "", tlsConf, conf, true)
+}
+
+func (t *Transport) dial(ctx context.Context, addr net.Addr, hostname string, tlsConf *tls.Config, conf *Config, use0RTT bool) (EarlyConnection, error) {
 	if err := validateConfig(conf); err != nil {
 		return nil, err
 	}
@@ -179,7 +170,16 @@ func (t *Transport) DialEarly(ctx context.Context, addr net.Addr, tlsConf *tls.C
 	}
 	tlsConf = tlsConf.Clone()
 	tlsConf.MinVersion = tls.VersionTLS13
-	return dial(ctx, newSendConn(t.conn, addr), t.connIDGenerator, t.handlerMap, tlsConf, conf, onClose, true)
+	// If no ServerName is set, infer the ServerName from the hostname we're connecting to.
+	if tlsConf.ServerName == "" {
+		if hostname == "" {
+			if udpAddr, ok := addr.(*net.UDPAddr); ok {
+				hostname = udpAddr.IP.String()
+			}
+		}
+		tlsConf.ServerName = hostname
+	}
+	return dial(ctx, newSendConn(t.conn, addr, packetInfo{}, utils.DefaultLogger), t.connIDGenerator, t.handlerMap, tlsConf, conf, onClose, use0RTT)
 }
 
 func (t *Transport) init(allowZeroLengthConnIDs bool) error {
@@ -195,7 +195,6 @@ func (t *Transport) init(allowZeroLengthConnIDs bool) error {
 				return
 			}
 		}
-		t.conn = conn
 
 		t.logger = utils.DefaultLogger // TODO: make this configurable
 		t.conn = conn
@@ -229,7 +228,7 @@ func (t *Transport) WriteTo(b []byte, addr net.Addr) (int, error) {
 	if err := t.init(false); err != nil {
 		return 0, err
 	}
-	return t.conn.WritePacket(b, uint16(len(b)), addr, nil)
+	return t.conn.WritePacket(b, addr, nil)
 }
 
 func (t *Transport) enqueueClosePacket(p closePacket) {
@@ -247,7 +246,7 @@ func (t *Transport) runSendQueue() {
 		case <-t.listening:
 			return
 		case p := <-t.closeQueue:
-			t.conn.WritePacket(p.payload, uint16(len(p.payload)), p.addr, p.info.OOB())
+			t.conn.WritePacket(p.payload, p.addr, p.info.OOB())
 		case p := <-t.statelessResetQueue:
 			t.sendStatelessReset(p)
 		}
@@ -408,7 +407,7 @@ func (t *Transport) sendStatelessReset(p receivedPacket) {
 	rand.Read(data)
 	data[0] = (data[0] & 0x7f) | 0x40
 	data = append(data, token[:]...)
-	if _, err := t.conn.WritePacket(data, uint16(len(data)), p.remoteAddr, p.info.OOB()); err != nil {
+	if _, err := t.conn.WritePacket(data, p.remoteAddr, p.info.OOB()); err != nil {
 		t.logger.Debugf("Error sending Stateless Reset to %s: %s", p.remoteAddr, err)
 	}
 }


Test patch:
diff --git a/crypto_stream_test.go b/crypto_stream_test.go
index 9a4a2ee5..67de9149 100644
--- a/crypto_stream_test.go
+++ b/crypto_stream_test.go
@@ -1,6 +1,7 @@
 package quic
 
 import (
+	"crypto/rand"
 	"fmt"
 
 	"github.com/quic-go/quic-go/internal/protocol"
@@ -15,7 +16,7 @@ var _ = Describe("Crypto Stream", func() {
 	var str cryptoStream
 
 	BeforeEach(func() {
-		str = newCryptoStream()
+		str = newCryptoStream(false)
 	})
 
 	Context("handling incoming data", func() {
@@ -137,4 +138,23 @@ var _ = Describe("Crypto Stream", func() {
 			Expect(f.Data).To(Equal([]byte("bar")))
 		})
 	})
+
+	It("reassembles data", func() {
+		str = newCryptoStream(true)
+		data := make([]byte, 1337)
+		l := len(data) - 4
+		data[1] = uint8(l >> 16)
+		data[2] = uint8(l >> 8)
+		data[3] = uint8(l)
+		rand.Read(data[4:])
+
+		for i, b := range data {
+			Expect(str.GetCryptoData()).To(BeEmpty())
+			Expect(str.HandleCryptoFrame(&wire.CryptoFrame{
+				Offset: protocol.ByteCount(i),
+				Data:   []byte{b},
+			})).To(Succeed())
+		}
+		Expect(str.GetCryptoData()).To(Equal(data))
+	})
 })
diff --git a/integrationtests/gomodvendor/go.sum b/integrationtests/gomodvendor/go.sum
index ad9adb31..ab32e138 100644
--- a/integrationtests/gomodvendor/go.sum
+++ b/integrationtests/gomodvendor/go.sum
@@ -136,8 +136,8 @@ github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7q
 github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
 github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A=
-github.com/quic-go/qtls-go1-20 v0.3.1 h1:O4BLOM3hwfVF3AcktIylQXyl7Yi2iBNVy5QsV+ySxbg=
-github.com/quic-go/qtls-go1-20 v0.3.1/go.mod h1:X9Nh97ZL80Z+bX/gUXMbipO6OxdiDi58b/fMC9mAL+k=
+github.com/quic-go/qtls-go1-20 v0.3.2 h1:rRgN3WfnKbyik4dBV8A6girlJVxGand/d+jVKbQq5GI=
+github.com/quic-go/qtls-go1-20 v0.3.2/go.mod h1:X9Nh97ZL80Z+bX/gUXMbipO6OxdiDi58b/fMC9mAL+k=
 github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
 github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY=
diff --git a/integrationtests/self/handshake_test.go b/integrationtests/self/handshake_test.go
index cccff8a6..8123c8fe 100644
--- a/integrationtests/self/handshake_test.go
+++ b/integrationtests/self/handshake_test.go
@@ -202,6 +202,8 @@ var _ = Describe("Handshake tests", func() {
 			Expect(errors.As(err, &transportErr)).To(BeTrue())
 			Expect(transportErr.ErrorCode.IsCryptoError()).To(BeTrue())
 			Expect(transportErr.Error()).To(ContainSubstring("x509: certificate is valid for localhost, not foo.bar"))
+			var certErr *tls.CertificateVerificationError
+			Expect(errors.As(transportErr, &certErr)).To(BeTrue())
 		})
 
 		It("fails the handshake if the client fails to provide the requested client cert", func() {
@@ -453,7 +455,7 @@ var _ = Describe("Handshake tests", func() {
 
 		It("rejects invalid Retry token with the INVALID_TOKEN error", func() {
 			serverConfig.RequireAddressValidation = func(net.Addr) bool { return true }
-			serverConfig.MaxRetryTokenAge = time.Nanosecond
+			serverConfig.MaxRetryTokenAge = -time.Second
 
 			server, err := quic.ListenAddr("localhost:0", getTLSConfig(), serverConfig)
 			Expect(err).ToNot(HaveOccurred())
diff --git a/integrationtests/self/http_test.go b/integrationtests/self/http_test.go
index e41dc85e..b647d4d9 100644
--- a/integrationtests/self/http_test.go
+++ b/integrationtests/self/http_test.go
@@ -42,7 +42,7 @@ var _ = Describe("HTTP tests", func() {
 		rt             *http3.RoundTripper
 		server         *http3.Server
 		stoppedServing chan struct{}
-		port           string
+		port           int
 	)
 
 	BeforeEach(func() {
@@ -93,7 +93,7 @@ var _ = Describe("HTTP tests", func() {
 		Expect(err).NotTo(HaveOccurred())
 		conn, err := net.ListenUDP("udp", addr)
 		Expect(err).NotTo(HaveOccurred())
-		port = strconv.Itoa(conn.LocalAddr().(*net.UDPAddr).Port)
+		port = conn.LocalAddr().(*net.UDPAddr).Port
 
 		stoppedServing = make(chan struct{})
 
@@ -120,7 +120,7 @@ var _ = Describe("HTTP tests", func() {
 	})
 
 	It("downloads a hello", func() {
-		resp, err := client.Get("https://localhost:" + port + "/hello")
+		resp, err := client.Get(fmt.Sprintf("https://localhost:%d/hello", port))
 		Expect(err).ToNot(HaveOccurred())
 		Expect(resp.StatusCode).To(Equal(200))
 		body, err := io.ReadAll(gbytes.TimeoutReader(resp.Body, 3*time.Second))
@@ -129,12 +129,12 @@ var _ = Describe("HTTP tests", func() {
 	})
 
 	It("requests to different servers with the same udpconn", func() {
-		resp, err := client.Get("https://localhost:" + port + "/remoteAddr")
+		resp, err := client.Get(fmt.Sprintf("https://localhost:%d/remoteAddr", port))
 		Expect(err).ToNot(HaveOccurred())
 		Expect(resp.StatusCode).To(Equal(200))
 		addr1 := resp.Header.Get("X-RemoteAddr")
 		Expect(addr1).ToNot(Equal(""))
-		resp, err = client.Get("https://127.0.0.1:" + port + "/remoteAddr")
+		resp, err = client.Get(fmt.Sprintf("https://127.0.0.1:%d/remoteAddr", port))
 		Expect(err).ToNot(HaveOccurred())
 		Expect(resp.StatusCode).To(Equal(200))
 		addr2 := resp.Header.Get("X-RemoteAddr")
@@ -146,7 +146,7 @@ var _ = Describe("HTTP tests", func() {
 		group, ctx := errgroup.WithContext(context.Background())
 		for i := 0; i < 2; i++ {
 			group.Go(func() error {
-				req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://localhost:"+port+"/hello", nil)
+				req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://localhost:%d/hello", port), nil)
 				Expect(err).ToNot(HaveOccurred())
 				resp, err := client.Do(req)
 				Expect(err).ToNot(HaveOccurred())
@@ -172,7 +172,7 @@ var _ = Describe("HTTP tests", func() {
 			close(handlerCalled)
 		})
 
-		req, err := http.NewRequest(http.MethodGet, "https://localhost:"+port+"/headers/request", nil)
+		req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://localhost:%d/headers/request", port), nil)
 		Expect(err).ToNot(HaveOccurred())
 		req.Header.Set("foo", "bar")
 		req.Header.Set("lorem", "ipsum")
@@ -189,7 +189,7 @@ var _ = Describe("HTTP tests", func() {
 			w.Header().Set("lorem", "ipsum")
 		})
 
-		resp, err := client.Get("https://localhost:" + port + "/headers/response")
+		resp, err := client.Get(fmt.Sprintf("https://localhost:%d/headers/response", port))
 		Expect(err).ToNot(HaveOccurred())
 		Expect(resp.StatusCode).To(Equal(200))
 		Expect(resp.Header.Get("foo")).To(Equal("bar"))
@@ -197,7 +197,7 @@ var _ = Describe("HTTP tests", func() {
 	})
 
 	It("downloads a small file", func() {
-		resp, err := client.Get("https://localhost:" + port + "/prdata")
+		resp, err := client.Get(fmt.Sprintf("https://localhost:%d/prdata", port))
 		Expect(err).ToNot(HaveOccurred())
 		Expect(resp.StatusCode).To(Equal(200))
 		body, err := io.ReadAll(gbytes.TimeoutReader(resp.Body, 5*time.Second))
@@ -206,7 +206,7 @@ var _ = Describe("HTTP tests", func() {
 	})
 
 	It("downloads a large file", func() {
-		resp, err := client.Get("https://localhost:" + port + "/prdatalong")
+		resp, err := client.Get(fmt.Sprintf("https://localhost:%d/prdatalong", port))
 		Expect(err).ToNot(HaveOccurred())
 		Expect(resp.StatusCode).To(Equal(200))
 		body, err := io.ReadAll(gbytes.TimeoutReader(resp.Body, 20*time.Second))
@@ -218,7 +218,7 @@ var _ = Describe("HTTP tests", func() {
 		const num = 150
 
 		for i := 0; i < num; i++ {
-			resp, err := client.Get("https://localhost:" + port + "/hello")
+			resp, err := client.Get(fmt.Sprintf("https://localhost:%d/hello", port))
 			Expect(err).ToNot(HaveOccurred())
 			Expect(resp.StatusCode).To(Equal(200))
 			body, err := io.ReadAll(gbytes.TimeoutReader(resp.Body, 3*time.Second))
@@ -231,7 +231,7 @@ var _ = Describe("HTTP tests", func() {
 		const num = 150
 
 		for i := 0; i < num; i++ {
-			resp, err := client.Get("https://localhost:" + port + "/prdata")
+			resp, err := client.Get(fmt.Sprintf("https://localhost:%d/prdata", port))
 			Expect(err).ToNot(HaveOccurred())
 			Expect(resp.StatusCode).To(Equal(200))
 			Expect(resp.Body.Close()).To(Succeed())
@@ -240,7 +240,7 @@ var _ = Describe("HTTP tests", func() {
 
 	It("posts a small message", func() {
 		resp, err := client.Post(
-			"https://localhost:"+port+"/echo",
+			fmt.Sprintf("https://localhost:%d/echo", port),
 			"text/plain",
 			bytes.NewReader([]byte("Hello, world!")),
 		)
@@ -253,7 +253,7 @@ var _ = Describe("HTTP tests", func() {
 
 	It("uploads a file", func() {
 		resp, err := client.Post(
-			"https://localhost:"+port+"/echo",
+			fmt.Sprintf("https://localhost:%d/echo", port),
 			"text/plain",
 			bytes.NewReader(PRData),
 		)
@@ -277,7 +277,7 @@ var _ = Describe("HTTP tests", func() {
 		})
 
 		client.Transport.(*http3.RoundTripper).DisableCompression = false
-		resp, err := client.Get("https://localhost:" + port + "/gzipped/hello")
+		resp, err := client.Get(fmt.Sprintf("https://localhost:%d/gzipped/hello", port))
 		Expect(err).ToNot(HaveOccurred())
 		Expect(resp.StatusCode).To(Equal(200))
 		Expect(resp.Uncompressed).To(BeTrue())
@@ -303,7 +303,7 @@ var _ = Describe("HTTP tests", func() {
 			}
 		})
 
-		req, err := http.NewRequest(http.MethodGet, "https://localhost:"+port+"/cancel", nil)
+		req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://localhost:%d/cancel", port), nil)
 		Expect(err).ToNot(HaveOccurred())
 		ctx, cancel := context.WithCancel(context.Background())
 		req = req.WithContext(ctx)
@@ -336,7 +336,7 @@ var _ = Describe("HTTP tests", func() {
 		})
 
 		r, w := io.Pipe()
-		req, err := http.NewRequest("PUT", "https://localhost:"+port+"/echoline", r)
+		req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("https://localhost:%d/echoline", port), r)
 		Expect(err).ToNot(HaveOccurred())
 		rsp, err := client.Do(req)
 		Expect(err).ToNot(HaveOccurred())
@@ -373,7 +373,7 @@ var _ = Describe("HTTP tests", func() {
 			}()
 		})
 
-		req, err := http.NewRequest(http.MethodGet, "https://localhost:"+port+"/httpstreamer", nil)
+		req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://localhost:%d/httpstreamer", port), nil)
 		Expect(err).ToNot(HaveOccurred())
 		rsp, err := client.Transport.(*http3.RoundTripper).RoundTripOpt(req, http3.RoundTripOpt{DontCloseRequestStream: true})
 		Expect(err).ToNot(HaveOccurred())
@@ -431,7 +431,11 @@ var _ = Describe("HTTP tests", func() {
 			})
 
 			expectedEnd := time.Now().Add(deadlineDelay)
-			resp, err := client.Post("https://localhost:"+port+"/read-deadline", "text/plain", neverEnding('a'))
+			resp, err := client.Post(
+				fmt.Sprintf("https://localhost:%d/read-deadline", port),
+				"text/plain",
+				neverEnding('a'),
+			)
 			Expect(err).ToNot(HaveOccurred())
 			Expect(resp.StatusCode).To(Equal(200))
 
@@ -453,7 +457,7 @@ var _ = Describe("HTTP tests", func() {
 
 			expectedEnd := time.Now().Add(deadlineDelay)
 
-			resp, err := client.Get("https://localhost:" + port + "/write-deadline")
+			resp, err := client.Get(fmt.Sprintf("https://localhost:%d/write-deadline", port))
 			Expect(err).ToNot(HaveOccurred())
 			Expect(resp.StatusCode).To(Equal(200))
 
diff --git a/integrationtests/self/resumption_test.go b/integrationtests/self/resumption_test.go
index 264f832e..bedf0470 100644
--- a/integrationtests/self/resumption_test.go
+++ b/integrationtests/self/resumption_test.go
@@ -56,7 +56,7 @@ var _ = Describe("TLS session resumption", func() {
 			context.Background(),
 			fmt.Sprintf("localhost:%d", server.Addr().(*net.UDPAddr).Port),
 			tlsConf,
-			nil,
+			getQuicConfig(nil),
 		)
 		Expect(err).ToNot(HaveOccurred())
 		var sessionKey string
@@ -71,7 +71,7 @@ var _ = Describe("TLS session resumption", func() {
 			context.Background(),
 			fmt.Sprintf("localhost:%d", server.Addr().(*net.UDPAddr).Port),
 			tlsConf,
-			nil,
+			getQuicConfig(nil),
 		)
 		Expect(err).ToNot(HaveOccurred())
 		Expect(gets).To(Receive(Equal(sessionKey)))
@@ -85,7 +85,7 @@ var _ = Describe("TLS session resumption", func() {
 	It("doesn't use session resumption, if the config disables it", func() {
 		sConf := getTLSConfig()
 		sConf.SessionTicketsDisabled = true
-		server, err := quic.ListenAddr("localhost:0", sConf, nil)
+		server, err := quic.ListenAddr("localhost:0", sConf, getQuicConfig(nil))
 		Expect(err).ToNot(HaveOccurred())
 		defer server.Close()
 
@@ -98,7 +98,7 @@ var _ = Describe("TLS session resumption", func() {
 			context.Background(),
 			fmt.Sprintf("localhost:%d", server.Addr().(*net.UDPAddr).Port),
 			tlsConf,
-			nil,
+			getQuicConfig(nil),
 		)
 		Expect(err).ToNot(HaveOccurred())
 		Consistently(puts).ShouldNot(Receive())
@@ -114,7 +114,55 @@ var _ = Describe("TLS session resumption", func() {
 			context.Background(),
 			fmt.Sprintf("localhost:%d", server.Addr().(*net.UDPAddr).Port),
 			tlsConf,
-			nil,
+			getQuicConfig(nil),
+		)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(conn.ConnectionState().TLS.DidResume).To(BeFalse())
+
+		serverConn, err = server.Accept(context.Background())
+		Expect(err).ToNot(HaveOccurred())
+		Expect(serverConn.ConnectionState().TLS.DidResume).To(BeFalse())
+	})
+
+	It("doesn't use session resumption, if the config returned by GetConfigForClient disables it", func() {
+		sConf := &tls.Config{
+			GetConfigForClient: func(*tls.ClientHelloInfo) (*tls.Config, error) {
+				conf := getTLSConfig()
+				conf.SessionTicketsDisabled = true
+				return conf, nil
+			},
+		}
+
+		server, err := quic.ListenAddr("localhost:0", sConf, getQuicConfig(nil))
+		Expect(err).ToNot(HaveOccurred())
+		defer server.Close()
+
+		gets := make(chan string, 100)
+		puts := make(chan string, 100)
+		cache := newClientSessionCache(tls.NewLRUClientSessionCache(10), gets, puts)
+		tlsConf := getTLSClientConfig()
+		tlsConf.ClientSessionCache = cache
+		conn, err := quic.DialAddr(
+			context.Background(),
+			fmt.Sprintf("localhost:%d", server.Addr().(*net.UDPAddr).Port),
+			tlsConf,
+			getQuicConfig(nil),
+		)
+		Expect(err).ToNot(HaveOccurred())
+		Consistently(puts).ShouldNot(Receive())
+		Expect(conn.ConnectionState().TLS.DidResume).To(BeFalse())
+
+		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+		defer cancel()
+		serverConn, err := server.Accept(ctx)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(serverConn.ConnectionState().TLS.DidResume).To(BeFalse())
+
+		conn, err = quic.DialAddr(
+			context.Background(),
+			fmt.Sprintf("localhost:%d", server.Addr().(*net.UDPAddr).Port),
+			tlsConf,
+			getQuicConfig(nil),
 		)
 		Expect(err).ToNot(HaveOccurred())
 		Expect(conn.ConnectionState().TLS.DidResume).To(BeFalse())
diff --git a/integrationtests/tools/proxy/proxy_test.go b/integrationtests/tools/proxy/proxy_test.go
index 81e13b23..c9b80be5 100644
--- a/integrationtests/tools/proxy/proxy_test.go
+++ b/integrationtests/tools/proxy/proxy_test.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"fmt"
 	"net"
+	"runtime"
 	"runtime/pprof"
 	"strconv"
 	"strings"
@@ -68,7 +69,11 @@ var _ = Describe("QUIC Proxy", func() {
 			addr, err := net.ResolveUDPAddr("udp", "localhost:"+strconv.Itoa(proxy.LocalPort()))
 			Expect(err).ToNot(HaveOccurred())
 			_, err = net.ListenUDP("udp", addr)
-			Expect(err).To(MatchError(fmt.Sprintf("listen udp 127.0.0.1:%d: bind: address already in use", proxy.LocalPort())))
+			if runtime.GOOS == "windows" {
+				Expect(err).To(MatchError(fmt.Sprintf("listen udp 127.0.0.1:%d: bind: Only one usage of each socket address (protocol/network address/port) is normally permitted.", proxy.LocalPort())))
+			} else {
+				Expect(err).To(MatchError(fmt.Sprintf("listen udp 127.0.0.1:%d: bind: address already in use", proxy.LocalPort())))
+			}
 			Expect(proxy.Close()).To(Succeed()) // stopping is tested in the next test
 		})
 
diff --git a/internal/protocol/protocol_test.go b/internal/protocol/protocol_test.go
index d9048f01..e672d31e 100644
--- a/internal/protocol/protocol_test.go
+++ b/internal/protocol/protocol_test.go
@@ -22,4 +22,12 @@ var _ = Describe("Protocol", func() {
 		Expect(ECN(0b00000001)).To(Equal(ECT1))
 		Expect(ECN(0b00000011)).To(Equal(ECNCE))
 	})
+
+	It("has a string representation for ECN", func() {
+		Expect(ECNNon.String()).To(Equal("Not-ECT"))
+		Expect(ECT0.String()).To(Equal("ECT(0)"))
+		Expect(ECT1.String()).To(Equal("ECT(1)"))
+		Expect(ECNCE.String()).To(Equal("CE"))
+		Expect(ECN(42).String()).To(Equal("invalid ECN value: 42"))
+	})
 })
diff --git a/internal/qerr/errors_test.go b/internal/qerr/errors_test.go
index 52d5c9e9..770ebd68 100644
--- a/internal/qerr/errors_test.go
+++ b/internal/qerr/errors_test.go
@@ -2,6 +2,7 @@ package qerr
 
 import (
 	"errors"
+	"fmt"
 	"net"
 
 	"github.com/quic-go/quic-go/internal/protocol"
@@ -10,6 +11,12 @@ import (
 	. "github.com/onsi/gomega"
 )
 
+type myError int
+
+var _ error = myError(0)
+
+func (e myError) Error() string { return fmt.Sprintf("my error %d", e) }
+
 var _ = Describe("QUIC Errors", func() {
 	Context("Transport Errors", func() {
 		It("has a string representation", func() {
@@ -41,12 +48,20 @@ var _ = Describe("QUIC Errors", func() {
 
 		Context("crypto errors", func() {
 			It("has a string representation for errors with a message", func() {
-				err := NewLocalCryptoError(0x42, "foobar")
-				Expect(err.Error()).To(Equal("CRYPTO_ERROR 0x142 (local): foobar"))
+				myErr := myError(1337)
+				err := NewLocalCryptoError(0x42, myErr)
+				Expect(err.Error()).To(Equal("CRYPTO_ERROR 0x142 (local): my error 1337"))
+			})
+
+			It("unwraps errors", func() {
+				var myErr myError
+				err := NewLocalCryptoError(0x42, myError(1337))
+				Expect(errors.As(err, &myErr)).To(BeTrue())
+				Expect(myErr).To(BeEquivalentTo(1337))
 			})
 
 			It("has a string representation for errors without a message", func() {
-				err := NewLocalCryptoError(0x2a, "")
+				err := NewLocalCryptoError(0x2a, nil)
 				Expect(err.Error()).To(Equal("CRYPTO_ERROR 0x12a (local): tls: bad certificate"))
 			})
 		})
diff --git a/mock_raw_conn_test.go b/mock_raw_conn_test.go
new file mode 100644
index 00000000..66b9c611
--- /dev/null
+++ b/mock_raw_conn_test.go
@@ -0,0 +1,122 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/quic-go/quic-go (interfaces: RawConn)
+
+// Package quic is a generated GoMock package.
+package quic
+
+import (
+	net "net"
+	reflect "reflect"
+	time "time"
+
+	gomock "github.com/golang/mock/gomock"
+)
+
+// MockRawConn is a mock of RawConn interface.
+type MockRawConn struct {
+	ctrl     *gomock.Controller
+	recorder *MockRawConnMockRecorder
+}
+
+// MockRawConnMockRecorder is the mock recorder for MockRawConn.
+type MockRawConnMockRecorder struct {
+	mock *MockRawConn
+}
+
+// NewMockRawConn creates a new mock instance.
+func NewMockRawConn(ctrl *gomock.Controller) *MockRawConn {
+	mock := &MockRawConn{ctrl: ctrl}
+	mock.recorder = &MockRawConnMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockRawConn) EXPECT() *MockRawConnMockRecorder {
+	return m.recorder
+}
+
+// Close mocks base method.
+func (m *MockRawConn) Close() error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Close")
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Close indicates an expected call of Close.
+func (mr *MockRawConnMockRecorder) Close() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockRawConn)(nil).Close))
+}
+
+// LocalAddr mocks base method.
+func (m *MockRawConn) LocalAddr() net.Addr {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "LocalAddr")
+	ret0, _ := ret[0].(net.Addr)
+	return ret0
+}
+
+// LocalAddr indicates an expected call of LocalAddr.
+func (mr *MockRawConnMockRecorder) LocalAddr() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LocalAddr", reflect.TypeOf((*MockRawConn)(nil).LocalAddr))
+}
+
+// ReadPacket mocks base method.
+func (m *MockRawConn) ReadPacket() (receivedPacket, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ReadPacket")
+	ret0, _ := ret[0].(receivedPacket)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// ReadPacket indicates an expected call of ReadPacket.
+func (mr *MockRawConnMockRecorder) ReadPacket() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadPacket", reflect.TypeOf((*MockRawConn)(nil).ReadPacket))
+}
+
+// SetReadDeadline mocks base method.
+func (m *MockRawConn) SetReadDeadline(arg0 time.Time) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "SetReadDeadline", arg0)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// SetReadDeadline indicates an expected call of SetReadDeadline.
+func (mr *MockRawConnMockRecorder) SetReadDeadline(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetReadDeadline", reflect.TypeOf((*MockRawConn)(nil).SetReadDeadline), arg0)
+}
+
+// WritePacket mocks base method.
+func (m *MockRawConn) WritePacket(arg0 []byte, arg1 net.Addr, arg2 []byte) (int, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "WritePacket", arg0, arg1, arg2)
+	ret0, _ := ret[0].(int)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// WritePacket indicates an expected call of WritePacket.
+func (mr *MockRawConnMockRecorder) WritePacket(arg0, arg1, arg2 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WritePacket", reflect.TypeOf((*MockRawConn)(nil).WritePacket), arg0, arg1, arg2)
+}
+
+// capabilities mocks base method.
+func (m *MockRawConn) capabilities() connCapabilities {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "capabilities")
+	ret0, _ := ret[0].(connCapabilities)
+	return ret0
+}
+
+// capabilities indicates an expected call of capabilities.
+func (mr *MockRawConnMockRecorder) capabilities() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "capabilities", reflect.TypeOf((*MockRawConn)(nil).capabilities))
+}
diff --git a/packet_packer_test.go b/packet_packer_test.go
index 3ff15eaf..38ced47a 100644
--- a/packet_packer_test.go
+++ b/packet_packer_test.go
@@ -2,6 +2,7 @@ package quic
 
 import (
 	"bytes"
+	"errors"
 	"fmt"
 	"net"
 	"time"
@@ -334,7 +335,7 @@ var _ = Describe("Packet packer", func() {
 				sealingManager.EXPECT().GetInitialSealer().Return(nil, handshake.ErrKeysDropped)
 				sealingManager.EXPECT().GetHandshakeSealer().Return(getSealer(), nil)
 				sealingManager.EXPECT().Get1RTTSealer().Return(nil, handshake.ErrKeysNotYetAvailable)
-				quicErr := qerr.NewLocalCryptoError(0x42, "crypto error")
+				quicErr := qerr.NewLocalCryptoError(0x42, errors.New("crypto error"))
 				quicErr.FrameType = 0x1234
 				p, err := packer.PackConnectionClose(quicErr, maxPacketSize, protocol.Version1)
 				Expect(err).ToNot(HaveOccurred())
diff --git a/send_conn_test.go b/send_conn_test.go
index 56fe9236..024a8eba 100644
--- a/send_conn_test.go
+++ b/send_conn_test.go
@@ -2,46 +2,81 @@ package quic
 
 import (
 	"net"
+	"net/netip"
 
+	"github.com/quic-go/quic-go/internal/utils"
+
+	"github.com/golang/mock/gomock"
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
 )
 
-var _ = Describe("Connection (for sending packets)", func() {
-	var (
-		c          sendConn
-		packetConn *MockPacketConn
-		addr       net.Addr
-	)
-
-	BeforeEach(func() {
-		addr = &net.UDPAddr{IP: net.IPv4(192, 168, 100, 200), Port: 1337}
-		packetConn = NewMockPacketConn(mockCtrl)
-		rawConn, err := wrapConn(packetConn)
-		Expect(err).ToNot(HaveOccurred())
-		c = newSendConnWithPacketInfo(rawConn, addr, packetInfo{})
-	})
+// Only if appendUDPSegmentSizeMsg actually appends a message (and isn't only a stub implementation),
+// GSO is actually supported on this platform.
+var platformSupportsGSO = len(appendUDPSegmentSizeMsg([]byte{}, 1337)) > 0
 
-	It("writes", func() {
-		packetConn.EXPECT().WriteTo([]byte("foobar"), addr)
-		Expect(c.Write([]byte("foobar"), 6)).To(Succeed())
-	})
+var _ = Describe("Connection (for sending packets)", func() {
+	remoteAddr := &net.UDPAddr{IP: net.IPv4(192, 168, 100, 200), Port: 1337}
 
-	It("gets the remote address", func() {
+	It("gets the local and remote addresses", func() {
+		localAddr := &net.UDPAddr{IP: net.IPv4(192, 168, 0, 1), Port: 1234}
+		rawConn := NewMockRawConn(mockCtrl)
+		rawConn.EXPECT().LocalAddr().Return(localAddr)
+		c := newSendConn(rawConn, remoteAddr, packetInfo{}, utils.DefaultLogger)
+		Expect(c.LocalAddr().String()).To(Equal("192.168.0.1:1234"))
 		Expect(c.RemoteAddr().String()).To(Equal("192.168.100.200:1337"))
 	})
 
-	It("gets the local address", func() {
-		addr := &net.UDPAddr{
-			IP:   net.IPv4(192, 168, 0, 1),
-			Port: 1234,
-		}
-		packetConn.EXPECT().LocalAddr().Return(addr)
-		Expect(c.LocalAddr()).To(Equal(addr))
+	It("uses the local address from the packet info", func() {
+		localAddr := &net.UDPAddr{IP: net.IPv4(192, 168, 0, 1), Port: 1234}
+		rawConn := NewMockRawConn(mockCtrl)
+		rawConn.EXPECT().LocalAddr().Return(localAddr)
+		c := newSendConn(rawConn, remoteAddr, packetInfo{addr: netip.AddrFrom4([4]byte{127, 0, 0, 42})}, utils.DefaultLogger)
+		Expect(c.LocalAddr().String()).To(Equal("127.0.0.42:1234"))
 	})
 
-	It("closes", func() {
-		packetConn.EXPECT().Close()
-		Expect(c.Close()).To(Succeed())
-	})
+	if platformSupportsGSO {
+		It("writes with GSO", func() {
+			rawConn := NewMockRawConn(mockCtrl)
+			rawConn.EXPECT().LocalAddr()
+			rawConn.EXPECT().capabilities().Return(connCapabilities{GSO: true}).AnyTimes()
+			c := newSendConn(rawConn, remoteAddr, packetInfo{}, utils.DefaultLogger)
+			rawConn.EXPECT().WritePacket([]byte("foobar"), remoteAddr, gomock.Any()).Do(func(_ []byte, _ net.Addr, oob []byte) {
+				msg := appendUDPSegmentSizeMsg([]byte{}, 3)
+				Expect(oob).To(Equal(msg))
+			})
+			Expect(c.Write([]byte("foobar"), 3)).To(Succeed())
+		})
+
+		It("disables GSO if writing fails", func() {
+			rawConn := NewMockRawConn(mockCtrl)
+			rawConn.EXPECT().LocalAddr()
+			rawConn.EXPECT().capabilities().Return(connCapabilities{GSO: true}).AnyTimes()
+			c := newSendConn(rawConn, remoteAddr, packetInfo{}, utils.DefaultLogger)
+			Expect(c.capabilities().GSO).To(BeTrue())
+			gomock.InOrder(
+				rawConn.EXPECT().WritePacket([]byte("foobar"), remoteAddr, gomock.Any()).DoAndReturn(func(_ []byte, _ net.Addr, oob []byte) (int, error) {
+					msg := appendUDPSegmentSizeMsg([]byte{}, 3)
+					Expect(oob).To(Equal(msg))
+					return 0, errGSO
+				}),
+				rawConn.EXPECT().WritePacket([]byte("foo"), remoteAddr, gomock.Len(0)).Return(3, nil),
+				rawConn.EXPECT().WritePacket([]byte("bar"), remoteAddr, gomock.Len(0)).Return(3, nil),
+			)
+			Expect(c.Write([]byte("foobar"), 3)).To(Succeed())
+			Expect(c.capabilities().GSO).To(BeFalse()) // GSO support is now disabled
+			// make sure we actually enforce that
+			Expect(func() { c.Write([]byte("foobar"), 3) }).To(PanicWith("inconsistent packet size (6 vs 3)"))
+		})
+	} else {
+		It("writes without GSO", func() {
+			remoteAddr := &net.UDPAddr{IP: net.IPv4(192, 168, 100, 200), Port: 1337}
+			rawConn := NewMockRawConn(mockCtrl)
+			rawConn.EXPECT().LocalAddr()
+			rawConn.EXPECT().capabilities()
+			c := newSendConn(rawConn, remoteAddr, packetInfo{}, utils.DefaultLogger)
+			rawConn.EXPECT().WritePacket([]byte("foobar"), remoteAddr, gomock.Len(0))
+			Expect(c.Write([]byte("foobar"), 6)).To(Succeed())
+		})
+	}
 })
diff --git a/sys_conn_helper_linux_test.go b/sys_conn_helper_linux_test.go
index fa39c523..4cf59abe 100644
--- a/sys_conn_helper_linux_test.go
+++ b/sys_conn_helper_linux_test.go
@@ -1,22 +1,24 @@
-// We need root permissions to use RCVBUFFORCE.
-// This test is therefore only compiled when the root build flag is set.
-// It can only succeed if the tests are then also run with root permissions.
-//go:build linux && root
+//go:build linux
 
 package quic
 
 import (
+	"errors"
 	"net"
 	"os"
 
+	"golang.org/x/sys/unix"
+
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
 )
 
+var errGSO = &os.SyscallError{Err: unix.EIO}
+
 var _ = Describe("forcing a change of send and receive buffer sizes", func() {
 	It("forces a change of the receive buffer size", func() {
 		if os.Getuid() != 0 {
-			Fail("Must be root to force change the receive buffer size")
+			Skip("Must be root to force change the receive buffer size")
 		}
 
 		c, err := net.ListenPacket("udp", "127.0.0.1:0")
@@ -43,7 +45,7 @@ var _ = Describe("forcing a change of send and receive buffer sizes", func() {
 
 	It("forces a change of the send buffer size", func() {
 		if os.Getuid() != 0 {
-			Fail("Must be root to force change the send buffer size")
+			Skip("Must be root to force change the send buffer size")
 		}
 
 		c, err := net.ListenPacket("udp", "127.0.0.1:0")
@@ -67,4 +69,10 @@ var _ = Describe("forcing a change of send and receive buffer sizes", func() {
 		//  The kernel doubles this value (to allow space for bookkeeping overhead)
 		Expect(size).To(Equal(2 * large))
 	})
+
+	It("detects GSO errors", func() {
+		Expect(isGSOError(errGSO)).To(BeTrue())
+		Expect(isGSOError(nil)).To(BeFalse())
+		Expect(isGSOError(errors.New("test"))).To(BeFalse())
+	})
 })
diff --git a/sys_conn_helper_nonlinux_test.go b/sys_conn_helper_nonlinux_test.go
new file mode 100644
index 00000000..29d42ad3
--- /dev/null
+++ b/sys_conn_helper_nonlinux_test.go
@@ -0,0 +1,7 @@
+//go:build !linux
+
+package quic
+
+import "errors"
+
+var errGSO = errors.New("fake GSO error")