// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
// Moving a connection to another address (RFC 9000 §8.2, §9). A UDP source address proves nothing
// and a connection id is in the clear, so a connection moves only for a packet that authenticated,
// that says something other than probing, and that is the newest it has seen: anything less lets
// whoever can put a packet on the wire choose where this server's answers go.
///|
/// The frames a packet may carry on a path without moving the connection to it (RFC 9000 §9.1).
fn is_probing(frame : @frame.Frame) -> Bool {
frame
is (PathChallenge(_) | PathResponse(_) | NewConnectionId(..) | Padding(_))
}
///|
/// How many probe timeouts a path has to answer a challenge (RFC 9000 §8.2.4 says three).
let path_timeouts : Int64 = 3L
///|
/// Where a connection came from while a new address has yet to prove itself, and what it knew
/// about that path, so a move that was never answered can be undone (RFC 9000 §9.3.2).
priv struct Moved[A] {
peer : A
validated : Bool
rx : Int64
tx : Int64
give_up : Int64
mut resend : Int64
}
///|
/// How much more may go to the current address, which an unvalidated one limits (RFC 9000 §8.1).
fn[A] Session::budget(self : Session[A]) -> Int64 {
if self.validated {
9223372036854775807L
} else {
amplification * self.rx - self.tx
}
}
///|
/// One packet of path frames to `to`, outside the send loop: a PATH_CHALLENGE or PATH_RESPONSE is
/// sent on one particular path and is never retransmitted as it stands (RFC 9000 §13.3). It is
/// padded to 1200 bytes, which is how a path shows it carries a full-size datagram (§8.2.1, §8.2.2),
/// unless the budget allows less.
fn[A] Session::probe(
self : Session[A],
frames : Array[@frame.Frame],
to : A,
budget : Int64,
) -> Unit {
guard self.app_tx is Some(_) else { return }
let pn = self.core.conn.next_packet_number(Level::Application)
self.sender.advance_past(pn)
let payload = @packet.payload(frames)
let build = p => self.seal(Level::Application, p, pn)
let full = pad(payload, build, min_datagram)
let datagram = if full.length().to_int64() <= budget {
full
} else {
build(payload)
}
guard datagram.length().to_int64() <= budget else { return }
self.probes.push((datagram, to))
}
///|
/// An authenticated packet from an address other than the connection's. A challenge is answered
/// where it came from; the connection moves only if this packet is not just probing and is the
/// newest, since an older one is a reordered packet from before the peer moved (§9.3). The frames
/// handed back are what is left to act on.
fn[A] Session::on_other_path(
self : Session[A],
frames : Array[@frame.Frame],
newest : Bool,
from : A,
addressed : Bytes,
nonce : Bytes,
size : Int32,
now : Int64,
) -> Array[@frame.Frame] {
if self.phase == Active && newest && !frames.iter().all(is_probing) {
self.move_to(from, addressed, nonce, size, now)
return frames
}
let budget = amplification * size.to_int64()
for frame in frames {
if frame is PathChallenge(data) {
self.probe([PathResponse(data)], from, budget)
}
}
frames.filter(frame => !(frame is PathChallenge(_)))
}
///|
/// Start answering at `to`, and challenge it. Until it answers it is an address like any other that
/// has never proven itself, so its budget starts over at what it has just sent (§9.4).
fn[A] Session::move_to(
self : Session[A],
to : A,
addressed : Bytes,
nonce : Bytes,
size : Int32,
now : Int64,
) -> Unit {
let pto = self.sender.recovery.pto()
let give_up = now + path_timeouts * pto
self.moved = match self.moved {
// A second move before the first was answered still goes back to the last proven address.
Some(earlier) => Some({ ..earlier, give_up, resend: now + pto, })
None =>
Some({
peer: self.peer,
validated: self.validated,
rx: self.rx,
tx: self.tx,
give_up,
resend: now + pto,
})
}
self.peer = to
self.validated = false
self.rx = size.to_int64()
self.tx = 0L
// A peer that changed its id on the way here does not want the two paths linked, so this end
// changes too, if it can (RFC 9000 §9.5). A NAT rebinding keeps the id, and so does this.
if self.ids.heard_on != addressed {
ignore(self.next_peer_id())
}
self.challenge(self.path.start(nonce))
}
///|
/// Send the outstanding challenge to the current address, on its budget.
fn[A] Session::challenge(self : Session[A], data : Bytes) -> Unit {
let before = self.probes.length()
self.probe([PathChallenge(data)], self.peer, self.budget())
if self.probes.length() > before {
self.tx = self.tx + self.probes[before].0.length().to_int64()
}
}
///|
/// The current address answered its challenge, so it is the peer's, and what this connection knew
/// about the path it left no longer applies (§9.4).
fn[A] Session::on_path_proven(self : Session[A]) -> Unit {
guard self.moved is Some(_) else { return }
self.moved = None
self.validated = true
self.sender.recovery.on_new_path()
}
///|
/// A moved connection's clock: challenge again a probe timeout on, and after three go back where
/// it came from, since a path that will not answer is not where the peer is (§9.3.2).
fn[A] Session::tick_path(self : Session[A], now : Int64) -> Unit {
guard self.moved is Some(moved) else { return }
if now >= moved.give_up {
self.peer = moved.peer
self.validated = moved.validated
self.rx = moved.rx
self.tx = moved.tx
self.moved = None
self.path.reset()
self.path.validated = moved.validated
return
}
if now >= moved.resend {
moved.resend = now + self.sender.recovery.pto()
self.challenge(self.path.challenge())
}
}