Repository: quic-go/quic-go
Pull request #4545 (2024-06-02 05:36:00)

make Path MTU Discovery resilient to random packet loss
We now tolerate the random (i.e. not MTU-related) loss of up to probe packets. This should make DPLPMTUD resilient to packet loss. This is relevant since MTU discovery is started at the beginning of the connection, where packet loss is likely once the congestion slow start period reaches the bottleneck bandwidth.

See code for a detailed description of the algorithm used.

The algorithm contains a tunable parameter describing how many random packet losses it tolerates. For the special case of 0, the new algorithm is equivalent to the old algorithm.

For a search of randomly chosen MTUs between 1200 and 1500 bytes (no random packet loss), this is how this parameter influences the progression of the algorithm:

| tolerance to N packet losses | probe packets sent | difference from real MTU |
|---|---|---|
| 0 | 4 | 8.9 |
| 1 | 6 | 5.27 |
| **2** | **8.5** | **1.87** |
| 3 | 11.2 | 0.85 |

In this PR, we choose the N=2 variant.

It would be desirable to decrease the number of probe packets sent, and the **average** difference from the real MTU would justify doing that. However, I don't see an obvious way to do so without increasing the **worst case** behavior (the worst case difference from the real MTU).

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/quic-go/quic-go/pull/4545?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) Report
Attention: Patch coverage is `85.71429%` with `6 lines` in your changes are missing coverage. Please review.
> Project coverage is 85.09%. Comparing base [(`375fc59`)](https://app.codecov.io/gh/quic-go/quic-go/commit/375fc59084c1599af29ff9dc1695dc8883bbd7fa?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) to head [(`dd12c30`)](https://app.codecov.io/gh/quic-go/quic-go/commit/dd12c30b1e85ad2e1047dc27d895384abefcdfe0?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go).

| [Files](https://app.codecov.io/gh/quic-go/quic-go/pull/4545?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) | Patch % | Lines |
|---|---|---|
| [mtu\_discoverer.go](https://app.codecov.io/gh/quic-go/quic-go/pull/4545?src=pr&el=tree&filepath=mtu_discoverer.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go#diff-bXR1X2Rpc2NvdmVyZXIuZ28=) | 85.71% | [5 Missing and 1 partial :warning: ](https://app.codecov.io/gh/quic-go/quic-go/pull/4545?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) |

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


```diff
@@            Coverage Diff             @@
##           master    #4545      +/-   ##
==========================================
- Coverage   85.15%   85.09%   -0.06%     
==========================================
  Files         154      151       -3     
  Lines       14800    14552     -248     
==========================================
- Hits        12602    12382     -220     
+ Misses       1690     1678      -12     
+ Partials      508      492      -16     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/quic-go/quic-go/pull/4545?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go).


Patch:
diff --git a/connection.go b/connection.go
index 8d832db7..33ab14b8 100644
--- a/connection.go
+++ b/connection.go
@@ -169,10 +169,9 @@ type connection struct {
 	// closeChan is used to notify the run loop that it should terminate
 	closeChan chan closeError
 
-	ctx                context.Context
-	ctxCancel          context.CancelCauseFunc
-	handshakeCtx       context.Context
-	handshakeCtxCancel context.CancelFunc
+	ctx                   context.Context
+	ctxCancel             context.CancelCauseFunc
+	handshakeCompleteChan chan struct{}
 
 	undecryptablePackets          []receivedPacket // undecryptable packets, waiting for a change in encryption level
 	undecryptablePacketsToProcess []receivedPacket
@@ -486,7 +485,7 @@ func (s *connection) preSetup() {
 	s.receivedPackets = make(chan receivedPacket, protocol.MaxConnUnprocessedPackets)
 	s.closeChan = make(chan closeError, 1)
 	s.sendingScheduled = make(chan struct{}, 1)
-	s.handshakeCtx, s.handshakeCtxCancel = context.WithCancel(context.Background())
+	s.handshakeCompleteChan = make(chan struct{})
 
 	now := time.Now()
 	s.lastPacketReceivedTime = now
@@ -667,7 +666,7 @@ func (s *connection) earlyConnReady() <-chan struct{} {
 }
 
 func (s *connection) HandshakeComplete() <-chan struct{} {
-	return s.handshakeCtx.Done()
+	return s.handshakeCompleteChan
 }
 
 func (s *connection) Context() context.Context {
@@ -732,7 +731,7 @@ func (s *connection) idleTimeoutStartTime() time.Time {
 }
 
 func (s *connection) handleHandshakeComplete() error {
-	defer s.handshakeCtxCancel()
+	defer close(s.handshakeCompleteChan)
 	// Once the handshake completes, we have derived 1-RTT keys.
 	// There's no point in queueing undecryptable packets for later decryption anymore.
 	s.undecryptablePackets = nil
diff --git a/go.mod b/go.mod
index 00abc69a..8cc39ebd 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
 	github.com/francoispqt/gojay v1.2.13
 	github.com/onsi/ginkgo/v2 v2.9.5
 	github.com/onsi/gomega v1.27.6
+	github.com/prometheus/client_golang v1.19.1
 	github.com/quic-go/qpack v0.4.0
 	go.uber.org/mock v0.4.0
 	golang.org/x/crypto v0.23.0
@@ -17,13 +18,18 @@ require (
 )
 
 require (
+	github.com/beorn7/perks v1.0.1 // indirect
+	github.com/cespare/xxhash/v2 v2.2.0 // indirect
 	github.com/go-logr/logr v1.2.4 // indirect
 	github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
 	github.com/google/go-cmp v0.6.0 // indirect
 	github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect
+	github.com/prometheus/client_model v0.5.0 // indirect
+	github.com/prometheus/common v0.48.0 // indirect
+	github.com/prometheus/procfs v0.12.0 // indirect
 	golang.org/x/mod v0.17.0 // indirect
 	golang.org/x/text v0.15.0 // indirect
 	golang.org/x/tools v0.21.0 // indirect
-	gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
+	google.golang.org/protobuf v1.33.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/go.sum b/go.sum
index 8fbf1c8a..fda9cbfc 100644
--- a/go.sum
+++ b/go.sum
@@ -10,8 +10,12 @@ git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGy
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
 github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g=
 github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s=
+github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
+github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -61,8 +65,9 @@ github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0
 github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
 github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
@@ -84,11 +89,21 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
+github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
+github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
 github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
+github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
+github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
 github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
+github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
+github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
 github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
+github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
+github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
 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/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
 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=
@@ -199,11 +214,11 @@ google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmE
 google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio=
 google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
-google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
+google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
 gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/http3/client.go b/http3/client.go
index f5370549..23ac088f 100644
--- a/http3/client.go
+++ b/http3/client.go
@@ -82,7 +82,13 @@ func (c *SingleDestinationRoundTripper) Start() Connection {
 func (c *SingleDestinationRoundTripper) init() {
 	c.decoder = qpack.NewDecoder(func(hf qpack.HeaderField) {})
 	c.requestWriter = newRequestWriter()
-	c.hconn = newConnection(c.Connection, c.EnableDatagrams, protocol.PerspectiveClient, c.Logger)
+	c.hconn = newConnection(
+		c.Connection.Context(),
+		c.Connection,
+		c.EnableDatagrams,
+		protocol.PerspectiveClient,
+		c.Logger,
+	)
 	// send the SETTINGs frame, using 0-RTT data, if possible
 	go func() {
 		if err := c.setupConn(c.hconn); err != nil {
diff --git a/http3/conn.go b/http3/conn.go
index 7ea4b292..df7fb282 100644
--- a/http3/conn.go
+++ b/http3/conn.go
@@ -37,6 +37,7 @@ type Connection interface {
 
 type connection struct {
 	quic.Connection
+	ctx context.Context
 
 	perspective protocol.Perspective
 	logger      *slog.Logger
@@ -53,12 +54,14 @@ type connection struct {
 }
 
 func newConnection(
+	ctx context.Context,
 	quicConn quic.Connection,
 	enableDatagrams bool,
 	perspective protocol.Perspective,
 	logger *slog.Logger,
 ) *connection {
-	c := &connection{
+	return &connection{
+		ctx:              ctx,
 		Connection:       quicConn,
 		perspective:      perspective,
 		logger:           logger,
@@ -67,7 +70,6 @@ func newConnection(
 		receivedSettings: make(chan struct{}),
 		streams:          make(map[protocol.StreamID]*datagrammer),
 	}
-	return c
 }
 
 func (c *connection) clearStream(id quic.StreamID) {
@@ -264,3 +266,5 @@ func (c *connection) ReceivedSettings() <-chan struct{} { return c.receivedSetti
 // Settings returns the settings received on this connection.
 // It is only valid to call this function after the channel returned by ReceivedSettings was closed.
 func (c *connection) Settings() *Settings { return c.settings }
+
+func (c *connection) Context() context.Context { return c.ctx }
diff --git a/http3/server.go b/http3/server.go
index 5d7aec8a..7e6ffd54 100644
--- a/http3/server.go
+++ b/http3/server.go
@@ -94,6 +94,10 @@ func ConfigureTLSConfig(tlsConf *tls.Config) *tls.Config {
 			if config == nil {
 				return nil, nil
 			}
+			// Workaround for https://github.com/golang/go/issues/60506.
+			// This initializes the session tickets _before_ cloning the config.
+			_, _ = config.DecryptTicket(nil, tls.ConnectionState{})
+
 			config = config.Clone()
 			config.NextProtos = []string{proto}
 			return config, nil
@@ -194,9 +198,8 @@ type Server struct {
 	// In that case, the stream type will not be set.
 	UniStreamHijacker func(StreamType, quic.ConnectionTracingID, quic.ReceiveStream, error) (hijacked bool)
 
-	// ConnContext optionally specifies a function that modifies
-	// the context used for a new connection c. The provided ctx
-	// has a ServerContextKey value.
+	// ConnContext optionally specifies a function that modifies the context used for a new connection c.
+	// The provided ctx has a ServerContextKey value.
 	ConnContext func(ctx context.Context, c quic.Connection) context.Context
 
 	Logger *slog.Logger
@@ -436,7 +439,19 @@ func (s *Server) handleConn(conn quic.Connection) error {
 	}).Append(b)
 	str.Write(b)
 
+	ctx := conn.Context()
+	ctx = context.WithValue(ctx, ServerContextKey, s)
+	ctx = context.WithValue(ctx, http.LocalAddrContextKey, conn.LocalAddr())
+	ctx = context.WithValue(ctx, RemoteAddrContextKey, conn.RemoteAddr())
+	if s.ConnContext != nil {
+		ctx = s.ConnContext(ctx, conn)
+		if ctx == nil {
+			panic("http3: ConnContext returned nil")
+		}
+	}
+
 	hconn := newConnection(
+		ctx,
 		conn,
 		s.EnableDatagrams,
 		protocol.PerspectiveServer,
@@ -533,17 +548,10 @@ func (s *Server) handleRequest(conn *connection, str quic.Stream, datagrams *dat
 		s.Logger.Debug("handling request", "method", req.Method, "host", req.Host, "uri", req.RequestURI)
 	}
 
-	ctx := str.Context()
-	ctx = context.WithValue(ctx, ServerContextKey, s)
-	ctx = context.WithValue(ctx, http.LocalAddrContextKey, conn.LocalAddr())
-	ctx = context.WithValue(ctx, RemoteAddrContextKey, conn.RemoteAddr())
-	if s.ConnContext != nil {
-		ctx = s.ConnContext(ctx, conn.Connection)
-		if ctx == nil {
-			panic("http3: ConnContext returned nil")
-		}
-	}
+	ctx, cancel := context.WithCancel(conn.Context())
 	req = req.WithContext(ctx)
+	context.AfterFunc(str.Context(), cancel)
+
 	r := newResponseWriter(hstr, conn, req.Method == http.MethodHead, s.Logger)
 	handler := s.Handler
 	if handler == nil {
diff --git a/internal/handshake/crypto_setup.go b/internal/handshake/crypto_setup.go
index 2134d114..20bcc474 100644
--- a/internal/handshake/crypto_setup.go
+++ b/internal/handshake/crypto_setup.go
@@ -123,44 +123,12 @@ func NewCryptoSetupServer(
 	)
 	cs.allow0RTT = allow0RTT
 
-	quicConf := &tls.QUICConfig{TLSConfig: tlsConf}
-	qtls.SetupConfigForServer(quicConf, cs.allow0RTT, cs.getDataForSessionTicket, cs.handleSessionTicket)
-	addConnToClientHelloInfo(quicConf.TLSConfig, localAddr, remoteAddr)
-
-	cs.tlsConf = quicConf.TLSConfig
-	cs.conn = tls.QUICServer(quicConf)
-
+	tlsConf = qtls.SetupConfigForServer(tlsConf, localAddr, remoteAddr, cs.getDataForSessionTicket, cs.handleSessionTicket)
+	cs.tlsConf = tlsConf
+	cs.conn = tls.QUICServer(&tls.QUICConfig{TLSConfig: tlsConf})
 	return cs
 }
 
-// The tls.Config contains two callbacks that pass in a tls.ClientHelloInfo.
-// Since crypto/tls doesn't do it, we need to make sure to set the Conn field with a fake net.Conn
-// that allows the caller to get the local and the remote address.
-func addConnToClientHelloInfo(conf *tls.Config, localAddr, remoteAddr net.Addr) {
-	if conf.GetConfigForClient != nil {
-		gcfc := conf.GetConfigForClient
-		conf.GetConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) {
-			info.Conn = &conn{localAddr: localAddr, remoteAddr: remoteAddr}
-			c, err := gcfc(info)
-			if c != nil {
-				c = c.Clone()
-				// This won't be necessary anymore once https://github.com/golang/go/issues/63722 is accepted.
-				c.MinVersion = tls.VersionTLS13
-				// We're returning a tls.Config here, so we need to apply this recursively.
-				addConnToClientHelloInfo(c, localAddr, remoteAddr)
-			}
-			return c, err
-		}
-	}
-	if conf.GetCertificate != nil {
-		gc := conf.GetCertificate
-		conf.GetCertificate = func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
-			info.Conn = &conn{localAddr: localAddr, remoteAddr: remoteAddr}
-			return gc(info)
-		}
-	}
-}
-
 func newCryptoSetup(
 	connID protocol.ConnectionID,
 	tp *wire.TransportParameters,
@@ -376,9 +344,7 @@ 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 err := h.conn.SendSessionTicket(tls.QUICSessionTicketOptions{
-		EarlyData: h.allow0RTT,
-	}); err != nil {
+	if err := h.conn.SendSessionTicket(tls.QUICSessionTicketOptions{EarlyData: 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.
diff --git a/internal/handshake/conn.go b/internal/qtls/conn.go
similarity index 97%
rename from internal/handshake/conn.go
rename to internal/qtls/conn.go
index 54af823b..6660ac66 100644
--- a/internal/handshake/conn.go
+++ b/internal/qtls/conn.go
@@ -1,4 +1,4 @@
-package handshake
+package qtls
 
 import (
 	"net"
diff --git a/internal/qtls/qtls.go b/internal/qtls/qtls.go
index ebcd9d4d..cdfe82a2 100644
--- a/internal/qtls/qtls.go
+++ b/internal/qtls/qtls.go
@@ -4,20 +4,23 @@ import (
 	"bytes"
 	"crypto/tls"
 	"fmt"
+	"net"
 
 	"github.com/quic-go/quic-go/internal/protocol"
 )
 
-func SetupConfigForServer(qconf *tls.QUICConfig, _ bool, getData func() []byte, handleSessionTicket func([]byte, bool) bool) {
-	conf := qconf.TLSConfig
-
+func SetupConfigForServer(
+	conf *tls.Config,
+	localAddr, remoteAddr net.Addr,
+	getData func() []byte,
+	handleSessionTicket func([]byte, bool) bool,
+) *tls.Config {
 	// Workaround for https://github.com/golang/go/issues/60506.
 	// This initializes the session tickets _before_ cloning the config.
 	_, _ = conf.DecryptTicket(nil, tls.ConnectionState{})
 
 	conf = conf.Clone()
 	conf.MinVersion = tls.VersionTLS13
-	qconf.TLSConfig = conf
 
 	// add callbacks to save transport parameters into the session ticket
 	origWrapSession := conf.WrapSession
@@ -58,6 +61,29 @@ func SetupConfigForServer(qconf *tls.QUICConfig, _ bool, getData func() []byte,
 
 		return state, nil
 	}
+	// The tls.Config contains two callbacks that pass in a tls.ClientHelloInfo.
+	// Since crypto/tls doesn't do it, we need to make sure to set the Conn field with a fake net.Conn
+	// that allows the caller to get the local and the remote address.
+	if conf.GetConfigForClient != nil {
+		gcfc := conf.GetConfigForClient
+		conf.GetConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) {
+			info.Conn = &conn{localAddr: localAddr, remoteAddr: remoteAddr}
+			c, err := gcfc(info)
+			if c != nil {
+				// We're returning a tls.Config here, so we need to apply this recursively.
+				c = SetupConfigForServer(c, localAddr, remoteAddr, getData, handleSessionTicket)
+			}
+			return c, err
+		}
+	}
+	if conf.GetCertificate != nil {
+		gc := conf.GetCertificate
+		conf.GetCertificate = func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
+			info.Conn = &conn{localAddr: localAddr, remoteAddr: remoteAddr}
+			return gc(info)
+		}
+	}
+	return conf
 }
 
 func SetupConfigForClient(
diff --git a/metrics/connection_tracer.go b/metrics/connection_tracer.go
new file mode 100644
index 00000000..adaee497
--- /dev/null
+++ b/metrics/connection_tracer.go
@@ -0,0 +1,201 @@
+package metrics
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net"
+	"time"
+
+	"github.com/quic-go/quic-go"
+	"github.com/quic-go/quic-go/internal/qerr"
+	"github.com/quic-go/quic-go/logging"
+
+	"github.com/prometheus/client_golang/prometheus"
+)
+
+var (
+	connStarted = prometheus.NewCounterVec(
+		prometheus.CounterOpts{
+			Namespace: metricNamespace,
+			Name:      "connections_started_total",
+			Help:      "Connections Started",
+		},
+		[]string{"dir"},
+	)
+	connClosed = prometheus.NewCounterVec(
+		prometheus.CounterOpts{
+			Namespace: metricNamespace,
+			Name:      "connections_closed_total",
+			Help:      "Connections Closed",
+		},
+		[]string{"dir", "reason"},
+	)
+	connDuration = prometheus.NewHistogramVec(
+		prometheus.HistogramOpts{
+			Namespace: metricNamespace,
+			Name:      "connection_duration_seconds",
+			Help:      "Duration of a Connection",
+			Buckets:   prometheus.ExponentialBuckets(1.0/16, 2, 25), // up to 24 days
+		},
+		[]string{"dir"},
+	)
+	connHandshakeDuration = prometheus.NewHistogramVec(
+		prometheus.HistogramOpts{
+			Namespace: metricNamespace,
+			Name:      "handshake_duration_seconds",
+			Help:      "Duration of the QUIC Handshake",
+			Buckets:   prometheus.ExponentialBuckets(0.001, 1.3, 35),
+		},
+		[]string{"dir"},
+	)
+)
+
+// DefaultTracer returns a callback that creates a metrics ConnectionTracer.
+// The ConnectionTracer returned can be set on the quic.Config for a new connection.
+// It should be reused across QUIC connections.
+func DefaultTracer() func(_ context.Context, p logging.Perspective, _ logging.ConnectionID) *logging.ConnectionTracer {
+	return DefaultTracerWithRegisterer(prometheus.DefaultRegisterer)
+}
+
+// DefaultTracerWithRegisterer returns a callback that creates a metrics ConnectionTracer
+// using a given Prometheus registerer.
+func DefaultTracerWithRegisterer(registerer prometheus.Registerer) func(_ context.Context, p logging.Perspective, _ logging.ConnectionID) *logging.ConnectionTracer {
+	return func(_ context.Context, p logging.Perspective, _ logging.ConnectionID) *logging.ConnectionTracer {
+		switch p {
+		case logging.PerspectiveClient:
+			return NewClientConnectionTracerWithRegisterer(registerer)
+		case logging.PerspectiveServer:
+			return NewServerConnectionTracerWithRegisterer(registerer)
+		default:
+			panic("invalid perspective")
+		}
+	}
+}
+
+// NewClientConnectionTracerWithRegisterer creates a new connection tracer for a connection
+// dialed on the client side with a given Prometheus registerer.
+func NewClientConnectionTracerWithRegisterer(registerer prometheus.Registerer) *logging.ConnectionTracer {
+	return newConnectionTracerWithRegisterer(registerer, true)
+}
+
+// NewServerConnectionTracerWithRegisterer creates a new connection tracer for a connection
+// accepted on the server side with a given Prometheus registerer.
+func NewServerConnectionTracerWithRegisterer(registerer prometheus.Registerer) *logging.ConnectionTracer {
+	return newConnectionTracerWithRegisterer(registerer, false)
+}
+
+func newConnectionTracerWithRegisterer(registerer prometheus.Registerer, isClient bool) *logging.ConnectionTracer {
+	for _, c := range [...]prometheus.Collector{
+		connStarted,
+		connHandshakeDuration,
+		connClosed,
+		connDuration,
+	} {
+		if err := registerer.Register(c); err != nil {
+			if ok := errors.As(err, &prometheus.AlreadyRegisteredError{}); !ok {
+				panic(err)
+			}
+		}
+	}
+
+	direction := "incoming"
+	if isClient {
+		direction = "outgoing"
+	}
+
+	var (
+		startTime         time.Time
+		handshakeComplete bool
+	)
+	return &logging.ConnectionTracer{
+		StartedConnection: func(_, _ net.Addr, _, _ logging.ConnectionID) {
+			tags := getStringSlice()
+			defer putStringSlice(tags)
+
+			startTime = time.Now()
+
+			*tags = append(*tags, direction)
+			connStarted.WithLabelValues(*tags...).Inc()
+		},
+		ClosedConnection: func(e error) {
+			tags := getStringSlice()
+			defer putStringSlice(tags)
+
+			*tags = append(*tags, direction)
+			// call connDuration.Observe before adding any more labels
+			if handshakeComplete {
+				connDuration.WithLabelValues(*tags...).Observe(time.Since(startTime).Seconds())
+			}
+
+			var (
+				statelessResetErr     *quic.StatelessResetError
+				handshakeTimeoutErr   *quic.HandshakeTimeoutError
+				idleTimeoutErr        *quic.IdleTimeoutError
+				applicationErr        *quic.ApplicationError
+				transportErr          *quic.TransportError
+				versionNegotiationErr *quic.VersionNegotiationError
+			)
+			var reason string
+			switch {
+			case errors.As(e, &statelessResetErr):
+				reason = "stateless_reset"
+			case errors.As(e, &handshakeTimeoutErr):
+				reason = "handshake_timeout"
+			case errors.As(e, &idleTimeoutErr):
+				if handshakeComplete {
+					reason = "idle_timeout"
+				} else {
+					reason = "handshake_timeout"
+				}
+			case errors.As(e, &applicationErr):
+				if applicationErr.Remote {
+					reason = "application_error (remote)"
+				} else {
+					reason = "application_error (local)"
+				}
+			case errors.As(e, &transportErr):
+				switch {
+				case transportErr.ErrorCode == qerr.ApplicationErrorErrorCode:
+					if transportErr.Remote {
+						reason = "application_error (remote)"
+					} else {
+						reason = "application_error (local)"
+					}
+				case transportErr.ErrorCode.IsCryptoError():
+					if transportErr.Remote {
+						reason = "crypto_error (remote)"
+					} else {
+						reason = "crypto_error (local)"
+					}
+				default:
+					if transportErr.Remote {
+						reason = "transport_error (remote)"
+					} else {
+						reason = fmt.Sprintf("transport_error (local): %s", transportErr.ErrorCode)
+					}
+				}
+			case errors.As(e, &versionNegotiationErr):
+				reason = "version_mismatch"
+			default:
+				reason = "unknown"
+			}
+			*tags = append(*tags, reason)
+			connClosed.WithLabelValues(*tags...).Inc()
+		},
+		UpdatedKeyFromTLS: func(l logging.EncryptionLevel, p logging.Perspective) {
+			// The client derives both 1-RTT keys when the handshake completes.
+			// The server derives the 1-RTT read key when the handshake completes.
+			if l != logging.Encryption1RTT || p != logging.PerspectiveClient {
+				return
+			}
+			handshakeComplete = true
+
+			tags := getStringSlice()
+			defer putStringSlice(tags)
+
+			*tags = append(*tags, direction)
+			connHandshakeDuration.WithLabelValues(*tags...).Observe(time.Since(startTime).Seconds())
+		},
+	}
+}
diff --git a/metrics/dashboards/README.md b/metrics/dashboards/README.md
new file mode 100644
index 00000000..f7620421
--- /dev/null
+++ b/metrics/dashboards/README.md
@@ -0,0 +1,37 @@
+# quic-go Prometheus / Grafana setup
+
+Expose a Grafana endpoint on `http://localhost:5001/prometheus`:
+```go
+import "github.com/prometheus/client_golang/prometheus/promhttp"
+
+go func() {
+    http.Handle("/prometheus", promhttp.Handler())
+    log.Fatal(http.ListenAndServe(":5001", nil))
+}()
+```
+
+Set a metrics tracer on the `Transport`:
+```go
+quic.Transport{
+	Tracer: metrics.NewTracer(),
+}
+```
+
+When using multiple `Transport`s, it is recommended to use the metrics tracer struct for all of them.
+
+
+Set a metrics connection tracer on the `Config`:
+```go
+tracer := metrics.DefaultTracer()
+quic.Config{
+	Tracer: tracer,
+}
+```
+
+It is recommended to use the same connection tracer returned by `DefaultTracer` on the `Config`s for all connections.
+
+
+Running:
+```shell
+docker-compose up
+```
diff --git a/metrics/dashboards/datasources.yml b/metrics/dashboards/datasources.yml
new file mode 100644
index 00000000..ed47ec11
--- /dev/null
+++ b/metrics/dashboards/datasources.yml
@@ -0,0 +1,13 @@
+apiVersion: 1
+
+deleteDatasources:
+  - name: Prometheus
+    orgId: 1
+
+datasources:
+  - name: Prometheus
+    orgId: 1
+    type: prometheus
+    access: proxy
+    url: http://prometheus:9090
+    editable: false
diff --git a/metrics/dashboards/docker-compose.yml b/metrics/dashboards/docker-compose.yml
new file mode 100644
index 00000000..8e3a8a55
--- /dev/null
+++ b/metrics/dashboards/docker-compose.yml
@@ -0,0 +1,25 @@
+version: '3.8'
+
+volumes:
+  prometheus_data: {}
+  grafana_data: {}
+
+services:
+  prometheus:
+    image: prom/prometheus:latest
+    container_name: prometheus
+    volumes:
+      - ./prometheus.yml:/etc/prometheus/prometheus.yml
+      - prometheus_data:/prometheus
+    command:
+      - '--config.file=/etc/prometheus/prometheus.yml'
+    expose:
+      - 9090
+  grafana:
+    image: grafana/grafana:latest
+    container_name: grafana
+    volumes:
+      - grafana_data:/var/lib/grafana
+      - ./datasources.yml:/etc/grafana/provisioning/datasources/prom.yml
+    ports:
+      - "3000:3000"
diff --git a/metrics/dashboards/prometheus.yml b/metrics/dashboards/prometheus.yml
new file mode 100644
index 00000000..5018097e
--- /dev/null
+++ b/metrics/dashboards/prometheus.yml
@@ -0,0 +1,9 @@
+global:
+  scrape_interval: 15s
+
+scrape_configs:
+  - job_name: 'quic-go'
+    scrape_interval: 15s
+    static_configs:
+      - targets: ['host.docker.internal:5001']
+    metrics_path: '/prometheus'
diff --git a/metrics/pool.go b/metrics/pool.go
new file mode 100644
index 00000000..75868bea
--- /dev/null
+++ b/metrics/pool.go
@@ -0,0 +1,27 @@
+package metrics
+
+import (
+	"fmt"
+	"sync"
+)
+
+const capacity = 4
+
+// The stringPool is used to avoid allocations when passing labels to Prometheus.
+var stringPool = sync.Pool{New: func() any {
+	s := make([]string, 0, capacity)
+	return &s
+}}
+
+func getStringSlice() *[]string {
+	s := stringPool.Get().(*[]string)
+	*s = (*s)[:0]
+	return s
+}
+
+func putStringSlice(s *[]string) {
+	if c := cap(*s); c < capacity {
+		panic(fmt.Sprintf("unexpected slice cap: %d", c))
+	}
+	stringPool.Put(s)
+}
diff --git a/metrics/tracer.go b/metrics/tracer.go
new file mode 100644
index 00000000..41245bbd
--- /dev/null
+++ b/metrics/tracer.go
@@ -0,0 +1,147 @@
+package metrics
+
+import (
+	"errors"
+	"fmt"
+	"net"
+
+	"github.com/quic-go/quic-go/internal/protocol"
+	"github.com/quic-go/quic-go/internal/qerr"
+	"github.com/quic-go/quic-go/logging"
+
+	"github.com/prometheus/client_golang/prometheus"
+)
+
+const metricNamespace = "quicgo"
+
+func getIPVersion(addr net.Addr) string {
+	udpAddr, ok := addr.(*net.UDPAddr)
+	if !ok {
+		return ""
+	}
+	if udpAddr.IP.To4() != nil {
+		return "ipv4"
+	}
+	return "ipv6"
+}
+
+var (
+	connsRejected = prometheus.NewCounterVec(
+		prometheus.CounterOpts{
+			Namespace: metricNamespace,
+			Name:      "server_connections_rejected_total",
+			Help:      "Connections Rejected",
+		},
+		[]string{"ip_version", "reason"},
+	)
+	packetDropped = prometheus.NewCounterVec(
+		prometheus.CounterOpts{
+			Namespace: metricNamespace,
+			Name:      "server_received_packets_dropped_total",
+			Help:      "packets dropped",
+		},
+		[]string{"ip_version", "reason"},
+	)
+)
+
+// NewTracer creates a new tracer using the default Prometheus registerer.
+// The Tracer returned from this function can be used to collect metrics for
+// events happening before the establishment of a QUIC connection.
+// It can be set on the Tracer field of quic.Transport.
+func NewTracer() *logging.Tracer {
+	return NewTracerWithRegisterer(prometheus.DefaultRegisterer)
+}
+
+// NewTracerWithRegisterer creates a new tracer using a given Prometheus registerer.
+func NewTracerWithRegisterer(registerer prometheus.Registerer) *logging.Tracer {
+	for _, c := range [...]prometheus.Collector{
+		connsRejected,
+		packetDropped,
+	} {
+		if err := registerer.Register(c); err != nil {
+			if ok := errors.As(err, &prometheus.AlreadyRegisteredError{}); !ok {
+				panic(err)
+			}
+		}
+	}
+
+	return &logging.Tracer{
+		SentPacket: func(addr net.Addr, hdr *logging.Header, _ logging.ByteCount, frames []logging.Frame) {
+			tags := getStringSlice()
+			defer putStringSlice(tags)
+
+			var reason string
+			switch {
+			case hdr.Type == protocol.PacketTypeRetry:
+				reason = "retry"
+			case hdr.Type == protocol.PacketTypeInitial:
+				var ccf *logging.ConnectionCloseFrame
+				for _, f := range frames {
+					cc, ok := f.(*logging.ConnectionCloseFrame)
+					if ok {
+						ccf = cc
+						break
+					}
+				}
+				// This should never happen. We only send Initials before creating the connection in order to
+				// reject a connection attempt.
+				if ccf == nil {
+					return
+				}
+				if ccf.IsApplicationError {
+					//nolint:exhaustive // Only a few error codes applicable.
+					switch qerr.TransportErrorCode(ccf.ErrorCode) {
+					case qerr.ConnectionRefused:
+						reason = "connection_refused"
+					case qerr.InvalidToken:
+						reason = "invalid_token"
+					default:
+						// This shouldn't happen, the server doesn't send CONNECTION_CLOSE frames with different errors.
+						reason = fmt.Sprintf("transport_error: %d", ccf.ErrorCode)
+					}
+				} else {
+					// This shouldn't happen, the server doesn't send application-level CONNECTION_CLOSE frames.
+					reason = "application_error"
+				}
+			}
+			*tags = append(*tags, getIPVersion(addr))
+			*tags = append(*tags, reason)
+			connsRejected.WithLabelValues(*tags...).Inc()
+		},
+		SentVersionNegotiationPacket: func(addr net.Addr, _, _ logging.ArbitraryLenConnectionID, _ []logging.VersionNumber) {
+			tags := getStringSlice()
+			defer putStringSlice(tags)
+
+			*tags = append(*tags, getIPVersion(addr))
+			*tags = append(*tags, "version_negotiation")
+			connsRejected.WithLabelValues(*tags...).Inc()
+		},
+		DroppedPacket: func(addr net.Addr, pt logging.PacketType, _ logging.ByteCount, reason logging.PacketDropReason) {
+			tags := getStringSlice()
+			defer putStringSlice(tags)
+
+			var dropReason string
+			//nolint:exhaustive // Only a few drop reasons applicable.
+			switch reason {
+			case logging.PacketDropDOSPrevention:
+				if pt == logging.PacketType0RTT {
+					dropReason = "0rtt_dos_prevention"
+				} else {
+					dropReason = "dos_prevention"
+				}
+			case logging.PacketDropHeaderParseError:
+				dropReason = "header_parsing"
+			case logging.PacketDropPayloadDecryptError:
+				dropReason = "payload_decrypt"
+			case logging.PacketDropUnexpectedPacket:
+				dropReason = "unexpected_packet"
+			default:
+				dropReason = "unknown"
+			}
+
+			*tags = append(*tags, getIPVersion(addr))
+			*tags = append(*tags, dropReason)
+			packetDropped.WithLabelValues(*tags...).Inc()
+		},
+	}
+}
diff --git a/mtu_discoverer.go b/mtu_discoverer.go
index 1ee62606..3f3a640a 100644
--- a/mtu_discoverer.go
+++ b/mtu_discoverer.go
@@ -25,16 +25,80 @@ const (
 	maxMTUDiff = 20
 	// send a probe packet every mtuProbeDelay RTTs
 	mtuProbeDelay = 5
+	// Once maxLostMTUProbes MTU probe packets larger than a certain size are lost,
+	// MTU discovery won't probe for larger MTUs than this size.
+	// The algorithm used here is resilient to packet loss of (maxLostMTUProbes - 1) packets.
+	maxLostMTUProbes = 3
 )
 
+// The Path MTU is found by sending a larger packet every now and then.
+// If the packet is acknowledged, we conclude that the path supports this larger packet size.
+// If the packet is lost, this can mean one of two things:
+//   1. The path doesn't support this larger packet size, or
+//   2. The packet was lost due to packet loss, independent of its size.
+// The algorithm used here is resilient to packet loss of (maxLostMTUProbes - 1) packets.
+// For simplicty, the following example use maxLostMTUProbes = 2.
+//
+// Initialization:
+//    |------------------------------------------------------------------------------|
+//   min                                                                            max
+//
+// The first MTU probe packet will have size (min+max)/2.
+// Assume that this packet is acknowledged. We can now move the min marker,
+// and continue the search in the resulting interval.
+//
+// If 1st probe packet acknowledged:
+//    |---------------------------------------|--------------------------------------|
+//                                           min                                    max
+//
+// If 1st probe packet lost:
+//    |---------------------------------------|--------------------------------------|
+//   min                                    lost[0]                                 max
+//
+// We can't conclude that the path doesn't support this packet size, since the loss of the probe
+// packet could have been unrelated to the packet size.  A larger probe packet will be sent later on.
+// After a loss, the next probe packet has size (min+lost[0])/2.
+// Now assume this probe packet is acknowledged:
+//
+// 2nd probe packet acknowledged:
+//    |------------------|--------------------|--------------------------------------|
+//                      min                lost[0]                                  max
+//
+// First of all, we conclude that the path supports at least this MTU. That's progress!
+// Second, we probe a bit more aggressively with the next probe packet:
+// After an acknowledgement, the next probe packet has size (min+max)/2.
+// This means we'll send a packet larger than the first probe packet (which was lost).
+//
+// If 3rd probe packet acknowledged:
+//    |-------------------------------------------------|----------------------------|
+//                                                     min                          max
+//
+// We can conclude that the loss of the 1st probe packet was not due to its size, and
+// continue searching in a much smaller interval now.
+//
+// If 3rd probe packet lost:
+//    |------------------|--------------------|---------|----------------------------|
+//                      min                lost[0]     max
+//
+// Since in our example numPTOProbes = 2, and we lost 2 packets smaller than max, we
+// conclude that this packet size is not supported on the path, and reduce the maximum
+// value of the search interval.
+//
+// MTU discovery concludes once the interval min and max has been narrowed down to maxMTUDiff.
+
 type mtuFinder struct {
 	lastProbeTime time.Time
 	mtuIncreased  func(protocol.ByteCount)
 
 	rttStats *utils.RTTStats
+
 	inFlight protocol.ByteCount // the size of the probe packet currently in flight. InvalidByteCount if none is in flight
-	current  protocol.ByteCount
-	max      protocol.ByteCount // the maximum value, as advertised by the peer (or our maximum size buffer)
+	min      protocol.ByteCount
+	limit    protocol.ByteCount
+
+	// on initialization, we treat the maximum size as the first "lost" packet
+	lost             [maxLostMTUProbes]protocol.ByteCount
+	lastProbeWasLost bool
 
 	tracer *logging.ConnectionTracer
 }
@@ -47,29 +111,43 @@ func newMTUDiscoverer(
 	mtuIncreased func(protocol.ByteCount),
 	tracer *logging.ConnectionTracer,
 ) *mtuFinder {
-	return &mtuFinder{
+	f := &mtuFinder{
 		inFlight:     protocol.InvalidByteCount,
-		current:      start,
-		max:          max,
+		min:          start,
+		limit:        max,
 		rttStats:     rttStats,
 		mtuIncreased: mtuIncreased,
 		tracer:       tracer,
 	}
+	for i := range f.lost {
+		if i == 0 {
+			f.lost[i] = max
+			continue
+		}
+		f.lost[i] = protocol.InvalidByteCount
+	}
+	return f
 }
 
 func (f *mtuFinder) done() bool {
-	return f.max-f.current <= maxMTUDiff+1
+	return f.max()-f.min <= maxMTUDiff+1
 }
 
-func (f *mtuFinder) Start() {
-	if f.max == protocol.InvalidByteCount {
-		panic("invalid")
+func (f *mtuFinder) max() protocol.ByteCount {
+	for i, v := range f.lost {
+		if v == protocol.InvalidByteCount {
+			return f.lost[i-1]
+		}
 	}
+	return f.lost[len(f.lost)-1]
+}
+
+func (f *mtuFinder) Start() {
 	f.lastProbeTime = time.Now() // makes sure the first probe packet is not sent immediately
 }
 
 func (f *mtuFinder) ShouldSendProbe(now time.Time) bool {
-	if f.max == 0 || f.lastProbeTime.IsZero() {
+	if f.lastProbeTime.IsZero() {
 		return false
 	}
 	if f.inFlight != protocol.InvalidByteCount || f.done() {
@@ -79,7 +157,12 @@ func (f *mtuFinder) ShouldSendProbe(now time.Time) bool {
 }
 
 func (f *mtuFinder) GetPing() (ackhandler.Frame, protocol.ByteCount) {
-	size := (f.max + f.current) / 2
+	var size protocol.ByteCount
+	if f.lastProbeWasLost {
+		size = (f.min + f.lost[0]) / 2
+	} else {
+		size = (f.min + f.max()) / 2
+	}
 	f.lastProbeTime = time.Now()
 	f.inFlight = size
 	return ackhandler.Frame{
@@ -89,7 +172,7 @@ func (f *mtuFinder) GetPing() (ackhandler.Frame, protocol.ByteCount) {
 }
 
 func (f *mtuFinder) CurrentSize() protocol.ByteCount {
-	return f.current
+	return f.min
 }
 
 type mtuFinderAckHandler struct {
@@ -104,7 +187,25 @@ func (h *mtuFinderAckHandler) OnAcked(wire.Frame) {
 		panic("OnAcked callback called although there's no MTU probe packet in flight")
 	}
 	h.inFlight = protocol.InvalidByteCount
-	h.current = size
+	h.min = size
+	h.lastProbeWasLost = false
+	// remove all values smaller than size from the lost array
+	var j int
+	for i, v := range h.lost {
+		if size < v {
+			j = i
+			break
+		}
+	}
+	if j > 0 {
+		for i := 0; i < len(h.lost); i++ {
+			if i+j < len(h.lost) {
+				h.lost[i] = h.lost[i+j]
+			} else {
+				h.lost[i] = protocol.InvalidByteCount
+			}
+		}
+	}
 	if h.tracer != nil && h.tracer.UpdatedMTU != nil {
 		h.tracer.UpdatedMTU(size, h.done())
 	}
@@ -116,6 +217,13 @@ func (h *mtuFinderAckHandler) OnLost(wire.Frame) {
 	if size == protocol.InvalidByteCount {
 		panic("OnLost callback called although there's no MTU probe packet in flight")
 	}
-	h.max = size
+	h.lastProbeWasLost = true
 	h.inFlight = protocol.InvalidByteCount
+	for i, v := range h.lost {
+		if size < v {
+			copy(h.lost[i+1:], h.lost[i:])
+			h.lost[i] = size
+			break
+		}
+	}
 }


Test patch:
diff --git a/http3/client_test.go b/http3/client_test.go
index f368ffa9..4e3b0f23 100644
--- a/http3/client_test.go
+++ b/http3/client_test.go
@@ -326,6 +326,7 @@ var _ = Describe("Client", func() {
 				return len(b), nil
 			})
 			conn := mockquic.NewMockEarlyConnection(mockCtrl)
+			conn.EXPECT().Context().Return(context.Background())
 			conn.EXPECT().OpenUniStream().Return(controlStr, nil)
 			conn.EXPECT().OpenStreamSync(gomock.Any()).DoAndReturn(func(context.Context) (quic.Stream, error) {
 				<-settingsFrameWritten
@@ -360,6 +361,7 @@ var _ = Describe("Client", func() {
 				<-done
 				return nil, errors.New("test done")
 			}).MaxTimes(1)
+			conn.EXPECT().Context().Return(context.Background())
 			b := quicvarint.Append(nil, streamTypeControlStream)
 			b = (&settingsFrame{ExtendedConnect: true}).Append(b)
 			r := bytes.NewReader(b)
@@ -392,6 +394,7 @@ var _ = Describe("Client", func() {
 				wg.Done()
 				return nil, errors.New("test done")
 			}).MaxTimes(1)
+			conn.EXPECT().Context().Return(context.Background())
 			b := quicvarint.Append(nil, streamTypeControlStream)
 			b = (&settingsFrame{ExtendedConnect: true}).Append(b)
 			r := bytes.NewReader(b)
@@ -427,6 +430,7 @@ var _ = Describe("Client", func() {
 			var wg sync.WaitGroup
 			wg.Add(2)
 			conn := mockquic.NewMockEarlyConnection(mockCtrl)
+			conn.EXPECT().Context().Return(context.Background())
 			conn.EXPECT().OpenUniStream().DoAndReturn(func() (quic.SendStream, error) {
 				<-done
 				wg.Done()
@@ -507,6 +511,7 @@ var _ = Describe("Client", func() {
 			str.EXPECT().Context().Return(context.Background()).AnyTimes()
 			str.EXPECT().StreamID().AnyTimes()
 			conn = mockquic.NewMockEarlyConnection(mockCtrl)
+			conn.EXPECT().Context().Return(context.Background())
 			conn.EXPECT().OpenUniStream().Return(controlStr, nil)
 			conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
 				<-testDone
diff --git a/http3/conn_test.go b/http3/conn_test.go
index 8af906cd..4be8b7ae 100644
--- a/http3/conn_test.go
+++ b/http3/conn_test.go
@@ -24,6 +24,7 @@ var _ = Describe("Connection", func() {
 			qconn := mockquic.NewMockEarlyConnection(mockCtrl)
 			qconn.EXPECT().ReceiveDatagram(gomock.Any()).Return(nil, errors.New("no datagrams"))
 			conn := newConnection(
+				context.Background(),
 				qconn,
 				false,
 				protocol.PerspectiveServer,
@@ -56,6 +57,7 @@ var _ = Describe("Connection", func() {
 		It("rejects duplicate control streams", func() {
 			qconn := mockquic.NewMockEarlyConnection(mockCtrl)
 			conn := newConnection(
+				context.Background(),
 				qconn,
 				false,
 				protocol.PerspectiveServer,
@@ -97,6 +99,7 @@ var _ = Describe("Connection", func() {
 			It(fmt.Sprintf("ignores the QPACK %s streams", name), func() {
 				qconn := mockquic.NewMockEarlyConnection(mockCtrl)
 				conn := newConnection(
+					context.Background(),
 					qconn,
 					false,
 					protocol.PerspectiveClient,
@@ -125,6 +128,7 @@ var _ = Describe("Connection", func() {
 			It(fmt.Sprintf("rejects duplicate QPACK %s streams", name), func() {
 				qconn := mockquic.NewMockEarlyConnection(mockCtrl)
 				conn := newConnection(
+					context.Background(),
 					qconn,
 					false,
 					protocol.PerspectiveClient,
@@ -160,6 +164,7 @@ var _ = Describe("Connection", func() {
 		It("resets streams other than the control stream and the QPACK streams", func() {
 			qconn := mockquic.NewMockEarlyConnection(mockCtrl)
 			conn := newConnection(
+				context.Background(),
 				qconn,
 				false,
 				protocol.PerspectiveServer,
@@ -185,6 +190,7 @@ var _ = Describe("Connection", func() {
 		It("errors when the first frame on the control stream is not a SETTINGS frame", func() {
 			qconn := mockquic.NewMockEarlyConnection(mockCtrl)
 			conn := newConnection(
+				context.Background(),
 				qconn,
 				false,
 				protocol.PerspectiveServer,
@@ -215,6 +221,7 @@ var _ = Describe("Connection", func() {
 		It("errors when parsing the frame on the control stream fails", func() {
 			qconn := mockquic.NewMockEarlyConnection(mockCtrl)
 			conn := newConnection(
+				context.Background(),
 				qconn,
 				false,
 				protocol.PerspectiveServer,
@@ -252,6 +259,7 @@ var _ = Describe("Connection", func() {
 			It(fmt.Sprintf("errors when parsing the %s opens a push stream", pers), func() {
 				qconn := mockquic.NewMockEarlyConnection(mockCtrl)
 				conn := newConnection(
+					context.Background(),
 					qconn,
 					false,
 					pers.Opposite(),
@@ -281,6 +289,7 @@ var _ = Describe("Connection", func() {
 		It("errors when the server advertises datagram support (and we enabled support for it)", func() {
 			qconn := mockquic.NewMockEarlyConnection(mockCtrl)
 			conn := newConnection(
+				context.Background(),
 				qconn,
 				true,
 				protocol.PerspectiveClient,
@@ -319,6 +328,7 @@ var _ = Describe("Connection", func() {
 		BeforeEach(func() {
 			qconn = mockquic.NewMockEarlyConnection(mockCtrl)
 			conn = newConnection(
+				context.Background(),
 				qconn,
 				true,
 				protocol.PerspectiveClient,
diff --git a/http3/http_stream_test.go b/http3/http_stream_test.go
index a97dcb7b..a4c889e2 100644
--- a/http3/http_stream_test.go
+++ b/http3/http_stream_test.go
@@ -2,6 +2,7 @@ package http3
 
 import (
 	"bytes"
+	"context"
 	"io"
 	"math"
 	"net/http"
@@ -42,7 +43,7 @@ var _ = Describe("Stream", func() {
 				errorCbCalled = true
 				return nil
 			}).AnyTimes()
-			str = newStream(qstr, newConnection(conn, false, protocol.PerspectiveClient, nil), nil)
+			str = newStream(qstr, newConnection(context.Background(), conn, false, protocol.PerspectiveClient, nil), nil)
 		})
 
 		It("reads DATA frames in a single run", func() {
@@ -170,7 +171,7 @@ var _ = Describe("Request Stream", func() {
 		requestWriter := newRequestWriter()
 		conn := mockquic.NewMockEarlyConnection(mockCtrl)
 		str = newRequestStream(
-			newStream(qstr, newConnection(conn, false, protocol.PerspectiveClient, nil), nil),
+			newStream(qstr, newConnection(context.Background(), conn, false, protocol.PerspectiveClient, nil), nil),
 			requestWriter,
 			make(chan struct{}),
 			qpack.NewDecoder(func(qpack.HeaderField) {}),
diff --git a/http3/server_test.go b/http3/server_test.go
index f6b635fe..7e2138d5 100644
--- a/http3/server_test.go
+++ b/http3/server_test.go
@@ -148,7 +148,7 @@ var _ = Describe("Server", func() {
 			qconn.EXPECT().LocalAddr().AnyTimes()
 			qconn.EXPECT().ConnectionState().Return(quic.ConnectionState{}).AnyTimes()
 			qconn.EXPECT().Context().Return(context.Background()).AnyTimes()
-			conn = newConnection(qconn, false, protocol.PerspectiveServer, nil)
+			conn = newConnection(context.Background(), qconn, false, protocol.PerspectiveServer, nil)
 		})
 
 		It("calls the HTTP handler function", func() {
@@ -169,8 +169,6 @@ var _ = Describe("Server", func() {
 			Eventually(requestChan).Should(Receive(&req))
 			Expect(req.Host).To(Equal("www.example.com"))
 			Expect(req.RemoteAddr).To(Equal("127.0.0.1:1337"))
-			Expect(req.Context().Value(ServerContextKey)).To(Equal(s))
-			Expect(req.Context().Value(testConnContextKey("test"))).To(Equal(conn.Connection))
 		})
 
 		It("returns 200 with an empty handler", func() {
@@ -555,6 +553,7 @@ var _ = Describe("Server", func() {
 				conn = mockquic.NewMockEarlyConnection(mockCtrl)
 				controlStr := mockquic.NewMockStream(mockCtrl)
 				controlStr.EXPECT().Write(gomock.Any())
+				conn.EXPECT().Context().Return(context.Background())
 				conn.EXPECT().OpenUniStream().Return(controlStr, nil)
 				conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
 					<-testDone
@@ -734,7 +733,9 @@ var _ = Describe("Server", func() {
 			handlerCalled := make(chan struct{})
 			s.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 				defer GinkgoRecover()
-				Expect(r.Context().Done()).To(BeClosed())
+				// The context is canceled via context.AfterFunc,
+				// which performs the cancellation in a new Go routine.
+				Eventually(r.Context().Done()).Should(BeClosed())
 				Expect(r.Context().Err()).To(MatchError(context.Canceled))
 				close(handlerCalled)
 			})
@@ -1159,6 +1160,9 @@ var _ = Describe("Server", func() {
 			conn := mockquic.NewMockEarlyConnection(mockCtrl)
 			controlStr := mockquic.NewMockStream(mockCtrl)
 			controlStr.EXPECT().Write(gomock.Any())
+			conn.EXPECT().LocalAddr()
+			conn.EXPECT().RemoteAddr()
+			conn.EXPECT().Context().Return(context.Background())
 			conn.EXPECT().OpenUniStream().Return(controlStr, nil)
 			testDone := make(chan struct{})
 			conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
diff --git a/integrationtests/gomodvendor/go.sum b/integrationtests/gomodvendor/go.sum
index 260b2088..6d99bc8b 100644
--- a/integrationtests/gomodvendor/go.sum
+++ b/integrationtests/gomodvendor/go.sum
@@ -47,8 +47,8 @@ golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
 golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw=
 golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
-google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
+google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/integrationtests/self/http_test.go b/integrationtests/self/http_test.go
index 0264d1a4..db5523d6 100644
--- a/integrationtests/self/http_test.go
+++ b/integrationtests/self/http_test.go
@@ -350,9 +350,9 @@ var _ = Describe("HTTP tests", func() {
 		mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
 			defer GinkgoRecover()
 			defer close(handlerCalled)
+			// TODO(4508): check for request context cancellations
 			for {
 				if _, err := w.Write([]byte("foobar")); err != nil {
-					Expect(r.Context().Done()).To(BeClosed())
 					var http3Err *http3.Error
 					Expect(errors.As(err, &http3Err)).To(BeTrue())
 					Expect(http3Err.ErrorCode).To(Equal(http3.ErrCode(0x10c)))
@@ -570,7 +570,7 @@ var _ = Describe("HTTP tests", func() {
 			tracingID = c.Context().Value(quic.ConnectionTracingKey).(quic.ConnectionTracingID)
 			return ctx
 		}
-		mux.HandleFunc("/conn-context", func(w http.ResponseWriter, r *http.Request) {
+		mux.HandleFunc("/http3-conn-context", func(w http.ResponseWriter, r *http.Request) {
 			defer GinkgoRecover()
 			v, ok := r.Context().Value(ctxKey(0)).(string)
 			Expect(ok).To(BeTrue())
@@ -589,9 +589,45 @@ var _ = Describe("HTTP tests", func() {
 			Expect(id).To(Equal(tracingID))
 		})
 
-		resp, err := client.Get(fmt.Sprintf("https://localhost:%d/conn-context", port))
+		resp, err := client.Get(fmt.Sprintf("https://localhost:%d/http3-conn-context", port))
 		Expect(err).ToNot(HaveOccurred())
-		Expect(resp.StatusCode).To(Equal(200))
+		Expect(resp.StatusCode).To(Equal(http.StatusOK))
+	})
+
+	It("uses the QUIC connection context", func() {
+		conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
+		Expect(err).ToNot(HaveOccurred())
+		defer conn.Close()
+		tr := &quic.Transport{
+			Conn: conn,
+			ConnContext: func() context.Context {
+				//nolint:staticcheck
+				return context.WithValue(context.Background(), "foo", "bar")
+			},
+		}
+		defer tr.Close()
+		tlsConf := getTLSConfig()
+		tlsConf.NextProtos = []string{http3.NextProtoH3}
+		ln, err := tr.Listen(tlsConf, getQuicConfig(nil))
+		Expect(err).ToNot(HaveOccurred())
+		defer ln.Close()
+
+		mux.HandleFunc("/quic-conn-context", func(w http.ResponseWriter, r *http.Request) {
+			defer GinkgoRecover()
+			v, ok := r.Context().Value("foo").(string)
+			Expect(ok).To(BeTrue())
+			Expect(v).To(Equal("bar"))
+		})
+		go func() {
+			defer GinkgoRecover()
+			c, err := ln.Accept(context.Background())
+			Expect(err).ToNot(HaveOccurred())
+			server.ServeQUICConn(c)
+		}()
+
+		resp, err := client.Get(fmt.Sprintf("https://localhost:%d/quic-conn-context", conn.LocalAddr().(*net.UDPAddr).Port))
+		Expect(err).ToNot(HaveOccurred())
+		Expect(resp.StatusCode).To(Equal(http.StatusOK))
 	})
 
 	It("checks the server's settings", func() {
diff --git a/integrationtests/self/mtu_test.go b/integrationtests/self/mtu_test.go
index 64de6ab1..744d9fa0 100644
--- a/integrationtests/self/mtu_test.go
+++ b/integrationtests/self/mtu_test.go
@@ -18,9 +18,7 @@ import (
 )
 
 var _ = Describe("DPLPMTUD", func() {
-	// This test is very sensitive to packet loss, as the loss of a single Path MTU probe packet makes DPLPMTUD
-	// clip the assumed MTU at that value.
-	It("discovers the MTU", FlakeAttempts(3), func() {
+	It("discovers the MTU", func() {
 		rtt := scaleDuration(5 * time.Millisecond)
 		const mtu = 1400
 
diff --git a/integrationtests/self/zero_rtt_test.go b/integrationtests/self/zero_rtt_test.go
index 46b3786d..bf966b9c 100644
--- a/integrationtests/self/zero_rtt_test.go
+++ b/integrationtests/self/zero_rtt_test.go
@@ -813,87 +813,93 @@ var _ = Describe("0-RTT", func() {
 		Entry("doesn't reject 0-RTT when the server's transport connection flow control limit increased", func(c *quic.Config, limit uint64) { c.InitialConnectionReceiveWindow = limit }),
 	)
 
-	for _, l := range []int{0, 15} {
-		connIDLen := l
-
-		It(fmt.Sprintf("correctly deals with 0-RTT rejections, for %d byte connection IDs", connIDLen), func() {
-			tlsConf := getTLSConfig()
-			clientConf := getTLSClientConfig()
-			dialAndReceiveSessionTicket(tlsConf, nil, clientConf)
-			// now dial new connection with different transport parameters
-			counter, tracer := newPacketTracer()
-			ln, err := quic.ListenAddrEarly(
-				"localhost:0",
-				tlsConf,
-				getQuicConfig(&quic.Config{
-					MaxIncomingUniStreams: 1,
-					Tracer:                newTracer(tracer),
-				}),
-			)
-			Expect(err).ToNot(HaveOccurred())
-			defer ln.Close()
-			proxy, num0RTTPackets := runCountingProxy(ln.Addr().(*net.UDPAddr).Port)
-			defer proxy.Close()
+	test0RTTRejection := func(tlsConf *tls.Config) {
+		clientConf := getTLSClientConfig()
+		dialAndReceiveSessionTicket(tlsConf, nil, clientConf)
+		// now dial new connection with different transport parameters
+		counter, tracer := newPacketTracer()
+		ln, err := quic.ListenAddrEarly(
+			"localhost:0",
+			tlsConf,
+			getQuicConfig(&quic.Config{
+				MaxIncomingUniStreams: 1,
+				Tracer:                newTracer(tracer),
+			}),
+		)
+		Expect(err).ToNot(HaveOccurred())
+		defer ln.Close()
+		proxy, num0RTTPackets := runCountingProxy(ln.Addr().(*net.UDPAddr).Port)
+		defer proxy.Close()
 
-			conn, err := quic.DialAddrEarly(
-				context.Background(),
-				fmt.Sprintf("localhost:%d", proxy.LocalPort()),
-				clientConf,
-				getQuicConfig(nil),
-			)
-			Expect(err).ToNot(HaveOccurred())
-			// The client remembers that it was allowed to open 2 uni-directional streams.
-			firstStr, err := conn.OpenUniStream()
+		conn, err := quic.DialAddrEarly(
+			context.Background(),
+			fmt.Sprintf("localhost:%d", proxy.LocalPort()),
+			clientConf,
+			getQuicConfig(&quic.Config{}),
+		)
+		Expect(err).ToNot(HaveOccurred())
+		// The client remembers that it was allowed to open 2 uni-directional streams.
+		firstStr, err := conn.OpenUniStream()
+		Expect(err).ToNot(HaveOccurred())
+		written := make(chan struct{}, 2)
+		go func() {
+			defer GinkgoRecover()
+			defer func() { written <- struct{}{} }()
+			_, err := firstStr.Write([]byte("first flight"))
 			Expect(err).ToNot(HaveOccurred())
-			written := make(chan struct{}, 2)
-			go func() {
-				defer GinkgoRecover()
-				defer func() { written <- struct{}{} }()
-				_, err := firstStr.Write([]byte("first flight"))
-				Expect(err).ToNot(HaveOccurred())
-			}()
-			secondStr, err := conn.OpenUniStream()
+		}()
+		secondStr, err := conn.OpenUniStream()
+		Expect(err).ToNot(HaveOccurred())
+		go func() {
+			defer GinkgoRecover()
+			defer func() { written <- struct{}{} }()
+			_, err := secondStr.Write([]byte("first flight"))
 			Expect(err).ToNot(HaveOccurred())
-			go func() {
-				defer GinkgoRecover()
-				defer func() { written <- struct{}{} }()
-				_, err := secondStr.Write([]byte("first flight"))
-				Expect(err).ToNot(HaveOccurred())
-			}()
+		}()
 
-			ctx, cancel := context.WithTimeout(context.Background(), time.Second)
-			defer cancel()
-			_, err = conn.AcceptStream(ctx)
-			Expect(err).To(MatchError(quic.Err0RTTRejected))
-			Eventually(written).Should(Receive())
-			Eventually(written).Should(Receive())
-			_, err = firstStr.Write([]byte("foobar"))
-			Expect(err).To(MatchError(quic.Err0RTTRejected))
-			_, err = conn.OpenUniStream()
-			Expect(err).To(MatchError(quic.Err0RTTRejected))
-
-			_, err = conn.AcceptStream(ctx)
-			Expect(err).To(Equal(quic.Err0RTTRejected))
-
-			newConn := conn.NextConnection()
-			str, err := newConn.OpenUniStream()
-			Expect(err).ToNot(HaveOccurred())
-			_, err = newConn.OpenUniStream()
-			Expect(err).To(HaveOccurred())
-			Expect(err.Error()).To(ContainSubstring("too many open streams"))
-			_, err = str.Write([]byte("second flight"))
-			Expect(err).ToNot(HaveOccurred())
-			Expect(str.Close()).To(Succeed())
-			Expect(conn.CloseWithError(0, "")).To(Succeed())
+		ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+		defer cancel()
+		_, err = conn.AcceptStream(ctx)
+		Expect(err).To(MatchError(quic.Err0RTTRejected))
+		Eventually(written).Should(Receive())
+		Eventually(written).Should(Receive())
+		_, err = firstStr.Write([]byte("foobar"))
+		Expect(err).To(MatchError(quic.Err0RTTRejected))
+		_, err = conn.OpenUniStream()
+		Expect(err).To(MatchError(quic.Err0RTTRejected))
 
-			// The client should send 0-RTT packets, but the server doesn't process them.
-			num0RTT := num0RTTPackets.Load()
-			fmt.Fprintf(GinkgoWriter, "Sent %d 0-RTT packets.", num0RTT)
-			Expect(num0RTT).ToNot(BeZero())
-			Expect(get0RTTPackets(counter.getRcvdLongHeaderPackets())).To(BeEmpty())
-		})
+		_, err = conn.AcceptStream(ctx)
+		Expect(err).To(Equal(quic.Err0RTTRejected))
+
+		newConn := conn.NextConnection()
+		str, err := newConn.OpenUniStream()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = newConn.OpenUniStream()
+		Expect(err).To(HaveOccurred())
+		Expect(err.Error()).To(ContainSubstring("too many open streams"))
+		_, err = str.Write([]byte("second flight"))
+		Expect(err).ToNot(HaveOccurred())
+		Expect(str.Close()).To(Succeed())
+		Expect(conn.CloseWithError(0, "")).To(Succeed())
+
+		// The client should send 0-RTT packets, but the server doesn't process them.
+		num0RTT := num0RTTPackets.Load()
+		fmt.Fprintf(GinkgoWriter, "Sent %d 0-RTT packets.", num0RTT)
+		Expect(num0RTT).ToNot(BeZero())
+		Expect(get0RTTPackets(counter.getRcvdLongHeaderPackets())).To(BeEmpty())
 	}
 
+	It("correctly deals with 0-RTT rejections", func() {
+		test0RTTRejection(getTLSConfig())
+	})
+
+	It("correctly deals with 0-RTT rejections, when the server uses GetConfigForClient", func() {
+		tlsConf := getTLSConfig()
+		test0RTTRejection(&tls.Config{
+			GetConfigForClient: func(*tls.ClientHelloInfo) (*tls.Config, error) { return tlsConf, nil },
+		})
+	})
+
 	It("queues 0-RTT packets, if the Initial is delayed", func() {
 		tlsConf := getTLSConfig()
 		clientConf := getTLSClientConfig()
diff --git a/internal/handshake/crypto_setup_test.go b/internal/handshake/crypto_setup_test.go
index 0054867e..aa57f281 100644
--- a/internal/handshake/crypto_setup_test.go
+++ b/internal/handshake/crypto_setup_test.go
@@ -9,7 +9,6 @@ import (
 	"crypto/x509/pkix"
 	"math/big"
 	"net"
-	"reflect"
 	"time"
 
 	mocktls "github.com/quic-go/quic-go/internal/mocks/tls"
@@ -106,79 +105,6 @@ var _ = Describe("Crypto Setup TLS", func() {
 		Expect(err.Error()).To(ContainSubstring("tls: handshake data received at wrong level"))
 	})
 
-	Context("filling in a net.Conn in tls.ClientHelloInfo", func() {
-		var (
-			local  = &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 42}
-			remote = &net.UDPAddr{IP: net.IPv4(192, 168, 0, 1), Port: 1337}
-		)
-
-		It("wraps GetCertificate", func() {
-			var localAddr, remoteAddr net.Addr
-			tlsConf := &tls.Config{
-				GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
-					localAddr = info.Conn.LocalAddr()
-					remoteAddr = info.Conn.RemoteAddr()
-					cert := generateCert()
-					return &cert, nil
-				},
-			}
-			addConnToClientHelloInfo(tlsConf, local, remote)
-			_, err := tlsConf.GetCertificate(&tls.ClientHelloInfo{})
-			Expect(err).ToNot(HaveOccurred())
-			Expect(localAddr).To(Equal(local))
-			Expect(remoteAddr).To(Equal(remote))
-		})
-
-		It("wraps GetConfigForClient", func() {
-			var localAddr, remoteAddr net.Addr
-			tlsConf := &tls.Config{
-				GetConfigForClient: func(info *tls.ClientHelloInfo) (*tls.Config, error) {
-					localAddr = info.Conn.LocalAddr()
-					remoteAddr = info.Conn.RemoteAddr()
-					return &tls.Config{}, nil
-				},
-			}
-			addConnToClientHelloInfo(tlsConf, local, remote)
-			conf, err := tlsConf.GetConfigForClient(&tls.ClientHelloInfo{})
-			Expect(err).ToNot(HaveOccurred())
-			Expect(localAddr).To(Equal(local))
-			Expect(remoteAddr).To(Equal(remote))
-			Expect(conf).ToNot(BeNil())
-			Expect(conf.MinVersion).To(BeEquivalentTo(tls.VersionTLS13))
-		})
-
-		It("wraps GetConfigForClient, recursively", func() {
-			var localAddr, remoteAddr net.Addr
-			tlsConf := &tls.Config{}
-			var innerConf *tls.Config
-			getCert := func(info *tls.ClientHelloInfo) (*tls.Certificate, error) { //nolint:unparam
-				localAddr = info.Conn.LocalAddr()
-				remoteAddr = info.Conn.RemoteAddr()
-				cert := generateCert()
-				return &cert, nil
-			}
-			tlsConf.GetConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) {
-				innerConf = tlsConf.Clone()
-				// set the MaxVersion, so we can check that quic-go doesn't overwrite the user's config
-				innerConf.MaxVersion = tls.VersionTLS12
-				innerConf.GetCertificate = getCert
-				return innerConf, nil
-			}
-			addConnToClientHelloInfo(tlsConf, local, remote)
-			conf, err := tlsConf.GetConfigForClient(&tls.ClientHelloInfo{})
-			Expect(err).ToNot(HaveOccurred())
-			Expect(conf).ToNot(BeNil())
-			Expect(conf.MinVersion).To(BeEquivalentTo(tls.VersionTLS13))
-			_, err = conf.GetCertificate(&tls.ClientHelloInfo{})
-			Expect(err).ToNot(HaveOccurred())
-			Expect(localAddr).To(Equal(local))
-			Expect(remoteAddr).To(Equal(remote))
-			// make sure that the tls.Config returned by GetConfigForClient isn't modified
-			Expect(reflect.ValueOf(innerConf.GetCertificate).Pointer() == reflect.ValueOf(getCert).Pointer()).To(BeTrue())
-			Expect(innerConf.MaxVersion).To(BeEquivalentTo(tls.VersionTLS12))
-		})
-	})
-
 	Context("doing the handshake", func() {
 		newRTTStatsWithRTT := func(rtt time.Duration) *utils.RTTStats {
 			rttStats := &utils.RTTStats{}
diff --git a/internal/qtls/qtls_test.go b/internal/qtls/qtls_test.go
index 1007b82a..b041af74 100644
--- a/internal/qtls/qtls_test.go
+++ b/internal/qtls/qtls_test.go
@@ -2,6 +2,8 @@ package qtls
 
 import (
 	"crypto/tls"
+	"net"
+	"reflect"
 
 	"github.com/quic-go/quic-go/internal/protocol"
 
@@ -41,13 +43,86 @@ var _ = Describe("interface go crypto/tls", func() {
 	})
 
 	Context("setting up a tls.Config for the server", func() {
+		var (
+			local  = &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 42}
+			remote = &net.UDPAddr{IP: net.IPv4(192, 168, 0, 1), Port: 1337}
+		)
+
 		It("sets the minimum TLS version to TLS 1.3", func() {
 			orig := &tls.Config{MinVersion: tls.VersionTLS12}
-			conf := &tls.QUICConfig{TLSConfig: orig}
-			SetupConfigForServer(conf, false, nil, nil)
-			Expect(conf.TLSConfig.MinVersion).To(BeEquivalentTo(tls.VersionTLS13))
+			conf := SetupConfigForServer(orig, local, remote, nil, nil)
+			Expect(conf.MinVersion).To(BeEquivalentTo(tls.VersionTLS13))
 			// check that the original config wasn't modified
 			Expect(orig.MinVersion).To(BeEquivalentTo(tls.VersionTLS12))
 		})
+
+		It("wraps GetCertificate", func() {
+			var localAddr, remoteAddr net.Addr
+			tlsConf := &tls.Config{
+				GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
+					localAddr = info.Conn.LocalAddr()
+					remoteAddr = info.Conn.RemoteAddr()
+					return &tls.Certificate{}, nil
+				},
+			}
+			conf := SetupConfigForServer(tlsConf, local, remote, nil, nil)
+			_, err := conf.GetCertificate(&tls.ClientHelloInfo{})
+			Expect(err).ToNot(HaveOccurred())
+			Expect(localAddr).To(Equal(local))
+			Expect(remoteAddr).To(Equal(remote))
+		})
+
+		It("wraps GetConfigForClient", func() {
+			var localAddr, remoteAddr net.Addr
+			tlsConf := SetupConfigForServer(
+				&tls.Config{
+					GetConfigForClient: func(info *tls.ClientHelloInfo) (*tls.Config, error) {
+						localAddr = info.Conn.LocalAddr()
+						remoteAddr = info.Conn.RemoteAddr()
+						return &tls.Config{}, nil
+					},
+				},
+				local,
+				remote,
+				nil,
+				nil,
+			)
+			conf, err := tlsConf.GetConfigForClient(&tls.ClientHelloInfo{})
+			Expect(err).ToNot(HaveOccurred())
+			Expect(localAddr).To(Equal(local))
+			Expect(remoteAddr).To(Equal(remote))
+			Expect(conf).ToNot(BeNil())
+			Expect(conf.MinVersion).To(BeEquivalentTo(tls.VersionTLS13))
+		})
+
+		It("wraps GetConfigForClient, recursively", func() {
+			var localAddr, remoteAddr net.Addr
+			tlsConf := &tls.Config{}
+			var innerConf *tls.Config
+			getCert := func(info *tls.ClientHelloInfo) (*tls.Certificate, error) { //nolint:unparam
+				localAddr = info.Conn.LocalAddr()
+				remoteAddr = info.Conn.RemoteAddr()
+				return &tls.Certificate{}, nil
+			}
+			tlsConf.GetConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) {
+				innerConf = tlsConf.Clone()
+				// set the MaxVersion, so we can check that quic-go doesn't overwrite the user's config
+				innerConf.MaxVersion = tls.VersionTLS12
+				innerConf.GetCertificate = getCert
+				return innerConf, nil
+			}
+			tlsConf = SetupConfigForServer(tlsConf, local, remote, nil, nil)
+			conf, err := tlsConf.GetConfigForClient(&tls.ClientHelloInfo{})
+			Expect(err).ToNot(HaveOccurred())
+			Expect(conf).ToNot(BeNil())
+			Expect(conf.MinVersion).To(BeEquivalentTo(tls.VersionTLS13))
+			_, err = conf.GetCertificate(&tls.ClientHelloInfo{})
+			Expect(err).ToNot(HaveOccurred())
+			Expect(localAddr).To(Equal(local))
+			Expect(remoteAddr).To(Equal(remote))
+			// make sure that the tls.Config returned by GetConfigForClient isn't modified
+			Expect(reflect.ValueOf(innerConf.GetCertificate).Pointer() == reflect.ValueOf(getCert).Pointer()).To(BeTrue())
+			Expect(innerConf.MaxVersion).To(BeEquivalentTo(tls.VersionTLS12))
+		})
 	})
 })
diff --git a/mtu_discoverer_test.go b/mtu_discoverer_test.go
index fc42f539..ff764670 100644
--- a/mtu_discoverer_test.go
+++ b/mtu_discoverer_test.go
@@ -1,9 +1,11 @@
 package quic
 
 import (
-	"math/rand"
+	"fmt"
 	"time"
 
+	"golang.org/x/exp/rand"
+
 	"github.com/quic-go/quic-go/internal/protocol"
 	"github.com/quic-go/quic-go/internal/utils"
 	"github.com/quic-go/quic-go/logging"
@@ -25,6 +27,7 @@ var _ = Describe("MTU Discoverer", func() {
 		now           time.Time
 		discoveredMTU protocol.ByteCount
 	)
+	r := rand.New(rand.NewSource(uint64(GinkgoRandomSeed())))
 
 	BeforeEach(func() {
 		rttStats = &utils.RTTStats{}
@@ -76,6 +79,7 @@ var _ = Describe("MTU Discoverer", func() {
 		t := now.Add(5 * rtt)
 		for d.ShouldSendProbe(t) {
 			ping, size := d.GetPing()
+			fmt.Println("sending", size)
 			ping.Handler.OnAcked(ping.Frame)
 			sizes = append(sizes, size)
 			t = t.Add(5 * rtt)
@@ -91,53 +95,107 @@ var _ = Describe("MTU Discoverer", func() {
 		}
 	})
 
-	It("finds the MTU", func() {
-		const rep = 3000
-		var maxDiff protocol.ByteCount
-		for i := 0; i < rep; i++ {
-			maxMTU := protocol.ByteCount(rand.Intn(int(3000-startMTU))) + startMTU + 1
-			currentMTU := startMTU
-			var tracedMTU protocol.ByteCount
-			var tracerDone bool
-			d := newMTUDiscoverer(
-				rttStats,
-				startMTU,
-				maxMTU,
-				func(s protocol.ByteCount) { currentMTU = s },
-				&logging.ConnectionTracer{
-					UpdatedMTU: func(mtu logging.ByteCount, done bool) {
-						tracedMTU = mtu
-						tracerDone = done
-					},
+	It("finds the MTU", MustPassRepeatedly(300), func() {
+		maxMTU := protocol.ByteCount(r.Intn(int(3000-startMTU))) + startMTU + 1
+		currentMTU := startMTU
+		var tracedMTU protocol.ByteCount
+		var tracerDone bool
+		d := newMTUDiscoverer(
+			rttStats,
+			startMTU,
+			maxMTU,
+			func(s protocol.ByteCount) { currentMTU = s },
+			&logging.ConnectionTracer{
+				UpdatedMTU: func(mtu logging.ByteCount, done bool) {
+					tracedMTU = mtu
+					tracerDone = done
 				},
-			)
-			d.Start()
-			now := time.Now()
-			realMTU := protocol.ByteCount(rand.Intn(int(maxMTU-startMTU))) + startMTU
-			t := now.Add(mtuProbeDelay * rtt)
-			var count int
-			for d.ShouldSendProbe(t) {
-				if count > 25 {
-					Fail("too many iterations")
-				}
-				count++
+			},
+		)
+		d.Start()
+		now := time.Now()
+		realMTU := protocol.ByteCount(r.Intn(int(maxMTU-startMTU))) + startMTU
+		fmt.Fprintf(GinkgoWriter, "MTU: %d, max: %d\n", realMTU, maxMTU)
+		t := now.Add(mtuProbeDelay * rtt)
+		var probes []protocol.ByteCount
+		for d.ShouldSendProbe(t) {
+			if len(probes) > 24 {
+				Fail(fmt.Sprintf("too many iterations: %v", probes))
+			}
+			ping, size := d.GetPing()
+			probes = append(probes, size)
+			if size <= realMTU {
+				ping.Handler.OnAcked(ping.Frame)
+			} else {
+				ping.Handler.OnLost(ping.Frame)
+			}
+			t = t.Add(mtuProbeDelay * rtt)
+		}
+		diff := realMTU - currentMTU
+		Expect(diff).To(BeNumerically(">=", 0))
+		if maxMTU > currentMTU+maxMTU {
+			Expect(tracedMTU).To(Equal(currentMTU))
+			Expect(tracerDone).To(BeTrue())
+		}
+		fmt.Fprintf(GinkgoWriter, "MTU discovered: %d (diff: %d)\n", currentMTU, diff)
+		fmt.Fprintf(GinkgoWriter, "probes sent (%d): %v\n", len(probes), probes)
+		Expect(diff).To(BeNumerically("<=", maxMTUDiff))
+	})
 
-				ping, size := d.GetPing()
-				if size <= realMTU {
-					ping.Handler.OnAcked(ping.Frame)
+	const maxRandomLoss = maxLostMTUProbes - 1
+	It(fmt.Sprintf("finds the MTU, with up to %d packets lost", maxRandomLoss), MustPassRepeatedly(500), func() {
+		maxMTU := protocol.ByteCount(r.Intn(int(3000-startMTU))) + startMTU + 1
+		currentMTU := startMTU
+		var tracedMTU protocol.ByteCount
+		var tracerDone bool
+		d := newMTUDiscoverer(
+			rttStats,
+			startMTU,
+			maxMTU,
+			func(s protocol.ByteCount) { currentMTU = s },
+			&logging.ConnectionTracer{
+				UpdatedMTU: func(mtu logging.ByteCount, done bool) {
+					tracedMTU = mtu
+					tracerDone = done
+				},
+			},
+		)
+		d.Start()
+		now := time.Now()
+		realMTU := protocol.ByteCount(r.Intn(int(maxMTU-startMTU))) + startMTU
+		fmt.Fprintf(GinkgoWriter, "MTU: %d, max: %d\n", realMTU, maxMTU)
+		t := now.Add(mtuProbeDelay * rtt)
+		var probes, randomLosses []protocol.ByteCount
+		for d.ShouldSendProbe(t) {
+			if len(probes) > 32 {
+				Fail(fmt.Sprintf("too many iterations: %v", probes))
+			}
+			ping, size := d.GetPing()
+			probes = append(probes, size)
+			packetFits := size <= realMTU
+			var acked bool
+			if packetFits {
+				randomLoss := r.Intn(maxLostMTUProbes) == 0 && len(randomLosses) < maxRandomLoss
+				if randomLoss {
+					randomLosses = append(randomLosses, size)
 				} else {
-					ping.Handler.OnLost(ping.Frame)
+					ping.Handler.OnAcked(ping.Frame)
+					acked = true
 				}
-				t = t.Add(mtuProbeDelay * rtt)
 			}
-			diff := realMTU - currentMTU
-			Expect(diff).To(BeNumerically(">=", 0))
-			maxDiff = max(maxDiff, diff)
-			if maxMTU > currentMTU+maxMTU {
-				Expect(tracedMTU).To(Equal(currentMTU))
-				Expect(tracerDone).To(BeTrue())
+			if !acked {
+				ping.Handler.OnLost(ping.Frame)
 			}
+			t = t.Add(mtuProbeDelay * rtt)
+		}
+		diff := realMTU - currentMTU
+		Expect(diff).To(BeNumerically(">=", 0))
+		if maxMTU > currentMTU+maxMTU {
+			Expect(tracedMTU).To(Equal(currentMTU))
+			Expect(tracerDone).To(BeTrue())
 		}
-		Expect(maxDiff).To(BeEquivalentTo(maxMTUDiff))
+		fmt.Fprintf(GinkgoWriter, "MTU discovered with random losses %v: %d (diff: %d)\n", randomLosses, currentMTU, diff)
+		fmt.Fprintf(GinkgoWriter, "probes sent (%d): %v\n", len(probes), probes)
+		Expect(diff).To(BeNumerically("<=", maxMTUDiff))
 	})
 })