Repository: quic-go/quic-go
Pull request #4940 (2025-01-27 07:20:11)

ackhandler: implement timer logic for path probe packets
Part of #234.

Path probe packets are treated differently from regular packets: The new path might have a vastly different RTT than the original path.

Path probe packets are declared lost 1s after they are sent. This value can be reduced, once implement proper retransmission logic for lost path probes.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/quic-go/quic-go/pull/4940?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 `15 lines` in your changes missing coverage. Please review.
> Project coverage is 83.83%. Comparing base [(`7f5ea8a`)](https://app.codecov.io/gh/quic-go/quic-go/commit/7f5ea8a54ddb6dab151985c13dbad5de205c1bd7?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) to head [(`3a0ae68`)](https://app.codecov.io/gh/quic-go/quic-go/commit/3a0ae68a040f3f387a73f12b0ff71453c5214279?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go).

| [Files with missing lines](https://app.codecov.io/gh/quic-go/quic-go/pull/4940?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 |
|---|---|---|
| [internal/ackhandler/sent\_packet\_handler.go](https://app.codecov.io/gh/quic-go/quic-go/pull/4940?src=pr&el=tree&filepath=internal%2Fackhandler%2Fsent_packet_handler.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go#diff-aW50ZXJuYWwvYWNraGFuZGxlci9zZW50X3BhY2tldF9oYW5kbGVyLmdv) | 85.00% | [10 Missing and 5 partials :warning: ](https://app.codecov.io/gh/quic-go/quic-go/pull/4940?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    #4940      +/-   ##
==========================================
- Coverage   83.84%   83.83%   -0.01%     
==========================================
  Files         153      150       -3     
  Lines       16457    16186     -271     
==========================================
- Hits        13798    13569     -229     
+ Misses       2132     2108      -24     
+ Partials      527      509      -18     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/quic-go/quic-go/pull/4940?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 5afc7c6b..df0ca314 100644
--- a/connection.go
+++ b/connection.go
@@ -19,6 +19,7 @@ import (
 	"github.com/quic-go/quic-go/internal/protocol"
 	"github.com/quic-go/quic-go/internal/qerr"
 	"github.com/quic-go/quic-go/internal/utils"
+	"github.com/quic-go/quic-go/internal/utils/ringbuffer"
 	"github.com/quic-go/quic-go/internal/wire"
 	"github.com/quic-go/quic-go/logging"
 )
@@ -147,15 +148,17 @@ type connection struct {
 	packer        packer
 	mtuDiscoverer mtuDiscoverer // initialized when the transport parameters are received
 
-	maxPayloadSizeEstimate atomic.Uint32
+	currentMTUEstimate atomic.Uint32
 
 	initialStream       *cryptoStream
 	handshakeStream     *cryptoStream
 	oneRTTStream        *cryptoStream // only set for the server
 	cryptoStreamHandler cryptoStreamHandler
 
-	receivedPackets  chan receivedPacket
-	sendingScheduled chan struct{}
+	notifyReceivedPacket chan struct{}
+	sendingScheduled     chan struct{}
+	receivedPacketMx     sync.Mutex
+	receivedPackets      ringbuffer.RingBuffer[receivedPacket]
 
 	// closeChan is used to notify the run loop that it should terminate
 	closeChan chan struct{}
@@ -279,7 +282,7 @@ var newConnection = func(
 		s.tracer,
 		s.logger,
 	)
-	s.maxPayloadSizeEstimate.Store(uint32(estimateMaxPayloadSize(protocol.ByteCount(s.config.InitialPacketSize))))
+	s.currentMTUEstimate.Store(uint32(estimateMaxPayloadSize(protocol.ByteCount(s.config.InitialPacketSize))))
 	statelessResetToken := statelessResetter.GetStatelessResetToken(srcConnID)
 	params := &wire.TransportParameters{
 		InitialMaxStreamDataBidiLocal:   protocol.ByteCount(s.config.InitialStreamReceiveWindow),
@@ -391,7 +394,7 @@ var newClientConnection = func(
 		s.tracer,
 		s.logger,
 	)
-	s.maxPayloadSizeEstimate.Store(uint32(estimateMaxPayloadSize(protocol.ByteCount(s.config.InitialPacketSize))))
+	s.currentMTUEstimate.Store(uint32(estimateMaxPayloadSize(protocol.ByteCount(s.config.InitialPacketSize))))
 	oneRTTStream := newCryptoStream()
 	params := &wire.TransportParameters{
 		InitialMaxStreamDataBidiRemote: protocol.ByteCount(s.config.InitialStreamReceiveWindow),
@@ -478,7 +481,8 @@ func (s *connection) preSetup() {
 		s.perspective,
 	)
 	s.framer = newFramer(s.connFlowController)
-	s.receivedPackets = make(chan receivedPacket, protocol.MaxConnUnprocessedPackets)
+	s.receivedPackets.Init(8)
+	s.notifyReceivedPacket = make(chan struct{}, 1)
 	s.closeChan = make(chan struct{}, 1)
 	s.sendingScheduled = make(chan struct{}, 1)
 	s.handshakeCompleteChan = make(chan struct{})
@@ -496,18 +500,14 @@ func (s *connection) run() (err error) {
 	defer func() { s.ctxCancel(err) }()
 
 	defer func() {
-		// Drain queued packets that will never be processed.
-		for {
-			select {
-			case p, ok := <-s.receivedPackets:
-				if !ok {
-					return
-				}
-				p.buffer.Decrement()
-				p.buffer.MaybeRelease()
-			default:
-				return
-			}
+		// drain queued packets that will never be processed
+		s.receivedPacketMx.Lock()
+		defer s.receivedPacketMx.Unlock()
+
+		for !s.receivedPackets.Empty() {
+			p := s.receivedPackets.PopFront()
+			p.buffer.Decrement()
+			p.buffer.MaybeRelease()
 		}
 	}()
 
@@ -544,10 +544,15 @@ runLoop:
 		default:
 		}
 
-		s.maybeResetTimer()
+		// no need to set a timer if we can send packets immediately
+		if s.pacingDeadline != deadlineSendImmediately {
+			s.maybeResetTimer()
+		}
 
-		var processedUndecryptablePacket bool
+		// 1st: handle undecryptable packets, if any.
+		// This can only occur before completion of the handshake.
 		if len(s.undecryptablePacketsToProcess) > 0 {
+			var processedUndecryptablePacket bool
 			queue := s.undecryptablePacketsToProcess
 			s.undecryptablePacketsToProcess = nil
 			for _, p := range queue {
@@ -560,39 +565,54 @@ runLoop:
 					processedUndecryptablePacket = true
 				}
 			}
+			if processedUndecryptablePacket {
+				// if we processed any undecryptable packets, jump to the resetting of the timers directly
+				continue
+			}
 		}
-		// If we processed any undecryptable packets, jump to the resetting of the timers directly.
-		if !processedUndecryptablePacket {
+
+		// 2nd: receive packets.
+		processed, err := s.handlePackets() // don't check receivedPackets.Len() in the run loop to avoid locking the mutex
+		if err != nil {
+			s.setCloseError(&closeError{err: err})
+			break runLoop
+		}
+
+		// We don't need to wait for new events if:
+		// * we processed packets: we probably need to send an ACK, and potentially more data
+		// * the pacer allows us to send more packets immediately
+		shouldProceedImmediately := sendQueueAvailable == nil && (processed || s.pacingDeadline == deadlineSendImmediately)
+		if !shouldProceedImmediately {
+			// 3rd: wait for something to happen:
+			// * closing of the connection
+			// * timer firing
+			// * sending scheduled
+			// * send queue available
+			// * received packets
 			select {
 			case <-s.closeChan:
 				break runLoop
 			case <-s.timer.Chan():
 				s.timer.SetRead()
-				// We do all the interesting stuff after the switch statement, so
-				// nothing to see here.
 			case <-s.sendingScheduled:
-				// We do all the interesting stuff after the switch statement, so
-				// nothing to see here.
 			case <-sendQueueAvailable:
-			case firstPacket := <-s.receivedPackets:
-				wasProcessed, err := s.handlePackets(firstPacket)
+			case <-s.notifyReceivedPacket:
+				wasProcessed, err := s.handlePackets()
 				if err != nil {
 					s.setCloseError(&closeError{err: err})
 					break runLoop
 				}
-				// Only reset the timers if this packet was actually processed.
-				// This avoids modifying any state when handling undecryptable packets,
-				// which could be injected by an attacker.
+				// if we processed any undecryptable packets, jump to the resetting of the timers directly
 				if !wasProcessed {
 					continue
 				}
 			}
 		}
 
+		// Check for loss detection timeout.
+		// This could cause packets to be declared lost, and retransmissions to be enqueued.
 		now := time.Now()
 		if timeout := s.sentPacketHandler.GetLossDetectionTimeout(); !timeout.IsZero() && timeout.Before(now) {
-			// This could cause packets to be retransmitted.
-			// Check it before trying to send packets.
 			if err := s.sentPacketHandler.OnLossDetectionTimeout(now); err != nil {
 				s.setCloseError(&closeError{err: err})
 				break runLoop
@@ -784,32 +804,45 @@ func (s *connection) handleHandshakeConfirmed(now time.Time) error {
 	return nil
 }
 
-func (s *connection) handlePackets(firstPacket receivedPacket) (wasProcessed bool, _ error) {
-	wasProcessed, err := s.handleOnePacket(firstPacket)
-	if err != nil {
-		return false, err
-	}
-	// only process a single packet at a time before handshake completion
-	if !s.handshakeComplete {
-		return wasProcessed, nil
-	}
+func (s *connection) handlePackets() (wasProcessed bool, _ error) {
 	// Now process all packets in the receivedPackets channel.
 	// Limit the number of packets to the length of the receivedPackets channel,
 	// so we eventually get a chance to send out an ACK when receiving a lot of packets.
-	numPackets := len(s.receivedPackets)
-receiveLoop:
+	s.receivedPacketMx.Lock()
+	numPackets := s.receivedPackets.Len()
+	if numPackets == 0 {
+		s.receivedPacketMx.Unlock()
+		return false, nil
+	}
+
+	var hasMorePackets bool
 	for i := 0; i < numPackets; i++ {
+		if i > 0 {
+			s.receivedPacketMx.Lock()
+		}
+		p := s.receivedPackets.PopFront()
+		hasMorePackets = !s.receivedPackets.Empty()
+		s.receivedPacketMx.Unlock()
+
+		processed, err := s.handleOnePacket(p)
+		if err != nil {
+			return false, err
+		}
+		if processed {
+			wasProcessed = true
+		}
+		if !hasMorePackets {
+			break
+		}
+		// only process a single packet at a time before handshake completion
+		if !s.handshakeComplete {
+			break
+		}
+	}
+	if hasMorePackets {
 		select {
-		case p := <-s.receivedPackets:
-			processed, err := s.handleOnePacket(p)
-			if err != nil {
-				return false, err
-			}
-			if processed {
-				wasProcessed = true
-			}
+		case s.notifyReceivedPacket <- struct{}{}:
 		default:
-			break receiveLoop
 		}
 	}
 	return wasProcessed, nil
@@ -1392,14 +1425,22 @@ func (s *connection) handleFrame(
 
 // handlePacket is called by the server with a new packet
 func (s *connection) handlePacket(p receivedPacket) {
+	s.receivedPacketMx.Lock()
 	// Discard packets once the amount of queued packets is larger than
 	// the channel size, protocol.MaxConnUnprocessedPackets
-	select {
-	case s.receivedPackets <- p:
-	default:
+	if s.receivedPackets.Len() >= protocol.MaxConnUnprocessedPackets {
 		if s.tracer != nil && s.tracer.DroppedPacket != nil {
 			s.tracer.DroppedPacket(logging.PacketTypeNotDetermined, protocol.InvalidPacketNumber, p.Size(), logging.PacketDropDOSPrevention)
 		}
+		s.receivedPacketMx.Unlock()
+		return
+	}
+	s.receivedPackets.PushBack(p)
+	s.receivedPacketMx.Unlock()
+
+	select {
+	case s.notifyReceivedPacket <- struct{}{}:
+	default:
 	}
 }
 
@@ -1586,6 +1627,13 @@ func (s *connection) handleAckFrame(frame *wire.AckFrame, encLevel protocol.Encr
 			return err
 		}
 	}
+	// If one of the acknowledged packets was a Path MTU probe packet, this might have increased the Path MTU estimate.
+	if s.mtuDiscoverer != nil {
+		if mtu := s.mtuDiscoverer.CurrentSize(); mtu > protocol.ByteCount(s.currentMTUEstimate.Load()) {
+			s.currentMTUEstimate.Store(uint32(mtu))
+			s.sentPacketHandler.SetMaxDatagramSize(mtu)
+		}
+	}
 	return s.cryptoStreamHandler.SetLargest1RTTAcked(frame.LargestAcked())
 }
 
@@ -1851,7 +1899,6 @@ func (s *connection) applyTransportParameters() {
 		s.rttStats,
 		protocol.ByteCount(s.config.InitialPacketSize),
 		maxPacketSize,
-		s.onMTUIncreased,
 		s.tracer,
 	)
 }
@@ -1972,7 +2019,10 @@ func (s *connection) sendPacketsWithoutGSO(now time.Time) error {
 			return nil
 		}
 		// Prioritize receiving of packets over sending out more packets.
-		if len(s.receivedPackets) > 0 {
+		s.receivedPacketMx.Lock()
+		hasPackets := !s.receivedPackets.Empty()
+		s.receivedPacketMx.Unlock()
+		if hasPackets {
 			s.pacingDeadline = deadlineSendImmediately
 			return nil
 		}
@@ -2030,7 +2080,10 @@ func (s *connection) sendPacketsWithGSO(now time.Time) error {
 		}
 
 		// Prioritize receiving of packets over sending out more packets.
-		if len(s.receivedPackets) > 0 {
+		s.receivedPacketMx.Lock()
+		hasPackets := !s.receivedPackets.Empty()
+		s.receivedPacketMx.Unlock()
+		if hasPackets {
 			s.pacingDeadline = deadlineSendImmediately
 			return nil
 		}
@@ -2141,7 +2194,7 @@ func (s *connection) registerPackedShortHeaderPacket(p shortHeaderPacket, ecn pr
 	if p.Ack != nil {
 		largestAcked = p.Ack.LargestAcked()
 	}
-	s.sentPacketHandler.SentPacket(now, p.PacketNumber, largestAcked, p.StreamFrames, p.Frames, protocol.Encryption1RTT, ecn, p.Length, p.IsPathMTUProbePacket)
+	s.sentPacketHandler.SentPacket(now, p.PacketNumber, largestAcked, p.StreamFrames, p.Frames, protocol.Encryption1RTT, ecn, p.Length, p.IsPathMTUProbePacket, false)
 	s.connIDManager.SentPacket()
 }
 
@@ -2155,7 +2208,7 @@ func (s *connection) sendPackedCoalescedPacket(packet *coalescedPacket, ecn prot
 		if p.ack != nil {
 			largestAcked = p.ack.LargestAcked()
 		}
-		s.sentPacketHandler.SentPacket(now, p.header.PacketNumber, largestAcked, p.streamFrames, p.frames, p.EncryptionLevel(), ecn, p.length, false)
+		s.sentPacketHandler.SentPacket(now, p.header.PacketNumber, largestAcked, p.streamFrames, p.frames, p.EncryptionLevel(), ecn, p.length, false, false)
 		if s.perspective == protocol.PerspectiveClient && p.EncryptionLevel() == protocol.EncryptionHandshake &&
 			!s.droppedInitialKeys {
 			// On the client side, Initial keys are dropped as soon as the first Handshake packet is sent.
@@ -2173,7 +2226,7 @@ func (s *connection) sendPackedCoalescedPacket(packet *coalescedPacket, ecn prot
 		if p.Ack != nil {
 			largestAcked = p.Ack.LargestAcked()
 		}
-		s.sentPacketHandler.SentPacket(now, p.PacketNumber, largestAcked, p.StreamFrames, p.Frames, protocol.Encryption1RTT, ecn, p.Length, p.IsPathMTUProbePacket)
+		s.sentPacketHandler.SentPacket(now, p.PacketNumber, largestAcked, p.StreamFrames, p.Frames, protocol.Encryption1RTT, ecn, p.Length, p.IsPathMTUProbePacket, false)
 	}
 	s.connIDManager.SentPacket()
 	s.sendQueue.Send(packet.buffer, 0, ecn)
@@ -2319,11 +2372,6 @@ func (s *connection) onStreamCompleted(id protocol.StreamID) {
 	s.framer.RemoveActiveStream(id)
 }
 
-func (s *connection) onMTUIncreased(mtu protocol.ByteCount) {
-	s.maxPayloadSizeEstimate.Store(uint32(estimateMaxPayloadSize(mtu)))
-	s.sentPacketHandler.SetMaxDatagramSize(mtu)
-}
-
 func (s *connection) SendDatagram(p []byte) error {
 	if !s.supportsDatagrams() {
 		return errors.New("datagram support disabled")
@@ -2334,7 +2382,7 @@ func (s *connection) SendDatagram(p []byte) error {
 	// Under many circumstances we could send a few more bytes.
 	maxDataLen := min(
 		f.MaxDataLen(s.peerParams.MaxDatagramFrameSize, s.version),
-		protocol.ByteCount(s.maxPayloadSizeEstimate.Load()),
+		protocol.ByteCount(s.currentMTUEstimate.Load()),
 	)
 	if protocol.ByteCount(len(p)) > maxDataLen {
 		return &DatagramTooLargeError{MaxDatagramPayloadSize: int64(maxDataLen)}
diff --git a/http3/server.go b/http3/server.go
index 097a8005..1479609c 100644
--- a/http3/server.go
+++ b/http3/server.go
@@ -684,7 +684,7 @@ func (s *Server) handleRequest(conn *connection, str quic.Stream, datagrams *dat
 				if logger == nil {
 					logger = slog.Default()
 				}
-				logger.Error("http: panic serving", "arg", p, "trace", string(buf))
+				logger.Error("http3: panic serving", "arg", p, "trace", string(buf))
 			}
 		}()
 		handler.ServeHTTP(r, req)
@@ -694,18 +694,6 @@ func (s *Server) handleRequest(conn *connection, str quic.Stream, datagrams *dat
 		return
 	}
 
-	// only write response when there is no panic
-	if !panicked {
-		// response not written to the client yet, set Content-Length
-		if !r.headerWritten {
-			if _, haveCL := r.header["Content-Length"]; !haveCL {
-				r.header.Set("Content-Length", strconv.FormatInt(r.numWritten, 10))
-			}
-		}
-		r.Flush()
-		r.flushTrailers()
-	}
-
 	// abort the stream when there is a panic
 	if panicked {
 		str.CancelRead(quic.StreamErrorCode(ErrCodeInternalError))
@@ -713,9 +701,17 @@ func (s *Server) handleRequest(conn *connection, str quic.Stream, datagrams *dat
 		return
 	}
 
+	// response not written to the client yet, set Content-Length
+	if !r.headerWritten {
+		if _, haveCL := r.header["Content-Length"]; !haveCL {
+			r.header.Set("Content-Length", strconv.FormatInt(r.numWritten, 10))
+		}
+	}
+	r.Flush()
+	r.flushTrailers()
+
 	// If the EOF was read by the handler, CancelRead() is a no-op.
 	str.CancelRead(quic.StreamErrorCode(ErrCodeNoError))
-
 	str.Close()
 }
 
diff --git a/internal/ackhandler/interfaces.go b/internal/ackhandler/interfaces.go
index acf95426..5fcce44d 100644
--- a/internal/ackhandler/interfaces.go
+++ b/internal/ackhandler/interfaces.go
@@ -10,7 +10,7 @@ import (
 // SentPacketHandler handles ACKs received for outgoing packets
 type SentPacketHandler interface {
 	// SentPacket may modify the packet
-	SentPacket(t time.Time, pn, largestAcked protocol.PacketNumber, streamFrames []StreamFrame, frames []Frame, encLevel protocol.EncryptionLevel, ecn protocol.ECN, size protocol.ByteCount, isPathMTUProbePacket bool)
+	SentPacket(t time.Time, pn, largestAcked protocol.PacketNumber, streamFrames []StreamFrame, frames []Frame, encLevel protocol.EncryptionLevel, ecn protocol.ECN, size protocol.ByteCount, isPathMTUProbePacket, isPathProbePacket bool)
 	// ReceivedAck processes an ACK frame.
 	// It does not store a copy of the frame.
 	ReceivedAck(f *wire.AckFrame, encLevel protocol.EncryptionLevel, rcvTime time.Time) (bool /* 1-RTT packet acked */, error)
@@ -34,6 +34,8 @@ type SentPacketHandler interface {
 
 	GetLossDetectionTimeout() time.Time
 	OnLossDetectionTimeout(now time.Time) error
+
+	MigratedPath(now time.Time, initialMaxPacketSize protocol.ByteCount)
 }
 
 type sentPacketTracker interface {
diff --git a/internal/ackhandler/sent_packet_handler.go b/internal/ackhandler/sent_packet_handler.go
index 6730de0e..f8b8cd92 100644
--- a/internal/ackhandler/sent_packet_handler.go
+++ b/internal/ackhandler/sent_packet_handler.go
@@ -27,6 +27,9 @@ const (
 	maxPTODuration = 60 * time.Second
 )
 
+// Path probe packets are declared lost after this time.
+const pathProbePacketLossTimeout = time.Second
+
 type packetNumberSpace struct {
 	history sentPacketHistory
 	pns     packetNumberGenerator
@@ -249,11 +252,12 @@ func (h *sentPacketHandler) SentPacket(
 	ecn protocol.ECN,
 	size protocol.ByteCount,
 	isPathMTUProbePacket bool,
+	isPathProbePacket bool,
 ) {
 	h.bytesSent += size
 
 	pnSpace := h.getPacketNumberSpace(encLevel)
-	if h.logger.Debug() && pnSpace.history.HasOutstandingPackets() {
+	if h.logger.Debug() && (pnSpace.history.HasOutstandingPackets() || pnSpace.history.HasOutstandingPathProbes()) {
 		for p := max(0, pnSpace.largestSent+1); p < pn; p++ {
 			h.logger.Debugf("Skipping packet number %d", p)
 		}
@@ -262,6 +266,18 @@ func (h *sentPacketHandler) SentPacket(
 	pnSpace.largestSent = pn
 	isAckEliciting := len(streamFrames) > 0 || len(frames) > 0
 
+	if isPathProbePacket {
+		p := getPacket()
+		p.SendTime = t
+		p.PacketNumber = pn
+		p.EncryptionLevel = encLevel
+		p.Length = size
+		p.Frames = frames
+		p.isPathProbePacket = true
+		pnSpace.history.SentPathProbePacket(p)
+		h.setLossDetectionTimer(t)
+		return
+	}
 	if isAckEliciting {
 		pnSpace.lastAckElicitingPacketTime = t
 		h.bytesInFlight += size
@@ -341,7 +357,7 @@ func (h *sentPacketHandler) ReceivedAck(ack *wire.AckFrame, encLevel protocol.En
 	}
 	// update the RTT, if the largest acked is newly acknowledged
 	if len(ackedPackets) > 0 {
-		if p := ackedPackets[len(ackedPackets)-1]; p.PacketNumber == ack.LargestAcked() {
+		if p := ackedPackets[len(ackedPackets)-1]; p.PacketNumber == ack.LargestAcked() && !p.isPathProbePacket {
 			// don't use the ack delay for Initial and Handshake packets
 			var ackDelay time.Duration
 			if encLevel == protocol.Encryption1RTT {
@@ -366,6 +382,9 @@ func (h *sentPacketHandler) ReceivedAck(ack *wire.AckFrame, encLevel protocol.En
 	pnSpace.largestAcked = max(pnSpace.largestAcked, largestAcked)
 
 	h.detectLostPackets(rcvTime, encLevel)
+	if encLevel == protocol.Encryption1RTT {
+		h.detectLostPathProbes(rcvTime)
+	}
 	var acked1RTTPacket bool
 	for _, p := range ackedPackets {
 		if p.includedInBytesInFlight && !p.declaredLost {
@@ -375,7 +394,9 @@ func (h *sentPacketHandler) ReceivedAck(ack *wire.AckFrame, encLevel protocol.En
 			acked1RTTPacket = true
 		}
 		h.removeFromBytesInFlight(p)
-		putPacket(p)
+		if !p.isPathProbePacket {
+			putPacket(p)
+		}
 	}
 	// After this point, we must not use ackedPackets any longer!
 	// We've already returned the buffers.
@@ -411,11 +432,11 @@ func (h *sentPacketHandler) detectAndRemoveAckedPackets(ack *wire.AckFrame, encL
 	largestAcked := ack.LargestAcked()
 	var processErr error
 	pnSpace.history.Iterate(func(p *packet) bool {
-		// Ignore packets below the lowest acked
+		// ignore packets below the lowest acked
 		if p.PacketNumber < lowestAcked {
 			return true
 		}
-		// Break after largest acked is reached
+		// break after largest acked is reached
 		if p.PacketNumber > largestAcked {
 			return false
 		}
@@ -443,7 +464,15 @@ func (h *sentPacketHandler) detectAndRemoveAckedPackets(ack *wire.AckFrame, encL
 			}
 			return false
 		}
-		h.ackedPackets = append(h.ackedPackets, p)
+		if p.isPathProbePacket {
+			probePacket := pnSpace.history.RemovePathProbe(p.PacketNumber)
+			if probePacket == nil {
+				panic(fmt.Sprintf("path probe doesn't exist: %d", p.PacketNumber))
+			}
+			h.ackedPackets = append(h.ackedPackets, probePacket)
+		} else {
+			h.ackedPackets = append(h.ackedPackets, p)
+		}
 		return true
 	})
 	if h.logger.Debug() && len(h.ackedPackets) > 0 {
@@ -510,7 +539,7 @@ func (h *sentPacketHandler) getScaledPTO(includeMaxAckDelay bool) time.Duration
 }
 
 // same logic as getLossTimeAndSpace, but for lastAckElicitingPacketTime instead of lossTime
-func (h *sentPacketHandler) getPTOTimeAndSpace(now time.Time) (pto time.Time, encLevel protocol.EncryptionLevel, ok bool) {
+func (h *sentPacketHandler) getPTOTimeAndSpace(now time.Time) (pto time.Time, encLevel protocol.EncryptionLevel) {
 	// We only send application data probe packets once the handshake is confirmed,
 	// because before that, we don't have the keys to decrypt ACKs sent in 1-RTT packets.
 	if !h.handshakeConfirmed && !h.hasOutstandingCryptoPackets() {
@@ -519,32 +548,35 @@ func (h *sentPacketHandler) getPTOTimeAndSpace(now time.Time) (pto time.Time, en
 		}
 		t := now.Add(h.getScaledPTO(false))
 		if h.initialPackets != nil {
-			return t, protocol.EncryptionInitial, true
+			return t, protocol.EncryptionInitial
 		}
-		return t, protocol.EncryptionHandshake, true
+		return t, protocol.EncryptionHandshake
 	}
 
-	if h.initialPackets != nil {
+	if h.initialPackets != nil && h.initialPackets.history.HasOutstandingPackets() &&
+		!h.initialPackets.lastAckElicitingPacketTime.IsZero() {
 		encLevel = protocol.EncryptionInitial
 		if t := h.initialPackets.lastAckElicitingPacketTime; !t.IsZero() {
 			pto = t.Add(h.getScaledPTO(false))
 		}
 	}
-	if h.handshakePackets != nil && !h.handshakePackets.lastAckElicitingPacketTime.IsZero() {
+	if h.handshakePackets != nil && h.handshakePackets.history.HasOutstandingPackets() &&
+		!h.handshakePackets.lastAckElicitingPacketTime.IsZero() {
 		t := h.handshakePackets.lastAckElicitingPacketTime.Add(h.getScaledPTO(false))
 		if pto.IsZero() || (!t.IsZero() && t.Before(pto)) {
 			pto = t
 			encLevel = protocol.EncryptionHandshake
 		}
 	}
-	if h.handshakeConfirmed && !h.appDataPackets.lastAckElicitingPacketTime.IsZero() {
+	if h.handshakeConfirmed && h.appDataPackets.history.HasOutstandingPackets() &&
+		!h.appDataPackets.lastAckElicitingPacketTime.IsZero() {
 		t := h.appDataPackets.lastAckElicitingPacketTime.Add(h.getScaledPTO(true))
 		if pto.IsZero() || (!t.IsZero() && t.Before(pto)) {
 			pto = t
 			encLevel = protocol.Encryption1RTT
 		}
 	}
-	return pto, encLevel, true
+	return pto, encLevel
 }
 
 func (h *sentPacketHandler) hasOutstandingCryptoPackets() bool {
@@ -576,8 +608,8 @@ func (h *sentPacketHandler) setLossDetectionTimer(now time.Time) {
 
 func (h *sentPacketHandler) lossDetectionTime(now time.Time) alarmTimer {
 	// cancel the alarm if no packets are outstanding
-	if h.peerCompletedAddressValidation &&
-		!h.hasOutstandingCryptoPackets() && !h.appDataPackets.history.HasOutstandingPackets() {
+	if h.peerCompletedAddressValidation && !h.hasOutstandingCryptoPackets() &&
+		!h.appDataPackets.history.HasOutstandingPackets() && !h.appDataPackets.history.HasOutstandingPathProbes() {
 		return alarmTimer{}
 	}
 
@@ -586,24 +618,58 @@ func (h *sentPacketHandler) lossDetectionTime(now time.Time) alarmTimer {
 		return alarmTimer{}
 	}
 
+	var pathProbeLossTime time.Time
+	if h.appDataPackets.history.HasOutstandingPathProbes() {
+		if p := h.appDataPackets.history.FirstOutstandingPathProbe(); p != nil {
+			pathProbeLossTime = p.SendTime.Add(pathProbePacketLossTimeout)
+		}
+	}
+
 	// early retransmit timer or time loss detection
 	lossTime, encLevel := h.getLossTimeAndSpace()
-	if !lossTime.IsZero() {
+	if !lossTime.IsZero() && (pathProbeLossTime.IsZero() || lossTime.Before(pathProbeLossTime)) {
 		return alarmTimer{
 			Time:            lossTime,
 			TimerType:       logging.TimerTypeACK,
 			EncryptionLevel: encLevel,
 		}
 	}
+	ptoTime, encLevel := h.getPTOTimeAndSpace(now)
+	if !ptoTime.IsZero() && (pathProbeLossTime.IsZero() || ptoTime.Before(pathProbeLossTime)) {
+		return alarmTimer{
+			Time:            ptoTime,
+			TimerType:       logging.TimerTypePTO,
+			EncryptionLevel: encLevel,
+		}
+	}
+	if !pathProbeLossTime.IsZero() {
+		return alarmTimer{
+			Time:            pathProbeLossTime,
+			TimerType:       logging.TimerTypePathProbe,
+			EncryptionLevel: encLevel,
+		}
+	}
+	return alarmTimer{}
+}
 
-	ptoTime, encLevel, ok := h.getPTOTimeAndSpace(now)
-	if !ok {
-		return alarmTimer{}
+func (h *sentPacketHandler) detectLostPathProbes(now time.Time) {
+	if !h.appDataPackets.history.HasOutstandingPathProbes() {
+		return
 	}
-	return alarmTimer{
-		Time:            ptoTime,
-		TimerType:       logging.TimerTypePTO,
-		EncryptionLevel: encLevel,
+	lossTime := now.Add(-pathProbePacketLossTimeout)
+	// RemovePathProbe cannot be called while iterating.
+	var lostPathProbes []*packet
+	h.appDataPackets.history.IteratePathProbes(func(p *packet) bool {
+		if !p.SendTime.After(lossTime) {
+			lostPathProbes = append(lostPathProbes, p)
+		}
+		return true
+	})
+	for _, p := range lostPathProbes {
+		for _, f := range p.Frames {
+			f.Handler.OnLost(f.Frame)
+		}
+		h.appDataPackets.history.RemovePathProbe(p.PacketNumber)
 	}
 }
 
@@ -626,10 +692,11 @@ func (h *sentPacketHandler) detectLostPackets(now time.Time, encLevel protocol.E
 			return false
 		}
 
+		isRegularPacket := !p.skippedPacket && !p.isPathProbePacket
 		var packetLost bool
 		if !p.SendTime.After(lostSendTime) {
 			packetLost = true
-			if !p.skippedPacket {
+			if isRegularPacket {
 				if h.logger.Debug() {
 					h.logger.Debugf("\tlost packet %d (time threshold)", p.PacketNumber)
 				}
@@ -639,7 +706,7 @@ func (h *sentPacketHandler) detectLostPackets(now time.Time, encLevel protocol.E
 			}
 		} else if pnSpace.largestAcked >= p.PacketNumber+packetThreshold {
 			packetLost = true
-			if !p.skippedPacket {
+			if isRegularPacket {
 				if h.logger.Debug() {
 					h.logger.Debugf("\tlost packet %d (reordering threshold)", p.PacketNumber)
 				}
@@ -657,7 +724,7 @@ func (h *sentPacketHandler) detectLostPackets(now time.Time, encLevel protocol.E
 		}
 		if packetLost {
 			pnSpace.history.DeclareLost(p.PacketNumber)
-			if !p.skippedPacket {
+			if isRegularPacket {
 				// the bytes in flight need to be reduced no matter if the frames in this packet will be retransmitted
 				h.removeFromBytesInFlight(p)
 				h.queueFramesForRetransmission(p)
@@ -675,6 +742,11 @@ func (h *sentPacketHandler) detectLostPackets(now time.Time, encLevel protocol.E
 
 func (h *sentPacketHandler) OnLossDetectionTimeout(now time.Time) error {
 	defer h.setLossDetectionTimer(now)
+
+	if h.handshakeConfirmed {
+		h.detectLostPathProbes(now)
+	}
+
 	earliestLossTime, encLevel := h.getLossTimeAndSpace()
 	if !earliestLossTime.IsZero() {
 		if h.logger.Debug() {
@@ -706,11 +778,12 @@ func (h *sentPacketHandler) OnLossDetectionTimeout(now time.Time) error {
 		return nil
 	}
 
-	_, encLevel, ok := h.getPTOTimeAndSpace(now)
-	if !ok {
+	ptoTime, encLevel := h.getPTOTimeAndSpace(now)
+	if ptoTime.IsZero() {
 		return nil
 	}
-	if ps := h.getPacketNumberSpace(encLevel); !ps.history.HasOutstandingPackets() && !h.peerCompletedAddressValidation {
+	ps := h.getPacketNumberSpace(encLevel)
+	if !ps.history.HasOutstandingPackets() && !ps.history.HasOutstandingPathProbes() && !h.peerCompletedAddressValidation {
 		return nil
 	}
 	h.ptoCount++
@@ -917,3 +990,27 @@ func (h *sentPacketHandler) ResetForRetry(now time.Time) {
 	}
 	h.ptoCount = 0
 }
+
+func (h *sentPacketHandler) MigratedPath(now time.Time, initialMaxDatagramSize protocol.ByteCount) {
+	h.rttStats.ResetForPathMigration()
+	h.appDataPackets.history.Iterate(func(p *packet) bool {
+		h.appDataPackets.history.DeclareLost(p.PacketNumber)
+		if !p.skippedPacket && !p.isPathProbePacket {
+			h.removeFromBytesInFlight(p)
+			h.queueFramesForRetransmission(p)
+		}
+		return true
+	})
+	h.appDataPackets.history.IteratePathProbes(func(p *packet) bool {
+		h.appDataPackets.history.RemovePathProbe(p.PacketNumber)
+		return true
+	})
+	h.congestion = congestion.NewCubicSender(
+		congestion.DefaultClock{},
+		h.rttStats,
+		initialMaxDatagramSize,
+		true, // use Reno
+		h.tracer,
+	)
+	h.setLossDetectionTimer(now)
+}
diff --git a/internal/mocks/ackhandler/sent_packet_handler.go b/internal/mocks/ackhandler/sent_packet_handler.go
index bac7ec78..61939c9d 100644
--- a/internal/mocks/ackhandler/sent_packet_handler.go
+++ b/internal/mocks/ackhandler/sent_packet_handler.go
@@ -155,6 +155,42 @@ func (c *MockSentPacketHandlerGetLossDetectionTimeoutCall) DoAndReturn(f func()
 	return c
 }
 
+// MigratedPath mocks base method.
+func (m *MockSentPacketHandler) MigratedPath(now time.Time, initialMaxPacketSize protocol.ByteCount) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "MigratedPath", now, initialMaxPacketSize)
+}
+
+// MigratedPath indicates an expected call of MigratedPath.
+func (mr *MockSentPacketHandlerMockRecorder) MigratedPath(now, initialMaxPacketSize any) *MockSentPacketHandlerMigratedPathCall {
+	mr.mock.ctrl.T.Helper()
+	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MigratedPath", reflect.TypeOf((*MockSentPacketHandler)(nil).MigratedPath), now, initialMaxPacketSize)
+	return &MockSentPacketHandlerMigratedPathCall{Call: call}
+}
+
+// MockSentPacketHandlerMigratedPathCall wrap *gomock.Call
+type MockSentPacketHandlerMigratedPathCall struct {
+	*gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockSentPacketHandlerMigratedPathCall) Return() *MockSentPacketHandlerMigratedPathCall {
+	c.Call = c.Call.Return()
+	return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockSentPacketHandlerMigratedPathCall) Do(f func(time.Time, protocol.ByteCount)) *MockSentPacketHandlerMigratedPathCall {
+	c.Call = c.Call.Do(f)
+	return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockSentPacketHandlerMigratedPathCall) DoAndReturn(f func(time.Time, protocol.ByteCount)) *MockSentPacketHandlerMigratedPathCall {
+	c.Call = c.Call.DoAndReturn(f)
+	return c
+}
+
 // OnLossDetectionTimeout mocks base method.
 func (m *MockSentPacketHandler) OnLossDetectionTimeout(now time.Time) error {
 	m.ctrl.T.Helper()
@@ -458,15 +494,15 @@ func (c *MockSentPacketHandlerSendModeCall) DoAndReturn(f func(time.Time) ackhan
 }
 
 // SentPacket mocks base method.
-func (m *MockSentPacketHandler) SentPacket(t time.Time, pn, largestAcked protocol.PacketNumber, streamFrames []ackhandler.StreamFrame, frames []ackhandler.Frame, encLevel protocol.EncryptionLevel, ecn protocol.ECN, size protocol.ByteCount, isPathMTUProbePacket bool) {
+func (m *MockSentPacketHandler) SentPacket(t time.Time, pn, largestAcked protocol.PacketNumber, streamFrames []ackhandler.StreamFrame, frames []ackhandler.Frame, encLevel protocol.EncryptionLevel, ecn protocol.ECN, size protocol.ByteCount, isPathMTUProbePacket, isPathProbePacket bool) {
 	m.ctrl.T.Helper()
-	m.ctrl.Call(m, "SentPacket", t, pn, largestAcked, streamFrames, frames, encLevel, ecn, size, isPathMTUProbePacket)
+	m.ctrl.Call(m, "SentPacket", t, pn, largestAcked, streamFrames, frames, encLevel, ecn, size, isPathMTUProbePacket, isPathProbePacket)
 }
 
 // SentPacket indicates an expected call of SentPacket.
-func (mr *MockSentPacketHandlerMockRecorder) SentPacket(t, pn, largestAcked, streamFrames, frames, encLevel, ecn, size, isPathMTUProbePacket any) *MockSentPacketHandlerSentPacketCall {
+func (mr *MockSentPacketHandlerMockRecorder) SentPacket(t, pn, largestAcked, streamFrames, frames, encLevel, ecn, size, isPathMTUProbePacket, isPathProbePacket any) *MockSentPacketHandlerSentPacketCall {
 	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SentPacket", reflect.TypeOf((*MockSentPacketHandler)(nil).SentPacket), t, pn, largestAcked, streamFrames, frames, encLevel, ecn, size, isPathMTUProbePacket)
+	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SentPacket", reflect.TypeOf((*MockSentPacketHandler)(nil).SentPacket), t, pn, largestAcked, streamFrames, frames, encLevel, ecn, size, isPathMTUProbePacket, isPathProbePacket)
 	return &MockSentPacketHandlerSentPacketCall{Call: call}
 }
 
@@ -482,13 +518,13 @@ func (c *MockSentPacketHandlerSentPacketCall) Return() *MockSentPacketHandlerSen
 }
 
 // Do rewrite *gomock.Call.Do
-func (c *MockSentPacketHandlerSentPacketCall) Do(f func(time.Time, protocol.PacketNumber, protocol.PacketNumber, []ackhandler.StreamFrame, []ackhandler.Frame, protocol.EncryptionLevel, protocol.ECN, protocol.ByteCount, bool)) *MockSentPacketHandlerSentPacketCall {
+func (c *MockSentPacketHandlerSentPacketCall) Do(f func(time.Time, protocol.PacketNumber, protocol.PacketNumber, []ackhandler.StreamFrame, []ackhandler.Frame, protocol.EncryptionLevel, protocol.ECN, protocol.ByteCount, bool, bool)) *MockSentPacketHandlerSentPacketCall {
 	c.Call = c.Call.Do(f)
 	return c
 }
 
 // DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MockSentPacketHandlerSentPacketCall) DoAndReturn(f func(time.Time, protocol.PacketNumber, protocol.PacketNumber, []ackhandler.StreamFrame, []ackhandler.Frame, protocol.EncryptionLevel, protocol.ECN, protocol.ByteCount, bool)) *MockSentPacketHandlerSentPacketCall {
+func (c *MockSentPacketHandlerSentPacketCall) DoAndReturn(f func(time.Time, protocol.PacketNumber, protocol.PacketNumber, []ackhandler.StreamFrame, []ackhandler.Frame, protocol.EncryptionLevel, protocol.ECN, protocol.ByteCount, bool, bool)) *MockSentPacketHandlerSentPacketCall {
 	c.Call = c.Call.DoAndReturn(f)
 	return c
 }
diff --git a/logging/types.go b/logging/types.go
index 0d79b0a9..65da3559 100644
--- a/logging/types.go
+++ b/logging/types.go
@@ -63,9 +63,11 @@ type TimerType uint8
 
 const (
 	// TimerTypeACK is the timer type for the early retransmit timer
-	TimerTypeACK TimerType = iota
+	TimerTypeACK TimerType = iota + 1
 	// TimerTypePTO is the timer type for the PTO retransmit timer
 	TimerTypePTO
+	// TimerTypePathProbe is the timer type for the path probe retransmit timer
+	TimerTypePathProbe
 )
 
 // TimeoutReason is the reason why a connection is closed
diff --git a/mtu_discoverer.go b/mtu_discoverer.go
index ee636a6d..d2d1ac77 100644
--- a/mtu_discoverer.go
+++ b/mtu_discoverer.go
@@ -88,7 +88,6 @@ const (
 
 type mtuFinder struct {
 	lastProbeTime time.Time
-	mtuIncreased  func(protocol.ByteCount)
 
 	rttStats *utils.RTTStats
 
@@ -107,15 +106,13 @@ var _ mtuDiscoverer = &mtuFinder{}
 func newMTUDiscoverer(
 	rttStats *utils.RTTStats,
 	start, max protocol.ByteCount,
-	mtuIncreased func(protocol.ByteCount),
 	tracer *logging.ConnectionTracer,
 ) *mtuFinder {
 	f := &mtuFinder{
-		inFlight:     protocol.InvalidByteCount,
-		min:          start,
-		rttStats:     rttStats,
-		mtuIncreased: mtuIncreased,
-		tracer:       tracer,
+		inFlight: protocol.InvalidByteCount,
+		min:      start,
+		rttStats: rttStats,
+		tracer:   tracer,
 	}
 	for i := range f.lost {
 		if i == 0 {
@@ -207,7 +204,6 @@ func (h *mtuFinderAckHandler) OnAcked(wire.Frame) {
 	if h.tracer != nil && h.tracer.UpdatedMTU != nil {
 		h.tracer.UpdatedMTU(size, h.done())
 	}
-	h.mtuIncreased(size)
 }
 
 func (h *mtuFinderAckHandler) OnLost(wire.Frame) {
diff --git a/qlog/types.go b/qlog/types.go
index 25dfc710..d0ff7ae6 100644
--- a/qlog/types.go
+++ b/qlog/types.go
@@ -291,6 +291,8 @@ func (t timerType) String() string {
 		return "ack"
 	case logging.TimerTypePTO:
 		return "pto"
+	case logging.TimerTypePathProbe:
+		return "path_probe"
 	default:
 		return "unknown timer type"
 	}


Test patch:
diff --git a/connection_test.go b/connection_test.go
index 32ade183..57f49a03 100644
--- a/connection_test.go
+++ b/connection_test.go
@@ -1399,48 +1399,52 @@ func TestConnection0RTTTransportParameters(t *testing.T) {
 
 func TestConnectionReceivePrioritization(t *testing.T) {
 	t.Run("handshake complete", func(t *testing.T) {
-		counter := testConnectionReceivePrioritization(t, true)
-		require.Equal(t, 10, counter)
+		events := testConnectionReceivePrioritization(t, true, 5)
+		require.Equal(t, []string{"unpack", "unpack", "unpack", "unpack", "unpack", "pack"}, events)
 	})
 
 	// before handshake completion, we trigger packing of a new packet every time we receive a packet
 	t.Run("handshake not complete", func(t *testing.T) {
-		counter := testConnectionReceivePrioritization(t, false)
-		require.Equal(t, 1, counter)
+		events := testConnectionReceivePrioritization(t, false, 5)
+		require.Equal(t, []string{
+			"unpack", "pack",
+			"unpack", "pack",
+			"unpack", "pack",
+			"unpack", "pack",
+			"unpack", "pack",
+		}, events)
 	})
 }
 
-func testConnectionReceivePrioritization(t *testing.T, handshakeComplete bool) int {
+func testConnectionReceivePrioritization(t *testing.T, handshakeComplete bool, numPackets int) []string {
 	mockCtrl := gomock.NewController(t)
 	unpacker := NewMockUnpacker(mockCtrl)
 	opts := []testConnectionOpt{connectionOptUnpacker(unpacker)}
 	if handshakeComplete {
 		opts = append(opts, connectionOptHandshakeConfirmed())
 	}
-	tc := newServerTestConnection(t,
-		mockCtrl,
-		nil,
-		false,
-		opts...,
-	)
+	tc := newServerTestConnection(t, mockCtrl, nil, false, opts...)
 
+	var events []string
 	var counter int
-	var packedFirst bool
+	var testDone bool
 	done := make(chan struct{})
 	unpacker.EXPECT().UnpackShortHeader(gomock.Any(), gomock.Any()).DoAndReturn(
 		func(rcvTime time.Time, data []byte) (protocol.PacketNumber, protocol.PacketNumberLen, protocol.KeyPhaseBit, []byte, error) {
-			if !packedFirst {
-				counter++
+			counter++
+			if counter == numPackets {
+				testDone = true
 			}
+			events = append(events, "unpack")
 			return protocol.PacketNumber(counter), protocol.PacketNumberLen2, protocol.KeyPhaseZero, []byte{0, 1} /* PADDING, PING */, nil
 		},
-	).AnyTimes()
+	).Times(numPackets)
 	switch handshakeComplete {
 	case false:
 		tc.packer.EXPECT().PackCoalescedPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
 			func(b bool, bc protocol.ByteCount, t time.Time, v protocol.Version) (*coalescedPacket, error) {
-				if !packedFirst {
-					packedFirst = true
+				events = append(events, "pack")
+				if testDone {
 					close(done)
 				}
 				return nil, nil
@@ -1449,8 +1453,8 @@ func testConnectionReceivePrioritization(t *testing.T, handshakeComplete bool) i
 	case true:
 		tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
 			func(b *packetBuffer, bc protocol.ByteCount, t time.Time, v protocol.Version) (shortHeaderPacket, error) {
-				if !packedFirst {
-					packedFirst = true
+				events = append(events, "pack")
+				if testDone {
 					close(done)
 				}
 				return shortHeaderPacket{}, errNothingToPack
@@ -1458,10 +1462,11 @@ func testConnectionReceivePrioritization(t *testing.T, handshakeComplete bool) i
 		).AnyTimes()
 	}
 
-	for i := 0; i < 10; i++ {
+	for i := range numPackets {
 		tc.conn.handlePacket(getShortHeaderPacket(t, tc.srcConnID, protocol.PacketNumber(i), []byte("foobar")))
 	}
 
+	tc.connRunner.EXPECT().Remove(gomock.Any()).AnyTimes()
 	errChan := make(chan error, 1)
 	go func() { errChan <- tc.conn.run() }()
 
@@ -1480,8 +1485,7 @@ func testConnectionReceivePrioritization(t *testing.T, handshakeComplete bool) i
 	case <-time.After(time.Second):
 		t.Fatal("timeout")
 	}
-
-	return counter
+	return events
 }
 
 func TestConnectionPacketBuffering(t *testing.T) {
@@ -1631,15 +1635,15 @@ func TestConnectionPacketPacing(t *testing.T) {
 	gomock.InOrder(
 		// 1. allow 2 packets to be sent
 		sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny),
-		sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()),
+		sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()),
 		sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny),
-		sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()),
+		sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()),
 		sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendPacingLimited),
 		// 2. become pacing limited for 25ms
 		sph.EXPECT().TimeUntilSend().DoAndReturn(func() time.Time { return time.Now().Add(step) }),
 		// 3. send another packet
 		sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny),
-		sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()),
+		sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()),
 		sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendPacingLimited),
 		// 4. become pacing limited for 25ms...
 		sph.EXPECT().TimeUntilSend().DoAndReturn(func() time.Time { return time.Now().Add(step) }),
@@ -1648,7 +1652,7 @@ func TestConnectionPacketPacing(t *testing.T) {
 		sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendPacingLimited),
 		// 5. stop the test by becoming pacing limited forever
 		sph.EXPECT().TimeUntilSend().Return(time.Now().Add(time.Hour)),
-		sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()),
+		sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()),
 	)
 	sph.EXPECT().ECNMode(gomock.Any()).AnyTimes()
 	for i := 0; i < 3; i++ {
@@ -1739,7 +1743,7 @@ func TestConnectionIdleTimeout(t *testing.T) {
 
 	sph.EXPECT().GetLossDetectionTimeout().AnyTimes()
 	sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
-	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
+	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
 	sph.EXPECT().ECNMode(gomock.Any()).AnyTimes()
 	var lastSendTime time.Time
 	tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
@@ -1886,7 +1890,7 @@ func TestConnectionACKTimer(t *testing.T) {
 
 	sph.EXPECT().GetLossDetectionTimeout().AnyTimes()
 	sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
-	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
 	sph.EXPECT().ECNMode(gomock.Any()).AnyTimes()
 	rph.EXPECT().GetAlarmTimeout().Return(time.Now().Add(time.Hour))
 	tc.sendConn.EXPECT().Write(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
@@ -1956,7 +1960,7 @@ func TestConnectionGSOBatch(t *testing.T) {
 	// allow packets to be sent
 	sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
 	sph.EXPECT().TimeUntilSend().Return(time.Time{}).AnyTimes()
-	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
 	sph.EXPECT().GetLossDetectionTimeout().Return(time.Time{}).AnyTimes()
 	sph.EXPECT().ECNMode(gomock.Any()).Return(protocol.ECT1).AnyTimes()
 
@@ -2015,7 +2019,7 @@ func TestConnectionGSOBatchPacketSize(t *testing.T) {
 	// allow packets to be sent
 	sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
 	sph.EXPECT().TimeUntilSend().Return(time.Time{}).AnyTimes()
-	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
 	sph.EXPECT().GetLossDetectionTimeout().Return(time.Time{}).AnyTimes()
 	sph.EXPECT().ECNMode(gomock.Any()).Return(protocol.ECT1).AnyTimes()
 
@@ -2095,7 +2099,7 @@ func TestConnectionGSOBatchECN(t *testing.T) {
 	ecnMode := protocol.ECT1
 	sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
 	sph.EXPECT().TimeUntilSend().Return(time.Time{}).AnyTimes()
-	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
 	sph.EXPECT().GetLossDetectionTimeout().Return(time.Time{}).AnyTimes()
 	sph.EXPECT().ECNMode(gomock.Any()).DoAndReturn(func(bool) protocol.ECN { return ecnMode }).AnyTimes()
 
@@ -2197,7 +2201,7 @@ func testConnectionPTOProbePackets(t *testing.T, encLevel protocol.EncryptionLev
 	sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendNone)
 	sph.EXPECT().ECNMode(gomock.Any())
 	sph.EXPECT().QueueProbePacket(encLevel).Return(false)
-	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
+	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
 
 	tc.packer.EXPECT().MaybePackPTOProbePacket(encLevel, gomock.Any(), gomock.Any(), protocol.Version1).DoAndReturn(
 		func(encLevel protocol.EncryptionLevel, maxSize protocol.ByteCount, t time.Time, version protocol.Version) (*coalescedPacket, error) {
@@ -2250,7 +2254,7 @@ func TestConnectionCongestionControl(t *testing.T) {
 	sph.EXPECT().ECNMode(true).AnyTimes()
 	sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).Times(2)
 	sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAck).MaxTimes(1)
-	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(2)
+	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(2)
 	// Since we're already sending out packets, we don't expect any calls to PackAckOnlyPacket
 	for i := 0; i < 2; i++ {
 		tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
@@ -2343,7 +2347,7 @@ func testConnectionSendQueue(t *testing.T, enableGSO bool) {
 		},
 	)
 	sph.EXPECT().GetLossDetectionTimeout().AnyTimes()
-	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
+	sph.EXPECT().SentPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
 	sph.EXPECT().SendMode(gomock.Any()).Return(ackhandler.SendAny).AnyTimes()
 	sph.EXPECT().ECNMode(gomock.Any()).AnyTimes()
 	tc.packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(
diff --git a/connection_timer_test.go b/connection_timer_test.go
index d850fc43..189f7b5b 100644
--- a/connection_timer_test.go
+++ b/connection_timer_test.go
@@ -47,14 +47,3 @@ func TestConnectionTimerReset(t *testing.T) {
 	timer.SetTimer(now.Add(time.Hour), now.Add(time.Minute), time.Time{}, time.Time{})
 	require.Equal(t, now.Add(time.Hour), timer.Deadline())
 }
-
-func TestConnectionTimerSendImmediately(t *testing.T) {
-	now := time.Now()
-	timer := newTimer()
-	timer.SetTimer(now.Add(time.Hour), now.Add(time.Minute), time.Time{}, time.Time{})
-	require.Equal(t, now.Add(time.Minute), timer.Deadline())
-	timer.SetRead()
-
-	timer.SetTimer(now.Add(time.Hour), now.Add(time.Minute), time.Time{}, deadlineSendImmediately)
-	require.Equal(t, deadlineSendImmediately, timer.Deadline())
-}
diff --git a/http3/server_test.go b/http3/server_test.go
index c3b8c1c7..4af00e20 100644
--- a/http3/server_test.go
+++ b/http3/server_test.go
@@ -314,7 +314,7 @@ var _ = Describe("Server", func() {
 
 			s.handleRequest(conn, str, nil, qpackDecoder)
 			Expect(responseBuf.Bytes()).To(HaveLen(0))
-			Expect(logBuf.String()).To(ContainSubstring("http: panic serving"))
+			Expect(logBuf.String()).To(ContainSubstring("http3: panic serving"))
 			Expect(logBuf.String()).To(ContainSubstring("foobar"))
 		})
 
diff --git a/internal/ackhandler/sent_packet_handler_test.go b/internal/ackhandler/sent_packet_handler_test.go
index a51b76cf..15a4eabd 100644
--- a/internal/ackhandler/sent_packet_handler_test.go
+++ b/internal/ackhandler/sent_packet_handler_test.go
@@ -117,7 +117,7 @@ func testSentPacketHandlerSendAndAcknowledge(t *testing.T, encLevel protocol.Enc
 			e = protocol.Encryption0RTT
 		}
 		pn := sph.PopPacketNumber(e)
-		sph.SentPacket(now, pn, protocol.InvalidPacketNumber, nil, []Frame{packets.NewPingFrame(pn)}, e, protocol.ECNNon, 1200, false)
+		sph.SentPacket(now, pn, protocol.InvalidPacketNumber, nil, []Frame{packets.NewPingFrame(pn)}, e, protocol.ECNNon, 1200, false, false)
 		pns = append(pns, pn)
 	}
 
@@ -172,7 +172,7 @@ func TestSentPacketHandlerAcknowledgeSkippedPacket(t *testing.T) {
 		if pn >= 1e6 {
 			t.Fatal("expected a skipped packet number")
 		}
-		sph.SentPacket(now, pn, protocol.InvalidPacketNumber, nil, []Frame{{Frame: &wire.PingFrame{}}}, protocol.Encryption1RTT, protocol.ECNNon, 1200, false)
+		sph.SentPacket(now, pn, protocol.InvalidPacketNumber, nil, []Frame{{Frame: &wire.PingFrame{}}}, protocol.Encryption1RTT, protocol.ECNNon, 1200, false, false)
 		lastPN = pn
 		if skippedPN != protocol.InvalidPacketNumber {
 			break
@@ -216,7 +216,7 @@ func testSentPacketHandlerRTTs(t *testing.T, encLevel protocol.EncryptionLevel,
 
 	sendPacket := func(ti time.Time) protocol.PacketNumber {
 		pn := sph.PopPacketNumber(encLevel)
-		sph.SentPacket(ti, pn, protocol.InvalidPacketNumber, nil, []Frame{{Frame: &wire.PingFrame{}}}, encLevel, protocol.ECNNon, 1200, false)
+		sph.SentPacket(ti, pn, protocol.InvalidPacketNumber, nil, []Frame{{Frame: &wire.PingFrame{}}}, encLevel, protocol.ECNNon, 1200, false, false)
 		return pn
 	}
 
@@ -318,7 +318,7 @@ func testSentPacketHandlerAmplificationLimitServer(t *testing.T, addressValidate
 	for i := 0; i < 4; i++ {
 		require.Equal(t, SendAny, sph.SendMode(time.Now()))
 		pn := sph.PopPacketNumber(protocol.EncryptionInitial)
-		sph.SentPacket(time.Now(), pn, protocol.InvalidPacketNumber, nil, []Frame{{Frame: &wire.PingFrame{}}}, protocol.EncryptionInitial, protocol.ECNNon, 999, false)
+		sph.SentPacket(time.Now(), pn, protocol.InvalidPacketNumber, nil, []Frame{{Frame: &wire.PingFrame{}}}, protocol.EncryptionInitial, protocol.ECNNon, 999, false, false)
 		if i != 3 {
 			require.NotZero(t, sph.GetLossDetectionTimeout())
 		}
@@ -333,7 +333,7 @@ func testSentPacketHandlerAmplificationLimitServer(t *testing.T, addressValidate
 	for i := 0; i < 3; i++ {
 		require.Equal(t, SendAny, sph.SendMode(time.Now()))
 		pn := sph.PopPacketNumber(protocol.EncryptionInitial)
-		sph.SentPacket(time.Now(), pn, protocol.InvalidPacketNumber, nil, []Frame{{Frame: &wire.PingFrame{}}}, protocol.EncryptionInitial, protocol.ECNNon, 1000, false)
+		sph.SentPacket(time.Now(), pn, protocol.InvalidPacketNumber, nil, []Frame{{Frame: &wire.PingFrame{}}}, protocol.EncryptionInitial, protocol.ECNNon, 1000, false, false)
 	}
 	require.Equal(t, SendNone, sph.SendMode(time.Now()))
 	require.Zero(t, sph.GetLossDetectionTimeout())
@@ -373,7 +373,7 @@ func testSentPacketHandlerAmplificationLimitClient(t *testing.T, dropHandshake b
 
 	require.Equal(t, SendAny, sph.SendMode(time.Now()))
 	pn := sph.PopPacketNumber(protocol.EncryptionInitial)
-	sph.SentPacket(time.Now(), pn, protocol.InvalidPacketNumber, nil, []Frame{{Frame: &wire.PingFrame{}}}, protocol.EncryptionInitial, protocol.ECNNon, 999, false)
+	sph.SentPacket(time.Now(), pn, protocol.InvalidPacketNumber, nil, []Frame{{Frame: &wire.PingFrame{}}}, protocol.EncryptionInitial, protocol.ECNNon, 999, false, false)
 	// it's not surprising that the loss detection timer is set, as this packet might be lost...
 	require.NotZero(t, sph.GetLossDetectionTimeout())
 	// ... but it's still set after receiving an ACK for this packet,
@@ -405,7 +405,7 @@ func testSentPacketHandlerAmplificationLimitClient(t *testing.T, dropHandshake b
 
 	// receiving an ACK for a handshake packet shows that the server completed address validation
 	pn = sph.PopPacketNumber(protocol.EncryptionHandshake)
-	sph.SentPacket(time.Now(), pn, protocol.InvalidPacketNumber, nil, []Frame{{Frame: &wire.PingFrame{}}}, protocol.EncryptionHandshake, protocol.ECNNon, 999, false)
+	sph.SentPacket(time.Now(), pn, protocol.InvalidPacketNumber, nil, []Frame{{Frame: &wire.PingFrame{}}}, protocol.EncryptionHandshake, protocol.ECNNon, 999, false, false)
 	require.NotZero(t, sph.GetLossDetectionTimeout())
 	_, err = sph.ReceivedAck(&wire.AckFrame{AckRanges: ackRanges(pn)}, protocol.EncryptionHandshake, time.Now())
 	require.NoError(t, err)
@@ -428,7 +428,7 @@ func TestSentPacketHandlerDelayBasedLossDetection(t *testing.T) {
 	var packets packetTracker
 	sendPacket := func(ti time.Time, isPathMTUProbePacket bool) protocol.PacketNumber {
 		pn := sph.PopPacketNumber(protocol.EncryptionInitial)
-		sph.SentPacket(ti, pn, protocol.InvalidPacketNumber, nil, []Frame{packets.NewPingFrame(pn)}, protocol.EncryptionInitial, protocol.ECNNon, 1000, isPathMTUProbePacket)
+		sph.SentPacket(ti, pn, protocol.InvalidPacketNumber, nil, []Frame{packets.NewPingFrame(pn)}, protocol.EncryptionInitial, protocol.ECNNon, 1000, isPathMTUProbePacket, false)
 		return pn
 	}
 
@@ -482,7 +482,7 @@ func TestSentPacketHandlerPacketBasedLossDetection(t *testing.T) {
 	var pns []protocol.PacketNumber
 	for range 5 {
 		pn := sph.PopPacketNumber(protocol.EncryptionInitial)
-		sph.SentPacket(now, pn, protocol.InvalidPacketNumber, nil, []Frame{packets.NewPingFrame(pn)}, protocol.EncryptionInitial, protocol.ECNNon, 1000, false)
+		sph.SentPacket(now, pn, protocol.InvalidPacketNumber, nil, []Frame{packets.NewPingFrame(pn)}, protocol.EncryptionInitial, protocol.ECNNon, 1000, false, false)
 		pns = append(pns, pn)
 	}
 
@@ -551,9 +551,9 @@ func testSentPacketHandlerPTO(t *testing.T, encLevel protocol.EncryptionLevel, p
 		pn := sph.PopPacketNumber(encLevel)
 		if ackEliciting {
 			tr.EXPECT().SetLossTimer(logging.TimerTypePTO, encLevel, gomock.Any())
-			sph.SentPacket(ti, pn, protocol.InvalidPacketNumber, nil, []Frame{packets.NewPingFrame(pn)}, encLevel, protocol.ECNNon, 1000, false)
+			sph.SentPacket(ti, pn, protocol.InvalidPacketNumber, nil, []Frame{packets.NewPingFrame(pn)}, encLevel, protocol.ECNNon, 1000, false, false)
 		} else {
-			sph.SentPacket(ti, pn, protocol.InvalidPacketNumber, nil, nil, encLevel, protocol.ECNNon, 1000, true)
+			sph.SentPacket(ti, pn, protocol.InvalidPacketNumber, nil, nil, encLevel, protocol.ECNNon, 1000, true, false)
 		}
 		return pn
 	}
@@ -682,7 +682,7 @@ func TestSentPacketHandlerPacketNumberSpacesPTO(t *testing.T) {
 
 	sendPacket := func(ti time.Time, encLevel protocol.EncryptionLevel) protocol.PacketNumber {
 		pn := sph.PopPacketNumber(encLevel)
-		sph.SentPacket(ti, pn, protocol.InvalidPacketNumber, nil, []Frame{{Frame: &wire.PingFrame{}}}, encLevel, protocol.ECNNon, 1000, false)
+		sph.SentPacket(ti, pn, protocol.InvalidPacketNumber, nil, []Frame{{Frame: &wire.PingFrame{}}}, encLevel, protocol.ECNNon, 1000, false, false)
 		return pn
 	}
 
@@ -779,7 +779,7 @@ func TestSentPacketHandler0RTT(t *testing.T) {
 		} else {
 			frames = []Frame{{Frame: &wire.PingFrame{}}}
 		}
-		sph.SentPacket(ti, pn, protocol.InvalidPacketNumber, nil, frames, encLevel, protocol.ECNNon, 1000, false)
+		sph.SentPacket(ti, pn, protocol.InvalidPacketNumber, nil, frames, encLevel, protocol.ECNNon, 1000, false, false)
 		return pn
 	}
 
@@ -836,7 +836,7 @@ func TestSentPacketHandlerCongestion(t *testing.T) {
 		pn := sph.PopPacketNumber(protocol.EncryptionInitial)
 		bytesInFlight += 1000
 		cong.EXPECT().OnPacketSent(now, bytesInFlight, pn, protocol.ByteCount(1000), true)
-		sph.SentPacket(now, pn, protocol.InvalidPacketNumber, nil, []Frame{packets.NewPingFrame(pn)}, protocol.EncryptionInitial, protocol.ECNNon, 1000, i == 1)
+		sph.SentPacket(now, pn, protocol.InvalidPacketNumber, nil, []Frame{packets.NewPingFrame(pn)}, protocol.EncryptionInitial, protocol.ECNNon, 1000, i == 1, false)
 		pns = append(pns, pn)
 		sendTimes = append(sendTimes, now)
 		now = now.Add(100 * time.Millisecond)
@@ -889,7 +889,7 @@ func TestSentPacketHandlerCongestion(t *testing.T) {
 	now = timeout.Add(100 * time.Millisecond)
 	pn := sph.PopPacketNumber(protocol.EncryptionInitial)
 	cong.EXPECT().OnPacketSent(now, protocol.ByteCount(2000), pn, protocol.ByteCount(1000), true)
-	sph.SentPacket(now, pn, protocol.InvalidPacketNumber, nil, []Frame{packets.NewPingFrame(pn)}, protocol.EncryptionInitial, protocol.ECNNon, 1000, false)
+	sph.SentPacket(now, pn, protocol.InvalidPacketNumber, nil, []Frame{packets.NewPingFrame(pn)}, protocol.EncryptionInitial, protocol.ECNNon, 1000, false, false)
 }
 
 func TestSentPacketHandlerRetry(t *testing.T) {
@@ -925,12 +925,12 @@ func testSentPacketHandlerRetry(t *testing.T, rtt, expectedRTT time.Duration) {
 	for range 2 {
 		pn := sph.PopPacketNumber(protocol.EncryptionInitial)
 		initialPNs = append(initialPNs, pn)
-		sph.SentPacket(now, pn, protocol.InvalidPacketNumber, nil, []Frame{initialPackets.NewPingFrame(pn)}, protocol.EncryptionInitial, protocol.ECNNon, 1000, false)
+		sph.SentPacket(now, pn, protocol.InvalidPacketNumber, nil, []Frame{initialPackets.NewPingFrame(pn)}, protocol.EncryptionInitial, protocol.ECNNon, 1000, false, false)
 		now = now.Add(100 * time.Millisecond)
 
 		pn = sph.PopPacketNumber(protocol.Encryption0RTT)
 		appDataPNs = append(appDataPNs, pn)
-		sph.SentPacket(now, pn, protocol.InvalidPacketNumber, nil, []Frame{appDataPackets.NewPingFrame(pn)}, protocol.Encryption0RTT, protocol.ECNNon, 1000, false)
+		sph.SentPacket(now, pn, protocol.InvalidPacketNumber, nil, []Frame{appDataPackets.NewPingFrame(pn)}, protocol.Encryption0RTT, protocol.ECNNon, 1000, false, false)
 		now = now.Add(100 * time.Millisecond)
 	}
 	require.Equal(t, protocol.ByteCount(4000), sph.getBytesInFlight())
@@ -972,7 +972,7 @@ func TestSentPacketHandlerRetryAfterPTO(t *testing.T) {
 	start := time.Now()
 	now := start
 	pn1 := sph.PopPacketNumber(protocol.EncryptionInitial)
-	sph.SentPacket(now, pn1, protocol.InvalidPacketNumber, nil, []Frame{packets.NewPingFrame(pn1)}, protocol.EncryptionInitial, protocol.ECNNon, 1000, false)
+	sph.SentPacket(now, pn1, protocol.InvalidPacketNumber, nil, []Frame{packets.NewPingFrame(pn1)}, protocol.EncryptionInitial, protocol.ECNNon, 1000, false, false)
 
 	timeout := sph.GetLossDetectionTimeout()
 	require.NotZero(t, timeout)
@@ -983,7 +983,7 @@ func TestSentPacketHandlerRetryAfterPTO(t *testing.T) {
 	// send a retransmission for the first packet
 	now = timeout.Add(100 * time.Millisecond)
 	pn2 := sph.PopPacketNumber(protocol.EncryptionInitial)
-	sph.SentPacket(now, pn2, protocol.InvalidPacketNumber, nil, []Frame{packets.NewPingFrame(pn2)}, protocol.EncryptionInitial, protocol.ECNNon, 900, false)
+	sph.SentPacket(now, pn2, protocol.InvalidPacketNumber, nil, []Frame{packets.NewPingFrame(pn2)}, protocol.EncryptionInitial, protocol.ECNNon, 900, false, false)
 
 	const rtt = time.Second
 	sph.ResetForRetry(now.Add(rtt))
@@ -1014,15 +1014,15 @@ func TestSentPacketHandlerECN(t *testing.T) {
 	sph.congestion = cong
 
 	// ECN marks on non-1-RTT packets are ignored
-	sph.SentPacket(time.Now(), sph.PopPacketNumber(protocol.EncryptionInitial), protocol.InvalidPacketNumber, nil, nil, protocol.EncryptionInitial, protocol.ECT1, 1200, false)
-	sph.SentPacket(time.Now(), sph.PopPacketNumber(protocol.EncryptionHandshake), protocol.InvalidPacketNumber, nil, nil, protocol.EncryptionHandshake, protocol.ECT0, 1200, false)
-	sph.SentPacket(time.Now(), sph.PopPacketNumber(protocol.Encryption0RTT), protocol.InvalidPacketNumber, nil, nil, protocol.Encryption0RTT, protocol.ECNCE, 1200, false)
+	sph.SentPacket(time.Now(), sph.PopPacketNumber(protocol.EncryptionInitial), protocol.InvalidPacketNumber, nil, nil, protocol.EncryptionInitial, protocol.ECT1, 1200, false, false)
+	sph.SentPacket(time.Now(), sph.PopPacketNumber(protocol.EncryptionHandshake), protocol.InvalidPacketNumber, nil, nil, protocol.EncryptionHandshake, protocol.ECT0, 1200, false, false)
+	sph.SentPacket(time.Now(), sph.PopPacketNumber(protocol.Encryption0RTT), protocol.InvalidPacketNumber, nil, nil, protocol.Encryption0RTT, protocol.ECNCE, 1200, false, false)
 
 	var packets packetTracker
 	sendPacket := func(ti time.Time, ecn protocol.ECN) protocol.PacketNumber {
 		pn := sph.PopPacketNumber(protocol.Encryption1RTT)
 		ecnHandler.EXPECT().SentPacket(pn, ecn)
-		sph.SentPacket(ti, pn, protocol.InvalidPacketNumber, nil, []Frame{packets.NewPingFrame(pn)}, protocol.Encryption1RTT, ecn, 1200, false)
+		sph.SentPacket(ti, pn, protocol.InvalidPacketNumber, nil, []Frame{packets.NewPingFrame(pn)}, protocol.Encryption1RTT, ecn, 1200, false, false)
 		return pn
 	}
 
@@ -1096,3 +1096,131 @@ func TestSentPacketHandlerECN(t *testing.T) {
 	_, err = sph.ReceivedAck(&wire.AckFrame{AckRanges: ackRanges(pns[0])}, protocol.Encryption1RTT, now.Add(100*time.Millisecond))
 	require.NoError(t, err)
 }
+
+func TestSentPacketHandlerPathProbe(t *testing.T) {
+	const rtt = 10 * time.Millisecond // RTT of the original path
+	var rttStats utils.RTTStats
+	rttStats.UpdateRTT(rtt, 0)
+
+	sph := newSentPacketHandler(
+		0,
+		1200,
+		&rttStats,
+		true,
+		false,
+		protocol.PerspectiveClient,
+		nil,
+		utils.DefaultLogger,
+	)
+	sph.DropPackets(protocol.EncryptionInitial, time.Now())
+	sph.DropPackets(protocol.EncryptionHandshake, time.Now())
+
+	var packets packetTracker
+	sendPacket := func(ti time.Time, isPathProbe bool) protocol.PacketNumber {
+		pn := sph.PopPacketNumber(protocol.Encryption1RTT)
+		sph.SentPacket(ti, pn, protocol.InvalidPacketNumber, nil, []Frame{packets.NewPingFrame(pn)}, protocol.Encryption1RTT, protocol.ECNNon, 1200, false, isPathProbe)
+		return pn
+	}
+
+	// send 5 packets: 2 non-probe packets, 1 probe packet, 2 non-probe packets
+	now := time.Now()
+	var pns [5]protocol.PacketNumber
+	pns[0] = sendPacket(now, false)
+	now = now.Add(rtt)
+	pns[1] = sendPacket(now, false)
+	pns[2] = sendPacket(now, true)
+	pathProbeTimeout := now.Add(pathProbePacketLossTimeout)
+	now = now.Add(rtt)
+	pns[3] = sendPacket(now, false)
+	now = now.Add(rtt)
+	pns[4] = sendPacket(now, false)
+	require.Less(t, sph.GetLossDetectionTimeout(), pathProbeTimeout)
+
+	now = now.Add(100 * time.Millisecond)
+	// make sure that this ACK doesn't declare the path probe packet lost
+	require.Greater(t, pathProbeTimeout, now)
+	_, err := sph.ReceivedAck(
+		&wire.AckFrame{AckRanges: ackRanges(pns[0], pns[3], pns[4])},
+		protocol.Encryption1RTT,
+		now,
+	)
+	require.NoError(t, err)
+	require.Equal(t, []protocol.PacketNumber{pns[0], pns[3], pns[4]}, packets.Acked)
+	// despite having been sent at the same time, the probe packet was not lost
+	require.Equal(t, []protocol.PacketNumber{pns[1]}, packets.Lost)
+
+	// the timeout is now based on the probe packet
+	timeout := sph.GetLossDetectionTimeout()
+	require.Equal(t, pathProbeTimeout, timeout)
+	require.Zero(t, sph.getBytesInFlight())
+	pn1 := sendPacket(now, false)
+	pn2 := sendPacket(now, false)
+	require.Equal(t, protocol.ByteCount(2400), sph.getBytesInFlight())
+
+	// send one more non-probe packet
+	pn := sendPacket(now, false)
+	// the timeout is now based on this packet
+	require.Less(t, sph.GetLossDetectionTimeout(), pathProbeTimeout)
+	_, err = sph.ReceivedAck(
+		&wire.AckFrame{AckRanges: ackRanges(pns[2], pn)},
+		protocol.Encryption1RTT,
+		now,
+	)
+	require.NoError(t, err)
+
+	packets.Lost = packets.Lost[:0]
+	sph.MigratedPath(now, 1200)
+	require.Zero(t, sph.getBytesInFlight())
+	require.Zero(t, rttStats.SmoothedRTT())
+	require.Equal(t, []protocol.PacketNumber{pn1, pn2}, packets.Lost)
+}
+
+func TestSentPacketHandlerPathProbeAckAndLoss(t *testing.T) {
+	const rtt = 10 * time.Millisecond // RTT of the original path
+	var rttStats utils.RTTStats
+	rttStats.UpdateRTT(rtt, 0)
+
+	sph := newSentPacketHandler(
+		0,
+		1200,
+		&rttStats,
+		true,
+		false,
+		protocol.PerspectiveClient,
+		nil,
+		utils.DefaultLogger,
+	)
+	sph.DropPackets(protocol.EncryptionInitial, time.Now())
+	sph.DropPackets(protocol.EncryptionHandshake, time.Now())
+
+	var packets packetTracker
+	sendPacket := func(ti time.Time, isPathProbe bool) protocol.PacketNumber {
+		pn := sph.PopPacketNumber(protocol.Encryption1RTT)
+		sph.SentPacket(ti, pn, protocol.InvalidPacketNumber, nil, []Frame{packets.NewPingFrame(pn)}, protocol.Encryption1RTT, protocol.ECNNon, 1200, false, isPathProbe)
+		return pn
+	}
+
+	now := time.Now()
+	pn1 := sendPacket(now, true)
+	t1 := now
+	now = now.Add(100 * time.Millisecond)
+	_ = sendPacket(now, true)
+	now = now.Add(100 * time.Millisecond)
+	pn3 := sendPacket(now, true)
+
+	now = now.Add(100 * time.Millisecond)
+	require.Equal(t, t1.Add(pathProbePacketLossTimeout), sph.GetLossDetectionTimeout())
+	_, err := sph.ReceivedAck(
+		&wire.AckFrame{AckRanges: ackRanges(pn3)},
+		protocol.Encryption1RTT,
+		now,
+	)
+	require.NoError(t, err)
+	require.Equal(t, []protocol.PacketNumber{pn3}, packets.Acked)
+	require.Empty(t, packets.Lost)
+
+	require.Equal(t, t1.Add(pathProbePacketLossTimeout), sph.GetLossDetectionTimeout())
+
+	require.NoError(t, sph.OnLossDetectionTimeout(sph.GetLossDetectionTimeout()))
+	require.Equal(t, []protocol.PacketNumber{pn1}, packets.Lost)
+}
diff --git a/mtu_discoverer_test.go b/mtu_discoverer_test.go
index 6d87b9ec..415d0638 100644
--- a/mtu_discoverer_test.go
+++ b/mtu_discoverer_test.go
@@ -17,7 +17,7 @@ func TestMTUDiscovererTiming(t *testing.T) {
 	const rtt = 100 * time.Millisecond
 	var rttStats utils.RTTStats
 	rttStats.UpdateRTT(rtt, 0)
-	d := newMTUDiscoverer(&rttStats, 1000, 2000, func(s protocol.ByteCount) {}, nil)
+	d := newMTUDiscoverer(&rttStats, 1000, 2000, nil)
 
 	now := time.Now()
 	require.False(t, d.ShouldSendProbe(now))
@@ -37,21 +37,20 @@ func TestMTUDiscovererTiming(t *testing.T) {
 }
 
 func TestMTUDiscovererAckAndLoss(t *testing.T) {
-	var mtu protocol.ByteCount
-	d := newMTUDiscoverer(&utils.RTTStats{}, 1000, 2000, func(s protocol.ByteCount) { mtu = s }, nil)
+	d := newMTUDiscoverer(&utils.RTTStats{}, 1000, 2000, nil)
 	// we use an RTT of 0 here, so we don't have to advance the timer on every step
 	now := time.Now()
 	ping, size := d.GetPing(now)
 	require.Equal(t, protocol.ByteCount(1500), size)
 	// the MTU is reduced if the frame is lost
 	ping.Handler.OnLost(ping.Frame)
-	require.Zero(t, mtu) // no change to the MTU yet
+	require.Equal(t, protocol.ByteCount(1000), d.CurrentSize()) // no change to the MTU yet
 
 	require.True(t, d.ShouldSendProbe(now))
 	ping, size = d.GetPing(now)
 	require.Equal(t, protocol.ByteCount(1250), size)
 	ping.Handler.OnAcked(ping.Frame)
-	require.Equal(t, protocol.ByteCount(1250), mtu) // the MTU is increased
+	require.Equal(t, protocol.ByteCount(1250), d.CurrentSize()) // the MTU is increased
 
 	// Even though the 1500 byte MTU probe packet was lost, we try again with a higher MTU.
 	// This protects against regular (non-MTU-related) packet loss.
@@ -59,7 +58,7 @@ func TestMTUDiscovererAckAndLoss(t *testing.T) {
 	ping, size = d.GetPing(now)
 	require.Greater(t, size, protocol.ByteCount(1500))
 	ping.Handler.OnAcked(ping.Frame)
-	require.Equal(t, size, mtu)
+	require.Equal(t, size, d.CurrentSize())
 
 	// We continue probing until the MTU is close to the maximum.
 	var steps int
@@ -91,13 +90,9 @@ func testMTUDiscovererMTUDiscovery(t *testing.T) {
 	rttStats.UpdateRTT(rtt, 0)
 
 	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 },
+	d := newMTUDiscoverer(&rttStats, startMTU, maxMTU,
 		&logging.ConnectionTracer{
 			UpdatedMTU: func(mtu logging.ByteCount, done bool) {
 				tracedMTU = mtu
@@ -122,6 +117,7 @@ func testMTUDiscovererMTUDiscovery(t *testing.T) {
 		}
 		now = now.Add(mtuProbeDelay * rtt)
 	}
+	currentMTU := d.CurrentSize()
 	diff := realMTU - currentMTU
 	require.GreaterOrEqual(t, diff, protocol.ByteCount(0))
 	if maxMTU > currentMTU+maxMTU {
@@ -151,15 +147,10 @@ func testMTUDiscovererWithRandomLoss(t *testing.T) {
 	require.Equal(t, rtt, rttStats.SmoothedRTT())
 
 	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 },
+	d := newMTUDiscoverer(rttStats, startMTU, maxMTU,
 		&logging.ConnectionTracer{
 			UpdatedMTU: func(mtu logging.ByteCount, done bool) {
 				tracedMTU = mtu
@@ -195,6 +186,7 @@ func testMTUDiscovererWithRandomLoss(t *testing.T) {
 		now = now.Add(mtuProbeDelay * rtt)
 	}
 
+	currentMTU := d.CurrentSize()
 	diff := realMTU - currentMTU
 	require.GreaterOrEqual(t, diff, protocol.ByteCount(0))
 	if maxMTU > currentMTU+maxMTU {
diff --git a/qlog/types_test.go b/qlog/types_test.go
index b31c3e1a..71c595e5 100644
--- a/qlog/types_test.go
+++ b/qlog/types_test.go
@@ -94,6 +94,7 @@ func TestTimerTypeStringRepresentation(t *testing.T) {
 	}{
 		{logging.TimerTypeACK, "ack"},
 		{logging.TimerTypePTO, "pto"},
+		{logging.TimerTypePathProbe, "path_probe"},
 	}
 
 	for _, tc := range testCases {