test: strengthen durable behavior coverage after review
This commit is contained in:
14 files changed
+1063
-85
No files matched your search
@@ -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");
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user