MTU discovery probes downward - SetMtuDiscoveryTarget uses transient max_packet_length() instead of long_term_mtu_
- Dominant language
- C++
- Stars
- 897
- Forks
- 177
- PR merge metrics
- No merged PRs in 30d
Description
**Summary**
When a client sets kMTUH via SetClientConnectionOptions, MTU discovery initializes the discoverer with the transient soft-capped value of max_packet_length() (set by SetSoftMaxPacketLength during packet coalescing), instead of the durable long_term_mtu_. The discoverer then sends every probe at a size below the actual max_packet_length, the OnPathMtuIncreased gate rejects every ack, the bisect interprets the lack of growth as failure and shrinks downward, and after kMtuDiscoveryAttempts (3) the discoverer stops permanently. The connection's MTU stays at the default 1250 forever.
**Environment**
- QUICHE: 066230c83873ae4247832b07aca60bda4b52f6e6 (May 2025) — bug is also present on main as of today (verified by diff: quic_mtu_discovery.cc and the relevant quic_connection.cc functions are byte-identical between this commit and main HEAD)
- Role: client (Perspective::IS_CLIENT)
- Connection options: kMTUH set via SetClientConnectionOptions
- Server peer max_udp_payload_size: 1472 (so peer MTU is not the bottleneck)
**Logs added to Quiche to debug issue**
The clearest evidence is a single timestamped block from OnConfigNegotiated -> SetFromConfig -> SetMtuDiscoveryTarget:
.93555 PacketCreator::SetSoftMaxPacketLength: from=1250 to=1015 <- coalescer activates soft cap
.93564 tls_client_handshaker: handshake finished
.93582 transport_parameters: Parsed transport parameters [Server max_udp_payload_size=1472 …]
.93584 SetMtuDiscoveryTarget: target=1400 max_packet_length=1015 long_term_mtu=1250 limited_target=1350
.93586 MtuDiscoverer Enable called: max_packet_length=1015 target_max_packet_length=1350
.93586 MtuDiscoverer ENABLED: min_probe_length=1015 max_probe_length=1350
.93593 PacketCreator::RemoveSoftMaxPacketLength: current=1015 latched=1250 ← soft cap removed 38μs too late
.93614 PacketCreator::SetMaxPacketLength: from=1015 to=1250
Note line .93584 prints max_packet_length=1015 and long_term_mtu=1250 side by side at the moment Enable captures the wrong value. The Enable lands inside the 38-microsecond window where the coalescer's soft cap is active.
**Resulting probe behavior**
With min_probe_length_=1015 and max_probe_length_=1350 (instead of the expected min=1250, max=1400), the bisect produces probes that are all smaller than the actual max_packet_length():
MTU probe SENT: probe_size=1183 max_packet_length=1250 mtu_probe_count=1
MTU probe SENT: probe_size=1099 max_packet_length=1250 mtu_probe_count=2
MTU probe SENT: probe_size=1057 max_packet_length=1250 mtu_probe_count=3
All three probes are physically transmitted and acked by the server (the path supports 1250-byte packets, confirmed by regular data traffic at that size flowing fine). When each probe ack arrives, it ack fails the bytes_sent > largest_mtu_acked_ check before OnPathMtuIncreased is ever called.
After 3 probe attempts (kMtuDiscoveryAttempts), remaining_probe_count_ reaches 0 and discovery is permanently disabled. max_packet_length never moves off 1250 for the lifetime of the connection.
Why the bisect probes downward
The discoverer's only "did the probe succeed?" signal is whether min_probe_length_ was updated by OnMaxPacketLengthUpdated. That callback is only invoked from OnPathMtuIncreased after its gate if (packet_size > max_packet_length()) passes. With min_probe_length_ poisoned to 1015 while max_packet_length() is 1250, every probe lands in the gap and the gate rejects it. From the discoverer's perspective min_probe_length_ never updates, so next_probe_packet_length() returns the same value on each subsequent call. The bisect logic at quic_mtu_discovery.cc:78-83:
QuicPacketLength probe_packet_length = next_probe_packet_length();
if (probe_packet_length == last_probe_length_) {
// The next probe packet is as big as the previous one. Assuming the
// previous one exceeded MTU, we need to decrease the probe packet length.
max_probe_length_ = probe_packet_length;
}
…assumes "same value as last time" means "previous probe was too big," and shrinks max_probe_length_. The next midpoint is therefore smaller. The discoverer is performing a binary search on a question it cannot get answers to - every probe is treated as "failed" because it can't see the
acks.
**Expected behavior**
With kMTUH and `kMtuDiscoveryTargetPacketSizeHigh=1400` clamped by the writer's `GetMaxPacketSize()=1350` (so `limited_target=1350`), the discoverer should have been enabled with `min_probe_length=1250` (the durable `long_term_mtu_`) and `max_probe_length=1350`. The first probe would then be `(1250+1350+1)/2 = 1300`, which is `> max_packet_length=1250`, so its ack would pass the `OnPathMtuIncreased` gate and grow `max_packet_length` to 1300. The bisect would proceed upward from there.
Instead, the discoverer was enabled with `min_probe_length=1015`:
quic_mtu_discovery.cc:39] MtuDiscoverer ENABLED: min_probe_length=1015 max_probe_length=1350
That `1015` is the transient soft-capped value of `max_packet_length()` at the moment `SetMtuDiscoveryTarget` ran inside the post-handshake `SetFromConfig`. Every subsequent probe is therefore sized below the actual `max_packet_length=1250`, and `OnPathMtuIncreased`'s gate rejects every ack.
**Root cause**
SetMtuDiscoveryTarget (quic_connection.cc:5113-5117):
void QuicConnection::SetMtuDiscoveryTarget(QuicByteCount target) {
QUIC_DVLOG(2) << ENDPOINT << "SetMtuDiscoveryTarget: " << target;
mtu_discoverer_.Disable();
mtu_discoverer_.Enable(max_packet_length(), GetLimitedMaxPacketSize(target));
}
max_packet_length() is forwarded to packet_creator_.max_packet_length(), which is the value mutated by SetSoftMaxPacketLength during coalescing. SetFromConfig is called twice for clients (once at Initialize, once at OnConfigNegotiated per quic_connection.cc:452 comment), and the second call lands inside the post-handshake coalesced response build, when the soft cap is active. The discoverer captures the soft-capped value (1015 in the example above) and keeps it as min_probe_length_ for the lifetime of the connection.
long_term_mtu_ is the durable MTU value (set only by SetMaxPacketLength, never by SetSoftMaxPacketLength) and exists for exactly this kind of "what is the connection's MTU" use case — see quic_connection.cc:3287 for an existing example of preferring long_term_mtu_ over
max_packet_length().
**Proposed fix**
void QuicConnection::SetMtuDiscoveryTarget(QuicByteCount target) {
QUIC_DVLOG(2) << ENDPOINT << "SetMtuDiscoveryTarget: " << target;
mtu_discoverer_.Disable();
- mtu_discoverer_.Enable(max_packet_length(), GetLimitedMaxPacketSize(target));
+ mtu_discoverer_.Enable(long_term_mtu_, GetLimitedMaxPacketSize(target));
}
This makes the Enable timing-independent of any in-flight coalescer state. long_term_mtu_ reflects the durable MTU regardless of when SetMtuDiscoveryTarget happens to be called.
Diagnostic patches used to produce the logs above
1. SetMtuDiscoveryTarget parameters (quic_connection.cc):
```
void QuicConnection::SetMtuDiscoveryTarget(QuicByteCount target) {
+ QUIC_LOG(WARNING) << ENDPOINT << "SetMtuDiscoveryTarget: target=" << target
+ << " max_packet_length=" << max_packet_length()
+ << " long_term_mtu=" << long_term_mtu_
+ << " peer_max_packet_size=" << peer_max_packet_size_
+ << " limited_target=" << GetLimitedMaxPacketSize(target);
mtu_discoverer_.Disable();
```
2. MtuDiscoverer Enable (quic_mtu_discovery.cc):
```
void QuicConnectionMtuDiscoverer::Enable(
QuicByteCount max_packet_length, QuicByteCount target_max_packet_length) {
+ QUIC_LOG(WARNING) << "MtuDiscoverer Enable called: max_packet_length="
+ << max_packet_length
+ << " target_max_packet_length=" << target_max_packet_length;
QUICHE_DCHECK(!IsEnabled());
...
min_probe_length_ = max_packet_length;
max_probe_length_ = target_max_packet_length;
+ QUIC_LOG(WARNING) << "MtuDiscoverer ENABLED: min_probe_length="
+ << min_probe_length_
+ << " max_probe_length=" << max_probe_length_;
```
3. MTU probe sends (quic_connection.cc, OnMtuDiscoveryAlarm):
```
if (mtu_discoverer_.ShouldProbeMtu(largest_sent_packet)) {
++mtu_probe_count_;
- SendMtuDiscoveryPacket(
- mtu_discoverer_.GetUpdatedMtuProbeSize(largest_sent_packet));
+ QuicPacketLength probe_size =
+ mtu_discoverer_.GetUpdatedMtuProbeSize(largest_sent_packet);
+ QUIC_LOG(WARNING) << ENDPOINT << "MTU probe SENT: probe_size=" << probe_size
+ << " max_packet_length=" << max_packet_length()
+ << " mtu_probe_count=" << mtu_probe_count_;
+ SendMtuDiscoveryPacket(probe_size);
}
```
4. OnPathMtuIncreased invocations (quic_connection.cc) — note: this fires for any acked packet whose size exceeds the previous largest_mtu_acked_, not only MTU probes. Labeled accordingly:
```
void QuicConnection::OnPathMtuIncreased(QuicPacketLength packet_size) {
+ QUIC_LOG(WARNING) << ENDPOINT << "OnPathMtuIncreased called: packet_size=" << packet_size
+ << " max_packet_length=" << max_packet_length();
if (packet_size > max_packet_length()) {
```
Contributor guide
Assessment
This issue has not been assessed yet.