tests: rework the actually useful tests to live in the integration crate
This commit is contained in:
94 files changed
+1373
-15245
No files matched your search
@@ -180,148 +180,3 @@ fn applet_argv(applet_name: &str, args: &[OsString]) -> Vec<OsString> {
|
||||
argv.extend(args.iter().cloned());
|
||||
argv
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn applet_detection_maps_binary_names() {
|
||||
assert_eq!(
|
||||
detect_applet_from_argv0("/usr/bin/irtt-client"),
|
||||
Some(RequestedApplet::Client)
|
||||
);
|
||||
assert_eq!(
|
||||
detect_applet_from_argv0("irtt-tui"),
|
||||
Some(RequestedApplet::Tui)
|
||||
);
|
||||
assert_eq!(
|
||||
detect_applet_from_argv0("irtt-server"),
|
||||
Some(RequestedApplet::Server)
|
||||
);
|
||||
assert_eq!(detect_applet_from_argv0("irttd"), None);
|
||||
assert_eq!(detect_applet_from_argv0("irtt-rs"), None);
|
||||
assert_eq!(detect_applet_from_argv0("custom-name"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_irtt_cli_binary_name_is_no_longer_a_recognized_applet() {
|
||||
assert_eq!(detect_applet_from_argv0("irtt-cli"), None);
|
||||
assert_eq!(detect_applet_from_argv0("/usr/bin/irtt-cli"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applet_detection_ignores_the_platform_executable_suffix() {
|
||||
let exe = std::env::consts::EXE_SUFFIX;
|
||||
|
||||
assert_eq!(
|
||||
detect_applet_from_argv0(&format!("/opt/irtt/irtt-server{exe}")),
|
||||
Some(RequestedApplet::Server)
|
||||
);
|
||||
assert_eq!(
|
||||
detect_applet_from_argv0(&format!("irtt-client{exe}")),
|
||||
Some(RequestedApplet::Client)
|
||||
);
|
||||
|
||||
// The dispatcher's own name must not be taken for an unknown applet.
|
||||
let dispatcher = format!("irtt-rs{exe}");
|
||||
assert_eq!(detect_applet_from_argv0(&dispatcher), None);
|
||||
assert!(dispatch_from_argv(vec![dispatcher.into(), "--help".into()]).is_ok());
|
||||
|
||||
// A name that merely ends in the suffix letters keeps them.
|
||||
assert!(detect_applet_from_argv0("irtt-serverexe").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_executable_suffix_handling_is_assertable_from_any_host() {
|
||||
assert_eq!(
|
||||
strip_suffix_ignoring_case("irtt-server.exe", ".exe"),
|
||||
"irtt-server"
|
||||
);
|
||||
assert_eq!(strip_suffix_ignoring_case("IRTT-RS.EXE", ".exe"), "IRTT-RS");
|
||||
assert_eq!(
|
||||
strip_suffix_ignoring_case("irtt-server", ".exe"),
|
||||
"irtt-server"
|
||||
);
|
||||
assert_eq!(
|
||||
strip_suffix_ignoring_case("irtt-serverexe", ".exe"),
|
||||
"irtt-serverexe"
|
||||
);
|
||||
assert_eq!(strip_suffix_ignoring_case(".exe", ".exe"), ".exe");
|
||||
assert_eq!(
|
||||
strip_suffix_ignoring_case("irtt-server.exe", ""),
|
||||
"irtt-server.exe"
|
||||
);
|
||||
// A multi-byte tail must not split mid-character.
|
||||
assert_eq!(strip_suffix_ignoring_case("irtt-så", ".exe"), "irtt-så");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_help_renders_dispatcher_help() {
|
||||
let dispatch = dispatch_from_argv(vec!["irtt-rs".into(), "--help".into()]).unwrap();
|
||||
let AppletDispatch::Help(help) = dispatch else {
|
||||
panic!("expected dispatcher help");
|
||||
};
|
||||
assert!(help.contains("IRTT-compatible multi-applet dispatcher"));
|
||||
assert!(help.contains("client"));
|
||||
if cfg!(feature = "tui") {
|
||||
assert!(help.contains("Run the terminal UI applet"));
|
||||
} else {
|
||||
assert!(help.contains("Terminal UI applet is not available in this build"));
|
||||
}
|
||||
assert!(help.contains("server"));
|
||||
if cfg!(feature = "server") {
|
||||
assert!(help.contains("Run the UDP server applet"));
|
||||
} else {
|
||||
assert!(help.contains("UDP server applet is not available in this build"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_without_applet_is_an_error() {
|
||||
let err = dispatch_from_argv(vec!["irtt-rs".into()]).unwrap_err();
|
||||
assert!(err.contains("choose an applet"));
|
||||
assert!(err.contains("client"));
|
||||
if cfg!(feature = "tui") {
|
||||
assert!(err.contains("tui"));
|
||||
}
|
||||
assert!(err.contains("server"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_subcommands_dispatch_to_applet_argv() {
|
||||
assert_eq!(
|
||||
dispatch_from_argv(vec!["irtt-rs".into(), "client".into(), "host:2112".into()])
|
||||
.unwrap(),
|
||||
AppletDispatch::Run {
|
||||
applet: RequestedApplet::Client,
|
||||
argv: vec!["irtt-client".into(), "host:2112".into()],
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
dispatch_from_argv(vec!["irtt-rs".into(), "tui".into(), "host:2112".into()]).unwrap(),
|
||||
AppletDispatch::Run {
|
||||
applet: RequestedApplet::Tui,
|
||||
argv: vec!["irtt-tui".into(), "host:2112".into()],
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
dispatch_from_argv(vec!["irtt-rs".into(), "server".into()]).unwrap(),
|
||||
AppletDispatch::Run {
|
||||
applet: RequestedApplet::Server,
|
||||
argv: vec!["irtt-server".into()],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_irtt_binary_name_is_an_error() {
|
||||
let err = dispatch_from_argv(vec!["/usr/bin/irtt-typo".into()]).unwrap_err();
|
||||
assert!(err.contains("unknown applet name 'irtt-typo'"));
|
||||
assert!(err.contains("irtt-client"));
|
||||
if cfg!(feature = "tui") {
|
||||
assert!(err.contains("irtt-tui"));
|
||||
}
|
||||
assert!(err.contains("irtt-server"));
|
||||
}
|
||||
}
|
||||
@@ -151,195 +151,3 @@ pub enum HeaderMode {
|
||||
/// Never print headers.
|
||||
Never,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::{CommandFactory, Parser};
|
||||
use irtt_client::NegotiationPolicy;
|
||||
use irtt_proto::{Clock, ReceivedStats, StampAt};
|
||||
|
||||
fn parse(args: &[&str]) -> Result<ClientArgs, clap::Error> {
|
||||
let mut argv = vec!["irtt-client"];
|
||||
argv.extend_from_slice(args);
|
||||
ClientArgs::try_parse_from(argv)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_parser_keeps_finite_default_duration() {
|
||||
let args = parse(&["127.0.0.1:2112"]).unwrap();
|
||||
|
||||
assert_eq!(args.targets.len(), 1);
|
||||
assert_eq!(args.prepare().unwrap().targets[0].label, "127.0.0.1:2112");
|
||||
assert_eq!(args.pacing, GroupPacingArg::Staggered);
|
||||
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
|
||||
assert_eq!(args.interval, Duration::from_secs(1));
|
||||
assert_eq!(args.format, OutputFormat::Table);
|
||||
assert_eq!(args.columns, None);
|
||||
assert_eq!(args.header, HeaderMode::Auto);
|
||||
assert!(!args.targets_stdin);
|
||||
assert!(!args.is_continuous());
|
||||
assert_eq!(args.timestamp_mode(), TimestampArg::Both);
|
||||
|
||||
let config = args.prepare().unwrap().client;
|
||||
assert_eq!(config.duration, Some(DEFAULT_CLIENT_DURATION));
|
||||
assert_eq!(config.received_stats, ReceivedStats::Both);
|
||||
assert_eq!(config.stamp_at, StampAt::Both);
|
||||
assert_eq!(config.clock, Clock::Both);
|
||||
assert_eq!(config.negotiation_policy, NegotiationPolicy::Strict);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_parser_accepts_stream_formats_and_rejects_tui_output() {
|
||||
for format in ["table", "csv", "tsv", "jsonl"] {
|
||||
assert!(parse(&["--format", format, "127.0.0.1:2112"]).is_ok());
|
||||
}
|
||||
assert!(parse(&["--format", "tui", "127.0.0.1:2112"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_columns_does_not_require_server() {
|
||||
let args = parse(&["--list-columns"]).unwrap();
|
||||
assert!(args.targets.is_empty());
|
||||
assert!(args.list_columns);
|
||||
// The parser accepts no target; preparation is where a target becomes
|
||||
// mandatory, and the stream command returns before preparing when
|
||||
// listing columns.
|
||||
let err = args.prepare().unwrap_err();
|
||||
assert!(err.contains("--list-columns"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_positional_targets_parse_as_targets() {
|
||||
let args = parse(&["host-a:2112", "host-b:2112"]).unwrap();
|
||||
let specs = args.prepare().unwrap().targets;
|
||||
|
||||
assert_eq!(specs[0].label, "host-a:2112");
|
||||
assert_eq!(specs[0].managed.server_addr, "host-a:2112");
|
||||
assert_eq!(specs[1].label, "host-b:2112");
|
||||
assert_eq!(specs[1].managed.server_addr, "host-b:2112");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_labeled_targets_parse() {
|
||||
let args = parse(&["ams=ams.example.com:2112", "sg=sg.example.com:2112"]).unwrap();
|
||||
let specs = args.prepare().unwrap().targets;
|
||||
|
||||
assert_eq!(specs[0].label, "ams");
|
||||
assert_eq!(specs[0].managed.server_addr, "ams.example.com:2112");
|
||||
assert_eq!(specs[1].label, "sg");
|
||||
assert_eq!(specs[1].managed.server_addr, "sg.example.com:2112");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_labeled_and_unlabeled_targets_preserve_argument_order() {
|
||||
let args = parse(&["local.example", "eu=a.example", "backup.example"]).unwrap();
|
||||
let specs = args.prepare().unwrap().targets;
|
||||
|
||||
let labels: Vec<_> = specs.iter().map(|target| target.label.as_str()).collect();
|
||||
assert_eq!(labels, ["local.example", "eu", "backup.example"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_labels_are_rejected() {
|
||||
let args = parse(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap();
|
||||
let err = args.prepare().unwrap_err();
|
||||
|
||||
assert!(err.contains("duplicate target label"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_positional_target_strings_get_stable_suffixes() {
|
||||
let args = parse(&["host-a:2112", "host-a:2112"]).unwrap();
|
||||
let specs = args.prepare().unwrap().targets;
|
||||
|
||||
assert_eq!(specs[0].label, "host-a:2112");
|
||||
assert_eq!(specs[1].label, "host-a:2112#2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_target_endpoints_are_allowed() {
|
||||
let args = parse(&["127.0.0.1:2112", "127.0.0.1"]).unwrap();
|
||||
assert_eq!(args.prepare().unwrap().target_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_labelled_target_syntax_is_rejected() {
|
||||
assert!(parse(&["=127.0.0.1:2112"]).unwrap().prepare().is_err());
|
||||
assert!(parse(&["label="]).unwrap().prepare().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_target_option_is_rejected() {
|
||||
assert!(parse(&["--target", "eu=host.example"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pacing_option_accepts_supported_values() {
|
||||
assert_eq!(
|
||||
parse(&["--pacing", "staggered", "127.0.0.1:2112"])
|
||||
.unwrap()
|
||||
.pacing,
|
||||
GroupPacingArg::Staggered
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&["--pacing", "burst", "127.0.0.1:2112"])
|
||||
.unwrap()
|
||||
.pacing,
|
||||
GroupPacingArg::Burst
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_help_lists_shared_protocol_options() {
|
||||
let help = ClientArgs::command().render_help().to_string();
|
||||
assert!(help.contains("--format <FORMAT>"));
|
||||
assert!(!help.contains("--target "));
|
||||
assert!(help.contains("--pacing <PACING>"));
|
||||
assert!(help.contains("--columns <COLUMNS>"));
|
||||
assert!(help.contains("--list-columns"));
|
||||
assert!(help.contains("--header <HEADER>"));
|
||||
assert!(help.contains("--tstamp <MODE>"));
|
||||
assert!(help.contains("--stats <STATS>"));
|
||||
assert!(help.contains("--sfill <STRING>"));
|
||||
assert!(help.contains("--dscp <0..=63>"));
|
||||
assert!(help.contains("--ttl <1..=255>"));
|
||||
assert!(help.contains("--loose"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_hmac_key_is_rejected() {
|
||||
let err = parse(&["--hmac", "", "127.0.0.1:2112"]).unwrap_err();
|
||||
assert!(err.to_string().contains("HMAC key must not be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_empty_hmac_key_is_accepted() {
|
||||
let args = parse(&["--hmac", "secret", "127.0.0.1:2112"]).unwrap();
|
||||
let config = args.prepare().unwrap().client;
|
||||
assert_eq!(config.hmac_key, Some(b"secret".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targets_stdin_requires_continuous_mode_and_allows_empty_initial_set() {
|
||||
assert!(parse(&["--targets-stdin"]).unwrap().prepare().is_err());
|
||||
let args = parse(&["--duration", "0", "--targets-stdin"]).unwrap();
|
||||
let setup = args.prepare().unwrap();
|
||||
assert!(setup.targets.is_empty());
|
||||
assert!(setup.stdin_controlled);
|
||||
let config = setup.managed_config();
|
||||
assert_eq!(
|
||||
config.completion,
|
||||
irtt_client::managed::ManagedCompletionPolicy::ExplicitStop
|
||||
);
|
||||
assert_eq!(config.max_live_target_generations, 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_format_summary_policy_is_table_only() {
|
||||
assert!(OutputFormat::Table.prints_summary());
|
||||
assert!(!OutputFormat::Csv.prints_summary());
|
||||
assert!(!OutputFormat::Tsv.prints_summary());
|
||||
assert!(!OutputFormat::Jsonl.prints_summary());
|
||||
}
|
||||
}
|
||||
@@ -1205,183 +1205,3 @@ fn write_json_string_content(out: &mut String, value: &str) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use irtt_client::ClientTimestamp;
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
fn output_config(format: OutputFormat, columns: Option<&str>) -> OutputConfig {
|
||||
OutputConfig::new(format, columns, HeaderMode::Auto, false).unwrap()
|
||||
}
|
||||
|
||||
fn remote() -> SocketAddr {
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
|
||||
}
|
||||
|
||||
fn reply() -> ClientEvent {
|
||||
let sent_at = ClientTimestamp {
|
||||
wall: UNIX_EPOCH + Duration::from_secs(1),
|
||||
mono: Instant::now(),
|
||||
};
|
||||
let received_at = ClientTimestamp {
|
||||
wall: sent_at.wall + Duration::from_micros(1200),
|
||||
mono: sent_at.mono + Duration::from_micros(1200),
|
||||
};
|
||||
ClientEvent::EchoReply {
|
||||
seq: 7,
|
||||
remote: remote(),
|
||||
sent_at,
|
||||
received_at,
|
||||
rtt: RttSample {
|
||||
raw: Duration::from_micros(1200),
|
||||
adjusted: None,
|
||||
effective: SignedDuration::from_nanos(1_200_000),
|
||||
},
|
||||
server_timing: None,
|
||||
one_way: None,
|
||||
received_stats: None,
|
||||
bytes: 64,
|
||||
packet_meta: PacketMeta::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_columns_includes_target() {
|
||||
assert!(OutputConfig::list_columns().contains("target"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_table_columns_always_include_target_first() {
|
||||
let header = output_config(OutputFormat::Table, None)
|
||||
.render_header()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(header.split_whitespace().next(), Some("target"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_default_keyword_matches_omitted_columns() {
|
||||
for format in [
|
||||
OutputFormat::Table,
|
||||
OutputFormat::Csv,
|
||||
OutputFormat::Tsv,
|
||||
OutputFormat::Jsonl,
|
||||
] {
|
||||
let omitted = output_config(format, None).render_header();
|
||||
let explicit = output_config(format, Some("default")).render_header();
|
||||
assert_eq!(omitted, explicit);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_columns_do_not_insert_target() {
|
||||
let config = output_config(OutputFormat::Csv, Some("seq,effective_rtt_us"));
|
||||
let header = config.render_header().unwrap();
|
||||
|
||||
assert!(!header.split(',').any(|column| column == "target"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_target_column_renders_label() {
|
||||
let config = output_config(OutputFormat::Csv, Some("target,seq,effective_rtt_us"));
|
||||
let line = config
|
||||
.render_event(&reply(), Some("ams"), Some(&EventRenderStats::default()))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
config.render_header().as_deref(),
|
||||
Some("target,seq,effective_rtt_us")
|
||||
);
|
||||
assert_eq!(line, "ams,7,1200");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_csv_renders_target_label_in_row() {
|
||||
let config = output_config(OutputFormat::Csv, None);
|
||||
let line = config
|
||||
.render_event(&reply(), Some("ams"), Some(&EventRenderStats::default()))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(line.split(',').next(), Some("ams"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_jsonl_renders_target_key_first() {
|
||||
let config = output_config(OutputFormat::Jsonl, None);
|
||||
let line = config
|
||||
.render_event(&reply(), Some("ams"), Some(&EventRenderStats::default()))
|
||||
.unwrap();
|
||||
|
||||
assert!(line.starts_with("{\"target\":\"ams\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_row_with_long_target_keeps_next_column_aligned() {
|
||||
let config = output_config(OutputFormat::Table, None);
|
||||
let header = config.render_header().unwrap();
|
||||
let event_offset = header.find("event").unwrap();
|
||||
|
||||
for label in ["ams".to_owned(), "a".repeat(40), "é".repeat(40)] {
|
||||
let line = config
|
||||
.render_event(&reply(), Some(&label), Some(&EventRenderStats::default()))
|
||||
.unwrap();
|
||||
let prefix = line.split("echo_reply").next().unwrap();
|
||||
assert_eq!(prefix.chars().count(), event_offset, "{line}");
|
||||
if label.chars().count() > event_offset {
|
||||
assert!(prefix.trim_end().ends_with("..."), "{line}");
|
||||
} else {
|
||||
assert!(prefix.starts_with(&label), "{line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_preserves_full_long_target_label() {
|
||||
let config = output_config(OutputFormat::Csv, Some("target,seq"));
|
||||
let long_target = "a".repeat(40);
|
||||
let line = config
|
||||
.render_event(
|
||||
&reply(),
|
||||
Some(&long_target),
|
||||
Some(&EventRenderStats::default()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(line, format!("{long_target},7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tsv_preserves_full_long_target_label() {
|
||||
let config = output_config(OutputFormat::Tsv, Some("target,seq"));
|
||||
let long_target = "b".repeat(40);
|
||||
let line = config
|
||||
.render_event(
|
||||
&reply(),
|
||||
Some(&long_target),
|
||||
Some(&EventRenderStats::default()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(line, format!("{long_target}\t7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonl_preserves_full_long_target_label() {
|
||||
let config = output_config(OutputFormat::Jsonl, Some("target,seq"));
|
||||
let long_target = "c".repeat(40);
|
||||
let line = config
|
||||
.render_event(
|
||||
&reply(),
|
||||
Some(&long_target),
|
||||
Some(&EventRenderStats::default()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(line, format!("{{\"target\":\"{long_target}\",\"seq\":7}}"));
|
||||
}
|
||||
}
|
||||
@@ -938,289 +938,3 @@ fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option
|
||||
};
|
||||
Some(format!("irtt-rs: warning: finite exact statistics may retain about {formatted} for this run; {guidance}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use irtt_client::managed::{
|
||||
ManagedLifecycle, ManagedTargetFailure, ManagedTargetFailureKind,
|
||||
ManagedTargetFailurePhase, ManagedTargetLifecycle, ManagedTargetStatus, TargetId,
|
||||
};
|
||||
use std::{io::BufReader, io::Cursor, sync::Arc};
|
||||
|
||||
#[test]
|
||||
fn stdin_record_reader_removes_only_line_framing() {
|
||||
let mut reader = BufReader::with_capacity(2, Cursor::new(b" first \r\n\nthird\r"));
|
||||
let mut buffer = Vec::with_capacity(MAX_STDIN_RECORD_BYTES + 1);
|
||||
|
||||
assert_eq!(
|
||||
read_stdin_record(&mut reader, &mut buffer).unwrap(),
|
||||
Some(" first ".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
read_stdin_record(&mut reader, &mut buffer).unwrap(),
|
||||
Some(String::new())
|
||||
);
|
||||
assert_eq!(
|
||||
read_stdin_record(&mut reader, &mut buffer).unwrap(),
|
||||
Some("third\r".to_owned())
|
||||
);
|
||||
assert_eq!(read_stdin_record(&mut reader, &mut buffer).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_record_reader_rejects_over_limit_unterminated_record() {
|
||||
let input = vec![b'x'; MAX_STDIN_RECORD_BYTES + 1];
|
||||
let mut reader = BufReader::with_capacity(17, Cursor::new(input));
|
||||
let mut buffer = Vec::with_capacity(MAX_STDIN_RECORD_BYTES + 1);
|
||||
|
||||
let error = read_stdin_record(&mut reader, &mut buffer).unwrap_err();
|
||||
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
|
||||
assert!(error.to_string().contains("maximum size"));
|
||||
assert!(buffer.len() <= MAX_STDIN_RECORD_BYTES + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_mailbox_value_survives_an_earlier_submitted_acknowledgement() {
|
||||
let mailbox = StdinMailbox::default();
|
||||
mailbox.publish(vec![ManagedTargetConfig::new("a", "127.0.0.1:2112")]);
|
||||
let submitted = mailbox.latest().unwrap();
|
||||
mailbox.publish(vec![ManagedTargetConfig::new("b", "127.0.0.1:2113")]);
|
||||
let pending = mailbox.latest().unwrap();
|
||||
|
||||
mailbox.clear_if_current(submitted.revision);
|
||||
|
||||
assert_eq!(mailbox.latest().unwrap().revision, pending.revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_update_retries_after_status_publishes_during_acknowledgement() {
|
||||
let mailbox = StdinMailbox::default();
|
||||
mailbox.publish(Vec::new());
|
||||
let target_set = mailbox.latest().unwrap();
|
||||
let saturated = Arc::new(status_with_targets(&[TargetInstance {
|
||||
id: TargetId::from("old"),
|
||||
generation: 1,
|
||||
}]));
|
||||
let capacity_freed = Arc::new(status_with_targets(&[]));
|
||||
// Capacity becomes available after the transaction rejected but before
|
||||
// its acknowledgement reaches the controller. The rejection path must
|
||||
// retain the pre-submission status, not this later snapshot.
|
||||
let mut retry_after =
|
||||
retry_after_capacity_rejection(&mailbox, &target_set, Arc::clone(&saturated));
|
||||
|
||||
assert!(retry_waits_for_status_change(
|
||||
&mut retry_after,
|
||||
&target_set,
|
||||
&saturated
|
||||
));
|
||||
assert!(!retry_waits_for_status_change(
|
||||
&mut retry_after,
|
||||
&target_set,
|
||||
&capacity_freed
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_desired_set_supersedes_a_capacity_rejected_candidate_before_retry() {
|
||||
let mailbox = StdinMailbox::default();
|
||||
mailbox.publish(vec![ManagedTargetConfig::new("a", "127.0.0.1:2112")]);
|
||||
let applied = mailbox.latest().unwrap();
|
||||
mailbox.clear_if_current(applied.revision);
|
||||
|
||||
mailbox.publish(vec![ManagedTargetConfig::new("b", "127.0.0.1:2113")]);
|
||||
let rejected = mailbox.latest().unwrap();
|
||||
let saturated = Arc::new(status_with_targets(&[TargetInstance {
|
||||
id: TargetId::from("old"),
|
||||
generation: 1,
|
||||
}]));
|
||||
let mut retry_after = retry_after_capacity_rejection(&mailbox, &rejected, saturated);
|
||||
mailbox.publish(vec![ManagedTargetConfig::new("c", "127.0.0.1:2114")]);
|
||||
let replacement = mailbox.latest().unwrap();
|
||||
|
||||
assert!(!retry_waits_for_status_change(
|
||||
&mut retry_after,
|
||||
&replacement,
|
||||
&Arc::new(status_with_targets(&[TargetInstance {
|
||||
id: TargetId::from("old"),
|
||||
generation: 1,
|
||||
}]))
|
||||
));
|
||||
assert!(retry_after.is_none());
|
||||
assert_eq!(replacement.targets[0].id.as_str(), "c");
|
||||
mailbox.clear_if_current(replacement.revision);
|
||||
assert!(mailbox.latest().is_none());
|
||||
}
|
||||
|
||||
fn status_with_targets(targets: &[TargetInstance]) -> ManagedStatus {
|
||||
ManagedStatus {
|
||||
lifecycle: ManagedLifecycle::Running,
|
||||
stop_requested: false,
|
||||
applied_command_sequence: 0,
|
||||
desired_target_count: targets.len(),
|
||||
connecting_target_count: 0,
|
||||
opening_target_count: 0,
|
||||
active_target_count: 0,
|
||||
draining_target_count: 0,
|
||||
closing_target_count: 0,
|
||||
terminal_target_count: 0,
|
||||
total_target_outcomes: 0,
|
||||
successful_target_outcomes: 0,
|
||||
failed_target_outcomes: 0,
|
||||
peer_closed_target_outcomes: 0,
|
||||
discarded_target_outcomes: 0,
|
||||
targets: Arc::from(
|
||||
targets
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|target| ManagedTargetStatus {
|
||||
target,
|
||||
desired: true,
|
||||
lifecycle: ManagedTargetLifecycle::Active,
|
||||
server_addr: Arc::from("127.0.0.1:2112"),
|
||||
remote: None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into_boxed_slice(),
|
||||
),
|
||||
recent_target_outcomes: Arc::from([]),
|
||||
final_outcome: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_accounting_prunes_retired_generations_and_retains_only_shutdown_snapshot() {
|
||||
let retired = TargetInstance {
|
||||
id: TargetId::from("edge"),
|
||||
generation: 1,
|
||||
};
|
||||
let replacement = TargetInstance {
|
||||
id: TargetId::from("edge"),
|
||||
generation: 2,
|
||||
};
|
||||
let live_status = status_with_targets(std::slice::from_ref(&replacement));
|
||||
let empty_status = status_with_targets(&[]);
|
||||
let mut stats = BTreeMap::from([
|
||||
(retired.clone(), StatsCollector::new(stats_config(true))),
|
||||
(replacement.clone(), StatsCollector::new(stats_config(true))),
|
||||
]);
|
||||
|
||||
reconcile_stdin_stats(&mut stats, &live_status, None);
|
||||
assert_eq!(stats.keys().cloned().collect::<Vec<_>>(), [replacement]);
|
||||
|
||||
let summary_targets = snapshot_stdin_summary_targets(&mut stats, &live_status);
|
||||
stats.insert(retired, StatsCollector::new(stats_config(true)));
|
||||
reconcile_stdin_stats(&mut stats, &empty_status, Some(&summary_targets));
|
||||
|
||||
assert_eq!(
|
||||
stats.keys().cloned().collect::<Vec<_>>(),
|
||||
[TargetInstance {
|
||||
id: TargetId::from("edge"),
|
||||
generation: 2,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_deduplication_is_bounded_under_target_churn() {
|
||||
let mut terminal_targets = BoundedTargetSet::new(2);
|
||||
let target = |generation| TargetInstance {
|
||||
id: TargetId::from("edge"),
|
||||
generation,
|
||||
};
|
||||
|
||||
assert!(terminal_targets.insert(target(1)));
|
||||
assert!(terminal_targets.insert(target(2)));
|
||||
assert!(terminal_targets.insert(target(3)));
|
||||
assert!(terminal_targets.insert(target(1)));
|
||||
assert!(!terminal_targets.insert(target(3)));
|
||||
}
|
||||
|
||||
fn long_finite_args() -> ClientArgs {
|
||||
use clap::Parser;
|
||||
|
||||
ClientArgs::try_parse_from([
|
||||
"irtt-client",
|
||||
"--duration",
|
||||
"4200s",
|
||||
"--interval",
|
||||
"100ms",
|
||||
"127.0.0.1:2112",
|
||||
])
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Smallest target count whose aggregate estimate reaches the warning
|
||||
/// threshold, derived from the stats crate's own estimate rather than from
|
||||
/// any bytes-per-probe assumption held here.
|
||||
fn first_warning_target_count(args: &ClientArgs) -> usize {
|
||||
let probes = expected_probe_count(args.duration, args.interval);
|
||||
(1..=1024)
|
||||
.find(|targets| {
|
||||
let total = probes.saturating_mul(*targets as u64);
|
||||
stats_config(false).estimated_retained_bytes(total)
|
||||
>= FINITE_STATS_MEMORY_WARNING_BYTES
|
||||
})
|
||||
.expect("a long finite run should cross the warning threshold within 1024 targets")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_stats_memory_warning_accounts_for_target_count() {
|
||||
let args = long_finite_args();
|
||||
let crossing = first_warning_target_count(&args);
|
||||
|
||||
assert!(
|
||||
crossing > 1,
|
||||
"this run should need more than one target to cross the threshold, \
|
||||
otherwise the test proves nothing about target-count accounting"
|
||||
);
|
||||
assert!(
|
||||
finite_stats_memory_warning(&args, crossing - 1).is_none(),
|
||||
"an aggregate estimate below the threshold should not warn"
|
||||
);
|
||||
assert!(
|
||||
finite_stats_memory_warning(&args, crossing).is_some(),
|
||||
"an aggregate estimate at the threshold should warn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_stats_memory_warning_saturates_target_count_multiplication() {
|
||||
assert!(finite_stats_memory_warning(&long_finite_args(), usize::MAX).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_failure_diagnostics_include_primary_and_cleanup_failures() {
|
||||
let target = ManagedTargetOutcome {
|
||||
target: TargetInstance {
|
||||
id: TargetId::from("edge"),
|
||||
generation: 1,
|
||||
},
|
||||
server_addr: Arc::from("127.0.0.1:2112"),
|
||||
remote: None,
|
||||
end_reason: ManagedTargetEndReason::Failed(ManagedTargetFailure {
|
||||
phase: ManagedTargetFailurePhase::Receiving,
|
||||
kind: ManagedTargetFailureKind::Protocol,
|
||||
message: "primary failure".into(),
|
||||
}),
|
||||
packets_sent: 0,
|
||||
replies_received: 0,
|
||||
duplicates: 0,
|
||||
late: 0,
|
||||
warning_events: 0,
|
||||
cleanup_failure: Some(ManagedTargetFailure {
|
||||
phase: ManagedTargetFailurePhase::Closing,
|
||||
kind: ManagedTargetFailureKind::Socket,
|
||||
message: "cleanup failure".into(),
|
||||
}),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
target_failure_messages(&target),
|
||||
[
|
||||
"irtt-rs: target edge failed (receiving protocol): primary failure",
|
||||
"irtt-rs: target edge cleanup failed (closing socket): cleanup failure",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -121,257 +121,3 @@ fn write_time_row(out: &mut String, label: &str, value: &TimeStats) {
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use irtt_client::{
|
||||
ClientEvent, ClientTimestamp, PacketMeta, RttSample, ServerTiming, SignedDuration,
|
||||
};
|
||||
use irtt_stats::{StatsCollector, StatsConfig};
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
time::{Duration, Instant, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
fn test_timestamp(offset: Duration) -> ClientTimestamp {
|
||||
ClientTimestamp {
|
||||
wall: UNIX_EPOCH + offset,
|
||||
mono: Instant::now() + offset,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_remote() -> SocketAddr {
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_summary_omits_optional_metric_sections() {
|
||||
let summary = StatsCollector::new(StatsConfig::finite()).snapshot();
|
||||
let output = format_summary(&summary);
|
||||
|
||||
assert!(output.contains("Metric"));
|
||||
assert!(output.contains("Min"));
|
||||
assert!(output.contains("Mean"));
|
||||
assert!(output.contains("Median"));
|
||||
assert!(output.contains("Max"));
|
||||
assert!(output.contains("Stddev"));
|
||||
assert!(output.contains("packets: sent=0 received=0 unique=0 lost=0"));
|
||||
assert!(!output.contains("RTT 0"));
|
||||
assert!(!output.contains("server processing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_formats_counts_and_available_metrics() {
|
||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||
let sent_at = test_timestamp(Duration::from_secs(1));
|
||||
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500));
|
||||
|
||||
collector.process(&ClientEvent::EchoSent {
|
||||
seq: 1,
|
||||
remote: test_remote(),
|
||||
scheduled_at: Some(sent_at.mono),
|
||||
sent_at,
|
||||
bytes: 64,
|
||||
send_call: Duration::from_micros(10),
|
||||
timer_error: Some(Duration::from_micros(2)),
|
||||
});
|
||||
collector.process(&ClientEvent::EchoReply {
|
||||
seq: 1,
|
||||
remote: test_remote(),
|
||||
sent_at,
|
||||
received_at,
|
||||
rtt: RttSample {
|
||||
raw: Duration::from_micros(1500),
|
||||
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
|
||||
effective: SignedDuration::from_nanos(1_200_000),
|
||||
},
|
||||
server_timing: Some(ServerTiming {
|
||||
receive_wall_ns: None,
|
||||
receive_mono_ns: None,
|
||||
send_wall_ns: None,
|
||||
send_mono_ns: None,
|
||||
midpoint_wall_ns: None,
|
||||
midpoint_mono_ns: None,
|
||||
processing: Some(Duration::from_micros(300)),
|
||||
}),
|
||||
one_way: None,
|
||||
received_stats: None,
|
||||
bytes: 64,
|
||||
packet_meta: PacketMeta::default(),
|
||||
});
|
||||
|
||||
let output = format_summary(&collector.snapshot());
|
||||
|
||||
assert!(output.contains("packets: sent=1 received=1 unique=1 lost=0 loss=0.00%"));
|
||||
assert!(output.contains("bytes: sent=64 received=64"));
|
||||
assert!(output.contains("RTT"));
|
||||
assert!(output.contains("1.2ms"));
|
||||
assert!(!output.contains("raw RTT"));
|
||||
assert!(!output.contains("adjusted RTT"));
|
||||
assert!(output.contains("server processing"));
|
||||
assert!(output.contains("300.0µs"));
|
||||
assert!(output.contains("send call"));
|
||||
assert!(output.contains("10.0µs"));
|
||||
assert!(output.contains("timer error"));
|
||||
assert!(output.contains("2.0µs"));
|
||||
assert!(!output.contains("medians unavailable"));
|
||||
}
|
||||
|
||||
/// Returns the Median cell of the row with this label.
|
||||
fn median_cell(output: &str, label: &str) -> String {
|
||||
let row = output
|
||||
.lines()
|
||||
.find(|line| line.trim_start().starts_with(label))
|
||||
.unwrap_or_else(|| panic!("summary should contain a {label:?} row:\n{output}"));
|
||||
// Metric, Count, Min, Mean, Median, ...
|
||||
// Counting from the right keeps multi-word labels out of the way.
|
||||
row.split_whitespace()
|
||||
.rev()
|
||||
.nth(2)
|
||||
.unwrap_or_else(|| panic!("row {row:?} should have a Median cell"))
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// Collects probes whose send-call, timer-error, and server-processing
|
||||
/// values each have a median distinct from their min, mean, and max, so
|
||||
/// the rendered cell can only come from a real median calculation.
|
||||
fn spread_finite_summary(config: StatsConfig) -> Snapshot {
|
||||
let mut collector = StatsCollector::new(config);
|
||||
for (seq, (send_call_us, timer_error_us, processing_us)) in
|
||||
[(10_u64, 2_u64, 100_u64), (40, 12, 700), (20, 4, 300)]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let seq = seq as u32;
|
||||
let offset = Duration::from_secs(1) + Duration::from_millis(u64::from(seq));
|
||||
let sent_at = test_timestamp(offset);
|
||||
let received_at = test_timestamp(offset + Duration::from_micros(1500));
|
||||
|
||||
collector.process(&ClientEvent::EchoSent {
|
||||
seq,
|
||||
remote: test_remote(),
|
||||
scheduled_at: Some(sent_at.mono),
|
||||
sent_at,
|
||||
bytes: 64,
|
||||
send_call: Duration::from_micros(send_call_us),
|
||||
timer_error: Some(Duration::from_micros(timer_error_us)),
|
||||
});
|
||||
collector.process(&ClientEvent::EchoReply {
|
||||
seq,
|
||||
remote: test_remote(),
|
||||
sent_at,
|
||||
received_at,
|
||||
rtt: RttSample {
|
||||
raw: Duration::from_micros(1500),
|
||||
adjusted: None,
|
||||
effective: SignedDuration::from_nanos(1_500_000),
|
||||
},
|
||||
server_timing: Some(ServerTiming {
|
||||
receive_wall_ns: None,
|
||||
receive_mono_ns: None,
|
||||
send_wall_ns: None,
|
||||
send_mono_ns: None,
|
||||
midpoint_wall_ns: None,
|
||||
midpoint_mono_ns: None,
|
||||
processing: Some(Duration::from_micros(processing_us)),
|
||||
}),
|
||||
one_way: None,
|
||||
received_stats: None,
|
||||
bytes: 64,
|
||||
packet_meta: PacketMeta::default(),
|
||||
});
|
||||
}
|
||||
collector.snapshot()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_summary_shows_medians_for_send_call_timer_error_and_processing() {
|
||||
let output = format_summary(&spread_finite_summary(StatsConfig::finite()));
|
||||
|
||||
// Medians of 10/20/40, 2/4/12, and 100/300/700 µs. None of these
|
||||
// equals the row's min, mean, or max.
|
||||
assert_eq!(median_cell(&output, "send call"), "20.0µs");
|
||||
assert_eq!(median_cell(&output, "timer error"), "4.0µs");
|
||||
assert_eq!(median_cell(&output, "server processing"), "300.0µs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuous_summary_still_shows_no_medians_for_those_rows() {
|
||||
let output = format_summary(&spread_finite_summary(StatsConfig::continuous()));
|
||||
|
||||
for label in ["send call", "timer error", "server processing"] {
|
||||
assert_eq!(
|
||||
median_cell(&output, label),
|
||||
"-",
|
||||
"continuous mode retains no exact samples for {label}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verbose_summary_includes_raw_and_adjusted_rtt_rows() {
|
||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500));
|
||||
collector.process(&ClientEvent::EchoReply {
|
||||
seq: 1,
|
||||
remote: test_remote(),
|
||||
sent_at: test_timestamp(Duration::from_secs(1)),
|
||||
received_at,
|
||||
rtt: RttSample {
|
||||
raw: Duration::from_micros(1500),
|
||||
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
|
||||
effective: SignedDuration::from_nanos(1_200_000),
|
||||
},
|
||||
server_timing: None,
|
||||
one_way: None,
|
||||
received_stats: None,
|
||||
bytes: 64,
|
||||
packet_meta: PacketMeta::default(),
|
||||
});
|
||||
|
||||
let output = format_summary_with_options(
|
||||
&collector.snapshot(),
|
||||
SummaryFormatOptions {
|
||||
verbose: true,
|
||||
..SummaryFormatOptions::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert!(output.contains("raw RTT"));
|
||||
assert!(output.contains("adjusted RTT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_only_note_is_format_option() {
|
||||
let summary = StatsCollector::new(StatsConfig::continuous()).snapshot();
|
||||
let output = format_summary_with_options(
|
||||
&summary,
|
||||
SummaryFormatOptions {
|
||||
show_running_only_note: true,
|
||||
..SummaryFormatOptions::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert!(output.contains("irtt-rs summary"));
|
||||
assert!(output.contains("medians unavailable"));
|
||||
assert!(output.contains("continuous mode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_stats_can_format_negative_values() {
|
||||
let stats = TimeStats {
|
||||
count: 1,
|
||||
total_ns: -500,
|
||||
min_ns: Some(-500),
|
||||
max_ns: Some(-500),
|
||||
mean_ns: -500.0,
|
||||
median_ns: Some(-500.0),
|
||||
variance_ns2: 0.0,
|
||||
};
|
||||
|
||||
let mut out = String::new();
|
||||
write_time_row(&mut out, "RTT", &stats);
|
||||
assert!(out.contains("-500ns"));
|
||||
}
|
||||
}
|
||||
@@ -115,93 +115,3 @@ pub(crate) fn format_optional_count(value: Option<u64>) -> String {
|
||||
fn absent() -> String {
|
||||
ABSENT.to_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nanosecond_scalars_select_units_at_the_defined_boundaries() {
|
||||
assert_eq!(format_ns_f64(0.0), "0ns");
|
||||
assert_eq!(format_ns_f64(999.0), "999ns");
|
||||
assert_eq!(format_ns_f64(1_000.0), "1.0µs");
|
||||
assert_eq!(format_ns_f64(999_999.0), "1000.0µs");
|
||||
assert_eq!(format_ns_f64(1_000_000.0), "1.0ms");
|
||||
assert_eq!(format_ns_f64(999_999_999.0), "1000.0ms");
|
||||
assert_eq!(format_ns_f64(1_000_000_000.0), "1.000s");
|
||||
assert_eq!(format_ns_f64(1_500_000_000.0), "1.500s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_nanosecond_scalars_keep_their_sign() {
|
||||
assert_eq!(format_ns_f64(-500.0), "-500ns");
|
||||
assert_eq!(format_ns_f64(-1_500.0), "-1.5µs");
|
||||
assert_eq!(format_ns_f64(-2_500_000.0), "-2.5ms");
|
||||
assert_eq!(format_ns_f64(-1_000_000_000.0), "-1.000s");
|
||||
assert_eq!(format_ns_i128(-1_500), "-1.5µs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_and_floating_nanosecond_scalars_agree() {
|
||||
for value in [0_i128, 1, 999, 1_000, 1_234_567, -1_234_567, 5_000_000_000] {
|
||||
assert_eq!(format_ns_i128(value), format_ns_f64(value as f64));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn microseconds_use_the_micro_sign() {
|
||||
assert!(format_ns_f64(1_500.0).ends_with("µs"));
|
||||
assert!(!format_ns_f64(1_500.0).contains("us"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durations_render_as_nanosecond_scalars() {
|
||||
assert_eq!(format_duration(Duration::from_millis(25)), "25.0ms");
|
||||
assert_eq!(format_duration(Duration::from_secs(1)), "1.000s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "client")]
|
||||
fn signed_durations_render_as_nanosecond_scalars() {
|
||||
assert_eq!(
|
||||
format_signed_duration(SignedDuration::from_nanos(-1_500)),
|
||||
"-1.5µs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_and_non_finite_scalars_render_as_a_placeholder() {
|
||||
assert_eq!(format_optional_ns_i128(None), ABSENT);
|
||||
assert_eq!(format_ns_f64(f64::NAN), ABSENT);
|
||||
assert_eq!(format_ns_f64(f64::INFINITY), ABSENT);
|
||||
assert_eq!(format_percent(f64::NAN), ABSENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "client")]
|
||||
fn absent_optional_ns_f64_renders_as_a_placeholder() {
|
||||
assert_eq!(format_optional_ns_f64(None), ABSENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "tui")]
|
||||
fn absent_counts_and_ratios_render_as_a_placeholder() {
|
||||
assert_eq!(format_optional_count(None), ABSENT);
|
||||
assert_eq!(format_percent_ratio(1, 0), ABSENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentages_have_one_spelling() {
|
||||
assert_eq!(format_percent(0.0), "0.00%");
|
||||
assert_eq!(format_percent(12.345), "12.35%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "tui")]
|
||||
fn percent_ratios_and_counts_have_one_spelling() {
|
||||
assert_eq!(format_percent_ratio(1, 4), "25.00%");
|
||||
assert_eq!(format_percent_ratio(5, 4), "100.00%");
|
||||
assert_eq!(format_count(42), "42");
|
||||
assert_eq!(format_optional_count(Some(0)), "0");
|
||||
}
|
||||
}
|
||||
@@ -257,394 +257,3 @@ fn parse_duration_units(input: &str) -> Result<Duration, String> {
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::server::env_lock::with_env_lock;
|
||||
|
||||
fn parse(args: &[&str]) -> Result<ServerArgs, clap::Error> {
|
||||
with_env_lock(|| {
|
||||
let mut argv = vec!["irtt-server"];
|
||||
argv.extend_from_slice(args);
|
||||
ServerArgs::try_parse_from(argv)
|
||||
})
|
||||
}
|
||||
|
||||
fn bound(args: &[&str]) -> ServerArgs {
|
||||
let mut argv = vec!["--bind", "127.0.0.1:2112"];
|
||||
argv.extend_from_slice(args);
|
||||
parse(&argv).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_is_optional_and_parses_both_families() {
|
||||
let ipv4 = parse(&["--bind", "127.0.0.1:2112"]).unwrap();
|
||||
assert_eq!(ipv4.bind, [SocketAddr::from((Ipv4Addr::LOCALHOST, 2112))]);
|
||||
|
||||
let ipv6 = parse(&["--bind", "[::1]:2112"]).unwrap();
|
||||
assert_eq!(ipv6.bind, [SocketAddr::from((Ipv6Addr::LOCALHOST, 2112))]);
|
||||
|
||||
assert!(parse(&["--bind", "127.0.0.1"]).is_err());
|
||||
assert!(parse(&["--bind", "localhost:2112"]).is_err());
|
||||
// Still one address per option: comma-separated syntax is not a thing
|
||||
// here, and accepting it silently would be worse than rejecting it.
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112,[::1]:2112"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_explicit_bind_resolves_to_the_wildcard_default_pair() {
|
||||
let args = parse(&[]).unwrap();
|
||||
assert!(
|
||||
args.bind.is_empty(),
|
||||
"the empty list is the parsed representation of \"no explicit bind\""
|
||||
);
|
||||
assert_eq!(
|
||||
args.resolve_binds(),
|
||||
[
|
||||
SocketAddr::from((Ipv6Addr::UNSPECIFIED, 2112)),
|
||||
SocketAddr::from((Ipv4Addr::UNSPECIFIED, 2112)),
|
||||
],
|
||||
"zero explicit binds means the ordinary port on both wildcard families, IPv6 first"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_bind_replaces_the_defaults_rather_than_augmenting_them() {
|
||||
let args = parse(&["--bind", "127.0.0.1:2112"]).unwrap();
|
||||
assert_eq!(
|
||||
args.resolve_binds(),
|
||||
[SocketAddr::from((Ipv4Addr::LOCALHOST, 2112))],
|
||||
"one explicit bind means exactly that one listener, not that listener plus defaults"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_explicit_binds_are_unaffected_by_the_default_resolution() {
|
||||
let args = parse(&["--bind", "127.0.0.1:2112", "--bind", "[::1]:2112"]).unwrap();
|
||||
assert_eq!(args.resolve_binds(), args.bind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_is_repeatable_and_keeps_the_requested_order() {
|
||||
let args = parse(&[
|
||||
"--bind",
|
||||
"[::1]:2112",
|
||||
"--bind",
|
||||
"127.0.0.1:2112",
|
||||
"--bind",
|
||||
"127.0.0.1:2113",
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
args.bind,
|
||||
[
|
||||
SocketAddr::from((Ipv6Addr::LOCALHOST, 2112)),
|
||||
SocketAddr::from((Ipv4Addr::LOCALHOST, 2112)),
|
||||
SocketAddr::from((Ipv4Addr::LOCALHOST, 2113)),
|
||||
],
|
||||
"listeners are served in the order they were requested"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_configuration_is_one_policy_however_many_listeners_there_are() {
|
||||
// Addresses never enter the server configuration: every listener is
|
||||
// built from a clone of this one value.
|
||||
let one = parse(&["--bind", "127.0.0.1:2112", "--max-sessions", "4"])
|
||||
.unwrap()
|
||||
.server_config();
|
||||
let several = parse(&[
|
||||
"--bind",
|
||||
"127.0.0.1:2112",
|
||||
"--bind",
|
||||
"[::1]:2112",
|
||||
"--max-sessions",
|
||||
"4",
|
||||
])
|
||||
.unwrap()
|
||||
.server_config();
|
||||
|
||||
assert_eq!(one, several);
|
||||
assert_eq!(several.max_sessions(), 4, "and it is a per-listener bound");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_policy_options_keep_the_library_defaults() {
|
||||
assert_eq!(bound(&[]).server_config(), ServerConfig::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_options_map_onto_the_server_config() {
|
||||
let config = bound(&[
|
||||
"--hmac",
|
||||
"secret",
|
||||
"--max-sessions",
|
||||
"512",
|
||||
"--max-packet-length",
|
||||
"1472",
|
||||
"--min-interval",
|
||||
"20ms",
|
||||
"--burst",
|
||||
"3",
|
||||
"--idle-timeout",
|
||||
"30s",
|
||||
"--max-duration",
|
||||
"2m",
|
||||
])
|
||||
.server_config();
|
||||
|
||||
assert_eq!(config.hmac_key(), Some(b"secret".as_slice()));
|
||||
assert_eq!(config.max_sessions(), 512);
|
||||
assert_eq!(config.max_packet_length(), 1472);
|
||||
assert_eq!(config.min_send_interval(), Duration::from_millis(20));
|
||||
assert_eq!(config.burst_allowance(), 3);
|
||||
assert_eq!(config.idle_timeout(), Duration::from_secs(30));
|
||||
assert_eq!(config.max_test_duration(), Some(Duration::from_secs(120)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_capability_restrictions_map_onto_the_server_config() {
|
||||
let config = bound(&["--timestamp-allowance", "single", "--no-dscp"]).server_config();
|
||||
|
||||
assert_eq!(
|
||||
config.timestamp_allowance(),
|
||||
TimestampAllowance::Single,
|
||||
"a request for both timestamps is negotiated to midpoint"
|
||||
);
|
||||
assert!(!config.dscp_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_timestamp_allowance_mode_parses() {
|
||||
for (argument, expected) in [
|
||||
("dual", TimestampAllowance::Dual),
|
||||
("single", TimestampAllowance::Single),
|
||||
("none", TimestampAllowance::None),
|
||||
] {
|
||||
assert_eq!(
|
||||
bound(&["--timestamp-allowance", argument])
|
||||
.server_config()
|
||||
.timestamp_allowance(),
|
||||
expected,
|
||||
"--timestamp-allowance {argument}"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(parse(&[
|
||||
"--bind",
|
||||
"127.0.0.1:2112",
|
||||
"--timestamp-allowance",
|
||||
"midpoint"
|
||||
])
|
||||
.is_err());
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--timestamp-allowance", "both"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_capability_restrictions_are_off_when_their_flags_are_absent() {
|
||||
// Explicitly, and not only through the whole-config comparison above:
|
||||
// these are the two values that would silently change every existing
|
||||
// deployment's negotiation if the CLI restated them.
|
||||
let config = bound(&["--idle-timeout", "30s"]).server_config();
|
||||
|
||||
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Dual);
|
||||
assert!(config.dscp_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_zero_policy_values_are_preserved() {
|
||||
let config = bound(&[
|
||||
"--min-interval",
|
||||
"0",
|
||||
"--idle-timeout",
|
||||
"0ms",
|
||||
"--burst",
|
||||
"0",
|
||||
"--max-sessions",
|
||||
"0",
|
||||
"--max-packet-length",
|
||||
"0",
|
||||
])
|
||||
.server_config();
|
||||
|
||||
assert_eq!(config.min_send_interval(), Duration::ZERO);
|
||||
assert_eq!(config.idle_timeout(), Duration::ZERO);
|
||||
assert_eq!(config.burst_allowance(), 0);
|
||||
assert_eq!(config.max_sessions(), 0);
|
||||
assert_eq!(config.max_packet_length(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durations_require_a_supported_unit() {
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--idle-timeout", "30"]).is_err());
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--min-interval", "20us"]).is_err());
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "2h"]).is_err());
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--idle-timeout", "ms"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_hmac_key_is_rejected() {
|
||||
let err = parse(&["--bind", "127.0.0.1:2112", "--hmac", ""]).unwrap_err();
|
||||
assert!(err.to_string().contains("HMAC key must not be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whitespace_only_hmac_key_is_accepted_literally() {
|
||||
let config = bound(&["--hmac", " "]).server_config();
|
||||
assert_eq!(config.hmac_key(), Some(b" ".as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_maximum_duration_is_not_a_spelling_of_no_maximum() {
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0"]).is_err());
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0s"]).is_err());
|
||||
assert_eq!(bound(&[]).server_config().max_test_duration(), None);
|
||||
}
|
||||
|
||||
/// Runs `body` with the given environment variables set, restoring
|
||||
/// whatever was there before (present or absent) once `body` returns.
|
||||
/// Held under `with_env_lock` for the whole span, including `body`, so a
|
||||
/// `parse` call elsewhere never observes a partially-applied environment.
|
||||
fn with_env<T>(vars: &[(&str, &str)], body: impl FnOnce() -> T) -> T {
|
||||
with_env_lock(|| {
|
||||
let previous: Vec<(&str, Option<String>)> = vars
|
||||
.iter()
|
||||
.map(|(k, _)| (*k, std::env::var(k).ok()))
|
||||
.collect();
|
||||
for (k, v) in vars {
|
||||
std::env::set_var(k, v);
|
||||
}
|
||||
let result = body();
|
||||
for (k, v) in previous {
|
||||
match v {
|
||||
Some(v) => std::env::set_var(k, v),
|
||||
None => std::env::remove_var(k),
|
||||
}
|
||||
}
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_variables_supply_policy_when_no_flag_is_given() {
|
||||
with_env(
|
||||
&[
|
||||
("IRTT_SERVER_MAX_SESSIONS", "512"),
|
||||
("IRTT_SERVER_MAX_PACKET_LENGTH", "1472"),
|
||||
("IRTT_SERVER_MIN_INTERVAL", "20ms"),
|
||||
("IRTT_SERVER_BURST", "3"),
|
||||
("IRTT_SERVER_IDLE_TIMEOUT", "30s"),
|
||||
("IRTT_SERVER_MAX_DURATION", "2m"),
|
||||
("IRTT_SERVER_TIMESTAMP_ALLOWANCE", "single"),
|
||||
("IRTT_SERVER_HMAC", "secret"),
|
||||
("IRTT_SERVER_NO_DSCP", "true"),
|
||||
],
|
||||
|| {
|
||||
let config = parse(&["--bind", "127.0.0.1:2112"])
|
||||
.unwrap()
|
||||
.server_config();
|
||||
assert_eq!(config.max_sessions(), 512);
|
||||
assert_eq!(config.max_packet_length(), 1472);
|
||||
assert_eq!(config.min_send_interval(), Duration::from_millis(20));
|
||||
assert_eq!(config.burst_allowance(), 3);
|
||||
assert_eq!(config.idle_timeout(), Duration::from_secs(30));
|
||||
assert_eq!(config.max_test_duration(), Some(Duration::from_secs(120)));
|
||||
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Single);
|
||||
assert_eq!(config.hmac_key(), Some(b"secret".as_slice()));
|
||||
assert!(!config.dscp_allowed());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// `IRTT_SERVER_NO_DSCP` is the one policy variable whose value is a
|
||||
/// boolean rather than a number, a duration or a string, and the
|
||||
/// container/orchestrator deployments this whole environment surface
|
||||
/// exists for spell booleans `1`/`0`. clap's default parser for a flag
|
||||
/// accepts only the literals `true`/`false` and turns everything else
|
||||
/// into a *parse failure*, so `IRTT_SERVER_NO_DSCP=1` used to stop the
|
||||
/// server from starting at all rather than enabling the flag. Falsey
|
||||
/// semantics are what keeps that from being a startup failure.
|
||||
#[test]
|
||||
fn the_no_dscp_environment_variable_accepts_ordinary_boolean_spellings() {
|
||||
for value in ["1", "true", "TRUE", "yes", "on", "enabled"] {
|
||||
with_env(&[("IRTT_SERVER_NO_DSCP", value)], || {
|
||||
let config = parse(&["--bind", "127.0.0.1:2112"])
|
||||
.expect("a truthy value must not fail argument parsing")
|
||||
.server_config();
|
||||
assert!(!config.dscp_allowed(), "IRTT_SERVER_NO_DSCP={value}");
|
||||
});
|
||||
}
|
||||
|
||||
for value in ["0", "false", "no", "off", ""] {
|
||||
with_env(&[("IRTT_SERVER_NO_DSCP", value)], || {
|
||||
let config = parse(&["--bind", "127.0.0.1:2112"])
|
||||
.expect("a falsey value must not fail argument parsing")
|
||||
.server_config();
|
||||
assert!(config.dscp_allowed(), "IRTT_SERVER_NO_DSCP={value}");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The command-line flag stays a flag: it takes no value, whatever the
|
||||
/// environment parser accepts.
|
||||
#[test]
|
||||
fn the_no_dscp_flag_still_takes_no_command_line_value() {
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--no-dscp=false"]).is_err());
|
||||
assert!(!bound(&["--no-dscp"]).server_config().dscp_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_flag_overrides_the_environment() {
|
||||
with_env(&[("IRTT_SERVER_MAX_SESSIONS", "512")], || {
|
||||
let config = parse(&["--bind", "127.0.0.1:2112", "--max-sessions", "4"])
|
||||
.unwrap()
|
||||
.server_config();
|
||||
assert_eq!(
|
||||
config.max_sessions(),
|
||||
4,
|
||||
"the command-line value wins over the environment"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_environment_wins_over_the_built_in_default() {
|
||||
with_env(&[("IRTT_SERVER_BURST", "9")], || {
|
||||
let config = bound(&[]).server_config();
|
||||
assert_eq!(
|
||||
config.burst_allowance(),
|
||||
9,
|
||||
"an unset flag still falls back to the environment before the library default"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn irtt_server_bind_supplies_a_single_address_from_the_environment() {
|
||||
with_env(&[("IRTT_SERVER_BIND", "127.0.0.1:2113")], || {
|
||||
let args = parse(&[]).unwrap();
|
||||
assert_eq!(args.bind, [SocketAddr::from((Ipv4Addr::LOCALHOST, 2113))]);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_bind_flag_overrides_irtt_server_bind() {
|
||||
with_env(&[("IRTT_SERVER_BIND", "127.0.0.1:2113")], || {
|
||||
let args = parse(&["--bind", "[::1]:2112"]).unwrap();
|
||||
assert_eq!(args.bind, [SocketAddr::from((Ipv6Addr::LOCALHOST, 2112))]);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_irtt_server_bind_unset_keeps_the_wildcard_default() {
|
||||
let args = parse(&[]).unwrap();
|
||||
assert!(
|
||||
args.bind.is_empty(),
|
||||
"with no --bind and no IRTT_SERVER_BIND, resolve_binds still falls back to the wildcard pair"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
//! Serializes every test in this module tree that touches process
|
||||
//! environment variables.
|
||||
//!
|
||||
//! `ServerArgs` parsing reads `IRTT_SERVER_*` via `clap`'s `env` support, and
|
||||
//! `std::env::set_var`/`remove_var` mutate genuinely process-wide state.
|
||||
//! Every test that parses `ServerArgs` — whether or not it sets an env var
|
||||
//! itself — must go through [`with_env_lock`], or a parse in one test thread
|
||||
//! can observe a value another thread's test set only for the duration of
|
||||
//! its own assertions. Reentrant per-thread, since a test that sets env vars
|
||||
//! typically calls `parse` again from inside its own locked section.
|
||||
|
||||
use std::{cell::Cell, sync::Mutex};
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
thread_local! {
|
||||
static ENV_LOCK_HELD: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
pub(crate) fn with_env_lock<T>(f: impl FnOnce() -> T) -> T {
|
||||
if ENV_LOCK_HELD.with(Cell::get) {
|
||||
return f();
|
||||
}
|
||||
let _guard = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
ENV_LOCK_HELD.with(|held| held.set(true));
|
||||
let result = f();
|
||||
ENV_LOCK_HELD.with(|held| held.set(false));
|
||||
result
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
pub mod args;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod env_lock;
|
||||
pub mod run;
|
||||
|
||||
pub use args::ServerArgs;
|
||||
|
||||
@@ -105,105 +105,3 @@ async fn shutdown_signal(shutdown_requested: &AtomicBool) {
|
||||
tokio::time::sleep(SHUTDOWN_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use clap::Parser;
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::server::env_lock::with_env_lock;
|
||||
|
||||
/// The ordinary invocation, and the one that keeps the single-listener case
|
||||
/// on the same orchestration path as every other.
|
||||
#[test]
|
||||
fn an_already_requested_shutdown_binds_one_listener_and_returns() {
|
||||
let args = with_env_lock(|| {
|
||||
ServerArgs::try_parse_from(["irtt-server", "--bind", "127.0.0.1:0"]).unwrap()
|
||||
});
|
||||
let shutdown_requested = AtomicBool::new(true);
|
||||
|
||||
run_server(args, &shutdown_requested).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_already_requested_shutdown_binds_every_listener_and_returns() {
|
||||
let args = with_env_lock(|| {
|
||||
ServerArgs::try_parse_from([
|
||||
"irtt-server",
|
||||
"--bind",
|
||||
"127.0.0.1:0",
|
||||
"--bind",
|
||||
"127.0.0.1:0",
|
||||
])
|
||||
.unwrap()
|
||||
});
|
||||
let shutdown_requested = AtomicBool::new(true);
|
||||
|
||||
run_server(args, &shutdown_requested).unwrap();
|
||||
}
|
||||
|
||||
fn default_binds() -> Vec<SocketAddr> {
|
||||
with_env_lock(|| {
|
||||
ServerArgs::try_parse_from(["irtt-server"])
|
||||
.unwrap()
|
||||
.resolve_binds()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implicit_default_losing_an_unsupported_family_falls_back_to_the_other() {
|
||||
let binds = default_binds();
|
||||
let failed = binds[0];
|
||||
|
||||
let remaining = implicit_default_fallback(true, false, &binds, failed).unwrap();
|
||||
|
||||
assert_eq!(remaining, [binds[1]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_supported_familys_failure_never_falls_back() {
|
||||
// The family itself works fine here; whatever made this specific
|
||||
// bind fail (port in use, permission, ...) is a real problem this
|
||||
// invocation should surface, not paper over.
|
||||
let binds = default_binds();
|
||||
|
||||
assert_eq!(
|
||||
implicit_default_fallback(true, true, &binds, binds[0]),
|
||||
None,
|
||||
"family_available: true means this is not a family-support problem"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_binds_never_fall_back() {
|
||||
let binds = default_binds();
|
||||
|
||||
assert_eq!(
|
||||
implicit_default_fallback(false, false, &binds, binds[0]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_outside_the_requested_set_does_not_fall_back() {
|
||||
let binds = default_binds();
|
||||
let unrelated = SocketAddr::from(([127, 0, 0, 1], 2113));
|
||||
|
||||
assert_eq!(
|
||||
implicit_default_fallback(true, false, &binds, unrelated),
|
||||
None,
|
||||
"the failing address must actually be one this invocation asked for"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn losing_the_only_remaining_default_address_does_not_fall_back() {
|
||||
let single = [SocketAddr::from(([0, 0, 0, 0], 2112))];
|
||||
|
||||
assert_eq!(
|
||||
implicit_default_fallback(true, false, &single, single[0]),
|
||||
None,
|
||||
"no address would be left to retry with"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -73,171 +73,3 @@ impl std::ops::Deref for TuiArgs {
|
||||
&self.common
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::{CommandFactory, Parser};
|
||||
|
||||
#[derive(Parser)]
|
||||
struct SharedOnlyArgs {
|
||||
#[command(flatten)]
|
||||
common: CommonClientArgs,
|
||||
}
|
||||
|
||||
fn parse(args: &[&str]) -> Result<TuiArgs, clap::Error> {
|
||||
let mut argv = vec!["irtt-tui"];
|
||||
argv.extend_from_slice(args);
|
||||
TuiArgs::try_parse_from(argv)
|
||||
}
|
||||
|
||||
fn parse_shared(args: &[&str]) -> Result<SharedOnlyArgs, clap::Error> {
|
||||
let mut argv = vec!["shared-only"];
|
||||
argv.extend_from_slice(args);
|
||||
SharedOnlyArgs::try_parse_from(argv)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tui_parser_defaults_to_continuous_and_has_no_output_option() {
|
||||
let args = parse(&["127.0.0.1:2112"]).unwrap();
|
||||
assert_eq!(args.targets.len(), 1);
|
||||
assert_eq!(args.prepare().unwrap().targets[0].label, "127.0.0.1:2112");
|
||||
assert_eq!(args.pacing, GroupPacingArg::Staggered);
|
||||
assert_eq!(args.duration, DEFAULT_TUI_DURATION);
|
||||
assert!(args.is_continuous());
|
||||
assert_eq!(args.prepare().unwrap().client.duration, None);
|
||||
|
||||
let finite = parse(&["--duration", "30s", "127.0.0.1:2112"]).unwrap();
|
||||
assert_eq!(finite.duration, Duration::from_secs(30));
|
||||
assert_eq!(
|
||||
finite.prepare().unwrap().client.duration,
|
||||
Some(Duration::from_secs(30))
|
||||
);
|
||||
|
||||
assert!(parse(&["--output", "human", "127.0.0.1:2112"]).is_err());
|
||||
let help = TuiArgs::command().render_help().to_string();
|
||||
assert!(!help.contains("--output"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_positional_targets_parse() {
|
||||
let args = parse(&["host-a:2112", "host-b:2112"]).unwrap();
|
||||
let specs = args.prepare().unwrap().targets;
|
||||
|
||||
assert_eq!(specs[0].label, "host-a:2112");
|
||||
assert_eq!(specs[0].managed.server_addr, "host-a:2112");
|
||||
assert_eq!(specs[1].label, "host-b:2112");
|
||||
assert_eq!(specs[1].managed.server_addr, "host-b:2112");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_labeled_targets_parse() {
|
||||
let args = parse(&["ams=ams.example.com:2112", "sg=sg.example.com:2112"]).unwrap();
|
||||
let specs = args.prepare().unwrap().targets;
|
||||
|
||||
assert_eq!(specs[0].label, "ams");
|
||||
assert_eq!(specs[0].managed.server_addr, "ams.example.com:2112");
|
||||
assert_eq!(specs[1].label, "sg");
|
||||
assert_eq!(specs[1].managed.server_addr, "sg.example.com:2112");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_least_one_target_is_required() {
|
||||
// The TUI has no target-free mode, so this is rejected at parse time
|
||||
// rather than during preparation.
|
||||
assert!(parse(&[]).is_err());
|
||||
assert!(parse(&["ams=ams.example.com:2112"]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_target_option_is_rejected() {
|
||||
assert!(parse(&["--target", "eu=host.example"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pacing_option_accepts_supported_values() {
|
||||
assert_eq!(
|
||||
parse(&["--pacing", "staggered", "127.0.0.1:2112"])
|
||||
.unwrap()
|
||||
.pacing,
|
||||
GroupPacingArg::Staggered
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&["--pacing", "burst", "127.0.0.1:2112"])
|
||||
.unwrap()
|
||||
.pacing,
|
||||
GroupPacingArg::Burst
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_client_options_match_tui_config_mapping() {
|
||||
let shared = [
|
||||
"--interval",
|
||||
"250ms",
|
||||
"--length",
|
||||
"128",
|
||||
"--hmac",
|
||||
"secret",
|
||||
"--clock",
|
||||
"monotonic",
|
||||
"--tstamp",
|
||||
"receive",
|
||||
"--stats",
|
||||
"count",
|
||||
"--sfill",
|
||||
"abc",
|
||||
"--dscp",
|
||||
"46",
|
||||
"--ttl",
|
||||
"64",
|
||||
"--loose",
|
||||
];
|
||||
let tui_args = [
|
||||
"--duration",
|
||||
"30s",
|
||||
"--interval",
|
||||
"250ms",
|
||||
"--length",
|
||||
"128",
|
||||
"--hmac",
|
||||
"secret",
|
||||
"--clock",
|
||||
"monotonic",
|
||||
"--tstamp",
|
||||
"receive",
|
||||
"--stats",
|
||||
"count",
|
||||
"--sfill",
|
||||
"abc",
|
||||
"--dscp",
|
||||
"46",
|
||||
"--ttl",
|
||||
"64",
|
||||
"--loose",
|
||||
"127.0.0.1:2112",
|
||||
];
|
||||
let tui = parse(&tui_args).unwrap().prepare().unwrap().client;
|
||||
let common = parse_shared(&shared).unwrap().common;
|
||||
let shared = common.to_client_config(Duration::from_secs(30));
|
||||
|
||||
assert_eq!(shared.duration, tui.duration);
|
||||
assert_eq!(shared.interval, tui.interval);
|
||||
assert_eq!(shared.length, tui.length);
|
||||
assert_eq!(shared.received_stats, tui.received_stats);
|
||||
assert_eq!(shared.stamp_at, tui.stamp_at);
|
||||
assert_eq!(shared.clock, tui.clock);
|
||||
assert_eq!(shared.dscp, tui.dscp);
|
||||
assert_eq!(shared.hmac_key, tui.hmac_key);
|
||||
assert_eq!(shared.server_fill, tui.server_fill);
|
||||
assert_eq!(shared.negotiation_policy, tui.negotiation_policy);
|
||||
assert_eq!(shared.socket_config.ttl, tui.socket_config.ttl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tui_help_lists_multi_target_options() {
|
||||
let help = TuiArgs::command().render_help().to_string();
|
||||
assert!(!help.contains("--target "));
|
||||
assert!(help.contains("--pacing <PACING>"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -81,61 +81,3 @@ pub fn request_managed_stop_once(stop_requested: &mut bool) -> bool {
|
||||
*stop_requested = true;
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn peer_close_run_error_is_run_mode_and_interruption_aware() {
|
||||
assert_eq!(peer_close_run_error(false, false, 1), None);
|
||||
assert_eq!(peer_close_run_error(true, false, 0), None);
|
||||
assert_eq!(peer_close_run_error(true, true, 1), None);
|
||||
assert_eq!(
|
||||
peer_close_run_error(true, false, 1).as_deref(),
|
||||
Some("continuous run ended because of peer closure (1 target session)")
|
||||
);
|
||||
assert_eq!(
|
||||
peer_close_run_error(true, false, 3).as_deref(),
|
||||
Some("continuous run ended because of peer closure (3 target sessions)")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouped_peer_close_stop_policy_is_run_mode_and_one_shot() {
|
||||
let mut stop_requested = false;
|
||||
assert!(!request_managed_stop_for_peer_close(
|
||||
false,
|
||||
false,
|
||||
1,
|
||||
&mut stop_requested,
|
||||
));
|
||||
assert!(!request_managed_stop_for_peer_close(
|
||||
true,
|
||||
true,
|
||||
1,
|
||||
&mut stop_requested,
|
||||
));
|
||||
assert!(!request_managed_stop_for_peer_close(
|
||||
true,
|
||||
false,
|
||||
0,
|
||||
&mut stop_requested,
|
||||
));
|
||||
assert!(!stop_requested);
|
||||
|
||||
assert!(request_managed_stop_for_peer_close(
|
||||
true,
|
||||
false,
|
||||
1,
|
||||
&mut stop_requested,
|
||||
));
|
||||
assert!(stop_requested);
|
||||
assert!(!request_managed_stop_for_peer_close(
|
||||
true,
|
||||
false,
|
||||
1,
|
||||
&mut stop_requested,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -322,170 +322,3 @@ pub fn parse_stdin_target_set(
|
||||
let args = elements.into_iter().map(TargetArg::new).collect::<Vec<_>>();
|
||||
prepare_managed_targets(target_specs_with_empty(&args, true)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn target(input: &str) -> TargetArg {
|
||||
TargetArg::new(input)
|
||||
}
|
||||
|
||||
fn specs(inputs: &[&str]) -> Result<Vec<TargetSpec>, String> {
|
||||
target_specs(&inputs.iter().map(|input| target(input)).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_target_syntax_and_ipv6_remain_supported() {
|
||||
let parsed = specs(&[
|
||||
"host.example",
|
||||
"host.example:2112",
|
||||
"[::1]:2112",
|
||||
"eu=host.example",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(parsed[0].label, "host.example");
|
||||
assert_eq!(parsed[1].label, "host.example:2112");
|
||||
assert_eq!(parsed[2].label, "[::1]:2112");
|
||||
assert_eq!(parsed[3].label, "eu");
|
||||
assert!(parsed
|
||||
.iter()
|
||||
.all(|target| matches!(target.auth, TargetAuth::Inherit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_hmac_supports_inherit_override_disable_and_base64_padding() {
|
||||
let parsed = specs(&[
|
||||
"default=default.example",
|
||||
"private=private.example@hmac=YWJjZA==",
|
||||
"public=public.example@hmac=",
|
||||
])
|
||||
.unwrap();
|
||||
assert!(matches!(parsed[0].auth, TargetAuth::Inherit));
|
||||
assert_eq!(parsed[1].auth, TargetAuth::Override(b"YWJjZA==".to_vec()));
|
||||
assert!(matches!(parsed[2].auth, TargetAuth::Disable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_delimiters_are_escaped_contextually() {
|
||||
let parsed = specs(&["eu\\=west=host\\@hmac=literal@hmac=a\\@b\\,c\\=d\\\\e"]).unwrap();
|
||||
assert_eq!(parsed[0].label, "eu=west");
|
||||
assert_eq!(parsed[0].addr, "host@hmac=literal");
|
||||
assert_eq!(parsed[0].auth, TargetAuth::Override(b"a@b,c=d\\e".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positional_commas_are_not_reserved() {
|
||||
let parsed = specs(&["edge=host,region@hmac=key,part"]).unwrap();
|
||||
assert_eq!(parsed[0].addr, "host,region");
|
||||
assert_eq!(parsed[0].auth, TargetAuth::Override(b"key,part".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_diagnostics_and_debug_do_not_reveal_hmac_values() {
|
||||
let secret = "very-secret-value";
|
||||
let error = specs(&[&format!("target=host@hmac={secret}\\q")]).unwrap_err();
|
||||
assert!(!error.contains(secret));
|
||||
assert!(!format!("{:?}", target(&format!("target=host@hmac={secret}"))).contains(secret));
|
||||
assert!(
|
||||
!format!("{:?}", TargetAuth::Override(secret.as_bytes().to_vec())).contains(secret)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_suffix_repeated_unlabeled_and_reject_duplicate_labels() {
|
||||
let parsed = specs(&["host-a:2112", "host-a:2112"]).unwrap();
|
||||
assert_eq!(parsed[0].label, "host-a:2112");
|
||||
assert_eq!(parsed[1].label, "host-a:2112#2");
|
||||
assert_eq!(
|
||||
specs(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap_err(),
|
||||
"duplicate target label"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_preserve_argument_order_and_unlabeled_counts() {
|
||||
let parsed = specs(&["local", "eu=eu.example", "foo", "us=us.example", "foo"]).unwrap();
|
||||
let labels: Vec<_> = parsed.iter().map(|target| target.label.as_str()).collect();
|
||||
assert_eq!(labels, ["local", "eu", "foo", "us", "foo#2"]);
|
||||
let addresses: Vec<_> = parsed.iter().map(|target| target.addr.as_str()).collect();
|
||||
assert_eq!(
|
||||
addresses,
|
||||
["local", "eu.example", "foo", "us.example", "foo"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_reject_generated_suffix_collisions() {
|
||||
assert_eq!(
|
||||
specs(&[
|
||||
"host.example",
|
||||
"host.example",
|
||||
"host.example#2=other.example"
|
||||
])
|
||||
.unwrap_err(),
|
||||
"duplicate target label"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_target_syntax_is_rejected_without_echoing_input() {
|
||||
for input in ["=host", "label=", "host@hmac=value\\q", "host\\x"] {
|
||||
assert!(specs(&[input]).is_err(), "{input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_preparation_maps_auth_to_managed_config() {
|
||||
let prepared =
|
||||
prepare_managed_targets(specs(&["a=one@hmac=key", "b=two@hmac="]).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
prepared[0]
|
||||
.managed
|
||||
.auth
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.hmac_key
|
||||
.as_deref(),
|
||||
Some(b"key".as_slice())
|
||||
);
|
||||
assert_eq!(prepared[1].managed.auth.as_ref().unwrap().hmac_key, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_preparation_preserves_duplicate_endpoints() {
|
||||
let prepared =
|
||||
prepare_managed_targets(specs(&["a=example.test", "b=example.test"]).unwrap()).unwrap();
|
||||
assert_eq!(prepared[0].managed.server_addr, "example.test");
|
||||
assert_eq!(prepared[1].managed.server_addr, "example.test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_target_sets_frame_commas_without_trimming_payload() {
|
||||
let parsed = parse_stdin_target_set("a=one@hmac=x\\,y,b=two", 128).unwrap();
|
||||
assert_eq!(
|
||||
parsed[0].managed.auth.as_ref().unwrap().hmac_key.as_deref(),
|
||||
Some(b"x,y".as_slice())
|
||||
);
|
||||
assert_eq!(parsed[1].label, "b");
|
||||
assert_eq!(
|
||||
parse_stdin_target_set(" []", 128).unwrap()[0]
|
||||
.managed
|
||||
.server_addr,
|
||||
" []"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_stdin_target_set("[] ", 128).unwrap()[0]
|
||||
.managed
|
||||
.server_addr,
|
||||
"[] "
|
||||
);
|
||||
assert!(parse_stdin_target_set("", 128).is_err());
|
||||
assert!(parse_stdin_target_set("a=one,b=two", 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_empty_set_is_exactly_bracket_pair() {
|
||||
assert!(parse_stdin_target_set("[]", 128).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
#![cfg(all(feature = "client", feature = "server", feature = "tui"))]
|
||||
|
||||
#[path = "../../irtt-integration-tests/tests/support/in_tree_server.rs"]
|
||||
mod in_tree_server;
|
||||
|
||||
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.
|
||||
#[test]
|
||||
fn applets_dispatch_validate_arguments_and_export_a_real_client_run() {
|
||||
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"),
|
||||
] {
|
||||
assert!(Command::new(binary)
|
||||
.arg("--help")
|
||||
.output()
|
||||
.unwrap()
|
||||
.status
|
||||
.success());
|
||||
assert!(!Command::new(binary)
|
||||
.arg("--invalid-option")
|
||||
.output()
|
||||
.unwrap()
|
||||
.status
|
||||
.success());
|
||||
}
|
||||
for option in ["--dscp", "--ttl"] {
|
||||
assert!(!Command::new(dispatcher)
|
||||
.args(["client", option, "256", "127.0.0.1:9"])
|
||||
.output()
|
||||
.unwrap()
|
||||
.status
|
||||
.success());
|
||||
}
|
||||
let server =
|
||||
in_tree_server::InTreeServer::start(ServerConfig::default().with_hmac_key(b"cli-test"));
|
||||
let output = Command::new(dispatcher)
|
||||
.args([
|
||||
"client",
|
||||
"--duration",
|
||||
"100ms",
|
||||
"--interval",
|
||||
"20ms",
|
||||
"--hmac",
|
||||
"cli-test",
|
||||
"--length",
|
||||
"128",
|
||||
"--sfill",
|
||||
"pattern:ab",
|
||||
"--clock",
|
||||
"wall",
|
||||
"--tstamp",
|
||||
"both",
|
||||
"--stats",
|
||||
"both",
|
||||
"--ttl",
|
||||
"64",
|
||||
"--format",
|
||||
"jsonl",
|
||||
"--columns",
|
||||
"target,event,bytes",
|
||||
&format!("edge={}", server.addr),
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
let rows: Vec<_> = stdout.lines().collect();
|
||||
assert!(!rows.is_empty());
|
||||
assert!(rows.iter().all(|row| row.starts_with('{')
|
||||
&& row.ends_with('}')
|
||||
&& row.contains("\"target\":\"edge\"")));
|
||||
assert!(rows
|
||||
.iter()
|
||||
.any(|row| row.contains("\"event\":\"echo_reply\"") && row.contains("\"bytes\":128")));
|
||||
assert!(rows
|
||||
.iter()
|
||||
.any(|row| row.contains("\"event\":\"session_closed\"")));
|
||||
}
|
||||
@@ -93,6 +93,29 @@ risks clearing readiness state that the normal Echo receive path depends on.
|
||||
There is no background task or second `AsyncFd` around the Tokio-owned
|
||||
socket.
|
||||
|
||||
### Testing kernel timestamp behavior
|
||||
|
||||
The requirements above describe production behavior, not a required unit-test
|
||||
architecture.
|
||||
|
||||
Prefer Linux tests that exercise the real socket and kernel timestamp path
|
||||
through production APIs. A test intended to prove TX timestamp support must
|
||||
actually require and observe a kernel TX timestamp when running on a supported
|
||||
Linux environment; accepting the userspace fallback does not prove that path.
|
||||
|
||||
Likewise, prefer observable socket/client behavior for genuine error-queue
|
||||
errors and bounded draining where it can reasonably be induced.
|
||||
|
||||
Do not restore synthetic `ControlMessageOwned` permutations, classifier-unit
|
||||
suites, raw ancillary fixtures, test-only socket hooks, or other internal seams
|
||||
solely to cover every `MSG_ERRQUEUE` branch. `nix` owns ancillary-message
|
||||
decoding and ABI safety; `irtt-client` tests should primarily prove its own
|
||||
observable interpretation and behavior through the real path.
|
||||
|
||||
If some invariant cannot reasonably be induced through a production interface,
|
||||
a small focused internal test may be retained, but the burden is on that test to
|
||||
justify why integration coverage is insufficient.
|
||||
|
||||
## Retained state
|
||||
|
||||
`PendingProbe` carries an optional `kernel_tx_timestamp: Option<SystemTime>`
|
||||
|
||||
@@ -42,9 +42,3 @@ doc-scrape-examples = true
|
||||
name = "managed_client"
|
||||
required-features = ["tokio"]
|
||||
doc-scrape-examples = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Keep the packaged client testable against the latest published server while
|
||||
# workspace tests continue to exercise the local source through this path.
|
||||
irtt-server = { version = "0.5.1", path = "../irtt-server" }
|
||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] }
|
||||
@@ -886,6 +886,3 @@ fn poll_readable_until(
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,82 +0,0 @@
|
||||
//! A real `irtt-server` running in-process, for tests that need to prove
|
||||
//! `AsyncClient` and the blocking `Client` are equivalent against a real peer.
|
||||
//!
|
||||
//! This duplicates `crates/irtt-integration-tests/tests/support/in_tree_server.rs`
|
||||
//! rather than depending on it: this module backs private (`super::*`) unit
|
||||
//! tests here, while the integration crate covers black-box client/server
|
||||
//! behavior, and neither can depend on the other's test-only code.
|
||||
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
sync::mpsc,
|
||||
thread::{self, JoinHandle},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use irtt_server::{Server, ServerConfig};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
const STARTUP_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
pub struct InTreeServer {
|
||||
pub addr: SocketAddr,
|
||||
shutdown: Option<oneshot::Sender<()>>,
|
||||
done: Option<JoinHandle<Result<(), irtt_server::ServerRuntimeError>>>,
|
||||
}
|
||||
|
||||
impl InTreeServer {
|
||||
pub fn start(config: ServerConfig) -> Self {
|
||||
let (startup_tx, startup_rx) = mpsc::sync_channel(1);
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
let done = thread::spawn(move || {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("failed to build in-tree test server runtime");
|
||||
runtime.block_on(async move {
|
||||
let bind_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
|
||||
let mut server = Server::bind(bind_addr, config).await?;
|
||||
startup_tx
|
||||
.send(server.local_addr()?)
|
||||
.expect("test stopped waiting for in-tree server startup");
|
||||
server
|
||||
.run(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
})
|
||||
});
|
||||
|
||||
let addr = match startup_rx.recv_timeout(STARTUP_TIMEOUT) {
|
||||
Ok(addr) => addr,
|
||||
Err(error) => {
|
||||
let _ = shutdown_tx.send(());
|
||||
let result = done.join().expect("in-tree test server thread panicked");
|
||||
panic!("in-tree test server failed before startup ({error}): {result:?}");
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
addr,
|
||||
shutdown: Some(shutdown_tx),
|
||||
done: Some(done),
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_inner(&mut self) {
|
||||
if let Some(shutdown) = self.shutdown.take() {
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
if let Some(done) = self.done.take() {
|
||||
done.join()
|
||||
.expect("in-tree test server thread panicked")
|
||||
.expect("in-tree test server stopped with an error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InTreeServer {
|
||||
fn drop(&mut self) {
|
||||
self.stop_inner();
|
||||
}
|
||||
}
|
||||
@@ -613,6 +613,3 @@ pub(crate) fn validate_datagram_length(expected: usize, actual: usize) -> Result
|
||||
Err(ClientError::DatagramLengthMismatch { expected, actual })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,126 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[cfg(not(any(
|
||||
target_os = "fuchsia",
|
||||
target_os = "redox",
|
||||
target_os = "solaris",
|
||||
target_os = "illumos",
|
||||
target_os = "haiku",
|
||||
)))]
|
||||
fn normal_open_applies_negotiated_traffic_class_after_open_and_close_clears_it() {
|
||||
let mut params = default_params();
|
||||
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF): a
|
||||
// compliant server echoes back the raw byte it received, unchanged.
|
||||
params.dscp = 184;
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut config = default_test_config(server.addr);
|
||||
config.dscp = 46;
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
socket2::SockRef::from(&client.socket).tos_v4().unwrap() & 0xfc,
|
||||
0
|
||||
);
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert_eq!(
|
||||
socket2::SockRef::from(&client.socket).tos_v4().unwrap() & 0xfc,
|
||||
184
|
||||
);
|
||||
|
||||
client.close().unwrap();
|
||||
assert_eq!(socket2::SockRef::from(&client.socket).tos_v4().unwrap(), 0);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(any(
|
||||
target_os = "fuchsia",
|
||||
target_os = "redox",
|
||||
target_os = "solaris",
|
||||
target_os = "illumos",
|
||||
target_os = "haiku",
|
||||
)))]
|
||||
fn normal_open_uses_negotiated_traffic_class_not_requested_dscp() {
|
||||
let mut returned = default_params();
|
||||
returned.dscp = 0;
|
||||
let server = open_success_server(returned);
|
||||
|
||||
let mut config = default_test_config(server.addr);
|
||||
config.dscp = 46;
|
||||
config.negotiation_policy = NegotiationPolicy::Loose;
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
let negotiated = assert_open_started(client.open().unwrap());
|
||||
assert_eq!(negotiated.params.dscp, 0);
|
||||
assert_eq!(
|
||||
negotiated.restrictions,
|
||||
vec![crate::NegotiationRestriction::DscpChanged {
|
||||
requested: 46,
|
||||
negotiated: 0,
|
||||
}]
|
||||
);
|
||||
assert_eq!(socket2::SockRef::from(&client.socket).tos_v4().unwrap(), 0);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(any(
|
||||
target_os = "fuchsia",
|
||||
target_os = "redox",
|
||||
target_os = "solaris",
|
||||
target_os = "illumos",
|
||||
target_os = "haiku"
|
||||
)))]
|
||||
fn authenticated_peer_close_clears_negotiated_traffic_class() {
|
||||
let mut params = default_params();
|
||||
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF).
|
||||
params.dscp = 184;
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let (request, _) = recv_request(&socket, &tx);
|
||||
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
|
||||
socket
|
||||
.send_to(
|
||||
&echo_reply_packet_with_flags(
|
||||
TOKEN,
|
||||
seq,
|
||||
¶ms,
|
||||
&TimestampFields::default(),
|
||||
None,
|
||||
FLAG_REPLY | flags::FLAG_CLOSE,
|
||||
),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let mut config = default_test_config(server.addr);
|
||||
config.dscp = 46;
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
|
||||
let events = client.recv_once().unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
events.as_slice(),
|
||||
[
|
||||
ClientEvent::EchoReply { .. },
|
||||
ClientEvent::SessionClosed { .. }
|
||||
]
|
||||
));
|
||||
|
||||
assert_eq!(socket2::SockRef::from(&client.socket).tos_v4().unwrap(), 0);
|
||||
server.join();
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hmac_open_ignores_missing_hmac_before_valid_reply() {
|
||||
let key = b"secret".to_vec();
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.hmac_key = Some(key.clone());
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let missing = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&key));
|
||||
socket.send_to(&missing, peer).unwrap();
|
||||
socket.send_to(&valid, peer).unwrap();
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_open_ignores_bad_hmac_before_valid_reply() {
|
||||
let key = b"secret".to_vec();
|
||||
let wrong_key = b"wrong".to_vec();
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.hmac_key = Some(key.clone());
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let bad = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&wrong_key));
|
||||
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&key));
|
||||
socket.send_to(&bad, peer).unwrap();
|
||||
socket.send_to(&valid, peer).unwrap();
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_token_hmac_negotiation_failure_sends_authenticated_cleanup_close() {
|
||||
let key = b"secret".to_vec();
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.hmac_key = Some(key.clone());
|
||||
let mut returned = params_from_config(&config).unwrap();
|
||||
returned.interval_ns += 1;
|
||||
let server_key = key.clone();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, Some(&server_key));
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::NegotiationRejected { .. })
|
||||
));
|
||||
let packets: Vec<_> = server.rx.iter().take(2).collect();
|
||||
let cleanup = &packets[1];
|
||||
assert_eq!(cleanup[3], flags::FLAG_CLOSE | FLAG_HMAC);
|
||||
verify_packet_hmac(&key, cleanup).unwrap();
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(cleanup[4 + HMAC_SIZE..12 + HMAC_SIZE].try_into().unwrap()),
|
||||
TOKEN
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_close_packet_includes_valid_hmac() {
|
||||
let key = b"secret".to_vec();
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.hmac_key = Some(key.clone());
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server_key = key.clone();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&server_key));
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.close().unwrap();
|
||||
let packets: Vec<_> = server.rx.iter().take(2).collect();
|
||||
let close = &packets[1];
|
||||
assert_eq!(close[3], flags::FLAG_CLOSE | FLAG_HMAC);
|
||||
verify_packet_hmac(&key, close).unwrap();
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(close[4 + HMAC_SIZE..12 + HMAC_SIZE].try_into().unwrap()),
|
||||
TOKEN
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_hmac_reply_is_dropped() {
|
||||
let key = b"secret".to_vec();
|
||||
let wrong_key = b"wrong".to_vec();
|
||||
let params = default_params();
|
||||
let server_key = key.clone();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&server_key));
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let mut buf = [0_u8; 2048];
|
||||
if let Ok((size, _)) = socket.recv_from(&mut buf) {
|
||||
tx.send(buf[..size].to_vec()).unwrap();
|
||||
let seq = u32::from_le_bytes(
|
||||
buf[4 + HMAC_SIZE + 8..4 + HMAC_SIZE + 12]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
let ts = TimestampFields::default();
|
||||
let reply_packet = echo_reply_packet_with_flags(
|
||||
TOKEN,
|
||||
seq,
|
||||
¶ms,
|
||||
&ts,
|
||||
Some(&wrong_key),
|
||||
FLAG_REPLY | flags::FLAG_CLOSE,
|
||||
);
|
||||
socket.send_to(&reply_packet, peer).unwrap();
|
||||
}
|
||||
});
|
||||
let config = ClientConfig {
|
||||
hmac_key: Some(key),
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
thread::sleep(Duration::from_millis(30));
|
||||
let events = client.recv_once().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(&events[0], ClientEvent::Warning { .. }));
|
||||
assert!(!client.is_peer_closed());
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_echo_request_reply_works() {
|
||||
let key = b"testkey".to_vec();
|
||||
let params = default_params();
|
||||
let server_key = key.clone();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&server_key));
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let mut buf = [0_u8; 2048];
|
||||
if let Ok((size, _)) = socket.recv_from(&mut buf) {
|
||||
tx.send(buf[..size].to_vec()).unwrap();
|
||||
verify_packet_hmac(&server_key, &buf[..size]).unwrap();
|
||||
let seq = u32::from_le_bytes(
|
||||
buf[4 + HMAC_SIZE + 8..4 + HMAC_SIZE + 12]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
let ts = TimestampFields {
|
||||
recv_mono: Some(100),
|
||||
send_mono: Some(200),
|
||||
..Default::default()
|
||||
};
|
||||
let reply_packet = echo_reply_packet(TOKEN, seq, ¶ms, &ts, Some(&server_key));
|
||||
socket.send_to(&reply_packet, peer).unwrap();
|
||||
}
|
||||
});
|
||||
let config = ClientConfig {
|
||||
hmac_key: Some(key),
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
let events = client.recv_once().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(&events[0], ClientEvent::EchoReply { .. }));
|
||||
server.join();
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
use super::*;
|
||||
use crate::config::NegotiationPolicy;
|
||||
use crate::{
|
||||
session::machine::{
|
||||
compute_one_way, compute_rtt, params_from_config, sequence_is_after, sequence_is_before,
|
||||
unix_epoch_ns_i64, update_highest_received,
|
||||
},
|
||||
session::negotiate_params,
|
||||
NegotiatedParams, RunMode, SignedDuration, WarningKind, MAX_UDP_PAYLOAD_LENGTH,
|
||||
};
|
||||
use irtt_proto::{flags, Params, TimestampFields, PROTOCOL_VERSION};
|
||||
use irtt_proto::{
|
||||
flags::FLAG_HMAC, flags::FLAG_OPEN, flags::FLAG_REPLY, verify_packet_hmac, Clock,
|
||||
ReceivedStats, StampAt, HMAC_SIZE, MAGIC,
|
||||
};
|
||||
use std::{thread, time::SystemTime};
|
||||
|
||||
mod support;
|
||||
use support::*;
|
||||
|
||||
mod close;
|
||||
mod config;
|
||||
mod dscp;
|
||||
mod hmac;
|
||||
mod negotiation;
|
||||
mod no_test;
|
||||
mod open;
|
||||
mod probes_replies;
|
||||
mod sequence;
|
||||
mod ttl;
|
||||
@@ -1,164 +0,0 @@
|
||||
use super::*;
|
||||
use crate::NegotiationRestriction;
|
||||
|
||||
fn assert_negotiates(
|
||||
requested: &Params,
|
||||
returned: &Params,
|
||||
policy: NegotiationPolicy,
|
||||
) -> NegotiatedParams {
|
||||
negotiate_params(requested, returned.clone(), policy)
|
||||
.unwrap_or_else(|err| panic!("expected negotiation success, got {err:?}"))
|
||||
}
|
||||
|
||||
fn rejection_reason(requested: &Params, returned: &Params, policy: NegotiationPolicy) -> String {
|
||||
match negotiate_params(requested, returned.clone(), policy) {
|
||||
Err(ClientError::NegotiationRejected { reason }) => reason,
|
||||
other => panic!("expected negotiation rejection, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_negotiation_accepts_server_restricted_params() {
|
||||
let config = ClientConfig::default();
|
||||
let mut requested = params_from_config(&config).unwrap();
|
||||
requested.length = 128;
|
||||
let mut returned = requested.clone();
|
||||
returned.duration_ns /= 2;
|
||||
returned.length = 0;
|
||||
let negotiated = assert_negotiates(&requested, &returned, NegotiationPolicy::Loose);
|
||||
assert_eq!(
|
||||
negotiated.restrictions,
|
||||
vec![
|
||||
NegotiationRestriction::DurationReduced {
|
||||
requested_ns: requested.duration_ns,
|
||||
negotiated_ns: returned.duration_ns,
|
||||
},
|
||||
NegotiationRestriction::LengthReduced {
|
||||
requested: requested.length,
|
||||
negotiated: returned.length,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_negotiation_rejects_non_positive_returned_interval() {
|
||||
let config = ClientConfig::default();
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
|
||||
for interval_ns in [0, -1] {
|
||||
let mut returned = requested.clone();
|
||||
returned.interval_ns = interval_ns;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
||||
"interval must be positive"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_negotiation_rejects_negative_returned_length() {
|
||||
let config = ClientConfig::default();
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
let mut returned = requested.clone();
|
||||
returned.length = -1;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
||||
"length must be non-negative"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_negotiation_rejects_negative_returned_length() {
|
||||
let config = ClientConfig::default();
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
let mut returned = requested.clone();
|
||||
returned.length = -1;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
|
||||
"length must be non-negative"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_negotiation_rejects_runtime_invalid_returned_dscp() {
|
||||
let config = ClientConfig {
|
||||
dscp: 46,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
assert_eq!(
|
||||
requested.dscp, 184,
|
||||
"codepoint 46 must become raw wire byte 184"
|
||||
);
|
||||
|
||||
for dscp in [-1, 256] {
|
||||
let mut returned = requested.clone();
|
||||
returned.dscp = dscp;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
||||
"dscp must be in range 0..=255"
|
||||
);
|
||||
}
|
||||
|
||||
// A raw returned value of 184 must be accepted even though it exceeds the
|
||||
// 0..=63 codepoint range; it is the correctly negotiated wire value for
|
||||
// codepoint 46 and is not subject to the codepoint bound at all.
|
||||
let returned = requested.clone();
|
||||
assert!(
|
||||
assert_negotiates(&requested, &returned, NegotiationPolicy::Loose)
|
||||
.restrictions
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_negotiation_records_dscp_disabled_by_server() {
|
||||
let config = ClientConfig {
|
||||
dscp: 46,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
let mut returned = requested.clone();
|
||||
returned.dscp = 0;
|
||||
|
||||
let negotiated = assert_negotiates(&requested, &returned, NegotiationPolicy::Loose);
|
||||
|
||||
assert_eq!(
|
||||
negotiated.restrictions,
|
||||
vec![NegotiationRestriction::DscpChanged {
|
||||
requested: 46,
|
||||
negotiated: 0,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negotiation_rejects_unsupported_dscp_changes() {
|
||||
let config = ClientConfig {
|
||||
dscp: 46,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
let mut returned = requested.clone();
|
||||
returned.dscp = 8;
|
||||
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
||||
"server returned unsupported DSCP change"
|
||||
);
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
|
||||
"server returned unsupported DSCP change"
|
||||
);
|
||||
|
||||
let zero_config = ClientConfig::default();
|
||||
let zero_requested = params_from_config(&zero_config).unwrap();
|
||||
let mut returned = zero_requested.clone();
|
||||
returned.dscp = 46;
|
||||
|
||||
assert_eq!(
|
||||
rejection_reason(&zero_requested, &returned, NegotiationPolicy::Loose),
|
||||
"server returned unsupported DSCP change"
|
||||
);
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
use super::*;
|
||||
use crate::metadata::ReceiveMeta;
|
||||
|
||||
#[test]
|
||||
fn server_processing_greater_than_raw_does_not_underflow() {
|
||||
let base = Instant::now();
|
||||
let rtt = compute_rtt(
|
||||
&ClientTimestamp {
|
||||
mono: base,
|
||||
wall: SystemTime::now(),
|
||||
},
|
||||
&ClientTimestamp {
|
||||
mono: base + Duration::from_nanos(1),
|
||||
wall: SystemTime::now(),
|
||||
},
|
||||
&TimestampFields {
|
||||
recv_mono: Some(0),
|
||||
send_mono: Some(1_000_000_000),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(rtt.adjusted, Some(SignedDuration::from_nanos(-999_999_999)));
|
||||
assert_eq!(rtt.effective, SignedDuration::from_nanos(-999_999_999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_rtt_uses_raw_when_adjusted_rtt_is_unavailable() {
|
||||
let base = Instant::now();
|
||||
let rtt = compute_rtt(
|
||||
&ClientTimestamp {
|
||||
mono: base,
|
||||
wall: SystemTime::now(),
|
||||
},
|
||||
&ClientTimestamp {
|
||||
mono: base + Duration::from_nanos(42),
|
||||
wall: SystemTime::now(),
|
||||
},
|
||||
&TimestampFields::default(),
|
||||
);
|
||||
|
||||
assert_eq!(rtt.adjusted, None);
|
||||
assert_eq!(rtt.effective, SignedDuration::from_duration(rtt.raw));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unix_epoch_ns_i64_rejects_out_of_range_wall_time() {
|
||||
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
|
||||
let max = SystemTime::UNIX_EPOCH + Duration::from_nanos(i64_max_ns);
|
||||
let overflow = max + Duration::from_nanos(1);
|
||||
|
||||
assert_eq!(unix_epoch_ns_i64(max), Some(i64::MAX));
|
||||
assert_eq!(unix_epoch_ns_i64(overflow), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_one_way_omits_samples_when_client_wall_time_overflows_i64() {
|
||||
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
|
||||
let overflow_wall =
|
||||
SystemTime::UNIX_EPOCH + Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1);
|
||||
let sent_at = ClientTimestamp {
|
||||
mono: Instant::now(),
|
||||
wall: overflow_wall,
|
||||
};
|
||||
let received_at = ClientTimestamp {
|
||||
mono: sent_at.mono + Duration::from_millis(40),
|
||||
wall: overflow_wall + Duration::from_millis(40),
|
||||
};
|
||||
let ts = TimestampFields {
|
||||
recv_wall: Some(i64::MAX),
|
||||
send_wall: Some(i64::MAX),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
compute_one_way(
|
||||
&sent_at,
|
||||
sent_at.wall,
|
||||
&received_at,
|
||||
None,
|
||||
&ReceiveMeta::default(),
|
||||
&ts
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_one_way_returns_available_direction_samples() {
|
||||
let sent_at = ClientTimestamp {
|
||||
mono: Instant::now(),
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
|
||||
};
|
||||
let received_at = ClientTimestamp {
|
||||
mono: sent_at.mono + Duration::from_millis(40),
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
|
||||
};
|
||||
let ts = TimestampFields {
|
||||
recv_wall: Some(10_000_000_000 + 15_000_000),
|
||||
send_wall: Some(10_000_000_000 + 25_000_000),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let sample = compute_one_way(
|
||||
&sent_at,
|
||||
sent_at.wall,
|
||||
&received_at,
|
||||
None,
|
||||
&ReceiveMeta::default(),
|
||||
&ts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
sample.client_to_server,
|
||||
Some(SignedDuration::from_nanos(15_000_000))
|
||||
);
|
||||
assert_eq!(
|
||||
sample.server_to_client,
|
||||
Some(SignedDuration::from_nanos(15_000_000))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_one_way_preserves_negative_client_to_server_delay() {
|
||||
let sent_at = ClientTimestamp {
|
||||
mono: Instant::now(),
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
|
||||
};
|
||||
let received_at = ClientTimestamp {
|
||||
mono: sent_at.mono + Duration::from_millis(40),
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
|
||||
};
|
||||
let ts = TimestampFields {
|
||||
recv_wall: Some(10_000_000_000 - 5_000_000),
|
||||
send_wall: Some(10_000_000_000 + 25_000_000),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let sample = compute_one_way(
|
||||
&sent_at,
|
||||
sent_at.wall,
|
||||
&received_at,
|
||||
None,
|
||||
&ReceiveMeta::default(),
|
||||
&ts,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sample.client_to_server,
|
||||
Some(SignedDuration::from_nanos(-5_000_000))
|
||||
);
|
||||
assert_eq!(
|
||||
sample.server_to_client,
|
||||
Some(SignedDuration::from_nanos(15_000_000))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_one_way_preserves_negative_server_to_client_delay() {
|
||||
let sent_at = ClientTimestamp {
|
||||
mono: Instant::now(),
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
|
||||
};
|
||||
let received_at = ClientTimestamp {
|
||||
mono: sent_at.mono + Duration::from_millis(40),
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
|
||||
};
|
||||
let ts = TimestampFields {
|
||||
recv_wall: Some(10_000_000_000 + 15_000_000),
|
||||
send_wall: Some(10_000_000_000 + 45_000_000),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let sample = compute_one_way(
|
||||
&sent_at,
|
||||
sent_at.wall,
|
||||
&received_at,
|
||||
None,
|
||||
&ReceiveMeta::default(),
|
||||
&ts,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sample.client_to_server,
|
||||
Some(SignedDuration::from_nanos(15_000_000))
|
||||
);
|
||||
assert_eq!(
|
||||
sample.server_to_client,
|
||||
Some(SignedDuration::from_nanos(-5_000_000))
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sequence_ordering_handles_normal_and_wrapped_values() {
|
||||
assert!(sequence_is_after(11, 10));
|
||||
assert!(sequence_is_before(9, 10));
|
||||
assert!(!sequence_is_after(10, 10));
|
||||
assert!(!sequence_is_before(10, 10));
|
||||
|
||||
assert!(sequence_is_after(0, u32::MAX - 1));
|
||||
assert!(!sequence_is_before(0, u32::MAX - 1));
|
||||
assert!(sequence_is_after(1, u32::MAX));
|
||||
assert!(!sequence_is_before(1, u32::MAX));
|
||||
|
||||
assert!(sequence_is_before(u32::MAX, 1));
|
||||
assert!(!sequence_is_after(u32::MAX, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highest_received_seq_updates_across_wrap() {
|
||||
let mut highest = Some(u32::MAX);
|
||||
|
||||
update_highest_received(&mut highest, 0);
|
||||
assert_eq!(highest, Some(0));
|
||||
update_highest_received(&mut highest, u32::MAX);
|
||||
assert_eq!(highest, Some(0));
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn connect_accepts_valid_ttl_config_values() {
|
||||
for ttl in [None, Some(1), Some(255)] {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 9)));
|
||||
config.socket_config.ttl = ttl;
|
||||
Client::connect(config).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_applies_configured_ipv4_ttl_before_open_and_close_preserves_it() {
|
||||
let params = Params {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
duration_ns: 3_000_000_000,
|
||||
interval_ns: 1_000_000_000,
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
..Params::default()
|
||||
};
|
||||
let server = open_success_server(params.clone());
|
||||
let mut config = default_test_config(server.addr);
|
||||
config.socket_config.ttl = Some(64);
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
assert_eq!(socket2::SockRef::from(&client.socket).ttl_v4().unwrap(), 64);
|
||||
|
||||
client.open().unwrap();
|
||||
server.join();
|
||||
|
||||
client.close().unwrap();
|
||||
assert_eq!(socket2::SockRef::from(&client.socket).ttl_v4().unwrap(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_rejects_invalid_ttl_values() {
|
||||
for ttl in [0, 256] {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 9)));
|
||||
config.socket_config.ttl = Some(ttl);
|
||||
assert!(matches!(
|
||||
Client::connect(config),
|
||||
Err(ClientError::InvalidConfig { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,5 @@ pub use model::*;
|
||||
#[cfg(feature = "tokio")]
|
||||
pub use task::*;
|
||||
|
||||
#[cfg(all(feature = "tokio", test))]
|
||||
mod blocking_tests;
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
mod schedule;
|
||||
@@ -152,73 +152,3 @@ pub(crate) fn instant_abs_diff(left: Instant, right: Instant) -> Duration {
|
||||
.or_else(|| right.checked_duration_since(left))
|
||||
.unwrap_or(Duration::ZERO)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use irtt_proto::Params;
|
||||
|
||||
fn negotiated(duration_ns: i64) -> NegotiatedParams {
|
||||
NegotiatedParams {
|
||||
params: Params {
|
||||
interval_ns: 10_000_000,
|
||||
duration_ns,
|
||||
..Params::default()
|
||||
},
|
||||
restrictions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missed_slots_skip_on_the_absolute_grid_only_after_commit() {
|
||||
let start = Instant::now();
|
||||
let mut schedule = ProbeSchedule::new(start, &negotiated(0)).unwrap();
|
||||
for (now_ms, intended_ms, next_ms) in [(0, 0, 10), (45, 40, 50), (50, 50, 60)] {
|
||||
let deadline = schedule.next_send_deadline().unwrap();
|
||||
let commit = schedule
|
||||
.preflight_managed_commit(deadline, start + Duration::from_millis(now_ms))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
commit.scheduled_at,
|
||||
start + Duration::from_millis(intended_ms)
|
||||
);
|
||||
// A failed or pending submission discards this preflight.
|
||||
assert_eq!(schedule.next_send_deadline(), Some(deadline));
|
||||
schedule.commit(commit);
|
||||
assert_eq!(
|
||||
schedule.next_send_deadline(),
|
||||
Some(start + Duration::from_millis(next_ms))
|
||||
);
|
||||
}
|
||||
schedule
|
||||
.skip_missed_slots_at(start + Duration::from_millis(85))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
schedule.next_send_deadline(),
|
||||
Some(start + Duration::from_millis(90))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_duration_excludes_its_end_even_when_a_slot_is_missed() {
|
||||
let start = Instant::now();
|
||||
let mut schedule = ProbeSchedule::new(start, &negotiated(20_000_000)).unwrap();
|
||||
assert!(schedule.permit_probe_at(start + Duration::from_millis(19)));
|
||||
let commit = schedule
|
||||
.preflight_managed_commit(start, start + Duration::from_millis(19))
|
||||
.unwrap();
|
||||
schedule.commit(commit);
|
||||
assert!(schedule.is_finished());
|
||||
|
||||
let mut missed = ProbeSchedule::new(start, &negotiated(20_000_000)).unwrap();
|
||||
assert!(!missed.permit_probe_at(start + Duration::from_millis(20)));
|
||||
assert!(missed.is_finished());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cadence_rejects_overflow_before_commit() {
|
||||
assert!(matches!(
|
||||
advance_cadence(Instant::now(), Duration::MAX, Instant::now()),
|
||||
Err(ClientError::DurationOverflow)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -177,74 +177,6 @@ pub struct ManagedClientHandle {
|
||||
max_live_target_generations: usize,
|
||||
status: watch::Receiver<Arc<ManagedStatus>>,
|
||||
events: broadcast::WeakSender<ManagedEvent>,
|
||||
#[cfg(test)]
|
||||
update_submission_hook: Option<Arc<UpdateSubmissionTestHook>>,
|
||||
}
|
||||
|
||||
// Pauses between admission and enqueue to exercise stop/seal linearization.
|
||||
#[cfg(test)]
|
||||
#[derive(Default)]
|
||||
struct UpdateSubmissionTestHook {
|
||||
state: std::sync::Mutex<UpdateSubmissionTestState>,
|
||||
arrived: std::sync::Condvar,
|
||||
released: std::sync::Condvar,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Default)]
|
||||
struct UpdateSubmissionTestState {
|
||||
armed: bool,
|
||||
arrived: bool,
|
||||
released: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl UpdateSubmissionTestHook {
|
||||
fn arm(&self) {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.armed = true;
|
||||
}
|
||||
|
||||
fn pause_after_admission(&self) {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !state.armed {
|
||||
return;
|
||||
}
|
||||
state.arrived = true;
|
||||
self.arrived.notify_all();
|
||||
while !state.released {
|
||||
state = self
|
||||
.released
|
||||
.wait(state)
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_until_arrived(&self) {
|
||||
let state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (state, timeout) = self
|
||||
.arrived
|
||||
.wait_timeout_while(state, Duration::from_secs(2), |state| !state.arrived)
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
assert!(state.arrived && !timeout.timed_out());
|
||||
}
|
||||
|
||||
fn release(&self) {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state.released = true;
|
||||
self.released.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ManagedClientHandle {
|
||||
@@ -306,11 +238,6 @@ impl ManagedClientHandle {
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
if let Some(hook) = &self.update_submission_hook {
|
||||
hook.pause_after_admission();
|
||||
}
|
||||
|
||||
// Observing `Open` only permits attempting submission. A successful
|
||||
// `try_send` transfers command ownership to the driver. A concurrent
|
||||
// stop may move admission to `Stopping` before this send, allowing the
|
||||
@@ -403,9 +330,6 @@ impl Future for ManagedStopReceipt {
|
||||
type ConnectFuture =
|
||||
Pin<Box<dyn Future<Output = Result<AsyncClient, ClientError>> + Send + 'static>>;
|
||||
type WakeFuture = Pin<Box<dyn Future<Output = Option<watch::Receiver<()>>> + Send + 'static>>;
|
||||
// Snapshots at publication verify status is visible before the corresponding event.
|
||||
#[cfg(test)]
|
||||
type EventObservations = Arc<std::sync::Mutex<Vec<(ManagedEvent, Arc<ManagedStatus>)>>>;
|
||||
|
||||
fn arm_wake(mut receiver: watch::Receiver<()>) -> WakeFuture {
|
||||
Box::pin(async move {
|
||||
@@ -710,20 +634,6 @@ pub struct ManagedClientTask {
|
||||
final_outcome: Option<Arc<ManagedOutcome>>,
|
||||
next_generation: u64,
|
||||
applied_command_sequence: u64,
|
||||
#[cfg(test)]
|
||||
event_observations: Option<EventObservations>,
|
||||
#[cfg(test)]
|
||||
drain_test_hook: DrainTestHook,
|
||||
// Counts work that cannot be observed through lossy presentation events.
|
||||
#[cfg(test)]
|
||||
timeout_inspections: usize,
|
||||
}
|
||||
|
||||
// Holds queued replies past the drain deadline to exercise the final receive budget.
|
||||
#[cfg(test)]
|
||||
#[derive(Default)]
|
||||
struct DrainTestHook {
|
||||
defer_work_until_deadline: bool,
|
||||
}
|
||||
|
||||
impl ManagedClientTask {
|
||||
@@ -734,13 +644,6 @@ impl ManagedClientTask {
|
||||
}
|
||||
|
||||
fn publish_event(&self, event: ManagedEvent) {
|
||||
#[cfg(test)]
|
||||
if let Some(observations) = &self.event_observations {
|
||||
observations
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.push((event.clone(), Arc::clone(&self.resources().status.borrow())));
|
||||
}
|
||||
if let Some(events) = &self.resources().events {
|
||||
let _ = events.send(event);
|
||||
}
|
||||
@@ -1376,22 +1279,6 @@ impl ManagedClientTask {
|
||||
mut cleanup_failure,
|
||||
mut post_deadline_receives_remaining,
|
||||
} => {
|
||||
#[cfg(test)]
|
||||
let defer_work = self.drain_test_hook.defer_work_until_deadline && now < deadline;
|
||||
#[cfg(not(test))]
|
||||
let defer_work = false;
|
||||
if defer_work {
|
||||
self.targets[index].state = TargetState::Draining {
|
||||
client,
|
||||
drain_started_at,
|
||||
deadline,
|
||||
primary_end,
|
||||
cleanup_failure,
|
||||
post_deadline_receives_remaining,
|
||||
};
|
||||
return false;
|
||||
}
|
||||
|
||||
if client
|
||||
.next_probe_timeout_deadline()
|
||||
.is_some_and(|timeout| timeout <= now)
|
||||
@@ -1899,10 +1786,6 @@ impl ManagedClientTask {
|
||||
}
|
||||
let index = self.timeout_cursor;
|
||||
self.timeout_cursor = (self.timeout_cursor + 1) % target_len;
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.timeout_inspections += 1;
|
||||
}
|
||||
if self.target_has_due_timeout(index, now) {
|
||||
let generation = self.targets[index].instance.generation;
|
||||
let step = self.poll_target_timeout(index, now);
|
||||
@@ -1958,20 +1841,9 @@ impl ManagedClientTask {
|
||||
.next_probe_timeout_deadline()
|
||||
.is_some_and(|deadline| deadline <= now)
|
||||
}
|
||||
TargetState::Draining {
|
||||
client,
|
||||
deadline: _deadline,
|
||||
..
|
||||
} => {
|
||||
#[cfg(test)]
|
||||
let defer_work = self.drain_test_hook.defer_work_until_deadline && now < *_deadline;
|
||||
#[cfg(not(test))]
|
||||
let defer_work = false;
|
||||
!defer_work
|
||||
&& client
|
||||
.next_probe_timeout_deadline()
|
||||
.is_some_and(|timeout| timeout <= now)
|
||||
}
|
||||
TargetState::Draining { client, .. } => client
|
||||
.next_probe_timeout_deadline()
|
||||
.is_some_and(|timeout| timeout <= now),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -2076,17 +1948,9 @@ impl ManagedClientTask {
|
||||
TargetState::Draining {
|
||||
client, deadline, ..
|
||||
} => {
|
||||
#[cfg(test)]
|
||||
let defer_work = self.drain_test_hook.defer_work_until_deadline;
|
||||
#[cfg(not(test))]
|
||||
let defer_work = false;
|
||||
non_send_deadline = non_send_deadline
|
||||
.into_iter()
|
||||
.chain(
|
||||
(!defer_work)
|
||||
.then(|| client.next_probe_timeout_deadline())
|
||||
.flatten(),
|
||||
)
|
||||
.chain(client.next_probe_timeout_deadline())
|
||||
.chain(Some(*deadline))
|
||||
.min();
|
||||
}
|
||||
@@ -2476,12 +2340,6 @@ fn build_task(
|
||||
final_outcome: None,
|
||||
next_generation,
|
||||
applied_command_sequence: 0,
|
||||
#[cfg(test)]
|
||||
event_observations: None,
|
||||
#[cfg(test)]
|
||||
drain_test_hook: DrainTestHook::default(),
|
||||
#[cfg(test)]
|
||||
timeout_inspections: 0,
|
||||
};
|
||||
let handle = ManagedClientHandle {
|
||||
stop,
|
||||
@@ -2489,8 +2347,6 @@ fn build_task(
|
||||
max_live_target_generations,
|
||||
status: status_receiver,
|
||||
events: weak_events,
|
||||
#[cfg(test)]
|
||||
update_submission_hook: None,
|
||||
};
|
||||
Ok((task, handle))
|
||||
}
|
||||
@@ -2531,7 +2387,3 @@ fn close_timeout_failure() -> ManagedTargetFailure {
|
||||
message: Arc::from("best-effort close did not become writable before its deadline"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests.rs"]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -56,136 +56,3 @@ impl From<ReceiveMeta> for PacketMeta {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use super::{ReceiveMeta, MAX_KERNEL_RX_LAG};
|
||||
use crate::event::PacketMeta;
|
||||
|
||||
fn userspace_wall() -> SystemTime {
|
||||
SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)
|
||||
}
|
||||
|
||||
fn meta_with_kernel_rx(kernel_rx_timestamp: SystemTime) -> ReceiveMeta {
|
||||
ReceiveMeta {
|
||||
traffic_class: None,
|
||||
kernel_rx_timestamp: Some(kernel_rx_timestamp),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_receive_wall_without_kernel_timestamp_uses_userspace_sample() {
|
||||
let userspace = userspace_wall();
|
||||
|
||||
assert_eq!(
|
||||
ReceiveMeta::default().preferred_receive_wall(userspace),
|
||||
userspace
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_receive_wall_uses_plausible_earlier_kernel_timestamp() {
|
||||
let userspace = userspace_wall();
|
||||
let kernel = userspace - Duration::from_micros(80);
|
||||
|
||||
assert_eq!(
|
||||
meta_with_kernel_rx(kernel).preferred_receive_wall(userspace),
|
||||
kernel
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_receive_wall_accepts_kernel_timestamp_equal_to_userspace_sample() {
|
||||
let userspace = userspace_wall();
|
||||
|
||||
assert_eq!(
|
||||
meta_with_kernel_rx(userspace).preferred_receive_wall(userspace),
|
||||
userspace
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_receive_wall_rejects_kernel_timestamp_later_than_userspace_sample() {
|
||||
let userspace = userspace_wall();
|
||||
let kernel = userspace + Duration::from_nanos(1);
|
||||
|
||||
assert_eq!(
|
||||
meta_with_kernel_rx(kernel).preferred_receive_wall(userspace),
|
||||
userspace
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_receive_wall_accepts_kernel_timestamp_exactly_at_the_lag_bound() {
|
||||
// The bound is inclusive: a lag of exactly MAX_KERNEL_RX_LAG is
|
||||
// still accepted, one nanosecond more is not.
|
||||
let userspace = userspace_wall();
|
||||
let at_bound = userspace - MAX_KERNEL_RX_LAG;
|
||||
let past_bound = userspace - MAX_KERNEL_RX_LAG - Duration::from_nanos(1);
|
||||
|
||||
assert_eq!(
|
||||
meta_with_kernel_rx(at_bound).preferred_receive_wall(userspace),
|
||||
at_bound
|
||||
);
|
||||
assert_eq!(
|
||||
meta_with_kernel_rx(past_bound).preferred_receive_wall(userspace),
|
||||
userspace
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_preserves_kernel_timestamp_rejected_for_measurement() {
|
||||
let userspace = userspace_wall();
|
||||
let rejected = userspace + Duration::from_secs(30);
|
||||
let meta = meta_with_kernel_rx(rejected);
|
||||
|
||||
assert_eq!(meta.preferred_receive_wall(userspace), userspace);
|
||||
assert_eq!(
|
||||
PacketMeta::from(meta).kernel_rx_timestamp,
|
||||
Some(rejected),
|
||||
"observed metadata must survive rejection as a measurement endpoint"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_observed_traffic_class_derives_dscp_and_ecn() {
|
||||
let packet_meta = PacketMeta::from(ReceiveMeta {
|
||||
traffic_class: Some(184),
|
||||
kernel_rx_timestamp: None,
|
||||
});
|
||||
|
||||
assert_eq!(packet_meta.traffic_class, Some(184));
|
||||
assert_eq!(packet_meta.dscp, Some(46));
|
||||
assert_eq!(packet_meta.ecn, Some(0));
|
||||
assert_eq!(packet_meta.kernel_rx_timestamp, None);
|
||||
|
||||
let packet_meta = PacketMeta::from(ReceiveMeta {
|
||||
traffic_class: Some(186),
|
||||
kernel_rx_timestamp: None,
|
||||
});
|
||||
|
||||
assert_eq!(packet_meta.traffic_class, Some(186));
|
||||
assert_eq!(packet_meta.dscp, Some(46));
|
||||
assert_eq!(packet_meta.ecn, Some(2));
|
||||
assert_eq!(packet_meta.kernel_rx_timestamp, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_mapping_preserves_none_vs_observed_zero() {
|
||||
let unavailable = PacketMeta::from(ReceiveMeta::default());
|
||||
let observed_zero = PacketMeta::from(ReceiveMeta {
|
||||
traffic_class: Some(0),
|
||||
kernel_rx_timestamp: None,
|
||||
});
|
||||
|
||||
assert_eq!(unavailable.traffic_class, None);
|
||||
assert_eq!(unavailable.dscp, None);
|
||||
assert_eq!(unavailable.ecn, None);
|
||||
|
||||
assert_eq!(observed_zero.traffic_class, Some(0));
|
||||
assert_eq!(observed_zero.dscp, Some(0));
|
||||
assert_eq!(observed_zero.ecn, Some(0));
|
||||
}
|
||||
}
|
||||
@@ -195,12 +195,6 @@ impl PendingMap {
|
||||
self.map.is_empty()
|
||||
}
|
||||
|
||||
// Verify repeated or abandoned send preflights do not create pending probes.
|
||||
#[cfg(test)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.map.len()
|
||||
}
|
||||
|
||||
/// Mutable access to a still-pending probe by wire sequence, without
|
||||
/// disturbing its position in the timeout order. Used to attach an
|
||||
/// observed kernel TX timestamp to a probe that has not yet completed or
|
||||
@@ -215,7 +209,7 @@ impl PendingMap {
|
||||
.map(|entry| entry.probe.timeout_at)
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", test))]
|
||||
#[cfg(feature = "tokio")]
|
||||
pub fn latest_timeout_deadline(&self) -> Option<Instant> {
|
||||
self.last
|
||||
.and_then(|wire_seq| self.map.get(&wire_seq))
|
||||
@@ -244,12 +238,6 @@ impl PendingMap {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check sequence-reuse and kernel timestamp retention without consuming a probe.
|
||||
#[cfg(test)]
|
||||
pub fn contains(&self, wire_seq: u32) -> bool {
|
||||
self.map.contains_key(&wire_seq)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -305,12 +293,6 @@ impl TimedOutMap {
|
||||
self.map.values().map(|probe| probe.timeout_at).max()
|
||||
}
|
||||
|
||||
// Check sequence-reuse and kernel timestamp retention without consuming a probe.
|
||||
#[cfg(test)]
|
||||
pub fn contains(&self, wire_seq: u32) -> bool {
|
||||
self.map.contains_key(&wire_seq)
|
||||
}
|
||||
|
||||
fn evict_oldest(&mut self) {
|
||||
while let Some(oldest_key) = self.insertion_order.pop_front() {
|
||||
if self.map.remove(&oldest_key).is_some() {
|
||||
@@ -393,212 +375,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(map: &mut PendingMap, probe: PendingProbe) {
|
||||
map.preflight_insert(probe.wire_seq).unwrap();
|
||||
map.commit_insert(probe);
|
||||
}
|
||||
|
||||
// Public loss/reply events cannot reveal leaked retention bookkeeping.
|
||||
#[test]
|
||||
fn pending_map_rejects_over_capacity() {
|
||||
let mut map = PendingMap::new(2);
|
||||
let now = Instant::now();
|
||||
map.preflight_insert(0).unwrap();
|
||||
map.commit_insert(pending(0, now + Duration::from_secs(4)));
|
||||
map.preflight_insert(1).unwrap();
|
||||
map.commit_insert(pending(1, now + Duration::from_secs(4)));
|
||||
assert!(matches!(
|
||||
map.preflight_insert(2),
|
||||
Err(ClientError::PendingLimitExceeded { limit: 2 })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_map_preflight_rejects_sequence_collision() {
|
||||
let mut map = PendingMap::new(2);
|
||||
let now = Instant::now();
|
||||
map.preflight_insert(9).unwrap();
|
||||
map.commit_insert(pending(9, now));
|
||||
|
||||
assert!(matches!(
|
||||
map.preflight_insert(9),
|
||||
Err(ClientError::PendingSequenceCollision { seq: 9 })
|
||||
));
|
||||
assert_eq!(map.map.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_map_unlinks_head_middle_and_tail_and_updates_extrema() {
|
||||
let mut map = PendingMap::new(4);
|
||||
let now = Instant::now();
|
||||
insert(&mut map, pending(1, now + Duration::from_secs(1)));
|
||||
insert(&mut map, pending(2, now + Duration::from_secs(2)));
|
||||
insert(&mut map, pending(3, now + Duration::from_secs(3)));
|
||||
|
||||
assert_eq!(
|
||||
map.next_timeout_deadline(),
|
||||
Some(now + Duration::from_secs(1))
|
||||
);
|
||||
assert_eq!(
|
||||
map.latest_timeout_deadline(),
|
||||
Some(now + Duration::from_secs(3))
|
||||
);
|
||||
|
||||
assert!(map.remove(2).is_some());
|
||||
assert_eq!(
|
||||
map.next_timeout_deadline(),
|
||||
Some(now + Duration::from_secs(1))
|
||||
);
|
||||
assert_eq!(
|
||||
map.latest_timeout_deadline(),
|
||||
Some(now + Duration::from_secs(3))
|
||||
);
|
||||
|
||||
assert!(map.remove(1).is_some());
|
||||
assert_eq!(
|
||||
map.next_timeout_deadline(),
|
||||
Some(now + Duration::from_secs(3))
|
||||
);
|
||||
assert_eq!(
|
||||
map.latest_timeout_deadline(),
|
||||
Some(now + Duration::from_secs(3))
|
||||
);
|
||||
|
||||
assert!(map.remove(3).is_some());
|
||||
assert_eq!(map.next_timeout_deadline(), None);
|
||||
assert_eq!(map.latest_timeout_deadline(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_map_bounded_expiration_matches_exhaustive_order() {
|
||||
let now = Instant::now();
|
||||
let probes = [
|
||||
pending(1, now + Duration::from_secs(1)),
|
||||
pending(2, now + Duration::from_secs(2)),
|
||||
pending(3, now + Duration::from_secs(3)),
|
||||
pending(4, now + Duration::from_secs(4)),
|
||||
];
|
||||
let mut bounded = PendingMap::new(probes.len());
|
||||
let mut exhaustive = PendingMap::new(probes.len());
|
||||
for probe in probes.iter().cloned() {
|
||||
insert(&mut bounded, probe.clone());
|
||||
insert(&mut exhaustive, probe);
|
||||
}
|
||||
|
||||
let empty = bounded.drain_expired_bounded(now + Duration::from_secs(4), 0);
|
||||
assert!(empty.probes.is_empty());
|
||||
assert!(empty.more_due);
|
||||
|
||||
let mut actual = Vec::new();
|
||||
loop {
|
||||
let batch = bounded.drain_expired_bounded(now + Duration::from_secs(4), 2);
|
||||
actual.extend(batch.probes.into_iter().map(|probe| probe.wire_seq));
|
||||
if !batch.more_due {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let expected = exhaustive
|
||||
.drain_expired_bounded(now + Duration::from_secs(4), usize::MAX)
|
||||
.probes
|
||||
.into_iter()
|
||||
.map(|probe| probe.wire_seq)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(actual, vec![1, 2, 3, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_map_sequence_reuse_leaves_no_stale_link() {
|
||||
let mut map = PendingMap::new(1);
|
||||
let now = Instant::now();
|
||||
insert(&mut map, pending(7, now));
|
||||
assert!(map.remove(7).is_some());
|
||||
insert(&mut map, pending(7, now + Duration::from_secs(1)));
|
||||
|
||||
assert!(map.drain_expired_bounded(now, usize::MAX).probes.is_empty());
|
||||
assert_eq!(
|
||||
map.drain_expired_bounded(now + Duration::from_secs(1), usize::MAX)
|
||||
.probes
|
||||
.into_iter()
|
||||
.map(|probe| probe.wire_seq)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![7]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_map_commit_uses_only_preflighted_map_capacity() {
|
||||
let mut map = PendingMap::new(2);
|
||||
let now = Instant::now();
|
||||
map.preflight_insert(7).unwrap();
|
||||
let capacity = map.map.capacity();
|
||||
|
||||
map.commit_insert(pending(7, now));
|
||||
|
||||
assert_eq!(map.map.capacity(), capacity);
|
||||
assert_eq!(map.map.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_map_mixed_removals_preserve_links() {
|
||||
let mut map = PendingMap::new(4);
|
||||
let now = Instant::now();
|
||||
for seq in 1..=4 {
|
||||
insert(
|
||||
&mut map,
|
||||
pending(seq, now + Duration::from_secs(u64::from(seq))),
|
||||
);
|
||||
}
|
||||
|
||||
assert!(map.remove(2).is_some());
|
||||
let expired = map.drain_expired_bounded(now + Duration::from_secs(3), usize::MAX);
|
||||
assert_eq!(
|
||||
expired
|
||||
.probes
|
||||
.into_iter()
|
||||
.map(|probe| probe.wire_seq)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1, 3]
|
||||
);
|
||||
assert!(map.remove(4).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_probe_tracking_evicts_oldest_entries() {
|
||||
let mut set = CompletedSet::new(3);
|
||||
set.insert(0);
|
||||
set.insert(1);
|
||||
set.insert(2);
|
||||
assert!(set.contains(0));
|
||||
assert!(set.contains(1));
|
||||
assert!(set.contains(2));
|
||||
set.insert(3);
|
||||
assert_eq!(set.set.len(), 3);
|
||||
assert!(!set.contains(0));
|
||||
assert!(set.contains(3));
|
||||
|
||||
let mut map = TimedOutMap::new(2);
|
||||
let now = Instant::now();
|
||||
map.insert(pending(0, now));
|
||||
map.insert(pending(1, now));
|
||||
map.insert(pending(2, now));
|
||||
|
||||
assert_eq!(map.map.len(), 2);
|
||||
assert!(map.remove(0).is_none());
|
||||
assert!(map.remove(1).is_some());
|
||||
assert!(map.remove(2).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timed_out_map_remove_prunes_insertion_order() {
|
||||
fn timed_out_retention_stays_bounded_after_repeated_removals() {
|
||||
let mut map = TimedOutMap::new(4);
|
||||
let now = Instant::now();
|
||||
|
||||
for i in 0..20 {
|
||||
map.insert(pending(i, now));
|
||||
assert!(map.remove(i).is_some());
|
||||
assert_eq!(map.map.len(), 0);
|
||||
assert_eq!(map.insertion_order.len(), 0);
|
||||
assert!(map.map.len() <= 4);
|
||||
assert!(map.insertion_order.len() <= 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,42 +111,3 @@ pub(crate) fn drain_tx_timestamps<S: std::os::fd::AsRawFd>(
|
||||
) -> io::Result<()> {
|
||||
linux::drain_tx_timestamps(socket, on_timestamp)
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "tokio"))]
|
||||
mod tests {
|
||||
use std::net::UdpSocket;
|
||||
|
||||
use crate::metadata::ReceiveMeta;
|
||||
|
||||
fn connected_loopback_pair() -> (UdpSocket, UdpSocket) {
|
||||
let a = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let b = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
a.connect(b.local_addr().unwrap()).unwrap();
|
||||
b.connect(a.local_addr().unwrap()).unwrap();
|
||||
(a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokio_try_receive_preserves_fallback_metadata_and_would_block() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
runtime.block_on(async {
|
||||
let (sender, receiver) = connected_loopback_pair();
|
||||
receiver.set_nonblocking(true).unwrap();
|
||||
let receiver = tokio::net::UdpSocket::from_std(receiver).unwrap();
|
||||
sender.send(b"tokio").unwrap();
|
||||
receiver.readable().await.unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = super::try_recv_tokio_datagram(&receiver, &mut buf).unwrap();
|
||||
assert_eq!(&buf[..datagram.len], b"tokio");
|
||||
assert_eq!(datagram.meta, ReceiveMeta::default());
|
||||
|
||||
let error = super::try_recv_tokio_datagram(&receiver, &mut buf).unwrap_err();
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -215,333 +215,3 @@ fn system_time_from_timespec(timespec: TimeSpec) -> Option<SystemTime> {
|
||||
/// `cmsghdr` alignment, which 8-byte alignment covers on supported targets.
|
||||
#[repr(align(8))]
|
||||
struct ControlBuffer([u8; CONTROL_LEN]);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
io,
|
||||
net::{SocketAddr, UdpSocket},
|
||||
time::{Duration, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use nix::sys::time::TimeSpec;
|
||||
|
||||
use crate::{
|
||||
event::PacketMeta,
|
||||
receive::{configure_receive_metadata, recv_datagram},
|
||||
socket_options::apply_traffic_class_to_socket,
|
||||
timing::ClientTimestamp,
|
||||
};
|
||||
|
||||
fn connected_ipv4_loopback_pair() -> (UdpSocket, UdpSocket) {
|
||||
let a = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
let b = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
a.connect(b.local_addr().unwrap()).unwrap();
|
||||
b.connect(a.local_addr().unwrap()).unwrap();
|
||||
a.set_read_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
b.set_read_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
(a, b)
|
||||
}
|
||||
|
||||
fn connected_ipv6_loopback_pair() -> Option<(UdpSocket, UdpSocket)> {
|
||||
let bind_addr = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 0));
|
||||
let a = match UdpSocket::bind(bind_addr) {
|
||||
Ok(socket) => socket,
|
||||
Err(error) if is_unavailable_ipv6_loopback(&error) => {
|
||||
eprintln!(
|
||||
"skipping IPv6 ancillary receive test: IPv6 loopback unavailable: {error}"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
};
|
||||
let b = match UdpSocket::bind(bind_addr) {
|
||||
Ok(socket) => socket,
|
||||
Err(error) if is_unavailable_ipv6_loopback(&error) => {
|
||||
eprintln!(
|
||||
"skipping IPv6 ancillary receive test: IPv6 loopback unavailable: {error}"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
};
|
||||
match a.connect(b.local_addr().unwrap()) {
|
||||
Ok(()) => {}
|
||||
Err(error) if is_unavailable_ipv6_loopback(&error) => {
|
||||
eprintln!(
|
||||
"skipping IPv6 ancillary receive test: IPv6 loopback unavailable: {error}"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
}
|
||||
match b.connect(a.local_addr().unwrap()) {
|
||||
Ok(()) => {}
|
||||
Err(error) if is_unavailable_ipv6_loopback(&error) => {
|
||||
eprintln!(
|
||||
"skipping IPv6 ancillary receive test: IPv6 loopback unavailable: {error}"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
}
|
||||
a.set_read_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
b.set_read_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
Some((a, b))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recvmsg_receive_returns_length_payload_and_timestamp() {
|
||||
let (sender, receiver) = connected_ipv4_loopback_pair();
|
||||
configure_receive_metadata(&receiver, sender.local_addr().unwrap()).unwrap();
|
||||
sender.send(b"hello").unwrap();
|
||||
|
||||
let before = ClientTimestamp::now();
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
let after = ClientTimestamp::now();
|
||||
|
||||
assert_eq!(datagram.len, 5);
|
||||
assert_eq!(&buf[..datagram.len], b"hello");
|
||||
assert!(datagram.received_at.mono >= before.mono);
|
||||
assert!(datagram.received_at.mono <= after.mono);
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_recvmsg_would_block_clears_readiness() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
runtime.block_on(async {
|
||||
let (sender, receiver) = connected_ipv4_loopback_pair();
|
||||
configure_receive_metadata(&receiver, sender.local_addr().unwrap()).unwrap();
|
||||
receiver.set_nonblocking(true).unwrap();
|
||||
let receiver = tokio::net::UdpSocket::from_std(receiver).unwrap();
|
||||
let mut buf = [0_u8; 16];
|
||||
|
||||
sender.send(b"first").unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(1), receiver.readable())
|
||||
.await
|
||||
.expect("initial readability timed out")
|
||||
.unwrap();
|
||||
|
||||
let first = super::try_recv_tokio_datagram(&receiver, &mut buf).unwrap();
|
||||
assert_eq!(&buf[..first.len], b"first");
|
||||
|
||||
let error = super::try_recv_tokio_datagram(&receiver, &mut buf).unwrap_err();
|
||||
assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
|
||||
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(20), receiver.readable())
|
||||
.await
|
||||
.is_err(),
|
||||
"readable completed from stale readiness after recvmsg WouldBlock"
|
||||
);
|
||||
|
||||
sender.send(b"second").unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(1), receiver.readable())
|
||||
.await
|
||||
.expect("second readability timed out")
|
||||
.unwrap();
|
||||
|
||||
let second = super::try_recv_tokio_datagram(&receiver, &mut buf).unwrap();
|
||||
assert_eq!(&buf[..second.len], b"second");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_rx_timestamp_metadata_is_observed_when_kernel_provides_it() {
|
||||
let (sender, receiver) = connected_ipv4_loopback_pair();
|
||||
configure_receive_metadata(&receiver, sender.local_addr().unwrap()).unwrap();
|
||||
sender.send(b"stamp").unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
assert_eq!(datagram.len, 5);
|
||||
assert_eq!(&buf[..datagram.len], b"stamp");
|
||||
let Some(timestamp) = datagram.meta.kernel_rx_timestamp else {
|
||||
eprintln!(
|
||||
"skipping kernel timestamp assertion: kernel did not provide SCM_TIMESTAMPING"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let duration = timestamp.duration_since(UNIX_EPOCH).unwrap();
|
||||
assert!(duration.as_nanos() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_rx_timestamp_and_traffic_class_coexist() {
|
||||
let (sender, receiver) = connected_ipv4_loopback_pair();
|
||||
configure_receive_metadata(&receiver, sender.local_addr().unwrap()).unwrap();
|
||||
apply_traffic_class_to_socket(&sender, receiver.local_addr().unwrap(), 184).unwrap();
|
||||
sender.send(b"both").unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
assert_eq!(&buf[..datagram.len], b"both");
|
||||
|
||||
// Both cmsgs are requested on this socket; the parser must not
|
||||
// depend on the order the kernel emits them in to observe either.
|
||||
if datagram.meta.kernel_rx_timestamp.is_none() {
|
||||
eprintln!("skipping combined assertion: kernel did not provide SCM_TIMESTAMPING");
|
||||
return;
|
||||
}
|
||||
if datagram.meta.traffic_class.is_none() {
|
||||
eprintln!("skipping combined assertion: kernel did not provide IP_TOS");
|
||||
return;
|
||||
}
|
||||
assert!(datagram.meta.kernel_rx_timestamp.is_some());
|
||||
assert_eq!(datagram.meta.traffic_class.unwrap() & 0xfc, 184);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv4_traffic_class_metadata_is_observed_when_kernel_provides_it() {
|
||||
let (sender, receiver) = connected_ipv4_loopback_pair();
|
||||
configure_receive_metadata(&receiver, sender.local_addr().unwrap()).unwrap();
|
||||
apply_traffic_class_to_socket(&sender, receiver.local_addr().unwrap(), 184).unwrap();
|
||||
sender.send(b"dscp").unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
let Some(traffic_class) = datagram.meta.traffic_class else {
|
||||
eprintln!("skipping IPv4 ancillary metadata assertion: kernel did not provide IP_TOS");
|
||||
return;
|
||||
};
|
||||
|
||||
let packet_meta = PacketMeta::from(datagram.meta);
|
||||
assert_eq!(traffic_class & 0xfc, 184);
|
||||
assert_eq!(packet_meta.dscp, Some(46));
|
||||
assert_eq!(packet_meta.ecn, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_traffic_class_metadata_is_observed_when_kernel_provides_it() {
|
||||
let Some((sender, receiver)) = connected_ipv6_loopback_pair() else {
|
||||
return;
|
||||
};
|
||||
configure_receive_metadata(&receiver, sender.local_addr().unwrap()).unwrap();
|
||||
apply_traffic_class_to_socket(&sender, receiver.local_addr().unwrap(), 184).unwrap();
|
||||
sender.send(b"dscp").unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
let Some(traffic_class) = datagram.meta.traffic_class else {
|
||||
eprintln!(
|
||||
"skipping IPv6 ancillary metadata assertion: kernel did not provide IPV6_TCLASS"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let packet_meta = PacketMeta::from(datagram.meta);
|
||||
assert_eq!(traffic_class & 0xfc, 184);
|
||||
assert_eq!(packet_meta.dscp, Some(46));
|
||||
assert_eq!(packet_meta.ecn, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observed_zero_traffic_class_preserves_some_zero() {
|
||||
let (sender, receiver) = connected_ipv4_loopback_pair();
|
||||
configure_receive_metadata(&receiver, sender.local_addr().unwrap()).unwrap();
|
||||
sender.send(b"zero").unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
let Some(traffic_class) = datagram.meta.traffic_class else {
|
||||
eprintln!("skipping observed-zero assertion: kernel did not provide IP_TOS");
|
||||
return;
|
||||
};
|
||||
|
||||
let packet_meta = PacketMeta::from(datagram.meta);
|
||||
assert_eq!(traffic_class, 0);
|
||||
assert_eq!(packet_meta.traffic_class, Some(0));
|
||||
assert_eq!(packet_meta.dscp, Some(0));
|
||||
assert_eq!(packet_meta.ecn, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timespec_conversion_accepts_valid_unix_timestamp() {
|
||||
let timestamp = super::system_time_from_timespec(TimeSpec::new(1, 2)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
timestamp.duration_since(UNIX_EPOCH).unwrap(),
|
||||
Duration::new(1, 2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timespec_conversion_rejects_negative_or_invalid_values() {
|
||||
assert_eq!(super::system_time_from_timespec(TimeSpec::new(-1, 0)), None);
|
||||
assert_eq!(super::system_time_from_timespec(TimeSpec::new(0, -1)), None);
|
||||
assert_eq!(
|
||||
super::system_time_from_timespec(TimeSpec::new(0, 1_000_000_000)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
fn is_unavailable_ipv6_loopback(error: &io::Error) -> bool {
|
||||
matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_enable_tx_timestamping_then_drain_reports_ids_through_the_adapter_api() {
|
||||
let (socket, _peer) = connected_ipv4_loopback_pair();
|
||||
// Mirrors production: RX-only first, then a best-effort upgrade.
|
||||
configure_receive_metadata(&socket, socket.peer_addr().unwrap()).unwrap();
|
||||
|
||||
if !super::try_enable_tx_timestamping(&socket) {
|
||||
eprintln!(
|
||||
"skipping adapter-level TX timestamping smoke test: kernel/container denied \
|
||||
SO_TIMESTAMPING TX flags"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(error) = socket.send(b"one") {
|
||||
eprintln!("skipping adapter-level TX timestamping smoke test: send failed: {error}");
|
||||
return;
|
||||
}
|
||||
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
||||
let mut observed = None;
|
||||
while observed.is_none() && std::time::Instant::now() < deadline {
|
||||
super::drain_tx_timestamps(&socket, |id, timestamp| {
|
||||
observed = Some((id, timestamp));
|
||||
})
|
||||
.unwrap();
|
||||
std::thread::yield_now();
|
||||
}
|
||||
|
||||
let Some((id, timestamp)) = observed else {
|
||||
eprintln!(
|
||||
"skipping adapter-level TX timestamping smoke test: no TX timestamp record \
|
||||
arrived within the poll deadline"
|
||||
);
|
||||
return;
|
||||
};
|
||||
assert_eq!(id, 0);
|
||||
assert!(timestamp.duration_since(UNIX_EPOCH).unwrap() > Duration::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_tx_timestamps_is_a_bounded_nonblocking_no_op_on_an_untouched_socket() {
|
||||
let (socket, _peer) = connected_ipv4_loopback_pair();
|
||||
configure_receive_metadata(&socket, socket.peer_addr().unwrap()).unwrap();
|
||||
|
||||
let mut calls = 0_usize;
|
||||
super::drain_tx_timestamps(&socket, |_, _| calls += 1).unwrap();
|
||||
|
||||
assert_eq!(calls, 0);
|
||||
}
|
||||
}
|
||||
@@ -181,448 +181,3 @@ pub(crate) fn try_recv_error_queue_record(fd: RawFd) -> io::Result<Option<ErrorQ
|
||||
Err(errno) => Err(io::Error::from(errno)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
net::{SocketAddr, UdpSocket},
|
||||
os::fd::AsRawFd,
|
||||
time::{Duration, Instant, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use nix::sys::{
|
||||
socket::{setsockopt, sockopt},
|
||||
time::TimeSpec,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn extended_error(
|
||||
errno: u32,
|
||||
origin: u8,
|
||||
info: u32,
|
||||
data: u32,
|
||||
) -> nix::libc::sock_extended_err {
|
||||
nix::libc::sock_extended_err {
|
||||
ee_errno: errno,
|
||||
ee_origin: origin,
|
||||
ee_type: 0,
|
||||
ee_code: 0,
|
||||
ee_pad: 0,
|
||||
ee_info: info,
|
||||
ee_data: data,
|
||||
}
|
||||
}
|
||||
|
||||
fn timestamps_with_system(system: TimeSpec) -> Timestamps {
|
||||
Timestamps {
|
||||
system,
|
||||
hw_trans: TimeSpec::new(0, 0),
|
||||
hw_raw: TimeSpec::new(0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_timestamp_error(id: u32) -> ControlMessageOwned {
|
||||
ControlMessageOwned::Ipv4RecvErr(
|
||||
extended_error(
|
||||
nix::libc::ENOMSG as u32,
|
||||
nix::libc::SO_EE_ORIGIN_TIMESTAMPING,
|
||||
SCM_TSTAMP_SND,
|
||||
id,
|
||||
),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_valid_send_timestamp_completion() {
|
||||
let cmsgs = vec![
|
||||
send_timestamp_error(7),
|
||||
ControlMessageOwned::ScmTimestampsns(timestamps_with_system(TimeSpec::new(1, 2))),
|
||||
];
|
||||
|
||||
let record = classify(cmsgs.into_iter());
|
||||
|
||||
assert_eq!(
|
||||
record,
|
||||
ErrorQueueRecord::TxTimestamp {
|
||||
id: 7,
|
||||
timestamp: UNIX_EPOCH + Duration::new(1, 2),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_timestamp_origin_without_timestamp_as_malformed() {
|
||||
let cmsgs = vec![send_timestamp_error(3)];
|
||||
|
||||
assert_eq!(
|
||||
classify(cmsgs.into_iter()),
|
||||
ErrorQueueRecord::MalformedOrUnsupportedTimestamp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_timestamp_origin_with_invalid_timespec_as_malformed() {
|
||||
let cmsgs = vec![
|
||||
send_timestamp_error(3),
|
||||
ControlMessageOwned::ScmTimestampsns(timestamps_with_system(TimeSpec::new(-1, 0))),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
classify(cmsgs.into_iter()),
|
||||
ErrorQueueRecord::MalformedOrUnsupportedTimestamp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_non_timestamp_extended_error_as_socket_error() {
|
||||
let cmsgs = vec![ControlMessageOwned::Ipv4RecvErr(
|
||||
extended_error(
|
||||
nix::libc::ECONNREFUSED as u32,
|
||||
nix::libc::SO_EE_ORIGIN_ICMP,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
None,
|
||||
)];
|
||||
|
||||
assert_eq!(
|
||||
classify(cmsgs.into_iter()),
|
||||
ErrorQueueRecord::SocketError {
|
||||
errno: nix::libc::ECONNREFUSED,
|
||||
origin: nix::libc::SO_EE_ORIGIN_ICMP,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_timestamping_origin_with_wrong_completion_kind_as_malformed() {
|
||||
// Same origin byte as a send-timestamp completion, but a different
|
||||
// `ee_info` (e.g. SCM_TSTAMP_SCHED/ACK, which this client never
|
||||
// requests). A timestamping-origin record must never become a
|
||||
// fatal SocketError merely because it reports a completion kind we
|
||||
// did not ask for; it stays timing metadata.
|
||||
let cmsgs = vec![ControlMessageOwned::Ipv4RecvErr(
|
||||
extended_error(
|
||||
nix::libc::ENOMSG as u32,
|
||||
nix::libc::SO_EE_ORIGIN_TIMESTAMPING,
|
||||
1,
|
||||
0,
|
||||
),
|
||||
None,
|
||||
)];
|
||||
|
||||
assert_eq!(
|
||||
classify(cmsgs.into_iter()),
|
||||
ErrorQueueRecord::MalformedOrUnsupportedTimestamp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_timestamping_origin_with_unexpected_errno_as_malformed() {
|
||||
// Origin is the sole discriminator: even an origin==TIMESTAMPING
|
||||
// record with a surprising errno (the kernel always reports ENOMSG
|
||||
// for this origin, but classification must not assume that) is
|
||||
// still timing metadata, never a real socket error.
|
||||
let cmsgs = vec![ControlMessageOwned::Ipv4RecvErr(
|
||||
extended_error(
|
||||
nix::libc::ECONNREFUSED as u32,
|
||||
nix::libc::SO_EE_ORIGIN_TIMESTAMPING,
|
||||
SCM_TSTAMP_SND,
|
||||
0,
|
||||
),
|
||||
None,
|
||||
)];
|
||||
|
||||
assert_eq!(
|
||||
classify(cmsgs.into_iter()),
|
||||
ErrorQueueRecord::MalformedOrUnsupportedTimestamp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_records_without_any_extended_error() {
|
||||
let cmsgs = vec![ControlMessageOwned::Ipv4Tos(0)];
|
||||
|
||||
assert_eq!(classify(cmsgs.into_iter()), ErrorQueueRecord::Ignored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_cmsgs_do_not_affect_classification() {
|
||||
let cmsgs = vec![
|
||||
ControlMessageOwned::Ipv4Tos(0),
|
||||
send_timestamp_error(4),
|
||||
ControlMessageOwned::ScmTimestampsns(timestamps_with_system(TimeSpec::new(9, 0))),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
classify(cmsgs.into_iter()),
|
||||
ErrorQueueRecord::TxTimestamp {
|
||||
id: 4,
|
||||
timestamp: UNIX_EPOCH + Duration::new(9, 0),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmsg_order_does_not_affect_classification() {
|
||||
let forward = classify(
|
||||
vec![
|
||||
send_timestamp_error(9),
|
||||
ControlMessageOwned::ScmTimestampsns(timestamps_with_system(TimeSpec::new(5, 0))),
|
||||
]
|
||||
.into_iter(),
|
||||
);
|
||||
let reversed = classify(
|
||||
vec![
|
||||
ControlMessageOwned::ScmTimestampsns(timestamps_with_system(TimeSpec::new(5, 0))),
|
||||
send_timestamp_error(9),
|
||||
]
|
||||
.into_iter(),
|
||||
);
|
||||
|
||||
assert_eq!(forward, reversed);
|
||||
}
|
||||
|
||||
/// Bounded poll for one error-queue record, since software TX timestamp
|
||||
/// delivery is asynchronous even on loopback. Never sleeps hoping
|
||||
/// delivery "probably" happened; it keeps polling until either a
|
||||
/// record appears or the overall deadline passes.
|
||||
fn poll_for_record(fd: RawFd, overall_timeout: Duration) -> Option<ErrorQueueRecord> {
|
||||
let deadline = Instant::now() + overall_timeout;
|
||||
loop {
|
||||
if let Some(record) = try_recv_error_queue_record(fd).unwrap() {
|
||||
return Some(record);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return None;
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
}
|
||||
|
||||
fn tx_timestamped_loopback_socket() -> Option<(UdpSocket, UdpSocket)> {
|
||||
let socket = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
// Held for the caller's lifetime: if this peer socket were dropped,
|
||||
// its port would close and subsequent sends would fail with
|
||||
// ECONNREFUSED once the kernel reports ICMP port-unreachable.
|
||||
let peer = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
socket.connect(peer.local_addr().unwrap()).unwrap();
|
||||
|
||||
match setsockopt(&socket, sockopt::Timestamping, &TX_TIMESTAMPING_FLAGS) {
|
||||
Ok(()) => Some((socket, peer)),
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"skipping TX timestamping smoke test: kernel/container denied \
|
||||
SO_TIMESTAMPING TX flags: {error}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_opt_id_increments_sequentially_across_separate_sends() {
|
||||
let Some((socket, _peer)) = tx_timestamped_loopback_socket() else {
|
||||
return;
|
||||
};
|
||||
let fd = socket.as_raw_fd();
|
||||
|
||||
// Under heavy parallel test load, loopback UDP sends can
|
||||
// occasionally surface a stale ICMP-derived socket error unrelated
|
||||
// to this test's own sockets. That is an environmental condition,
|
||||
// not a claim this test makes about kernel TX timestamp behavior,
|
||||
// so it is a skip rather than a failure.
|
||||
if let Err(error) = socket.send(b"first") {
|
||||
eprintln!("skipping automatic OPT_ID assertion: first send failed: {error}");
|
||||
return;
|
||||
}
|
||||
let Some(first) = poll_for_record(fd, Duration::from_secs(2)) else {
|
||||
eprintln!(
|
||||
"skipping automatic OPT_ID assertion: no TX timestamp record was \
|
||||
delivered within the poll deadline"
|
||||
);
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
matches!(first, ErrorQueueRecord::TxTimestamp { id: 0, .. }),
|
||||
"expected first send to report id 0, got {first:?}"
|
||||
);
|
||||
|
||||
if let Err(error) = socket.send(b"second") {
|
||||
eprintln!("skipping automatic OPT_ID assertion: second send failed: {error}");
|
||||
return;
|
||||
}
|
||||
let Some(second) = poll_for_record(fd, Duration::from_secs(2)) else {
|
||||
eprintln!(
|
||||
"skipping automatic OPT_ID assertion: no TX timestamp record was \
|
||||
delivered for the second send within the poll deadline"
|
||||
);
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
matches!(second, ErrorQueueRecord::TxTimestamp { id: 1, .. }),
|
||||
"expected second send to report id 1, got {second:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rx_only_sends_before_tx_enable_do_not_consume_the_later_tx_id_namespace() {
|
||||
let socket = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
let peer = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
socket.connect(peer.local_addr().unwrap()).unwrap();
|
||||
setsockopt(
|
||||
&socket,
|
||||
sockopt::Timestamping,
|
||||
&super::super::RX_TIMESTAMPING_FLAGS,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Ordinary pre-enable sends: TX_SOFTWARE is not yet requested, so
|
||||
// none of these may leave anything on the error queue for the
|
||||
// later-enabled OPT_ID counter to have skipped past.
|
||||
for _ in 0..3 {
|
||||
if let Err(error) = socket.send(b"pre-enable") {
|
||||
eprintln!("skipping pre-enable send/RX-only smoke test: {error}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
try_recv_error_queue_record(socket.as_raw_fd()).unwrap(),
|
||||
None,
|
||||
"RX-only sends must never populate MSG_ERRQUEUE"
|
||||
);
|
||||
|
||||
// This mirrors production: RX-only is configured first (e.g. at
|
||||
// connect time), and only later, after a successful Open, is the
|
||||
// socket upgraded to TX_TIMESTAMPING_FLAGS.
|
||||
if setsockopt(&socket, sockopt::Timestamping, &TX_TIMESTAMPING_FLAGS).is_err() {
|
||||
eprintln!(
|
||||
"skipping pre-enable send/RX-only smoke test: kernel/container denied \
|
||||
SO_TIMESTAMPING TX flags"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(error) = socket.send(b"first-timestamped") {
|
||||
eprintln!(
|
||||
"skipping pre-enable send/RX-only smoke test: post-upgrade send failed: {error}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Some(record) = poll_for_record(socket.as_raw_fd(), Duration::from_secs(2)) else {
|
||||
eprintln!(
|
||||
"skipping pre-enable send/RX-only smoke test: no TX timestamp record was \
|
||||
delivered within the poll deadline"
|
||||
);
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
matches!(record, ErrorQueueRecord::TxTimestamp { id: 0, .. }),
|
||||
"expected the first send after upgrade to report id 0 (pre-enable sends must not \
|
||||
have consumed part of the namespace), got {record:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_rx_scm_timestamping_still_works_after_tx_flags_are_enabled() {
|
||||
let Some((socket, peer)) = tx_timestamped_loopback_socket() else {
|
||||
return;
|
||||
};
|
||||
peer.send_to(b"reply", socket.local_addr().unwrap())
|
||||
.unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let Ok(datagram) = super::super::recv_datagram(&socket, &mut buf) else {
|
||||
eprintln!("skipping post-TX-enable RX smoke test: receive failed");
|
||||
return;
|
||||
};
|
||||
assert_eq!(&buf[..datagram.len], b"reply");
|
||||
let Some(timestamp) = datagram.meta.kernel_rx_timestamp else {
|
||||
eprintln!(
|
||||
"skipping post-TX-enable RX smoke test: kernel did not provide SCM_TIMESTAMPING"
|
||||
);
|
||||
return;
|
||||
};
|
||||
assert!(timestamp.duration_since(std::time::UNIX_EPOCH).unwrap() > Duration::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tx_timestamp_and_normal_reply_streams_do_not_interfere() {
|
||||
let Some((socket, peer)) = tx_timestamped_loopback_socket() else {
|
||||
return;
|
||||
};
|
||||
let fd = socket.as_raw_fd();
|
||||
|
||||
if let Err(error) = socket.send(b"ping") {
|
||||
eprintln!("skipping TX/RX separation smoke test: ping send failed: {error}");
|
||||
return;
|
||||
}
|
||||
let mut incoming = [0_u8; 16];
|
||||
let Ok((len, from)) = peer.recv_from(&mut incoming) else {
|
||||
eprintln!("skipping TX/RX separation smoke test: peer never observed ping");
|
||||
return;
|
||||
};
|
||||
assert_eq!(&incoming[..len], b"ping");
|
||||
peer.send_to(b"pong", from).unwrap();
|
||||
|
||||
// Reading the normal payload must not require, wait for, or be
|
||||
// disturbed by draining MSG_ERRQUEUE first.
|
||||
let mut reply_buf = [0_u8; 16];
|
||||
let Ok(datagram) = super::super::recv_datagram(&socket, &mut reply_buf) else {
|
||||
eprintln!("skipping TX/RX separation smoke test: normal receive failed");
|
||||
return;
|
||||
};
|
||||
assert_eq!(&reply_buf[..datagram.len], b"pong");
|
||||
|
||||
// The TX timestamp for "ping" is still independently available on
|
||||
// the error queue, unconsumed by the normal receive above.
|
||||
let Some(record) = poll_for_record(fd, Duration::from_secs(2)) else {
|
||||
eprintln!("skipping TX/RX separation smoke test: no TX timestamp record arrived");
|
||||
return;
|
||||
};
|
||||
assert!(matches!(
|
||||
record,
|
||||
ErrorQueueRecord::TxTimestamp { id: 0, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tx_timestamp_correlation_does_not_depend_on_original_payload() {
|
||||
let Some((socket, _peer)) = tx_timestamped_loopback_socket() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// OPT_TSONLY notifications never carry the original payload; a
|
||||
// larger payload must classify identically to the zero-length one
|
||||
// used elsewhere, since correlation is by `id` alone.
|
||||
let payload = vec![0x5A_u8; 512];
|
||||
if let Err(error) = socket.send(&payload) {
|
||||
eprintln!("skipping OPT_TSONLY payload-independence smoke test: send failed: {error}");
|
||||
return;
|
||||
}
|
||||
let Some(record) = poll_for_record(socket.as_raw_fd(), Duration::from_secs(2)) else {
|
||||
eprintln!(
|
||||
"skipping OPT_TSONLY payload-independence smoke test: no TX timestamp record \
|
||||
arrived"
|
||||
);
|
||||
return;
|
||||
};
|
||||
assert!(matches!(
|
||||
record,
|
||||
ErrorQueueRecord::TxTimestamp { id: 0, .. }
|
||||
));
|
||||
}
|
||||
|
||||
// NOTE: There is no live test for whether a failed nonblocking `sendmsg`
|
||||
// consumes an automatic OPT_ID. Forcing a reliable, non-flaky `WouldBlock`
|
||||
// on a connected loopback UDP socket (for example through socket-buffer
|
||||
// saturation) has no deterministic OS-level mechanism in this environment,
|
||||
// and the repository's testing policy forbids sleep/saturation-based
|
||||
// flakiness. The correlation is intentionally best effort: a kernel ID can
|
||||
// theoretically be consumed by a send that later fails. The deterministic
|
||||
// adapter test covers only the local half: an injected `WouldBlock` does
|
||||
// not advance the local wire-sequence counter. Any later unmatched kernel
|
||||
// timestamp is discarded and the userspace `sent_at` remains the fallback.
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -346,113 +346,3 @@ fn record_restriction(
|
||||
restrictions.push(restriction);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use irtt_proto::{Clock, ReceivedStats, ServerFill, StampAt};
|
||||
|
||||
fn default_params() -> Params {
|
||||
Params {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
duration_ns: 3_000_000_000,
|
||||
interval_ns: 1_000_000_000,
|
||||
length: 256,
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF).
|
||||
dscp: 184,
|
||||
server_fill: Some(ServerFill {
|
||||
value: "rand".to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_rejected(requested: &Params, returned: &Params, policy: NegotiationPolicy) {
|
||||
assert!(matches!(
|
||||
negotiate_params(requested, returned.clone(), policy),
|
||||
Err(ClientError::NegotiationRejected { .. })
|
||||
));
|
||||
}
|
||||
|
||||
fn assert_negotiates(
|
||||
requested: &Params,
|
||||
returned: &Params,
|
||||
policy: NegotiationPolicy,
|
||||
) -> NegotiatedParams {
|
||||
negotiate_params(requested, returned.clone(), policy)
|
||||
.unwrap_or_else(|err| panic!("expected negotiation success, got {err:?}"))
|
||||
}
|
||||
|
||||
fn rejection_reason(
|
||||
requested: &Params,
|
||||
returned: &Params,
|
||||
policy: NegotiationPolicy,
|
||||
) -> String {
|
||||
match negotiate_params(requested, returned.clone(), policy) {
|
||||
Err(ClientError::NegotiationRejected { reason }) => reason,
|
||||
other => panic!("expected negotiation rejection, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_duration_negotiation_uses_run_duration_semantics() {
|
||||
let requested = default_params();
|
||||
|
||||
let mut returned = requested.clone();
|
||||
returned.duration_ns = requested.duration_ns / 2;
|
||||
let negotiated = assert_negotiates(&requested, &returned, NegotiationPolicy::Loose);
|
||||
assert_eq!(
|
||||
negotiated.restrictions,
|
||||
vec![NegotiationRestriction::DurationReduced {
|
||||
requested_ns: requested.duration_ns,
|
||||
negotiated_ns: returned.duration_ns,
|
||||
}]
|
||||
);
|
||||
|
||||
let mut returned = requested.clone();
|
||||
returned.duration_ns = requested.duration_ns + 1;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
||||
"duration increased"
|
||||
);
|
||||
|
||||
let mut returned = requested.clone();
|
||||
returned.duration_ns = 0;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
||||
"server returned continuous duration for finite request"
|
||||
);
|
||||
|
||||
let mut continuous_requested = requested.clone();
|
||||
continuous_requested.duration_ns = 0;
|
||||
let mut finite_returned = continuous_requested.clone();
|
||||
finite_returned.duration_ns = 1_000_000_000;
|
||||
let negotiated = assert_negotiates(
|
||||
&continuous_requested,
|
||||
&finite_returned,
|
||||
NegotiationPolicy::Loose,
|
||||
);
|
||||
assert_eq!(
|
||||
negotiated.restrictions,
|
||||
vec![NegotiationRestriction::DurationReduced {
|
||||
requested_ns: 0,
|
||||
negotiated_ns: finite_returned.duration_ns,
|
||||
}]
|
||||
);
|
||||
|
||||
assert_rejected(
|
||||
&continuous_requested,
|
||||
&finite_returned,
|
||||
NegotiationPolicy::Strict,
|
||||
);
|
||||
|
||||
assert!(negotiate_params(
|
||||
&continuous_requested,
|
||||
continuous_requested.clone(),
|
||||
NegotiationPolicy::Strict
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -158,166 +158,3 @@ pub(crate) fn connect_tokio_udp_socket(
|
||||
socket.set_nonblocking(true)?;
|
||||
Ok(tokio::net::UdpSocket::from_std(socket)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_server_addr_adds_default_ports() {
|
||||
assert_eq!(normalize_server_addr("127.0.0.1"), "127.0.0.1:2112");
|
||||
assert_eq!(normalize_server_addr("127.0.0.1:1234"), "127.0.0.1:1234");
|
||||
assert_eq!(normalize_server_addr("localhost"), "localhost:2112");
|
||||
assert_eq!(normalize_server_addr("localhost:1234"), "localhost:1234");
|
||||
assert_eq!(normalize_server_addr("::1"), "[::1]:2112");
|
||||
assert_eq!(normalize_server_addr("[::1]"), "[::1]:2112");
|
||||
assert_eq!(normalize_server_addr("[::1]:1234"), "[::1]:1234");
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_ipv6_only_is_applied_before_bind() {
|
||||
let config = SocketConfig {
|
||||
ipv6_only: true,
|
||||
..SocketConfig::default()
|
||||
};
|
||||
let remote = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], DEFAULT_PORT));
|
||||
|
||||
let socket = create_prebind_udp_socket(&config, remote).unwrap();
|
||||
|
||||
assert!(socket.only_v6().unwrap());
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_ipv6_socket_preserves_v6only_and_explicit_bind() {
|
||||
let bind_addr = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 0));
|
||||
let peer = match UdpSocket::bind(bind_addr) {
|
||||
Ok(peer) => peer,
|
||||
Err(error) if ipv6_loopback_unavailable(&error) => {
|
||||
eprintln!("skipping IPv6 Tokio socket test: IPv6 loopback unavailable: {error}");
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
};
|
||||
let remote = peer.local_addr().unwrap();
|
||||
let config = SocketConfig {
|
||||
bind_addr: Some(bind_addr),
|
||||
ipv6_only: true,
|
||||
..SocketConfig::default()
|
||||
};
|
||||
|
||||
tokio_runtime().block_on(async {
|
||||
let socket = connect_tokio_udp_socket(&config, remote).unwrap();
|
||||
|
||||
assert!(socket2::SockRef::from(&socket).only_v6().unwrap());
|
||||
assert!(socket.local_addr().unwrap().is_ipv6());
|
||||
assert_eq!(socket.local_addr().unwrap().ip(), bind_addr.ip());
|
||||
assert_eq!(socket.peer_addr().unwrap(), remote);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_ipv4_socket_preserves_family_bind_peer_and_ttl() {
|
||||
let peer = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
let remote = peer.local_addr().unwrap();
|
||||
let bind_addr = SocketAddr::from(([127, 0, 0, 1], 0));
|
||||
let config = SocketConfig {
|
||||
bind_addr: Some(bind_addr),
|
||||
ttl: Some(64),
|
||||
..SocketConfig::default()
|
||||
};
|
||||
|
||||
tokio_runtime().block_on(async {
|
||||
let socket = connect_tokio_udp_socket(&config, remote).unwrap();
|
||||
|
||||
assert!(socket.local_addr().unwrap().is_ipv4());
|
||||
assert_eq!(socket.local_addr().unwrap().ip(), bind_addr.ip());
|
||||
assert_eq!(socket.peer_addr().unwrap(), remote);
|
||||
assert_eq!(socket2::SockRef::from(&socket).ttl_v4().unwrap(), 64);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_resolver_filters_address_families() {
|
||||
let mut config = ClientConfig {
|
||||
server_addr: "localhost:2112".to_owned(),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
config.socket_config.ipv4_only = true;
|
||||
|
||||
tokio_runtime().block_on(async {
|
||||
let remote = resolve_remote_tokio(&config).await.unwrap();
|
||||
|
||||
assert!(remote.is_ipv4());
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_resolver_selects_ipv6_for_ipv6_only_hostname() {
|
||||
let mut config = ClientConfig {
|
||||
server_addr: "localhost:2112".to_owned(),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
config.socket_config.ipv6_only = true;
|
||||
|
||||
tokio_runtime().block_on(async {
|
||||
let remote = resolve_remote_tokio(&config).await.unwrap();
|
||||
assert!(remote.is_ipv6());
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_resolver_rejects_disallowed_literal_family() {
|
||||
let mut config = ClientConfig {
|
||||
server_addr: "127.0.0.1:2112".to_owned(),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
config.socket_config.ipv6_only = true;
|
||||
|
||||
tokio_runtime().block_on(async {
|
||||
assert!(matches!(
|
||||
resolve_remote_tokio(&config).await,
|
||||
Err(ClientError::Resolve { .. })
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_resolver_rejects_mixed_family_restrictions() {
|
||||
let mut config = ClientConfig {
|
||||
server_addr: "127.0.0.1:2112".to_owned(),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
config.socket_config.ipv4_only = true;
|
||||
config.socket_config.ipv6_only = true;
|
||||
|
||||
tokio_runtime().block_on(async {
|
||||
assert!(matches!(
|
||||
resolve_remote_tokio(&config).await,
|
||||
Err(ClientError::Resolve { .. })
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
fn tokio_runtime() -> tokio::runtime::Runtime {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
fn ipv6_loopback_unavailable(error: &std::io::Error) -> bool {
|
||||
matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::AddrNotAvailable | std::io::ErrorKind::Unsupported
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
use irtt_client::*;
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[path = "client/mod.rs"]
|
||||
mod client;
|
||||
+7
-8
@@ -2,8 +2,7 @@ use super::*;
|
||||
|
||||
#[test]
|
||||
fn open_fails_after_close() {
|
||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
@@ -123,7 +122,7 @@ fn close_flagged_duplicate_emits_duplicate_then_closes() {
|
||||
}
|
||||
});
|
||||
let mut client = Client::connect(ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -181,7 +180,7 @@ fn close_flagged_retained_timeout_emits_late_then_closes() {
|
||||
});
|
||||
let mut client = Client::connect(ClientConfig {
|
||||
probe_timeout: Duration::from_millis(50),
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -276,7 +275,7 @@ fn close_flagged_evicted_sequence_emits_late_then_closes() {
|
||||
});
|
||||
let mut client = Client::connect(ClientConfig {
|
||||
max_pending_probes: 1,
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -340,7 +339,7 @@ fn close_flagged_untracked_sequence_emits_warning_then_closes() {
|
||||
}
|
||||
});
|
||||
let mut client = Client::connect(ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -398,7 +397,7 @@ fn wrong_token_close_flag_does_not_close_session() {
|
||||
}
|
||||
});
|
||||
let mut client = Client::connect(ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -453,7 +452,7 @@ fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
|
||||
}
|
||||
});
|
||||
let config = ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
File renamed without changes.
@@ -0,0 +1,76 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hmac_open_ignores_missing_hmac_before_valid_reply() {
|
||||
let key = b"secret".to_vec();
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.hmac_key = Some(key.clone());
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let missing = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&key));
|
||||
socket.send_to(&missing, peer).unwrap();
|
||||
socket.send_to(&valid, peer).unwrap();
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_open_ignores_bad_hmac_before_valid_reply() {
|
||||
let key = b"secret".to_vec();
|
||||
let wrong_key = b"wrong".to_vec();
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.hmac_key = Some(key.clone());
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let bad = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&wrong_key));
|
||||
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&key));
|
||||
socket.send_to(&bad, peer).unwrap();
|
||||
socket.send_to(&valid, peer).unwrap();
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_token_hmac_negotiation_failure_sends_authenticated_cleanup_close() {
|
||||
let key = b"secret".to_vec();
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.hmac_key = Some(key.clone());
|
||||
let mut returned = default_params();
|
||||
returned.interval_ns += 1;
|
||||
let server_key = key.clone();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, Some(&server_key));
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::NegotiationRejected { .. })
|
||||
));
|
||||
let packets: Vec<_> = server.rx.iter().take(2).collect();
|
||||
let cleanup = &packets[1];
|
||||
assert_eq!(cleanup[3], flags::FLAG_CLOSE | FLAG_HMAC);
|
||||
verify_packet_hmac(&key, cleanup).unwrap();
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(cleanup[4 + HMAC_SIZE..12 + HMAC_SIZE].try_into().unwrap()),
|
||||
TOKEN
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use super::*;
|
||||
use irtt_client::NegotiationPolicy;
|
||||
use irtt_client::{NegotiatedParams, RunMode, WarningKind, MAX_UDP_PAYLOAD_LENGTH};
|
||||
use irtt_proto::{flags, Params, TimestampFields};
|
||||
use irtt_proto::{
|
||||
flags::FLAG_HMAC, flags::FLAG_OPEN, flags::FLAG_REPLY, verify_packet_hmac, Clock, HMAC_SIZE,
|
||||
MAGIC,
|
||||
};
|
||||
use std::thread;
|
||||
#[cfg(all(target_os = "linux", feature = "ancillary"))]
|
||||
use std::time::SystemTime;
|
||||
|
||||
mod support;
|
||||
use support::*;
|
||||
|
||||
mod close;
|
||||
mod config;
|
||||
mod hmac;
|
||||
mod no_test;
|
||||
mod open;
|
||||
mod probes_replies;
|
||||
mod ttl;
|
||||
+7
-8
@@ -4,7 +4,7 @@ use super::*;
|
||||
fn no_test_rejects_non_close_open_reply() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.run_mode = RunMode::NoTest;
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
@@ -27,7 +27,6 @@ fn no_test_rejects_non_close_open_reply() {
|
||||
u64::from_le_bytes(packets[1][4..12].try_into().unwrap()),
|
||||
TOKEN
|
||||
);
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
server.join();
|
||||
}
|
||||
|
||||
@@ -35,7 +34,7 @@ fn no_test_rejects_non_close_open_reply() {
|
||||
fn no_test_rejects_non_zero_token_with_close_reply() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.run_mode = RunMode::NoTest;
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let params = default_params();
|
||||
let server = no_test_server(params, TOKEN);
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
@@ -50,7 +49,7 @@ fn no_test_rejects_non_zero_token_with_close_reply() {
|
||||
fn no_test_strict_negotiation_rejects_changed_params() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.run_mode = RunMode::NoTest;
|
||||
let mut params = params_from_config(&config).unwrap();
|
||||
let mut params = default_params();
|
||||
params.dscp = 1;
|
||||
let server = no_test_server(params, 0);
|
||||
config.server_addr = server.addr.to_string();
|
||||
@@ -67,7 +66,7 @@ fn no_test_loose_negotiation_accepts_restricted_params() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.run_mode = RunMode::NoTest;
|
||||
config.negotiation_policy = NegotiationPolicy::Loose;
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
let requested = default_params();
|
||||
let mut params = requested.clone();
|
||||
params.duration_ns /= 2;
|
||||
let server = no_test_server(params.clone(), 0);
|
||||
@@ -77,7 +76,7 @@ fn no_test_loose_negotiation_accepts_restricted_params() {
|
||||
assert_eq!(negotiated.params, params);
|
||||
assert_eq!(
|
||||
negotiated.restrictions,
|
||||
vec![crate::NegotiationRestriction::DurationReduced {
|
||||
vec![irtt_client::NegotiationRestriction::DurationReduced {
|
||||
requested_ns: requested.duration_ns,
|
||||
negotiated_ns: params.duration_ns,
|
||||
}]
|
||||
@@ -89,7 +88,7 @@ fn no_test_loose_negotiation_accepts_restricted_params() {
|
||||
fn send_probe_fails_after_no_test_completed() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.run_mode = RunMode::NoTest;
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let params = default_params();
|
||||
let server = no_test_server(params, 0);
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
@@ -105,7 +104,7 @@ fn send_probe_fails_after_no_test_completed() {
|
||||
fn open_fails_after_no_test_completed() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.run_mode = RunMode::NoTest;
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let params = default_params();
|
||||
let server = no_test_server(params, 0);
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
+5
-37
@@ -2,8 +2,7 @@ use super::*;
|
||||
|
||||
#[test]
|
||||
fn open_fails_when_already_open() {
|
||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let params = default_params();
|
||||
let server = open_success_server(params);
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
@@ -36,7 +35,7 @@ fn open_retries_after_first_timeout() {
|
||||
fn protocol_version_mismatch_fails() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.negotiation_policy = NegotiationPolicy::Loose;
|
||||
let mut params = params_from_config(&config).unwrap();
|
||||
let mut params = default_params();
|
||||
params.protocol_version = 2;
|
||||
let server = open_success_server(params);
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
@@ -45,14 +44,12 @@ fn protocol_version_mismatch_fails() {
|
||||
Err(ClientError::ProtocolVersionMismatch { received: 2, .. })
|
||||
));
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_rejection_fails_in_normal_mode() {
|
||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY | flags::FLAG_CLOSE, 0, ¶ms, None);
|
||||
@@ -61,7 +58,6 @@ fn server_rejection_fails_in_normal_mode() {
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert!(matches!(client.open(), Err(ClientError::ServerRejected)));
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
server.join();
|
||||
}
|
||||
|
||||
@@ -132,25 +128,6 @@ fn post_token_negotiation_failure_sends_cleanup_close() {
|
||||
u64::from_le_bytes(packets[1][4..12].try_into().unwrap()),
|
||||
TOKEN
|
||||
);
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_open_restores_configured_receive_timeout() {
|
||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = open_success_server(params);
|
||||
let mut config = default_test_config(server.addr);
|
||||
config.socket_config.recv_timeout = Some(Duration::from_millis(75));
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
assert_eq!(
|
||||
client.socket.read_timeout().unwrap(),
|
||||
Some(Duration::from_millis(75))
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
@@ -159,7 +136,7 @@ fn open_timeout_after_all_timeouts() {
|
||||
let server = timeout_server(Duration::from_millis(700));
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(50)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -168,10 +145,6 @@ fn open_timeout_after_all_timeouts() {
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
||||
assert_eq!(server.rx.iter().take(2).count(), 2);
|
||||
assert_eq!(
|
||||
client.socket.read_timeout().unwrap(),
|
||||
Some(Duration::from_millis(50))
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
@@ -222,7 +195,7 @@ fn only_ignored_open_datagrams_eventually_time_out() {
|
||||
});
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(50)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -233,10 +206,6 @@ fn only_ignored_open_datagrams_eventually_time_out() {
|
||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
||||
assert_eq!(server.rx.iter().take(2).count(), 2);
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
assert_eq!(
|
||||
client.socket.read_timeout().unwrap(),
|
||||
Some(Duration::from_millis(50))
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
@@ -259,7 +228,6 @@ fn trusted_zero_token_normal_reply_is_terminal() {
|
||||
Err(ClientError::Protocol(irtt_proto::ProtoError::ZeroToken))
|
||||
));
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
|
||||
server.join();
|
||||
}
|
||||
File renamed without changes.
+1
-2
@@ -2,7 +2,7 @@ use super::*;
|
||||
use support::echo_packet_len;
|
||||
|
||||
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
|
||||
fn assert_packet_meta_unavailable(packet_meta: &crate::event::PacketMeta) {
|
||||
fn assert_packet_meta_unavailable(packet_meta: &irtt_client::PacketMeta) {
|
||||
assert_eq!(packet_meta.traffic_class, None);
|
||||
assert_eq!(packet_meta.dscp, None);
|
||||
assert_eq!(packet_meta.ecn, None);
|
||||
@@ -25,4 +25,3 @@ mod reply_classification;
|
||||
mod reply_decode;
|
||||
mod reply_metadata;
|
||||
mod timeouts;
|
||||
mod timing_math;
|
||||
+3
-60
@@ -1,62 +1,5 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn minimal_negotiated_layout_works() {
|
||||
let params = Params {
|
||||
protocol_version: 1,
|
||||
duration_ns: 3_000_000_000,
|
||||
interval_ns: 1_000_000_000,
|
||||
received_stats: ReceivedStats::None,
|
||||
stamp_at: StampAt::None,
|
||||
clock: Clock::Both,
|
||||
..Params::default()
|
||||
};
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let mut buf = [0_u8; 2048];
|
||||
if let Ok((size, _)) = socket.recv_from(&mut buf) {
|
||||
tx.send(buf[..size].to_vec()).unwrap();
|
||||
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
|
||||
let ts = TimestampFields::default();
|
||||
let reply_packet = echo_reply_packet(TOKEN, seq, ¶ms, &ts, None);
|
||||
socket.send_to(&reply_packet, peer).unwrap();
|
||||
}
|
||||
});
|
||||
let config = ClientConfig {
|
||||
received_stats: ReceivedStats::None,
|
||||
stamp_at: StampAt::None,
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
thread::sleep(Duration::from_millis(30));
|
||||
let events = client.recv_once().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
if let ClientEvent::EchoReply {
|
||||
received_stats,
|
||||
server_timing,
|
||||
..
|
||||
} = &events[0]
|
||||
{
|
||||
assert!(received_stats.is_none());
|
||||
assert!(server_timing.is_none());
|
||||
} else {
|
||||
panic!("expected EchoReply");
|
||||
}
|
||||
server.join();
|
||||
}
|
||||
|
||||
// ---------- Regression tests ----------
|
||||
|
||||
#[test]
|
||||
@@ -81,7 +24,7 @@ fn short_echo_reply_does_not_emit_echo_reply() {
|
||||
});
|
||||
let config = ClientConfig {
|
||||
length: 64,
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -127,7 +70,7 @@ fn overlong_datagram_detection_uses_extra_receive_byte() {
|
||||
});
|
||||
let config = ClientConfig {
|
||||
length: 4096,
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -170,7 +113,7 @@ fn exact_length_echo_reply_still_emits_echo_reply() {
|
||||
});
|
||||
let config = ClientConfig {
|
||||
length: 4096,
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
+2
-2
@@ -23,7 +23,7 @@ fn duplicate_reply_emits_duplicate_event() {
|
||||
}
|
||||
});
|
||||
let config = ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -77,7 +77,7 @@ fn out_of_order_reply_emits_late_event() {
|
||||
socket.send_to(&reply0, peer).unwrap();
|
||||
});
|
||||
let config = ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
+2
-62
@@ -5,7 +5,7 @@ fn recv_once_returns_empty_on_timeout() {
|
||||
let params = default_params();
|
||||
let server = open_success_server(params);
|
||||
let config = ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(50)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -18,66 +18,6 @@ fn recv_once_returns_empty_on_timeout() {
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recv_once_decodes_echo_reply_and_emits_event() {
|
||||
let params = default_params();
|
||||
let (mut client, server) = open_client_with_echo_server(¶ms);
|
||||
|
||||
client.send_probe().unwrap();
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let events = client.recv_once().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
match &events[0] {
|
||||
ClientEvent::EchoReply {
|
||||
seq,
|
||||
rtt,
|
||||
received_stats,
|
||||
server_timing,
|
||||
bytes,
|
||||
packet_meta,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(*seq, 0);
|
||||
assert_eq!(*bytes, echo_packet_len(false, ¶ms));
|
||||
assert!(rtt.raw > Duration::ZERO);
|
||||
assert_eq!(
|
||||
rtt.effective,
|
||||
rtt.adjusted
|
||||
.unwrap_or_else(|| SignedDuration::from_duration(rtt.raw))
|
||||
);
|
||||
assert!(received_stats.is_some());
|
||||
let stats = received_stats.as_ref().unwrap();
|
||||
assert_eq!(stats.count, Some(42));
|
||||
assert_eq!(stats.window, Some(0x07));
|
||||
assert!(server_timing.is_some());
|
||||
let st = server_timing.as_ref().unwrap();
|
||||
assert!(st.processing.is_some());
|
||||
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
|
||||
{
|
||||
assert_eq!(packet_meta.traffic_class, None);
|
||||
assert_eq!(packet_meta.dscp, None);
|
||||
assert_eq!(packet_meta.ecn, None);
|
||||
assert_eq!(packet_meta.kernel_rx_timestamp, None);
|
||||
}
|
||||
#[cfg(all(target_os = "linux", feature = "ancillary"))]
|
||||
{
|
||||
assert_eq!(
|
||||
packet_meta.dscp,
|
||||
packet_meta.traffic_class.map(|tc| tc >> 2)
|
||||
);
|
||||
assert_eq!(
|
||||
packet_meta.ecn,
|
||||
packet_meta.traffic_class.map(|tc| tc & 0b11)
|
||||
);
|
||||
}
|
||||
}
|
||||
other => panic!("expected EchoReply, got {other:?}"),
|
||||
}
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recv_once_decodes_only_received_bytes_after_longer_datagram() {
|
||||
let params = default_params();
|
||||
@@ -113,7 +53,7 @@ fn recv_once_decodes_only_received_bytes_after_longer_datagram() {
|
||||
socket.send_to(&shorter, peer).unwrap();
|
||||
});
|
||||
let config = ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
+2
-2
@@ -14,13 +14,13 @@ fn echo_reply_metadata_propagates_observed_dscp_with_ancillary() {
|
||||
let (size, _) = socket.recv_from(&mut buf).unwrap();
|
||||
tx.send(buf[..size].to_vec()).unwrap();
|
||||
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
|
||||
crate::socket_options::apply_traffic_class_to_socket(&socket, peer, 184).unwrap();
|
||||
socket2::SockRef::from(&socket).set_tos_v4(184).unwrap();
|
||||
let reply_packet =
|
||||
echo_reply_packet(TOKEN, seq, ¶ms, &TimestampFields::default(), None);
|
||||
socket.send_to(&reply_packet, peer).unwrap();
|
||||
});
|
||||
let config = ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
+3
-3
@@ -6,7 +6,7 @@ fn poll_timeouts_emits_echo_loss() {
|
||||
let server = silent_open_server(params);
|
||||
let config = ClientConfig {
|
||||
probe_timeout: Duration::from_millis(100),
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(50)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -57,7 +57,7 @@ fn late_reply_after_timeout_preserves_measurement_metadata() {
|
||||
});
|
||||
let config = ClientConfig {
|
||||
probe_timeout: Duration::from_millis(40),
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -119,7 +119,7 @@ fn pending_full_does_not_send_packet() {
|
||||
let config = ClientConfig {
|
||||
duration: Some(Duration::from_secs(60)),
|
||||
max_pending_probes: 2,
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(50)),
|
||||
..Default::default()
|
||||
},
|
||||
+1
-1
@@ -253,7 +253,7 @@ pub(super) fn assert_no_test_completed(outcome: OpenOutcome) -> NegotiatedParams
|
||||
pub(super) fn open_client_with_echo_server(params: &Params) -> (Client, FakeServer) {
|
||||
let server = echo_server(params.clone());
|
||||
let config = ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -0,0 +1,22 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn connect_accepts_valid_ttl_config_values() {
|
||||
for ttl in [None, Some(1), Some(255)] {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 9)));
|
||||
config.socket_config.ttl = ttl;
|
||||
Client::connect(config).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_rejects_invalid_ttl_values() {
|
||||
for ttl in [0, 256] {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 9)));
|
||||
config.socket_config.ttl = Some(ttl);
|
||||
assert!(matches!(
|
||||
Client::connect(config),
|
||||
Err(ClientError::InvalidConfig { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -75,16 +75,48 @@ async fn exercise_client_server() {
|
||||
..
|
||||
}] if *remote == server_addr
|
||||
));
|
||||
assert!(matches!(
|
||||
client.recv().await.unwrap().as_slice(),
|
||||
[ClientEvent::EchoReply {
|
||||
seq: 0,
|
||||
remote,
|
||||
server_timing: Some(_),
|
||||
received_stats: Some(_),
|
||||
..
|
||||
}] if *remote == server_addr
|
||||
));
|
||||
let events = client.recv().await.unwrap();
|
||||
let [ClientEvent::EchoReply {
|
||||
seq: 0,
|
||||
remote,
|
||||
sent_at,
|
||||
received_at,
|
||||
rtt,
|
||||
server_timing: Some(timing),
|
||||
one_way: Some(one_way),
|
||||
received_stats: Some(_),
|
||||
packet_meta,
|
||||
..
|
||||
}] = events.as_slice()
|
||||
else {
|
||||
panic!("expected a measured reply, got {events:?}");
|
||||
};
|
||||
assert_eq!(*remote, server_addr);
|
||||
assert_eq!(rtt.raw, received_at.mono.duration_since(sent_at.mono));
|
||||
// Verify the measurement endpoint through public reply metadata and real
|
||||
// OS timestamps. This also covers the userspace fallback on other targets.
|
||||
let receive_wall = packet_meta
|
||||
.kernel_rx_timestamp
|
||||
.filter(|kernel| {
|
||||
received_at
|
||||
.wall
|
||||
.duration_since(*kernel)
|
||||
.is_ok_and(|lag| lag <= Duration::from_secs(1))
|
||||
})
|
||||
.unwrap_or(received_at.wall);
|
||||
let receive_ns = i128::try_from(
|
||||
receive_wall
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
one_way.server_to_client,
|
||||
Some(irtt_client::SignedDuration::from_nanos(
|
||||
receive_ns - i128::from(timing.send_wall_ns.unwrap())
|
||||
))
|
||||
);
|
||||
assert!(matches!(
|
||||
client.close().await.unwrap().as_slice(),
|
||||
[ClientEvent::SessionClosed { remote, .. }] if *remote == server_addr
|
||||
|
||||
+10
-13
@@ -13,13 +13,17 @@ use irtt_proto::{
|
||||
};
|
||||
use tokio::runtime::{Builder, Runtime};
|
||||
|
||||
#[path = "in_tree_server_support.rs"]
|
||||
mod in_tree_server;
|
||||
mod support;
|
||||
|
||||
use super::*;
|
||||
use crate::{Client, RunMode, SocketConfig};
|
||||
use in_tree_server::InTreeServer;
|
||||
use irtt_client::{AsyncClient, ClientConfig, ClientError, ClientEvent, OpenOutcome};
|
||||
use irtt_client::{Client, RunMode, SocketConfig};
|
||||
use irtt_server::ServerConfig;
|
||||
use std::{
|
||||
io,
|
||||
net::SocketAddr,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use support::InTreeServer;
|
||||
|
||||
const TOKEN: u64 = 0x1234_5678_90ab_cdef;
|
||||
|
||||
@@ -341,7 +345,6 @@ fn authenticated_rejection_is_terminal_without_retry() {
|
||||
client.open().await,
|
||||
Err(ClientError::ServerRejected)
|
||||
));
|
||||
assert!(client.machine.prepare_open_request().is_ok());
|
||||
});
|
||||
assert_eq!(server.finish().len(), 1);
|
||||
}
|
||||
@@ -387,11 +390,6 @@ fn authenticated_async_lifecycle_applies_negotiated_dscp() {
|
||||
.unwrap();
|
||||
let opened = client.open().await.unwrap();
|
||||
assert_eq!(open_negotiated(&opened).params.dscp, 184);
|
||||
#[cfg(not(any(
|
||||
target_os = "fuchsia", target_os = "redox", target_os = "solaris",
|
||||
target_os = "illumos", target_os = "haiku",
|
||||
)))]
|
||||
assert_eq!(socket2::SockRef::from(&client.socket).tos_v4().unwrap() >> 2, 46);
|
||||
client.send_probe().await.unwrap();
|
||||
assert!(matches!(client.recv().await.unwrap().as_slice(), [ClientEvent::EchoReply { seq: 0, .. }]));
|
||||
assert!(matches!(client.close().await.unwrap().as_slice(), [ClientEvent::SessionClosed { token, .. }] if *token == open_token(&opened)));
|
||||
@@ -433,7 +431,7 @@ fn blocking_and_async_peer_close_are_semantically_equivalent() {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_negotiated(outcome: &OpenOutcome) -> &crate::NegotiatedParams {
|
||||
fn open_negotiated(outcome: &OpenOutcome) -> &irtt_client::NegotiatedParams {
|
||||
match outcome {
|
||||
OpenOutcome::Started { negotiated, .. }
|
||||
| OpenOutcome::NoTestCompleted { negotiated, .. } => negotiated,
|
||||
@@ -675,7 +673,6 @@ fn ignored_open_traffic_uses_one_request_and_deadline_per_attempt() {
|
||||
runtime().block_on(async {
|
||||
let mut client = AsyncClient::connect(client_config).await.unwrap();
|
||||
assert!(matches!(client.open().await, Err(ClientError::OpenTimeout)));
|
||||
assert!(client.machine.prepare_open_request().is_ok());
|
||||
});
|
||||
assert_eq!(server.finish().len(), 2);
|
||||
}
|
||||
+6
-45
@@ -1,6 +1,6 @@
|
||||
use std::{
|
||||
net::{SocketAddr, UdpSocket},
|
||||
sync::{mpsc::sync_channel, Arc, Condvar, Mutex},
|
||||
sync::{Arc, Condvar, Mutex},
|
||||
thread::{self, JoinHandle},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
@@ -11,12 +11,11 @@ use irtt_proto::{
|
||||
};
|
||||
use tokio::{runtime::Builder, time::timeout};
|
||||
|
||||
use super::{
|
||||
blocking::WorkerRuntime, BlockingManagedClient, ManagedClientConfig, ManagedCompletionPolicy,
|
||||
ManagedEndReason, ManagedEvent, ManagedLifecycle, ManagedPacing, ManagedTargetConfig,
|
||||
ManagedTargetEndReason,
|
||||
use irtt_client::managed::{
|
||||
BlockingManagedClient, ManagedClientConfig, ManagedCompletionPolicy, ManagedEndReason,
|
||||
ManagedEvent, ManagedLifecycle, ManagedPacing, ManagedTargetConfig, ManagedTargetEndReason,
|
||||
};
|
||||
use crate::ClientConfig;
|
||||
use irtt_client::ClientConfig;
|
||||
|
||||
const TOKEN: u64 = 0x1234_5678_90ab_cdef;
|
||||
|
||||
@@ -166,7 +165,7 @@ fn target(server: &EchoServer) -> ManagedTargetConfig {
|
||||
ManagedTargetConfig::new("target", server.addr.to_string())
|
||||
}
|
||||
|
||||
fn assert_stopped(outcome: &super::ManagedOutcome) {
|
||||
fn assert_stopped(outcome: &irtt_client::managed::ManagedOutcome) {
|
||||
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
|
||||
}
|
||||
|
||||
@@ -308,44 +307,6 @@ fn dropping_owner_stops_and_joins_worker() {
|
||||
server.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_worker_runtime_inside_async_context_does_not_panic() {
|
||||
test_runtime().block_on(async {
|
||||
let runtime = Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
drop(WorkerRuntime::new(runtime));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_runtime_shutdown_is_bounded_with_blocking_work() {
|
||||
let runtime = Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
let (started_send, started_receive) = sync_channel(0);
|
||||
let (release_send, release_receive) = sync_channel(0);
|
||||
|
||||
runtime.block_on(async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
started_send.send(()).unwrap();
|
||||
release_receive.recv().unwrap();
|
||||
});
|
||||
});
|
||||
started_receive
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.unwrap();
|
||||
|
||||
let started = Instant::now();
|
||||
WorkerRuntime::new(runtime).shutdown();
|
||||
assert!(started.elapsed() < Duration::from_secs(2));
|
||||
release_send.send(()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_after_natural_completion_preserves_outcome() {
|
||||
let server = EchoServer::start();
|
||||
@@ -0,0 +1,169 @@
|
||||
mod support;
|
||||
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
|
||||
use irtt_client::{managed::*, ClientConfig, ClientEvent};
|
||||
use irtt_server::ServerConfig;
|
||||
use support::InTreeServer;
|
||||
use tokio::time::timeout;
|
||||
|
||||
// A real service and public events protect cadence/fairness without depending
|
||||
// on a scheduler, a send cursor, or a private event-publication hook.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn both_pacing_modes_serve_every_target_on_its_negotiated_grid() {
|
||||
timeout(Duration::from_secs(5), async {
|
||||
let server = InTreeServer::start(ServerConfig::default());
|
||||
for pacing in [ManagedPacing::Burst, ManagedPacing::Staggered] {
|
||||
let config = ManagedClientConfig {
|
||||
client: ClientConfig {
|
||||
duration: Some(Duration::from_millis(200)),
|
||||
interval: Duration::from_millis(20),
|
||||
probe_timeout: Duration::from_millis(100),
|
||||
open_timeouts: vec![Duration::from_millis(200)],
|
||||
..ClientConfig::default()
|
||||
},
|
||||
pacing,
|
||||
..ManagedClientConfig::default()
|
||||
};
|
||||
let targets =
|
||||
["first", "second"].map(|id| ManagedTargetConfig::new(id, server.addr.to_string()));
|
||||
let (task, handle) = ManagedClient::task(config, targets.into()).unwrap();
|
||||
let mut events = handle.subscribe().unwrap();
|
||||
let run = tokio::spawn(task);
|
||||
let mut sends: HashMap<TargetInstance, Vec<_>> = HashMap::new();
|
||||
while let Ok(event) = events.recv().await {
|
||||
if let ManagedEvent::Client {
|
||||
target,
|
||||
event:
|
||||
ClientEvent::EchoSent {
|
||||
scheduled_at,
|
||||
sent_at,
|
||||
..
|
||||
},
|
||||
} = event
|
||||
{
|
||||
let scheduled = scheduled_at.expect("managed probes expose their cadence");
|
||||
assert!(sent_at.mono >= scheduled);
|
||||
let previous = sends.entry(target).or_default();
|
||||
if let Some(last) = previous.last() {
|
||||
let elapsed: Duration = scheduled.duration_since(*last);
|
||||
assert!(!elapsed.is_zero());
|
||||
assert_eq!(elapsed.as_nanos() % Duration::from_millis(20).as_nanos(), 0);
|
||||
}
|
||||
previous.push(scheduled);
|
||||
}
|
||||
}
|
||||
let outcome = run.await.unwrap();
|
||||
assert_eq!(outcome.end_reason, ManagedEndReason::TargetsComplete);
|
||||
assert_eq!(outcome.successful_target_outcomes, 2);
|
||||
assert_eq!(sends.len(), 2);
|
||||
for target in outcome.recent_target_outcomes.iter() {
|
||||
assert!(sends[&target.target].len() >= 2);
|
||||
assert!(target.replies_received >= 2);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("managed pacing did not complete");
|
||||
}
|
||||
|
||||
// Updates, receipts, generations and cleanup are observable through the control
|
||||
// handle and event subscription; no task internals or artificial races are needed.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn target_updates_preserve_identical_sessions_and_retire_removed_generations() {
|
||||
timeout(Duration::from_secs(5), async {
|
||||
let server = InTreeServer::start(ServerConfig::default());
|
||||
let config = ManagedClientConfig {
|
||||
client: ClientConfig {
|
||||
duration: None,
|
||||
interval: Duration::from_millis(20),
|
||||
probe_timeout: Duration::from_millis(100),
|
||||
open_timeouts: vec![Duration::from_millis(200)],
|
||||
..ClientConfig::default()
|
||||
},
|
||||
completion: ManagedCompletionPolicy::ExplicitStop,
|
||||
..ManagedClientConfig::default()
|
||||
};
|
||||
let configured = ManagedTargetConfig::new("one", server.addr.to_string());
|
||||
let (task, handle) = ManagedClient::task(config, vec![]).unwrap();
|
||||
let mut events = handle.subscribe().unwrap();
|
||||
let run = tokio::spawn(task);
|
||||
let added = handle
|
||||
.update_targets(vec![configured.clone()])
|
||||
.unwrap()
|
||||
.await
|
||||
.unwrap();
|
||||
let first = added.status.targets[0].target.clone();
|
||||
loop {
|
||||
if matches!(events.recv().await.unwrap(), ManagedEvent::Client {
|
||||
target, event: ClientEvent::EchoReply { .. }
|
||||
} if target == first)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
let identical = handle
|
||||
.update_targets(vec![configured.clone()])
|
||||
.unwrap()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(identical.status.targets.len(), 1);
|
||||
assert_eq!(identical.status.targets[0].target, first);
|
||||
loop {
|
||||
match events.recv().await.unwrap() {
|
||||
ManagedEvent::Client {
|
||||
target,
|
||||
event: ClientEvent::SessionStarted { .. },
|
||||
} if target == first => panic!("identical update reopened the session"),
|
||||
ManagedEvent::Client {
|
||||
target,
|
||||
event: ClientEvent::EchoReply { .. },
|
||||
} if target == first => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
handle.update_targets(vec![]).unwrap().await.unwrap();
|
||||
loop {
|
||||
if let ManagedEvent::TargetFinished { outcome } = events.recv().await.unwrap() {
|
||||
assert_eq!(outcome.target, first);
|
||||
assert_eq!(outcome.end_reason, ManagedTargetEndReason::Removed);
|
||||
assert_eq!(outcome.cleanup_failure, None);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let readded = handle
|
||||
.update_targets(vec![configured])
|
||||
.unwrap()
|
||||
.await
|
||||
.unwrap();
|
||||
let second = readded
|
||||
.status
|
||||
.targets
|
||||
.iter()
|
||||
.find(|target| target.desired)
|
||||
.unwrap()
|
||||
.target
|
||||
.clone();
|
||||
assert!(second.generation > first.generation);
|
||||
loop {
|
||||
if matches!(events.recv().await.unwrap(), ManagedEvent::Client {
|
||||
target, event: ClientEvent::EchoReply { .. }
|
||||
} if target == second)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
let stop = handle.stop();
|
||||
let outcome = run.await.unwrap();
|
||||
stop.await;
|
||||
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
|
||||
assert_eq!(outcome.total_target_outcomes, 2);
|
||||
assert!(outcome.recent_target_outcomes.iter().any(|target| {
|
||||
target.target == second
|
||||
&& target.end_reason == ManagedTargetEndReason::Stopped
|
||||
&& target.cleanup_failure.is_none()
|
||||
}));
|
||||
})
|
||||
.await
|
||||
.expect("managed target updates did not complete");
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
use std::{
|
||||
future::Future,
|
||||
net::{SocketAddr, UdpSocket},
|
||||
pin::Pin,
|
||||
sync::{Arc, Mutex},
|
||||
task::{Context, Poll, Waker},
|
||||
thread::{self, JoinHandle},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use irtt_proto::{
|
||||
decode_request, encode_echo_reply, encode_open_reply, flags, verify_packet_hmac,
|
||||
DecodedRequestKind, EchoReply, OpenReply, Params, ReceivedStats, StampAt, TimestampFields,
|
||||
};
|
||||
use tokio::runtime::{Builder, Runtime};
|
||||
|
||||
use irtt_client::{managed::*, ClientConfig};
|
||||
use irtt_client::{ClientAuthConfig, RunMode};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
const TOKEN: u64 = 0x1234_5678_90ab_cdef;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ServerBehavior {
|
||||
Echo,
|
||||
NoTest,
|
||||
PeerClose,
|
||||
DelayedEcho(Duration),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum PacketKind {
|
||||
Open,
|
||||
Probe,
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PacketRecord {
|
||||
kind: PacketKind,
|
||||
at: Instant,
|
||||
}
|
||||
|
||||
struct TestServer {
|
||||
addr: SocketAddr,
|
||||
records: Arc<Mutex<Vec<PacketRecord>>>,
|
||||
thread: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
fn finish(self) -> Vec<PacketRecord> {
|
||||
self.thread.join().unwrap();
|
||||
self.records.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Admits a request the way a compliant server does: structural decode first,
|
||||
/// then HMAC presence policy, then authentication. Returns `None` for anything
|
||||
/// a server would silently discard.
|
||||
fn decode_server_request<'a>(
|
||||
packet: &'a [u8],
|
||||
key: Option<&[u8]>,
|
||||
) -> Option<DecodedRequestKind<'a>> {
|
||||
let request = decode_request(packet).ok()?;
|
||||
if request.hmac_present != key.is_some() {
|
||||
return None;
|
||||
}
|
||||
if let Some(key) = key {
|
||||
verify_packet_hmac(key, packet).ok()?;
|
||||
}
|
||||
Some(request.kind)
|
||||
}
|
||||
|
||||
fn decode_open_params(packet: &[u8], key: Option<&[u8]>) -> Params {
|
||||
match decode_server_request(packet, key) {
|
||||
Some(DecodedRequestKind::Open { params, .. }) => Params::decode(params).unwrap(),
|
||||
other => panic!("expected an authenticated open request, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn start_server(behavior: ServerBehavior, key: Option<Vec<u8>>) -> TestServer {
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
let addr = socket.local_addr().unwrap();
|
||||
let records = Arc::new(Mutex::new(Vec::new()));
|
||||
let thread_records = Arc::clone(&records);
|
||||
let thread = thread::spawn(move || {
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let (open_len, peer) = socket.recv_from(&mut buffer).unwrap();
|
||||
thread_records.lock().unwrap().push(PacketRecord {
|
||||
kind: PacketKind::Open,
|
||||
at: Instant::now(),
|
||||
});
|
||||
let negotiated = decode_open_params(&buffer[..open_len], key.as_deref());
|
||||
let no_test = matches!(behavior, ServerBehavior::NoTest);
|
||||
let open_reply = encode_open_reply(
|
||||
&OpenReply {
|
||||
flags: flags::FLAG_OPEN
|
||||
| flags::FLAG_REPLY
|
||||
| if no_test { flags::FLAG_CLOSE } else { 0 },
|
||||
token: if no_test { 0 } else { TOKEN },
|
||||
params: negotiated.clone(),
|
||||
},
|
||||
key.as_deref(),
|
||||
)
|
||||
.unwrap();
|
||||
socket.send_to(&open_reply, peer).unwrap();
|
||||
if no_test {
|
||||
return;
|
||||
}
|
||||
|
||||
loop {
|
||||
let Ok((len, packet_peer)) = socket.recv_from(&mut buffer) else {
|
||||
return;
|
||||
};
|
||||
let packet = &buffer[..len];
|
||||
match decode_server_request(packet, key.as_deref()) {
|
||||
Some(DecodedRequestKind::Echo { sequence, .. }) => {
|
||||
thread_records.lock().unwrap().push(PacketRecord {
|
||||
kind: PacketKind::Probe,
|
||||
at: Instant::now(),
|
||||
});
|
||||
if let ServerBehavior::DelayedEcho(delay) = behavior {
|
||||
thread::sleep(delay);
|
||||
}
|
||||
let peer_close = matches!(behavior, ServerBehavior::PeerClose);
|
||||
let reply = encode_echo_reply(
|
||||
&EchoReply {
|
||||
flags: flags::FLAG_REPLY
|
||||
| if peer_close { flags::FLAG_CLOSE } else { 0 },
|
||||
token: TOKEN,
|
||||
sequence,
|
||||
recv_count: None,
|
||||
recv_window: None,
|
||||
timestamps: TimestampFields::default(),
|
||||
payload: Vec::new(),
|
||||
},
|
||||
&negotiated,
|
||||
key.as_deref(),
|
||||
)
|
||||
.unwrap();
|
||||
socket.send_to(&reply, packet_peer).unwrap();
|
||||
if peer_close {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Some(DecodedRequestKind::Close { .. }) => {
|
||||
thread_records.lock().unwrap().push(PacketRecord {
|
||||
kind: PacketKind::Close,
|
||||
at: Instant::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
Some(DecodedRequestKind::Open { .. }) | None => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
TestServer {
|
||||
addr,
|
||||
records,
|
||||
thread,
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime() -> Runtime {
|
||||
Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn config(pacing: ManagedPacing) -> ManagedClientConfig {
|
||||
ManagedClientConfig {
|
||||
client: ClientConfig {
|
||||
duration: Some(Duration::from_millis(130)),
|
||||
interval: Duration::from_millis(60),
|
||||
probe_timeout: Duration::from_millis(35),
|
||||
open_timeouts: vec![Duration::from_millis(200)],
|
||||
received_stats: ReceivedStats::None,
|
||||
stamp_at: StampAt::None,
|
||||
..ClientConfig::default()
|
||||
},
|
||||
pacing,
|
||||
final_drain: Duration::from_millis(5),
|
||||
..ManagedClientConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn target(id: &str, addr: SocketAddr) -> ManagedTargetConfig {
|
||||
ManagedTargetConfig::new(id, addr.to_string())
|
||||
}
|
||||
|
||||
fn poll_once<F: Future>(future: Pin<&mut F>) -> Poll<F::Output> {
|
||||
Future::poll(future, &mut Context::from_waker(Waker::noop()))
|
||||
}
|
||||
|
||||
fn probes(records: &[PacketRecord]) -> Vec<Instant> {
|
||||
records
|
||||
.iter()
|
||||
.filter_map(|record| matches!(record.kind, PacketKind::Probe).then_some(record.at))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn has_close(records: &[PacketRecord]) -> bool {
|
||||
records
|
||||
.iter()
|
||||
.any(|record| matches!(record.kind, PacketKind::Close))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construction_is_runtime_and_io_free() {
|
||||
let (task, handle) = ManagedClient::task(
|
||||
config(ManagedPacing::Staggered),
|
||||
vec![ManagedTargetConfig::new("dns", "no-such-host.invalid")],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(handle.status().lifecycle, ManagedLifecycle::NotStarted);
|
||||
drop(task);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiescent_rejects_empty_initial_targets() {
|
||||
assert!(matches!(
|
||||
ManagedClient::task(config(ManagedPacing::Staggered), vec![]),
|
||||
Err(ManagedConfigError::EmptyInitialTargets)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_empty_waits_for_stop() {
|
||||
let mut config = config(ManagedPacing::Staggered);
|
||||
config.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||
let (task, handle) = ManagedClient::task(config, vec![]).unwrap();
|
||||
let outcome = runtime().block_on(async move {
|
||||
let mut task = Box::pin(task);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(20), task.as_mut())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Running);
|
||||
let receipt = handle.stop();
|
||||
let outcome = task.await;
|
||||
receipt.await;
|
||||
outcome
|
||||
});
|
||||
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);
|
||||
managed.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||
managed.event_capacity = usize::MAX;
|
||||
assert!(matches!(
|
||||
ManagedClient::task(managed, vec![]),
|
||||
Err(ManagedConfigError::EventCapacityTooLarge {
|
||||
configured: usize::MAX,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_command_capacity_is_rejected_without_panic() {
|
||||
let mut managed = config(ManagedPacing::Staggered);
|
||||
managed.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||
managed.command_capacity = usize::MAX;
|
||||
assert!(matches!(
|
||||
ManagedClient::task(managed, vec![]),
|
||||
Err(ManagedConfigError::CommandCapacityTooLarge {
|
||||
configured: usize::MAX,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_poll_without_runtime_fails_durably() {
|
||||
let (task, handle) = ManagedClient::task(
|
||||
config(ManagedPacing::Staggered),
|
||||
vec![target("one", "127.0.0.1:9".parse().unwrap())],
|
||||
)
|
||||
.unwrap();
|
||||
let mut task = Box::pin(task);
|
||||
let Poll::Ready(outcome) = poll_once(task.as_mut()) else {
|
||||
panic!("task did not fail on its first poll")
|
||||
};
|
||||
assert_eq!(
|
||||
outcome.end_reason,
|
||||
ManagedEndReason::DriverFailed(ManagedDriverFailure::NoTokioRuntime)
|
||||
);
|
||||
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_poll_stop() {
|
||||
let (task, handle) = ManagedClient::task(
|
||||
config(ManagedPacing::Staggered),
|
||||
vec![ManagedTargetConfig::new("one", "no-such-host.invalid")],
|
||||
)
|
||||
.unwrap();
|
||||
let mut receipt = Box::pin(handle.stop());
|
||||
let mut task = Box::pin(task);
|
||||
let Poll::Ready(outcome) = poll_once(task.as_mut()) else {
|
||||
panic!("pre-stopped task did not complete")
|
||||
};
|
||||
assert!(poll_once(receipt.as_mut()).is_ready());
|
||||
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_immediate_replies_use_retained_drain_deadline() {
|
||||
let server = start_server(ServerBehavior::Echo, None);
|
||||
let mut managed = config(ManagedPacing::Staggered);
|
||||
managed.client.probe_timeout = Duration::from_secs(3);
|
||||
managed.final_drain = Duration::from_millis(30);
|
||||
let (task, _) = ManagedClient::task(managed, vec![target("one", server.addr)]).unwrap();
|
||||
let started_at = Instant::now();
|
||||
let outcome = runtime().block_on(async {
|
||||
tokio::time::timeout(Duration::from_secs(1), task)
|
||||
.await
|
||||
.expect("replied probes must not hold the drain until their obsolete timeout")
|
||||
});
|
||||
assert!(started_at.elapsed() < Duration::from_secs(1));
|
||||
let records = server.finish();
|
||||
assert_eq!(outcome.end_reason, ManagedEndReason::TargetsComplete);
|
||||
assert!(matches!(
|
||||
outcome.recent_target_outcomes[0].end_reason,
|
||||
ManagedTargetEndReason::TestComplete
|
||||
));
|
||||
assert!(!probes(&records).is_empty() && has_close(&records));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_test_target_completion() {
|
||||
let server = start_server(ServerBehavior::NoTest, None);
|
||||
let mut config = config(ManagedPacing::Staggered);
|
||||
config.client.run_mode = RunMode::NoTest;
|
||||
let (task, _) = ManagedClient::task(config, vec![target("no-test", server.addr)]).unwrap();
|
||||
let outcome = runtime().block_on(task);
|
||||
let records = server.finish();
|
||||
assert!(matches!(
|
||||
outcome.recent_target_outcomes[0].end_reason,
|
||||
ManagedTargetEndReason::NoTestComplete
|
||||
));
|
||||
assert!(probes(&records).is_empty() && !has_close(&records));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_peer_close_outcome() {
|
||||
let key = b"managed-peer-close".to_vec();
|
||||
let server = start_server(ServerBehavior::PeerClose, Some(key.clone()));
|
||||
let mut configured = target("peer", server.addr);
|
||||
configured.auth = Some(ClientAuthConfig {
|
||||
hmac_key: Some(key),
|
||||
});
|
||||
let (task, _) =
|
||||
ManagedClient::task(config(ManagedPacing::Staggered), vec![configured]).unwrap();
|
||||
let outcome = runtime().block_on(task);
|
||||
let records = server.finish();
|
||||
assert_eq!(outcome.peer_closed_target_outcomes, 1);
|
||||
assert!(matches!(
|
||||
outcome.recent_target_outcomes[0].end_reason,
|
||||
ManagedTargetEndReason::PeerClosed
|
||||
));
|
||||
assert!(!has_close(&records));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_multi_target_completion() {
|
||||
let first = start_server(ServerBehavior::Echo, None);
|
||||
let second = start_server(ServerBehavior::Echo, None);
|
||||
let (task, _) = ManagedClient::task(
|
||||
config(ManagedPacing::Burst),
|
||||
vec![target("first", first.addr), target("second", second.addr)],
|
||||
)
|
||||
.unwrap();
|
||||
let outcome = runtime().block_on(task);
|
||||
assert_eq!(outcome.total_target_outcomes, 2);
|
||||
assert_eq!(outcome.successful_target_outcomes, 2);
|
||||
assert!(!probes(&first.finish()).is_empty());
|
||||
assert!(!probes(&second.finish()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_failure_isolation() {
|
||||
let good = start_server(ServerBehavior::Echo, None);
|
||||
let unused = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let bad_addr = unused.local_addr().unwrap();
|
||||
drop(unused);
|
||||
let (task, _) = ManagedClient::task(
|
||||
config(ManagedPacing::Staggered),
|
||||
vec![target("good", good.addr), target("bad", bad_addr)],
|
||||
)
|
||||
.unwrap();
|
||||
let outcome = runtime().block_on(task);
|
||||
assert_eq!(outcome.total_target_outcomes, 2);
|
||||
assert_eq!(outcome.successful_target_outcomes, 1);
|
||||
assert_eq!(outcome.failed_target_outcomes, 1);
|
||||
assert!(!probes(&good.finish()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_limit_failure_drains_reply_and_closes_session() {
|
||||
let key = b"managed-send-failure-cleanup".to_vec();
|
||||
let server = start_server(
|
||||
ServerBehavior::DelayedEcho(Duration::from_millis(80)),
|
||||
Some(key.clone()),
|
||||
);
|
||||
let mut managed = config(ManagedPacing::Staggered);
|
||||
managed.client.duration = Some(Duration::from_millis(250));
|
||||
managed.client.interval = Duration::from_millis(20);
|
||||
managed.client.probe_timeout = Duration::from_secs(2);
|
||||
managed.client.max_pending_probes = 1;
|
||||
managed.final_drain = Duration::from_millis(50);
|
||||
let mut configured = target("one", server.addr);
|
||||
configured.auth = Some(ClientAuthConfig {
|
||||
hmac_key: Some(key),
|
||||
});
|
||||
let (task, _) = ManagedClient::task(managed, vec![configured]).unwrap();
|
||||
let outcome = runtime().block_on(async {
|
||||
tokio::time::timeout(Duration::from_secs(1), task)
|
||||
.await
|
||||
.expect("send failure cleanup must shorten after draining the committed reply")
|
||||
});
|
||||
let records = server.finish();
|
||||
let target = &outcome.recent_target_outcomes[0];
|
||||
assert_eq!(outcome.failed_target_outcomes, 1);
|
||||
assert!(matches!(
|
||||
target.end_reason,
|
||||
ManagedTargetEndReason::Failed(ManagedTargetFailure {
|
||||
phase: ManagedTargetFailurePhase::Sending,
|
||||
kind: ManagedTargetFailureKind::ResourceExhausted,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert_eq!(target.cleanup_failure, None);
|
||||
assert_eq!(target.packets_sent, 1);
|
||||
assert_eq!(target.replies_received, 1);
|
||||
assert_eq!(probes(&records).len(), 1);
|
||||
assert!(has_close(&records));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_loss_independence() {
|
||||
let server = start_server(ServerBehavior::Echo, None);
|
||||
let mut config = config(ManagedPacing::Staggered);
|
||||
config.event_capacity = 1;
|
||||
let (task, handle) = ManagedClient::task(config, vec![target("one", server.addr)]).unwrap();
|
||||
let mut events = handle.subscribe().unwrap();
|
||||
let outcome = runtime().block_on(task);
|
||||
assert!(matches!(
|
||||
events.try_recv(),
|
||||
Err(broadcast::error::TryRecvError::Lagged(_))
|
||||
));
|
||||
assert_eq!(outcome.successful_target_outcomes, 1);
|
||||
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Completed);
|
||||
server.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_all_handles_does_not_stop() {
|
||||
let server = start_server(ServerBehavior::Echo, None);
|
||||
let (task, handle) = ManagedClient::task(
|
||||
config(ManagedPacing::Staggered),
|
||||
vec![target("one", server.addr)],
|
||||
)
|
||||
.unwrap();
|
||||
drop(handle);
|
||||
let outcome = runtime().block_on(task);
|
||||
assert_eq!(outcome.end_reason, ManagedEndReason::TargetsComplete);
|
||||
server.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_drop_abandonment() {
|
||||
let (task, handle) = ManagedClient::task(
|
||||
config(ManagedPacing::Staggered),
|
||||
vec![target("one", "127.0.0.1:9".parse().unwrap())],
|
||||
)
|
||||
.unwrap();
|
||||
let mut events = handle.subscribe().unwrap();
|
||||
drop(task);
|
||||
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Abandoned);
|
||||
assert_eq!(handle.status().total_target_outcomes, 0);
|
||||
assert!(matches!(events.try_recv(), Ok(ManagedEvent::Abandoned)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_subscription_is_closed() {
|
||||
let (task, handle) = ManagedClient::task(
|
||||
config(ManagedPacing::Staggered),
|
||||
vec![target("one", "127.0.0.1:9".parse().unwrap())],
|
||||
)
|
||||
.unwrap();
|
||||
let mut task = Box::pin(task);
|
||||
assert!(poll_once(task.as_mut()).is_ready());
|
||||
assert!(matches!(
|
||||
handle.subscribe(),
|
||||
Err(ManagedSubscribeError::Closed)
|
||||
));
|
||||
}
|
||||
@@ -1,16 +1,5 @@
|
||||
//! Clock sampling for echo timestamps.
|
||||
//!
|
||||
//! An echo reply reports when the server received the request and when it sent
|
||||
//! the answer, so the core needs a clock. It takes one through this private
|
||||
//! seam rather than reading the system clock inline, so that timestamp behavior
|
||||
//! is deterministically testable without a runtime, sleeps or timing
|
||||
//! tolerances.
|
||||
//!
|
||||
//! This is the clock counterpart of [`TokenSource`](crate::token::TokenSource)
|
||||
//! and nothing more. It is **not** a runtime abstraction, not a transport
|
||||
//! boundary and not a pluggable product API: it is crate-private and the public
|
||||
//! constructor still takes only a [`ServerConfig`](crate::ServerConfig).
|
||||
//!
|
||||
//! The public [`ServerCore::handle_datagram`](crate::ServerCore::handle_datagram)
|
||||
//! entry point takes no timestamp: the core samples every instant it reports.
|
||||
//! The Tokio runtime uses a crate-private entry point that may additionally hand
|
||||
@@ -21,10 +10,7 @@
|
||||
//! one that models transport metadata. A [`ClockSample`] remains one paired
|
||||
//! userspace instant and never carries a kernel reading.
|
||||
|
||||
use std::{
|
||||
fmt,
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// One instant, read from both clock domains together.
|
||||
///
|
||||
@@ -77,19 +63,6 @@ impl ClockSample {
|
||||
}
|
||||
}
|
||||
|
||||
/// A source of clock samples.
|
||||
///
|
||||
/// Deliberately private, like [`TokenSource`](crate::token::TokenSource): it
|
||||
/// exists so tests can script the receive and send instants of a reply, not as
|
||||
/// an extension point. The production implementation is [`SystemClock`].
|
||||
///
|
||||
/// Sampling is infallible. A clock that could refuse would make an admitted
|
||||
/// echo fail for a reason that is neither the peer's fault nor recoverable, so
|
||||
/// the production source saturates rather than reporting an error.
|
||||
pub(crate) trait ClockSource: fmt::Debug + Send {
|
||||
fn sample(&mut self) -> ClockSample;
|
||||
}
|
||||
|
||||
/// Reads the wall clock from [`SystemTime`] and the monotonic clock from an
|
||||
/// [`Instant`] captured when the source was created.
|
||||
///
|
||||
@@ -115,8 +88,8 @@ impl Default for SystemClock {
|
||||
}
|
||||
}
|
||||
|
||||
impl ClockSource for SystemClock {
|
||||
fn sample(&mut self) -> ClockSample {
|
||||
impl SystemClock {
|
||||
pub(crate) fn sample(&mut self) -> ClockSample {
|
||||
ClockSample {
|
||||
wall_ns: wall_ns(),
|
||||
mono_ns: saturating_ns(self.origin.elapsed()),
|
||||
@@ -136,7 +109,7 @@ fn wall_ns() -> i64 {
|
||||
/// does not fit the wire's signed-nanosecond field.
|
||||
///
|
||||
/// This is the conversion for an instant the server did **not** read from its
|
||||
/// own [`ClockSource`] — today, a kernel receive timestamp the transport
|
||||
/// own clock — today, a kernel receive timestamp the transport
|
||||
/// observed. It reports unrepresentability rather than saturating, because a
|
||||
/// clamped instant is indistinguishable from a real one at the boundary and
|
||||
/// would then be compared against a genuine sample as though it were plausible.
|
||||
@@ -183,88 +156,3 @@ fn mean_ns(a: i64, b: i64) -> i64 {
|
||||
i64::MAX
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Constructed values only: the production clock's real readings are not
|
||||
/// assertable without a timing tolerance, and the behavioral echo tests use
|
||||
/// a scripted source instead.
|
||||
#[test]
|
||||
fn a_midpoint_is_the_per_domain_mean_and_cannot_overflow() {
|
||||
let receive = ClockSample {
|
||||
wall_ns: 1_000,
|
||||
mono_ns: 10_000,
|
||||
};
|
||||
let send = ClockSample {
|
||||
wall_ns: 1_200,
|
||||
mono_ns: 10_300,
|
||||
};
|
||||
assert_eq!(
|
||||
receive.midpoint(send),
|
||||
ClockSample {
|
||||
wall_ns: 1_100,
|
||||
mono_ns: 10_150,
|
||||
}
|
||||
);
|
||||
|
||||
// The sum overflows `i64`; the mean does not.
|
||||
let high = ClockSample {
|
||||
wall_ns: i64::MAX,
|
||||
mono_ns: i64::MAX,
|
||||
};
|
||||
assert_eq!(
|
||||
high.midpoint(high),
|
||||
ClockSample {
|
||||
wall_ns: i64::MAX,
|
||||
mono_ns: i64::MAX,
|
||||
}
|
||||
);
|
||||
let low = ClockSample {
|
||||
wall_ns: i64::MIN,
|
||||
mono_ns: i64::MIN,
|
||||
};
|
||||
assert_eq!(
|
||||
low.midpoint(low),
|
||||
ClockSample {
|
||||
wall_ns: i64::MIN,
|
||||
mono_ns: i64::MIN,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
high.midpoint(low),
|
||||
ClockSample {
|
||||
wall_ns: 0,
|
||||
mono_ns: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_duration_beyond_the_wire_field_saturates_rather_than_panicking() {
|
||||
assert_eq!(saturating_ns(Duration::from_nanos(1_500)), 1_500);
|
||||
assert_eq!(saturating_ns(Duration::MAX), i64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_observed_instant_converts_or_reports_that_it_cannot() {
|
||||
assert_eq!(
|
||||
wall_ns_of(UNIX_EPOCH + Duration::from_nanos(1_500)),
|
||||
Some(1_500)
|
||||
);
|
||||
assert_eq!(wall_ns_of(UNIX_EPOCH), Some(0));
|
||||
|
||||
// Before the epoch is negative, not a wrap and not a rejection.
|
||||
assert_eq!(
|
||||
wall_ns_of(UNIX_EPOCH - Duration::from_nanos(1_500)),
|
||||
Some(-1_500)
|
||||
);
|
||||
|
||||
// Beyond the field, in either direction, is `None` rather than a
|
||||
// clamped value that would then read as an ordinary instant.
|
||||
let beyond = Duration::from_nanos(u64::MAX) + Duration::from_secs(1);
|
||||
assert_eq!(wall_ns_of(UNIX_EPOCH + beyond), None);
|
||||
assert_eq!(wall_ns_of(UNIX_EPOCH - beyond), None);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use irtt_proto::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
clock::{saturating_ns, wall_ns_of, ClockSample, ClockSource, SystemClock},
|
||||
clock::{saturating_ns, wall_ns_of, ClockSample, SystemClock},
|
||||
config::ServerConfig,
|
||||
error::ServerError,
|
||||
fill::{echo_payload_len, negotiate_server_fill},
|
||||
@@ -27,8 +27,7 @@ use crate::{
|
||||
/// the bytes of one received datagram, and gets back the bytes to send to that
|
||||
/// endpoint, or nothing. Everything it does is a pure function of its state and
|
||||
/// that input, except for drawing session tokens and sampling the clock an echo
|
||||
/// reply's timestamps come from — both of which are private injected seams, so
|
||||
/// the whole engine stays deterministically testable.
|
||||
/// reply's timestamps come from — the clock is sampled directly and tokens have a private fault-injection seam.
|
||||
///
|
||||
/// The Tokio runtime wraps it directly:
|
||||
///
|
||||
@@ -88,7 +87,7 @@ pub struct ServerCore {
|
||||
config: ServerConfig,
|
||||
sessions: HashMap<u64, Session>,
|
||||
tokens: Box<dyn TokenSource>,
|
||||
clock: Box<dyn ClockSource>,
|
||||
clock: SystemClock,
|
||||
}
|
||||
|
||||
impl ServerCore {
|
||||
@@ -99,24 +98,13 @@ impl ServerCore {
|
||||
Self::with_token_source(config, Box::new(OsTokenSource))
|
||||
}
|
||||
|
||||
/// A core with a chosen token source and the production clock, which is
|
||||
/// what tests that care about session identity but not about timestamp
|
||||
/// values want.
|
||||
// Token fault injection covers random-source failures and allocation bounds.
|
||||
pub(crate) fn with_token_source(config: ServerConfig, tokens: Box<dyn TokenSource>) -> Self {
|
||||
Self::with_sources(config, tokens, Box::new(SystemClock::new()))
|
||||
}
|
||||
|
||||
/// A core with both nondeterministic sources chosen.
|
||||
pub(crate) fn with_sources(
|
||||
config: ServerConfig,
|
||||
tokens: Box<dyn TokenSource>,
|
||||
clock: Box<dyn ClockSource>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
sessions: HashMap::new(),
|
||||
tokens,
|
||||
clock,
|
||||
clock: SystemClock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ impl FillMode {
|
||||
// region as unspecified residual buffer content, which returned
|
||||
// other traffic's bytes, and a compatible server must not.
|
||||
Self::None => Vec::new(),
|
||||
Self::Random => random_payload(len, getrandom::fill),
|
||||
Self::Random => random_payload(len),
|
||||
Self::Pattern(pattern) => pattern.iter().copied().cycle().take(len).collect(),
|
||||
}
|
||||
}
|
||||
@@ -132,25 +132,17 @@ fn hex_digit(digit: u8) -> Option<u8> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `len` random bytes drawn with `draw`, or `len` zeroes if the draw failed.
|
||||
/// `len` random bytes from the operating system, or zeroes if the draw failed.
|
||||
///
|
||||
/// `draw` is a parameter rather than a direct call so the failure path is
|
||||
/// assertable without a production hook: the caller passes the operating
|
||||
/// system's random source and one unit test passes a scripted one. An empty
|
||||
/// region asks the source for nothing at all.
|
||||
///
|
||||
/// **A failed draw zero-fills and is not an error.** Payload bytes carry no
|
||||
/// protocol meaning, so a random source having a bad afternoon must not cost a
|
||||
/// session its reply, let alone take the server down: the reply is still
|
||||
/// structurally valid and interoperable. The buffer is rewritten rather than
|
||||
/// trusted, because a failed draw leaves its contents unspecified.
|
||||
/// Failure is not fatal: payload variation has no protocol meaning, so a
|
||||
/// random-source failure must not terminate a listener or drop a session.
|
||||
///
|
||||
/// The bytes exist to vary the payload, and nothing here is a security claim.
|
||||
/// `getrandom` is used because the crate already depends on it; session tokens,
|
||||
/// which *are* security state, keep their own separate source.
|
||||
fn random_payload<E>(len: usize, draw: impl FnOnce(&mut [u8]) -> Result<(), E>) -> Vec<u8> {
|
||||
fn random_payload(len: usize) -> Vec<u8> {
|
||||
let mut payload = vec![0; len];
|
||||
if len > 0 && draw(&mut payload).is_err() {
|
||||
if len > 0 && getrandom::fill(&mut payload).is_err() {
|
||||
payload.fill(0);
|
||||
}
|
||||
payload
|
||||
@@ -223,21 +215,3 @@ pub(crate) fn echo_payload_len(hmac: bool, params: &Params) -> usize {
|
||||
let header_len = PacketLayout::echo(hmac, params).header_len();
|
||||
echo_packet_len(hmac, params).map_or(0, |len| len.saturating_sub(header_len))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_failed_random_draw_yields_zeroes() {
|
||||
// The real source cannot be made to fail without a production hook, and
|
||||
// the fallback itself is the policy under test: a failed draw must
|
||||
// produce a full-length payload of zeroes rather than an error or the
|
||||
// buffer's unspecified contents.
|
||||
let scribble = |buffer: &mut [u8]| {
|
||||
buffer.fill(0xab);
|
||||
Err(())
|
||||
};
|
||||
assert_eq!(random_payload(6, scribble), vec![0; 6]);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,7 @@
|
||||
//! admission, authentication policy, open negotiation, the session table, echo
|
||||
//! receive state, rate and lifetime policy, resource decisions and reply
|
||||
//! construction. It performs no I/O; the clock its timestamps and deadlines
|
||||
//! come from is a private injected seam, not a runtime abstraction, so the
|
||||
//! engine stays testable. Each reply it produces is an [`OutboundDatagram`]:
|
||||
//! come from is sampled by the core. Each reply is an [`OutboundDatagram`]:
|
||||
//! the packet and the raw traffic class it must be sent with. [`Server`] owns
|
||||
//! one Tokio UDP listener and one core, and provides sequential receive, reply,
|
||||
//! scheduled expiry maintenance and caller-controlled shutdown. [`ServerSet`]
|
||||
|
||||
@@ -403,203 +403,7 @@ pub enum ServerRuntimeError {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{net::Ipv4Addr, time::Duration};
|
||||
|
||||
use irtt_proto::{
|
||||
decode_echo_reply, decode_open_reply, encode_request, Clock, Params, ReceivedStats,
|
||||
RequestToEncode, StampAt,
|
||||
};
|
||||
use tokio::{net::UdpSocket, sync::oneshot, time::timeout};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn bind_reports_local_address_and_shutdown_stops_run() {
|
||||
let requested = SocketAddr::from((Ipv4Addr::LOCALHOST, 0));
|
||||
let mut server = Server::bind(requested, ServerConfig::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let bound = server.local_addr().unwrap();
|
||||
assert_eq!(bound.ip(), requested.ip());
|
||||
assert_ne!(bound.port(), 0);
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
shutdown_tx.send(()).unwrap();
|
||||
server
|
||||
.run(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Construction is where a wildcard listener's promise is made or refused.
|
||||
///
|
||||
/// Both outcomes are asserted from the same code, because which one a target
|
||||
/// gets is exactly the thing under test: a target with a source-selection
|
||||
/// path configures it and serves; one without refuses the bind rather than
|
||||
/// starting a listener whose replies may come from an address no client
|
||||
/// contacted. An explicit bind is unaffected either way.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn from_socket_settles_wildcard_source_selection_before_serving_anything() {
|
||||
let explicit = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
assert!(Server::from_socket(explicit, ServerConfig::default()).is_ok());
|
||||
|
||||
let wildcard = UdpSocket::bind("0.0.0.0:0").await.unwrap();
|
||||
let wildcard = Server::from_socket(wildcard, ServerConfig::default());
|
||||
if socket_io::SUPPORTED {
|
||||
let server = wildcard.expect("a supported target configures source selection");
|
||||
assert!(server.select_reply_source);
|
||||
} else {
|
||||
assert!(matches!(
|
||||
wildcard,
|
||||
Err(ServerRuntimeError::WildcardSourceSelectionUnsupported { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn ipv4_open_echo_close_crosses_a_real_udp_socket() {
|
||||
exercise_udp_path(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn ipv6_open_echo_close_when_loopback_is_available() {
|
||||
let addr = "[::1]:0".parse().unwrap();
|
||||
let server = match Server::bind(addr, unthrottled()).await {
|
||||
Ok(server) => server,
|
||||
Err(ServerRuntimeError::Bind { source, .. })
|
||||
if matches!(
|
||||
source.kind(),
|
||||
io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported
|
||||
) =>
|
||||
{
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("unexpected IPv6 loopback bind failure: {error}"),
|
||||
};
|
||||
exercise_bound_server(server).await;
|
||||
}
|
||||
|
||||
async fn exercise_udp_path(addr: SocketAddr) {
|
||||
let server = Server::bind(addr, unthrottled()).await.unwrap();
|
||||
exercise_bound_server(server).await;
|
||||
}
|
||||
|
||||
async fn exercise_bound_server(mut server: Server) {
|
||||
let server_addr = server.local_addr().unwrap();
|
||||
let client_bind = match server_addr {
|
||||
SocketAddr::V4(_) => "127.0.0.1:0",
|
||||
SocketAddr::V6(_) => "[::1]:0",
|
||||
};
|
||||
let client = UdpSocket::bind(client_bind).await.unwrap();
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
let server_task = tokio::spawn(async move {
|
||||
server
|
||||
.run(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
});
|
||||
|
||||
let requested = Params {
|
||||
protocol_version: 1,
|
||||
duration_ns: 1_000_000_000,
|
||||
interval_ns: 100_000_000,
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
..Params::default()
|
||||
};
|
||||
let open = encode_request(
|
||||
RequestToEncode::Open {
|
||||
params: &requested,
|
||||
no_test: false,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
client.send_to(&open, server_addr).await.unwrap();
|
||||
|
||||
let mut buffer = vec![0; RECEIVE_BUFFER_LEN];
|
||||
let (len, reply_source) = timeout(Duration::from_secs(1), client.recv_from(&mut buffer))
|
||||
.await
|
||||
.expect("OPEN reply timeout")
|
||||
.unwrap();
|
||||
assert_eq!(reply_source, server_addr);
|
||||
let open_reply = decode_open_reply(&buffer[..len], None).unwrap();
|
||||
assert_ne!(open_reply.token, 0);
|
||||
|
||||
let echo = encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token: open_reply.token,
|
||||
sequence: 0,
|
||||
params: &open_reply.params,
|
||||
payload: &[],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
client.send_to(&echo, server_addr).await.unwrap();
|
||||
let (len, reply_source) = timeout(Duration::from_secs(1), client.recv_from(&mut buffer))
|
||||
.await
|
||||
.expect("ECHO reply timeout")
|
||||
.unwrap();
|
||||
assert_eq!(reply_source, server_addr);
|
||||
let echo_reply = decode_echo_reply(&buffer[..len], &open_reply.params, None).unwrap();
|
||||
assert_eq!(echo_reply.token, open_reply.token);
|
||||
assert_eq!(echo_reply.sequence, 0);
|
||||
assert_eq!(echo_reply.recv_count, Some(1));
|
||||
assert_eq!(echo_reply.recv_window, Some(1));
|
||||
|
||||
let close = encode_request(
|
||||
RequestToEncode::Close {
|
||||
token: open_reply.token,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
client.send_to(&close, server_addr).await.unwrap();
|
||||
client.send_to(&echo, server_addr).await.unwrap();
|
||||
assert!(
|
||||
timeout(Duration::from_millis(100), client.recv_from(&mut buffer))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
shutdown_tx.send(()).unwrap();
|
||||
server_task.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
fn unthrottled() -> ServerConfig {
|
||||
ServerConfig::default().with_min_send_interval(Duration::ZERO)
|
||||
}
|
||||
|
||||
/// Which bound addresses owe their replies a selected source.
|
||||
///
|
||||
/// The IPv4-mapped unspecified address is the one that is not obvious, and
|
||||
/// it is not hypothetical: Linux accepts `[::ffff:0.0.0.0]` as an IPv4
|
||||
/// wildcard bind and reports it back unchanged, so missing it would leave a
|
||||
/// working wildcard listener on the plain send path. A mapped address
|
||||
/// naming a real host address is explicit and must stay there.
|
||||
#[test]
|
||||
fn a_bound_address_naming_no_particular_local_address_is_a_wildcard() {
|
||||
for (addr, wildcard) in [
|
||||
("0.0.0.0:2112", true),
|
||||
("[::]:2112", true),
|
||||
("[::ffff:0.0.0.0]:2112", true),
|
||||
("127.0.0.1:2112", false),
|
||||
("192.0.2.10:2112", false),
|
||||
("[::1]:2112", false),
|
||||
("[::ffff:127.0.0.1]:2112", false),
|
||||
] {
|
||||
assert_eq!(
|
||||
is_wildcard(addr.parse().unwrap()),
|
||||
wildcard,
|
||||
"{addr} classified wrongly"
|
||||
);
|
||||
}
|
||||
}
|
||||
use super::may_send_unappliable;
|
||||
|
||||
/// The rule that decides what an unappliable traffic class means, which no
|
||||
/// normal interface can reach: it needs a host that refuses the socket
|
||||
|
||||
@@ -393,14 +393,6 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{token::TokenSource, ServerCore, ServerError};
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_set_serving_nothing_is_refused() {
|
||||
assert!(matches!(
|
||||
ServerSet::bind([], ServerConfig::default()).await,
|
||||
Err(ServerSetError::NoListeners)
|
||||
));
|
||||
}
|
||||
|
||||
/// A token source that draws one value successfully and then fails every
|
||||
/// later draw.
|
||||
///
|
||||
|
||||
@@ -256,189 +256,3 @@ fn unsupported() -> io::Error {
|
||||
"reply source-address selection is unsupported on this target",
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Sends `payload` to a fresh explicit-address listener and returns what the
|
||||
/// transport layer decided about it, received into a `capacity`-byte buffer.
|
||||
///
|
||||
/// The buffer is deliberately tiny: the full-buffer rule is written against
|
||||
/// the caller's capacity, so it is provable without a 65,536-byte datagram.
|
||||
async fn receive_one(
|
||||
capacity: usize,
|
||||
payload: &[u8],
|
||||
) -> (io::Result<Option<ReceivedDatagram>>, Vec<u8>) {
|
||||
let listener = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
|
||||
let sender = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
|
||||
sender
|
||||
.send_to(payload, listener.local_addr().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut buffer = vec![0; capacity];
|
||||
let received = receive(&listener, &mut buffer, false).await;
|
||||
(received, buffer)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_datagram_shorter_than_the_buffer_reaches_the_core_intact() {
|
||||
let payload = b"irtt";
|
||||
let (received, buffer) = receive_one(16, payload).await;
|
||||
|
||||
let received = received
|
||||
.unwrap()
|
||||
.expect("a datagram that cannot have been truncated is accepted");
|
||||
assert_eq!(received.len, payload.len());
|
||||
assert_eq!(received.peer.ip(), Ipv4Addr::LOCALHOST);
|
||||
assert_eq!(received.reply_source, ReplySource::Bound);
|
||||
assert_eq!(&buffer[..received.len], payload);
|
||||
}
|
||||
|
||||
/// An exactly-full receive is ambiguous, not obviously fine: `recv_from`
|
||||
/// reports no truncation flag, so this datagram is indistinguishable from a
|
||||
/// larger one cut down to the same capacity. Both are dropped.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_datagram_filling_the_buffer_is_dropped_as_possibly_truncated() {
|
||||
let (received, _) = receive_one(4, b"irtt").await;
|
||||
assert!(received.unwrap().is_none());
|
||||
}
|
||||
|
||||
/// What matters is that an oversized datagram never reaches the core, not
|
||||
/// how the platform says so: Unix truncates it to the buffer, which the
|
||||
/// full-buffer rule then drops, while Windows fails the receive outright
|
||||
/// with `WSAEMSGSIZE`. Asserting the shared guarantee keeps this test true
|
||||
/// on both.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_datagram_larger_than_the_buffer_never_reaches_the_core() {
|
||||
let (received, _) = receive_one(4, b"irtt-rs").await;
|
||||
assert!(!matches!(received, Ok(Some(_))));
|
||||
}
|
||||
|
||||
/// A listener that never asked for kernel timestamps receives normally and
|
||||
/// reports none — the field is optional in the plainest sense, and its
|
||||
/// absence is not a defect to be worked around later.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_datagram_with_no_kernel_timestamp_is_served_all_the_same() {
|
||||
let (received, _) = receive_one(16, b"irtt").await;
|
||||
let received = received.unwrap().expect("an untimestamped datagram serves");
|
||||
assert_eq!(received.reply_source, ReplySource::Bound);
|
||||
assert_eq!(received.kernel_rx_timestamp, None);
|
||||
}
|
||||
|
||||
/// Linux-only coverage of the two receive paths, which is where the
|
||||
/// timestamp actually comes from.
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Sends `payload` from loopback to `listener` and receives it once.
|
||||
async fn exchange(
|
||||
listener: &UdpSocket,
|
||||
select_reply_source: bool,
|
||||
) -> (Option<ReceivedDatagram>, Vec<u8>) {
|
||||
let payload = b"irtt";
|
||||
let destination =
|
||||
SocketAddr::from((Ipv4Addr::LOCALHOST, listener.local_addr().unwrap().port()));
|
||||
let sender = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
|
||||
sender.send_to(payload, destination).await.unwrap();
|
||||
|
||||
let mut buffer = vec![0; 64];
|
||||
let received = receive(listener, &mut buffer, select_reply_source)
|
||||
.await
|
||||
.unwrap();
|
||||
(received, buffer)
|
||||
}
|
||||
|
||||
/// Reports the timestamp, or says why it is skipping the assertion
|
||||
/// rather than inventing one. Loopback under a normal kernel supplies
|
||||
/// it; a container or emulator that does not must not fail the run.
|
||||
fn timestamp_or_skip(received: &ReceivedDatagram, what: &str) {
|
||||
match received.kernel_rx_timestamp {
|
||||
Some(timestamp) => assert!(
|
||||
timestamp.duration_since(std::time::UNIX_EPOCH).is_ok(),
|
||||
"a kernel receive timestamp is a wall-clock instant"
|
||||
),
|
||||
None => eprintln!(
|
||||
"skipping {what} kernel timestamp assertion: \
|
||||
kernel did not provide SCM_TIMESTAMPNS"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The explicit-address listener's new `recvmsg` path: it must keep
|
||||
/// reporting exactly what `recv_from` did, and additionally see the
|
||||
/// timestamp `recv_from` had no way to retrieve.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn an_explicit_listener_receives_a_datagram_and_its_kernel_timestamp() {
|
||||
let listener = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
|
||||
assert!(
|
||||
try_configure_kernel_rx_timestamp(&listener),
|
||||
"Linux loopback accepts SO_TIMESTAMPNS"
|
||||
);
|
||||
|
||||
let (received, buffer) = exchange(&listener, false).await;
|
||||
let received = received.expect("an explicit listener serves a short datagram");
|
||||
|
||||
assert_eq!(received.len, 4);
|
||||
assert_eq!(&buffer[..received.len], b"irtt");
|
||||
assert_eq!(received.peer.ip(), Ipv4Addr::LOCALHOST);
|
||||
assert_eq!(received.reply_source, ReplySource::Bound);
|
||||
timestamp_or_skip(&received, "explicit");
|
||||
}
|
||||
|
||||
/// The regression this whole change risks: asking for a second control
|
||||
/// message must not crowd out the first. A wildcard listener with both
|
||||
/// enabled has to recover its destination *and* its timestamp from one
|
||||
/// datagram, with no `MSG_CTRUNC` drop and no dependence on the order
|
||||
/// the kernel returns them in.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_wildcard_listener_recovers_its_destination_and_timestamp_together() {
|
||||
let listener = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await.unwrap();
|
||||
configure_destination_metadata(&listener, true).unwrap();
|
||||
assert!(try_configure_kernel_rx_timestamp(&listener));
|
||||
|
||||
let (received, buffer) = exchange(&listener, true).await;
|
||||
let received = received.expect(
|
||||
"a wildcard listener asking for two control messages still serves the datagram",
|
||||
);
|
||||
|
||||
assert_eq!(received.len, 4);
|
||||
assert_eq!(&buffer[..received.len], b"irtt");
|
||||
assert_eq!(received.peer.ip(), Ipv4Addr::LOCALHOST);
|
||||
assert_eq!(
|
||||
received.reply_source,
|
||||
ReplySource::V4 {
|
||||
address: Ipv4Addr::LOCALHOST
|
||||
},
|
||||
"the required destination survives the added timestamp request"
|
||||
);
|
||||
timestamp_or_skip(&received, "wildcard");
|
||||
}
|
||||
|
||||
/// The timestamp is not a precondition for either listener: a wildcard
|
||||
/// one that never enabled it still recovers the destination its reply
|
||||
/// depends on and serves the request.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_wildcard_listener_without_timestamps_still_recovers_its_destination() {
|
||||
let listener = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await.unwrap();
|
||||
configure_destination_metadata(&listener, true).unwrap();
|
||||
|
||||
let (received, _) = exchange(&listener, true).await;
|
||||
let received = received.expect("no timestamp requested, and none needed");
|
||||
|
||||
assert_eq!(
|
||||
received.reply_source,
|
||||
ReplySource::V4 {
|
||||
address: Ipv4Addr::LOCALHOST
|
||||
}
|
||||
);
|
||||
assert_eq!(received.kernel_rx_timestamp, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -545,150 +545,3 @@ impl ControlBuffer {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Which reply sources need their interface named, which no normal
|
||||
/// interface can show: it takes a host whose route back to a peer leaves by
|
||||
/// a different interface than the request arrived on, and a test host has
|
||||
/// one loopback.
|
||||
#[test]
|
||||
fn only_a_link_local_source_pins_the_egress_interface() {
|
||||
const ARRIVED_ON: u32 = 7;
|
||||
|
||||
for (address, expected, why) in [
|
||||
(
|
||||
"fe80::1",
|
||||
ARRIVED_ON,
|
||||
"a link-local source needs its link named",
|
||||
),
|
||||
(
|
||||
"fe80::7858:baff:fe91:183d",
|
||||
ARRIVED_ON,
|
||||
"and so does any other one",
|
||||
),
|
||||
("2001:db8::1", 0, "a global source identifies itself"),
|
||||
("fd00::1", 0, "so does a unique-local one"),
|
||||
("::1", 0, "and loopback"),
|
||||
("::ffff:127.0.0.2", 0, "and an IPv4-mapped destination"),
|
||||
] {
|
||||
assert_eq!(
|
||||
egress_interface_index(address.parse().unwrap(), ARRIVED_ON),
|
||||
expected,
|
||||
"{address}: {why}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the two listener kinds do with the metadata they did or did not
|
||||
/// get, which is the whole distinction this module rests on: a destination
|
||||
/// is correctness-critical for one of them and irrelevant to the other.
|
||||
#[test]
|
||||
fn only_a_listener_that_needs_a_destination_is_stopped_by_a_missing_one() {
|
||||
let recovered = ReplySource::V4 {
|
||||
address: Ipv4Addr::LOCALHOST,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
resolved_reply_source(Some(recovered), true),
|
||||
Some(recovered),
|
||||
"a wildcard listener replies from the destination it recovered"
|
||||
);
|
||||
assert_eq!(
|
||||
resolved_reply_source(None, true),
|
||||
None,
|
||||
"and cannot answer a request whose destination did not arrive"
|
||||
);
|
||||
assert_eq!(
|
||||
resolved_reply_source(None, false),
|
||||
Some(ReplySource::Bound),
|
||||
"an explicit listener never needed one"
|
||||
);
|
||||
assert_eq!(
|
||||
resolved_reply_source(Some(recovered), false),
|
||||
Some(ReplySource::Bound),
|
||||
"and sends from its bind even if one turned up"
|
||||
);
|
||||
}
|
||||
|
||||
/// Which truncation is fatal to whom. A cut payload always is; cut control
|
||||
/// data is fatal only where it may have taken the destination with it.
|
||||
/// Turning a lost *timestamp* into a lost packet would trade a real client
|
||||
/// request for optional precision.
|
||||
#[test]
|
||||
fn control_truncation_only_condemns_a_datagram_whose_destination_it_may_have_taken() {
|
||||
for (flags, require_destination, unusable, why) in [
|
||||
(MsgFlags::empty(), true, false, "a clean wildcard receive"),
|
||||
(MsgFlags::empty(), false, false, "a clean explicit receive"),
|
||||
(
|
||||
MsgFlags::MSG_TRUNC,
|
||||
false,
|
||||
true,
|
||||
"a cut payload is not what the client sent",
|
||||
),
|
||||
(
|
||||
MsgFlags::MSG_TRUNC,
|
||||
true,
|
||||
true,
|
||||
"and no less so for a wildcard listener",
|
||||
),
|
||||
(
|
||||
MsgFlags::MSG_CTRUNC,
|
||||
true,
|
||||
true,
|
||||
"cut control data may have taken the destination",
|
||||
),
|
||||
(
|
||||
MsgFlags::MSG_CTRUNC,
|
||||
false,
|
||||
false,
|
||||
"but for an explicit listener it can only have cost a timestamp",
|
||||
),
|
||||
(
|
||||
MsgFlags::MSG_TRUNC | MsgFlags::MSG_CTRUNC,
|
||||
false,
|
||||
true,
|
||||
"the payload rule still applies",
|
||||
),
|
||||
] {
|
||||
assert_eq!(is_unusable(flags, require_destination), unusable, "{why}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A representable kernel reading becomes an instant; anything else becomes
|
||||
/// absent metadata. Note what is *not* rejected here: a reading far from
|
||||
/// now is structurally fine, and judging it belongs to whoever measures
|
||||
/// with it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg_attr(target_env = "musl", allow(deprecated))]
|
||||
#[test]
|
||||
fn a_kernel_timestamp_converts_only_where_it_describes_an_instant() {
|
||||
assert_eq!(
|
||||
system_time_from_timespec(TimeSpec::new(1, 2))
|
||||
.unwrap()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap(),
|
||||
Duration::new(1, 2)
|
||||
);
|
||||
assert_eq!(
|
||||
system_time_from_timespec(TimeSpec::new(0, 0)),
|
||||
Some(UNIX_EPOCH),
|
||||
"the epoch itself is representable"
|
||||
);
|
||||
|
||||
assert_eq!(system_time_from_timespec(TimeSpec::new(-1, 0)), None);
|
||||
assert_eq!(system_time_from_timespec(TimeSpec::new(0, -1)), None);
|
||||
assert_eq!(
|
||||
system_time_from_timespec(TimeSpec::new(0, 1_000_000_000)),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
system_time_from_timespec(TimeSpec::new(libc::time_t::MIN, libc::c_long::MIN)),
|
||||
None,
|
||||
"and the extremes convert rather than panic"
|
||||
);
|
||||
let _ = system_time_from_timespec(TimeSpec::new(libc::time_t::MAX, 999_999_999));
|
||||
}
|
||||
}
|
||||
@@ -149,36 +149,3 @@ fn unsupported_traffic_class(_traffic_class: u32, option: &'static str) -> io::R
|
||||
format!("{option} are unsupported on this target"),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Which listeners mark with the IPv4 option.
|
||||
///
|
||||
/// The mapped forms are the point: such a socket is `AF_INET6` but sends
|
||||
/// IPv4, and `IPV6_TCLASS` does not reach those packets. Linux marks them
|
||||
/// through `IP_TOS`; the platforms that would reject that keep the IPv6
|
||||
/// option, because an unappliable marking stops the send and a dropped
|
||||
/// reply is worse than an unmarked one.
|
||||
#[test]
|
||||
fn a_mapped_listener_marks_with_the_ipv4_option_where_that_option_works() {
|
||||
let mapped = cfg!(target_os = "linux");
|
||||
|
||||
for (addr, is_ipv4) in [
|
||||
("0.0.0.0:2112", true),
|
||||
("127.0.0.1:2112", true),
|
||||
("[::ffff:0.0.0.0]:2112", mapped),
|
||||
("[::ffff:127.0.0.1]:2112", mapped),
|
||||
("[::]:2112", false),
|
||||
("[::1]:2112", false),
|
||||
("[2001:db8::1]:2112", false),
|
||||
] {
|
||||
assert_eq!(
|
||||
marks_with_ipv4_option(addr.parse().unwrap()),
|
||||
is_ipv4,
|
||||
"{addr} would mark with the wrong option"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
//! The structural admission boundary.
|
||||
//!
|
||||
//! A datagram that never decodes as a request is discarded before any request
|
||||
//! kind is chosen, so it can reach neither the session table nor an echo's
|
||||
//! receive state.
|
||||
|
||||
use super::support::{client_params, core_for, open_request, open_session, peer, ScriptedTokens};
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
|
||||
#[test]
|
||||
fn structurally_invalid_datagrams_are_discarded() {
|
||||
let mut core = core_for(None, ScriptedTokens::new([TOKEN_A]));
|
||||
open_session(&mut core, peer(), None);
|
||||
let session = core.session(TOKEN_A).cloned().expect("session must exist");
|
||||
|
||||
let mut bad_magic = open_request(&client_params(), None);
|
||||
bad_magic[0] ^= 0x01;
|
||||
|
||||
let mut reserved_flag = open_request(&client_params(), None);
|
||||
reserved_flag[3] |= 0x10;
|
||||
|
||||
let mut reply_flag = open_request(&client_params(), None);
|
||||
reply_flag[3] |= irtt_proto::FLAG_REPLY;
|
||||
|
||||
for (name, packet) in [
|
||||
("empty datagram", Vec::new()),
|
||||
("datagram shorter than the header", vec![0x14, 0xa7, 0x5b]),
|
||||
("bad magic", bad_magic),
|
||||
("reserved flag bit", reserved_flag),
|
||||
("reply flag", reply_flag),
|
||||
] {
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &packet).unwrap(),
|
||||
None,
|
||||
"{name} must be discarded"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(core.session(TOKEN_A), Some(&session));
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
//! Client-initiated close.
|
||||
//!
|
||||
//! A close request that matches a live token *and* arrived from that session's
|
||||
//! exact endpoint releases the session and is answered with nothing. Every
|
||||
//! other close — unknown, stale or zero token, foreign endpoint, failed
|
||||
//! authentication — is the same silence with the session left live, so these
|
||||
//! tests assert the table as well as the reply.
|
||||
|
||||
use std::net::{SocketAddr, SocketAddrV6};
|
||||
|
||||
use irtt_proto::{encode_request, RequestToEncode};
|
||||
|
||||
use super::support::{
|
||||
client_params, close_request, close_request_with_trailing, core_for, core_with_tokens,
|
||||
open_request, open_session, other_peer, peer, ScriptedTokens, KEY, OTHER_KEY,
|
||||
};
|
||||
use crate::ServerConfig;
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
||||
const UNKNOWN_TOKEN: u64 = 0x2122_2324_2526_2728;
|
||||
|
||||
#[test]
|
||||
fn a_valid_close_removes_the_session_and_answers_nothing() {
|
||||
for hmac_key in [None, Some(KEY)] {
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_for(hmac_key, tokens.clone());
|
||||
let token = open_session(&mut core, peer(), hmac_key);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
|
||||
// Exactly `None`, not an empty or acknowledging packet: version 1 has
|
||||
// no close reply, so the client learns nothing about the outcome.
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &close_request(token, hmac_key))
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert_eq!(core.session(token), None);
|
||||
assert_eq!(
|
||||
tokens.remaining(),
|
||||
0,
|
||||
"a close must not draw from the token source"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeated_close_is_a_stale_token_and_changes_nothing() {
|
||||
for hmac_key in [None, Some(KEY)] {
|
||||
let mut core = core_for(hmac_key, ScriptedTokens::new([TOKEN_A]));
|
||||
let token = open_session(&mut core, peer(), hmac_key);
|
||||
let close = close_request(token, hmac_key);
|
||||
|
||||
assert_eq!(core.handle_datagram(peer(), &close).unwrap(), None);
|
||||
assert_eq!(core.handle_datagram(peer(), &close).unwrap(), None);
|
||||
|
||||
assert_eq!(core.session_count(), 0, "nothing may be resurrected");
|
||||
assert_eq!(core.session(token), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_echo_after_a_close_is_a_stale_token() {
|
||||
// The same request would have been answered a moment earlier: releasing the
|
||||
// session takes its token with it, so there is nothing left to reach.
|
||||
for hmac_key in [None, Some(KEY)] {
|
||||
let mut core = core_for(hmac_key, ScriptedTokens::new([TOKEN_A]));
|
||||
let token = open_session(&mut core, peer(), hmac_key);
|
||||
let params = core.session(token).expect("session must exist").params();
|
||||
let echo = encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token,
|
||||
sequence: 0,
|
||||
params,
|
||||
payload: &[],
|
||||
},
|
||||
hmac_key,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
core.handle_datagram(peer(), &close_request(token, hmac_key))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(core.handle_datagram(peer(), &echo).unwrap(), None);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert_eq!(core.session(token), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_from_any_other_endpoint_leaves_the_session_live() {
|
||||
// Possessing the token is not authority to close. The first four pairs
|
||||
// differ in an established identity component; the last differs only in
|
||||
// IPv6 zone, which is `irtt-rs` policy rather than observed behavior — see
|
||||
// `same_endpoint`. It is pinned so the choice cannot drift silently, not
|
||||
// because evidence settled it.
|
||||
for (bound, foreign) in [
|
||||
("198.51.100.7:41234", "198.51.100.7:41235"),
|
||||
("198.51.100.7:41234", "198.51.100.9:41234"),
|
||||
("198.51.100.7:41234", "[::ffff:198.51.100.7]:41234"),
|
||||
("[2001:db8::1]:2112", "[2001:db8::2]:2112"),
|
||||
("[fe80::1%3]:2112", "[fe80::1%4]:2112"),
|
||||
] {
|
||||
let bound = bound.parse().unwrap();
|
||||
let foreign = foreign.parse().unwrap();
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let token = open_session(&mut core, bound, None);
|
||||
let session = core.session(token).cloned().expect("session must exist");
|
||||
let close = close_request(token, None);
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(foreign, &close).unwrap(),
|
||||
None,
|
||||
"a close from {foreign} must be silent"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(
|
||||
core.session(token),
|
||||
Some(&session),
|
||||
"a close from {foreign} must not remove the session bound to {bound}"
|
||||
);
|
||||
|
||||
// The same request from the session's own endpoint does close it, so
|
||||
// the request itself was never the reason for the silence.
|
||||
assert_eq!(core.handle_datagram(bound, &close).unwrap(), None);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_flow_information_is_not_part_of_endpoint_identity() {
|
||||
// Identity is family, address, port and scope. The flow label is routing
|
||||
// metadata that may vary within one flow, so letting `SocketAddrV6`
|
||||
// equality decide would strand a session its own client could never close.
|
||||
let ip = "2001:db8::1".parse().unwrap();
|
||||
let unlabeled = SocketAddr::V6(SocketAddrV6::new(ip, 2112, 0, 0));
|
||||
let labeled = SocketAddr::V6(SocketAddrV6::new(ip, 2112, 0x000f_1234, 0));
|
||||
assert_ne!(
|
||||
unlabeled, labeled,
|
||||
"the two must differ only in flow information"
|
||||
);
|
||||
|
||||
for (bound, closing) in [(unlabeled, labeled), (labeled, unlabeled)] {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let token = open_session(&mut core, bound, None);
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(closing, &close_request(token, None))
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
core.session_count(),
|
||||
0,
|
||||
"a close from {closing} must release the session opened from {bound}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_or_zero_token_closes_nothing() {
|
||||
// Zero is reserved for no-test replies and is never issued to a session, so
|
||||
// it can only ever miss the table.
|
||||
for (name, token) in [("an unknown token", UNKNOWN_TOKEN), ("a zero token", 0)] {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let live = open_session(&mut core, peer(), None);
|
||||
let session = core.session(live).cloned().expect("session must exist");
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &close_request(token, None))
|
||||
.unwrap(),
|
||||
None,
|
||||
"{name} must be answered with silence"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1, "{name} must remove nothing");
|
||||
assert_eq!(core.session(live), Some(&session));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_that_fails_authentication_leaves_the_session_live() {
|
||||
// Authentication runs before the session is looked up, so a close that
|
||||
// fails it is indistinguishable from one bearing an unknown token — and it
|
||||
// must not be able to tear down an authenticated session.
|
||||
let mut core = core_for(Some(KEY), ScriptedTokens::new([TOKEN_A]));
|
||||
let token = open_session(&mut core, peer(), Some(KEY));
|
||||
let session = core.session(token).cloned().expect("session must exist");
|
||||
|
||||
let mut corrupted = close_request(token, Some(KEY));
|
||||
corrupted[5] ^= 0x80;
|
||||
|
||||
for (name, packet) in [
|
||||
(
|
||||
"a close with no MAC field at all",
|
||||
close_request(token, None),
|
||||
),
|
||||
(
|
||||
"a close signed with a different key",
|
||||
close_request(token, Some(OTHER_KEY)),
|
||||
),
|
||||
("a close with one bit flipped in the MAC", corrupted),
|
||||
] {
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &packet).unwrap(),
|
||||
None,
|
||||
"{name} must be answered with silence"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1, "{name} must not remove a session");
|
||||
assert_eq!(core.session(token), Some(&session));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_authenticating_server_ignores_an_authenticated_close() {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let token = open_session(&mut core, peer(), None);
|
||||
let session = core.session(token).cloned().expect("session must exist");
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &close_request(token, Some(KEY)))
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(core.session(token), Some(&session));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_bytes_after_the_token_do_not_prevent_the_close() {
|
||||
for hmac_key in [None, Some(KEY)] {
|
||||
let mut core = core_for(hmac_key, ScriptedTokens::new([TOKEN_A]));
|
||||
let token = open_session(&mut core, peer(), hmac_key);
|
||||
|
||||
let close = close_request_with_trailing(token, &[0xde, 0xad, 0xbe, 0xef], hmac_key);
|
||||
|
||||
assert_eq!(core.handle_datagram(peer(), &close).unwrap(), None);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_removes_only_the_session_it_names() {
|
||||
// Repeated opens from one endpoint are independent sessions, so a close
|
||||
// must be resolved by token, never by endpoint.
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
|
||||
);
|
||||
let first = open_session(&mut core, peer(), None);
|
||||
let second = open_session(&mut core, peer(), None);
|
||||
let survivor = core.session(second).cloned().expect("session must exist");
|
||||
assert_eq!(core.session_count(), 2);
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &close_request(first, None))
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(core.session_count(), 1, "only one session may be released");
|
||||
assert_eq!(core.session(first), None);
|
||||
assert_eq!(core.session(second), Some(&survivor));
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &close_request(second, None))
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_frees_session_capacity_immediately() {
|
||||
// The session table is the only record of how full the server is, so a
|
||||
// removal is reclaimed at once with no separate counter to drift.
|
||||
let tokens = ScriptedTokens::new([TOKEN_A, TOKEN_B]);
|
||||
let mut core = core_with_tokens(ServerConfig::default().with_max_sessions(1), tokens.clone());
|
||||
let request = open_request(&client_params(), None);
|
||||
|
||||
let first = open_session(&mut core, peer(), None);
|
||||
assert_eq!(
|
||||
core.handle_datagram(other_peer(), &request).unwrap(),
|
||||
None,
|
||||
"the bound is reached, so the second open is refused"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(
|
||||
tokens.remaining(),
|
||||
1,
|
||||
"a refused open must not commit a token"
|
||||
);
|
||||
|
||||
core.handle_datagram(peer(), &close_request(first, None))
|
||||
.unwrap();
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert_eq!(
|
||||
tokens.remaining(),
|
||||
1,
|
||||
"a close must not draw from the token source"
|
||||
);
|
||||
|
||||
let second = open_session(&mut core, other_peer(), None);
|
||||
assert_eq!(second, TOKEN_B);
|
||||
assert_ne!(second, 0);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(core.session(second).unwrap().peer(), other_peer());
|
||||
}
|
||||
@@ -1,818 +0,0 @@
|
||||
//! Normal echo processing.
|
||||
//!
|
||||
//! An echo request that authenticates, fits the configured maximum, names a
|
||||
//! live session and arrives from that session's endpoint is answered from the
|
||||
//! session's negotiated parameters. Everything else is silence that leaves the
|
||||
//! receive state alone, which these tests prove from the wire — the count and
|
||||
//! window in the *next* accepted reply are what show that a rejected datagram
|
||||
//! changed nothing.
|
||||
//!
|
||||
//! The count and window vectors come from the clean
|
||||
//! `test-vectors/SERVER_BEHAVIORAL_VECTORS.md` Section 1 and are hard-coded
|
||||
//! here rather than recomputed, so that replacing the observed semantics with a
|
||||
//! conventional selective-acknowledgement bitmap fails these tests.
|
||||
|
||||
use std::net::{SocketAddr, SocketAddrV6};
|
||||
|
||||
use irtt_proto::{
|
||||
echo_packet_len, verify_packet_hmac, Clock, Params, ReceivedStats, StampAt, TimestampFields,
|
||||
};
|
||||
|
||||
use super::support::{
|
||||
core_for, core_with_sources, core_with_tokens, echo_params, echo_request, expect_echo_reply,
|
||||
open_negotiated, other_peer, peer, sample, unthrottled, ScriptedClock, ScriptedTokens, KEY,
|
||||
OTHER_KEY,
|
||||
};
|
||||
use crate::{clock::ClockSample, ServerConfig, TimestampAllowance};
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
const UNKNOWN_TOKEN: u64 = 0x2122_2324_2526_2728;
|
||||
|
||||
/// The lifecycle instant an open consumes before any echo is sent.
|
||||
///
|
||||
/// The core reads the clock once per authenticated request, to judge idle expiry
|
||||
/// and to start the new session's deadlines. It is earlier than every echo
|
||||
/// sample below, as a real monotonic reading would be, and no reply ever carries
|
||||
/// it: an open reply has no timestamp fields.
|
||||
fn open_sample() -> ClockSample {
|
||||
sample(900, 9_000)
|
||||
}
|
||||
|
||||
/// Params selecting no optional field at all, so the echo layout is the
|
||||
/// mandatory field block and nothing else.
|
||||
fn bare_params() -> Params {
|
||||
echo_params(
|
||||
ReceivedStats::None,
|
||||
StampAt::None,
|
||||
Clock::Unspecified,
|
||||
/* length */ 0,
|
||||
)
|
||||
}
|
||||
|
||||
/// Params reporting both statistics, which is what most vectors are stated in.
|
||||
fn counting_params(length: i64) -> Params {
|
||||
echo_params(
|
||||
ReceivedStats::Both,
|
||||
StampAt::None,
|
||||
Clock::Unspecified,
|
||||
length,
|
||||
)
|
||||
}
|
||||
|
||||
/// Opens one session and returns the core, the token and the negotiated params.
|
||||
///
|
||||
/// Rate limiting is disabled: these tests drive a whole vector of echoes at once
|
||||
/// against the production clock, and what they are about is the reply, not the
|
||||
/// allowance. `rate` covers the limiter.
|
||||
fn session_with(params: &Params) -> (crate::ServerCore, u64, Params) {
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), params, None);
|
||||
(core, token, negotiated)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_valid_echo_is_answered_from_the_negotiated_params() {
|
||||
let (mut core, token, negotiated) = session_with(&bare_params());
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 42, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("an admissible echo must be answered");
|
||||
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
assert_eq!(reply.token, token, "the session token is copied through");
|
||||
assert_eq!(
|
||||
reply.sequence, 42,
|
||||
"the sequence number is copied unchanged"
|
||||
);
|
||||
assert_eq!(reply.recv_count, None, "no statistics were negotiated");
|
||||
assert_eq!(reply.recv_window, None);
|
||||
assert_eq!(
|
||||
reply.timestamps,
|
||||
TimestampFields::default(),
|
||||
"no timestamps were negotiated, so none are emitted"
|
||||
);
|
||||
assert!(reply.payload.is_empty());
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
echo_packet_len(false, &negotiated).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
16,
|
||||
"magic, flags, token and sequence only"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
core.session_count(),
|
||||
1,
|
||||
"an echo neither creates nor releases a session"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_is_sized_by_the_session_not_by_the_request() {
|
||||
// A request may be shorter or longer than the negotiated length, and its
|
||||
// bytes past the sequence number are opaque either way.
|
||||
let (mut core, token, negotiated) = session_with(&counting_params(64));
|
||||
assert_eq!(negotiated.length, 64);
|
||||
|
||||
let short = Params {
|
||||
length: 0,
|
||||
..negotiated.clone()
|
||||
};
|
||||
// Requesting no statistics moves this request's payload region up to where
|
||||
// the *session's* reply keeps its count and window, so the opaque bytes
|
||||
// land squarely on fields a server must not read from a request.
|
||||
let long = Params {
|
||||
length: 100,
|
||||
received_stats: ReceivedStats::None,
|
||||
..negotiated.clone()
|
||||
};
|
||||
|
||||
for (sequence, name, sizing, payload, expected) in [
|
||||
(
|
||||
0,
|
||||
"a request shorter than the negotiated length",
|
||||
&short,
|
||||
&[][..],
|
||||
(1, 0x1),
|
||||
),
|
||||
(
|
||||
1,
|
||||
"a request longer than the negotiated length, with opaque bytes",
|
||||
&long,
|
||||
&[0xa5; 40][..],
|
||||
(2, 0x3),
|
||||
),
|
||||
] {
|
||||
let request = echo_request(token, sequence, sizing, payload, None);
|
||||
assert_ne!(request.len(), 64, "{name} must not be the reply's length");
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{name} must be answered"));
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
64,
|
||||
"{name}: the reply is the negotiated length"
|
||||
);
|
||||
assert_eq!(reply.sequence, sequence);
|
||||
assert_eq!(
|
||||
(reply.recv_count, reply.recv_window),
|
||||
(Some(expected.0), Some(expected.1)),
|
||||
"{name}: the opaque tail must not influence the reply"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_echo_over_the_configured_maximum_is_dropped_before_anything_advances() {
|
||||
let config = ServerConfig::default().with_max_packet_length(64);
|
||||
let mut core = core_with_tokens(config, ScriptedTokens::new([TOKEN_A]));
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), &counting_params(64), None);
|
||||
assert_eq!(negotiated.length, 64);
|
||||
|
||||
let at_maximum = echo_request(token, 0, &negotiated, &[], None);
|
||||
assert_eq!(at_maximum.len(), 64);
|
||||
let over_maximum = echo_request(
|
||||
token,
|
||||
1,
|
||||
&Params {
|
||||
length: 65,
|
||||
..negotiated.clone()
|
||||
},
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert_eq!(over_maximum.len(), 65);
|
||||
|
||||
// The comparison is strict, so exactly the maximum is served.
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &at_maximum)
|
||||
.unwrap()
|
||||
.expect("a request of exactly the maximum must be answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
assert_eq!((reply.recv_count, reply.recv_window), (Some(1), Some(0x1)));
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &over_maximum).unwrap(),
|
||||
None,
|
||||
"one byte over the maximum must be answered with silence"
|
||||
);
|
||||
|
||||
// The next admissible request is what proves the drop advanced nothing: had
|
||||
// the oversized one counted, this would report 3 rather than 2.
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 1, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("an admissible echo after a dropped one must be answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
assert_eq!((reply.recv_count, reply.recv_window), (Some(2), Some(0x3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_echo_that_owns_no_session_here_is_answered_with_silence() {
|
||||
let (mut core, token, negotiated) = session_with(&counting_params(0));
|
||||
|
||||
for (name, endpoint, request_token) in [
|
||||
("an unknown token", peer(), UNKNOWN_TOKEN),
|
||||
("a zero token", peer(), 0),
|
||||
(
|
||||
"a valid token from another source port",
|
||||
other_peer(),
|
||||
token,
|
||||
),
|
||||
] {
|
||||
let request = echo_request(request_token, 0, &negotiated, &[], None);
|
||||
assert_eq!(
|
||||
core.handle_datagram(endpoint, &request).unwrap(),
|
||||
None,
|
||||
"{name} must be answered with silence"
|
||||
);
|
||||
}
|
||||
|
||||
// Count 1 and window 0x1 are the first-request values, so none of the three
|
||||
// rejected datagrams touched this session's receive state.
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("the session's own echo must still be answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
assert_eq!((reply.recv_count, reply.recv_window), (Some(1), Some(0x1)));
|
||||
assert_eq!(core.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_echo_resolves_ipv6_endpoints_by_the_same_identity_rule_as_a_close() {
|
||||
// The comparator itself is pinned by the close tests; this is the echo path
|
||||
// reaching the same one rather than growing a second rule.
|
||||
let global = "2001:db8::1".parse().unwrap();
|
||||
let link_local = "fe80::1".parse().unwrap();
|
||||
let unlabeled = SocketAddr::V6(SocketAddrV6::new(global, 2112, 0, 0));
|
||||
let labeled = SocketAddr::V6(SocketAddrV6::new(global, 2112, 0x000f_1234, 0));
|
||||
let zone_three = SocketAddr::V6(SocketAddrV6::new(link_local, 2112, 0, 3));
|
||||
let zone_four = SocketAddr::V6(SocketAddrV6::new(link_local, 2112, 0, 4));
|
||||
|
||||
for (bound, sender, answered) in [
|
||||
// Flow information is routing metadata that identifies no endpoint.
|
||||
(unlabeled, labeled, true),
|
||||
(labeled, unlabeled, true),
|
||||
// The zone is identity by `irtt-rs` policy, not by observed behavior.
|
||||
(zone_three, zone_four, false),
|
||||
] {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let (token, negotiated) = open_negotiated(&mut core, bound, &counting_params(0), None);
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(sender, &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
reply.is_some(),
|
||||
answered,
|
||||
"an echo from {sender} on a session opened from {bound}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn received_count_and_window_follow_the_observed_vectors() {
|
||||
// Every row is `(request sequence, reply count, reply window)` exactly as
|
||||
// measured in Section 1 of the behavioral vectors.
|
||||
for (name, vectors) in [
|
||||
(
|
||||
"1.1 sequential, no loss",
|
||||
&[
|
||||
(0, 1, 0x1),
|
||||
(1, 2, 0x3),
|
||||
(2, 3, 0x7),
|
||||
(3, 4, 0xf),
|
||||
(4, 5, 0x1f),
|
||||
(5, 6, 0x3f),
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"1.2 one gap",
|
||||
&[(0, 1, 0x1), (1, 2, 0x3), (3, 3, 0xd), (4, 4, 0x1b)][..],
|
||||
),
|
||||
(
|
||||
"1.3 multiple gaps",
|
||||
&[(0, 1, 0x1), (3, 2, 0x9), (7, 3, 0x91), (8, 4, 0x123)][..],
|
||||
),
|
||||
(
|
||||
// Filling a gap late discards the history rather than setting the
|
||||
// bit the gap left clear.
|
||||
"1.4 later gap fill",
|
||||
&[(0, 1, 0x1), (1, 2, 0x3), (5, 3, 0x31), (3, 4, 0x1)][..],
|
||||
),
|
||||
(
|
||||
"1.5 duplicate sequence number",
|
||||
&[(0, 1, 0x1), (1, 2, 0x3), (1, 3, 0x3), (2, 4, 0x7)][..],
|
||||
),
|
||||
(
|
||||
// Row 5 rebuilds from the reset: bit 1 stays clear even though
|
||||
// sequence 2 was received before the reordering.
|
||||
"1.6 reordering",
|
||||
&[
|
||||
(0, 1, 0x1),
|
||||
(1, 2, 0x3),
|
||||
(2, 3, 0x7),
|
||||
(1, 4, 0x1),
|
||||
(3, 5, 0x5),
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"1.7 strictly descending sequence numbers",
|
||||
&[
|
||||
(5, 1, 0x1),
|
||||
(4, 2, 0x1),
|
||||
(3, 3, 0x1),
|
||||
(2, 4, 0x1),
|
||||
(1, 5, 0x1),
|
||||
(0, 6, 0x1),
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"1.8 a gap of exactly 63",
|
||||
&[(0, 1, 0x1), (63, 2, 0x8000_0000_0000_0001)][..],
|
||||
),
|
||||
("1.8 a gap of 64", &[(0, 1, 0x1), (64, 2, 0x1)][..]),
|
||||
("1.8 a gap of 65", &[(0, 1, 0x1), (65, 2, 0x1)][..]),
|
||||
("1.8 a gap of 1000", &[(0, 1, 0x1), (1000, 2, 0x1)][..]),
|
||||
(
|
||||
"1.9 a non-zero starting sequence number",
|
||||
&[(100, 1, 0x1), (101, 2, 0x3), (102, 3, 0x7)][..],
|
||||
),
|
||||
(
|
||||
"1.10 32-bit sequence wraparound",
|
||||
&[
|
||||
(0xffff_fffd, 1, 0x1),
|
||||
(0xffff_fffe, 2, 0x3),
|
||||
(0xffff_ffff, 3, 0x7),
|
||||
(0, 4, 0xf),
|
||||
(1, 5, 0x1f),
|
||||
][..],
|
||||
),
|
||||
] {
|
||||
let (mut core, token, negotiated) = session_with(&counting_params(0));
|
||||
|
||||
for &(sequence, count, window) in vectors {
|
||||
let packet = core
|
||||
.handle_datagram(
|
||||
peer(),
|
||||
&echo_request(token, sequence, &negotiated, &[], None),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{name}: sequence {sequence} must be answered"));
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
assert_eq!(
|
||||
reply.recv_count,
|
||||
Some(count),
|
||||
"{name}: count after sequence {sequence}"
|
||||
);
|
||||
assert_eq!(
|
||||
reply.recv_window,
|
||||
Some(window),
|
||||
"{name}: window after sequence {sequence}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_negotiated_received_statistics_are_reported() {
|
||||
// Field presence only; the transition itself is pinned by the vectors
|
||||
// above, so two in-order requests are enough here.
|
||||
for (stats, count, window) in [
|
||||
(ReceivedStats::None, None, None),
|
||||
(ReceivedStats::Count, Some(2), None),
|
||||
(ReceivedStats::Window, None, Some(0x3)),
|
||||
(ReceivedStats::Both, Some(2), Some(0x3)),
|
||||
] {
|
||||
let params = echo_params(stats, StampAt::None, Clock::Unspecified, 0);
|
||||
let (mut core, token, negotiated) = session_with(¶ms);
|
||||
|
||||
let mut reply = None;
|
||||
for sequence in 0..2 {
|
||||
let packet = core
|
||||
.handle_datagram(
|
||||
peer(),
|
||||
&echo_request(token, sequence, &negotiated, &[], None),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{stats:?}: sequence {sequence} must be answered"));
|
||||
reply = Some(expect_echo_reply(&packet, &negotiated, None));
|
||||
}
|
||||
|
||||
let reply = reply.expect("two requests were sent");
|
||||
assert_eq!(reply.recv_count, count, "count presence for {stats:?}");
|
||||
assert_eq!(reply.recv_window, window, "window presence for {stats:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_authenticated_session_answers_only_authenticated_echoes() {
|
||||
let mut core = core_for(Some(KEY), ScriptedTokens::new([TOKEN_A]));
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), &counting_params(0), Some(KEY));
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 3, &negotiated, &[], Some(KEY)))
|
||||
.unwrap()
|
||||
.expect("a correctly authenticated echo must be answered");
|
||||
assert!(
|
||||
verify_packet_hmac(KEY, packet.bytes()).is_ok(),
|
||||
"the reply must carry a MAC that verifies with the server's key"
|
||||
);
|
||||
// `expect_echo_reply` pins the HMAC flag and verifies the MAC again while
|
||||
// decoding, so a reply that authenticated but omitted the flag would fail
|
||||
// here — the failure mode that silently breaks upstream clients.
|
||||
let reply = expect_echo_reply(&packet, &negotiated, Some(KEY));
|
||||
assert_eq!(reply.token, token);
|
||||
assert_eq!(reply.sequence, 3);
|
||||
assert_eq!((reply.recv_count, reply.recv_window), (Some(1), Some(0x1)));
|
||||
|
||||
let mut corrupted = echo_request(token, 4, &negotiated, &[], Some(KEY));
|
||||
corrupted[5] ^= 0x80;
|
||||
for (name, packet) in [
|
||||
(
|
||||
"an echo with no MAC field at all",
|
||||
echo_request(token, 4, &negotiated, &[], None),
|
||||
),
|
||||
(
|
||||
"an echo signed with a different key",
|
||||
echo_request(token, 4, &negotiated, &[], Some(OTHER_KEY)),
|
||||
),
|
||||
("an echo with one bit flipped in the MAC", corrupted),
|
||||
] {
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &packet).unwrap(),
|
||||
None,
|
||||
"{name} must be answered with silence"
|
||||
);
|
||||
}
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 4, &negotiated, &[], Some(KEY)))
|
||||
.unwrap()
|
||||
.expect("an authenticated echo after failed ones must be answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, Some(KEY));
|
||||
assert_eq!(
|
||||
(reply.recv_count, reply.recv_window),
|
||||
(Some(2), Some(0x3)),
|
||||
"none of the packets that failed authentication may be counted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_negotiated_timestamps_are_emitted() {
|
||||
let received = sample(1_000, 10_000);
|
||||
let sent = sample(1_200, 10_300);
|
||||
|
||||
for (stamp_at, clock, expected) in [
|
||||
(
|
||||
StampAt::Send,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
send_wall: Some(1_200),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Send,
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Send,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
send_wall: Some(1_200),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Receive,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Receive,
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
recv_mono: Some(10_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Receive,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000),
|
||||
recv_mono: Some(10_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Both,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000),
|
||||
send_wall: Some(1_200),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Both,
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
recv_mono: Some(10_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Both,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000),
|
||||
recv_mono: Some(10_000),
|
||||
send_wall: Some(1_200),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(1_100),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
midpoint_mono: Some(10_150),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(1_100),
|
||||
midpoint_mono: Some(10_150),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
] {
|
||||
let clock_source = ScriptedClock::new([open_sample(), received, sent]);
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
clock_source.clone(),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, stamp_at, clock, 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None);
|
||||
assert_eq!(
|
||||
clock_source.remaining(),
|
||||
2,
|
||||
"an open consumes exactly its own lifecycle sample"
|
||||
);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{stamp_at:?} on {clock:?} must be answered"));
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
// Equality over the whole struct, so an unselected field appearing is a
|
||||
// failure as much as a selected one carrying the wrong instant.
|
||||
assert_eq!(reply.timestamps, expected, "{stamp_at:?} on {clock:?}");
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
echo_packet_len(false, &negotiated).unwrap(),
|
||||
"{stamp_at:?} on {clock:?} must be the negotiated layout's length"
|
||||
);
|
||||
assert_eq!(
|
||||
clock_source.remaining(),
|
||||
0,
|
||||
"{stamp_at:?} on {clock:?}: one receive and one send sample"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backward_wall_step_never_reports_receive_after_send() {
|
||||
// A reply's receive instant must not be later than its send instant, and
|
||||
// the wall clock can be stepped backwards between the two readings by NTP,
|
||||
// a hypervisor or an administrator. The pair settles on the later reading,
|
||||
// and only the wall domain moves: the monotonic one was already ordered.
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([open_sample(), sample(1_000, 10_000), sample(990, 10_300)]),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("a backward wall step must not stop the echo being answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
assert_eq!(
|
||||
reply.timestamps,
|
||||
TimestampFields {
|
||||
recv_wall: Some(990),
|
||||
recv_mono: Some(10_000),
|
||||
send_wall: Some(990),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
"the receive wall value is held back to the send reading, and only it"
|
||||
);
|
||||
assert!(reply.timestamps.recv_wall <= reply.timestamps.send_wall);
|
||||
assert!(reply.timestamps.recv_mono <= reply.timestamps.send_mono);
|
||||
|
||||
// A midpoint drawn from the same stepped pair is ordered for the same
|
||||
// reason: it is the mean of two values that are no longer inverted.
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([open_sample(), sample(1_000, 10_000), sample(990, 10_300)]),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, StampAt::Midpoint, Clock::Wall, 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("a backward wall step must not stop the echo being answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
assert_eq!(
|
||||
reply.timestamps,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(990),
|
||||
..TimestampFields::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_clock_midpoint_emits_exactly_one_field() {
|
||||
// A guard against reproducing upstream 0.9.1's dual-field midpoint, which
|
||||
// emits both midpoint fields whatever the negotiated clock. `irtt-proto`
|
||||
// still decodes that form from a peer; this is about what this server
|
||||
// sends.
|
||||
for (clock, expected) in [
|
||||
(
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(1_100),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
midpoint_mono: Some(10_150),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
] {
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([open_sample(), sample(1_000, 10_000), sample(1_200, 10_300)]),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, StampAt::Midpoint, clock, 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("a midpoint echo on {clock:?} must be answered"));
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
echo_packet_len(false, &negotiated).unwrap(),
|
||||
"{clock:?}: the negotiated length, not the compatibility one"
|
||||
);
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
24,
|
||||
"{clock:?}: one 8-byte midpoint field, not the upstream two"
|
||||
);
|
||||
assert_eq!(reply.timestamps, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_payload_is_server_generated_and_never_reflects_the_request() {
|
||||
// The inbound tail is opaque, and ServerFill describes the *server's* bytes:
|
||||
// a request's payload is neither echoed, mixed in, nor used to seed
|
||||
// anything. This session expressed no fill preference, so the region
|
||||
// carries the server's default fill; `fill` covers the modes themselves.
|
||||
let params = echo_params(ReceivedStats::None, StampAt::None, Clock::Unspecified, 64);
|
||||
let (mut core, token, negotiated) = session_with(¶ms);
|
||||
|
||||
let request = echo_request(token, 0, &negotiated, &[0xa5; 48], None);
|
||||
assert!(
|
||||
request[16..].iter().all(|byte| *byte == 0xa5),
|
||||
"the request must carry non-zero payload bytes to reflect"
|
||||
);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.expect("an admissible echo must be answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
assert_eq!(packet.bytes().len(), 64);
|
||||
assert_eq!(reply.payload.len(), 48);
|
||||
assert!(
|
||||
!reply.payload.contains(&0xa5),
|
||||
"no request byte may appear in the reply payload"
|
||||
);
|
||||
assert!(
|
||||
reply
|
||||
.payload
|
||||
.chunks(4)
|
||||
.all(|chunk| chunk == &b"irtt"[..chunk.len()]),
|
||||
"the region carries the server's default fill"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restricted_session_emits_the_restricted_timestamps_and_not_the_requested_ones() {
|
||||
// Timestamp generation follows the session's *negotiated* placement, which
|
||||
// is the point of restricting it during negotiation: a client asking for
|
||||
// both instants from both clocks gets one midpoint field per clock under a
|
||||
// single-timestamp allowance, and nothing at all under none — with the reply
|
||||
// laid out and sized from the restriction either way, not from the request.
|
||||
for (allowance, expected_stamp_at, expected, expected_len) in [
|
||||
(
|
||||
TimestampAllowance::Single,
|
||||
StampAt::Midpoint,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(1_100),
|
||||
midpoint_mono: Some(10_150),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
32,
|
||||
),
|
||||
(
|
||||
TimestampAllowance::None,
|
||||
StampAt::None,
|
||||
TimestampFields::default(),
|
||||
16,
|
||||
),
|
||||
] {
|
||||
let mut core = core_with_sources(
|
||||
unthrottled().with_timestamp_allowance(allowance),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([open_sample(), sample(1_000, 10_000), sample(1_200, 10_300)]),
|
||||
);
|
||||
let requested = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), &requested, None);
|
||||
assert_eq!(negotiated.stamp_at, expected_stamp_at, "{allowance:?}");
|
||||
assert_eq!(negotiated.clock, Clock::Both, "{allowance:?}");
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{allowance:?}: an admissible echo must be answered"));
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
// Equality over the whole struct, so a leftover receive or send field is
|
||||
// a failure as much as a missing midpoint one.
|
||||
assert_eq!(reply.timestamps, expected, "{allowance:?}");
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
echo_packet_len(false, &negotiated).unwrap(),
|
||||
"{allowance:?}: the restricted layout's length"
|
||||
);
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
expected_len,
|
||||
"{allowance:?}: no empty slot is kept for a restricted field"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
//! Server fill: what an open reply reports, what the session will fill with,
|
||||
//! and the bytes an echo reply actually carries.
|
||||
//!
|
||||
//! A client that expressed no preference keeps its absent or empty descriptor
|
||||
//! in the negotiated parameters. Payload tests assert the effective fill from
|
||||
//! reply bytes, without inspecting the session's internal representation.
|
||||
//!
|
||||
//! Payload lengths here are derived from `irtt-proto`'s own layout rather than
|
||||
//! written as offsets, and are deliberately not multiples of the pattern length.
|
||||
|
||||
use irtt_proto::{echo_header_len, Clock, Params, ReceivedStats, ServerFill, StampAt};
|
||||
|
||||
use super::support::{
|
||||
client_params, core_for, core_with_tokens, echo_params, echo_request, expect_echo_reply,
|
||||
expect_no_test_reply, expect_normal_open_reply, no_test_request, open_negotiated, open_request,
|
||||
other_peer, peer, unthrottled, ScriptedTokens, KEY,
|
||||
};
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
||||
|
||||
/// Request payload bytes an echo carries, so a reply reflecting them would be
|
||||
/// obvious.
|
||||
const REQUEST_FILLER: u8 = 0xa5;
|
||||
|
||||
/// Params requesting `descriptor` as the server fill.
|
||||
fn requesting(descriptor: Option<&str>) -> Params {
|
||||
Params {
|
||||
server_fill: descriptor.map(|value| ServerFill {
|
||||
value: value.to_owned(),
|
||||
}),
|
||||
..client_params()
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a session requesting `descriptor` and returns the descriptor the reply
|
||||
/// carried.
|
||||
fn negotiated(descriptor: Option<&str>) -> Option<String> {
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&requesting(descriptor), None))
|
||||
.unwrap()
|
||||
.expect("the open must be answered");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
reply.params.server_fill.map(|fill| fill.value)
|
||||
}
|
||||
|
||||
/// The descriptor a no-test open reports for `descriptor`, which creates no
|
||||
/// session and so retains no fill.
|
||||
fn no_test_negotiated(descriptor: Option<&str>) -> Option<String> {
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(unthrottled(), tokens.clone());
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &no_test_request(&requesting(descriptor), None))
|
||||
.unwrap()
|
||||
.expect("the no-test open must be answered");
|
||||
|
||||
let reply = expect_no_test_reply(&packet, None);
|
||||
assert_eq!(core.session_count(), 0, "no-test creates no session");
|
||||
assert_eq!(tokens.remaining(), 1, "no-test draws no session token");
|
||||
reply.params.server_fill.map(|fill| fill.value)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicitly_empty_descriptor_is_preserved_and_uses_the_default_fill() {
|
||||
// A low-level peer can send this even though `ClientConfig` rejects an
|
||||
// empty fill before it reaches the wire. The clean evidence groups empty
|
||||
// with absent as "no preference", so it is neither refused nor rewritten:
|
||||
// the requested wire value comes back exactly, and the server picks its own
|
||||
// behavior behind it.
|
||||
let returned = negotiated(Some(""));
|
||||
assert_eq!(
|
||||
returned.as_deref(),
|
||||
Some(""),
|
||||
"an empty request stays empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_or_malformed_descriptor_is_replaced_by_the_default() {
|
||||
// Here the server really did change what was asked for, so the reply says
|
||||
// so and a strict client may refuse the session. The requested string is
|
||||
// not retained anywhere.
|
||||
for descriptor in [
|
||||
"bogus",
|
||||
"RAND",
|
||||
"None",
|
||||
"Pattern:aabb",
|
||||
"pattern:",
|
||||
"pattern:f",
|
||||
"pattern:abc",
|
||||
"pattern:zz",
|
||||
"pattern:0g",
|
||||
"0123456789abcdef0123456789abcdef",
|
||||
] {
|
||||
let returned = negotiated(Some(descriptor));
|
||||
assert_eq!(
|
||||
returned.as_deref(),
|
||||
Some(crate::fill::DEFAULT_FILL_DESCRIPTOR),
|
||||
"{descriptor}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_negotiates_fill_exactly_as_a_normal_open_does() {
|
||||
// Same negotiation, no session, and therefore no effective mode to retain.
|
||||
assert_eq!(no_test_negotiated(None), None);
|
||||
assert_eq!(no_test_negotiated(Some("")).as_deref(), Some(""));
|
||||
assert_eq!(
|
||||
no_test_negotiated(Some("pattern:AaBb")).as_deref(),
|
||||
Some("pattern:AaBb")
|
||||
);
|
||||
assert_eq!(no_test_negotiated(Some("none")).as_deref(), Some("none"));
|
||||
assert_eq!(
|
||||
no_test_negotiated(Some("pattern:zz")).as_deref(),
|
||||
Some(crate::fill::DEFAULT_FILL_DESCRIPTOR)
|
||||
);
|
||||
}
|
||||
|
||||
/// Params for a session requesting `descriptor` whose echo replies have exactly
|
||||
/// `payload_len` payload bytes.
|
||||
///
|
||||
/// The negotiated length is the mandatory field block plus the region wanted,
|
||||
/// asked of `irtt-proto` rather than written as a constant: the block grows with
|
||||
/// the negotiated statistics, the timestamps and authentication's 16 bytes, and
|
||||
/// a test that hard-coded an offset would silently drift from the encoder.
|
||||
fn payload_params(descriptor: Option<&str>, payload_len: usize, hmac: bool) -> Params {
|
||||
let mut params = Params {
|
||||
server_fill: requesting(descriptor).server_fill,
|
||||
..echo_params(ReceivedStats::None, StampAt::None, Clock::Unspecified, 0)
|
||||
};
|
||||
params.length = (echo_header_len(hmac, ¶ms) + payload_len) as i64;
|
||||
params
|
||||
}
|
||||
|
||||
/// The payload regions of `count` successive echo replies on one session
|
||||
/// requesting `descriptor`.
|
||||
///
|
||||
/// Every request carries [`REQUEST_FILLER`] payload bytes, so a reply that
|
||||
/// reflected its request would be visible in any of these tests rather than only
|
||||
/// in the one about it.
|
||||
fn echo_payloads(
|
||||
descriptor: Option<&str>,
|
||||
payload_len: usize,
|
||||
hmac_key: Option<&[u8]>,
|
||||
count: u32,
|
||||
) -> Vec<Vec<u8>> {
|
||||
let requested = payload_params(descriptor, payload_len, hmac_key.is_some());
|
||||
let mut core = core_for(hmac_key, ScriptedTokens::new([TOKEN_A]));
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), &requested, hmac_key);
|
||||
|
||||
// The request fills its own payload region, which is the same size as the
|
||||
// reply's: the strongest shape for "a reply never reflects its request".
|
||||
let request_payload = vec![REQUEST_FILLER; payload_len];
|
||||
(0..count)
|
||||
.map(|sequence| {
|
||||
let request = echo_request(token, sequence, &negotiated, &request_payload, hmac_key);
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.expect("an admissible echo must be answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, hmac_key);
|
||||
assert_eq!(
|
||||
packet.bytes().len() as i64,
|
||||
negotiated.length,
|
||||
"a reply is exactly the negotiated length"
|
||||
);
|
||||
assert_eq!(reply.payload.len(), payload_len, "payload region length");
|
||||
reply.payload
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The payload region of one echo reply on a session requesting `descriptor`.
|
||||
fn echo_payload(descriptor: Option<&str>, payload_len: usize) -> Vec<u8> {
|
||||
echo_payloads(descriptor, payload_len, None, 1).remove(0)
|
||||
}
|
||||
|
||||
/// `pattern` repeated to `len` bytes, starting at its first byte.
|
||||
fn repeated(pattern: &[u8], len: usize) -> Vec<u8> {
|
||||
pattern.iter().copied().cycle().take(len).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_preference_fills_with_the_default_pattern() {
|
||||
// An absent descriptor uses default bytes. 7 is not a multiple of 4, so
|
||||
// the last repeat is partial.
|
||||
assert_eq!(echo_payload(None, 7), b"irttirt".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_preference_fills_with_the_default_pattern() {
|
||||
assert_eq!(echo_payload(Some(""), 7), b"irttirt".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_or_malformed_descriptor_fills_with_the_default_pattern() {
|
||||
for descriptor in ["bogus", "RAND", "pattern:", "pattern:abc", "pattern:zz"] {
|
||||
assert_eq!(
|
||||
echo_payload(Some(descriptor), 7),
|
||||
b"irttirt".to_vec(),
|
||||
"{descriptor}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_fill_produces_a_zero_payload_and_never_the_request() {
|
||||
// `irtt-rs` policy, and a deliberate divergence: the clean evidence records
|
||||
// the reference server leaving this region as unspecified residual buffer
|
||||
// content — returning the requester's own payload, or fragments of earlier
|
||||
// traffic from another client. Zeroes disclose nothing.
|
||||
let payload = echo_payload(Some("none"), 7);
|
||||
assert_eq!(payload, vec![0; 7]);
|
||||
assert!(!payload.contains(&REQUEST_FILLER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patterns_repeat_from_their_first_byte() {
|
||||
for (descriptor, expected) in [
|
||||
("pattern:00", &[0x00][..]),
|
||||
("pattern:ff00", &[0xff, 0x00][..]),
|
||||
// The reply follows the bytes the descriptor decodes to, whatever case
|
||||
// it was written in.
|
||||
("pattern:AaBb", &[0xaa, 0xbb][..]),
|
||||
("pattern:69727474", b"irtt"),
|
||||
] {
|
||||
assert_eq!(
|
||||
echo_payload(Some(descriptor), 7),
|
||||
repeated(expected, 7),
|
||||
"{descriptor}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pattern_restarts_for_every_reply() {
|
||||
// Deliberate `irtt-rs` policy. The clean evidence records the reference
|
||||
// implementation advancing one phase continuously across replies, sessions
|
||||
// and listeners, and states that payload phase carries no protocol meaning
|
||||
// and that a conforming client must not depend on it — so this server keeps
|
||||
// no fill state at all and every reply starts at byte zero.
|
||||
let payloads = echo_payloads(Some("pattern:010203"), 5, None, 2);
|
||||
assert_eq!(payloads[0], vec![0x01, 0x02, 0x03, 0x01, 0x02]);
|
||||
assert_eq!(
|
||||
payloads[1], payloads[0],
|
||||
"the phase resets rather than continuing at 03 01 02 03 01"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sessions_fill_independently() {
|
||||
// Interleaved on one core, so a shared cursor or a global phase would show
|
||||
// up as one session's bytes drifting into the other's.
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A, TOKEN_B]));
|
||||
let a_requested = payload_params(Some("pattern:aabb"), 5, false);
|
||||
let b_requested = payload_params(Some("pattern:112233"), 5, false);
|
||||
let (a_token, a) = open_negotiated(&mut core, peer(), &a_requested, None);
|
||||
let (b_token, b) = open_negotiated(&mut core, other_peer(), &b_requested, None);
|
||||
|
||||
let mut payload_of = |endpoint, token, sequence, params: &Params| {
|
||||
let packet = core
|
||||
.handle_datagram(endpoint, &echo_request(token, sequence, params, &[], None))
|
||||
.unwrap()
|
||||
.expect("an admissible echo must be answered");
|
||||
expect_echo_reply(&packet, params, None).payload
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
payload_of(peer(), a_token, 0, &a),
|
||||
vec![0xaa, 0xbb, 0xaa, 0xbb, 0xaa]
|
||||
);
|
||||
assert_eq!(
|
||||
payload_of(other_peer(), b_token, 0, &b),
|
||||
vec![0x11, 0x22, 0x33, 0x11, 0x22]
|
||||
);
|
||||
assert_eq!(
|
||||
payload_of(peer(), a_token, 1, &a),
|
||||
vec![0xaa, 0xbb, 0xaa, 0xbb, 0xaa],
|
||||
"session A is unaffected by session B's reply"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_random_session_fills_the_whole_region() {
|
||||
// Deterministic assertions only: the region is the negotiated one and the
|
||||
// reply decodes and authenticates. What the operating system's random
|
||||
// source returns is not a property this suite can assert without flipping a
|
||||
// coin, and `fill`'s unit tests cover propagation and the failure fallback.
|
||||
for hmac_key in [None, Some(KEY)] {
|
||||
// `echo_payloads` already asserts the datagram length and the region
|
||||
// length of each reply, and decoding verifies the MAC. Nothing is
|
||||
// asserted about the byte values, deliberately: a random payload
|
||||
// containing any particular byte is chance, not behavior.
|
||||
assert_eq!(echo_payloads(Some("rand"), 7, hmac_key, 2).len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_consumes_payload_space_at_the_same_negotiated_length() {
|
||||
// One negotiated length, two field blocks: authentication's 16 bytes come
|
||||
// out of the payload region, not out of the datagram. Every remaining
|
||||
// payload byte is still filled, and the authenticated reply's MAC covers
|
||||
// those bytes — `expect_echo_reply` verifies it while decoding.
|
||||
let unauthenticated = payload_params(Some("pattern:aa"), 48, false);
|
||||
let length = unauthenticated.length;
|
||||
let authenticated = Params {
|
||||
length,
|
||||
..payload_params(Some("pattern:aa"), 0, true)
|
||||
};
|
||||
|
||||
let plain = {
|
||||
let mut core = core_for(None, ScriptedTokens::new([TOKEN_A]));
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), &unauthenticated, None);
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("an admissible echo must be answered");
|
||||
assert_eq!(packet.bytes().len() as i64, length);
|
||||
expect_echo_reply(&packet, &negotiated, None).payload
|
||||
};
|
||||
let signed = {
|
||||
let mut core = core_for(Some(KEY), ScriptedTokens::new([TOKEN_A]));
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), &authenticated, Some(KEY));
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], Some(KEY)))
|
||||
.unwrap()
|
||||
.expect("an admissible echo must be answered");
|
||||
assert_eq!(packet.bytes().len() as i64, length);
|
||||
expect_echo_reply(&packet, &negotiated, Some(KEY)).payload
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
signed.len() + irtt_proto::HMAC_SIZE,
|
||||
plain.len(),
|
||||
"authentication takes its 16 bytes from the payload region"
|
||||
);
|
||||
assert!(plain.iter().chain(&signed).all(|byte| *byte == 0xaa));
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
use irtt_proto::{verify_packet_hmac, FLAG_HMAC};
|
||||
|
||||
use super::support::{
|
||||
client_params, core_with_tokens, expect_normal_open_reply, open_request,
|
||||
open_request_with_raw_params, param_int, peer, ScriptedTokens, KEY, OTHER_KEY,
|
||||
};
|
||||
use crate::ServerConfig;
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
|
||||
/// Asserts a datagram is silently discarded, leaving no trace at all: no reply,
|
||||
/// no session, and no session token drawn.
|
||||
fn assert_discarded(config: ServerConfig, name: &str, packet: &[u8]) {
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(config, tokens.clone());
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(peer(), packet)
|
||||
.expect("a rejected datagram is not a server error");
|
||||
|
||||
assert!(reply.is_none(), "{name} must be answered with silence");
|
||||
assert_eq!(core.session_count(), 0, "{name} must create no session");
|
||||
assert_eq!(
|
||||
tokens.remaining(),
|
||||
1,
|
||||
"{name} must not consume session capacity or a token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_authenticating_server_discards_everything_that_does_not_authenticate() {
|
||||
let config = ServerConfig::default().with_hmac_key(KEY);
|
||||
|
||||
assert_discarded(
|
||||
config.clone(),
|
||||
"open with no MAC field at all",
|
||||
&open_request(&client_params(), None),
|
||||
);
|
||||
assert_discarded(
|
||||
config.clone(),
|
||||
"open signed with a different key",
|
||||
&open_request(&client_params(), Some(OTHER_KEY)),
|
||||
);
|
||||
|
||||
let mut corrupted = open_request(&client_params(), Some(KEY));
|
||||
corrupted[5] ^= 0x80;
|
||||
assert_discarded(
|
||||
config.clone(),
|
||||
"open with one bit flipped in the MAC",
|
||||
&corrupted,
|
||||
);
|
||||
|
||||
let mut zeroed = open_request(&client_params(), Some(KEY));
|
||||
zeroed[4..20].fill(0);
|
||||
assert_discarded(config.clone(), "open with an all-zero MAC", &zeroed);
|
||||
|
||||
let mut truncated = open_request(&client_params(), Some(KEY));
|
||||
truncated.drain(4..12);
|
||||
assert_discarded(
|
||||
config.clone(),
|
||||
"open with a truncated MAC field",
|
||||
&truncated,
|
||||
);
|
||||
|
||||
let mut flag_cleared = open_request(&client_params(), Some(KEY));
|
||||
flag_cleared[3] &= !FLAG_HMAC;
|
||||
assert_discarded(
|
||||
config,
|
||||
"open with a valid MAC but the HMAC flag cleared",
|
||||
&flag_cleared,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_authenticating_server_discards_authenticated_requests() {
|
||||
assert_discarded(
|
||||
ServerConfig::default(),
|
||||
"authenticated open to a server with no key",
|
||||
&open_request(&client_params(), Some(KEY)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_authenticating_server_answers_a_valid_open_with_an_authenticated_reply() {
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default().with_hmac_key(KEY),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&client_params(), Some(KEY)))
|
||||
.unwrap()
|
||||
.expect("a correctly authenticated open must be answered");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, Some(KEY));
|
||||
assert_eq!(reply.token, TOKEN_A);
|
||||
assert_ne!(
|
||||
packet.bytes()[3] & FLAG_HMAC,
|
||||
0,
|
||||
"the reply must set FLAG_HMAC"
|
||||
);
|
||||
verify_packet_hmac(KEY, packet.bytes()).expect("the reply must carry a valid MAC");
|
||||
assert_eq!(core.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_authenticating_server_answers_an_ordinary_open() {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&client_params(), None))
|
||||
.unwrap()
|
||||
.expect("an unauthenticated open must be answered");
|
||||
|
||||
expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(packet.bytes()[3] & FLAG_HMAC, 0);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_is_checked_before_parameters_are_decoded() {
|
||||
// Both of these are dropped, and neither reveals which stage rejected it.
|
||||
// The pairing is the point: bad parameters under a valid MAC still drop,
|
||||
// and a bad MAC drops without the parameters ever being parsed.
|
||||
let config = ServerConfig::default().with_hmac_key(KEY);
|
||||
let bad_params = param_int(2, 0);
|
||||
|
||||
assert_discarded(
|
||||
config.clone(),
|
||||
"authenticated open with an explicit zero duration",
|
||||
&open_request_with_raw_params(&bad_params, Some(KEY)),
|
||||
);
|
||||
assert_discarded(
|
||||
config,
|
||||
"wrongly keyed open with an explicit zero duration",
|
||||
&open_request_with_raw_params(&bad_params, Some(OTHER_KEY)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_authentication_leaves_a_live_session_untouched() {
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default().with_hmac_key(KEY),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
);
|
||||
core.handle_datagram(peer(), &open_request(&client_params(), Some(KEY)))
|
||||
.unwrap()
|
||||
.expect("the session-creating open must succeed");
|
||||
let session = core.session(TOKEN_A).cloned().expect("session must exist");
|
||||
|
||||
for bad in [
|
||||
open_request(&client_params(), None),
|
||||
open_request(&client_params(), Some(OTHER_KEY)),
|
||||
] {
|
||||
assert_eq!(core.handle_datagram(peer(), &bad).unwrap(), None);
|
||||
}
|
||||
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(core.session(TOKEN_A), Some(&session));
|
||||
}
|
||||
@@ -1,668 +0,0 @@
|
||||
//! Consuming a kernel-observed receive timestamp.
|
||||
//!
|
||||
//! A Linux listener reports the kernel's arrival time for each datagram, and an
|
||||
//! echo reply may report it as the wall-clock instant the request was received.
|
||||
//! These tests pin what that is allowed to change and — mostly — what it is
|
||||
//! not: only `recv_wall`, only under [`StampAt::Receive`] and [`StampAt::Both`],
|
||||
//! only on a clock that reports wall time, and never a monotonic value, a
|
||||
//! midpoint, or any lifecycle, rate or deadline decision.
|
||||
//!
|
||||
//! Whether the transport actually captures a timestamp is settled by the socket
|
||||
//! layer's own tests; nothing here recertifies `SCM_TIMESTAMPNS`. These drive
|
||||
//! the core with fabricated values, so they run identically on every target.
|
||||
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use irtt_proto::{Clock, Params, ReceivedStats, StampAt, TimestampFields};
|
||||
|
||||
use super::support::{
|
||||
close_request, core_with_sources, echo_at, echo_params, echo_request,
|
||||
expect_closing_echo_reply, expect_echo_reply, expect_normal_open_reply, manual_core,
|
||||
open_negotiated, open_request, peer, sample, ManualClock, ScriptedClock, ScriptedTokens,
|
||||
};
|
||||
use crate::{
|
||||
clock::ClockSample,
|
||||
core::{preferred_receive_wall_ns, OutboundDatagram, ServerCore, MAX_KERNEL_RX_LAG},
|
||||
ServerConfig,
|
||||
};
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
const MS: i64 = 1_000_000;
|
||||
const SECOND: i64 = 1_000 * MS;
|
||||
|
||||
/// The lifecycle instant the session-creating open consumes.
|
||||
fn open_sample() -> ClockSample {
|
||||
sample(900_000, 9_000)
|
||||
}
|
||||
|
||||
/// The core's own receive reading for the echo every matrix test sends.
|
||||
fn received() -> ClockSample {
|
||||
sample(1_000_000, 10_000)
|
||||
}
|
||||
|
||||
/// The core's send reading for the same echo.
|
||||
fn sent() -> ClockSample {
|
||||
sample(1_200_000, 10_300)
|
||||
}
|
||||
|
||||
/// A kernel arrival reading 100 µs before [`received`], which is the ordinary
|
||||
/// shape: earlier than the server's own sample and well inside the bound.
|
||||
fn kernel_rx() -> SystemTime {
|
||||
at_epoch_ns(900_000)
|
||||
}
|
||||
|
||||
/// The [`SystemTime`] `ns` nanoseconds from the Unix epoch, before it when
|
||||
/// negative.
|
||||
fn at_epoch_ns(ns: i64) -> SystemTime {
|
||||
let magnitude = Duration::from_nanos(ns.unsigned_abs());
|
||||
if ns.is_negative() {
|
||||
UNIX_EPOCH - magnitude
|
||||
} else {
|
||||
UNIX_EPOCH + magnitude
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends one echo carrying `kernel_rx_timestamp` through the runtime's entry
|
||||
/// point and returns the reply's timestamp fields.
|
||||
fn echo_timestamps(
|
||||
stamp_at: StampAt,
|
||||
clock: Clock,
|
||||
kernel_rx_timestamp: Option<SystemTime>,
|
||||
) -> TimestampFields {
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([open_sample(), received(), sent()]),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, stamp_at, clock, /* length */ 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None);
|
||||
|
||||
let packet = core
|
||||
.handle_received_datagram(
|
||||
peer(),
|
||||
&echo_request(token, 0, &negotiated, &[], None),
|
||||
kernel_rx_timestamp,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{stamp_at:?} on {clock:?} must be answered"));
|
||||
expect_echo_reply(&packet, &negotiated, None).timestamps
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preferred_receive_wall_is_the_kernel_reading_only_when_it_is_plausible() {
|
||||
let userspace = received();
|
||||
let userspace_wall = userspace.wall_ns;
|
||||
let bound_ns = i64::try_from(MAX_KERNEL_RX_LAG.as_nanos()).unwrap();
|
||||
|
||||
// No observation at all: the server's own reading, which is the whole of
|
||||
// the pre-existing behavior and what every non-Linux target sees.
|
||||
assert_eq!(preferred_receive_wall_ns(None, userspace), userspace_wall);
|
||||
|
||||
// Earlier, and inside the bound.
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(kernel_rx()), userspace),
|
||||
900_000
|
||||
);
|
||||
|
||||
// Equal is accepted: the two clocks may simply have read the same
|
||||
// nanosecond.
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall)), userspace),
|
||||
userspace_wall
|
||||
);
|
||||
|
||||
// Later than the sample that observed the same datagram is impossible, so
|
||||
// it is metadata this server will not measure against. This is the shape a
|
||||
// backward wall step between the kernel's reading and the core's sample
|
||||
// produces.
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall + 1)), userspace),
|
||||
userspace_wall
|
||||
);
|
||||
|
||||
// The lag bound is inclusive.
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall - bound_ns)), userspace),
|
||||
userspace_wall - bound_ns
|
||||
);
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall - bound_ns - 1)), userspace),
|
||||
userspace_wall
|
||||
);
|
||||
|
||||
// A pre-epoch reading is representable as a negative value and is judged
|
||||
// by exactly the same rule, so against a host clock set anywhere near the
|
||||
// present it simply lags far too much. Nothing here special-cases the sign.
|
||||
let present = sample(1_700_000_000 * SECOND, 10_000);
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(UNIX_EPOCH - Duration::from_nanos(1)), present),
|
||||
present.wall_ns
|
||||
);
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(at_epoch_ns(present.wall_ns - bound_ns)), present),
|
||||
present.wall_ns - bound_ns,
|
||||
"and a realistic wall clock accepts a reading at the bound like any other"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unrepresentable_kernel_reading_falls_back_without_panicking() {
|
||||
// Far outside the wire's signed-nanosecond field in both directions, and
|
||||
// judged against a userspace sample at the field's own limits, so nothing
|
||||
// here can be rescued by a lucky comparison. A conversion that saturated
|
||||
// instead of reporting failure would turn one of these into an ordinary
|
||||
// instant.
|
||||
let beyond = Duration::from_nanos(u64::MAX) + Duration::from_secs(1);
|
||||
|
||||
for userspace_wall in [0, i64::MAX, i64::MIN] {
|
||||
let userspace = sample(userspace_wall, 10_000);
|
||||
for kernel in [UNIX_EPOCH + beyond, UNIX_EPOCH - beyond] {
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(kernel), userspace),
|
||||
userspace_wall,
|
||||
"an unrepresentable reading is never a measurement endpoint"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_receive_wall_field_can_come_from_the_kernel() {
|
||||
// The whole StampAt x Clock matrix, each asserted as one struct so that a
|
||||
// field appearing where it should not is as loud as a wrong instant. Every
|
||||
// expectation below is what the same session emits with no kernel metadata,
|
||||
// except `recv_wall` under Receive and Both on a wall-reporting clock.
|
||||
for (stamp_at, clock, expected) in [
|
||||
// A: the kernel reading is the reported arrival time.
|
||||
(
|
||||
StampAt::Receive,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
recv_wall: Some(900_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
// B: and the monotonic half of the same arrival stays the core's own.
|
||||
// The two fields deliberately name different physical instants.
|
||||
(
|
||||
StampAt::Receive,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
recv_wall: Some(900_000),
|
||||
recv_mono: Some(10_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
// C: nothing about the departure side moves, in either domain.
|
||||
(
|
||||
StampAt::Both,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
recv_wall: Some(900_000),
|
||||
recv_mono: Some(10_000),
|
||||
send_wall: Some(1_200_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
// D: wall only. The interval between these two now includes the
|
||||
// kernel-to-userspace part of the server's residency, which is the one
|
||||
// intended behavior change.
|
||||
(
|
||||
StampAt::Both,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
recv_wall: Some(900_000),
|
||||
send_wall: Some(1_200_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
// E: no wall field exists, so there is nothing to improve.
|
||||
(
|
||||
StampAt::Both,
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
recv_mono: Some(10_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Receive,
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
recv_mono: Some(10_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
// F: a reply reporting only its departure never learns of an arrival
|
||||
// observation.
|
||||
(
|
||||
StampAt::Send,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
send_wall: Some(1_200_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Send,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
send_wall: Some(1_200_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
// G: no timestamp at all.
|
||||
(StampAt::None, Clock::Both, TimestampFields::default()),
|
||||
// H and I: a midpoint is the mean of the core's own paired samples and
|
||||
// stays exactly that. Substituting only the wall arrival endpoint would
|
||||
// pull `midpoint_wall` to 1_050_000 — half the kernel-to-userspace
|
||||
// delay early — improving apparent upstream delay by precisely as much
|
||||
// as it worsened apparent downstream delay, and leaving the two
|
||||
// midpoint fields describing different midpoints.
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(1_100_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(1_100_000),
|
||||
midpoint_mono: Some(10_150),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
midpoint_mono: Some(10_150),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
echo_timestamps(stamp_at, clock, Some(kernel_rx())),
|
||||
expected,
|
||||
"{stamp_at:?} on {clock:?} with a plausible kernel reading"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_datagram_with_no_kernel_reading_emits_what_it_always_did() {
|
||||
// The same matrix without metadata, which is every non-Linux target and
|
||||
// every Linux listener whose kernel offered nothing. `recv_wall` is the
|
||||
// core's own 1_000_000 throughout, and the two paths are otherwise the same
|
||||
// implementation.
|
||||
for (stamp_at, clock, expected) in [
|
||||
(
|
||||
StampAt::Receive,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Both,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000_000),
|
||||
recv_mono: Some(10_000),
|
||||
send_wall: Some(1_200_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(1_100_000),
|
||||
midpoint_mono: Some(10_150),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
echo_timestamps(stamp_at, clock, None),
|
||||
expected,
|
||||
"{stamp_at:?} on {clock:?} without kernel metadata"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_implausible_kernel_reading_falls_back_to_the_servers_own_sample() {
|
||||
// Case A of the wall-step analysis: the wall clock stepped *backwards*
|
||||
// between the kernel's reading and the core's sample, so the kernel value
|
||||
// is later than a sample that observed the same datagram. Case B: it
|
||||
// stepped forwards so far that the reading lags beyond the bound. Neither
|
||||
// is a reason to drop the datagram or to answer differently in any other
|
||||
// respect.
|
||||
let userspace_wall = received().wall_ns;
|
||||
for kernel in [
|
||||
at_epoch_ns(userspace_wall + 1),
|
||||
at_epoch_ns(userspace_wall - i64::try_from(MAX_KERNEL_RX_LAG.as_nanos()).unwrap() - 1),
|
||||
] {
|
||||
assert_eq!(
|
||||
echo_timestamps(StampAt::Both, Clock::Both, Some(kernel)),
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000_000),
|
||||
recv_mono: Some(10_000),
|
||||
send_wall: Some(1_200_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backward_wall_step_after_receive_never_reports_receive_after_send() {
|
||||
// The kernel's reading and the send sample no longer come from one paired
|
||||
// source, so the ordering rule has to reach the reported arrival value on
|
||||
// its own: an accepted kernel reading of 900_000, against a send sample the
|
||||
// wall clock stepped back to 850_000, must still settle on the send
|
||||
// reading.
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([
|
||||
open_sample(),
|
||||
sample(1_000_000, 10_000),
|
||||
sample(850_000, 10_300),
|
||||
]),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None);
|
||||
|
||||
let packet = core
|
||||
.handle_received_datagram(
|
||||
peer(),
|
||||
&echo_request(token, 0, &negotiated, &[], None),
|
||||
Some(at_epoch_ns(900_000)),
|
||||
)
|
||||
.unwrap()
|
||||
.expect("a backward wall step must not stop the echo being answered");
|
||||
let timestamps = expect_echo_reply(&packet, &negotiated, None).timestamps;
|
||||
|
||||
assert_eq!(
|
||||
timestamps,
|
||||
TimestampFields {
|
||||
recv_wall: Some(850_000),
|
||||
recv_mono: Some(10_000),
|
||||
send_wall: Some(850_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
"the reported arrival wall value is held back to the send reading"
|
||||
);
|
||||
assert!(timestamps.recv_wall <= timestamps.send_wall);
|
||||
// The monotonic pair is ordered by its own source and owes nothing to what
|
||||
// the wall clock did.
|
||||
assert_eq!(timestamps.recv_mono, Some(10_000));
|
||||
assert!(timestamps.recv_mono <= timestamps.send_mono);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_public_core_path_emits_exactly_the_userspace_receive_wall() {
|
||||
// A direct `ServerCore` user has no transport metadata and must see the
|
||||
// behavior it always did, from the same implementation the runtime path
|
||||
// uses.
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([open_sample(), received(), sent()]),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("the public path must answer an ordinary echo");
|
||||
|
||||
assert_eq!(
|
||||
expect_echo_reply(&packet, &negotiated, None).timestamps,
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000_000),
|
||||
recv_mono: Some(10_000),
|
||||
send_wall: Some(1_200_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// One echo's outcome, in the terms a lifecycle test cares about: whether it was
|
||||
/// answered at all, what it counted, and how much session state the server still
|
||||
/// holds afterwards.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Outcome {
|
||||
answered: bool,
|
||||
closing: bool,
|
||||
recv_count: Option<u32>,
|
||||
recv_window: Option<u64>,
|
||||
session_count: usize,
|
||||
recv_wall: Option<i64>,
|
||||
}
|
||||
|
||||
fn outcome_of(
|
||||
core: &ServerCore,
|
||||
reply: Option<OutboundDatagram>,
|
||||
params: &Params,
|
||||
closing: bool,
|
||||
) -> Outcome {
|
||||
let Some(reply) = reply else {
|
||||
return Outcome {
|
||||
answered: false,
|
||||
closing: false,
|
||||
recv_count: None,
|
||||
recv_window: None,
|
||||
session_count: core.session_count(),
|
||||
recv_wall: None,
|
||||
};
|
||||
};
|
||||
let decoded = if closing {
|
||||
expect_closing_echo_reply(&reply, params, None)
|
||||
} else {
|
||||
expect_echo_reply(&reply, params, None)
|
||||
};
|
||||
Outcome {
|
||||
answered: true,
|
||||
closing,
|
||||
recv_count: decoded.recv_count,
|
||||
recv_window: decoded.recv_window,
|
||||
session_count: core.session_count(),
|
||||
recv_wall: decoded.timestamps.recv_wall,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends one echo as it would arrive at `at_ns`, with the kernel having observed
|
||||
/// it 1 µs earlier when `with_kernel_rx` is set.
|
||||
fn echo_at_with_kernel_rx(
|
||||
core: &mut ServerCore,
|
||||
clock: &ManualClock,
|
||||
at_ns: i64,
|
||||
token: u64,
|
||||
sequence: u32,
|
||||
params: &Params,
|
||||
with_kernel_rx: bool,
|
||||
) -> Option<OutboundDatagram> {
|
||||
if !with_kernel_rx {
|
||||
return echo_at(core, clock, at_ns, token, sequence, params, None);
|
||||
}
|
||||
clock.set(at_ns);
|
||||
core.handle_received_datagram(
|
||||
peer(),
|
||||
&echo_request(token, sequence, params, &[], None),
|
||||
Some(at_epoch_ns(at_ns - 1_000)),
|
||||
)
|
||||
.expect("a rejected or rate-limited echo is not an internal error")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_metadata_changes_no_lifecycle_rate_or_deadline_decision() {
|
||||
// Two identical runs over one scripted arrival schedule, one with a
|
||||
// plausible kernel reading on every echo and one with none. The schedule
|
||||
// crosses a rate-limit burst, a refill, an idle refresh that only a
|
||||
// rate-limited request can have produced, and the maximum-duration
|
||||
// boundary — and the two runs must agree on every one of those outcomes.
|
||||
//
|
||||
// The session reports its receive wall, so the one permitted difference is
|
||||
// visible rather than merely assumed absent.
|
||||
let params = Params {
|
||||
interval_ns: 100 * MS,
|
||||
..echo_params(ReceivedStats::Both, StampAt::Receive, Clock::Wall, 0)
|
||||
};
|
||||
let config = ServerConfig::default()
|
||||
.with_min_send_interval(Duration::from_millis(100))
|
||||
.with_burst_allowance(2)
|
||||
.with_idle_timeout(Duration::from_secs(2))
|
||||
.with_max_test_duration(Duration::from_secs(1));
|
||||
|
||||
let run = |with_kernel_rx: bool| {
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
config.clone(),
|
||||
¶ms,
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
let mut outcomes = Vec::new();
|
||||
let mut send = |core: &mut ServerCore, at_ns: i64, sequence: u32, closing: bool| {
|
||||
let reply = echo_at_with_kernel_rx(
|
||||
core,
|
||||
&clock,
|
||||
at_ns,
|
||||
token,
|
||||
sequence,
|
||||
&negotiated,
|
||||
with_kernel_rx,
|
||||
);
|
||||
outcomes.push(outcome_of(core, reply, &negotiated, closing));
|
||||
};
|
||||
|
||||
// A burst of three against an allowance of two: the third is
|
||||
// rate-limited, answers nothing and advances no statistic, but does
|
||||
// refresh the idle deadline.
|
||||
send(&mut core, 10 * MS, 0, false);
|
||||
send(&mut core, 10 * MS, 1, false);
|
||||
send(&mut core, 10 * MS, 2, false);
|
||||
// Past the refill, and out of order, so the window transition is
|
||||
// exercised too.
|
||||
send(&mut core, 200 * MS, 4, false);
|
||||
send(&mut core, 400 * MS, 3, false);
|
||||
// The idle timeout is 2 s and the last activity was at 400 ms, so this
|
||||
// request at 2.3 s finds the session alive only because activity — not
|
||||
// the open — is what the deadline runs from.
|
||||
send(&mut core, 2_300 * MS, 5, false);
|
||||
// The maximum-duration deadline is the first served echo plus one
|
||||
// second plus the two-second grace: 10 ms + 3 s. This crosses it and
|
||||
// carries the close, releasing the session.
|
||||
send(&mut core, 3_100 * MS, 6, true);
|
||||
// And every later request is an unknown token.
|
||||
send(&mut core, 3_200 * MS, 7, false);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
outcomes
|
||||
};
|
||||
|
||||
let without = run(false);
|
||||
let with = run(true);
|
||||
|
||||
assert_eq!(
|
||||
without.len(),
|
||||
8,
|
||||
"the schedule must actually have run to the end"
|
||||
);
|
||||
for (index, (without, with)) in without.iter().zip(&with).enumerate() {
|
||||
assert_eq!(
|
||||
(
|
||||
with.answered,
|
||||
with.closing,
|
||||
with.recv_count,
|
||||
with.recv_window,
|
||||
with.session_count
|
||||
),
|
||||
(
|
||||
without.answered,
|
||||
without.closing,
|
||||
without.recv_count,
|
||||
without.recv_window,
|
||||
without.session_count
|
||||
),
|
||||
"echo {index}: kernel metadata may not change any same-host decision"
|
||||
);
|
||||
}
|
||||
|
||||
// The permitted difference, and proof the runs were not simply identical:
|
||||
// every answered reply reported the kernel's reading rather than the
|
||||
// server's own, which is 1 µs later.
|
||||
let reported: Vec<_> = with
|
||||
.iter()
|
||||
.filter_map(|outcome| outcome.recv_wall)
|
||||
.collect();
|
||||
let userspace: Vec<_> = without
|
||||
.iter()
|
||||
.filter_map(|outcome| outcome.recv_wall)
|
||||
.collect();
|
||||
assert_eq!(reported.len(), 6, "six of the eight echoes were answered");
|
||||
assert_eq!(
|
||||
reported,
|
||||
userspace
|
||||
.iter()
|
||||
.map(|wall| wall - 1_000)
|
||||
.collect::<Vec<_>>(),
|
||||
"each answered reply reported the kernel's arrival reading"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_or_close_ignores_kernel_metadata_entirely() {
|
||||
// Neither emits a receive timestamp, and neither may take a different
|
||||
// lifecycle decision for having been observed. The clock script is what
|
||||
// pins that: an open consumes exactly its own lifecycle sample whatever the
|
||||
// transport reported.
|
||||
let clock = ScriptedClock::new([open_sample(), sample(1_000_000, 10_000)]);
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
clock.clone(),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0);
|
||||
|
||||
let packet = core
|
||||
.handle_received_datagram(peer(), &open_request(¶ms, None), Some(kernel_rx()))
|
||||
.unwrap()
|
||||
.expect("an open must be answered");
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(
|
||||
clock.remaining(),
|
||||
1,
|
||||
"an open consumes one lifecycle sample"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
|
||||
let closed: SocketAddr = peer();
|
||||
assert!(core
|
||||
.handle_received_datagram(closed, &close_request(reply.token, None), Some(kernel_rx()))
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(core.session_count(), 0, "the close released its session");
|
||||
assert_eq!(
|
||||
clock.remaining(),
|
||||
0,
|
||||
"and a close consumes one of its own, whatever the transport observed"
|
||||
);
|
||||
}
|
||||
@@ -1,621 +0,0 @@
|
||||
//! Session lifetime: idle expiry and the maximum-duration close.
|
||||
//!
|
||||
//! Time is a hand-moved monotonic clock, so every deadline here is exact and no
|
||||
//! test sleeps, tolerates or races.
|
||||
//!
|
||||
//! Two of the behaviors pinned here are deliberate `irtt-rs` policy rather than
|
||||
//! observed reference behavior, and each says so where it is tested: expiry is
|
||||
//! immediate at the configured deadline with no reply of any kind, and the
|
||||
//! deadline covers a session that has never carried an echo request. The clean
|
||||
//! specification records the reference server's five-second grace, its final
|
||||
//! lazy-release reply and its never-expiring unused sessions as upstream policy,
|
||||
//! states that expiry is entirely a server's own business, and recommends
|
||||
//! against reproducing the resource leak. The one interoperability constraint is
|
||||
//! negative — a server must never *signal* expiry — and silence satisfies it.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_proto::{echo_header_len, Clock, Params, ReceivedStats, ServerFill, StampAt, FLAG_CLOSE};
|
||||
|
||||
use super::support::{
|
||||
close_request, core_with_sources, echo_at, echo_params, echo_request,
|
||||
expect_closing_echo_reply, expect_echo_reply, expect_no_test_reply, manual_core,
|
||||
no_test_request, open_request, other_peer, peer, ManualClock, ScriptedTokens, KEY,
|
||||
};
|
||||
use crate::{OutboundDatagram, ServerConfig};
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
||||
|
||||
const MS: i64 = 1_000_000;
|
||||
const SECOND: i64 = 1_000_000_000;
|
||||
|
||||
/// Params reporting both statistics and requesting `interval_ns`.
|
||||
fn params_at(interval_ns: i64) -> Params {
|
||||
Params {
|
||||
interval_ns,
|
||||
..echo_params(
|
||||
ReceivedStats::Both,
|
||||
StampAt::None,
|
||||
Clock::Unspecified,
|
||||
/* length */ 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A configuration that expires after `idle` and rate-limits nothing.
|
||||
fn idle_after(idle: Duration) -> ServerConfig {
|
||||
ServerConfig::default()
|
||||
.with_idle_timeout(idle)
|
||||
.with_min_send_interval(Duration::ZERO)
|
||||
}
|
||||
|
||||
fn count_of(reply: Option<OutboundDatagram>, params: &Params) -> Option<u32> {
|
||||
let reply = reply?;
|
||||
expect_echo_reply(reply, params, None).recv_count
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_that_never_carried_an_echo_still_expires() {
|
||||
// `irtt-rs` policy. The reference server starts the idle clock at the first
|
||||
// echo request, so a session left unused never expires at all; a session is
|
||||
// created by one unauthenticated datagram and opens are never deduplicated,
|
||||
// which makes that an unbounded-state hazard rather than a compatibility
|
||||
// feature. Here the deadline runs from the open.
|
||||
//
|
||||
// A one-session table makes the reclamation externally visible: the second
|
||||
// open can only succeed if the first session was released before capacity
|
||||
// was judged.
|
||||
let config = idle_after(Duration::from_secs(2)).with_max_sessions(1);
|
||||
let clock = ManualClock::at(0);
|
||||
let mut core = core_with_sources(
|
||||
config,
|
||||
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
|
||||
clock.clone(),
|
||||
);
|
||||
let requested = params_at(SECOND);
|
||||
|
||||
clock.set(0);
|
||||
assert!(core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.is_some());
|
||||
assert_eq!(core.session_count(), 1);
|
||||
|
||||
// Still inside the deadline: the table is full and a second open is refused.
|
||||
clock.set(2 * SECOND - 1);
|
||||
assert_eq!(
|
||||
core.handle_datagram(other_peer(), &open_request(&requested, None))
|
||||
.unwrap(),
|
||||
None,
|
||||
"a live session must still occupy the table"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
|
||||
// At the deadline the unused session is reclaimed, before capacity is
|
||||
// judged, so the new open fits.
|
||||
clock.set(2 * SECOND);
|
||||
assert!(
|
||||
core.handle_datagram(other_peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"an expired session must not deny capacity to a new one"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1, "one replaced the other");
|
||||
|
||||
// And the reclaimed token names nothing.
|
||||
assert!(
|
||||
echo_at(&mut core, &clock, 2 * SECOND, TOKEN_A, 0, &requested, None).is_none(),
|
||||
"the released token must be unusable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maintenance_reclaims_an_idle_session_without_traffic() {
|
||||
let clock = ManualClock::at(0);
|
||||
let mut core = core_with_sources(
|
||||
idle_after(Duration::from_secs(2)),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
clock.clone(),
|
||||
);
|
||||
|
||||
assert!(core
|
||||
.handle_datagram(peer(), &open_request(¶ms_at(SECOND), None))
|
||||
.unwrap()
|
||||
.is_some());
|
||||
assert_eq!(core.session_count(), 1);
|
||||
|
||||
clock.set(2 * SECOND);
|
||||
core.maintain();
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_served_echo_moves_the_idle_deadline_along() {
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
idle_after(Duration::from_secs(2)),
|
||||
¶ms_at(SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
|
||||
// Inside the deadline the open set, and past the one it would have had.
|
||||
assert_eq!(
|
||||
count_of(
|
||||
echo_at(&mut core, &clock, SECOND, token, 0, &negotiated, None),
|
||||
&negotiated
|
||||
),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
count_of(
|
||||
echo_at(&mut core, &clock, 2_900 * MS, token, 1, &negotiated, None),
|
||||
&negotiated
|
||||
),
|
||||
Some(2),
|
||||
"the echo at 1 s moved the deadline from 2 s to 3 s"
|
||||
);
|
||||
|
||||
// Exactly at the deadline the last served echo set: gone, and silently.
|
||||
assert!(
|
||||
echo_at(&mut core, &clock, 4_900 * MS, token, 2, &negotiated, None).is_none(),
|
||||
"the deadline is inclusive"
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_expired_session_answers_nothing_at_all() {
|
||||
// `irtt-rs` policy, and the reason this is its own test. The reference
|
||||
// server was observed answering the first otherwise-serviceable echo to
|
||||
// reach an expired session — a final lazy-release reply — and only dropping
|
||||
// the one after it, with the release boundary a further five seconds past
|
||||
// the configured timeout. `irtt-rs` reproduces neither: at the deadline the
|
||||
// session is simply gone, and the client observes silence, which is the only
|
||||
// thing the specification actually requires of expiry.
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
idle_after(Duration::from_secs(2)),
|
||||
¶ms_at(SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
count_of(
|
||||
echo_at(
|
||||
&mut core,
|
||||
&clock,
|
||||
2 * SECOND - 1,
|
||||
token,
|
||||
0,
|
||||
&negotiated,
|
||||
None
|
||||
),
|
||||
&negotiated
|
||||
),
|
||||
Some(1),
|
||||
"one nanosecond short of the deadline is still served"
|
||||
);
|
||||
|
||||
// Two seconds after that echo, to the nanosecond.
|
||||
assert!(
|
||||
echo_at(
|
||||
&mut core,
|
||||
&clock,
|
||||
4 * SECOND - 1,
|
||||
token,
|
||||
1,
|
||||
&negotiated,
|
||||
None
|
||||
)
|
||||
.is_none(),
|
||||
"no final reply: an expired session is simply gone"
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert!(
|
||||
echo_at(&mut core, &clock, 4 * SECOND, token, 2, &negotiated, None).is_none(),
|
||||
"and stays gone"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rate_limited_echo_keeps_its_session_alive_without_counting() {
|
||||
// The one drop class the clean evidence records as refreshing the idle
|
||||
// deadline. It was established against every other tested class, which does
|
||||
// not, so `irtt-rs` keeps it.
|
||||
//
|
||||
// A 2 s minimum interval against an 8 s idle timeout negotiates 2 s and
|
||||
// refills every 2 s; a burst of one then makes the echo at 8.5 s — 1.5 s
|
||||
// after the previous one — rate-limited.
|
||||
let config = ServerConfig::default()
|
||||
.with_min_send_interval(Duration::from_secs(2))
|
||||
.with_idle_timeout(Duration::from_secs(8))
|
||||
.with_burst_allowance(1);
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
config,
|
||||
¶ms_at(2 * SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
assert_eq!(negotiated.interval_ns, 2 * SECOND);
|
||||
|
||||
// Served, spending the burst. The idle deadline is now 15 s.
|
||||
assert_eq!(
|
||||
count_of(
|
||||
echo_at(&mut core, &clock, 7 * SECOND, token, 0, &negotiated, None),
|
||||
&negotiated
|
||||
),
|
||||
Some(1)
|
||||
);
|
||||
|
||||
// Rate-limited, 1.5 s later: no reply, and the deadline moves to 16.5 s.
|
||||
assert!(
|
||||
echo_at(&mut core, &clock, 8_500 * MS, token, 1, &negotiated, None).is_none(),
|
||||
"no allowance, so no reply"
|
||||
);
|
||||
|
||||
// Past the deadline the served echo alone would have set, and short of the
|
||||
// one the rate-limited echo set.
|
||||
assert_eq!(
|
||||
count_of(
|
||||
echo_at(&mut core, &clock, 16 * SECOND, token, 2, &negotiated, None),
|
||||
&negotiated
|
||||
),
|
||||
Some(2),
|
||||
"the rate-limited echo kept the session alive, and was not counted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rejected_echo_does_not_move_the_idle_deadline() {
|
||||
// Two representative classes; the admission rules themselves are pinned by
|
||||
// the echo and hmac tests, and are not repeated here.
|
||||
let config = idle_after(Duration::from_secs(2)).with_max_packet_length(64);
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
config,
|
||||
¶ms_at(SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
|
||||
let oversized = echo_request(
|
||||
token,
|
||||
0,
|
||||
&Params {
|
||||
length: 65,
|
||||
..negotiated.clone()
|
||||
},
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert_eq!(oversized.len(), 65);
|
||||
|
||||
clock.set(2 * SECOND - 1);
|
||||
assert_eq!(core.handle_datagram(peer(), &oversized).unwrap(), None);
|
||||
assert_eq!(
|
||||
core.handle_datagram(
|
||||
other_peer(),
|
||||
&echo_request(token, 0, &negotiated, &[], None)
|
||||
)
|
||||
.unwrap(),
|
||||
None,
|
||||
"a foreign endpoint is refused whatever the session's state"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1, "neither released it early either");
|
||||
|
||||
// The deadline the open set, unmoved.
|
||||
assert!(
|
||||
echo_at(&mut core, &clock, 2 * SECOND, token, 0, &negotiated, None).is_none(),
|
||||
"a rejected echo must not have extended the session"
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_authenticated_request_reclaims_expired_sessions_including_a_no_test_open() {
|
||||
// `irtt-rs` policy. Expiry is a decision about the table, so it runs before
|
||||
// a request is dispatched and does not depend on what that request is for.
|
||||
// The reference server was observed releasing an expired session on a burst
|
||||
// of ordinary opens but not on no-test ones; a client cannot tell, since
|
||||
// expiry is never signalled, and the distinction is not worth reproducing.
|
||||
//
|
||||
// A no-test open still creates nothing of its own, which the token source
|
||||
// and the session count both check here.
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let clock = ManualClock::at(0);
|
||||
let mut core = core_with_sources(
|
||||
idle_after(Duration::from_secs(2)),
|
||||
tokens.clone(),
|
||||
clock.clone(),
|
||||
);
|
||||
let requested = params_at(SECOND);
|
||||
|
||||
assert!(core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.is_some());
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(tokens.remaining(), 0);
|
||||
|
||||
clock.set(2 * SECOND);
|
||||
let packet = core
|
||||
.handle_datagram(other_peer(), &no_test_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("a no-test open is answered whatever the table holds");
|
||||
expect_no_test_reply(&packet, None);
|
||||
|
||||
assert_eq!(
|
||||
core.session_count(),
|
||||
0,
|
||||
"the expired session was reclaimed, and no-test created none of its own"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_client_close_still_releases_its_session_without_a_reply() {
|
||||
// Unchanged by this slice, and worth pinning next to the close the *server*
|
||||
// sends: a client close is answered with nothing at all.
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
idle_after(Duration::from_secs(60)),
|
||||
¶ms_at(SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
assert!(echo_at(&mut core, &clock, SECOND, token, 0, &negotiated, None).is_some());
|
||||
|
||||
clock.set(2 * SECOND);
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &close_request(token, None))
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
|
||||
/// A configuration whose sessions run for `maximum` and are never idle-expired
|
||||
/// within a test's timescale.
|
||||
fn max_duration_of(maximum: Duration) -> ServerConfig {
|
||||
ServerConfig::default()
|
||||
.with_max_test_duration(maximum)
|
||||
.with_idle_timeout(Duration::from_secs(600))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_maximum_duration_deadline_starts_at_the_first_served_echo() {
|
||||
// Measured behavior: opening a session well before its first echo does not
|
||||
// move the deadline, and neither a rejected nor a rate-limited first request
|
||||
// starts it.
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
max_duration_of(Duration::from_secs(1)),
|
||||
¶ms_at(SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
negotiated.duration_ns, SECOND,
|
||||
"the requested duration was reduced to the maximum"
|
||||
);
|
||||
|
||||
// Ten seconds after the open — far past maximum + grace, had the open
|
||||
// started the clock.
|
||||
let packet = echo_at(&mut core, &clock, 10 * SECOND, token, 0, &negotiated, None)
|
||||
.expect("the first echo must be answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
assert_eq!(reply.recv_count, Some(1));
|
||||
|
||||
// The deadline is 13 s: this echo, three seconds after the open but two
|
||||
// before the origin's own deadline, is ordinary.
|
||||
let packet = echo_at(&mut core, &clock, 12_999 * MS, token, 1, &negotiated, None)
|
||||
.expect("an echo inside the deadline must be answered");
|
||||
assert_eq!(
|
||||
expect_echo_reply(&packet, &negotiated, None).recv_count,
|
||||
Some(2)
|
||||
);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_echo_past_the_maximum_duration_carries_close_and_ends_the_session() {
|
||||
// The session negotiates a fill and a payload region, so this covers the
|
||||
// close's payload as well: a server close is an ordinary echo reply with a
|
||||
// flag added, and it fills exactly as the session's other replies do.
|
||||
let requested = Params {
|
||||
server_fill: Some(ServerFill {
|
||||
value: "pattern:aabb".to_owned(),
|
||||
}),
|
||||
length: (echo_header_len(false, ¶ms_at(SECOND)) + 5) as i64,
|
||||
..params_at(SECOND)
|
||||
};
|
||||
let patterned = vec![0xaa, 0xbb, 0xaa, 0xbb, 0xaa];
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
max_duration_of(Duration::from_secs(1)),
|
||||
&requested,
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(echo_at(&mut core, &clock, 10 * SECOND, token, 0, &negotiated, None).is_some());
|
||||
// One nanosecond short of maximum + 2 s grace, measured from that echo.
|
||||
let packet = echo_at(
|
||||
&mut core,
|
||||
&clock,
|
||||
13 * SECOND - 1,
|
||||
token,
|
||||
1,
|
||||
&negotiated,
|
||||
None,
|
||||
)
|
||||
.expect("short of the deadline must be answered normally");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
assert_eq!(reply.flags & FLAG_CLOSE, 0, "not yet");
|
||||
assert_eq!(reply.payload, patterned, "an ordinary reply's fill");
|
||||
|
||||
// At the deadline exactly.
|
||||
let packet = echo_at(&mut core, &clock, 13 * SECOND, token, 2, &negotiated, None)
|
||||
.expect("the deadline-crossing echo is answered, with Close");
|
||||
let reply = expect_closing_echo_reply(&packet, &negotiated, None);
|
||||
assert_eq!(reply.token, token, "an ordinary reply in every other way");
|
||||
assert_eq!(reply.sequence, 2, "the triggering sequence, copied through");
|
||||
assert_eq!(
|
||||
(reply.recv_count, reply.recv_window),
|
||||
(Some(3), Some(0x7)),
|
||||
"the triggering request is included in the statistics it reports"
|
||||
);
|
||||
assert_eq!(
|
||||
reply.payload, patterned,
|
||||
"the close keeps the session's fill; it is not a special packet"
|
||||
);
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
irtt_proto::echo_packet_len(false, &negotiated).unwrap(),
|
||||
"the negotiated layout, unchanged by the flag"
|
||||
);
|
||||
assert_eq!(
|
||||
i64::from(packet.traffic_class()),
|
||||
negotiated.dscp,
|
||||
"a server close is an echo reply and keeps the session's marking"
|
||||
);
|
||||
|
||||
// The token is unusable from that reply onward.
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert!(
|
||||
echo_at(
|
||||
&mut core,
|
||||
&clock,
|
||||
13 * SECOND + 1,
|
||||
token,
|
||||
3,
|
||||
&negotiated,
|
||||
None
|
||||
)
|
||||
.is_none(),
|
||||
"every later request is an unknown token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rate_limited_echo_at_the_deadline_defers_the_close_rather_than_losing_it() {
|
||||
// The measured dropped-trigger case, and the reason rate allowance is
|
||||
// judged before the deadline: a deadline-crossing echo with no allowance is
|
||||
// silently dropped, the session stays usable, and the next echo that *is*
|
||||
// served carries the close.
|
||||
//
|
||||
// A 5 s minimum interval against a 600 s idle timeout negotiates 5 s and
|
||||
// refills every 5 s, so the echo three seconds after the first is limited.
|
||||
let config = max_duration_of(Duration::from_secs(1))
|
||||
.with_min_send_interval(Duration::from_secs(5))
|
||||
.with_burst_allowance(1);
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
config,
|
||||
¶ms_at(5 * SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
assert_eq!(negotiated.interval_ns, 5 * SECOND);
|
||||
|
||||
assert_eq!(
|
||||
count_of(
|
||||
echo_at(&mut core, &clock, 10 * SECOND, token, 0, &negotiated, None),
|
||||
&negotiated
|
||||
),
|
||||
Some(1),
|
||||
"the origin, which also spends the burst"
|
||||
);
|
||||
|
||||
assert!(
|
||||
echo_at(&mut core, &clock, 13 * SECOND, token, 1, &negotiated, None).is_none(),
|
||||
"past the deadline but with no allowance: dropped, not closed"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1, "and the session is still usable");
|
||||
|
||||
// Two seconds later the allowance is back.
|
||||
let packet = echo_at(&mut core, &clock, 15 * SECOND, token, 2, &negotiated, None)
|
||||
.expect("the next served echo carries the close");
|
||||
let reply = expect_closing_echo_reply(&packet, &negotiated, None);
|
||||
assert_eq!(
|
||||
reply.recv_count,
|
||||
Some(2),
|
||||
"the rate-limited request was never counted"
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rejected_echo_past_the_deadline_neither_closes_nor_releases() {
|
||||
// The close trigger runs only after ordinary admission and rate allowance
|
||||
// succeed, so a datagram the server would refuse at any time refuses in the
|
||||
// ordinary way here too. The specification records the corresponding
|
||||
// upstream behavior as untested for classes other than rate limiting, so
|
||||
// this is the deterministic `irtt-rs` reading rather than an observed one.
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
max_duration_of(Duration::from_secs(1)),
|
||||
¶ms_at(SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
assert!(echo_at(&mut core, &clock, 10 * SECOND, token, 0, &negotiated, None).is_some());
|
||||
|
||||
clock.set(14 * SECOND);
|
||||
assert_eq!(
|
||||
core.handle_datagram(
|
||||
other_peer(),
|
||||
&echo_request(token, 1, &negotiated, &[], None)
|
||||
)
|
||||
.unwrap(),
|
||||
None,
|
||||
"a foreign endpoint gets silence, not a close"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1, "and does not release the session");
|
||||
|
||||
let packet = echo_at(&mut core, &clock, 14_100 * MS, token, 2, &negotiated, None)
|
||||
.expect("the owning endpoint still receives the close");
|
||||
let reply = expect_closing_echo_reply(&packet, &negotiated, None);
|
||||
assert_eq!(
|
||||
reply.recv_count,
|
||||
Some(2),
|
||||
"the foreign request advanced nothing"
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_authenticated_session_closes_through_the_ordinary_reply_path() {
|
||||
// No special close authentication exists, and none should: the flag rides on
|
||||
// a reply the normal encoder and key path produced.
|
||||
let config = max_duration_of(Duration::from_secs(1)).with_hmac_key(KEY);
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
config,
|
||||
¶ms_at(SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
Some(KEY),
|
||||
);
|
||||
|
||||
assert!(echo_at(
|
||||
&mut core,
|
||||
&clock,
|
||||
10 * SECOND,
|
||||
token,
|
||||
0,
|
||||
&negotiated,
|
||||
Some(KEY)
|
||||
)
|
||||
.is_some());
|
||||
|
||||
let packet = echo_at(
|
||||
&mut core,
|
||||
&clock,
|
||||
13 * SECOND,
|
||||
token,
|
||||
1,
|
||||
&negotiated,
|
||||
Some(KEY),
|
||||
)
|
||||
.expect("the deadline-crossing echo is answered");
|
||||
// Decoding verifies the MAC, and the flag comparison pins HMAC and Close
|
||||
// together.
|
||||
let reply = expect_closing_echo_reply(&packet, &negotiated, Some(KEY));
|
||||
assert_eq!(reply.recv_count, Some(2));
|
||||
irtt_proto::verify_packet_hmac(KEY, packet.bytes())
|
||||
.expect("the close-flagged reply is authenticated");
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
@@ -1,29 +1,4 @@
|
||||
//! Deterministic tests for the server core.
|
||||
//!
|
||||
//! The core takes a source endpoint and datagram bytes and returns reply bytes,
|
||||
//! so these drive it directly; no socket is bound. Requests are built with the
|
||||
//! production encoders wherever the wire form is well-formed, and by hand only
|
||||
//! where the point of the test is a payload a compliant encoder cannot produce.
|
||||
//!
|
||||
//! The two nondeterministic parts are injected. Tokens come from a scripted
|
||||
//! source so session identity, collisions and allocation failure are all
|
||||
//! assertable; the clock an echo reply's timestamps come from is scripted the
|
||||
//! same way, so timestamp behavior is pinned exactly, with no sleeps, no
|
||||
//! runtime and no timing tolerances.
|
||||
//! Fault paths that cannot reasonably be induced through OS randomness.
|
||||
|
||||
mod admission;
|
||||
mod close;
|
||||
mod echo;
|
||||
mod fill;
|
||||
mod hmac;
|
||||
mod kernel_rx;
|
||||
mod lifecycle;
|
||||
mod negotiation;
|
||||
mod no_test;
|
||||
mod open;
|
||||
mod params;
|
||||
mod rate;
|
||||
mod sessions;
|
||||
mod support;
|
||||
mod tokens;
|
||||
mod traffic_class;
|
||||
@@ -1,392 +0,0 @@
|
||||
//! Parameter restrictions the server applies to an open request.
|
||||
//!
|
||||
//! Every case drives a real open through the core and reads the restriction out
|
||||
//! of the open reply, because the reply is the contract: a client enforces its
|
||||
//! own restriction policy against exactly these values, and the server has to
|
||||
//! honor what it returned.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_proto::{Clock, Params, StampAt};
|
||||
|
||||
use super::support::{
|
||||
client_params, core_with_tokens, echo_request, expect_echo_reply, expect_normal_open_reply,
|
||||
open_request, peer, unthrottled, ScriptedTokens,
|
||||
};
|
||||
use crate::{ServerConfig, TimestampAllowance};
|
||||
|
||||
const TOKEN_A: u64 = 0x2f45_9a01_c37d_6e88;
|
||||
|
||||
/// Negotiates one open against `config` and returns the restricted parameters.
|
||||
fn negotiated(config: ServerConfig, requested: &Params) -> Params {
|
||||
let mut core = core_with_tokens(config, ScriptedTokens::new([TOKEN_A]));
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(requested, None))
|
||||
.unwrap()
|
||||
.expect("the open must be answered");
|
||||
expect_normal_open_reply(&packet, None).params
|
||||
}
|
||||
|
||||
fn requesting(duration_ns: i64, interval_ns: i64) -> Params {
|
||||
Params {
|
||||
duration_ns,
|
||||
interval_ns,
|
||||
..client_params()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_requested_duration_is_reduced_to_the_configured_maximum() {
|
||||
let config = || ServerConfig::default().with_max_test_duration(Duration::from_secs(2));
|
||||
|
||||
assert_eq!(
|
||||
negotiated(config(), &requesting(10_000_000_000, 1_000_000_000)).duration_ns,
|
||||
2_000_000_000,
|
||||
"a longer duration is reduced to the maximum"
|
||||
);
|
||||
assert_eq!(
|
||||
negotiated(config(), &requesting(1_000_000_000, 1_000_000_000)).duration_ns,
|
||||
1_000_000_000,
|
||||
"a duration under the maximum is left alone"
|
||||
);
|
||||
// Duration zero is absent from the request, which means continuous. A
|
||||
// server with a finite maximum is restricting continuous mode to a finite
|
||||
// test, which is a reduction the client already models.
|
||||
assert_eq!(
|
||||
negotiated(config(), &requesting(0, 1_000_000_000)).duration_ns,
|
||||
2_000_000_000,
|
||||
"a continuous request takes the maximum"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_configured_maximum_a_duration_is_never_restricted() {
|
||||
let config = || ServerConfig::default();
|
||||
|
||||
assert_eq!(
|
||||
negotiated(config(), &requesting(86_400_000_000_000, 1_000_000_000)).duration_ns,
|
||||
86_400_000_000_000,
|
||||
"a day-long test is the operator's business, not negotiation's"
|
||||
);
|
||||
assert_eq!(
|
||||
negotiated(config(), &requesting(0, 1_000_000_000)).duration_ns,
|
||||
0,
|
||||
"continuous stays continuous"
|
||||
);
|
||||
|
||||
// A zero maximum is stored as no maximum rather than negotiated: Duration
|
||||
// zero on the wire means continuous, so a finite maximum of zero could only
|
||||
// be expressed by turning a client's finite test into an endless one.
|
||||
let zero = ServerConfig::default().with_max_test_duration(Duration::ZERO);
|
||||
assert_eq!(zero.max_test_duration(), None);
|
||||
assert_eq!(
|
||||
negotiated(zero, &requesting(1_000_000_000, 1_000_000_000)).duration_ns,
|
||||
1_000_000_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_requested_interval_is_raised_to_the_configured_minimum() {
|
||||
let config = || ServerConfig::default().with_min_send_interval(Duration::from_millis(100));
|
||||
|
||||
assert_eq!(
|
||||
negotiated(config(), &requesting(3_000_000_000, 20_000_000)).interval_ns,
|
||||
100_000_000,
|
||||
"a faster interval is raised to the minimum"
|
||||
);
|
||||
assert_eq!(
|
||||
negotiated(config(), &requesting(3_000_000_000, 500_000_000)).interval_ns,
|
||||
500_000_000,
|
||||
"an interval above the minimum is left alone"
|
||||
);
|
||||
// An absent Interval is the wire default zero, not a refusal — an explicit
|
||||
// zero is rejected during request admission and never reaches negotiation —
|
||||
// so it is raised like any other value below the floor.
|
||||
assert_eq!(
|
||||
negotiated(config(), &requesting(3_000_000_000, 0)).interval_ns,
|
||||
100_000_000,
|
||||
"an absent interval takes the minimum"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negotiated_interval_is_capped_at_a_quarter_of_the_idle_timeout() {
|
||||
// Without the cap, a client sending at the interval it was handed would
|
||||
// idle its own session out.
|
||||
let config = ServerConfig::default().with_idle_timeout(Duration::from_secs(8));
|
||||
|
||||
assert_eq!(
|
||||
negotiated(config, &requesting(3_000_000_000, 5_000_000_000)).interval_ns,
|
||||
2_000_000_000
|
||||
);
|
||||
|
||||
// A timeout whose quarter is past what the wire can express caps nothing,
|
||||
// because no representable interval reaches it. The quarter is taken before
|
||||
// the conversion to nanoseconds for exactly this reason: saturating first
|
||||
// would put the cap at a quarter of `i64::MAX` and reduce a long interval
|
||||
// the configured timeout never meant to touch.
|
||||
let absurd = ServerConfig::default().with_idle_timeout(Duration::MAX);
|
||||
assert_eq!(
|
||||
negotiated(absurd, &requesting(3_000_000_000, i64::MAX)).interval_ns,
|
||||
i64::MAX
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_idle_cap_wins_over_a_larger_configured_minimum_interval() {
|
||||
// The floor is applied first and the cap second, so a minimum above a
|
||||
// quarter of the idle timeout produces a negotiated interval below that
|
||||
// minimum. This is the configuration the specification records as an
|
||||
// upstream hazard: upstream returns 2 s here and still replenishes reply
|
||||
// allowance every 5 s, so a client obeying the reply is rate-limited.
|
||||
//
|
||||
// `irtt-rs` returns the same 2 s — the cap has to win, or the session
|
||||
// cannot stay alive — and refills at 2 s instead. `rate` proves the
|
||||
// enforcement half; this pins the value the client is told.
|
||||
let config = ServerConfig::default()
|
||||
.with_min_send_interval(Duration::from_secs(5))
|
||||
.with_idle_timeout(Duration::from_secs(8));
|
||||
|
||||
assert_eq!(
|
||||
negotiated(config, &requesting(3_000_000_000, 10_000_000)).interval_ns,
|
||||
2_000_000_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_idle_timeout_caps_nothing() {
|
||||
// A zero timeout expires a session at the first evaluation, so there is no
|
||||
// liveness to protect and a quarter of zero must not be read as a cap of
|
||||
// zero — which would negotiate an interval the client would reject.
|
||||
let config = ServerConfig::default()
|
||||
.with_idle_timeout(Duration::ZERO)
|
||||
.with_min_send_interval(Duration::from_millis(100));
|
||||
|
||||
assert_eq!(
|
||||
negotiated(config, &requesting(3_000_000_000, 3_600_000_000_000)).interval_ns,
|
||||
3_600_000_000_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_configuration_restricts_neither_capability() {
|
||||
// Both controls are opt-in, so an existing configuration negotiates exactly
|
||||
// what it did before they existed.
|
||||
let config = ServerConfig::default();
|
||||
|
||||
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Dual);
|
||||
assert!(config.dscp_allowed());
|
||||
assert_eq!(TimestampAllowance::default(), TimestampAllowance::Dual);
|
||||
|
||||
let restricted = config
|
||||
.clone()
|
||||
.with_timestamp_allowance(TimestampAllowance::Single)
|
||||
.with_dscp_allowed(false);
|
||||
assert_eq!(restricted.timestamp_allowance(), TimestampAllowance::Single);
|
||||
assert!(!restricted.dscp_allowed());
|
||||
assert_ne!(
|
||||
restricted, config,
|
||||
"the policy is part of the configuration"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_requested_stamp_at_is_reduced_to_the_configured_allowance() {
|
||||
// The whole observed mapping, driven through real opens and read out of the
|
||||
// reply, because the reply is what a client enforces its own restriction
|
||||
// policy against. Only one row substitutes rather than removes: a request
|
||||
// for both instants under a single-timestamp allowance is answered with the
|
||||
// *midpoint*, which still describes both, rather than with whichever of the
|
||||
// two the server preferred.
|
||||
for (requested, allowance, expected) in [
|
||||
(StampAt::None, TimestampAllowance::Dual, StampAt::None),
|
||||
(StampAt::None, TimestampAllowance::Single, StampAt::None),
|
||||
(StampAt::None, TimestampAllowance::None, StampAt::None),
|
||||
(StampAt::Send, TimestampAllowance::Dual, StampAt::Send),
|
||||
(StampAt::Send, TimestampAllowance::Single, StampAt::Send),
|
||||
(StampAt::Send, TimestampAllowance::None, StampAt::None),
|
||||
(StampAt::Receive, TimestampAllowance::Dual, StampAt::Receive),
|
||||
(
|
||||
StampAt::Receive,
|
||||
TimestampAllowance::Single,
|
||||
StampAt::Receive,
|
||||
),
|
||||
(StampAt::Receive, TimestampAllowance::None, StampAt::None),
|
||||
(StampAt::Both, TimestampAllowance::Dual, StampAt::Both),
|
||||
(StampAt::Both, TimestampAllowance::Single, StampAt::Midpoint),
|
||||
(StampAt::Both, TimestampAllowance::None, StampAt::None),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
TimestampAllowance::Dual,
|
||||
StampAt::Midpoint,
|
||||
),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
TimestampAllowance::Single,
|
||||
StampAt::Midpoint,
|
||||
),
|
||||
(StampAt::Midpoint, TimestampAllowance::None, StampAt::None),
|
||||
] {
|
||||
let params = Params {
|
||||
stamp_at: requested,
|
||||
clock: Clock::Both,
|
||||
..client_params()
|
||||
};
|
||||
let negotiated = negotiated(
|
||||
ServerConfig::default().with_timestamp_allowance(allowance),
|
||||
¶ms,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
negotiated.stamp_at, expected,
|
||||
"{requested:?} under {allowance:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
negotiated.clock,
|
||||
Clock::Both,
|
||||
"{requested:?} under {allowance:?}: the allowance restricts placement, not the clock"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_timestamp_restriction_never_rewrites_the_requested_clock() {
|
||||
// The clean evidence describes a timestamp *allowance*, and says nothing
|
||||
// about restricting clock domains. A server that quietly changed the clock
|
||||
// too would report a session the client never asked for — and a client
|
||||
// enforcing strict negotiation would refuse an otherwise serviceable one.
|
||||
let single = negotiated(
|
||||
ServerConfig::default().with_timestamp_allowance(TimestampAllowance::Single),
|
||||
&Params {
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Wall,
|
||||
..client_params()
|
||||
},
|
||||
);
|
||||
assert_eq!(single.stamp_at, StampAt::Midpoint);
|
||||
assert_eq!(single.clock, Clock::Wall);
|
||||
|
||||
let none = negotiated(
|
||||
ServerConfig::default().with_timestamp_allowance(TimestampAllowance::None),
|
||||
&Params {
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
..client_params()
|
||||
},
|
||||
);
|
||||
assert_eq!(none.stamp_at, StampAt::None);
|
||||
assert_eq!(
|
||||
none.clock,
|
||||
Clock::Both,
|
||||
"the clock tag stays exactly as requested, selecting nothing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_allowed_dscp_is_negotiated_stored_and_transported_unchanged() {
|
||||
// The default, and the behavioral compatibility this policy must not
|
||||
// disturb: the raw byte reaches the reply, the session and the transport.
|
||||
let requested = Params {
|
||||
dscp: 0xbb,
|
||||
..client_params()
|
||||
};
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
let open = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("the open must be answered");
|
||||
|
||||
let negotiated = expect_normal_open_reply(&open, None).params;
|
||||
assert_eq!(negotiated.dscp, 0xbb);
|
||||
assert_eq!(
|
||||
core.session(TOKEN_A)
|
||||
.expect("the session must be live")
|
||||
.params()
|
||||
.dscp,
|
||||
0xbb
|
||||
);
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("an admissible echo must be answered");
|
||||
expect_echo_reply(&reply, &negotiated, None);
|
||||
assert_eq!(i64::from(reply.traffic_class()), 0xbb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disallowed_dscp_is_negotiated_to_zero_and_the_session_runs_unmarked() {
|
||||
// The open is not refused: the server simply will not provide the marking,
|
||||
// and says so in the value it returns. Nothing downstream needs a second
|
||||
// record of the policy — the negotiated zero is what the transport reads.
|
||||
let requested = Params {
|
||||
dscp: 0xb8,
|
||||
..client_params()
|
||||
};
|
||||
let mut core = core_with_tokens(
|
||||
unthrottled().with_dscp_allowed(false),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
);
|
||||
let open = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("a disallowed DSCP must not refuse the open");
|
||||
|
||||
let negotiated = expect_normal_open_reply(&open, None).params;
|
||||
assert_eq!(negotiated.dscp, 0, "restricted to zero");
|
||||
assert_eq!(
|
||||
core.session(TOKEN_A)
|
||||
.expect("the session must be live")
|
||||
.params()
|
||||
.dscp,
|
||||
0,
|
||||
"and stored as the value the session will be run with"
|
||||
);
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("an unmarked session is an ordinary session");
|
||||
expect_echo_reply(&reply, &negotiated, None);
|
||||
assert_eq!(reply.traffic_class(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disallowed_dscp_restricts_a_value_no_socket_could_carry_too() {
|
||||
// Restriction runs during negotiation, before the transport's
|
||||
// "out-of-range means unmarked" fallback is relevant at all: these values
|
||||
// never reach the session, so the reply is honest rather than merely
|
||||
// harmless.
|
||||
for dscp in [-1, 256, i64::MAX] {
|
||||
let requested = Params {
|
||||
dscp,
|
||||
..client_params()
|
||||
};
|
||||
let mut core = core_with_tokens(
|
||||
unthrottled().with_dscp_allowed(false),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
);
|
||||
let open = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("an open requesting DSCP {dscp} must still be answered"));
|
||||
|
||||
let negotiated = expect_normal_open_reply(&open, None).params;
|
||||
assert_eq!(negotiated.dscp, 0, "requested {dscp}");
|
||||
assert_eq!(
|
||||
core.session(TOKEN_A)
|
||||
.expect("the session must be live")
|
||||
.params()
|
||||
.dscp,
|
||||
0,
|
||||
"requested {dscp}"
|
||||
);
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("the session must serve echoes after restricting {dscp}"));
|
||||
expect_echo_reply(&reply, &negotiated, None);
|
||||
assert_eq!(reply.traffic_class(), 0, "requested {dscp}");
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
use irtt_proto::{verify_packet_hmac, Clock, Params, StampAt};
|
||||
|
||||
use super::support::{
|
||||
client_params, core_with_tokens, expect_no_test_reply, no_test_request, open_request,
|
||||
open_request_with_raw_params, param_int, peer, unthrottled, ScriptedTokens, KEY,
|
||||
};
|
||||
use crate::{ServerConfig, TimestampAllowance};
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_replies_with_a_zero_token_and_creates_nothing() {
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(ServerConfig::default(), tokens.clone());
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &no_test_request(&client_params(), None))
|
||||
.unwrap()
|
||||
.expect("a no-test open must be answered");
|
||||
|
||||
let reply = expect_no_test_reply(&packet, None);
|
||||
assert_eq!(
|
||||
reply.params,
|
||||
Params {
|
||||
protocol_version: 1,
|
||||
..client_params()
|
||||
}
|
||||
);
|
||||
assert_eq!(core.session_count(), 0, "no-test creates no session");
|
||||
assert_eq!(
|
||||
tokens.remaining(),
|
||||
1,
|
||||
"no-test must not draw a session token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_negotiates_exactly_as_a_normal_open_does() {
|
||||
let requested = client_params();
|
||||
|
||||
let mut normal_core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let normal = normal_core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("normal open must be answered");
|
||||
|
||||
let mut no_test_core =
|
||||
core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let no_test = no_test_core
|
||||
.handle_datagram(peer(), &no_test_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("no-test open must be answered");
|
||||
|
||||
assert_eq!(
|
||||
expect_no_test_reply(&no_test, None).params,
|
||||
super::support::decode_reply(&normal, None).params
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_is_rejected_on_the_same_parameters_a_normal_open_is() {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let request = {
|
||||
let mut packet = no_test_request(&client_params(), None);
|
||||
// Replace the parameter payload with an explicit zero duration.
|
||||
packet.truncate(4);
|
||||
packet.extend_from_slice(¶m_int(2, 0));
|
||||
packet
|
||||
};
|
||||
|
||||
assert_eq!(core.handle_datagram(peer(), &request).unwrap(), None);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_selecting_timestamps_without_a_clock_is_silently_refused() {
|
||||
// No-test exists to tell a client what the session would be, so it must not
|
||||
// report a session that could not exist. The effective-parameter check runs
|
||||
// before the no-test path branches off, so both open forms refuse the same
|
||||
// incoherent timestamp request.
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(ServerConfig::default(), tokens.clone());
|
||||
|
||||
let requested = Params {
|
||||
clock: Clock::Unspecified,
|
||||
stamp_at: StampAt::Both,
|
||||
..client_params()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &no_test_request(&requested, None))
|
||||
.expect("an unusable timestamp request is not a server error"),
|
||||
None
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert_eq!(tokens.remaining(), 1, "no token may be drawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_reports_the_reduced_packet_length() {
|
||||
// No-test answers what the session *would* be, so it has to report the same
|
||||
// reduced length a session-creating open would enforce — while still
|
||||
// creating nothing and drawing no token.
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default().with_max_packet_length(64),
|
||||
tokens.clone(),
|
||||
);
|
||||
let requested = Params {
|
||||
length: 1000,
|
||||
..client_params()
|
||||
};
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &no_test_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("a reducible length must still be answered");
|
||||
|
||||
assert_eq!(expect_no_test_reply(&packet, None).params.length, 64);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert_eq!(tokens.remaining(), 1, "no-test must not draw a token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_is_refused_when_its_echo_field_block_exceeds_the_maximum() {
|
||||
// A maximum below the mandatory echo block describes a session that could
|
||||
// not exist at all, so no-test must not report one. Both open forms run the
|
||||
// same effective-parameter check before no-test branches off.
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default().with_max_packet_length(0),
|
||||
tokens.clone(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &no_test_request(&client_params(), None))
|
||||
.expect("an unservable packet size is not a server error"),
|
||||
None
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert_eq!(tokens.remaining(), 1, "no token may be drawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_is_still_answered_when_no_session_could_be_created() {
|
||||
// No-test consumes no capacity, so it must keep working exactly where a
|
||||
// session-creating open would be refused.
|
||||
let mut refuses_everything = core_with_tokens(
|
||||
ServerConfig::default().with_max_sessions(0),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
);
|
||||
assert_eq!(
|
||||
refuses_everything
|
||||
.handle_datagram(peer(), &open_request(&client_params(), None))
|
||||
.unwrap(),
|
||||
None,
|
||||
"max_sessions 0 refuses a session-creating open"
|
||||
);
|
||||
let packet = refuses_everything
|
||||
.handle_datagram(peer(), &no_test_request(&client_params(), None))
|
||||
.unwrap()
|
||||
.expect("no-test must be served at max_sessions 0");
|
||||
expect_no_test_reply(&packet, None);
|
||||
|
||||
let mut full = core_with_tokens(
|
||||
ServerConfig::default().with_max_sessions(1),
|
||||
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
|
||||
);
|
||||
full.handle_datagram(peer(), &open_request(&client_params(), None))
|
||||
.unwrap()
|
||||
.expect("the first open fills the table");
|
||||
assert_eq!(full.session_count(), 1);
|
||||
|
||||
let packet = full
|
||||
.handle_datagram(peer(), &no_test_request(&client_params(), None))
|
||||
.unwrap()
|
||||
.expect("no-test must be served with a full session table");
|
||||
expect_no_test_reply(&packet, None);
|
||||
assert_eq!(full.session_count(), 1, "no-test disturbed the table");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_authenticated_no_test_reply_is_authenticated_and_still_creates_nothing() {
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default().with_hmac_key(KEY),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &no_test_request(&client_params(), Some(KEY)))
|
||||
.unwrap()
|
||||
.expect("an authenticated no-test open must be answered");
|
||||
|
||||
expect_no_test_reply(&packet, Some(KEY));
|
||||
verify_packet_hmac(KEY, packet.bytes()).expect("the no-test reply must carry a valid MAC");
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_no_test_open_is_answered() {
|
||||
// No interval floor, so the reply shows the wire defaults an empty payload
|
||||
// decoded to rather than the timing policy negotiation would apply to them.
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let mut request = open_request_with_raw_params(&[], None);
|
||||
request[3] |= irtt_proto::FLAG_CLOSE;
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.expect("an empty no-test open must be answered");
|
||||
|
||||
let reply = expect_no_test_reply(&packet, None);
|
||||
assert_eq!(
|
||||
reply.params,
|
||||
Params {
|
||||
protocol_version: 1,
|
||||
..Params::default()
|
||||
}
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_reports_the_restricted_capabilities() {
|
||||
// No-test exists to tell a client what the session would be, so it reports
|
||||
// exactly what the same open would have negotiated under this policy —
|
||||
// while still creating nothing and drawing no token. Capability policy is
|
||||
// not skipped merely because no measurement session follows.
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default()
|
||||
.with_timestamp_allowance(TimestampAllowance::Single)
|
||||
.with_dscp_allowed(false),
|
||||
tokens.clone(),
|
||||
);
|
||||
let requested = Params {
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
dscp: 184,
|
||||
..client_params()
|
||||
};
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &no_test_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("a no-test open must be answered under a restricting policy");
|
||||
|
||||
let reply = expect_no_test_reply(&packet, None);
|
||||
assert_eq!(reply.params.stamp_at, StampAt::Midpoint);
|
||||
assert_eq!(reply.params.clock, Clock::Both, "the clock is untouched");
|
||||
assert_eq!(reply.params.dscp, 0);
|
||||
assert_eq!(reply.token, 0);
|
||||
assert_eq!(core.session_count(), 0, "no-test creates no session");
|
||||
assert_eq!(tokens.remaining(), 1, "no-test must not draw a token");
|
||||
}
|
||||
@@ -1,458 +0,0 @@
|
||||
use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt, TimestampFields};
|
||||
|
||||
use super::support::{
|
||||
client_params, core_with_tokens, echo_request, expect_echo_reply, expect_normal_open_reply,
|
||||
open_request, open_request_with_raw_params, param_int, peer, unthrottled, ScriptedTokens, KEY,
|
||||
};
|
||||
use crate::{ServerConfig, TimestampAllowance, DEFAULT_MAX_PACKET_LENGTH};
|
||||
|
||||
const TOKEN_A: u64 = 0x7896_b6ab_8771_5213;
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_open_is_answered_and_creates_one_session() {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&client_params(), None))
|
||||
.unwrap()
|
||||
.expect("an ordinary open must be answered");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(reply.token, TOKEN_A);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
|
||||
let session = core.session(TOKEN_A).expect("the session must be live");
|
||||
assert_eq!(session.params(), &reply.params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_records_the_exact_source_endpoint() {
|
||||
// Address family, address, port and — where present — the IPv6 scope all
|
||||
// form part of session identity, so the endpoint is stored verbatim rather
|
||||
// than normalized to an address.
|
||||
for endpoint in [
|
||||
"198.51.100.7:41234",
|
||||
"198.51.100.7:41235",
|
||||
"[2001:db8::1]:2112",
|
||||
"[fe80::1%3]:2112",
|
||||
] {
|
||||
let endpoint = endpoint.parse().unwrap();
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
core.handle_datagram(endpoint, &open_request(&client_params(), None))
|
||||
.unwrap()
|
||||
.expect("open must be answered");
|
||||
|
||||
assert_eq!(core.session(TOKEN_A).unwrap().peer(), endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_requested_protocol_version_is_accepted_and_answered_with_one() {
|
||||
// Version mismatch is detected client-side only: the server rewrites the
|
||||
// version and creates a normal session whatever was asked for.
|
||||
for requested in [Some(1), Some(0), Some(2), Some(-1), None] {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let params = Params {
|
||||
protocol_version: requested.unwrap_or(0),
|
||||
..client_params()
|
||||
};
|
||||
let request = match requested {
|
||||
// An explicit zero cannot be encoded by `Params::encode`, which
|
||||
// omits zero-valued integers, so encode that tag by hand.
|
||||
Some(0) => open_request_with_raw_params(¶m_int(1, 0), None),
|
||||
Some(_) => open_request(¶ms, None),
|
||||
None => open_request(
|
||||
&Params {
|
||||
protocol_version: 0,
|
||||
..params
|
||||
},
|
||||
None,
|
||||
),
|
||||
};
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("protocol version {requested:?} must be answered"));
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(reply.params.protocol_version, 1);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_parameter_payload_is_answered_with_only_a_version() {
|
||||
// The interval floor is disabled here so that the reply carries nothing but
|
||||
// the version: with one configured, an absent Interval negotiates up to it
|
||||
// and is encoded, which is interval policy rather than what this test is
|
||||
// about.
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request_with_raw_params(&[], None))
|
||||
.unwrap()
|
||||
.expect("an empty parameter payload is a valid open");
|
||||
|
||||
// header (4) + token (8) + ProtocolVersion=1 (2). Every other parameter
|
||||
// negotiates to its wire-default zero and is omitted entirely, which is
|
||||
// only reachable if absent parameters really did decode as zero.
|
||||
assert_eq!(packet.bytes().len(), 14);
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(
|
||||
reply.params,
|
||||
Params {
|
||||
protocol_version: 1,
|
||||
..Params::default()
|
||||
}
|
||||
);
|
||||
// An unspecified clock is not itself a defect. Only pairing it with a
|
||||
// selected `stamp_at` is, so a session requesting no timestamps at all is
|
||||
// fully valid with no Clock tag.
|
||||
assert_eq!(reply.params.stamp_at, StampAt::None);
|
||||
assert_eq!(reply.params.clock, Clock::Unspecified);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_parameter_tags_are_ignored_and_not_reflected_in_the_reply() {
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let mut payload = param_int(1, 1);
|
||||
payload.extend_from_slice(¶m_int(42, 7));
|
||||
payload.extend_from_slice(¶m_int(200, 9));
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request_with_raw_params(&payload, None))
|
||||
.unwrap()
|
||||
.expect("unknown tags must not prevent an open");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(
|
||||
reply.params,
|
||||
Params {
|
||||
protocol_version: 1,
|
||||
..Params::default()
|
||||
}
|
||||
);
|
||||
// The reply is the same 14 bytes an empty payload produces: no unknown tag
|
||||
// was copied through.
|
||||
assert_eq!(packet.bytes().len(), 14);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_timestamps_without_a_clock_is_silently_refused() {
|
||||
// Timestamps from no clock at all describe a session the server could not
|
||||
// run: the negotiated layout carries no timestamp field to fill. The open
|
||||
// is discarded rather than repaired — synthesizing a clock would answer
|
||||
// with a session the client never asked for, and dropping `stamp_at` would
|
||||
// silently discard the measurement it did ask for.
|
||||
//
|
||||
// Only an *absent* Clock tag reaches this: an explicit zero is already out
|
||||
// of range for the decoder, and a conforming client always sends a clock
|
||||
// when it selects timestamps.
|
||||
//
|
||||
// A server whose timestamp allowance is none accepts the very same request,
|
||||
// because then no timestamp is negotiated to be unproducible; see
|
||||
// `a_timestamp_disallowing_server_accepts_a_request_that_omitted_its_clock`.
|
||||
for stamp_at in [
|
||||
StampAt::Send,
|
||||
StampAt::Receive,
|
||||
StampAt::Both,
|
||||
StampAt::Midpoint,
|
||||
] {
|
||||
let requested = Params {
|
||||
clock: Clock::Unspecified,
|
||||
stamp_at,
|
||||
..client_params()
|
||||
};
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(ServerConfig::default(), tokens.clone());
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.expect("an unusable timestamp request is not a server error");
|
||||
|
||||
assert!(
|
||||
reply.is_none(),
|
||||
"{stamp_at:?} without a clock must be silent"
|
||||
);
|
||||
assert_eq!(
|
||||
core.session_count(),
|
||||
0,
|
||||
"{stamp_at:?} without a clock must create no session"
|
||||
);
|
||||
assert_eq!(
|
||||
tokens.remaining(),
|
||||
1,
|
||||
"{stamp_at:?} without a clock must not consume a token"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_timestamp_disallowing_server_accepts_a_request_that_omitted_its_clock() {
|
||||
// The same request the test above refuses, against a server that provides no
|
||||
// timestamps at all. The restriction runs before the effective-session
|
||||
// check, so `stamp_at` is already none by the time executability is judged:
|
||||
// the absent clock then selects nothing that was going to be reported, and
|
||||
// the session is one the server can run exactly as negotiated.
|
||||
//
|
||||
// This is what the ordering exists for. Validating the *request* instead
|
||||
// would refuse an open that this policy makes perfectly serviceable.
|
||||
let requested = Params {
|
||||
clock: Clock::Unspecified,
|
||||
stamp_at: StampAt::Both,
|
||||
..client_params()
|
||||
};
|
||||
let mut core = core_with_tokens(
|
||||
unthrottled().with_timestamp_allowance(TimestampAllowance::None),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("an omitted clock is safe once no timestamps are negotiated");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(reply.params.stamp_at, StampAt::None);
|
||||
assert_eq!(
|
||||
reply.params.clock,
|
||||
Clock::Unspecified,
|
||||
"the absent clock tag stays absent"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
|
||||
let echo = core
|
||||
.handle_datagram(
|
||||
peer(),
|
||||
&echo_request(reply.token, 0, &reply.params, &[], None),
|
||||
)
|
||||
.unwrap()
|
||||
.expect("the session must serve echoes");
|
||||
assert_eq!(
|
||||
expect_echo_reply(&echo, &reply.params, None).timestamps,
|
||||
TimestampFields::default(),
|
||||
"no timestamps were negotiated, so the reply carries none"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_non_length_parameters_survive_negotiation_unchanged() {
|
||||
// Statistics, timestamps and DSCP are what the capability restrictions
|
||||
// change when they are configured; this is the default configuration, which
|
||||
// restricts none of them. Version, an oversized packet
|
||||
// length, the duration maximum and the interval floor and cap are already
|
||||
// restricted and have their own tests; the length here is deliberately
|
||||
// under the default maximum, and the configuration below leaves duration
|
||||
// and interval unrestricted, so this test says nothing about either policy.
|
||||
//
|
||||
// The server fill below is a *valid* descriptor, which is what survives
|
||||
// unchanged. Fill negotiation has its own restriction rule and its own
|
||||
// tests in `fill`.
|
||||
//
|
||||
// This is about what *negotiation* rewrites, which is a separate question
|
||||
// from whether the core will acknowledge the result: an open whose
|
||||
// effective parameters are incoherent is still discarded, as
|
||||
// `selecting_timestamps_without_a_clock_is_silently_refused` shows.
|
||||
let unrestricted = Params {
|
||||
protocol_version: 2,
|
||||
duration_ns: 3_600_000_000_000,
|
||||
interval_ns: 1,
|
||||
length: 1472,
|
||||
received_stats: ReceivedStats::Window,
|
||||
stamp_at: StampAt::Midpoint,
|
||||
clock: Clock::Monotonic,
|
||||
dscp: 184,
|
||||
server_fill: Some(ServerFill {
|
||||
value: "pattern:abcd".to_owned(),
|
||||
}),
|
||||
};
|
||||
|
||||
// No duration maximum is configured by default, and the interval floor is
|
||||
// disabled, so a one-nanosecond interval is left where it was asked for.
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&unrestricted, None))
|
||||
.unwrap()
|
||||
.expect("open must be answered");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(
|
||||
reply.params,
|
||||
Params {
|
||||
protocol_version: 1,
|
||||
..unrestricted
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_positive_length_above_the_configured_maximum_is_reduced_to_it() {
|
||||
// The reduced value is what the client is told and what the session holds,
|
||||
// because it is what the server will actually emit. Both have to agree: an
|
||||
// upstream client treats a reply shorter than the negotiated length as
|
||||
// fatal, so an honest reduction at open is the only serviceable answer.
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default().with_max_packet_length(64),
|
||||
tokens.clone(),
|
||||
);
|
||||
let requested = Params {
|
||||
length: 1000,
|
||||
..client_params()
|
||||
};
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("a reducible length must still be answered");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(reply.params.length, 64);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(tokens.remaining(), 0, "one token was consumed");
|
||||
let session = core.session(TOKEN_A).expect("the session must be live");
|
||||
assert_eq!(
|
||||
session.params().length,
|
||||
64,
|
||||
"the session must enforce the reduced length"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_maximum_reduces_an_absurd_requested_length() {
|
||||
// The largest length the wire can carry is representable as a `usize` on a
|
||||
// 64-bit target, so packet sizing would happily have turned it into an
|
||||
// allocation; the configured maximum is what stops it. Restriction happens
|
||||
// during negotiation, before any sizing, so this holds on a 32-bit target
|
||||
// too — the request never reaches a conversion that could fail.
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let requested = Params {
|
||||
length: i64::MAX,
|
||||
..client_params()
|
||||
};
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("an absurd length is reduced, not refused");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(reply.params.length, DEFAULT_MAX_PACKET_LENGTH as i64);
|
||||
let session = core.session(TOKEN_A).expect("the session must be live");
|
||||
assert_eq!(session.params().length, DEFAULT_MAX_PACKET_LENGTH as i64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_whose_echo_field_block_exceeds_the_maximum_is_silently_refused() {
|
||||
// Length itself satisfies the policy — zero asks for nothing beyond the
|
||||
// mandatory fields — but the received-statistics fields alone make the
|
||||
// smallest compliant echo reply larger than 20 bytes. Capping the Length
|
||||
// parameter is therefore not enough on its own: this session could never be
|
||||
// answered, so it is never acknowledged.
|
||||
let requested = Params {
|
||||
protocol_version: 1,
|
||||
length: 0,
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::None,
|
||||
clock: Clock::Unspecified,
|
||||
..Params::default()
|
||||
};
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default().with_max_packet_length(20),
|
||||
tokens.clone(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.expect("an unservable packet size is not a server error"),
|
||||
None
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert_eq!(tokens.remaining(), 1, "no token may be drawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_counts_toward_the_executable_echo_size() {
|
||||
// An authentication field adds 16 bytes to the mandatory echo block, so a
|
||||
// maximum between the two floors admits the same minimal session on an
|
||||
// unauthenticated server and refuses it on an authenticated one. The sizes
|
||||
// themselves come from `irtt_proto`; what this pins is that the server asks
|
||||
// about its own authentication mode rather than assuming the smaller floor.
|
||||
let minimal = Params {
|
||||
protocol_version: 1,
|
||||
..Params::default()
|
||||
};
|
||||
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut authenticated = core_with_tokens(
|
||||
ServerConfig::default()
|
||||
.with_hmac_key(KEY)
|
||||
.with_max_packet_length(31),
|
||||
tokens.clone(),
|
||||
);
|
||||
assert_eq!(
|
||||
authenticated
|
||||
.handle_datagram(peer(), &open_request(&minimal, Some(KEY)))
|
||||
.expect("an unservable packet size is not a server error"),
|
||||
None
|
||||
);
|
||||
assert_eq!(authenticated.session_count(), 0);
|
||||
assert_eq!(tokens.remaining(), 1, "no token may be drawn");
|
||||
|
||||
let mut unauthenticated = core_with_tokens(
|
||||
ServerConfig::default().with_max_packet_length(31),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
);
|
||||
let packet = unauthenticated
|
||||
.handle_datagram(peer(), &open_request(&minimal, None))
|
||||
.unwrap()
|
||||
.expect("the same session fits without an authentication field");
|
||||
expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(unauthenticated.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_and_out_of_range_length_and_dscp_survive_negotiation() {
|
||||
// These lengths are all at or below the default maximum, and DSCP is
|
||||
// accepted as decoded, including values a socket could never carry:
|
||||
// negotiation restricts neither, and a value that is not a byte is simply
|
||||
// transported unmarked (see `traffic_class`).
|
||||
//
|
||||
// A negative length in particular stays negative in both the reply and the
|
||||
// stored session; the packet-length policy reduces oversized *positive*
|
||||
// lengths and touches nothing else. It does not need clamping here to be
|
||||
// executable: echo packet sizing in `irtt-proto` floors it at the required
|
||||
// field block, so the session this creates can be run as negotiated.
|
||||
for (length, dscp) in [(-1, -1), (0, 0), (1472, 184), (-4096, 512)] {
|
||||
let requested = Params {
|
||||
protocol_version: 1,
|
||||
length,
|
||||
dscp,
|
||||
..Params::default()
|
||||
};
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("length {length} dscp {dscp} must be answered"));
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(reply.params.length, length);
|
||||
assert_eq!(reply.params.dscp, dscp);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
let session = core.session(TOKEN_A).expect("the session must be live");
|
||||
assert_eq!(
|
||||
session.params(),
|
||||
&reply.params,
|
||||
"the session must hold the negotiated values verbatim"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt};
|
||||
|
||||
use super::support::{
|
||||
core_with_tokens, expect_normal_open_reply, open_request, open_request_with_raw_params,
|
||||
param_int, param_server_fill, peer, unthrottled, ScriptedTokens,
|
||||
};
|
||||
use crate::ServerConfig;
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
|
||||
fn reject_case(name: &str, payload: Vec<u8>) {
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(ServerConfig::default(), tokens.clone());
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(peer(), &open_request_with_raw_params(&payload, None))
|
||||
.expect("rejected parameters are not a server error");
|
||||
|
||||
assert!(reply.is_none(), "{name} must be answered with silence");
|
||||
assert_eq!(core.session_count(), 0, "{name} must create no session");
|
||||
assert_eq!(
|
||||
tokens.remaining(),
|
||||
1,
|
||||
"{name} must not consume a session token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_out_of_range_parameters_are_dropped_without_a_session() {
|
||||
// Truncation and overflow.
|
||||
reject_case("truncated tag varint", vec![0x80]);
|
||||
reject_case("truncated value varint", vec![2]);
|
||||
reject_case("truncated multi-byte value", vec![2, 0x80]);
|
||||
reject_case(
|
||||
"varint overflow",
|
||||
vec![
|
||||
1, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
],
|
||||
);
|
||||
|
||||
// Duration and Interval: present and not positive.
|
||||
reject_case("explicit duration zero", param_int(2, 0));
|
||||
reject_case("negative duration", param_int(2, -1));
|
||||
reject_case("explicit interval zero", param_int(3, 0));
|
||||
reject_case("negative interval", param_int(3, -5));
|
||||
|
||||
// Enum values outside their defined range.
|
||||
reject_case("received stats above range", param_int(5, 4));
|
||||
reject_case("negative received stats", param_int(5, -1));
|
||||
reject_case("stamp at above range", param_int(6, 5));
|
||||
reject_case("explicit clock zero", param_int(7, 0));
|
||||
reject_case("clock above range", param_int(7, 4));
|
||||
|
||||
// Server fill.
|
||||
reject_case(
|
||||
"server fill declaring more than the payload holds",
|
||||
param_server_fill(10, b"ab"),
|
||||
);
|
||||
reject_case(
|
||||
"server fill longer than 32 bytes",
|
||||
param_server_fill(33, b"0123456789abcdef0123456789abcdefx"),
|
||||
);
|
||||
reject_case(
|
||||
"server fill that is not utf-8",
|
||||
param_server_fill(1, &[0xff]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_duration_interval_and_clock_are_accepted_as_wire_defaults() {
|
||||
// The whole reason the decoder reports presence: an omitted tag and one
|
||||
// explicitly encoded as zero share a value but not a verdict.
|
||||
//
|
||||
// The interval floor is disabled so that the accepted wire default is
|
||||
// visible in the reply. An absent Interval is a value like any other to
|
||||
// negotiation, and the default floor would raise it; that is interval
|
||||
// policy, and `negotiation` tests it.
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(
|
||||
peer(),
|
||||
&open_request_with_raw_params(¶m_int(1, 1), None),
|
||||
)
|
||||
.unwrap()
|
||||
.expect("absent duration, interval and clock are all valid");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(reply.params.duration_ns, 0);
|
||||
assert_eq!(reply.params.interval_ns, 0);
|
||||
assert_eq!(reply.params.clock, Clock::Unspecified);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_optional_parameter_values_survive_unchanged() {
|
||||
for (name, requested) in [
|
||||
(
|
||||
"clock wall",
|
||||
Params {
|
||||
clock: Clock::Wall,
|
||||
..Params::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"clock monotonic",
|
||||
Params {
|
||||
clock: Clock::Monotonic,
|
||||
..Params::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"clock both",
|
||||
Params {
|
||||
clock: Clock::Both,
|
||||
..Params::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"received stats and stamp at",
|
||||
Params {
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Midpoint,
|
||||
clock: Clock::Wall,
|
||||
..Params::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"positive duration and interval",
|
||||
Params {
|
||||
duration_ns: 1,
|
||||
interval_ns: 1,
|
||||
..Params::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
// Exactly `MAX_SERVER_FILL_BYTES`, and a valid descriptor: the
|
||||
// longest one a client can send is also one this server honors, so
|
||||
// the row stays about the length bound rather than about fill
|
||||
// policy, which `fill` tests on its own.
|
||||
"maximum length server fill",
|
||||
Params {
|
||||
server_fill: Some(ServerFill {
|
||||
value: "pattern:0123456789abcdef01234567".to_owned(),
|
||||
}),
|
||||
..Params::default()
|
||||
},
|
||||
),
|
||||
] {
|
||||
// No interval floor: these rows are about which *values* survive
|
||||
// decoding and negotiation, not about the timing policy applied to
|
||||
// them.
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{name} must be answered"));
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(
|
||||
reply.params,
|
||||
Params {
|
||||
protocol_version: 1,
|
||||
..requested
|
||||
},
|
||||
"{name} must survive negotiation unchanged"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
//! Per-session reply allowance.
|
||||
//!
|
||||
//! The behavior pinned here is the clean specification's Section 9.3: a session
|
||||
//! starts with its full burst, spends one unit per echo it is answered,
|
||||
//! replenishes at one unit per refill interval, and drops an echo arriving with
|
||||
//! no allowance without a reply and without advancing either reception
|
||||
//! statistic. That last part is what the counts in the *later* replies prove.
|
||||
//!
|
||||
//! The token bucket itself is a black-box inference about the reference server,
|
||||
//! not a protocol requirement, so nothing here reaches into the limiter's
|
||||
//! representation: every assertion is about which datagrams came back.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_proto::{Clock, Params, ReceivedStats, StampAt};
|
||||
|
||||
use super::support::{
|
||||
core_with_sources, echo_at, echo_params, echo_request, expect_echo_reply, manual_core,
|
||||
open_negotiated, other_peer, peer, ManualClock, ScriptedTokens,
|
||||
};
|
||||
use crate::{OutboundDatagram, ServerConfig};
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
||||
|
||||
const MS: i64 = 1_000_000;
|
||||
const SECOND: i64 = 1_000_000_000;
|
||||
|
||||
/// Params reporting both statistics — the count is how a test tells a served
|
||||
/// request from a dropped one — and requesting `interval_ns`.
|
||||
fn params_at(interval_ns: i64) -> Params {
|
||||
Params {
|
||||
interval_ns,
|
||||
..echo_params(
|
||||
ReceivedStats::Both,
|
||||
StampAt::None,
|
||||
Clock::Unspecified,
|
||||
/* length */ 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The received count a reply reported, or `None` if there was no reply.
|
||||
fn count_of(reply: Option<OutboundDatagram>, params: &Params) -> Option<u32> {
|
||||
let reply = reply?;
|
||||
expect_echo_reply(reply, params, None).recv_count
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blast_is_answered_up_to_the_burst_and_then_replenishes() {
|
||||
// The measured case: a 100 ms minimum interval and a burst of 5 answered
|
||||
// exactly 5 of a blast of 12, and the next 3 requests after a 600 ms pause
|
||||
// were all answered, with the count running 1..5 and then 6, 7, 8.
|
||||
let config = ServerConfig::default()
|
||||
.with_min_send_interval(Duration::from_millis(100))
|
||||
.with_burst_allowance(5);
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
config,
|
||||
¶ms_at(SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
// The refill cadence is the configured minimum here, not the negotiated
|
||||
// interval: the ordinary case, where the minimum is the shorter of the two.
|
||||
assert_eq!(negotiated.interval_ns, SECOND);
|
||||
|
||||
for sequence in 0..12 {
|
||||
let count = count_of(
|
||||
echo_at(&mut core, &clock, 0, token, sequence, &negotiated, None),
|
||||
&negotiated,
|
||||
);
|
||||
let expected = (sequence < 5).then_some(sequence + 1);
|
||||
assert_eq!(count, expected, "request {sequence} of the blast");
|
||||
}
|
||||
|
||||
// A pause long enough to refill the whole burst, then three requests spaced
|
||||
// by one refill interval each. The counts continuing 6, 7, 8 is what proves
|
||||
// the seven dropped requests advanced nothing.
|
||||
for (offset, sequence, expected) in [(600, 12, 6), (700, 13, 7), (800, 14, 8)] {
|
||||
let count = count_of(
|
||||
echo_at(
|
||||
&mut core,
|
||||
&clock,
|
||||
offset * MS,
|
||||
token,
|
||||
sequence,
|
||||
&negotiated,
|
||||
None,
|
||||
),
|
||||
&negotiated,
|
||||
);
|
||||
assert_eq!(count, Some(expected), "request at {offset} ms");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_burst_of_one_answers_one_request_per_interval() {
|
||||
// The other measured case: with a burst of 1, a blast of 4 produced 1 reply.
|
||||
let config = ServerConfig::default()
|
||||
.with_min_send_interval(Duration::from_millis(100))
|
||||
.with_burst_allowance(1);
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
config,
|
||||
¶ms_at(SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
|
||||
for sequence in 0..4 {
|
||||
let count = count_of(
|
||||
echo_at(&mut core, &clock, 0, token, sequence, &negotiated, None),
|
||||
&negotiated,
|
||||
);
|
||||
assert_eq!(count, (sequence == 0).then_some(1), "request {sequence}");
|
||||
}
|
||||
|
||||
// Exactly one interval later the allowance is back, and the count says the
|
||||
// three dropped requests were never counted.
|
||||
let count = count_of(
|
||||
echo_at(&mut core, &clock, 100 * MS, token, 4, &negotiated, None),
|
||||
&negotiated,
|
||||
);
|
||||
assert_eq!(count, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_refill_cadence_is_never_slower_than_the_negotiated_interval() {
|
||||
// The deliberate `irtt-rs` divergence, and the reason this test exists.
|
||||
//
|
||||
// A 5 s minimum interval against an 8 s idle timeout negotiates 2 s, because
|
||||
// the idle cap is applied after the floor. The clean specification records
|
||||
// the reference server continuing to replenish every 5 s in that
|
||||
// configuration, so a fully conforming client sending at the 2 s interval it
|
||||
// was handed is rate-limited on every request. `irtt-rs` refills at the
|
||||
// interval it advertised instead.
|
||||
let config = ServerConfig::default()
|
||||
.with_min_send_interval(Duration::from_secs(5))
|
||||
.with_idle_timeout(Duration::from_secs(8))
|
||||
.with_burst_allowance(1);
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
config,
|
||||
¶ms_at(10 * MS),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
negotiated.interval_ns,
|
||||
2 * SECOND,
|
||||
"the idle cap wins over the larger configured minimum"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
count_of(
|
||||
echo_at(&mut core, &clock, 0, token, 0, &negotiated, None),
|
||||
&negotiated
|
||||
),
|
||||
Some(1),
|
||||
"the first request spends the burst"
|
||||
);
|
||||
|
||||
// One negotiated interval later — and well short of the 5 s the reference
|
||||
// server would have made this client wait.
|
||||
assert_eq!(
|
||||
count_of(
|
||||
echo_at(&mut core, &clock, 2 * SECOND, token, 1, &negotiated, None),
|
||||
&negotiated
|
||||
),
|
||||
Some(2),
|
||||
"a client sending at the interval it was given must not be limited"
|
||||
);
|
||||
assert_eq!(
|
||||
count_of(
|
||||
echo_at(&mut core, &clock, 4 * SECOND, token, 2, &negotiated, None),
|
||||
&negotiated
|
||||
),
|
||||
Some(3),
|
||||
"and must keep being answered at that cadence"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_burst_refuses_every_echo_and_a_zero_interval_refuses_none() {
|
||||
// The two zero cases are deliberately not the same. A zero burst is no
|
||||
// allowance at all; a zero interval is no waiting for allowance.
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
ServerConfig::default().with_burst_allowance(0),
|
||||
¶ms_at(SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
for (offset, sequence) in [(0, 0), (SECOND, 1), (60 * SECOND, 2)] {
|
||||
assert!(
|
||||
echo_at(
|
||||
&mut core,
|
||||
&clock,
|
||||
offset,
|
||||
token,
|
||||
sequence,
|
||||
&negotiated,
|
||||
None
|
||||
)
|
||||
.is_none(),
|
||||
"a zero burst answers nothing, however long the wait"
|
||||
);
|
||||
}
|
||||
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
ServerConfig::default().with_min_send_interval(Duration::ZERO),
|
||||
¶ms_at(SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
for sequence in 0..12 {
|
||||
assert_eq!(
|
||||
count_of(
|
||||
echo_at(&mut core, &clock, 0, token, sequence, &negotiated, None),
|
||||
&negotiated
|
||||
),
|
||||
Some(sequence + 1),
|
||||
"a zero interval never withholds allowance, whatever the burst"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_sessions_hold_their_allowance_independently() {
|
||||
let config = ServerConfig::default()
|
||||
.with_min_send_interval(Duration::from_millis(100))
|
||||
.with_burst_allowance(1);
|
||||
let clock = ManualClock::at(0);
|
||||
let mut core = core_with_sources(
|
||||
config,
|
||||
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
|
||||
clock.clone(),
|
||||
);
|
||||
let requested = params_at(SECOND);
|
||||
let (token_a, negotiated) = open_negotiated(&mut core, peer(), &requested, None);
|
||||
let (token_b, _) = open_negotiated(&mut core, other_peer(), &requested, None);
|
||||
|
||||
// The first session spends its whole allowance and is then limited.
|
||||
assert!(echo_at(&mut core, &clock, 0, token_a, 0, &negotiated, None).is_some());
|
||||
assert!(echo_at(&mut core, &clock, 0, token_a, 1, &negotiated, None).is_none());
|
||||
|
||||
// The second still has its own, from its own endpoint.
|
||||
clock.set(0);
|
||||
let packet = core
|
||||
.handle_datagram(
|
||||
other_peer(),
|
||||
&echo_request(token_b, 0, &negotiated, &[], None),
|
||||
)
|
||||
.unwrap()
|
||||
.expect("a sibling session's allowance is its own");
|
||||
assert_eq!(
|
||||
expect_echo_reply(&packet, &negotiated, None).recv_count,
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
use super::support::{
|
||||
client_params, core_with_tokens, expect_normal_open_reply, open_request, other_peer, peer,
|
||||
ScriptedTokens,
|
||||
};
|
||||
use crate::{ServerConfig, DEFAULT_MAX_SESSIONS};
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
||||
const TOKEN_C: u64 = 0x2122_2324_2526_2728;
|
||||
|
||||
#[test]
|
||||
fn the_default_session_bound_is_finite() {
|
||||
let bound = ServerConfig::default().max_sessions();
|
||||
|
||||
assert_eq!(bound, DEFAULT_MAX_SESSIONS);
|
||||
assert!(bound > 0, "the default bound must admit sessions");
|
||||
assert!(
|
||||
bound < usize::MAX,
|
||||
"there is deliberately no unlimited default"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_opens_from_one_endpoint_create_independent_sessions() {
|
||||
// Opens are never deduplicated by endpoint or by parameters: a client
|
||||
// retransmission legitimately produces a second, independent session.
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
|
||||
);
|
||||
let request = open_request(&client_params(), None);
|
||||
|
||||
let first = core
|
||||
.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.expect("first open must be answered");
|
||||
let second = core
|
||||
.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.expect("second open must be answered");
|
||||
|
||||
let first = expect_normal_open_reply(&first, None);
|
||||
let second = expect_normal_open_reply(&second, None);
|
||||
|
||||
assert_ne!(first.token, 0);
|
||||
assert_ne!(second.token, 0);
|
||||
assert_ne!(first.token, second.token);
|
||||
assert_eq!(core.session_count(), 2);
|
||||
assert_eq!(core.session(first.token).unwrap().peer(), peer());
|
||||
assert_eq!(core.session(second.token).unwrap().peer(), peer());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_session_table_drops_new_opens_without_evicting_anything() {
|
||||
let tokens = ScriptedTokens::new([TOKEN_A, TOKEN_B, TOKEN_C]);
|
||||
let mut core = core_with_tokens(ServerConfig::default().with_max_sessions(2), tokens.clone());
|
||||
let request = open_request(&client_params(), None);
|
||||
|
||||
core.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.expect("first open must be answered");
|
||||
core.handle_datagram(other_peer(), &request)
|
||||
.unwrap()
|
||||
.expect("second open must be answered");
|
||||
assert_eq!(core.session_count(), 2);
|
||||
assert_eq!(core.session(TOKEN_A).unwrap().peer(), peer());
|
||||
assert_eq!(core.session(TOKEN_B).unwrap().peer(), other_peer());
|
||||
|
||||
// Refusal is silence: the protocol has no rejection reply, so the client
|
||||
// simply sees an open timeout.
|
||||
assert_eq!(core.handle_datagram(peer(), &request).unwrap(), None);
|
||||
|
||||
assert_eq!(core.session_count(), 2, "nothing may be evicted");
|
||||
assert_eq!(core.session(TOKEN_A).unwrap().peer(), peer());
|
||||
assert_eq!(core.session(TOKEN_B).unwrap().peer(), other_peer());
|
||||
assert_eq!(core.session(TOKEN_C), None);
|
||||
assert_eq!(
|
||||
tokens.remaining(),
|
||||
1,
|
||||
"a refused open must not commit a token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_session_bound_refuses_every_session_creating_open() {
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(ServerConfig::default().with_max_sessions(0), tokens.clone());
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &open_request(&client_params(), None))
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert_eq!(tokens.remaining(), 1);
|
||||
}
|
||||
@@ -1,55 +1,14 @@
|
||||
use crate::{token::TokenSource, ServerConfig, ServerCore, ServerError};
|
||||
use irtt_proto::{
|
||||
decode_open_reply, encode_request, OpenReply, Params, RequestToEncode, FLAG_HMAC, FLAG_OPEN,
|
||||
FLAG_REPLY,
|
||||
};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
net::SocketAddr,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use irtt_proto::{
|
||||
decode_echo_reply, decode_open_reply, encode_request, varint, EchoReply, OpenReply,
|
||||
PacketLayout, Params, RequestToEncode, FLAG_CLOSE, FLAG_HMAC, FLAG_OPEN, FLAG_REPLY,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
clock::{ClockSample, ClockSource},
|
||||
core::{OutboundDatagram, ServerCore},
|
||||
error::ServerError,
|
||||
token::TokenSource,
|
||||
ServerConfig,
|
||||
};
|
||||
|
||||
pub(crate) const KEY: &[u8] = b"correctkey";
|
||||
pub(crate) const OTHER_KEY: &[u8] = b"wrongkey";
|
||||
|
||||
/// A default configuration with the minimum send interval disabled.
|
||||
///
|
||||
/// One knob, two consequences, and tests about neither of them want both gone:
|
||||
/// no requested interval is raised during negotiation, and no otherwise
|
||||
/// admissible echo ever waits for rate allowance. That keeps a test about
|
||||
/// parameter pass-through, receive statistics or timestamps from silently
|
||||
/// becoming a test of interval policy — and, since these tests drive the core as
|
||||
/// fast as the machine will run, from being rate-limited by a floor they never
|
||||
/// meant to configure.
|
||||
///
|
||||
/// Tests that *are* about interval negotiation, rate limiting or lifetime build
|
||||
/// their configuration explicitly.
|
||||
pub(crate) fn unthrottled() -> ServerConfig {
|
||||
ServerConfig::default().with_min_send_interval(Duration::ZERO)
|
||||
}
|
||||
|
||||
pub(crate) fn peer() -> SocketAddr {
|
||||
"198.51.100.7:41234".parse().unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn other_peer() -> SocketAddr {
|
||||
"198.51.100.7:41235".parse().unwrap()
|
||||
}
|
||||
|
||||
/// A token source handing out a fixed script of values, then failing.
|
||||
///
|
||||
/// Draining the script is itself a failure rather than a wrap-around, so a test
|
||||
/// that draws more tokens than it scripted is a loud test bug instead of a
|
||||
/// silent repeat.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ScriptedTokens {
|
||||
values: Arc<Mutex<VecDeque<Result<u64, ServerError>>>>,
|
||||
@@ -71,11 +30,6 @@ impl ScriptedTokens {
|
||||
values: Arc::new(Mutex::new(VecDeque::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// How many scripted values are still unused.
|
||||
pub(crate) fn remaining(&self) -> usize {
|
||||
self.values.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl TokenSource for ScriptedTokens {
|
||||
@@ -90,263 +44,14 @@ impl TokenSource for ScriptedTokens {
|
||||
}
|
||||
}
|
||||
|
||||
/// A clock handing out a fixed script of samples, then failing loudly.
|
||||
///
|
||||
/// Running the script dry is a test bug, not a wrap-around: a test that asserts
|
||||
/// timestamps must know exactly which sample each field came from. The core
|
||||
/// takes one sample per authenticated request — when it classifies a datagram as
|
||||
/// an echo, or after authenticating an open or close — and one more when an
|
||||
/// admitted echo is about to be answered.
|
||||
///
|
||||
/// Tests about lifetime and rate want [`ManualClock`] instead: they care about
|
||||
/// *when* a datagram arrives, not about how many times the core reads the clock
|
||||
/// on the way.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ScriptedClock {
|
||||
samples: Arc<Mutex<VecDeque<ClockSample>>>,
|
||||
pub(crate) fn peer() -> SocketAddr {
|
||||
"198.51.100.7:41234".parse().unwrap()
|
||||
}
|
||||
|
||||
impl ScriptedClock {
|
||||
pub(crate) fn new<I>(samples: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = ClockSample>,
|
||||
{
|
||||
Self {
|
||||
samples: Arc::new(Mutex::new(samples.into_iter().collect())),
|
||||
}
|
||||
}
|
||||
|
||||
/// How many scripted samples are still unused.
|
||||
pub(crate) fn remaining(&self) -> usize {
|
||||
self.samples.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl ClockSource for ScriptedClock {
|
||||
fn sample(&mut self) -> ClockSample {
|
||||
self.samples
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("the scripted clock ran out: script one sample per echo received, and one more per echo answered")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sample(wall_ns: i64, mono_ns: i64) -> ClockSample {
|
||||
ClockSample { wall_ns, mono_ns }
|
||||
}
|
||||
|
||||
/// A clock standing still at an instant the test moves by hand.
|
||||
///
|
||||
/// Lifetime and rate behavior is a question of when a datagram arrives, so these
|
||||
/// tests set the time, send a packet, and set it again — with no sleeps, no
|
||||
/// tolerances and nothing riding on how many times the core reads the clock.
|
||||
/// [`ScriptedClock`] remains for the timestamp tests, where every individual
|
||||
/// reading is the point.
|
||||
///
|
||||
/// Both domains read the same value. Lifetime decisions are monotonic-only by
|
||||
/// design, and no test here asserts a wall timestamp, so keeping the pair equal
|
||||
/// makes the readings obviously ordered.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ManualClock {
|
||||
now_ns: Arc<Mutex<i64>>,
|
||||
}
|
||||
|
||||
impl ManualClock {
|
||||
pub(crate) fn at(now_ns: i64) -> Self {
|
||||
Self {
|
||||
now_ns: Arc::new(Mutex::new(now_ns)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the clock to an exact instant. Deliberately absolute: a test that
|
||||
/// pins a deadline reads better stating the instant than accumulating
|
||||
/// offsets.
|
||||
pub(crate) fn set(&self, now_ns: i64) {
|
||||
*self.now_ns.lock().unwrap() = now_ns;
|
||||
}
|
||||
}
|
||||
|
||||
impl ClockSource for ManualClock {
|
||||
fn sample(&mut self) -> ClockSample {
|
||||
let now_ns = *self.now_ns.lock().unwrap();
|
||||
ClockSample {
|
||||
wall_ns: now_ns,
|
||||
mono_ns: now_ns,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A core with deterministic tokens, so session identity is assertable. Its
|
||||
/// clock is the production one, which is fine for everything that does not
|
||||
/// assert timestamp values.
|
||||
pub(crate) fn core_with_tokens(config: ServerConfig, tokens: ScriptedTokens) -> ServerCore {
|
||||
ServerCore::with_token_source(config, Box::new(tokens))
|
||||
}
|
||||
|
||||
/// A core with both nondeterministic sources chosen, for timestamp, rate and
|
||||
/// lifetime tests.
|
||||
pub(crate) fn core_with_sources(
|
||||
config: ServerConfig,
|
||||
tokens: ScriptedTokens,
|
||||
clock: impl ClockSource + 'static,
|
||||
) -> ServerCore {
|
||||
ServerCore::with_sources(config, Box::new(tokens), Box::new(clock))
|
||||
}
|
||||
|
||||
/// An otherwise default core that authenticates iff `hmac_key` is set.
|
||||
///
|
||||
/// Most behavior has to hold identically with and without authentication, so
|
||||
/// tests loop over both and build the core from the key.
|
||||
pub(crate) fn core_for(hmac_key: Option<&[u8]>, tokens: ScriptedTokens) -> ServerCore {
|
||||
let mut config = unthrottled();
|
||||
if let Some(key) = hmac_key {
|
||||
config = config.with_hmac_key(key);
|
||||
}
|
||||
core_with_tokens(config, tokens)
|
||||
}
|
||||
|
||||
/// A core whose clock the test moves by hand, with one session already open.
|
||||
///
|
||||
/// The clock starts at monotonic zero and the session is opened there, so every
|
||||
/// instant a rate or lifetime test names reads as an offset from the open.
|
||||
pub(crate) fn manual_core(
|
||||
config: ServerConfig,
|
||||
requested: &Params,
|
||||
tokens: ScriptedTokens,
|
||||
hmac_key: Option<&[u8]>,
|
||||
) -> (ServerCore, ManualClock, u64, Params) {
|
||||
let clock = ManualClock::at(0);
|
||||
let mut core = core_with_sources(config, tokens, clock.clone());
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), requested, hmac_key);
|
||||
(core, clock, token, negotiated)
|
||||
}
|
||||
|
||||
/// Sends one echo as it would arrive at monotonic instant `at_ns`.
|
||||
pub(crate) fn echo_at(
|
||||
core: &mut ServerCore,
|
||||
clock: &ManualClock,
|
||||
at_ns: i64,
|
||||
token: u64,
|
||||
sequence: u32,
|
||||
params: &Params,
|
||||
hmac_key: Option<&[u8]>,
|
||||
) -> Option<OutboundDatagram> {
|
||||
clock.set(at_ns);
|
||||
core.handle_datagram(
|
||||
peer(),
|
||||
&echo_request(token, sequence, params, &[], hmac_key),
|
||||
)
|
||||
.expect("a rejected or rate-limited echo is not an internal error")
|
||||
}
|
||||
|
||||
/// Opens one ordinary session from `endpoint` and returns its token.
|
||||
pub(crate) fn open_session(
|
||||
core: &mut ServerCore,
|
||||
endpoint: SocketAddr,
|
||||
hmac_key: Option<&[u8]>,
|
||||
) -> u64 {
|
||||
let packet = core
|
||||
.handle_datagram(endpoint, &open_request(&client_params(), hmac_key))
|
||||
.unwrap()
|
||||
.expect("the session-creating open must be answered");
|
||||
expect_normal_open_reply(&packet, hmac_key).token
|
||||
}
|
||||
|
||||
/// Opens one session carrying `params` and returns its token together with the
|
||||
/// parameters the server negotiated for it.
|
||||
///
|
||||
/// Echo tests need both: the token addresses the session, and the negotiated
|
||||
/// params are what the reply is laid out from and what decoding it requires.
|
||||
pub(crate) fn open_negotiated(
|
||||
core: &mut ServerCore,
|
||||
endpoint: SocketAddr,
|
||||
params: &Params,
|
||||
hmac_key: Option<&[u8]>,
|
||||
) -> (u64, Params) {
|
||||
let packet = core
|
||||
.handle_datagram(endpoint, &open_request(params, hmac_key))
|
||||
.unwrap()
|
||||
.expect("the session-creating open must be answered");
|
||||
let reply = expect_normal_open_reply(&packet, hmac_key);
|
||||
(reply.token, reply.params)
|
||||
}
|
||||
|
||||
/// Params requesting exactly the echo layout a test cares about, with the
|
||||
/// duration and interval a well-behaved client sends.
|
||||
pub(crate) fn echo_params(
|
||||
received_stats: irtt_proto::ReceivedStats,
|
||||
stamp_at: irtt_proto::StampAt,
|
||||
clock: irtt_proto::Clock,
|
||||
length: i64,
|
||||
) -> Params {
|
||||
Params {
|
||||
received_stats,
|
||||
stamp_at,
|
||||
clock,
|
||||
length,
|
||||
..client_params()
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes an echo request. `params` supplies only the *request's* own sizing;
|
||||
/// it is deliberately a separate argument from the session's negotiated params
|
||||
/// so tests can send requests shorter and longer than the session negotiated.
|
||||
pub(crate) fn echo_request(
|
||||
token: u64,
|
||||
sequence: u32,
|
||||
params: &Params,
|
||||
payload: &[u8],
|
||||
hmac_key: Option<&[u8]>,
|
||||
) -> Vec<u8> {
|
||||
encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token,
|
||||
sequence,
|
||||
params,
|
||||
payload,
|
||||
},
|
||||
hmac_key,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Decodes an echo reply the server produced and checks the flags every reply
|
||||
/// must carry. Decoding verifies the MAC against `hmac_key` on the way.
|
||||
pub(crate) fn expect_echo_reply(
|
||||
packet: impl AsRef<[u8]>,
|
||||
params: &Params,
|
||||
hmac_key: Option<&[u8]>,
|
||||
) -> EchoReply {
|
||||
let reply = decode_echo_reply(packet.as_ref(), params, hmac_key)
|
||||
.expect("server echo reply must decode");
|
||||
// Exact equality, so Open and Close are pinned clear as well: Open makes an
|
||||
// upstream client abort, and Close belongs to the server-initiated close
|
||||
// slice.
|
||||
let expected = FLAG_REPLY | if hmac_key.is_some() { FLAG_HMAC } else { 0 };
|
||||
assert_eq!(reply.flags, expected, "echo reply flags");
|
||||
reply
|
||||
}
|
||||
|
||||
/// Decodes a server-initiated close: an ordinary echo reply carrying Close.
|
||||
///
|
||||
/// The flags are compared exactly, so Open is pinned clear — a reply with it set
|
||||
/// makes an upstream client abort — and the reply is decoded and its MAC checked
|
||||
/// by the same path as any other, which is the point: the close is not a
|
||||
/// separate packet kind.
|
||||
pub(crate) fn expect_closing_echo_reply(
|
||||
packet: impl AsRef<[u8]>,
|
||||
params: &Params,
|
||||
hmac_key: Option<&[u8]>,
|
||||
) -> EchoReply {
|
||||
let reply = decode_echo_reply(packet.as_ref(), params, hmac_key)
|
||||
.expect("a closing echo reply must decode");
|
||||
let expected = FLAG_REPLY | FLAG_CLOSE | if hmac_key.is_some() { FLAG_HMAC } else { 0 };
|
||||
assert_eq!(reply.flags, expected, "closing echo reply flags");
|
||||
reply
|
||||
}
|
||||
|
||||
/// Encodes a normal open request carrying `params`.
|
||||
pub(crate) fn open_request(params: &Params, hmac_key: Option<&[u8]>) -> Vec<u8> {
|
||||
encode_request(
|
||||
RequestToEncode::Open {
|
||||
@@ -358,86 +63,6 @@ pub(crate) fn open_request(params: &Params, hmac_key: Option<&[u8]>) -> Vec<u8>
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Encodes a no-test open request carrying `params`.
|
||||
pub(crate) fn no_test_request(params: &Params, hmac_key: Option<&[u8]>) -> Vec<u8> {
|
||||
encode_request(
|
||||
RequestToEncode::Open {
|
||||
params,
|
||||
no_test: true,
|
||||
},
|
||||
hmac_key,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Encodes a close request for `token`.
|
||||
pub(crate) fn close_request(token: u64, hmac_key: Option<&[u8]>) -> Vec<u8> {
|
||||
encode_request(RequestToEncode::Close { token }, hmac_key).unwrap()
|
||||
}
|
||||
|
||||
/// Encodes a close request carrying arbitrary bytes after the token.
|
||||
///
|
||||
/// The decoder tolerates them, and this is the shape that proves it. The MAC
|
||||
/// covers the whole datagram, so it is recomputed over the final form rather
|
||||
/// than left behind by the encoder's shorter packet.
|
||||
pub(crate) fn close_request_with_trailing(
|
||||
token: u64,
|
||||
trailing: &[u8],
|
||||
hmac_key: Option<&[u8]>,
|
||||
) -> Vec<u8> {
|
||||
let mut packet = close_request(token, hmac_key);
|
||||
packet.extend_from_slice(trailing);
|
||||
if let Some(key) = hmac_key {
|
||||
irtt_proto::compute_hmac_in_place(key, &mut packet, hmac_field_offset()).unwrap();
|
||||
}
|
||||
packet
|
||||
}
|
||||
|
||||
/// Builds an open request around a raw parameter payload.
|
||||
///
|
||||
/// Used for payloads a compliant encoder cannot produce, such as a truncated
|
||||
/// varint or an out-of-range enum value.
|
||||
pub(crate) fn open_request_with_raw_params(payload: &[u8], hmac_key: Option<&[u8]>) -> Vec<u8> {
|
||||
let mut packet = irtt_proto::MAGIC.to_vec();
|
||||
packet.push(if hmac_key.is_some() {
|
||||
FLAG_OPEN | FLAG_HMAC
|
||||
} else {
|
||||
FLAG_OPEN
|
||||
});
|
||||
if hmac_key.is_some() {
|
||||
packet.extend_from_slice(&[0; irtt_proto::HMAC_SIZE]);
|
||||
}
|
||||
packet.extend_from_slice(payload);
|
||||
if let Some(key) = hmac_key {
|
||||
irtt_proto::compute_hmac_in_place(key, &mut packet, hmac_field_offset()).unwrap();
|
||||
}
|
||||
packet
|
||||
}
|
||||
|
||||
/// The authentication field sits immediately after the fixed header.
|
||||
fn hmac_field_offset() -> usize {
|
||||
PacketLayout::open_request(false).header_len()
|
||||
}
|
||||
|
||||
/// Encodes one integer parameter as `tag` + zigzag value.
|
||||
pub(crate) fn param_int(tag: u64, value: i64) -> Vec<u8> {
|
||||
let mut encoded = Vec::new();
|
||||
varint::encode_uvarint(tag, &mut encoded);
|
||||
varint::encode_varint(value, &mut encoded);
|
||||
encoded
|
||||
}
|
||||
|
||||
/// Encodes a `server_fill` parameter with an explicit declared length, so tests
|
||||
/// can declare a length the payload does not actually carry.
|
||||
pub(crate) fn param_server_fill(declared_len: u64, value: &[u8]) -> Vec<u8> {
|
||||
let mut encoded = Vec::new();
|
||||
varint::encode_uvarint(9, &mut encoded);
|
||||
varint::encode_uvarint(declared_len, &mut encoded);
|
||||
encoded.extend_from_slice(value);
|
||||
encoded
|
||||
}
|
||||
|
||||
/// Params a well-behaved client would request.
|
||||
pub(crate) fn client_params() -> Params {
|
||||
Params {
|
||||
protocol_version: 1,
|
||||
@@ -452,7 +77,6 @@ pub(crate) fn client_params() -> Params {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes a reply the server produced, checking it against the server's key.
|
||||
pub(crate) fn decode_reply(packet: impl AsRef<[u8]>, hmac_key: Option<&[u8]>) -> OpenReply {
|
||||
decode_open_reply(packet.as_ref(), hmac_key).expect("server reply must decode")
|
||||
}
|
||||
@@ -468,12 +92,3 @@ pub(crate) fn expect_normal_open_reply(
|
||||
assert_eq!(reply.params.protocol_version, 1);
|
||||
reply
|
||||
}
|
||||
|
||||
pub(crate) fn expect_no_test_reply(packet: impl AsRef<[u8]>, hmac_key: Option<&[u8]>) -> OpenReply {
|
||||
let reply = decode_reply(packet, hmac_key);
|
||||
let expected =
|
||||
FLAG_OPEN | FLAG_REPLY | FLAG_CLOSE | if hmac_key.is_some() { FLAG_HMAC } else { 0 };
|
||||
assert_eq!(reply.flags, expected, "no-test reply flags");
|
||||
assert_eq!(reply.token, 0, "a no-test reply carries a zero token");
|
||||
reply
|
||||
}
|
||||
@@ -34,7 +34,6 @@ fn a_colliding_draw_is_discarded_and_never_overwrites_the_live_session() {
|
||||
core.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.expect("first open must be answered");
|
||||
let first = core.session(TOKEN_A).cloned().expect("session must exist");
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &request)
|
||||
@@ -43,11 +42,6 @@ fn a_colliding_draw_is_discarded_and_never_overwrites_the_live_session() {
|
||||
|
||||
assert_eq!(expect_normal_open_reply(&packet, None).token, TOKEN_B);
|
||||
assert_eq!(core.session_count(), 2);
|
||||
assert_eq!(
|
||||
core.session(TOKEN_A),
|
||||
Some(&first),
|
||||
"the colliding token's session must be untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -61,7 +55,6 @@ fn exhausting_the_retry_budget_is_an_internal_error_that_creates_nothing() {
|
||||
core.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.expect("first open must be answered");
|
||||
let first = core.session(TOKEN_A).cloned().expect("session must exist");
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &request),
|
||||
@@ -71,7 +64,6 @@ fn exhausting_the_retry_budget_is_an_internal_error_that_creates_nothing() {
|
||||
);
|
||||
|
||||
assert_eq!(core.session_count(), 1, "no session may be half-created");
|
||||
assert_eq!(core.session(TOKEN_A), Some(&first));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
//! Which traffic class the core asks for each reply to be sent with.
|
||||
//!
|
||||
//! The negotiated DSCP parameter is the **raw IPv4 TOS / IPv6 Traffic Class
|
||||
//! byte**, not a six-bit codepoint: the codepoint is its upper six bits and the
|
||||
//! low two are ECN. Nothing here shifts, masks or rounds it, and these tests
|
||||
//! exist to keep it that way.
|
||||
//!
|
||||
//! The clean specification's Section 20 settles the observable rules for values
|
||||
//! in `0..=255`: an echo reply carries its own session's value verbatim, the
|
||||
//! marking follows the session rather than the listener, and an open reply is
|
||||
//! unmarked even on a listener that has already sent a marked echo reply. The
|
||||
//! server-close case is covered where the maximum-duration close is tested, and
|
||||
//! the socket-level proof that these bytes reach the wire lives in the runtime's
|
||||
//! Linux tests.
|
||||
|
||||
use irtt_proto::Params;
|
||||
|
||||
use super::support::{
|
||||
client_params, core_with_tokens, echo_request, expect_echo_reply, expect_no_test_reply,
|
||||
expect_normal_open_reply, no_test_request, open_request, peer, unthrottled, ScriptedTokens,
|
||||
};
|
||||
|
||||
const TOKEN_A: u64 = 0x7896_b6ab_8771_5213;
|
||||
|
||||
/// A raw byte whose low two bits are set on purpose: ECN is part of the value,
|
||||
/// and a server rebuilding the byte from a codepoint would lose them.
|
||||
const RAW_TRAFFIC_CLASS: i64 = 0xbb;
|
||||
|
||||
#[test]
|
||||
fn an_open_reply_is_unmarked_whatever_the_session_negotiates() {
|
||||
let requested = Params {
|
||||
dscp: 0xb8,
|
||||
..client_params()
|
||||
};
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("an ordinary open must be answered");
|
||||
|
||||
assert_eq!(
|
||||
expect_normal_open_reply(&reply, None).params.dscp,
|
||||
0xb8,
|
||||
"the negotiated value is returned to the client"
|
||||
);
|
||||
assert_eq!(
|
||||
reply.traffic_class(),
|
||||
0,
|
||||
"an open reply is unmarked however the session it creates is marked"
|
||||
);
|
||||
|
||||
let no_test = core
|
||||
.handle_datagram(peer(), &no_test_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("a no-test open must be answered");
|
||||
assert_eq!(expect_no_test_reply(&no_test, None).params.dscp, 0xb8);
|
||||
assert_eq!(
|
||||
no_test.traffic_class(),
|
||||
0,
|
||||
"a no-test reply is unmarked too"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_echo_reply_carries_the_sessions_raw_negotiated_byte() {
|
||||
let requested = Params {
|
||||
dscp: RAW_TRAFFIC_CLASS,
|
||||
..client_params()
|
||||
};
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
let open = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("the session-creating open must be answered");
|
||||
let negotiated = expect_normal_open_reply(&open, None).params;
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("an admissible echo must be answered");
|
||||
|
||||
expect_echo_reply(&reply, &negotiated, None);
|
||||
assert_eq!(
|
||||
i64::from(reply.traffic_class()),
|
||||
RAW_TRAFFIC_CLASS,
|
||||
"the raw byte, unshifted and unmasked"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negotiated_value_that_is_not_a_byte_is_transported_unmarked() {
|
||||
// `irtt-rs` policy, not observed behavior. The server deliberately accepts
|
||||
// a DSCP parameter outside `0..=255` and returns and stores it unchanged;
|
||||
// the clean specification records the reference host's handling of such
|
||||
// values as platform-specific and explicitly not a compatibility
|
||||
// requirement. A value that is not a byte cannot be a traffic-class byte,
|
||||
// so the transport asks for none — rather than wrapping −1 into 255,
|
||||
// truncating 256 to 0, or refusing a session the client can otherwise use.
|
||||
for dscp in [-1, 256, i64::MAX] {
|
||||
let requested = Params {
|
||||
dscp,
|
||||
..client_params()
|
||||
};
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let open = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("an open requesting DSCP {dscp} must still be answered"));
|
||||
let negotiated = expect_normal_open_reply(&open, None).params;
|
||||
assert_eq!(negotiated.dscp, dscp, "the value is negotiated unchanged");
|
||||
assert_eq!(open.traffic_class(), 0);
|
||||
assert_eq!(
|
||||
core.session(TOKEN_A)
|
||||
.expect("the session must be live")
|
||||
.params()
|
||||
.dscp,
|
||||
dscp,
|
||||
"and stored unchanged"
|
||||
);
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("the session must still serve echoes with DSCP {dscp}"));
|
||||
assert_eq!(
|
||||
expect_echo_reply(&reply, &negotiated, None).recv_count,
|
||||
Some(1),
|
||||
"an unmarkable session is an ordinary session in every other way"
|
||||
);
|
||||
assert_eq!(reply.traffic_class(), 0, "transported unmarked");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
use std::{net::Ipv4Addr, time::Duration};
|
||||
|
||||
use irtt_proto::{
|
||||
decode_echo_reply, decode_open_reply, encode_request, Clock, Params, ReceivedStats,
|
||||
RequestToEncode, StampAt,
|
||||
};
|
||||
use tokio::{net::UdpSocket, sync::oneshot, time::timeout};
|
||||
|
||||
use irtt_server::{Server, ServerConfig, ServerRuntimeError};
|
||||
use std::{io, net::SocketAddr};
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn bind_reports_local_address_and_shutdown_stops_run() {
|
||||
let requested = SocketAddr::from((Ipv4Addr::LOCALHOST, 0));
|
||||
let mut server = Server::bind(requested, ServerConfig::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let bound = server.local_addr().unwrap();
|
||||
assert_eq!(bound.ip(), requested.ip());
|
||||
assert_ne!(bound.port(), 0);
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
shutdown_tx.send(()).unwrap();
|
||||
server
|
||||
.run(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn ipv4_open_echo_close_crosses_a_real_udp_socket() {
|
||||
exercise_udp_path(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn ipv6_open_echo_close_when_loopback_is_available() {
|
||||
let addr = "[::1]:0".parse().unwrap();
|
||||
let server = match Server::bind(addr, unthrottled()).await {
|
||||
Ok(server) => server,
|
||||
Err(ServerRuntimeError::Bind { source, .. })
|
||||
if matches!(
|
||||
source.kind(),
|
||||
io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported
|
||||
) =>
|
||||
{
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("unexpected IPv6 loopback bind failure: {error}"),
|
||||
};
|
||||
exercise_bound_server(server).await;
|
||||
}
|
||||
|
||||
async fn exercise_udp_path(addr: SocketAddr) {
|
||||
let server = Server::bind(addr, unthrottled()).await.unwrap();
|
||||
exercise_bound_server(server).await;
|
||||
}
|
||||
|
||||
async fn exercise_bound_server(mut server: Server) {
|
||||
let server_addr = server.local_addr().unwrap();
|
||||
let client_bind = match server_addr {
|
||||
SocketAddr::V4(_) => "127.0.0.1:0",
|
||||
SocketAddr::V6(_) => "[::1]:0",
|
||||
};
|
||||
let client = UdpSocket::bind(client_bind).await.unwrap();
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
let server_task = tokio::spawn(async move {
|
||||
server
|
||||
.run(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
});
|
||||
|
||||
let requested = Params {
|
||||
protocol_version: 1,
|
||||
duration_ns: 1_000_000_000,
|
||||
interval_ns: 100_000_000,
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
..Params::default()
|
||||
};
|
||||
let open = encode_request(
|
||||
RequestToEncode::Open {
|
||||
params: &requested,
|
||||
no_test: false,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
client.send_to(&open, server_addr).await.unwrap();
|
||||
|
||||
let mut buffer = vec![0; 65_536];
|
||||
let (len, reply_source) = timeout(Duration::from_secs(1), client.recv_from(&mut buffer))
|
||||
.await
|
||||
.expect("OPEN reply timeout")
|
||||
.unwrap();
|
||||
assert_eq!(reply_source, server_addr);
|
||||
let open_reply = decode_open_reply(&buffer[..len], None).unwrap();
|
||||
assert_ne!(open_reply.token, 0);
|
||||
|
||||
let echo = encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token: open_reply.token,
|
||||
sequence: 0,
|
||||
params: &open_reply.params,
|
||||
payload: &[],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
client.send_to(&echo, server_addr).await.unwrap();
|
||||
let (len, reply_source) = timeout(Duration::from_secs(1), client.recv_from(&mut buffer))
|
||||
.await
|
||||
.expect("ECHO reply timeout")
|
||||
.unwrap();
|
||||
assert_eq!(reply_source, server_addr);
|
||||
let echo_reply = decode_echo_reply(&buffer[..len], &open_reply.params, None).unwrap();
|
||||
assert_eq!(echo_reply.token, open_reply.token);
|
||||
assert_eq!(echo_reply.sequence, 0);
|
||||
assert_eq!(echo_reply.recv_count, Some(1));
|
||||
assert_eq!(echo_reply.recv_window, Some(1));
|
||||
|
||||
let close = encode_request(
|
||||
RequestToEncode::Close {
|
||||
token: open_reply.token,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
client.send_to(&close, server_addr).await.unwrap();
|
||||
client.send_to(&echo, server_addr).await.unwrap();
|
||||
assert!(
|
||||
timeout(Duration::from_millis(100), client.recv_from(&mut buffer))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
shutdown_tx.send(()).unwrap();
|
||||
server_task.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
fn unthrottled() -> ServerConfig {
|
||||
ServerConfig::default().with_min_send_interval(Duration::ZERO)
|
||||
}
|
||||
@@ -465,3 +465,11 @@ where
|
||||
.await
|
||||
.expect("the test exceeded its bounded runtime");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_set_serving_nothing_is_refused() {
|
||||
assert!(matches!(
|
||||
ServerSet::bind([], ServerConfig::default()).await,
|
||||
Err(ServerSetError::NoListeners)
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
|
||||
use irtt_proto::{decode_echo_reply, decode_open_reply, encode_request, Params, RequestToEncode};
|
||||
use irtt_server::{ServerConfig, ServerCore};
|
||||
|
||||
// Admission, authentication, endpoint ownership and capacity are checked through
|
||||
// the public packet interface, with actual OS-generated session tokens.
|
||||
#[test]
|
||||
fn authenticated_sessions_are_bounded_and_owned_by_the_opening_endpoint() {
|
||||
let key = b"session-behavior";
|
||||
let peer: SocketAddr = "127.0.0.1:40000".parse().unwrap();
|
||||
let other: SocketAddr = "127.0.0.1:40001".parse().unwrap();
|
||||
let mut server = ServerCore::new(
|
||||
ServerConfig::default()
|
||||
.with_hmac_key(key)
|
||||
.with_max_sessions(1)
|
||||
.with_min_send_interval(Duration::ZERO),
|
||||
);
|
||||
let params = Params {
|
||||
received_stats: irtt_proto::ReceivedStats::Both,
|
||||
..Params::with_protocol_defaults()
|
||||
};
|
||||
let open = encode_request(
|
||||
RequestToEncode::Open {
|
||||
params: ¶ms,
|
||||
no_test: false,
|
||||
},
|
||||
Some(key),
|
||||
)
|
||||
.unwrap();
|
||||
let unauthenticated = encode_request(
|
||||
RequestToEncode::Open {
|
||||
params: ¶ms,
|
||||
no_test: false,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
for packet in [
|
||||
&[][..],
|
||||
&[0; 16][..],
|
||||
&open[..open.len() - 1],
|
||||
&unauthenticated,
|
||||
] {
|
||||
assert!(server.handle_datagram(peer, packet).unwrap().is_none());
|
||||
assert_eq!(server.session_count(), 0);
|
||||
}
|
||||
let reply = server.handle_datagram(peer, &open).unwrap().unwrap();
|
||||
let opened = decode_open_reply(reply.bytes(), Some(key)).unwrap();
|
||||
assert_ne!(opened.token, 0);
|
||||
assert!(server.handle_datagram(other, &open).unwrap().is_none());
|
||||
assert_eq!(server.session_count(), 1);
|
||||
let echo = encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token: opened.token,
|
||||
sequence: 0,
|
||||
params: &opened.params,
|
||||
payload: &[],
|
||||
},
|
||||
Some(key),
|
||||
)
|
||||
.unwrap();
|
||||
let close = encode_request(
|
||||
RequestToEncode::Close {
|
||||
token: opened.token,
|
||||
},
|
||||
Some(key),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(server.handle_datagram(other, &echo).unwrap().is_none());
|
||||
assert!(server.handle_datagram(other, &close).unwrap().is_none());
|
||||
let mut corrupted = echo.clone();
|
||||
corrupted[4] ^= 1;
|
||||
assert!(server.handle_datagram(peer, &corrupted).unwrap().is_none());
|
||||
let reply = server.handle_datagram(peer, &echo).unwrap().unwrap();
|
||||
let echoed = decode_echo_reply(reply.bytes(), &opened.params, Some(key)).unwrap();
|
||||
assert_eq!(echoed.sequence, 0);
|
||||
assert_eq!(echoed.recv_count, Some(1));
|
||||
assert!(server.handle_datagram(peer, &close).unwrap().is_none());
|
||||
assert_eq!(server.session_count(), 0);
|
||||
assert!(server.handle_datagram(peer, &echo).unwrap().is_none());
|
||||
assert!(server.handle_datagram(other, &open).unwrap().is_some());
|
||||
}
|
||||
|
||||
// Zero allowance and zero idle lifetime expose the boundary behavior directly,
|
||||
// without scripting clock reads or inspecting session/rate representations.
|
||||
#[test]
|
||||
fn zero_burst_and_zero_idle_timeout_silently_refuse_echoes() {
|
||||
let peer: SocketAddr = "127.0.0.1:40000".parse().unwrap();
|
||||
for config in [
|
||||
ServerConfig::default().with_burst_allowance(0),
|
||||
ServerConfig::default().with_idle_timeout(Duration::ZERO),
|
||||
] {
|
||||
let mut server = ServerCore::new(config);
|
||||
let params = Params::with_protocol_defaults();
|
||||
let open = encode_request(
|
||||
RequestToEncode::Open {
|
||||
params: ¶ms,
|
||||
no_test: false,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let reply = server.handle_datagram(peer, &open).unwrap().unwrap();
|
||||
let opened = decode_open_reply(reply.bytes(), None).unwrap();
|
||||
let echo = encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token: opened.token,
|
||||
sequence: 0,
|
||||
params: &opened.params,
|
||||
payload: &[],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(server.handle_datagram(peer, &echo).unwrap().is_none());
|
||||
}
|
||||
}
|
||||
@@ -185,65 +185,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipdv_tracker_late_wrapped_previous_completes_pair() {
|
||||
let mut tracker = IpdvTracker::new(None);
|
||||
assert!(tracker.insert(ipdv_sample(0, 14)).is_empty());
|
||||
|
||||
let pairs = tracker.insert(ipdv_sample(u32::MAX, 10));
|
||||
|
||||
assert_eq!(
|
||||
pairs,
|
||||
vec![CompletedIpdvPair {
|
||||
previous_seq: u32::MAX,
|
||||
current_seq: 0,
|
||||
rtt_ipdv_ns: 4,
|
||||
send_ipdv_ns: None,
|
||||
receive_ipdv_ns: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipdv_tracker_bounded_mode_limits_sequence_state() {
|
||||
let limit = 4;
|
||||
let mut tracker = IpdvTracker::new(Some(limit));
|
||||
|
||||
// Far more inserts than the limit, so eviction runs repeatedly. Every
|
||||
// per-sequence collection must stay bounded by the limit rather than
|
||||
// growing with the number of samples seen, and eviction must not stop
|
||||
// adjacent sequences from still completing a pair.
|
||||
for seq in 0..512_u32 {
|
||||
let pairs = tracker.insert(ipdv_sample(seq, i128::from(seq)));
|
||||
|
||||
assert_eq!(pairs.len(), usize::from(seq > 0), "seq {seq}");
|
||||
assert!(tracker.samples.len() <= limit, "seq {seq}");
|
||||
assert_eq!(
|
||||
tracker.sample_order.len(),
|
||||
tracker.samples.len(),
|
||||
"seq {seq}"
|
||||
);
|
||||
assert!(tracker.completed_pairs.len() <= limit, "seq {seq}");
|
||||
}
|
||||
}
|
||||
|
||||
mod properties {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
proptest! {
|
||||
/// The hand-written `ipdv_tracker_bounded_mode_limits_sequence_state`
|
||||
/// test above only exercises a strictly increasing `0..512`
|
||||
/// sequence. Real traffic can duplicate, skip, or otherwise not
|
||||
/// strictly increase sequence numbers (see the client's
|
||||
/// `LateReply`/`DuplicateReply` handling), so this generates
|
||||
/// arbitrary, possibly-repeated, possibly-non-monotonic sequence
|
||||
/// streams and checks the same bounds still hold after every
|
||||
/// insert: `samples` and `sample_order` never exceed the
|
||||
/// configured limit and stay in lockstep, and `completed_pairs`
|
||||
/// never exceeds `samples.len()` (a successful entry can only
|
||||
/// persist once both its endpoint samples are present, see
|
||||
/// `try_pair`, so it is always a subset of the live sample keys).
|
||||
// Hidden retention state must stay bounded even with duplicates,
|
||||
// gaps and reordered replies; snapshots cannot expose a leak here.
|
||||
#[test]
|
||||
fn bounded_tracker_state_never_exceeds_the_limit(
|
||||
limit in 1usize..8,
|
||||
@@ -253,32 +201,11 @@ mod tests {
|
||||
for seq in seqs {
|
||||
tracker.insert(ipdv_sample(seq, i128::from(seq)));
|
||||
prop_assert!(tracker.samples.len() <= limit);
|
||||
prop_assert_eq!(tracker.sample_order.len(), tracker.samples.len());
|
||||
prop_assert!(tracker.completed_pairs.len() <= tracker.samples.len());
|
||||
prop_assert!(tracker.sample_order.len() <= limit);
|
||||
prop_assert!(tracker.completed_pairs.len() <= limit);
|
||||
}
|
||||
}
|
||||
|
||||
/// A pair for a given adjacent `(seq - 1, seq)` boundary is only
|
||||
/// ever emitted once, however many times `insert` is called
|
||||
/// (including for already-seen or unrelated sequences in
|
||||
/// between). This is the unbounded case, so eviction cannot be
|
||||
/// the reason a pair fails to repeat.
|
||||
#[test]
|
||||
fn each_adjacent_pair_is_emitted_at_most_once(
|
||||
seqs in prop::collection::vec(0u32..12, 0..200),
|
||||
) {
|
||||
let mut tracker = IpdvTracker::new(None);
|
||||
let mut seen_pairs = std::collections::HashSet::new();
|
||||
for seq in seqs {
|
||||
for pair in tracker.insert(ipdv_sample(seq, i128::from(seq))) {
|
||||
let key = (pair.previous_seq, pair.current_seq);
|
||||
prop_assert!(
|
||||
seen_pairs.insert(key),
|
||||
"pair {key:?} emitted more than once"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,43 +218,3 @@ fn duration_ns_i128(duration: Duration) -> i128 {
|
||||
fn signed_duration_ns(duration: SignedDuration) -> i128 {
|
||||
duration.as_nanos()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn system_time_before_epoch_is_supported() {
|
||||
let before = UNIX_EPOCH - Duration::from_nanos(7);
|
||||
assert_eq!(system_time_ns(before), Some(-7));
|
||||
let after = UNIX_EPOCH + Duration::from_nanos(7);
|
||||
assert_eq!(system_time_ns(after), Some(7));
|
||||
let now = SystemTime::now();
|
||||
assert!(system_time_ns(now).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loss_windowing_timestamp_is_clamped_to_its_own_sent_at() {
|
||||
let base = Instant::now();
|
||||
let sent_at = ClientTimestamp {
|
||||
mono: base + Duration::from_millis(50),
|
||||
wall: UNIX_EPOCH + Duration::from_millis(50),
|
||||
};
|
||||
// The pre-send-anchored `timeout_at` can precede this same probe's
|
||||
// own post-send `sent_at` when probe_timeout is small relative to
|
||||
// the send-call duration (see irtt-client's AGENTS.md). Rolling-
|
||||
// window eviction assumes non-decreasing event timestamps, so the
|
||||
// windowing `at()` must not regress behind the probe's own Sent
|
||||
// event even though the public `timeout_at` field legitimately can.
|
||||
let timeout_at = base + Duration::from_millis(10);
|
||||
|
||||
let event = ClientEvent::EchoLoss {
|
||||
seq: 0,
|
||||
sent_at,
|
||||
timeout_at,
|
||||
};
|
||||
|
||||
let normalized = normalize_event(&event).expect("loss event normalizes");
|
||||
assert_eq!(normalized.at(), sent_at.mono);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,6 @@
|
||||
//! Property tests for the count-based and time-based rolling windows.
|
||||
//!
|
||||
//! [`RollingEvents`] (in `src/rolling.rs`) keeps two independent `VecDeque`s:
|
||||
//! one bounded by [`StatsConfig::rolling_count`] and one bounded by
|
||||
//! [`StatsConfig::rolling_time`]. They are pushed to together but evicted
|
||||
//! independently, and each exposes its own snapshot
|
||||
//! ([`StatsCollector::rolling_count`] / [`StatsCollector::rolling_time`]).
|
||||
//! Reading `rolling.rs` and the existing `rolling_count_eviction_recomputes_*`
|
||||
//! / `rolling_time_eviction_uses_event_timestamps` tests in `tests/stats.rs`
|
||||
//! confirms there is no single window bounded by *both* limits at once: the
|
||||
//! two bounds do not compose as AND or OR on shared storage, they are simply
|
||||
//! two separate parallel windows over the same input stream. This file proves
|
||||
//! that independence generatively: with both bounds configured
|
||||
//! simultaneously, each window must behave exactly as if the *other* bound
|
||||
//! did not exist.
|
||||
//!
|
||||
//! The reference model is a plain `Vec<(ClientEvent, at_ms)>` history that is
|
||||
//! re-filtered from scratch after every operation (once by count, once by
|
||||
//! time), then replayed through a fresh [`StatsCollector`] to obtain a
|
||||
//! [`Snapshot`]. Because [`StatsCollector::rolling_count`] /
|
||||
//! [`StatsCollector::rolling_time`] are themselves computed by replaying the
|
||||
//! retained events through a fresh `CoreStats` on every call (see
|
||||
//! `rolling.rs`'s `snapshot_window`), and because both the production window
|
||||
//! and this reference model process the *same* `ClientEvent` values in the
|
||||
//! *same* order through the *same* pure, deterministic normalization and
|
||||
//! aggregation code, the two snapshots are expected to be bit-for-bit equal,
|
||||
//! not merely close. `Snapshot` derives `PartialEq`, so the comparison below
|
||||
//! is exact `assert_eq!` with no floating-point tolerance: there is no
|
||||
//! independent numerical computation on the reference side to accumulate
|
||||
//! error against, both sides run the identical arithmetic in the identical
|
||||
//! order.
|
||||
//!
|
||||
//! The reference model's time-window recomputation (filter the whole history
|
||||
//! by `at_ms >= latest_at_ms - window_ms`) is only equivalent to the
|
||||
//! production incremental sliding-eviction (which pops from the front using
|
||||
//! the cutoff computed at each push) when event timestamps are pushed in
|
||||
//! non-decreasing order, exactly as `normalization.rs` documents ("Rolling-
|
||||
//! window eviction assumes events are pushed in non-decreasing `at()`
|
||||
//! order"). The operation generator below enforces that by construction: a
|
||||
//! shared clock only ever moves forward, and every generated event borrows
|
||||
//! its `at()` timestamp from the clock's current value.
|
||||
//! Independent count and time windows must match a filtered replay through
|
||||
//! StatsCollector, while cumulative accounting retains the complete history.
|
||||
//! Generated events use non-decreasing timestamps, as required by the collector.
|
||||
|
||||
use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||
|
||||
@@ -218,11 +180,7 @@ fn build_event(op: &Op, seq: u32, clock_ms: u64, base: Instant) -> ClientEvent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replays `events` in order through a fresh collector using the rolling
|
||||
/// windows' own semantics (running statistics only, same late-reply policy),
|
||||
/// mirroring exactly what `rolling.rs`'s private `snapshot_window` does for
|
||||
/// the production windows. This is the "recompute from scratch" reference
|
||||
/// primitive both the count and time reference windows below are built on.
|
||||
/// Public reference: collect exactly the events retained by one window.
|
||||
fn replay(events: &[ClientEvent], late_replies: LateReplyMode) -> Snapshot {
|
||||
let mut collector = StatsCollector::new(StatsConfig {
|
||||
samples: SampleMode::RunningOnly,
|
||||
|
||||
Reference in new issue
Block a user