test: strengthen durable behavior coverage after review
This commit is contained in:
14 files changed
+1063
-85
No files matched your search
Generated
+1
@@ -718,6 +718,7 @@ name = "irtt-client"
|
||||
version = "0.5.3"
|
||||
dependencies = [
|
||||
"irtt-proto",
|
||||
"irtt-server",
|
||||
"nix 0.31.3",
|
||||
"socket2",
|
||||
"thiserror 2.0.21",
|
||||
|
||||
@@ -1,22 +1,49 @@
|
||||
#![cfg(all(feature = "client", feature = "server", feature = "tui"))]
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
#[path = "../../irtt-integration-tests/tests/support/in_tree_server.rs"]
|
||||
mod in_tree_server;
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
use irtt_server::ServerConfig;
|
||||
use std::process::Command;
|
||||
|
||||
// The actual binaries own dispatch, argument handling, exit policy and output.
|
||||
// A real authenticated server also detects broken argument-to-config mapping.
|
||||
// Dispatch/help/availability belong to the application and run in every build.
|
||||
#[test]
|
||||
fn applets_dispatch_validate_arguments_and_export_a_real_client_run() {
|
||||
fn dispatcher_reports_and_runs_the_enabled_applets() {
|
||||
let dispatcher = env!("CARGO_BIN_EXE_irtt-rs");
|
||||
for binary in [
|
||||
dispatcher,
|
||||
env!("CARGO_BIN_EXE_irtt-client"),
|
||||
env!("CARGO_BIN_EXE_irtt-server"),
|
||||
env!("CARGO_BIN_EXE_irtt-tui"),
|
||||
let help = Command::new(dispatcher).arg("--help").output().unwrap();
|
||||
assert!(help.status.success());
|
||||
let help = String::from_utf8(help.stdout).unwrap();
|
||||
for (applet, enabled) in [
|
||||
("client", cfg!(feature = "client")),
|
||||
("server", cfg!(feature = "server")),
|
||||
("tui", cfg!(feature = "tui")),
|
||||
] {
|
||||
let row = help
|
||||
.lines()
|
||||
.find(|line| line.trim_start().starts_with(applet))
|
||||
.unwrap();
|
||||
assert_eq!(row.contains("not available"), !enabled);
|
||||
let output = Command::new(dispatcher)
|
||||
.args([applet, "--help"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.success(), enabled, "{applet}");
|
||||
if !enabled {
|
||||
assert!(String::from_utf8(output.stderr)
|
||||
.unwrap()
|
||||
.contains("not available"));
|
||||
}
|
||||
}
|
||||
let binaries = [
|
||||
dispatcher,
|
||||
#[cfg(feature = "client")]
|
||||
env!("CARGO_BIN_EXE_irtt-client"),
|
||||
#[cfg(feature = "server")]
|
||||
env!("CARGO_BIN_EXE_irtt-server"),
|
||||
#[cfg(feature = "tui")]
|
||||
env!("CARGO_BIN_EXE_irtt-tui"),
|
||||
];
|
||||
for binary in binaries {
|
||||
assert!(Command::new(binary)
|
||||
.arg("--help")
|
||||
.output()
|
||||
@@ -30,6 +57,13 @@ fn applets_dispatch_validate_arguments_and_export_a_real_client_run() {
|
||||
.status
|
||||
.success());
|
||||
}
|
||||
}
|
||||
|
||||
// A real authenticated server detects broken client argument-to-config mapping.
|
||||
#[cfg(feature = "client")]
|
||||
#[test]
|
||||
fn client_arguments_export_a_real_client_run() {
|
||||
let dispatcher = env!("CARGO_BIN_EXE_irtt-rs");
|
||||
for option in ["--dscp", "--ttl"] {
|
||||
assert!(!Command::new(dispatcher)
|
||||
.args(["client", option, "256", "127.0.0.1:9"])
|
||||
|
||||
@@ -25,6 +25,9 @@ default = []
|
||||
ancillary = ["dep:nix"]
|
||||
tokio = ["dep:tokio"]
|
||||
|
||||
[dev-dependencies]
|
||||
irtt-server.workspace = true
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"]
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Real Linux socket observations through the public client adapters.
|
||||
#![cfg(all(target_os = "linux", feature = "ancillary"))]
|
||||
|
||||
use std::{
|
||||
io::IoSliceMut,
|
||||
net::{SocketAddr, UdpSocket},
|
||||
sync::mpsc,
|
||||
thread,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use irtt_client::{Client, ClientConfig, ClientEvent, NegotiationPolicy, SocketConfig};
|
||||
use irtt_proto::{decode_request, Clock, DecodedRequestKind, ReceivedStats, StampAt};
|
||||
use irtt_server::{ServerConfig, ServerCore};
|
||||
use nix::sys::socket::{recvmsg, ControlMessageOwned, MsgFlags};
|
||||
|
||||
// A compliant packet peer uses the real server core. Its receive-side IP_TOS
|
||||
// observation is independent of the client/server parameter agreement.
|
||||
fn peer(dscp_allowed: bool, probes: usize) -> (SocketAddr, thread::JoinHandle<Vec<u8>>) {
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
socket2::SockRef::from(&socket)
|
||||
.set_recv_tos_v4(true)
|
||||
.unwrap();
|
||||
let addr = socket.local_addr().unwrap();
|
||||
let task = thread::spawn(move || {
|
||||
let mut core = ServerCore::new(
|
||||
ServerConfig::default()
|
||||
.with_min_send_interval(Duration::ZERO)
|
||||
.with_dscp_allowed(dscp_allowed),
|
||||
);
|
||||
let mut markings = Vec::new();
|
||||
// OPEN, every ECHO, then CLOSE.
|
||||
for _ in 0..probes + 2 {
|
||||
let mut bytes = [0; 2048];
|
||||
let mut control = nix::cmsg_space!(u8);
|
||||
let mut iov = [IoSliceMut::new(&mut bytes)];
|
||||
let msg = recvmsg::<nix::sys::socket::SockaddrIn>(
|
||||
std::os::fd::AsRawFd::as_raw_fd(&socket),
|
||||
&mut iov,
|
||||
Some(&mut control),
|
||||
MsgFlags::empty(),
|
||||
)
|
||||
.unwrap();
|
||||
let size = msg.bytes;
|
||||
let source = msg.address.unwrap();
|
||||
let marking = msg
|
||||
.cmsgs()
|
||||
.unwrap()
|
||||
.find_map(|message| match message {
|
||||
ControlMessageOwned::Ipv4Tos(tos) => Some(tos),
|
||||
_ => None,
|
||||
})
|
||||
.expect("Linux must report IP_TOS");
|
||||
markings.push(marking);
|
||||
let source = SocketAddr::from((source.ip(), source.port()));
|
||||
if let Some(reply) = core.handle_datagram(source, &bytes[..size]).unwrap() {
|
||||
socket.send_to(reply.bytes(), source).unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(core.session_count(), 0);
|
||||
markings
|
||||
});
|
||||
(addr, task)
|
||||
}
|
||||
|
||||
fn config(addr: SocketAddr) -> ClientConfig {
|
||||
ClientConfig {
|
||||
server_addr: addr.to_string(),
|
||||
dscp: 46,
|
||||
negotiation_policy: NegotiationPolicy::Loose,
|
||||
duration: None,
|
||||
interval: Duration::from_millis(10),
|
||||
clock: Clock::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
received_stats: ReceivedStats::Both,
|
||||
length: 128,
|
||||
open_timeouts: vec![Duration::from_secs(1)],
|
||||
socket_config: SocketConfig {
|
||||
recv_timeout: Some(Duration::from_secs(1)),
|
||||
..SocketConfig::default()
|
||||
},
|
||||
..ClientConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
// TX timestamps have no separate public field: the upstream OWD exposes the
|
||||
// selected send endpoint. Reconstruct it and require a real kernel endpoint
|
||||
// rather than accepting the userspace fallback. RTT must still use sent_at.
|
||||
fn assert_kernel_send_endpoint(event: &ClientEvent, before_send: SystemTime) {
|
||||
let ClientEvent::EchoReply {
|
||||
sent_at,
|
||||
received_at,
|
||||
rtt,
|
||||
server_timing: Some(timing),
|
||||
one_way: Some(one_way),
|
||||
packet_meta,
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
panic!("expected measured reply: {event:?}")
|
||||
};
|
||||
let ns = |wall: SystemTime| {
|
||||
i128::try_from(wall.duration_since(UNIX_EPOCH).unwrap().as_nanos()).unwrap()
|
||||
};
|
||||
let selected =
|
||||
i128::from(timing.receive_wall_ns.unwrap()) - one_way.client_to_server.unwrap().as_nanos();
|
||||
assert!(selected >= ns(before_send));
|
||||
assert!(
|
||||
selected < ns(sent_at.wall),
|
||||
"TX timestamp was not observed; userspace fallback is insufficient"
|
||||
);
|
||||
assert!(selected <= ns(received_at.wall));
|
||||
assert!(
|
||||
packet_meta.kernel_rx_timestamp.is_some(),
|
||||
"Linux RX timestamp required"
|
||||
);
|
||||
assert_eq!(rtt.raw, received_at.mono.duration_since(sent_at.mono));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_probes_use_kernel_tx_timing_and_negotiated_packet_marking() {
|
||||
for allowed in [true, false] {
|
||||
const PROBES: usize = 3;
|
||||
let (addr, peer) = peer(allowed, PROBES);
|
||||
let mut client = Client::connect(config(addr)).unwrap();
|
||||
client.open().unwrap();
|
||||
assert_eq!(
|
||||
client.negotiated_params().unwrap().params.dscp,
|
||||
if allowed { 184 } else { 0 }
|
||||
);
|
||||
for _ in 0..PROBES {
|
||||
let before = SystemTime::now();
|
||||
client.send_probe().unwrap();
|
||||
let events = client.recv_once().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_kernel_send_endpoint(&events[0], before);
|
||||
}
|
||||
client.close().unwrap();
|
||||
let markings = peer.join().unwrap();
|
||||
assert_eq!(markings[0], 0, "OPEN is unmarked");
|
||||
assert_eq!(
|
||||
&markings[1..=PROBES],
|
||||
vec![if allowed { 184 } else { 0 }; PROBES]
|
||||
);
|
||||
assert_eq!(
|
||||
markings[PROBES + 1],
|
||||
0,
|
||||
"local CLOSE clears the negotiated marking"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn async_probes_use_kernel_tx_timing_and_negotiated_packet_marking() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
for allowed in [true, false] {
|
||||
const PROBES: usize = 3;
|
||||
let (addr, peer) = peer(allowed, PROBES);
|
||||
runtime.block_on(async {
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
let mut client = irtt_client::AsyncClient::connect(config(addr))
|
||||
.await
|
||||
.unwrap();
|
||||
client.open().await.unwrap();
|
||||
assert_eq!(
|
||||
client.negotiated_params().unwrap().params.dscp,
|
||||
if allowed { 184 } else { 0 }
|
||||
);
|
||||
for _ in 0..PROBES {
|
||||
let before = SystemTime::now();
|
||||
client.send_probe().await.unwrap();
|
||||
let events = client.recv().await.unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_kernel_send_endpoint(&events[0], before);
|
||||
}
|
||||
client.close().await.unwrap();
|
||||
})
|
||||
.await
|
||||
.expect("async transport observations timed out");
|
||||
});
|
||||
let markings = peer.join().unwrap();
|
||||
assert_eq!(markings[0], 0);
|
||||
assert_eq!(
|
||||
&markings[1..=PROBES],
|
||||
vec![if allowed { 184 } else { 0 }; PROBES]
|
||||
);
|
||||
assert_eq!(markings[PROBES + 1], 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Real ICMP refusal remains a socket error, never optional timestamp metadata.
|
||||
// The bound peer closes only after the client has accepted a valid OPEN.
|
||||
#[test]
|
||||
fn a_closed_udp_peer_surfaces_a_socket_error() {
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let addr = socket.local_addr().unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
let (close_tx, close_rx) = mpsc::channel();
|
||||
let task = thread::spawn(move || {
|
||||
let mut core = ServerCore::new(ServerConfig::default());
|
||||
let mut bytes = [0; 2048];
|
||||
let (size, source) = socket.recv_from(&mut bytes).unwrap();
|
||||
assert!(matches!(
|
||||
decode_request(&bytes[..size]).unwrap().kind,
|
||||
DecodedRequestKind::Open { .. }
|
||||
));
|
||||
let reply = core
|
||||
.handle_datagram(source, &bytes[..size])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
socket.send_to(reply.bytes(), source).unwrap();
|
||||
close_rx.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
drop(socket);
|
||||
});
|
||||
let mut client = Client::connect(config(addr)).unwrap();
|
||||
client.open().unwrap();
|
||||
close_tx.send(()).unwrap();
|
||||
task.join().unwrap();
|
||||
// Refusal can arrive during send's TX drain or the following receive.
|
||||
let result = client.send_probe().and_then(|_| client.recv_once());
|
||||
assert!(
|
||||
matches!(result, Err(irtt_client::ClientError::Socket(ref error))
|
||||
if error.kind() == std::io::ErrorKind::ConnectionRefused),
|
||||
"{result:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
#![cfg(feature = "tokio")]
|
||||
|
||||
use std::{
|
||||
future::Future,
|
||||
pin::pin,
|
||||
task::{Context, Poll, Waker},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use irtt_client::managed::*;
|
||||
|
||||
fn config() -> ManagedClientConfig {
|
||||
ManagedClientConfig {
|
||||
completion: ManagedCompletionPolicy::ExplicitStop,
|
||||
command_capacity: 3,
|
||||
..ManagedClientConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_resolves_every_accepted_update_and_closes_admission() {
|
||||
let (task, handle) = ManagedClient::task(config(), vec![]).unwrap();
|
||||
let receipts: Vec<_> = (0..3)
|
||||
.map(|index| {
|
||||
handle
|
||||
.update_targets(vec![ManagedTargetConfig::new(
|
||||
format!("queued-{index}"),
|
||||
"127.0.0.1:9",
|
||||
)])
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
assert!(matches!(
|
||||
handle.update_targets(vec![]),
|
||||
Err(ManagedCommandError::QueueFull)
|
||||
));
|
||||
|
||||
let stop = handle.stop();
|
||||
assert!(matches!(
|
||||
handle.update_targets(vec![]),
|
||||
Err(ManagedCommandError::Stopping)
|
||||
));
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
runtime.block_on(async {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
let outcome = task.await;
|
||||
stop.await;
|
||||
for receipt in receipts {
|
||||
assert!(matches!(
|
||||
receipt.await,
|
||||
Err(ManagedCommandApplyError::Stopping)
|
||||
));
|
||||
}
|
||||
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
|
||||
assert_eq!(outcome.applied_command_sequence, 0);
|
||||
assert_eq!(outcome.total_target_outcomes, 0);
|
||||
let status = handle.status();
|
||||
assert_eq!(status.lifecycle, ManagedLifecycle::Completed);
|
||||
assert!(status.stop_requested);
|
||||
assert_eq!(status.final_outcome.as_deref(), Some(&outcome));
|
||||
assert!(matches!(
|
||||
handle.update_targets(vec![]),
|
||||
Err(ManagedCommandError::DriverClosed)
|
||||
));
|
||||
handle.stop().await;
|
||||
})
|
||||
.await
|
||||
.expect("stop must resolve every buffered update and repeated stop receipt");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_driver_resolves_every_accepted_update_with_its_terminal_failure() {
|
||||
let (task, handle) = ManagedClient::task(config(), vec![]).unwrap();
|
||||
let receipts: Vec<_> = (0..3)
|
||||
.map(|index| {
|
||||
handle
|
||||
.update_targets(vec![ManagedTargetConfig::new(
|
||||
format!("queued-{index}"),
|
||||
"127.0.0.1:9",
|
||||
)])
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
let mut context = Context::from_waker(Waker::noop());
|
||||
let Poll::Ready(outcome) = pin!(task).as_mut().poll(&mut context) else {
|
||||
panic!("polling without Tokio must fail immediately");
|
||||
};
|
||||
assert_eq!(
|
||||
outcome.end_reason,
|
||||
ManagedEndReason::DriverFailed(ManagedDriverFailure::NoTokioRuntime)
|
||||
);
|
||||
for receipt in receipts {
|
||||
assert!(matches!(
|
||||
pin!(receipt).as_mut().poll(&mut context),
|
||||
Poll::Ready(Err(ManagedCommandApplyError::DriverFailed(
|
||||
ManagedDriverFailure::NoTokioRuntime
|
||||
)))
|
||||
));
|
||||
}
|
||||
let status = handle.status();
|
||||
assert_eq!(status.lifecycle, ManagedLifecycle::Failed);
|
||||
assert_eq!(status.final_outcome.as_deref(), Some(&outcome));
|
||||
assert!(matches!(
|
||||
handle.update_targets(vec![]),
|
||||
Err(ManagedCommandError::DriverClosed)
|
||||
));
|
||||
assert!(pin!(handle.stop()).as_mut().poll(&mut context).is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_submission_and_stop_or_seal_never_lose_an_accepted_receipt() {
|
||||
let (task, handle) = ManagedClient::task(config(), vec![]).unwrap();
|
||||
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
|
||||
let submit_barrier = std::sync::Arc::clone(&barrier);
|
||||
let submit_handle = handle.clone();
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let submit = std::thread::spawn(move || {
|
||||
submit_barrier.wait();
|
||||
sender.send(submit_handle.update_targets(vec![])).unwrap();
|
||||
});
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
// Stop is latched before the driver starts, while submission may cross
|
||||
// admission before stop, after stop, or after terminal sealing.
|
||||
barrier.wait();
|
||||
let stop = handle.stop();
|
||||
runtime.block_on(async {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
let outcome = task.await;
|
||||
stop.await;
|
||||
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
|
||||
assert_eq!(outcome.applied_command_sequence, 0);
|
||||
assert_eq!(outcome.total_target_outcomes, 0);
|
||||
let result = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("concurrent submission must return");
|
||||
submit.join().unwrap();
|
||||
match result {
|
||||
Ok(receipt) => assert!(matches!(
|
||||
receipt.await,
|
||||
Err(ManagedCommandApplyError::Stopping)
|
||||
)),
|
||||
Err(ManagedCommandError::Stopping | ManagedCommandError::DriverClosed) => {}
|
||||
Err(error) => panic!("unexpected concurrent submission result: {error:?}"),
|
||||
}
|
||||
assert!(matches!(
|
||||
handle.update_targets(vec![]),
|
||||
Err(ManagedCommandError::DriverClosed)
|
||||
));
|
||||
})
|
||||
.await
|
||||
.expect("every accepted concurrent submission must receive a terminal disposition");
|
||||
});
|
||||
}
|
||||
@@ -381,7 +381,7 @@ fn recv_after_local_close_and_no_test_fails_on_first_poll() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_async_lifecycle_applies_negotiated_dscp() {
|
||||
fn authenticated_async_lifecycle_negotiates_dscp() {
|
||||
let key = b"async-lifecycle-key".to_vec();
|
||||
let server = InTreeServer::start(ServerConfig::default().with_hmac_key(key.clone()));
|
||||
runtime().block_on(async {
|
||||
|
||||
@@ -250,53 +250,6 @@ fn explicit_empty_waits_for_stop() {
|
||||
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_update_before_stop_resolves_stopping() {
|
||||
let mut managed = config(ManagedPacing::Staggered);
|
||||
managed.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||
let (task, handle) = ManagedClient::task(managed, vec![]).unwrap();
|
||||
let receipt = handle
|
||||
.update_targets(vec![ManagedTargetConfig::new("queued", "127.0.0.1:9")])
|
||||
.unwrap();
|
||||
let stop = handle.stop();
|
||||
let outcome = runtime().block_on(task);
|
||||
runtime().block_on(stop);
|
||||
assert_eq!(outcome.applied_command_sequence, 0);
|
||||
assert!(matches!(
|
||||
runtime().block_on(receipt),
|
||||
Err(ManagedCommandApplyError::Stopping)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_update_before_driver_failure_resolves_driver_failed() {
|
||||
let mut managed = config(ManagedPacing::Staggered);
|
||||
managed.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||
let (task, handle) = ManagedClient::task(managed, vec![]).unwrap();
|
||||
let receipt = handle
|
||||
.update_targets(vec![ManagedTargetConfig::new("queued", "127.0.0.1:9")])
|
||||
.unwrap();
|
||||
let mut task = Box::pin(task);
|
||||
let Poll::Ready(outcome) = poll_once(task.as_mut()) else {
|
||||
panic!("first poll outside Tokio did not fail")
|
||||
};
|
||||
assert_eq!(
|
||||
outcome.end_reason,
|
||||
ManagedEndReason::DriverFailed(ManagedDriverFailure::NoTokioRuntime)
|
||||
);
|
||||
let mut receipt = Box::pin(receipt);
|
||||
assert!(matches!(
|
||||
poll_once(Pin::as_mut(&mut receipt)),
|
||||
Poll::Ready(Err(ManagedCommandApplyError::DriverFailed(
|
||||
ManagedDriverFailure::NoTokioRuntime
|
||||
)))
|
||||
));
|
||||
assert!(matches!(
|
||||
handle.update_targets(vec![]),
|
||||
Err(ManagedCommandError::DriverClosed)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_event_capacity_is_rejected_without_panic() {
|
||||
let mut managed = config(ManagedPacing::Staggered);
|
||||
|
||||
@@ -333,9 +333,9 @@ effective parameters must be safe for the server to run.
|
||||
- The transition is pure and is committed only after the reply has encoded, so
|
||||
an internal encoding failure cannot leave a session claiming to have answered
|
||||
a request it never did.
|
||||
- Clock sampling is a private injected seam, like token generation, not a
|
||||
runtime abstraction: the receive instant is taken as soon as a datagram is
|
||||
classified as an echo and the send instant just before the reply is built, so
|
||||
- Clock sampling uses the system clock directly, not a runtime abstraction: the
|
||||
receive instant is taken as soon as a datagram is classified as an echo and
|
||||
the send instant just before the reply is built, so
|
||||
they bracket the server's own handling. The monotonic origin belongs to the
|
||||
clock source and is stable for its life.
|
||||
- A reply's receive instant is held back to its send instant where the wall
|
||||
@@ -601,16 +601,22 @@ half-created session.
|
||||
maintain shadow encoders for well-formed wire data.
|
||||
- Hand-build packets only where the point is a payload a compliant encoder
|
||||
cannot produce — truncated varints, out-of-range enums, corrupted MACs.
|
||||
- Token generation and clock sampling are the only nondeterministic parts; tests
|
||||
inject a scripted source for each, so identity, collisions, allocation failure
|
||||
and timestamp values are all assertable. Keep both seams private, and keep
|
||||
timestamp, rate and lifetime tests free of sleeps, tolerances and real
|
||||
wall-clock assertions.
|
||||
- Two clock fakes, for two different questions. A scripted clock returns a fixed
|
||||
list of samples and is for tests that assert individual timestamp *readings*; a
|
||||
hand-moved clock stands still until the test moves it, and is for rate and
|
||||
lifetime tests, which care when a datagram arrived and not how many times the
|
||||
core read the clock on the way. Prefer the latter for anything about deadlines.
|
||||
- Prefer the public `ServerCore` packet interface and real runtime sockets for
|
||||
durable behavior. Use production encoders and decoders, actual OS-generated
|
||||
tokens, and observable replies/session capacity rather than private state.
|
||||
- Observe nonzero rate refill and lifetime deadlines with real time when an
|
||||
oversleep cannot invalidate the assertion. Bound observation loops and report
|
||||
useful failures. Do not restore fake clocks, random-source injection, race
|
||||
gates, synthetic ancillary classifiers or private observability merely to
|
||||
reproduce the deleted test structure. Focused fault injection remains allowed
|
||||
under the root policy only when no reasonable production-interface alternative
|
||||
can protect an important invariant.
|
||||
- Random fill's OS-source failure cannot be forced through the packet interface.
|
||||
Its zero-fill fallback remains an explicitly unexercised error path; ordinary
|
||||
random-fill wire tests must not claim to cover it or add an injection seam
|
||||
merely for coverage. Existing token-allocation fault tests protect bounded
|
||||
allocation and transactional failure, which cannot be driven reliably with
|
||||
actual random draws.
|
||||
- Assert rate and lifetime behavior from replies: which datagrams came back, the
|
||||
count and window they reported, and `session_count`. The token bucket is a
|
||||
black-box inference about the reference server, not a protocol requirement, so
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
//! Durable server contracts observed through encoded requests and public replies.
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use irtt_proto::{
|
||||
decode_echo_reply, decode_open_reply, encode_request, Clock, EchoReply, OpenReply, Params,
|
||||
ReceivedStats, RequestToEncode, StampAt, FLAG_CLOSE,
|
||||
};
|
||||
use irtt_server::{ServerConfig, ServerCore, TimestampAllowance};
|
||||
|
||||
fn peer() -> SocketAddr {
|
||||
"127.0.0.1:40000".parse().unwrap()
|
||||
}
|
||||
fn open_packet(params: &Params, no_test: bool, key: Option<&[u8]>) -> Vec<u8> {
|
||||
encode_request(RequestToEncode::Open { params, no_test }, key).unwrap()
|
||||
}
|
||||
fn open(server: &mut ServerCore, params: &Params) -> OpenReply {
|
||||
let reply = server
|
||||
.handle_datagram(peer(), &open_packet(params, false, None))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
decode_open_reply(reply.bytes(), None).unwrap()
|
||||
}
|
||||
fn echo(server: &mut ServerCore, opened: &OpenReply, sequence: u32) -> Option<EchoReply> {
|
||||
let request = encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token: opened.token,
|
||||
sequence,
|
||||
params: &opened.params,
|
||||
payload: &[],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
server
|
||||
.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.map(|reply| decode_echo_reply(reply.bytes(), &opened.params, None).unwrap())
|
||||
}
|
||||
fn unthrottled() -> ServerConfig {
|
||||
ServerConfig::default().with_min_send_interval(Duration::ZERO)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_test_authenticates_negotiates_and_uses_no_session_capacity() {
|
||||
let key = b"no-test-contract";
|
||||
let mut server = ServerCore::new(
|
||||
ServerConfig::default()
|
||||
.with_hmac_key(key)
|
||||
.with_max_sessions(0)
|
||||
.with_max_packet_length(96)
|
||||
.with_min_send_interval(Duration::from_millis(50))
|
||||
.with_timestamp_allowance(TimestampAllowance::Single)
|
||||
.with_dscp_allowed(false),
|
||||
);
|
||||
let params = Params {
|
||||
protocol_version: 99,
|
||||
length: 128,
|
||||
dscp: 185,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
..Params::default()
|
||||
};
|
||||
let request = open_packet(¶ms, true, Some(key));
|
||||
assert!(server
|
||||
.handle_datagram(peer(), &open_packet(¶ms, true, None))
|
||||
.unwrap()
|
||||
.is_none());
|
||||
let mut bad_mac = request.clone();
|
||||
bad_mac[4] ^= 1;
|
||||
assert!(server.handle_datagram(peer(), &bad_mac).unwrap().is_none());
|
||||
let reply = server.handle_datagram(peer(), &request).unwrap().unwrap();
|
||||
assert_eq!(reply.traffic_class(), 0);
|
||||
let negotiated = decode_open_reply(reply.bytes(), Some(key)).unwrap();
|
||||
assert_eq!(negotiated.token, 0);
|
||||
assert_eq!(negotiated.params.protocol_version, 1);
|
||||
assert_ne!(negotiated.flags & FLAG_CLOSE, 0);
|
||||
assert_eq!(negotiated.params.length, 96);
|
||||
assert_eq!(negotiated.params.interval_ns, 50_000_000);
|
||||
assert_eq!(negotiated.params.stamp_at, StampAt::Midpoint);
|
||||
assert_eq!(negotiated.params.clock, Clock::Both);
|
||||
assert_eq!(negotiated.params.dscp, 0);
|
||||
assert_eq!(server.session_count(), 0);
|
||||
assert!(server
|
||||
.handle_datagram(peer(), &open_packet(¶ms, false, Some(key)))
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_test_refuses_unexecutable_layout_but_validates_after_restriction() {
|
||||
let params = Params {
|
||||
stamp_at: StampAt::Both,
|
||||
..Params::with_protocol_defaults()
|
||||
};
|
||||
let mut server = ServerCore::new(unthrottled());
|
||||
assert!(server
|
||||
.handle_datagram(peer(), &open_packet(¶ms, true, None))
|
||||
.unwrap()
|
||||
.is_none());
|
||||
let mut restricted =
|
||||
ServerCore::new(unthrottled().with_timestamp_allowance(TimestampAllowance::None));
|
||||
let reply = restricted
|
||||
.handle_datagram(peer(), &open_packet(¶ms, true, None))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
decode_open_reply(reply.bytes(), None)
|
||||
.unwrap()
|
||||
.params
|
||||
.stamp_at,
|
||||
StampAt::None
|
||||
);
|
||||
let rich = Params {
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
..Params::with_protocol_defaults()
|
||||
};
|
||||
let mut too_small = ServerCore::new(unthrottled().with_max_packet_length(32));
|
||||
assert!(too_small
|
||||
.handle_datagram(peer(), &open_packet(&rich, true, None))
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(too_small.session_count(), 0);
|
||||
let invalid = Params {
|
||||
interval_ns: -1,
|
||||
..Params::with_protocol_defaults()
|
||||
};
|
||||
assert!(server
|
||||
.handle_datagram(peer(), &open_packet(&invalid, true, None))
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(server.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receive_statistics_follow_verified_gap_duplicate_reordering_and_wrap_vectors() {
|
||||
// SERVER_BEHAVIORAL_VECTORS.md Sections 1.2, 1.5, 1.6, 1.8 and 1.10.
|
||||
let vectors: &[&[(u32, u64)]] = &[
|
||||
&[(0, 1), (1, 3), (3, 13), (4, 27)],
|
||||
&[(0, 1), (1, 3), (1, 3), (2, 7)],
|
||||
&[(0, 1), (1, 3), (2, 7), (1, 1), (3, 5)],
|
||||
&[(0, 1), (63, 0x8000_0000_0000_0001)],
|
||||
&[(0, 1), (64, 1)],
|
||||
&[
|
||||
(u32::MAX - 2, 1),
|
||||
(u32::MAX - 1, 3),
|
||||
(u32::MAX, 7),
|
||||
(0, 15),
|
||||
(1, 31),
|
||||
],
|
||||
];
|
||||
for vector in vectors {
|
||||
let mut server = ServerCore::new(unthrottled());
|
||||
let opened = open(
|
||||
&mut server,
|
||||
&Params {
|
||||
received_stats: ReceivedStats::Both,
|
||||
..Params::with_protocol_defaults()
|
||||
},
|
||||
);
|
||||
for (index, &(sequence, window)) in vector.iter().enumerate() {
|
||||
let reply = echo(&mut server, &opened, sequence).unwrap();
|
||||
assert_eq!(reply.recv_count, Some(index as u32 + 1));
|
||||
assert_eq!(
|
||||
reply.recv_window,
|
||||
Some(window),
|
||||
"sequence {sequence} in {vector:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spent_nonzero_burst_refills_without_counting_dropped_echoes() {
|
||||
let refill = Duration::from_millis(100);
|
||||
let mut server = ServerCore::new(
|
||||
ServerConfig::default()
|
||||
.with_min_send_interval(refill)
|
||||
.with_burst_allowance(1)
|
||||
.with_idle_timeout(Duration::MAX),
|
||||
);
|
||||
let opened = open(
|
||||
&mut server,
|
||||
&Params {
|
||||
received_stats: ReceivedStats::Both,
|
||||
..Params::with_protocol_defaults()
|
||||
},
|
||||
);
|
||||
let mut count = echo(&mut server, &opened, 0).unwrap().recv_count.unwrap();
|
||||
// Observe actual refusal rather than assuming two calls fit a timing window.
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while let Some(reply) = echo(&mut server, &opened, 0) {
|
||||
count = reply.recv_count.unwrap();
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"could not observe a spent burst within two seconds"
|
||||
);
|
||||
}
|
||||
thread::sleep(refill); // Oversleep only replenishes more; idle expiry is disabled in practice.
|
||||
let reply = echo(&mut server, &opened, 1).unwrap();
|
||||
assert_eq!(reply.recv_count, Some(count + 1));
|
||||
assert_eq!(reply.recv_window, Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nonzero_idle_deadline_reclaims_an_unused_session_before_another_open() {
|
||||
let idle = Duration::from_millis(10);
|
||||
let mut server = ServerCore::new(unthrottled().with_max_sessions(1).with_idle_timeout(idle));
|
||||
let params = Params::with_protocol_defaults();
|
||||
let old = open(&mut server, ¶ms);
|
||||
thread::sleep(idle); // Any oversleep still requires silent expiry and capacity recovery.
|
||||
assert!(echo(&mut server, &old, 0).is_none());
|
||||
assert_eq!(server.session_count(), 0);
|
||||
open(&mut server, ¶ms);
|
||||
assert_eq!(server.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_duration_starts_at_the_first_served_echo_and_closes_with_its_reply() {
|
||||
let maximum = Duration::from_millis(1);
|
||||
let mut server = ServerCore::new(
|
||||
unthrottled()
|
||||
.with_idle_timeout(Duration::MAX)
|
||||
.with_max_test_duration(maximum),
|
||||
);
|
||||
let opened = open(
|
||||
&mut server,
|
||||
&Params {
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
dscp: 185,
|
||||
..Params::with_protocol_defaults()
|
||||
},
|
||||
);
|
||||
assert_eq!(opened.params.duration_ns, 1_000_000);
|
||||
let elapsed_deadline = maximum + Duration::from_secs(2);
|
||||
thread::sleep(elapsed_deadline);
|
||||
let first = echo(&mut server, &opened, 0).unwrap();
|
||||
assert_eq!(
|
||||
first.flags & FLAG_CLOSE,
|
||||
0,
|
||||
"OPEN must not start the maximum-duration clock"
|
||||
);
|
||||
thread::sleep(elapsed_deadline); // Oversleep still requires the next served echo to close.
|
||||
let request = encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token: opened.token,
|
||||
sequence: 1,
|
||||
params: &opened.params,
|
||||
payload: &[],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let reply = server.handle_datagram(peer(), &request).unwrap().unwrap();
|
||||
assert_eq!(reply.traffic_class(), 185);
|
||||
let closed = decode_echo_reply(reply.bytes(), &opened.params, None).unwrap();
|
||||
assert_ne!(closed.flags & FLAG_CLOSE, 0);
|
||||
assert_eq!(closed.sequence, 1);
|
||||
assert_eq!(closed.recv_count, Some(2));
|
||||
assert_eq!(closed.recv_window, Some(3));
|
||||
assert!(closed.timestamps.recv_wall.is_some());
|
||||
assert!(closed.timestamps.send_mono.is_some());
|
||||
assert_eq!(server.session_count(), 0);
|
||||
assert!(echo(&mut server, &opened, 2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negotiated_timestamp_modes_emit_only_the_selected_fields_in_order() {
|
||||
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
|
||||
for placement in [
|
||||
StampAt::None,
|
||||
StampAt::Receive,
|
||||
StampAt::Send,
|
||||
StampAt::Both,
|
||||
StampAt::Midpoint,
|
||||
] {
|
||||
let mut server = ServerCore::new(unthrottled());
|
||||
let opened = open(
|
||||
&mut server,
|
||||
&Params {
|
||||
stamp_at: placement,
|
||||
clock,
|
||||
..Params::with_protocol_defaults()
|
||||
},
|
||||
);
|
||||
let t = echo(&mut server, &opened, 0).unwrap().timestamps;
|
||||
let wall = clock != Clock::Monotonic;
|
||||
let mono = clock != Clock::Wall;
|
||||
let recv = matches!(placement, StampAt::Receive | StampAt::Both);
|
||||
let send = matches!(placement, StampAt::Send | StampAt::Both);
|
||||
let midpoint = placement == StampAt::Midpoint;
|
||||
assert_eq!(t.recv_wall.is_some(), wall && recv);
|
||||
assert_eq!(t.recv_mono.is_some(), mono && recv);
|
||||
assert_eq!(t.send_wall.is_some(), wall && send);
|
||||
assert_eq!(t.send_mono.is_some(), mono && send);
|
||||
assert_eq!(t.midpoint_wall.is_some(), wall && midpoint);
|
||||
assert_eq!(t.midpoint_mono.is_some(), mono && midpoint);
|
||||
if let (Some(recv), Some(send)) = (t.recv_wall, t.send_wall) {
|
||||
assert!(recv <= send);
|
||||
}
|
||||
if let (Some(recv), Some(send)) = (t.recv_mono, t.send_mono) {
|
||||
assert!(0 <= recv && recv <= send);
|
||||
let next = echo(&mut server, &opened, 1).unwrap().timestamps;
|
||||
assert!(
|
||||
next.recv_mono.unwrap() >= send,
|
||||
"monotonic origin must remain stable"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,3 +145,13 @@ async fn exercise_bound_server(mut server: Server) {
|
||||
fn unthrottled() -> ServerConfig {
|
||||
ServerConfig::default().with_min_send_interval(Duration::ZERO)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "freebsd")))]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn wildcard_construction_is_refused_without_reply_source_selection() {
|
||||
let addr = "0.0.0.0:0".parse().unwrap();
|
||||
assert!(matches!(
|
||||
Server::bind(addr, unthrottled()).await,
|
||||
Err(ServerRuntimeError::WildcardSourceSelectionUnsupported { .. })
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Observe the emitted IP byte, independently of protocol negotiation.
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use std::{io::IoSliceMut, net::UdpSocket as StdSocket, os::fd::AsRawFd, time::Duration};
|
||||
|
||||
use irtt_proto::{
|
||||
decode_echo_reply, decode_open_reply, encode_request, OpenReply, Params, RequestToEncode,
|
||||
FLAG_CLOSE,
|
||||
};
|
||||
use irtt_server::{Server, ServerConfig};
|
||||
use nix::sys::socket::{recvmsg, ControlMessageOwned, MsgFlags};
|
||||
use tokio::{net::UdpSocket, sync::oneshot, time::timeout};
|
||||
|
||||
async fn exchange(client: &UdpSocket, request: &[u8], expected: u8) -> Vec<u8> {
|
||||
client.send(request).await.unwrap();
|
||||
loop {
|
||||
client.readable().await.unwrap();
|
||||
let packet = client.try_io(tokio::io::Interest::READABLE, || {
|
||||
let mut bytes = [0; 2048];
|
||||
let mut control = nix::cmsg_space!(u8);
|
||||
let mut iov = [IoSliceMut::new(&mut bytes)];
|
||||
let msg = recvmsg::<()>(
|
||||
client.as_raw_fd(),
|
||||
&mut iov,
|
||||
Some(&mut control),
|
||||
MsgFlags::MSG_DONTWAIT,
|
||||
)?;
|
||||
let size = msg.bytes;
|
||||
let class = msg.cmsgs().unwrap().find_map(|message| match message {
|
||||
ControlMessageOwned::Ipv4Tos(class) => Some(class),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(class, Some(expected));
|
||||
Ok(bytes[..size].to_vec())
|
||||
});
|
||||
match packet {
|
||||
Ok(packet) => return packet,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||
Err(error) => panic!("receive failed: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn echo(opened: &OpenReply, sequence: u32) -> Vec<u8> {
|
||||
encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token: opened.token,
|
||||
sequence,
|
||||
params: &opened.params,
|
||||
payload: &[],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn every_open_echo_and_server_close_applies_its_own_raw_traffic_class() {
|
||||
timeout(Duration::from_secs(8), async {
|
||||
let maximum = Duration::from_millis(1);
|
||||
let mut server = Server::bind(
|
||||
"127.0.0.1:0".parse().unwrap(),
|
||||
ServerConfig::default()
|
||||
.with_min_send_interval(Duration::ZERO)
|
||||
.with_idle_timeout(Duration::MAX)
|
||||
.with_max_test_duration(maximum),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let addr = server.local_addr().unwrap();
|
||||
let (shutdown, stopped) = oneshot::channel();
|
||||
let task = tokio::spawn(async move {
|
||||
server
|
||||
.run(async {
|
||||
let _ = stopped.await;
|
||||
})
|
||||
.await
|
||||
});
|
||||
let socket = StdSocket::bind("127.0.0.1:0").unwrap();
|
||||
socket2::SockRef::from(&socket)
|
||||
.set_recv_tos_v4(true)
|
||||
.unwrap();
|
||||
socket.set_nonblocking(true).unwrap();
|
||||
let client = UdpSocket::from_std(socket).unwrap();
|
||||
client.connect(addr).await.unwrap();
|
||||
let mut sessions = Vec::new();
|
||||
// Each new OPEN follows a prior ECHO. The first raw byte also includes
|
||||
// ECN bits: neither a codepoint shift nor masking would preserve it.
|
||||
for (requested, class) in [(185, 185), (32, 32), (0, 0), (256, 0), (-1, 0)] {
|
||||
let params = Params {
|
||||
dscp: requested,
|
||||
..Params::with_protocol_defaults()
|
||||
};
|
||||
let request = encode_request(
|
||||
RequestToEncode::Open {
|
||||
params: ¶ms,
|
||||
no_test: false,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let packet = exchange(&client, &request, 0).await;
|
||||
let opened = decode_open_reply(&packet, None).unwrap();
|
||||
assert_eq!(opened.params.dscp, requested);
|
||||
let packet = exchange(&client, &echo(&opened, 0), class).await;
|
||||
assert_eq!(
|
||||
decode_echo_reply(&packet, &opened.params, None)
|
||||
.unwrap()
|
||||
.flags
|
||||
& FLAG_CLOSE,
|
||||
0
|
||||
);
|
||||
sessions.push((opened, class));
|
||||
}
|
||||
// Every session has now served its first echo. Waiting past all duration
|
||||
// deadlines is safe under oversleep; idle expiry cannot intervene.
|
||||
tokio::time::sleep(maximum + Duration::from_secs(2)).await;
|
||||
for (opened, class) in sessions {
|
||||
let packet = exchange(&client, &echo(&opened, 1), class).await;
|
||||
assert_ne!(
|
||||
decode_echo_reply(&packet, &opened.params, None)
|
||||
.unwrap()
|
||||
.flags
|
||||
& FLAG_CLOSE,
|
||||
0
|
||||
);
|
||||
}
|
||||
shutdown.send(()).unwrap();
|
||||
task.await.unwrap().unwrap();
|
||||
})
|
||||
.await
|
||||
.expect("marked runtime exchanges did not complete");
|
||||
}
|
||||
@@ -199,10 +199,23 @@ mod tests {
|
||||
) {
|
||||
let mut tracker = IpdvTracker::new(Some(limit));
|
||||
for seq in seqs {
|
||||
tracker.insert(ipdv_sample(seq, i128::from(seq)));
|
||||
let completed_before = tracker.completed_pairs.clone();
|
||||
let pairs = tracker.insert(ipdv_sample(seq, i128::from(seq)));
|
||||
let emitted: HashSet<_> = pairs.iter().map(|pair| pair.current_seq).collect();
|
||||
prop_assert_eq!(emitted.len(), pairs.len());
|
||||
prop_assert!(emitted.is_disjoint(&completed_before), "a retained pair must not be emitted twice");
|
||||
prop_assert!(tracker.samples.len() <= limit);
|
||||
prop_assert!(tracker.sample_order.len() <= limit);
|
||||
prop_assert!(tracker.completed_pairs.len() <= limit);
|
||||
// Eviction must remove stale pair markers as well as samples;
|
||||
// otherwise retained state can prevent future adjacent pairs.
|
||||
prop_assert_eq!(tracker.sample_order.len(), tracker.samples.len());
|
||||
prop_assert_eq!(tracker.sample_order.iter().collect::<HashSet<_>>().len(), tracker.samples.len());
|
||||
prop_assert!(tracker.sample_order.iter().all(|seq| tracker.samples.contains_key(seq)));
|
||||
prop_assert!(tracker.completed_pairs.iter().all(|seq| {
|
||||
tracker.samples.contains_key(seq)
|
||||
&& tracker.samples.contains_key(&seq.wrapping_sub(1))
|
||||
}), "completed pairs must retain both sequence endpoints");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||
|
||||
use irtt_client::{
|
||||
ClientEvent, ClientTimestamp, PacketMeta, RttSample, ServerTiming, SignedDuration,
|
||||
};
|
||||
use irtt_stats::{StatsCollector, StatsConfig};
|
||||
|
||||
fn reply(
|
||||
base: Instant,
|
||||
seq: u32,
|
||||
send_ms: u64,
|
||||
receive_ms: u64,
|
||||
timing: ServerTiming,
|
||||
) -> ClientEvent {
|
||||
let raw = Duration::from_millis(receive_ms - send_ms);
|
||||
ClientEvent::EchoReply {
|
||||
seq,
|
||||
remote: "127.0.0.1:2112".parse().unwrap(),
|
||||
sent_at: ClientTimestamp {
|
||||
mono: base + Duration::from_millis(send_ms),
|
||||
wall: UNIX_EPOCH + Duration::from_millis(200 + u64::from(seq) * 10),
|
||||
},
|
||||
received_at: ClientTimestamp {
|
||||
mono: base + Duration::from_millis(receive_ms),
|
||||
wall: UNIX_EPOCH + Duration::from_millis(240 + u64::from(seq) * 20),
|
||||
},
|
||||
rtt: RttSample {
|
||||
raw,
|
||||
adjusted: None,
|
||||
effective: SignedDuration::from_duration(raw),
|
||||
},
|
||||
server_timing: Some(timing),
|
||||
one_way: None,
|
||||
received_stats: None,
|
||||
bytes: 64,
|
||||
packet_meta: PacketMeta::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directional_ipdv_uses_matching_clock_domains_and_preserves_zero_and_absence() {
|
||||
let first = ServerTiming {
|
||||
receive_mono_ns: Some(100_000_000),
|
||||
send_mono_ns: Some(101_000_000),
|
||||
receive_wall_ns: Some(1_000_000_000),
|
||||
send_wall_ns: Some(1_001_000_000),
|
||||
midpoint_mono_ns: None,
|
||||
midpoint_wall_ns: None,
|
||||
processing: None,
|
||||
};
|
||||
let second = ServerTiming {
|
||||
receive_mono_ns: Some(105_000_000),
|
||||
send_mono_ns: Some(108_000_000),
|
||||
receive_wall_ns: Some(1_020_000_000),
|
||||
send_wall_ns: Some(1_026_000_000),
|
||||
..first
|
||||
};
|
||||
// Wall and monotonic deltas deliberately disagree: availability alone
|
||||
// cannot prove the formulas or which clock domain wins.
|
||||
let mut wall_only = second;
|
||||
wall_only.receive_mono_ns = None;
|
||||
wall_only.send_mono_ns = None;
|
||||
let mut no_send = wall_only;
|
||||
no_send.receive_wall_ns = None;
|
||||
let mut no_receive = wall_only;
|
||||
no_receive.send_wall_ns = None;
|
||||
let zero = ServerTiming {
|
||||
receive_mono_ns: Some(110_000_000),
|
||||
send_mono_ns: Some(111_000_000),
|
||||
..second
|
||||
};
|
||||
for (label, current, send_ms, expected_send_ms, expected_receive_ms) in [
|
||||
("monotonic preferred", second, 30, Some(5), Some(3)),
|
||||
("wall fallback", wall_only, 30, Some(10), Some(5)),
|
||||
("missing send endpoint", no_send, 30, None, Some(5)),
|
||||
("missing receive endpoint", no_receive, 30, Some(10), None),
|
||||
("reordered client send times", second, 10, Some(15), Some(3)),
|
||||
("zero variation", zero, 30, Some(0), Some(0)),
|
||||
] {
|
||||
let base = Instant::now();
|
||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||
assert!(collector
|
||||
.process(&reply(base, 0, 20, 40, first))
|
||||
.ipdv_pairs
|
||||
.is_empty());
|
||||
let update = collector.process(&reply(base, 1, send_ms, 50, current));
|
||||
assert_eq!(update.ipdv_pairs.len(), 1, "{label}");
|
||||
let pair = &update.ipdv_pairs[0];
|
||||
assert_eq!(
|
||||
pair.send_ipdv,
|
||||
expected_send_ms.map(Duration::from_millis),
|
||||
"{label}"
|
||||
);
|
||||
assert_eq!(
|
||||
pair.receive_ipdv,
|
||||
expected_receive_ms.map(Duration::from_millis),
|
||||
"{label}"
|
||||
);
|
||||
let snapshot = collector.snapshot();
|
||||
for (expected, actual) in [
|
||||
(expected_send_ms, snapshot.ipdv.send),
|
||||
(expected_receive_ms, snapshot.ipdv.receive),
|
||||
] {
|
||||
assert_eq!(actual.count, u64::from(expected.is_some()), "{label}");
|
||||
assert_eq!(
|
||||
actual.total_ns,
|
||||
i128::from(expected.unwrap_or(0)) * 1_000_000,
|
||||
"{label}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,6 +319,19 @@ fn ipdv_wraparound_sequence_is_adjacent() {
|
||||
assert!(wrapped.contributed_sample);
|
||||
assert_one_ipdv_pair(&wrapped, u32::MAX, 0, Duration::from_millis(4));
|
||||
|
||||
// The client classifies duplicate datagrams before publishing events;
|
||||
// their accounting must not emit an adjacent pair a second time.
|
||||
for seq in [u32::MAX, 0] {
|
||||
let duplicate = collector.process(&ClientEvent::DuplicateReply {
|
||||
seq,
|
||||
remote: "127.0.0.1:2112".parse().unwrap(),
|
||||
received_at: ts(30),
|
||||
bytes: 64,
|
||||
});
|
||||
assert!(!duplicate.contributed_sample);
|
||||
assert_no_ipdv_pairs(&duplicate);
|
||||
}
|
||||
|
||||
let snapshot = collector.snapshot();
|
||||
assert_eq!(snapshot.ipdv.round_trip.count, 1);
|
||||
assert_eq!(snapshot.ipdv.round_trip.total_ns, 4_000_000);
|
||||
@@ -355,19 +368,6 @@ fn late_reply_can_complete_ipdv_pair() {
|
||||
assert_eq!(snapshot.ipdv.round_trip.total_ns, 10_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_exposes_directional_ipdv_when_available() {
|
||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||
collector.process(&unadjusted_reply(0, 10));
|
||||
let update = collector.process(&unadjusted_reply(1, 13));
|
||||
|
||||
assert!(update.contributed_sample);
|
||||
|
||||
let pair = assert_one_ipdv_pair(&update, 0, 1, Duration::from_millis(3));
|
||||
assert!(pair.send_ipdv.is_some());
|
||||
assert!(pair.receive_ipdv.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gap_fill_update_exposes_both_completed_ipdv_pairs() {
|
||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||
|
||||
Reference in new issue
Block a user