Repository: quic-go/quic-go
Pull request #3989 (2023-07-22 13:33:37)

http3: automatically add content-length for small responses
…all enough and doesn't call Flush

Another iteration of [3954](https://github.com/quic-go/quic-go/pull/3954).

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/quic-go/quic-go/pull/3989?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
> Merging [#3989](https://app.codecov.io/gh/quic-go/quic-go/pull/3989?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (e1f1b0e) into [master](https://app.codecov.io/gh/quic-go/quic-go/commit/469a6153b6798780bf90e8be37f197d0fcb18af2?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (469a615) will **decrease** coverage by `0.01%`.
> The diff coverage is `77.55%`.

```diff
@@            Coverage Diff             @@
##           master    #3989      +/-   ##
==========================================
- Coverage   82.86%   82.85%   -0.01%     
==========================================
  Files         146      146              
  Lines       14729    14752      +23     
==========================================
+ Hits        12204    12222      +18     
- Misses       2025     2029       +4     
- Partials      500      501       +1     
```


| [Impacted Files](https://app.codecov.io/gh/quic-go/quic-go/pull/3989?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [http3/response\_writer.go](https://app.codecov.io/gh/quic-go/quic-go/pull/3989?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-aHR0cDMvcmVzcG9uc2Vfd3JpdGVyLmdv) | `82.03% <75.00%> (-1.30%)` | :arrow_down: |
| [http3/server.go](https://app.codecov.io/gh/quic-go/quic-go/pull/3989?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-aHR0cDMvc2VydmVyLmdv) | `77.73% <100.00%> (+0.15%)` | :arrow_up: |


WeidiDeng: @marten-seemann Do you have time to take a look at this pr?
WeidiDeng: I don't known if we should flush headers immediately for large headers 😅 , as far as I know, I use this branch and [previous one](https://github.com/quic-go/quic-go/pull/3954) without encountering any problems at all.
WeidiDeng: I don't know why these connection tests failed 🤷 .

Patch:
diff --git a/.github/workflows/build-interop-docker.yml b/.github/workflows/build-interop-docker.yml
index e16663c7..bef6f205 100644
--- a/.github/workflows/build-interop-docker.yml
+++ b/.github/workflows/build-interop-docker.yml
@@ -1,11 +1,14 @@
 name: Build interop Docker image
 on: 
   push:
-    branches: [ interop ]
+    branches:
+      - master
+    tags:
+      - 'v*'
   
 jobs:
   interop:
-    runs-on: ubuntu-latest
+    runs-on: ${{ fromJSON(vars['DOCKER_RUNNER_UBUNTU'] || '"ubuntu-latest"') }}
     steps:
       - uses: actions/checkout@v3
       - name: Set up QEMU
@@ -19,9 +22,23 @@ jobs:
         with:
           username: ${{ secrets.DOCKER_USERNAME }}
           password: ${{ secrets.DOCKER_PASSWORD }}
+      - name: set tag name
+        id: tag
+        # Tagged releases won't be picked up by the interop runner automatically,
+        # but they can be useful when debugging regressions.
+        run: |
+          if [[ $GITHUB_REF == refs/tags/* ]]; then
+            echo "tag=${GITHUB_REF#refs/tags/}" | tee -a $GITHUB_OUTPUT;
+            echo "gitref=${GITHUB_REF#refs/tags/}" | tee -a $GITHUB_OUTPUT;
+          else
+            echo 'tag=latest' | tee -a $GITHUB_OUTPUT;
+            echo 'gitref=${{ github.sha }}' | tee -a $GITHUB_OUTPUT;
+          fi
       - uses: docker/build-push-action@v4
         with:
           context: "{{defaultContext}}:interop"
           platforms: linux/amd64,linux/arm64
           push: true
-          tags: martenseemann/quic-go-interop:latest
+          build-args: |
+            GITREF=${{ steps.tag.outputs.gitref }}
+          tags: martenseemann/quic-go-interop:${{ steps.tag.outputs.tag }}
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 a3f549ee..be34c22d 100644
--- a/fuzzing/handshake/fuzz.go
+++ b/fuzzing/handshake/fuzz.go
@@ -408,6 +408,7 @@ 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)
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/response_writer.go b/http3/response_writer.go
index 3d927185..90a30497 100644
--- a/http3/response_writer.go
+++ b/http3/response_writer.go
@@ -15,19 +15,61 @@ import (
 	"github.com/quic-go/qpack"
 )
 
+// The maximum length of an encoded HTTP/3 frame header is 16:
+// The frame has a type and length field, both QUIC varints (maximum 8 bytes in length)
+const frameHeaderLen = 16
+
+// headerWriter wraps the stream, so that the first Write call flushes the header to the stream
+type headerWriter struct {
+	str     quic.Stream
+	header  http.Header
+	status  int // status code passed to WriteHeader
+	written bool
+
+	logger utils.Logger
+}
+
+// writeHeader encodes and flush header to the stream
+func (hw *headerWriter) writeHeader() error {
+	var headers bytes.Buffer
+	enc := qpack.NewEncoder(&headers)
+	enc.WriteField(qpack.HeaderField{Name: ":status", Value: strconv.Itoa(hw.status)})
+
+	for k, v := range hw.header {
+		for index := range v {
+			enc.WriteField(qpack.HeaderField{Name: strings.ToLower(k), Value: v[index]})
+		}
+	}
+
+	buf := make([]byte, 0, frameHeaderLen+headers.Len())
+	buf = (&headersFrame{Length: uint64(headers.Len())}).Append(buf)
+	hw.logger.Infof("Responding with %d", hw.status)
+	buf = append(buf, headers.Bytes()...)
+
+	_, err := hw.str.Write(buf)
+	return err
+}
+
+// first Write will trigger flushing header
+func (hw *headerWriter) Write(p []byte) (int, error) {
+	if !hw.written {
+		if err := hw.writeHeader(); err != nil {
+			return 0, err
+		}
+		hw.written = true
+	}
+	return hw.str.Write(p)
+}
+
 type responseWriter struct {
+	*headerWriter
 	conn        quic.Connection
-	str         quic.Stream
 	bufferedStr *bufio.Writer
 	buf         []byte
 
-	header        http.Header
-	status        int // status code passed to WriteHeader
 	headerWritten bool
 	contentLen    int64 // if handler set valid Content-Length header
 	numWritten    int64 // bytes written
-
-	logger utils.Logger
 }
 
 var (
@@ -37,13 +79,16 @@ var (
 )
 
 func newResponseWriter(str quic.Stream, conn quic.Connection, logger utils.Logger) *responseWriter {
+	hw := &headerWriter{
+		str:    str,
+		header: http.Header{},
+		logger: logger,
+	}
 	return &responseWriter{
-		header:      http.Header{},
-		buf:         make([]byte, 16),
-		conn:        conn,
-		str:         str,
-		bufferedStr: bufio.NewWriter(str),
-		logger:      logger,
+		headerWriter: hw,
+		buf:          make([]byte, frameHeaderLen),
+		conn:         conn,
+		bufferedStr:  bufio.NewWriter(hw),
 	}
 }
 
@@ -83,27 +128,8 @@ func (w *responseWriter) WriteHeader(status int) {
 	}
 	w.status = status
 
-	var headers bytes.Buffer
-	enc := qpack.NewEncoder(&headers)
-	enc.WriteField(qpack.HeaderField{Name: ":status", Value: strconv.Itoa(status)})
-
-	for k, v := range w.header {
-		for index := range v {
-			enc.WriteField(qpack.HeaderField{Name: strings.ToLower(k), Value: v[index]})
-		}
-	}
-
-	w.buf = w.buf[:0]
-	w.buf = (&headersFrame{Length: uint64(headers.Len())}).Append(w.buf)
-	w.logger.Infof("Responding with %d", status)
-	if _, err := w.bufferedStr.Write(w.buf); err != nil {
-		w.logger.Errorf("could not write headers frame: %s", err.Error())
-	}
-	if _, err := w.bufferedStr.Write(headers.Bytes()); err != nil {
-		w.logger.Errorf("could not write header frame payload: %s", err.Error())
-	}
 	if !w.headerWritten {
-		w.Flush()
+		w.writeHeader()
 	}
 }
 
@@ -146,6 +172,15 @@ func (w *responseWriter) Write(p []byte) (int, error) {
 }
 
 func (w *responseWriter) FlushError() error {
+	if !w.headerWritten {
+		w.WriteHeader(http.StatusOK)
+	}
+	if !w.written {
+		if err := w.writeHeader(); err != nil {
+			return err
+		}
+		w.written = true
+	}
 	return w.bufferedStr.Flush()
 }
 
diff --git a/http3/server.go b/http3/server.go
index 8d39f374..4587a1fc 100644
--- a/http3/server.go
+++ b/http3/server.go
@@ -9,6 +9,7 @@ import (
 	"net"
 	"net/http"
 	"runtime"
+	"strconv"
 	"strings"
 	"sync"
 	"time"
@@ -627,7 +628,12 @@ func (s *Server) handleRequest(conn quic.Connection, str quic.Stream, decoder *q
 
 	// only write response when there is no panic
 	if !panicked {
-		r.WriteHeader(http.StatusOK)
+		// response not written to the client yet, set Content-Length
+		if !r.written {
+			if _, haveCL := r.header["Content-Length"]; !haveCL {
+				r.header.Set("Content-Length", strconv.FormatInt(r.numWritten, 10))
+			}
+		}
 		r.Flush()
 	}
 	// If the EOF was read by the handler, CancelRead() is a no-op.
diff --git a/internal/handshake/crypto_setup.go b/internal/handshake/crypto_setup.go
index a1179257..332dbd7e 100644
--- a/internal/handshake/crypto_setup.go
+++ b/internal/handshake/crypto_setup.go
@@ -664,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/wire/header.go b/internal/wire/header.go
index e2dc72e4..0c60f4dd 100644
--- a/internal/wire/header.go
+++ b/internal/wire/header.go
@@ -74,6 +74,10 @@ func parseArbitraryLenConnectionIDs(r *bytes.Reader) (dest, src protocol.Arbitra
 	return destConnID, srcConnID, nil
 }
 
+func IsPotentialQUICPacket(firstByte byte) bool {
+	return firstByte&0x40 > 0
+}
+
 // IsLongHeaderPacket says if this is a Long Header packet
 func IsLongHeaderPacket(firstByte byte) bool {
 	return firstByte&0x80 > 0
diff --git a/internal/wire/transport_parameters.go b/internal/wire/transport_parameters.go
index dc0aa22f..7226521b 100644
--- a/internal/wire/transport_parameters.go
+++ b/internal/wire/transport_parameters.go
@@ -454,6 +454,10 @@ func (p *TransportParameters) MarshalForSessionTicket(b []byte) []byte {
 	b = p.marshalVarintParam(b, initialMaxStreamsBidiParameterID, uint64(p.MaxBidiStreamNum))
 	// initial_max_uni_streams
 	b = p.marshalVarintParam(b, initialMaxStreamsUniParameterID, uint64(p.MaxUniStreamNum))
+	// max_datagram_frame_size
+	if p.MaxDatagramFrameSize != protocol.InvalidByteCount {
+		b = p.marshalVarintParam(b, maxDatagramFrameSizeParameterID, uint64(p.MaxDatagramFrameSize))
+	}
 	// active_connection_id_limit
 	return p.marshalVarintParam(b, activeConnectionIDLimitParameterID, p.ActiveConnectionIDLimit)
 }
@@ -472,6 +476,9 @@ func (p *TransportParameters) UnmarshalFromSessionTicket(r *bytes.Reader) error
 
 // ValidFor0RTT checks if the transport parameters match those saved in the session ticket.
 func (p *TransportParameters) ValidFor0RTT(saved *TransportParameters) bool {
+	if saved.MaxDatagramFrameSize != protocol.InvalidByteCount && (p.MaxDatagramFrameSize == protocol.InvalidByteCount || p.MaxDatagramFrameSize < saved.MaxDatagramFrameSize) {
+		return false
+	}
 	return p.InitialMaxStreamDataBidiLocal >= saved.InitialMaxStreamDataBidiLocal &&
 		p.InitialMaxStreamDataBidiRemote >= saved.InitialMaxStreamDataBidiRemote &&
 		p.InitialMaxStreamDataUni >= saved.InitialMaxStreamDataUni &&
@@ -484,6 +491,9 @@ func (p *TransportParameters) ValidFor0RTT(saved *TransportParameters) bool {
 // ValidForUpdate checks that the new transport parameters don't reduce limits after resuming a 0-RTT connection.
 // It is only used on the client side.
 func (p *TransportParameters) ValidForUpdate(saved *TransportParameters) bool {
+	if saved.MaxDatagramFrameSize != protocol.InvalidByteCount && (p.MaxDatagramFrameSize == protocol.InvalidByteCount || p.MaxDatagramFrameSize < saved.MaxDatagramFrameSize) {
+		return false
+	}
 	return p.ActiveConnectionIDLimit >= saved.ActiveConnectionIDLimit &&
 		p.InitialMaxData >= saved.InitialMaxData &&
 		p.InitialMaxStreamDataBidiLocal >= saved.InitialMaxStreamDataBidiLocal &&
diff --git a/internal/wire/version_negotiation.go b/internal/wire/version_negotiation.go
index 3dc62113..afde70fa 100644
--- a/internal/wire/version_negotiation.go
+++ b/internal/wire/version_negotiation.go
@@ -40,7 +40,10 @@ func ComposeVersionNegotiation(destConnID, srcConnID protocol.ArbitraryLenConnec
 	buf := bytes.NewBuffer(make([]byte, 0, expectedLen))
 	r := make([]byte, 1)
 	_, _ = rand.Read(r) // ignore the error here. It is not critical to have perfect random here.
-	buf.WriteByte(r[0] | 0x80)
+	// Setting the "QUIC bit" (0x40) is not required by the RFC,
+	// but it allows clients to demultiplex QUIC with a long list of other protocols.
+	// See RFC 9443 and https://mailarchive.ietf.org/arch/msg/quic/oR4kxGKY6mjtPC1CZegY1ED4beg/ for details.
+	buf.WriteByte(r[0] | 0xc0)
 	utils.BigEndian.WriteUint32(buf, 0) // version 0
 	buf.WriteByte(uint8(destConnID.Len()))
 	buf.Write(destConnID.Bytes())
diff --git a/interop/Dockerfile b/interop/Dockerfile
index 48d4f096..afff1276 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.20.2
+ENV GOVERSION=1.20.7
 
 RUN platform=$(echo ${TARGETPLATFORM} | tr '/' '-') && \
   filename="go${GOVERSION}.${platform}.tar.gz" && \
@@ -18,14 +18,15 @@ ENV PATH="/go/bin:${PATH}"
 # build with --build-arg CACHEBUST=$(date +%s)
 ARG CACHEBUST=1
 
-RUN git clone https://github.com/quic-go/quic-go && \
-  cd quic-go && \
-  git fetch origin interop && git checkout -t origin/interop && \
-  go get ./...
+# build other branches / commits / tags using --build-arg GITREF="<git reference>"
+ARG GITREF="master"
 
+RUN git clone https://github.com/quic-go/quic-go
 WORKDIR /quic-go
+RUN git checkout ${GITREF}
+RUN go get ./...
 
-RUN git rev-parse HEAD > commit.txt
+RUN git rev-parse HEAD | tee commit.txt
 RUN go build -o server -ldflags="-X github.com/quic-go/quic-go/qlog.quicGoVersion=$(git describe --always --long --dirty)" interop/server/main.go
 RUN go build -o client -ldflags="-X github.com/quic-go/quic-go/qlog.quicGoVersion=$(git describe --always --long --dirty)" interop/client/main.go
 
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 42fafd49..fe6dc1fc 100644
--- a/transport.go
+++ b/transport.go
@@ -7,12 +7,12 @@ import (
 	"errors"
 	"net"
 	"sync"
+	"sync/atomic"
 	"time"
 
-	"github.com/quic-go/quic-go/internal/wire"
-
 	"github.com/quic-go/quic-go/internal/protocol"
 	"github.com/quic-go/quic-go/internal/utils"
+	"github.com/quic-go/quic-go/internal/wire"
 	"github.com/quic-go/quic-go/logging"
 )
 
@@ -85,6 +85,9 @@ type Transport struct {
 	createdConn bool
 	isSingleUse bool // was created for a single server or client, i.e. by calling quic.Listen or quic.Dial
 
+	readingNonQUICPackets atomic.Bool
+	nonQUICPackets        chan receivedPacket
+
 	logger utils.Logger
 }
 
@@ -179,7 +182,7 @@ func (t *Transport) dial(ctx context.Context, addr net.Addr, hostname string, tl
 		}
 		tlsConf.ServerName = hostname
 	}
-	return dial(ctx, newSendConn(t.conn, addr), t.connIDGenerator, t.handlerMap, tlsConf, conf, onClose, use0RTT)
+	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 +198,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 +231,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 +249,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)
 		}
@@ -342,6 +344,13 @@ func (t *Transport) listen(conn rawConn) {
 }
 
 func (t *Transport) handlePacket(p receivedPacket) {
+	if len(p.data) == 0 {
+		return
+	}
+	if !wire.IsPotentialQUICPacket(p.data[0]) && !wire.IsLongHeaderPacket(p.data[0]) {
+		t.handleNonQUICPacket(p)
+		return
+	}
 	connID, err := wire.ParseConnectionID(p.data, t.connIDLen)
 	if err != nil {
 		t.logger.Debugf("error parsing connection ID on packet from %s: %s", p.remoteAddr, err)
@@ -408,7 +417,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)
 	}
 }
@@ -430,3 +439,42 @@ func (t *Transport) maybeHandleStatelessReset(data []byte) bool {
 	}
 	return false
 }
+
+func (t *Transport) handleNonQUICPacket(p receivedPacket) {
+	// Strictly speaking, this is racy,
+	// but we only care about receiving packets at some point after ReadNonQUICPacket has been called.
+	if !t.readingNonQUICPackets.Load() {
+		return
+	}
+	select {
+	case t.nonQUICPackets <- p:
+	default:
+		if t.Tracer != nil {
+			t.Tracer.DroppedPacket(p.remoteAddr, logging.PacketTypeNotDetermined, p.Size(), logging.PacketDropDOSPrevention)
+		}
+	}
+}
+
+const maxQueuedNonQUICPackets = 32
+
+// ReadNonQUICPacket reads non-QUIC packets received on the underlying connection.
+// The detection logic is very simple: Any packet that has the first and second bit of the packet set to 0.
+// Note that this is stricter than the detection logic defined in RFC 9443.
+func (t *Transport) ReadNonQUICPacket(ctx context.Context, b []byte) (int, net.Addr, error) {
+	if err := t.init(false); err != nil {
+		return 0, nil, err
+	}
+	if !t.readingNonQUICPackets.Load() {
+		t.nonQUICPackets = make(chan receivedPacket, maxQueuedNonQUICPackets)
+		t.readingNonQUICPackets.Store(true)
+	}
+	select {
+	case <-ctx.Done():
+		return 0, nil, ctx.Err()
+	case p := <-t.nonQUICPackets:
+		n := copy(b, p.data)
+		return n, p.remoteAddr, nil
+	case <-t.listening:
+		return 0, nil, errors.New("closed")
+	}
+}


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/http3/server_test.go b/http3/server_test.go
index f180ef0d..67713b92 100644
--- a/http3/server_test.go
+++ b/http3/server_test.go
@@ -180,6 +180,47 @@ var _ = Describe("Server", func() {
 			Expect(hfs).To(HaveKeyWithValue(":status", []string{"200"}))
 		})
 
+		It("sets Content-Length when the handler doesn't flush to the client", func() {
+			s.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Write([]byte("foobar"))
+			})
+
+			responseBuf := &bytes.Buffer{}
+			setRequest(encodeRequest(exampleGetRequest))
+			str.EXPECT().Context().Return(reqContext)
+			str.EXPECT().Write(gomock.Any()).DoAndReturn(responseBuf.Write).AnyTimes()
+			str.EXPECT().CancelRead(gomock.Any())
+
+			serr := s.handleRequest(conn, str, qpackDecoder, nil)
+			Expect(serr.err).ToNot(HaveOccurred())
+			hfs := decodeHeader(responseBuf)
+			Expect(hfs).To(HaveKeyWithValue(":status", []string{"200"}))
+			Expect(hfs).To(HaveKeyWithValue("content-length", []string{"6"}))
+			// status, content-length, date, content-type
+			Expect(hfs).To(HaveLen(4))
+		})
+
+		It("not sets Content-Length when the handler flushes to the client", func() {
+			s.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Write([]byte("foobar"))
+				// force flush
+				w.(http.Flusher).Flush()
+			})
+
+			responseBuf := &bytes.Buffer{}
+			setRequest(encodeRequest(exampleGetRequest))
+			str.EXPECT().Context().Return(reqContext)
+			str.EXPECT().Write(gomock.Any()).DoAndReturn(responseBuf.Write).AnyTimes()
+			str.EXPECT().CancelRead(gomock.Any())
+
+			serr := s.handleRequest(conn, str, qpackDecoder, nil)
+			Expect(serr.err).ToNot(HaveOccurred())
+			hfs := decodeHeader(responseBuf)
+			Expect(hfs).To(HaveKeyWithValue(":status", []string{"200"}))
+			// status, date, content-type
+			Expect(hfs).To(HaveLen(3))
+		})
+
 		It("handles a aborting handler", func() {
 			s.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 				panic(http.ErrAbortHandler)
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 951c4c7a..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() {
diff --git a/integrationtests/self/http_test.go b/integrationtests/self/http_test.go
index b647d4d9..a1067070 100644
--- a/integrationtests/self/http_test.go
+++ b/integrationtests/self/http_test.go
@@ -128,6 +128,18 @@ var _ = Describe("HTTP tests", func() {
 		Expect(string(body)).To(Equal("Hello, World!\n"))
 	})
 
+	It("sets content-length for small response", func() {
+		mux.HandleFunc("/small", func(w http.ResponseWriter, r *http.Request) {
+			defer GinkgoRecover()
+			w.Write([]byte("foobar"))
+		})
+
+		resp, err := client.Get(fmt.Sprintf("https://localhost:%d/small", port))
+		Expect(err).ToNot(HaveOccurred())
+		Expect(resp.StatusCode).To(Equal(200))
+		Expect(resp.Header.Get("Content-Length")).To(Equal(strconv.Itoa(len("foobar"))))
+	})
+
 	It("requests to different servers with the same udpconn", func() {
 		resp, err := client.Get(fmt.Sprintf("https://localhost:%d/remoteAddr", port))
 		Expect(err).ToNot(HaveOccurred())
diff --git a/integrationtests/self/multiplex_test.go b/integrationtests/self/multiplex_test.go
index 9b00bc34..72c858a1 100644
--- a/integrationtests/self/multiplex_test.go
+++ b/integrationtests/self/multiplex_test.go
@@ -2,9 +2,11 @@ package self_test
 
 import (
 	"context"
+	"crypto/rand"
 	"io"
 	"net"
 	"runtime"
+	"sync/atomic"
 	"time"
 
 	"github.com/quic-go/quic-go"
@@ -210,4 +212,67 @@ var _ = Describe("Multiplexing", func() {
 			})
 		}
 	})
+
+	It("sends and receives non-QUIC packets", func() {
+		addr1, err := net.ResolveUDPAddr("udp", "localhost:0")
+		Expect(err).ToNot(HaveOccurred())
+		conn1, err := net.ListenUDP("udp", addr1)
+		Expect(err).ToNot(HaveOccurred())
+		defer conn1.Close()
+		tr1 := &quic.Transport{Conn: conn1}
+
+		addr2, err := net.ResolveUDPAddr("udp", "localhost:0")
+		Expect(err).ToNot(HaveOccurred())
+		conn2, err := net.ListenUDP("udp", addr2)
+		Expect(err).ToNot(HaveOccurred())
+		defer conn2.Close()
+		tr2 := &quic.Transport{Conn: conn2}
+
+		server, err := tr1.Listen(getTLSConfig(), getQuicConfig(nil))
+		Expect(err).ToNot(HaveOccurred())
+		runServer(server)
+		defer server.Close()
+
+		ctx, cancel := context.WithCancel(context.Background())
+		defer cancel()
+		var sentPackets, rcvdPackets atomic.Int64
+		const packetLen = 128
+		// send a non-QUIC packet every 100µs
+		go func() {
+			defer GinkgoRecover()
+			ticker := time.NewTicker(time.Millisecond / 10)
+			defer ticker.Stop()
+			for {
+				select {
+				case <-ticker.C:
+				case <-ctx.Done():
+					return
+				}
+				b := make([]byte, packetLen)
+				rand.Read(b[1:]) // keep the first byte set to 0, so it's not classified as a QUIC packet
+				_, err := tr1.WriteTo(b, tr2.Conn.LocalAddr())
+				Expect(err).ToNot(HaveOccurred())
+				sentPackets.Add(1)
+			}
+		}()
+
+		// receive and count non-QUIC packets
+		go func() {
+			defer GinkgoRecover()
+			for {
+				b := make([]byte, 1024)
+				n, addr, err := tr2.ReadNonQUICPacket(ctx, b)
+				if err != nil {
+					Expect(err).To(MatchError(context.Canceled))
+					return
+				}
+				Expect(addr).To(Equal(tr1.Conn.LocalAddr()))
+				Expect(n).To(Equal(packetLen))
+				rcvdPackets.Add(1)
+			}
+		}()
+		dial(tr2, server.Addr())
+		Eventually(func() int64 { return sentPackets.Load() }).Should(BeNumerically(">", 10))
+		Eventually(func() int64 { return rcvdPackets.Load() }).Should(BeNumerically(">=", sentPackets.Load()*4/5))
+	})
 })
diff --git a/integrationtests/self/zero_rtt_oldgo_test.go b/integrationtests/self/zero_rtt_oldgo_test.go
index beaf351e..e5809286 100644
--- a/integrationtests/self/zero_rtt_oldgo_test.go
+++ b/integrationtests/self/zero_rtt_oldgo_test.go
@@ -801,4 +801,116 @@ var _ = Describe("0-RTT", func() {
 		Expect(len(zeroRTTPackets)).To(BeNumerically(">", 10))
 		Expect(zeroRTTPackets[0]).To(Equal(protocol.PacketNumber(0)))
 	})
+
+	It("sends 0-RTT datagrams", func() {
+		tlsConf, clientTLSConf := dialAndReceiveSessionTicket(getQuicConfig(&quic.Config{
+			EnableDatagrams: true,
+		}))
+
+		tracer := newPacketTracer()
+		ln, err := quic.ListenAddrEarly(
+			"localhost:0",
+			tlsConf,
+			getQuicConfig(&quic.Config{
+				Allow0RTT:       true,
+				EnableDatagrams: true,
+				Tracer:          newTracer(tracer),
+			}),
+		)
+		Expect(err).ToNot(HaveOccurred())
+		defer ln.Close()
+
+		proxy, num0RTTPackets := runCountingProxy(ln.Addr().(*net.UDPAddr).Port)
+		defer proxy.Close()
+
+		// second connection
+		sentMessage := GeneratePRData(100)
+		var receivedMessage []byte
+		received := make(chan struct{})
+		go func() {
+			defer GinkgoRecover()
+			defer close(received)
+			conn, err := ln.Accept(context.Background())
+			Expect(err).ToNot(HaveOccurred())
+			receivedMessage, err = conn.ReceiveMessage(context.Background())
+			Expect(err).ToNot(HaveOccurred())
+			Expect(conn.ConnectionState().Used0RTT).To(BeTrue())
+		}()
+		conn, err := quic.DialAddrEarly(
+			context.Background(),
+			fmt.Sprintf("localhost:%d", proxy.LocalPort()),
+			clientTLSConf,
+			getQuicConfig(&quic.Config{
+				EnableDatagrams: true,
+			}),
+		)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(conn.ConnectionState().SupportsDatagrams).To(BeTrue())
+		Expect(conn.SendMessage(sentMessage)).To(Succeed())
+		<-conn.HandshakeComplete()
+		<-received
+
+		Expect(conn.ConnectionState().Used0RTT).To(BeTrue())
+		Expect(receivedMessage).To(Equal(sentMessage))
+		num0RTT := atomic.LoadUint32(num0RTTPackets)
+		fmt.Fprintf(GinkgoWriter, "Sent %d 0-RTT packets.", num0RTT)
+		Expect(num0RTT).ToNot(BeZero())
+		zeroRTTPackets := get0RTTPackets(tracer.getRcvdLongHeaderPackets())
+		Expect(zeroRTTPackets).To(HaveLen(1))
+		Expect(conn.CloseWithError(0, "")).To(Succeed())
+	})
+
+	It("rejects 0-RTT datagrams when the server doesn't support datagrams anymore", func() {
+		tlsConf, clientTLSConf := dialAndReceiveSessionTicket(getQuicConfig(&quic.Config{
+			EnableDatagrams: true,
+		}))
+
+		tracer := newPacketTracer()
+		ln, err := quic.ListenAddrEarly(
+			"localhost:0",
+			tlsConf,
+			getQuicConfig(&quic.Config{
+				Allow0RTT:       true,
+				EnableDatagrams: false,
+				Tracer:          newTracer(tracer),
+			}),
+		)
+		Expect(err).ToNot(HaveOccurred())
+		defer ln.Close()
+
+		proxy, num0RTTPackets := runCountingProxy(ln.Addr().(*net.UDPAddr).Port)
+		defer proxy.Close()
+
+		// second connection
+		go func() {
+			defer GinkgoRecover()
+			conn, err := ln.Accept(context.Background())
+			Expect(err).ToNot(HaveOccurred())
+			_, err = conn.ReceiveMessage(context.Background())
+			Expect(err.Error()).To(Equal("datagram support disabled"))
+			<-conn.HandshakeComplete()
+			Expect(conn.ConnectionState().Used0RTT).To(BeFalse())
+		}()
+		conn, err := quic.DialAddrEarly(
+			context.Background(),
+			fmt.Sprintf("localhost:%d", proxy.LocalPort()),
+			clientTLSConf,
+			getQuicConfig(&quic.Config{
+				EnableDatagrams: true,
+			}),
+		)
+		Expect(err).ToNot(HaveOccurred())
+		// the client can temporarily send datagrams but the server doesn't process them.
+		Expect(conn.ConnectionState().SupportsDatagrams).To(BeTrue())
+		Expect(conn.SendMessage(make([]byte, 100))).To(Succeed())
+		<-conn.HandshakeComplete()
+
+		Expect(conn.ConnectionState().SupportsDatagrams).To(BeFalse())
+		Expect(conn.ConnectionState().Used0RTT).To(BeFalse())
+		num0RTT := atomic.LoadUint32(num0RTTPackets)
+		fmt.Fprintf(GinkgoWriter, "Sent %d 0-RTT packets.", num0RTT)
+		Expect(num0RTT).ToNot(BeZero())
+		Expect(get0RTTPackets(tracer.getRcvdLongHeaderPackets())).To(BeEmpty())
+		Expect(conn.CloseWithError(0, "")).To(Succeed())
+	})
 })
diff --git a/integrationtests/self/zero_rtt_test.go b/integrationtests/self/zero_rtt_test.go
index 2de283ac..011687ae 100644
--- a/integrationtests/self/zero_rtt_test.go
+++ b/integrationtests/self/zero_rtt_test.go
@@ -938,4 +938,119 @@ var _ = Describe("0-RTT", func() {
 		)
 		Expect(restored).To(BeTrue())
 	})
+
+	It("sends 0-RTT datagrams", func() {
+		tlsConf := getTLSConfig()
+		clientTLSConf := getTLSClientConfig()
+		dialAndReceiveSessionTicket(tlsConf, getQuicConfig(&quic.Config{
+			EnableDatagrams: true,
+		}), clientTLSConf)
+
+		tracer := newPacketTracer()
+		ln, err := quic.ListenAddrEarly(
+			"localhost:0",
+			tlsConf,
+			getQuicConfig(&quic.Config{
+				Allow0RTT:       true,
+				EnableDatagrams: true,
+				Tracer:          newTracer(tracer),
+			}),
+		)
+		Expect(err).ToNot(HaveOccurred())
+		defer ln.Close()
+		proxy, num0RTTPackets := runCountingProxy(ln.Addr().(*net.UDPAddr).Port)
+		defer proxy.Close()
+
+		// second connection
+		sentMessage := GeneratePRData(100)
+		var receivedMessage []byte
+		received := make(chan struct{})
+		go func() {
+			defer GinkgoRecover()
+			defer close(received)
+			conn, err := ln.Accept(context.Background())
+			Expect(err).ToNot(HaveOccurred())
+			receivedMessage, err = conn.ReceiveMessage(context.Background())
+			Expect(err).ToNot(HaveOccurred())
+			Expect(conn.ConnectionState().Used0RTT).To(BeTrue())
+		}()
+		conn, err := quic.DialAddrEarly(
+			context.Background(),
+			fmt.Sprintf("localhost:%d", proxy.LocalPort()),
+			clientTLSConf,
+			getQuicConfig(&quic.Config{
+				EnableDatagrams: true,
+			}),
+		)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(conn.ConnectionState().SupportsDatagrams).To(BeTrue())
+		Expect(conn.SendMessage(sentMessage)).To(Succeed())
+		<-conn.HandshakeComplete()
+		<-received
+
+		Expect(conn.ConnectionState().Used0RTT).To(BeTrue())
+		Expect(receivedMessage).To(Equal(sentMessage))
+		num0RTT := atomic.LoadUint32(num0RTTPackets)
+		fmt.Fprintf(GinkgoWriter, "Sent %d 0-RTT packets.", num0RTT)
+		Expect(num0RTT).ToNot(BeZero())
+		zeroRTTPackets := get0RTTPackets(tracer.getRcvdLongHeaderPackets())
+		Expect(zeroRTTPackets).To(HaveLen(1))
+		Expect(conn.CloseWithError(0, "")).To(Succeed())
+	})
+
+	It("rejects 0-RTT datagrams when the server doesn't support datagrams anymore", func() {
+		tlsConf := getTLSConfig()
+		clientTLSConf := getTLSClientConfig()
+		dialAndReceiveSessionTicket(tlsConf, getQuicConfig(&quic.Config{
+			EnableDatagrams: true,
+		}), clientTLSConf)
+
+		tracer := newPacketTracer()
+		ln, err := quic.ListenAddrEarly(
+			"localhost:0",
+			tlsConf,
+			getQuicConfig(&quic.Config{
+				Allow0RTT:       true,
+				EnableDatagrams: false,
+				Tracer:          newTracer(tracer),
+			}),
+		)
+		Expect(err).ToNot(HaveOccurred())
+		defer ln.Close()
+
+		proxy, num0RTTPackets := runCountingProxy(ln.Addr().(*net.UDPAddr).Port)
+		defer proxy.Close()
+
+		// second connection
+		go func() {
+			defer GinkgoRecover()
+			conn, err := ln.Accept(context.Background())
+			Expect(err).ToNot(HaveOccurred())
+			_, err = conn.ReceiveMessage(context.Background())
+			Expect(err.Error()).To(Equal("datagram support disabled"))
+			<-conn.HandshakeComplete()
+			Expect(conn.ConnectionState().Used0RTT).To(BeFalse())
+		}()
+		conn, err := quic.DialAddrEarly(
+			context.Background(),
+			fmt.Sprintf("localhost:%d", proxy.LocalPort()),
+			clientTLSConf,
+			getQuicConfig(&quic.Config{
+				EnableDatagrams: true,
+			}),
+		)
+		Expect(err).ToNot(HaveOccurred())
+		// the client can temporarily send datagrams but the server doesn't process them.
+		Expect(conn.ConnectionState().SupportsDatagrams).To(BeTrue())
+		Expect(conn.SendMessage(make([]byte, 100))).To(Succeed())
+		<-conn.HandshakeComplete()
+
+		Expect(conn.ConnectionState().SupportsDatagrams).To(BeFalse())
+		Expect(conn.ConnectionState().Used0RTT).To(BeFalse())
+		num0RTT := atomic.LoadUint32(num0RTTPackets)
+		fmt.Fprintf(GinkgoWriter, "Sent %d 0-RTT packets.", num0RTT)
+		Expect(num0RTT).ToNot(BeZero())
+		Expect(get0RTTPackets(tracer.getRcvdLongHeaderPackets())).To(BeEmpty())
+		Expect(conn.CloseWithError(0, "")).To(Succeed())
+	})
 })
diff --git a/internal/handshake/session_ticket_test.go b/internal/handshake/session_ticket_test.go
index 67e33b22..6f004de9 100644
--- a/internal/handshake/session_ticket_test.go
+++ b/internal/handshake/session_ticket_test.go
@@ -17,6 +17,7 @@ var _ = Describe("Session Ticket", func() {
 				InitialMaxStreamDataBidiLocal:  1,
 				InitialMaxStreamDataBidiRemote: 2,
 				ActiveConnectionIDLimit:        10,
+				MaxDatagramFrameSize:           20,
 			},
 			RTT: 1337 * time.Microsecond,
 		}
@@ -25,6 +26,7 @@ var _ = Describe("Session Ticket", func() {
 		Expect(t.Parameters.InitialMaxStreamDataBidiLocal).To(BeEquivalentTo(1))
 		Expect(t.Parameters.InitialMaxStreamDataBidiRemote).To(BeEquivalentTo(2))
 		Expect(t.Parameters.ActiveConnectionIDLimit).To(BeEquivalentTo(10))
+		Expect(t.Parameters.MaxDatagramFrameSize).To(BeEquivalentTo(20))
 		Expect(t.RTT).To(Equal(1337 * time.Microsecond))
 	})
 
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/internal/wire/transport_parameter_test.go b/internal/wire/transport_parameter_test.go
index 9dd306b7..2fb79539 100644
--- a/internal/wire/transport_parameter_test.go
+++ b/internal/wire/transport_parameter_test.go
@@ -503,6 +503,7 @@ var _ = Describe("Transport Parameters", func() {
 				MaxBidiStreamNum:               protocol.StreamNum(getRandomValueUpTo(int64(protocol.MaxStreamCount))),
 				MaxUniStreamNum:                protocol.StreamNum(getRandomValueUpTo(int64(protocol.MaxStreamCount))),
 				ActiveConnectionIDLimit:        2 + getRandomValueUpTo(math.MaxInt64-2),
+				MaxDatagramFrameSize:           protocol.ByteCount(getRandomValueUpTo(int64(protocol.MaxDatagramFrameSize))),
 			}
 			Expect(params.ValidFor0RTT(params)).To(BeTrue())
 			b := params.MarshalForSessionTicket(nil)
@@ -515,6 +516,7 @@ var _ = Describe("Transport Parameters", func() {
 			Expect(tp.MaxBidiStreamNum).To(Equal(params.MaxBidiStreamNum))
 			Expect(tp.MaxUniStreamNum).To(Equal(params.MaxUniStreamNum))
 			Expect(tp.ActiveConnectionIDLimit).To(Equal(params.ActiveConnectionIDLimit))
+			Expect(tp.MaxDatagramFrameSize).To(Equal(params.MaxDatagramFrameSize))
 		})
 
 		It("rejects the parameters if it can't parse them", func() {
@@ -540,6 +542,7 @@ var _ = Describe("Transport Parameters", func() {
 				MaxBidiStreamNum:               5,
 				MaxUniStreamNum:                6,
 				ActiveConnectionIDLimit:        7,
+				MaxDatagramFrameSize:           1000,
 			}
 
 			BeforeEach(func() {
@@ -611,6 +614,16 @@ var _ = Describe("Transport Parameters", func() {
 				p.ActiveConnectionIDLimit = 0
 				Expect(p.ValidFor0RTT(saved)).To(BeFalse())
 			})
+
+			It("accepts the parameters if the MaxDatagramFrameSize was increased", func() {
+				p.MaxDatagramFrameSize = saved.MaxDatagramFrameSize + 1
+				Expect(p.ValidFor0RTT(saved)).To(BeTrue())
+			})
+
+			It("rejects the parameters if the MaxDatagramFrameSize reduced", func() {
+				p.MaxDatagramFrameSize = saved.MaxDatagramFrameSize - 1
+				Expect(p.ValidFor0RTT(saved)).To(BeFalse())
+			})
 		})
 
 		Context("client checks the parameters after successfully sending 0-RTT data", func() {
@@ -623,6 +636,7 @@ var _ = Describe("Transport Parameters", func() {
 				MaxBidiStreamNum:               5,
 				MaxUniStreamNum:                6,
 				ActiveConnectionIDLimit:        7,
+				MaxDatagramFrameSize:           1000,
 			}
 
 			BeforeEach(func() {
@@ -699,6 +713,16 @@ var _ = Describe("Transport Parameters", func() {
 				p.ActiveConnectionIDLimit = saved.ActiveConnectionIDLimit + 1
 				Expect(p.ValidForUpdate(saved)).To(BeTrue())
 			})
+
+			It("rejects the parameters if the MaxDatagramFrameSize reduced", func() {
+				p.MaxDatagramFrameSize = saved.MaxDatagramFrameSize - 1
+				Expect(p.ValidForUpdate(saved)).To(BeFalse())
+			})
+
+			It("doesn't reject the parameters if the MaxDatagramFrameSize increased", func() {
+				p.MaxDatagramFrameSize = saved.MaxDatagramFrameSize + 1
+				Expect(p.ValidForUpdate(saved)).To(BeTrue())
+			})
 		})
 	})
 })
diff --git a/internal/wire/version_negotiation_test.go b/internal/wire/version_negotiation_test.go
index 65d0ba91..acd6d7ce 100644
--- a/internal/wire/version_negotiation_test.go
+++ b/internal/wire/version_negotiation_test.go
@@ -64,6 +64,7 @@ var _ = Describe("Version Negotiation Packets", func() {
 		versions := []protocol.VersionNumber{1001, 1003}
 		data := ComposeVersionNegotiation(destConnID, srcConnID, versions)
 		Expect(IsLongHeaderPacket(data[0])).To(BeTrue())
+		Expect(data[0] & 0x40).ToNot(BeZero())
 		v, err := ParseVersion(data)
 		Expect(err).ToNot(HaveOccurred())
 		Expect(v).To(BeZero())
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")
diff --git a/transport_test.go b/transport_test.go
index f46affb3..93e1d32a 100644
--- a/transport_test.go
+++ b/transport_test.go
@@ -2,6 +2,7 @@ package quic
 
 import (
 	"bytes"
+	"context"
 	"crypto/rand"
 	"crypto/tls"
 	"errors"
@@ -122,7 +123,7 @@ var _ = Describe("Transport", func() {
 		tr.Close()
 	})
 
-	It("drops unparseable packets", func() {
+	It("drops unparseable QUIC packets", func() {
 		addr := &net.UDPAddr{IP: net.IPv4(9, 8, 7, 6), Port: 1234}
 		packetChan := make(chan packetToRead)
 		tracer := mocklogging.NewMockTracer(mockCtrl)
@@ -136,7 +137,7 @@ var _ = Describe("Transport", func() {
 		tracer.EXPECT().DroppedPacket(addr, logging.PacketTypeNotDetermined, protocol.ByteCount(4), logging.PacketDropHeaderParseError).Do(func(net.Addr, logging.PacketType, protocol.ByteCount, logging.PacketDropReason) { close(dropped) })
 		packetChan <- packetToRead{
 			addr: addr,
-			data: []byte{0, 1, 2, 3},
+			data: []byte{0x40 /* set the QUIC bit */, 1, 2, 3},
 		}
 		Eventually(dropped).Should(BeClosed())
 
@@ -323,6 +324,78 @@ var _ = Describe("Transport", func() {
 		conns := getMultiplexer().(*connMultiplexer).conns
 		Expect(len(conns)).To(BeZero())
 	})
+
+	It("allows receiving non-QUIC packets", func() {
+		remoteAddr := &net.UDPAddr{IP: net.IPv4(9, 8, 7, 6), Port: 1234}
+		packetChan := make(chan packetToRead)
+		tracer := mocklogging.NewMockTracer(mockCtrl)
+		tr := &Transport{
+			Conn:               newMockPacketConn(packetChan),
+			ConnectionIDLength: 10,
+			Tracer:             tracer,
+		}
+		tr.init(true)
+		receivedPacketChan := make(chan []byte)
+		go func() {
+			defer GinkgoRecover()
+			b := make([]byte, 100)
+			n, addr, err := tr.ReadNonQUICPacket(context.Background(), b)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(addr).To(Equal(remoteAddr))
+			receivedPacketChan <- b[:n]
+		}()
+		// Receiving of non-QUIC packets is enabled when ReadNonQUICPacket is called.
+		// Give the Go routine some time to spin up.
+		time.Sleep(scaleDuration(50 * time.Millisecond))
+		packetChan <- packetToRead{
+			addr: remoteAddr,
+			data: []byte{0 /* don't set the QUIC bit */, 1, 2, 3},
+		}
+
+		Eventually(receivedPacketChan).Should(Receive(Equal([]byte{0, 1, 2, 3})))
+
+		// shutdown
+		close(packetChan)
+		tr.Close()
+	})
+
+	It("drops non-QUIC packet if the application doesn't process them quickly enough", func() {
+		remoteAddr := &net.UDPAddr{IP: net.IPv4(9, 8, 7, 6), Port: 1234}
+		packetChan := make(chan packetToRead)
+		tracer := mocklogging.NewMockTracer(mockCtrl)
+		tr := &Transport{
+			Conn:               newMockPacketConn(packetChan),
+			ConnectionIDLength: 10,
+			Tracer:             tracer,
+		}
+		tr.init(true)
+
+		ctx, cancel := context.WithCancel(context.Background())
+		cancel()
+		_, _, err := tr.ReadNonQUICPacket(ctx, make([]byte, 10))
+		Expect(err).To(MatchError(context.Canceled))
+
+		for i := 0; i < maxQueuedNonQUICPackets; i++ {
+			packetChan <- packetToRead{
+				addr: remoteAddr,
+				data: []byte{0 /* don't set the QUIC bit */, 1, 2, 3},
+			}
+		}
+
+		done := make(chan struct{})
+		tracer.EXPECT().DroppedPacket(remoteAddr, logging.PacketTypeNotDetermined, protocol.ByteCount(4), logging.PacketDropDOSPrevention).Do(func(net.Addr, logging.PacketType, protocol.ByteCount, logging.PacketDropReason) {
+			close(done)
+		})
+		packetChan <- packetToRead{
+			addr: remoteAddr,
+			data: []byte{0 /* don't set the QUIC bit */, 1, 2, 3},
+		}
+		Eventually(done).Should(BeClosed())
+
+		// shutdown
+		close(packetChan)
+		tr.Close()
+	})
 })
 
 type mockSyscallConn struct {