tests: rework the actually useful tests to live in the integration crate

This commit is contained in:
Nils Andreas Svee committed 2026-10-02 14:44:49 +07:00
1 parent 74e7532965
commit 3290a7508a
94 files changed
+1373 -15245

No files matched your search

-145
View File
@@ -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"));
}
}
-192
View File
@@ -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());
}
}
-180
View File
@@ -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}}"));
}
}
-286
View File
@@ -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",
]
);
}
}
-254
View File
@@ -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"));
}
}
-90
View File
@@ -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");
}
}
-391
View File
@@ -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
}
-2
View File
@@ -1,6 +1,4 @@
pub mod args;
#[cfg(test)]
pub(crate) mod env_lock;
pub mod run;
pub use args::ServerArgs;
-102
View File
@@ -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"
);
}
}
-168
View File
@@ -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());
}
}
+89
View File
@@ -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\"")));
}
+23
View File
@@ -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>`
-6
View File
@@ -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"] }
-3
View File
@@ -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();
}
}
-3
View File
@@ -613,6 +613,3 @@ pub(crate) fn validate_datagram_length(expected: usize, actual: usize) -> Result
Err(ClientError::DatagramLengthMismatch { expected, actual })
}
}
#[cfg(test)]
mod tests;
-126
View File
@@ -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, &params, 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, &params, 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,
&params,
&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();
}
-207
View File
@@ -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, &params, None);
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, 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, &params, Some(&wrong_key));
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, 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, &params, 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, &params, 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,
&params,
&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, &params, 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, &params, &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 { .. })
));
}
}
-3
View File
@@ -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)
));
}
}
+4 -152
View File
@@ -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
-133
View File
@@ -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));
}
}
+5 -218
View File
@@ -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);
}
}
}
-39
View File
@@ -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);
});
}
}
-330
View File
@@ -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());
}
}
-163
View File
@@ -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
)
}
}
+8
View File
@@ -0,0 +1,8 @@
use irtt_client::*;
use std::{
net::SocketAddr,
time::{Duration, Instant},
};
#[path = "client/mod.rs"]
mod client;
@@ -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, &params, 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()
},
+76
View File
@@ -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, &params, None);
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, 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, &params, Some(&wrong_key));
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, 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();
}
+22
View File
@@ -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;
@@ -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();
@@ -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, &params, 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();
}
@@ -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;
@@ -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, &params, 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, &params, &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()
},
@@ -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()
},
@@ -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(&params);
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, &params));
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()
},
@@ -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, &params, &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()
},
@@ -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()
},
@@ -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()
},
+22
View File
@@ -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
@@ -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);
}
@@ -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)
));
}
+4 -116
View File
@@ -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);
}
}
+5 -17
View File
@@ -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(),
}
}
+6 -32
View File
@@ -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]);
}
}
+1 -2
View File
@@ -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`]
+1 -197
View File
@@ -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
-8
View File
@@ -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.
///
-186
View File
@@ -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));
}
}
-33
View File
@@ -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"
);
}
}
}
-42
View File
@@ -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));
}
-309
View File
@@ -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());
}
-818
View File
@@ -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(&params);
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(), &params, 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(), &params, 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(), &params, 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(), &params, 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(&params);
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"
);
}
}
-341
View File
@@ -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, &params) + 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));
}
-160
View File
@@ -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));
}
-668
View File
@@ -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(), &params, 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(), &params, 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(), &params, 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(),
&params,
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(&params, 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"
);
}
-621
View File
@@ -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(&params_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)),
&params_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)),
&params_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,
&params_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,
&params_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)),
&params_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)),
&params_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, &params_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,
&params_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)),
&params_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,
&params_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 -26
View File
@@ -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;
-392
View File
@@ -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),
&params,
);
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}");
}
}
-258
View File
@@ -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(&param_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");
}
-458
View File
@@ -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(&param_int(1, 0), None),
Some(_) => open_request(&params, 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(&param_int(42, 7));
payload.extend_from_slice(&param_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"
);
}
}
-170
View File
@@ -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(&param_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"
);
}
}
-257
View File
@@ -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,
&params_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,
&params_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,
&params_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),
&params_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),
&params_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)
);
}
-96
View File
@@ -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);
}
+7 -392
View File
@@ -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
}
-8
View File
@@ -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");
}
}
+147
View File
@@ -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)
}
+8
View File
@@ -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: &params,
no_test: false,
},
Some(key),
)
.unwrap();
let unauthenticated = encode_request(
RequestToEncode::Open {
params: &params,
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: &params,
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());
}
}
+4 -77
View File
@@ -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"
);
}
}
}
}
}
}
-40
View File
@@ -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);
}
}
+4 -46
View File
@@ -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,