Skip to content

Don't gate PTO packets on address validation - #49

Open
rnro wants to merge 3 commits into
apple:mainfrom
rnro:pto_after_validation
Open

Don't gate PTO packets on address validation#49
rnro wants to merge 3 commits into
apple:mainfrom
rnro:pto_after_validation

Conversation

@rnro

@rnro rnro commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

At the moment the code will not send an ack-eliciting ping once the peer's address is validated. This appears to be linked to RFC 9000 Section 8.1, which states

To prevent this deadlock, clients MUST send a packet on a Probe Timeout (PTO) [...] the client MUST send an Initial packet in a UDP datagram that contains at least 1200 bytes if it does not have Handshake keys, and otherwise send a Handshake packet.

However I think this is a slight mis-read, and that RFC 9002 Section 6.2.4

When a PTO timer expires, a sender MUST send at least one ack-eliciting packet [...] When there is no data to send, the sender SHOULD send a PING or other ack-eliciting frame in a single packet, rearming the PTO timer.

shows that whilst validation is an important inflection point for the two behaviors, PTO pings should still be sent after it. The pseudocode in A9 I think also indicates this.

A new test with the current code results in outstanding frames which will never have a PTO fire and the current code will end up in a tight loop.

This change removes the validation distinction in sendPTO, along with the now-unused validation local and the timer-clear branch it fed; the difference in behavior required by the RFC will be enacted by the timer.

At the moment the code will not send an ack-eliciting ping once the
peer's address is validated. This appears to be linked to RFC 9000
Section 8.1, which states
> To prevent this deadlock, clients MUST send a packet on a Probe
> Timeout (PTO) [...] the client MUST send an Initial packet in a UDP
> datagram that contains at least 1200 bytes if it does not have
> Handshake keys, and otherwise send a Handshake packet.
However I think this is a slight mis-read, and that RFC 9002 Section
6.2.4
> When a PTO timer expires, a sender MUST send at least one ack-eliciting
> packet [...] When there is no data to send, the sender SHOULD send a
> PING or other ack-eliciting frame in a single packet, rearming the PTO
> timer.
shows that whilst validation is an important inflection point for the two
behaviors, PTO pings should still be sent after it. The pseudocode in A9
I think also indicates this.

A new test with the current code results in outstanding frames which will
never have a PTO fire and the current code will end up in a tight loop.

This change removes the validation distinction in `sendPTO`, along with
the now-unused validation local and the timer-clear branch it fed; the
difference in behavior required by the RFC will be enacted by the timer.
@agnosticdev

Copy link
Copy Markdown
Collaborator

At the moment the code will not send an ack-eliciting ping once the peer's address is validated.

PTO pings should still be sent after it.

PTO can generate PINGs still if the ACK blocks get large enough to piggyback a PING frame to make them ACK-eliciting, see the code here: https://github.com/apple/swift-network-evolution/blob/main/Sources/SwiftNetwork/QUIC/Ack.swift#L304C13-L304C24

if ackElicitingPacketsInFlight == 0 {
guard ackElicitingPacketsInFlight > 0 else {
if _slowPath(ackElicitingPacketsInFlight < 0) {
connection.log.fault("ackElicitingPacketsInFlight negative: \(ackElicitingPacketsInFlight)")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably make sure we are not underflowing ackElicitingPacketsInFlight, possibly adding an underflow check to -= 1 since this is an integer. That way we do not have to log out these negative values.

if !sentPTO {
connection.log.datapath("Sending a PING as PTO")
if peerCompletedValidation {
connection.log.fault("PTO fired after validation")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Historically this log has been a warning that something in Recovery may not be working as expected, or you may have run into a race condition in the logic. For example, I am looking at a case now where ackElicitingPacketsInFlight is 1, but then the handshake completes, clears the ack-eliciting packets, resetPTOCount set the count back 0, and then sendPTO falls into this path as a matter of timing. When this happens sendPTO tries to reset the timer and realizes that there are now no ackElicitingPacketsInFlight and then cancels the timer and continues on as normal.
So in this path we have now removed the resetTimer functionality, what is the rationale there?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we have lost the timer. By my read sendPTO's only caller is timerFired, which calls resetTimer right after it returns, and that cancels when nothing is in flight and the peer has validated us. So I think the existing shouldClearTimer cancel was redundant.

I think your callout about the potential race is right and I regressed that here. With nothing in flight the loop does nothing, so the unconditional fallback would have sent a spurious PING. It's now gated on whether anything is in flight rather than on validation. That case keeps the fault and sends nothing, and the probe is only unconditional when there's something to probe for.

@rpaulo

rpaulo commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

As discussed with @agnosticdev, the code was assuming that if we hadn't already finished peer validation (i.e., we were in the middle of the handshake), we should have had ack eliciting packets to send, but it seems like that may not be true when the timer is scheduled in conjunction with the receipt of an ACK.

rnro added 2 commits August 6, 2026 16:25
`sendPTO` chose how to probe using state that did not match what it was
probing. `hasAckElicitingPendingItems` was read once for the space
returned by `getEarliestTime` and then applied to *every space*.
I don't think this should be the case because pending items are held per
space.

An error in the previous commit meant that the fallback probed
unconditionally,
so a PTO that fired with nothing in flight still sent a PING. This
happens
when the handshake completes and clears the in-flight packets after
the timer was armed.

`ackElicitingPacketsInFlight` no longer decrements below zero, so the
read
side does not need to detect negative values. The all-spaces sum that
`sendPTO` and `resetTimer` both consult is now a shared property rather
than duplicated.

The two PTO tests now assert that a probe was recorded rather than that
the
PTO was counted, since the count advances on the attempt. They cover the
two branches that can produce no packet: a tail retransmit that cannot
be
rebuilt, and new data that writes no payload.
Resolved a conflict in `sendPTO` with 410ec51 ("Stop logging a fault
for valid PTO cases"), which split the `PTO fired after validation` log
into error and fault while keeping the validation gate. Kept this
branch's behaviour, which probes when packets are in flight. The case that
commit faulted for, nothing in flight after validation, still faults and
sends nothing.

Took main's `inout` `recordSentPackets` from 32fe031 in place of this
branch's `removeFirst()` change.
@rnro

rnro commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

At the moment the code will not send an ack-eliciting ping once the peer's address is validated.
PTO pings should still be sent after it.

PTO can generate PINGs still if the ACK blocks get large enough to piggyback a PING frame to make them ACK-eliciting, see the code here: https://github.com/apple/swift-network-evolution/blob/main/Sources/SwiftNetwork/QUIC/Ack.swift#L304C13-L304C24

Yep, my initial wording was too strong here. They can be sent in that case, I meant this last-resort PING. That highlighted case only fires a PING for specific traffic patterns - it needs inbound traffic gappy enough to produce more than 5 ACK blocks, and the flag is consumed on write. With no inbound traffic this reproduces deterministically.

@rnro

rnro commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

I've pushed a follow-up commit for the review comments, plus two issues they turned up, and merged in main.

Both ackElicitingPacketsInFlight decrement sites now refuse to go below zero, so nothing logs negative
values.

The PTO fired after validation path is no longer unconditional. It's gated on whether anything is
ack-eliciting in flight. The "state changed between arming the timer and it firing" case keeps its old
behaviour — fault, send nothing, resetTimer cancels. The probe stays unconditional when there really are
packets in flight. Those are two separate RFC obligations and I believe both are covered: a probe when packets are
in flight (9002 6.2.4), and the padded anti-deadlock packet before validation (6.2.2.1).

The other issues I noticed were in the same function.

  • sentPTO was set before the packet was built, so a pending item that writes no payload
    (e.g. a stream queued for service whose flow has since been torn down) incorrectly counted as a
    probe and suppressed the PING.
  • hasAckElicitingPendingItems was read for the wrong space. It was evaluated once for a
    particular packet number space (from getEarliestTime) and then applied to all three spaces
    but I think pending items are per-space.

I checked each test fails only for its own defect, by reverting each fix separately.

The merge conflicted with #62 ("Stop logging a fault for valid PTO cases") and I resolved in favour of
this PR. I think this PR was rightly removing fault logging from a case which can legitimately arise,
but I don't think sending nothing is the right action. By my read RFC 9002 says we should send a PING:
6.2.4 requires an ack-eliciting probe, 7.5 says probes must not be blocked by congestion control, and
a PING isn't flow controlled.

@rnro
rnro marked this pull request as ready for review August 7, 2026 19:56
@rnro
rnro requested review from kkuk24, rpaulo and tfpauly as code owners August 7, 2026 19:56
if !sentPTO {
if totalAckElicitingPacketsInFlight == 0, peerCompletedValidation(connection: connection) {
// Nothing is in flight to probe for, so the state must have changed between arming the
// timer and it firing (e.g. the handshake completed and cleared the in-flight packets).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, thank you for calling this condition out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants