test(managed): verify timeout budget fairness

This commit is contained in:
Nils Andreas Svee committed 2026-08-10 01:18:38 +07:00
1 parent a67331a297
commit 4632e310d7
5 files changed
+457 -4

No files matched your search

+5
View File
@@ -773,6 +773,11 @@ impl AsyncClient {
self.socket.poll_recv_ready(cx) self.socket.poll_recv_ready(cx)
} }
#[cfg(test)]
pub(crate) fn replace_pending_for_test(&mut self, probe: crate::probe::PendingProbe) {
self.machine.replace_pending_for_test(probe);
}
pub(crate) fn skip_missed_probe_slots_at(&mut self, now: Instant) -> Result<(), ClientError> { pub(crate) fn skip_missed_probe_slots_at(&mut self, now: Instant) -> Result<(), ClientError> {
let Some(schedule) = self.schedule.as_mut() else { let Some(schedule) = self.schedule.as_mut() else {
return Ok(()); return Ok(());
+33 -1
View File
@@ -16,7 +16,10 @@ use irtt_proto::{
use tokio::runtime::{Builder, Runtime}; use tokio::runtime::{Builder, Runtime};
use super::*; use super::*;
use crate::{socket_options::tokio_socket_traffic_class, Client, RunMode, SocketConfig}; use crate::{
probe::PendingProbe, socket_options::tokio_socket_traffic_class, Client, ClientTimestamp,
RunMode, SocketConfig,
};
const TOKEN: u64 = 0x1234_5678_90ab_cdef; const TOKEN: u64 = 0x1234_5678_90ab_cdef;
@@ -708,6 +711,35 @@ fn probe_false_readiness_resamples_all_timestamps() {
assert_eq!(server.finish().len(), 2); assert_eq!(server.finish().len(), 2);
} }
#[test]
fn public_timeout_polling_remains_exhaustive() {
let server = start_open_server(None, 0);
runtime().block_on(async {
let mut client = opened_client(server.addr, 0).await;
let now = Instant::now();
for seq in 0..3 {
client.replace_pending_for_test(PendingProbe {
wire_seq: seq,
sent_at: ClientTimestamp {
mono: now - Duration::from_secs(2),
wall: SystemTime::UNIX_EPOCH,
},
timeout_at: now - Duration::from_secs(1),
});
}
assert!(matches!(
client.poll_timeouts_at(now).unwrap().as_slice(),
[
ClientEvent::EchoLoss { seq: 0, .. },
ClientEvent::EchoLoss { seq: 1, .. },
ClientEvent::EchoLoss { seq: 2, .. },
]
));
});
assert_eq!(server.finish().len(), 1);
}
#[test] #[test]
fn successful_short_probe_commits_before_length_error() { fn successful_short_probe_commits_before_length_error() {
let server = start_open_server(None, 0); let server = start_open_server(None, 0);
+402 -2
View File
@@ -6,7 +6,7 @@ use std::{
sync::{Arc, Condvar, Mutex}, sync::{Arc, Condvar, Mutex},
task::{Context, Poll, Waker}, task::{Context, Poll, Waker},
thread::{self, JoinHandle}, thread::{self, JoinHandle},
time::{Duration, Instant}, time::{Duration, Instant, SystemTime},
}; };
use irtt_proto::{ use irtt_proto::{
@@ -16,7 +16,10 @@ use irtt_proto::{
use tokio::runtime::{Builder, Runtime}; use tokio::runtime::{Builder, Runtime};
use super::*; use super::*;
use crate::{socket::resolution_call_counts, ClientAuthConfig, NegotiationPolicy, RunMode}; use crate::{
probe::PendingProbe, socket::resolution_call_counts, ClientAuthConfig, ClientTimestamp,
NegotiationPolicy, RunMode,
};
const TOKEN: u64 = 0x1234_5678_90ab_cdef; const TOKEN: u64 = 0x1234_5678_90ab_cdef;
@@ -318,6 +321,71 @@ fn client_events(
.collect() .collect()
} }
fn timeout_probe(seq: u32, sent_at: Instant, timeout_at: Instant) -> PendingProbe {
PendingProbe {
wire_seq: seq,
sent_at: ClientTimestamp {
mono: sent_at,
wall: SystemTime::UNIX_EPOCH,
},
timeout_at,
}
}
fn open_target_for_timeout_test(runtime: &Runtime, task: &mut ManagedClientTask, index: usize) {
let TargetState::Pending { client_config } =
mem::replace(&mut task.targets[index].state, TargetState::Terminal)
else {
panic!("timeout test target must start pending");
};
let client = runtime.block_on(async {
let mut client = AsyncClient::connect(client_config).await.unwrap();
client.open().await.unwrap();
client
});
task.targets[index].desired = false;
task.install_target_state(index, TargetState::Active { client });
}
fn replace_pending_for_timeout_test(
task: &mut ManagedClientTask,
index: usize,
probe: PendingProbe,
) {
let client = match &mut task.targets[index].state {
TargetState::Active { client } | TargetState::Draining { client, .. } => client,
_ => panic!("timeout test target must be active or draining"),
};
client.replace_pending_for_test(probe);
}
fn poll_task_once(runtime: &Runtime, task: &mut Pin<Box<ManagedClientTask>>) {
runtime.block_on(poll_fn(|cx| {
assert!(task.as_mut().poll(cx).is_pending());
Poll::Ready(())
}));
}
fn timeout_losses_for(
observations: &Mutex<Vec<(ManagedEvent, Arc<ManagedStatus>)>>,
id: &str,
) -> usize {
observations
.lock()
.unwrap()
.iter()
.filter(|(event, _)| {
matches!(
event,
ManagedEvent::Client {
target,
event: ClientEvent::EchoLoss { .. },
} if target.id.as_ref() == id
)
})
.count()
}
fn probes(records: &[PacketRecord]) -> Vec<Instant> { fn probes(records: &[PacketRecord]) -> Vec<Instant> {
records records
.iter() .iter()
@@ -722,6 +790,7 @@ fn pruning_preserves_target_cursor_progress() {
) )
.unwrap(); .unwrap();
task.cursor = 5; task.cursor = 5;
task.timeout_cursor = 5;
task.send_cursor = 5; task.send_cursor = 5;
task.scan_remaining = 3; task.scan_remaining = 3;
task.burst_remaining = 3; task.burst_remaining = 3;
@@ -729,6 +798,7 @@ fn pruning_preserves_target_cursor_progress() {
task.targets.truncate(2); task.targets.truncate(2);
task.rebase_target_cursors(); task.rebase_target_cursors();
assert_eq!(task.cursor, 1); assert_eq!(task.cursor, 1);
assert_eq!(task.timeout_cursor, 1);
assert_eq!(task.send_cursor, 1); assert_eq!(task.send_cursor, 1);
assert_eq!(task.scan_remaining, 2); assert_eq!(task.scan_remaining, 2);
assert_eq!(task.burst_remaining, 2); assert_eq!(task.burst_remaining, 2);
@@ -737,6 +807,7 @@ fn pruning_preserves_target_cursor_progress() {
task.targets.clear(); task.targets.clear();
task.rebase_target_cursors(); task.rebase_target_cursors();
assert_eq!(task.cursor, 0); assert_eq!(task.cursor, 0);
assert_eq!(task.timeout_cursor, 0);
assert_eq!(task.send_cursor, 0); assert_eq!(task.send_cursor, 0);
assert_eq!(task.scan_remaining, 0); assert_eq!(task.scan_remaining, 0);
assert_eq!(task.burst_remaining, 0); assert_eq!(task.burst_remaining, 0);
@@ -1653,6 +1724,335 @@ fn stagger_gate_tracks_active_membership() {
second.finish(); second.finish();
} }
#[test]
fn timeout_work_is_bounded_per_task_poll_and_eventually_drains() {
let server = start_server(ServerBehavior::Echo, None);
let mut managed = config(ManagedPacing::Staggered);
managed.completion = ManagedCompletionPolicy::ExplicitStop;
managed.client.duration = None;
let (mut task, handle) =
ManagedClient::task(managed, vec![target("one", server.addr)]).unwrap();
let observations = Arc::new(Mutex::new(Vec::new()));
task.event_observations = Some(Arc::clone(&observations));
let runtime = runtime();
open_target_for_timeout_test(&runtime, &mut task, 0);
let now = Instant::now();
for seq in 0..u32::try_from(TIMEOUT_WORK_BUDGET + 1).unwrap() {
replace_pending_for_timeout_test(
&mut task,
0,
timeout_probe(
seq,
now - Duration::from_secs(2),
now - Duration::from_secs(1),
),
);
}
let mut task = Box::pin(task);
poll_task_once(&runtime, &mut task);
assert_eq!(
timeout_losses_for(&observations, "one"),
TIMEOUT_WORK_BUDGET
);
assert!(matches!(
&task.targets[0].state,
TargetState::Active { client }
if client.next_probe_timeout_deadline().is_some()
));
drive_task_until(&runtime, &mut task, |task| {
matches!(
&task.targets[0].state,
TargetState::Active { client }
if client.next_probe_timeout_deadline().is_none()
)
});
assert_eq!(
timeout_losses_for(&observations, "one"),
TIMEOUT_WORK_BUDGET + 1
);
drop(handle.stop());
runtime.block_on(task);
server.finish();
}
#[test]
fn timeout_work_round_robins_simultaneous_target_backlogs() {
let first = start_server(ServerBehavior::Echo, None);
let second = start_server(ServerBehavior::Echo, None);
let mut managed = config(ManagedPacing::Staggered);
managed.completion = ManagedCompletionPolicy::ExplicitStop;
managed.client.duration = None;
let (mut task, handle) = ManagedClient::task(
managed,
vec![target("first", first.addr), target("second", second.addr)],
)
.unwrap();
let observations = Arc::new(Mutex::new(Vec::new()));
task.event_observations = Some(Arc::clone(&observations));
let runtime = runtime();
open_target_for_timeout_test(&runtime, &mut task, 0);
open_target_for_timeout_test(&runtime, &mut task, 1);
let now = Instant::now();
let per_target = TIMEOUT_WORK_BUDGET + 16;
for index in 0..2 {
for seq in 0..u32::try_from(per_target).unwrap() {
replace_pending_for_timeout_test(
&mut task,
index,
timeout_probe(
seq,
now - Duration::from_secs(2),
now - Duration::from_secs(1),
),
);
}
}
let mut task = Box::pin(task);
poll_task_once(&runtime, &mut task);
assert_eq!(
timeout_losses_for(&observations, "first"),
TIMEOUT_WORK_BUDGET / 2
);
assert_eq!(
timeout_losses_for(&observations, "second"),
TIMEOUT_WORK_BUDGET / 2
);
assert!(matches!(
&task.targets[0].state,
TargetState::Active { client }
if client.next_probe_timeout_deadline().is_some()
));
assert!(matches!(
&task.targets[1].state,
TargetState::Active { client }
if client.next_probe_timeout_deadline().is_some()
));
drive_task_until(&runtime, &mut task, |task| {
task.targets.iter().all(|target| {
matches!(
&target.state,
TargetState::Active { client }
if client.next_probe_timeout_deadline().is_none()
)
})
});
assert_eq!(timeout_losses_for(&observations, "first"), per_target);
assert_eq!(timeout_losses_for(&observations, "second"), per_target);
drop(handle.stop());
runtime.block_on(task);
first.finish();
second.finish();
}
#[test]
fn partial_timeout_batch_blocks_active_receive_until_loss_commits() {
let reply_gate = Arc::new(PacketGate::default());
let server = start_server_with_gates(
ServerBehavior::Echo,
None,
None,
None,
Some(Arc::clone(&reply_gate)),
);
let mut managed = config(ManagedPacing::Staggered);
managed.completion = ManagedCompletionPolicy::ExplicitStop;
managed.client.duration = None;
let (mut task, handle) =
ManagedClient::task(managed, vec![target("one", server.addr)]).unwrap();
let observations = Arc::new(Mutex::new(Vec::new()));
task.event_observations = Some(Arc::clone(&observations));
let runtime = runtime();
open_target_for_timeout_test(&runtime, &mut task, 0);
let sent = match &mut task.targets[0].state {
TargetState::Active { client } => runtime.block_on(client.send_probe()).unwrap(),
_ => panic!("timeout test target did not open"),
};
assert!(matches!(
sent.as_slice(),
[ClientEvent::EchoSent { seq: 0, .. }]
));
wait_flag(&server.probe_seen);
let now = Instant::now();
replace_pending_for_timeout_test(
&mut task,
0,
timeout_probe(0, now - Duration::from_secs(1), now),
);
for seq in 1..=u32::try_from(TIMEOUT_WORK_BUDGET).unwrap() {
replace_pending_for_timeout_test(
&mut task,
0,
timeout_probe(
seq,
now - Duration::from_secs(2),
now - Duration::from_secs(1),
),
);
}
reply_gate.release();
wait_flag(&server.reply_sent);
let mut task = Box::pin(task);
poll_task_once(&runtime, &mut task);
assert_eq!(
timeout_losses_for(&observations, "one"),
TIMEOUT_WORK_BUDGET
);
assert!(!client_events(&observations).iter().any(|event| {
matches!(
event,
ClientEvent::EchoLoss { seq: 0, .. }
| ClientEvent::LateReply { seq: 0, .. }
| ClientEvent::EchoReply { seq: 0, .. }
)
}));
drive_task_until(&runtime, &mut task, |_| {
client_events(&observations)
.iter()
.any(|event| matches!(event, ClientEvent::LateReply { seq: 0, .. }))
});
let classified = client_events(&observations)
.into_iter()
.filter(|event| {
matches!(
event,
ClientEvent::EchoLoss { seq: 0, .. }
| ClientEvent::LateReply { seq: 0, .. }
| ClientEvent::EchoReply { seq: 0, .. }
)
})
.collect::<Vec<_>>();
assert!(matches!(
classified.as_slice(),
[
ClientEvent::EchoLoss { seq: 0, .. },
ClientEvent::LateReply { seq: 0, .. }
]
));
drop(handle.stop());
runtime.block_on(task);
server.finish();
}
#[test]
fn partial_timeout_batch_blocks_draining_receive_until_loss_commits() {
let reply_gate = Arc::new(PacketGate::default());
let server = start_server_with_gates(
ServerBehavior::Echo,
None,
None,
None,
Some(Arc::clone(&reply_gate)),
);
let mut managed = config(ManagedPacing::Staggered);
managed.completion = ManagedCompletionPolicy::ExplicitStop;
managed.client.duration = None;
let (mut task, handle) =
ManagedClient::task(managed, vec![target("one", server.addr)]).unwrap();
let observations = Arc::new(Mutex::new(Vec::new()));
task.event_observations = Some(Arc::clone(&observations));
let runtime = runtime();
open_target_for_timeout_test(&runtime, &mut task, 0);
let sent = match &mut task.targets[0].state {
TargetState::Active { client } => runtime.block_on(client.send_probe()).unwrap(),
_ => panic!("timeout test target did not open"),
};
assert!(matches!(
sent.as_slice(),
[ClientEvent::EchoSent { seq: 0, .. }]
));
wait_flag(&server.probe_seen);
let now = Instant::now();
replace_pending_for_timeout_test(
&mut task,
0,
timeout_probe(0, now - Duration::from_secs(1), now),
);
for seq in 1..=u32::try_from(TIMEOUT_WORK_BUDGET).unwrap() {
replace_pending_for_timeout_test(
&mut task,
0,
timeout_probe(
seq,
now - Duration::from_secs(2),
now - Duration::from_secs(1),
),
);
}
let TargetState::Active { client } =
mem::replace(&mut task.targets[0].state, TargetState::Terminal)
else {
panic!("timeout test target did not remain active");
};
assert!(task.begin_drain(0, client, ManagedTargetEndReason::Stopped, now));
reply_gate.release();
wait_flag(&server.reply_sent);
let mut task = Box::pin(task);
poll_task_once(&runtime, &mut task);
assert_eq!(
timeout_losses_for(&observations, "one"),
TIMEOUT_WORK_BUDGET
);
assert!(matches!(
&task.targets[0].state,
TargetState::Draining {
primary_end: ManagedTargetEndReason::Stopped,
cleanup_failure: None,
..
}
));
assert!(!client_events(&observations).iter().any(|event| {
matches!(
event,
ClientEvent::EchoLoss { seq: 0, .. }
| ClientEvent::LateReply { seq: 0, .. }
| ClientEvent::EchoReply { seq: 0, .. }
)
}));
drive_task_until(&runtime, &mut task, |_| {
client_events(&observations)
.iter()
.any(|event| matches!(event, ClientEvent::LateReply { seq: 0, .. }))
});
let classified = client_events(&observations)
.into_iter()
.filter(|event| {
matches!(
event,
ClientEvent::EchoLoss { seq: 0, .. }
| ClientEvent::LateReply { seq: 0, .. }
| ClientEvent::EchoReply { seq: 0, .. }
)
})
.collect::<Vec<_>>();
assert!(matches!(
classified.as_slice(),
[
ClientEvent::EchoLoss { seq: 0, .. },
ClientEvent::LateReply { seq: 0, .. }
]
));
drop(handle.stop());
let outcome = runtime.block_on(task);
let target = &outcome.recent_target_outcomes[0];
assert!(matches!(target.end_reason, ManagedTargetEndReason::Stopped));
assert!(target.cleanup_failure.is_none());
server.finish();
}
#[test] #[test]
fn overdue_queued_reply_is_lost_then_late() { fn overdue_queued_reply_is_lost_then_late() {
let reply_gate = Arc::new(PacketGate::default()); let reply_gate = Arc::new(PacketGate::default());
+2
View File
@@ -116,10 +116,12 @@ impl PendingMap {
self.map.len() self.map.len()
} }
#[cfg(any(feature = "tokio", test))]
pub fn next_timeout_deadline(&self) -> Option<Instant> { pub fn next_timeout_deadline(&self) -> Option<Instant> {
self.deadlines.first().map(|key| key.timeout_at) self.deadlines.first().map(|key| key.timeout_at)
} }
#[cfg(any(feature = "tokio", test))]
pub fn latest_timeout_deadline(&self) -> Option<Instant> { pub fn latest_timeout_deadline(&self) -> Option<Instant> {
self.deadlines.last().map(|key| key.timeout_at) self.deadlines.last().map(|key| key.timeout_at)
} }
+15 -1
View File
@@ -414,7 +414,9 @@ impl SessionMachine {
&mut self, &mut self,
now: Instant, now: Instant,
) -> Result<Vec<ClientEvent>, ClientError> { ) -> Result<Vec<ClientEvent>, ClientError> {
Ok(self.poll_timeouts_bounded_at(now, usize::MAX)?.events) let batch = self.poll_timeouts_bounded_at(now, usize::MAX)?;
debug_assert!(!batch.more_due);
Ok(batch.events)
} }
pub(crate) fn poll_timeouts_bounded_at( pub(crate) fn poll_timeouts_bounded_at(
@@ -1036,6 +1038,18 @@ impl SessionMachine {
} }
} }
#[cfg(all(test, feature = "tokio"))]
impl SessionMachine {
pub(crate) fn replace_pending_for_test(&mut self, probe: PendingProbe) {
let session = self
.open_session_mut()
.expect("test pending probes require an open session");
session.pending.remove(probe.wire_seq);
session.pending.preflight_insert(probe.wire_seq).unwrap();
session.pending.commit_insert(probe);
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;