Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions noq-proto/src/config/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ pub struct TransportConfig {
pub(crate) max_concurrent_bidi_streams: VarInt,
pub(crate) max_concurrent_uni_streams: VarInt,
pub(crate) max_idle_timeout: Option<VarInt>,
pub(crate) handshake_idle_timeout: Duration,
pub(crate) stream_receive_window: VarInt,
pub(crate) receive_window: VarInt,
pub(crate) send_window: u64,
Expand Down Expand Up @@ -118,6 +119,16 @@ impl TransportConfig {
self
}

/// Maximum duration of inactivity to accept before abandoning a handshake.
///
/// Used in place of [`max_idle_timeout`](Self::max_idle_timeout) until the handshake
/// completes, so a short idle timeout meant to detect a dead peer quickly does not also cut
/// a handshake off after a single probe. Defaults to 10 seconds.
Comment on lines +124 to +126

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Apply the handshake timeout before accepting Incoming

When server code delays calling Incoming::accept, this timeout does not govern that portion of the handshake: Endpoint::accept still rejects stale Initial packets using server_config.transport.max_idle_timeout. For example, with the defaults an Initial can remain queued for 20 seconds despite the new 10-second handshake timeout, and with max_idle_timeout(None) it can remain queued indefinitely; acceptance then creates a connection and starts a fresh handshake timer. Use handshake_idle_timeout for the stale-Initial check so the configured limit covers the handshake from receipt of the Initial.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 9f30274: Endpoint::accept now drops an Initial as stale once handshake_idle_timeout has passed since it arrived, instead of max_idle_timeout, so the limit covers the wait for accept and an unset idle timeout no longer lets an Initial wait forever. Test: stale_initial_uses_handshake_idle_timeout (accept 11s after the Initial times out; it was accepted before the change).

(Written by Claude Opus 5.5)

pub fn handshake_idle_timeout(&mut self, value: Duration) -> &mut Self {
self.handshake_idle_timeout = value;
self
}

/// Maximum number of bytes the peer may transmit without acknowledgement on any one stream
/// before becoming blocked.
///
Expand Down Expand Up @@ -558,6 +569,8 @@ impl Default for TransportConfig {
max_concurrent_uni_streams: 100u32.into(),
// 30 second default recommended by RFC 9308 § 3.2
max_idle_timeout: Some(VarInt(30_000)),
// msquic's HandshakeIdleTimeoutMs default
handshake_idle_timeout: Duration::from_secs(10),
stream_receive_window: STREAM_RWND.into(),
receive_window: VarInt::MAX,
send_window: (8 * STREAM_RWND).into(),
Expand Down Expand Up @@ -610,6 +623,7 @@ impl fmt::Debug for TransportConfig {
max_concurrent_bidi_streams,
max_concurrent_uni_streams,
max_idle_timeout,
handshake_idle_timeout,
stream_receive_window,
receive_window,
send_window,
Expand Down Expand Up @@ -647,6 +661,7 @@ impl fmt::Debug for TransportConfig {
s.field("max_concurrent_bidi_streams", max_concurrent_bidi_streams)
.field("max_concurrent_uni_streams", max_concurrent_uni_streams)
.field("max_idle_timeout", max_idle_timeout)
.field("handshake_idle_timeout", handshake_idle_timeout)
.field("stream_receive_window", stream_receive_window)
.field("receive_window", receive_window)
.field("send_window", send_window)
Expand Down
35 changes: 27 additions & 8 deletions noq-proto/src/connection/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3633,11 +3633,18 @@ impl Connection {
let path = self.path(path_id)?;
let pto_count = path.pto_count;

// The idle timeout the probes have to beat, as `reset_idle_timeout` arms it.
let idle = if self.state.is_handshake() {
Some(self.config.handshake_idle_timeout)
} else {
path.idle_timeout.or(self.idle_timeout)
};

// Cap the maximum interval between two tail-loss probes.
let max_interval = if path.rtt.get() > SLOW_RTT_THRESHOLD {
// For slow links we want to increase the interval beyond 2s.
(path.rtt.get() * 3) / 2
} else if let Some(idle) = path.idle_timeout.or(self.idle_timeout)
} else if let Some(idle) = idle
&& idle <= MIN_IDLE_FOR_FAST_PTO
{
// If the idle timeout is relatively low, cap at 1s so we get plenty of retries
Expand Down Expand Up @@ -3913,18 +3920,28 @@ impl Connection {
/// enabled there is an additional per-path idle timeout.
fn reset_idle_timeout(&mut self, now: Instant, space: SpaceKind, path_id: PathId) {
// First reset the global idle timeout.
if let Some(timeout) = self.idle_timeout {
if self.state.is_closed() {
self.timers
.stop(Timer::Conn(ConnTimer::Idle), self.qlog.with_time(now));
} else {
let dt = cmp::max(timeout, 3 * self.max_pto_for_space(space));
let timeout = if self.state.is_closed() {
None
} else if self.state.is_handshake() {
Some(self.config.handshake_idle_timeout)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use the handshake timeout when capping handshake PTOs

When handshake_idle_timeout is configured below 25 seconds while max_idle_timeout remains longer or disabled, the handshake timer uses the new value here, but pto_time_and_space still selects its retransmission cap from path.idle_timeout.or(self.idle_timeout). For example, with the default 333 ms initial RTT and a 2-second handshake timeout, the second Initial probe is scheduled at the same 2997 ms instant as the idle deadline; TimerTable processes the connection idle timer before the path loss timer, so the connection times out without sending that probe, recreating the short-timeout failure this option is meant to avoid. The PTO cap should consult the effective handshake timeout while the connection is handshaking.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in e6b21af: pto_time_and_space now picks the probe interval cap from handshake_idle_timeout while handshaking, the same timeout reset_idle_timeout arms. Test: handshake_idle_timeout_caps_probe_interval (idle timeout disabled, 7s handshake timeout, the first three flights lost; the next probe goes at about 6.05s with the fast cap and about 7.05s, past the deadline, without it). With defaults this moves the handshake to the 1s fast cap, since 10s is under the 25s threshold.

(Written by Claude Opus 5.5)

} else {
self.idle_timeout
};
// A deadline too far out to represent never fires.
let deadline = timeout.and_then(|timeout| {
now.checked_add(cmp::max(timeout, 3 * self.max_pto_for_space(space)))
});
match deadline {
Some(deadline) => {
self.timers.set(
Timer::Conn(ConnTimer::Idle),
now + dt,
deadline,
self.qlog.with_time(now),
);
}
None => self
.timers
.stop(Timer::Conn(ConnTimer::Idle), self.qlog.with_time(now)),
}

// Now handle the per-path state.
Expand Down Expand Up @@ -4800,6 +4817,8 @@ impl Connection {

self.events.push_back(Event::Connected);
self.state.move_to_established();
// Swap the handshake idle timeout for the negotiated one.
self.reset_idle_timeout(now, SpaceKind::Data, path_id);
trace!("established");

// Multipath can only be enabled after the state has reached Established.
Expand Down
15 changes: 7 additions & 8 deletions noq-proto/src/endpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ use thiserror::Error;
use tracing::{debug, error, trace, warn};

use crate::{
Duration, FourTuple, INITIAL_MTU, Instant, MAX_CID_SIZE, MIN_INITIAL_SIZE, PathId,
RESET_TOKEN_SIZE, ResetToken, Side, Transmit, TransportConfig, TransportError,
FourTuple, INITIAL_MTU, Instant, MAX_CID_SIZE, MIN_INITIAL_SIZE, PathId, RESET_TOKEN_SIZE,
ResetToken, Side, Transmit, TransportConfig, TransportError,
cid_generator::ConnectionIdGenerator,
coding::{BufMutExt, Decodable, Encodable, UnexpectedEnd},
config::{ClientConfig, EndpointConfig, ServerConfig},
Expand Down Expand Up @@ -567,12 +567,11 @@ impl Endpoint {
let server_config =
server_config.unwrap_or_else(|| self.server_config.as_ref().unwrap().clone());

if server_config
.transport
.max_idle_timeout
.is_some_and(|timeout| {
incoming.received_at + Duration::from_millis(timeout.into()) <= now
})
// The handshake starts when its Initial arrives, not when it is accepted.
if incoming
.received_at
.checked_add(server_config.transport.handshake_idle_timeout)
.is_some_and(|deadline| deadline <= now)
{
debug!("abandoning accept of stale initial");
self.index.remove_initial(dst_cid);
Expand Down
113 changes: 113 additions & 0 deletions noq-proto/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2871,6 +2871,119 @@ fn lost_finished_after_initial_backoff() {
assert!(elapsed < Duration::from_secs(1), "{elapsed:?}");
}

/// A short idle timeout does not cut the handshake to two Initial flights.
///
/// Before an RTT sample the PTO is 999ms, so a 2s idle timeout is armed at 2997ms, while
/// the second probe is due 999ms + 1998ms after the first send, plus however late the first
/// probe went out. The handshake idle timeout governs until the handshake completes.
#[test]
fn handshake_outlasts_short_idle_timeout() {
let _guard = subscribe();
let mut pair = Pair::default();
let mut transport = TransportConfig::default();
transport.max_idle_timeout(Some(Duration::from_secs(2).try_into().unwrap()));
let mut client = client_config();
client.transport_config(Arc::new(transport));

let client_ch = pair.begin_connect(client);
let start = pair.time;
// The first flight is lost, and so is the first probe, which goes out 2ms late.
pair.drive_client();
pair.server.inbound.clear();
pair.time = pair.client.next_wakeup().unwrap() + Duration::from_millis(2);
loop {
pair.drive_client();
pair.server.inbound.clear();
pair.time = pair.client.next_wakeup().unwrap();
// Past any pacing: the second probe, due 2999ms after the first send, beyond the
// old 2997ms idle deadline.
if pair.time - start > Duration::from_secs(2) {
break;
}
}

pair.drive();
assert_matches!(
pair.client_conn_mut(client_ch).poll(),
Some(Event::HandshakeDataReady)
);
assert_matches!(
pair.client_conn_mut(client_ch).poll(),
Some(Event::Connected)
);
}

/// The handshake idle timeout picks the cap on the probe interval during the handshake, so a
/// short one gets the fast cap even with the idle timeout disabled.
#[test]
fn handshake_idle_timeout_caps_probe_interval() {
let _guard = subscribe();
let mut pair = Pair::default();
let mut transport = TransportConfig::default();
transport
.max_idle_timeout(None)
.handshake_idle_timeout(Duration::from_secs(7));
let mut client = client_config();
client.transport_config(Arc::new(transport));

let client_ch = pair.begin_connect(client);
let start = pair.time;
// The first flight and the probes at 1s and 3s are lost.
while pair.time - start < Duration::from_secs(5) {
pair.drive_client();
pair.server.inbound.clear();
pair.time = pair.client.next_wakeup().unwrap();
}

// The fast cap sends the next probe at about 6.05s, inside the 7s handshake idle timeout.
// The normal 2s cap would send it at about 7.05s, after the client gave up.
pair.drive();
assert_matches!(
pair.client_conn_mut(client_ch).poll(),
Some(Event::HandshakeDataReady)
);
assert_matches!(
pair.client_conn_mut(client_ch).poll(),
Some(Event::Connected)
);
}

/// A handshake idle timeout too long to add to an `Instant` never fires, instead of panicking.
#[test]
fn unbounded_handshake_idle_timeout() {
let _guard = subscribe();
let mut transport = TransportConfig::default();
transport.handshake_idle_timeout(Duration::MAX);
let transport = Arc::new(transport);
let mut server = server_config();
server.transport = transport.clone();
let mut client = client_config();
client.transport_config(transport);

let mut pair = Pair::new(Default::default(), server);
pair.connect_with(client);
}

/// An Initial left waiting for `accept` past the handshake idle timeout is stale, even
/// though the idle timeout has not expired.
#[test]
fn stale_initial_uses_handshake_idle_timeout() {
let _guard = subscribe();
let mut pair = Pair::default();
pair.server.handle_incoming = Box::new(|_| IncomingConnectionBehavior::Wait);
pair.begin_connect(client_config());
pair.drive_client();
pair.drive_server();
let incoming = pair.server.waiting_incoming.pop().unwrap();

// Past the 10s handshake idle timeout, inside the 30s idle timeout.
pair.time += Duration::from_secs(11);
assert_matches!(
pair.server.try_accept(incoming, pair.time),
Err(ConnectionError::TimedOut)
);
}

/// Ensures that the server can respond with 3 initial packets during the handshake
/// before the anti-amplification limit kicks in when MTUs are similar.
#[test]
Expand Down
1 change: 1 addition & 0 deletions noq/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ fn handshake_timeout() {
let mut transport_config = TransportConfig::default();
transport_config
.max_idle_timeout(Some(IDLE_TIMEOUT.try_into().unwrap()))
.handshake_idle_timeout(IDLE_TIMEOUT)
.initial_rtt(Duration::from_millis(10));
client_config.transport_config(Arc::new(transport_config));

Expand Down
Loading