diff --git a/Cargo.lock b/Cargo.lock index c046d8e..e45e9f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -718,7 +718,6 @@ name = "irtt-client" version = "0.5.3" dependencies = [ "irtt-proto", - "irtt-server", "nix 0.31.3", "socket2", "thiserror 2.0.21", diff --git a/crates/irtt-app/src/applet.rs b/crates/irtt-app/src/applet.rs index f2459d6..3e8f509 100644 --- a/crates/irtt-app/src/applet.rs +++ b/crates/irtt-app/src/applet.rs @@ -180,148 +180,3 @@ fn applet_argv(applet_name: &str, args: &[OsString]) -> Vec { 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")); - } -} diff --git a/crates/irtt-app/src/cmd/client/args.rs b/crates/irtt-app/src/cmd/client/args.rs index ba3294c..259419a 100644 --- a/crates/irtt-app/src/cmd/client/args.rs +++ b/crates/irtt-app/src/cmd/client/args.rs @@ -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 { - 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 ")); - assert!(!help.contains("--target ")); - assert!(help.contains("--pacing ")); - assert!(help.contains("--columns ")); - assert!(help.contains("--list-columns")); - assert!(help.contains("--header
")); - assert!(help.contains("--tstamp ")); - assert!(help.contains("--stats ")); - assert!(help.contains("--sfill ")); - 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()); - } -} diff --git a/crates/irtt-app/src/cmd/client/output.rs b/crates/irtt-app/src/cmd/client/output.rs index ffcbf7a..ae3e2e8 100644 --- a/crates/irtt-app/src/cmd/client/output.rs +++ b/crates/irtt-app/src/cmd/client/output.rs @@ -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}}")); - } -} diff --git a/crates/irtt-app/src/cmd/client/run.rs b/crates/irtt-app/src/cmd/client/run.rs index ab5496c..f6ac03c 100644 --- a/crates/irtt-app/src/cmd/client/run.rs +++ b/crates/irtt-app/src/cmd/client/run.rs @@ -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::>() - .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::>(), [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::>(), - [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", - ] - ); - } -} diff --git a/crates/irtt-app/src/cmd/client/summary.rs b/crates/irtt-app/src/cmd/client/summary.rs index b738d6a..783dcfa 100644 --- a/crates/irtt-app/src/cmd/client/summary.rs +++ b/crates/irtt-app/src/cmd/client/summary.rs @@ -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")); - } -} diff --git a/crates/irtt-app/src/cmd/format.rs b/crates/irtt-app/src/cmd/format.rs index 0fc1f2f..ceeb5b2 100644 --- a/crates/irtt-app/src/cmd/format.rs +++ b/crates/irtt-app/src/cmd/format.rs @@ -115,93 +115,3 @@ pub(crate) fn format_optional_count(value: Option) -> 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"); - } -} diff --git a/crates/irtt-app/src/cmd/server/args.rs b/crates/irtt-app/src/cmd/server/args.rs index fdd1071..3e16a5b 100644 --- a/crates/irtt-app/src/cmd/server/args.rs +++ b/crates/irtt-app/src/cmd/server/args.rs @@ -257,394 +257,3 @@ fn parse_duration_units(input: &str) -> Result { )), } } - -#[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 { - 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(vars: &[(&str, &str)], body: impl FnOnce() -> T) -> T { - with_env_lock(|| { - let previous: Vec<(&str, Option)> = 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" - ); - } -} diff --git a/crates/irtt-app/src/cmd/server/env_lock.rs b/crates/irtt-app/src/cmd/server/env_lock.rs deleted file mode 100644 index 611f510..0000000 --- a/crates/irtt-app/src/cmd/server/env_lock.rs +++ /dev/null @@ -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 = const { Cell::new(false) }; -} - -pub(crate) fn with_env_lock(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 -} diff --git a/crates/irtt-app/src/cmd/server/mod.rs b/crates/irtt-app/src/cmd/server/mod.rs index 1a2da66..76c37fa 100644 --- a/crates/irtt-app/src/cmd/server/mod.rs +++ b/crates/irtt-app/src/cmd/server/mod.rs @@ -1,6 +1,4 @@ pub mod args; -#[cfg(test)] -pub(crate) mod env_lock; pub mod run; pub use args::ServerArgs; diff --git a/crates/irtt-app/src/cmd/server/run.rs b/crates/irtt-app/src/cmd/server/run.rs index c7a8f4e..442330e 100644 --- a/crates/irtt-app/src/cmd/server/run.rs +++ b/crates/irtt-app/src/cmd/server/run.rs @@ -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 { - 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" - ); - } -} diff --git a/crates/irtt-app/src/cmd/tui/args.rs b/crates/irtt-app/src/cmd/tui/args.rs index c1397d3..75ba262 100644 --- a/crates/irtt-app/src/cmd/tui/args.rs +++ b/crates/irtt-app/src/cmd/tui/args.rs @@ -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 { - let mut argv = vec!["irtt-tui"]; - argv.extend_from_slice(args); - TuiArgs::try_parse_from(argv) - } - - fn parse_shared(args: &[&str]) -> Result { - 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 ")); - } -} diff --git a/crates/irtt-app/src/cmd/tui/ui.rs b/crates/irtt-app/src/cmd/tui/ui.rs index 49f0f9d..2ed05d3 100644 --- a/crates/irtt-app/src/cmd/tui/ui.rs +++ b/crates/irtt-app/src/cmd/tui/ui.rs @@ -1982,1191 +1982,3 @@ fn format_optional_hex(value: Option) -> String { fn duration_ns(value: Duration) -> i128 { i128::try_from(value.as_nanos()).unwrap_or(i128::MAX) } - -#[cfg(test)] -mod tests { - use super::*; - use irtt_client::managed::{ - ManagedTargetFailure, ManagedTargetFailureKind, ManagedTargetFailurePhase, TargetId, - }; - use irtt_client::{ - ClientTimestamp, OneWayDelaySample, PacketMeta, RttSample, ServerTiming, WarningKind, - }; - use ratatui::backend::TestBackend; - use std::{ - net::{IpAddr, Ipv4Addr, SocketAddr}, - sync::Arc, - time::UNIX_EPOCH, - }; - - fn remote() -> SocketAddr { - SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112) - } - - fn ts(offset: Duration) -> ClientTimestamp { - ClientTimestamp { - wall: UNIX_EPOCH + offset, - mono: Instant::now() + offset, - } - } - - fn rtt(effective_ns: i128) -> RttSample { - RttSample { - raw: Duration::from_micros(1_500), - adjusted: Some(SignedDuration::from_nanos(effective_ns)), - effective: SignedDuration::from_nanos(effective_ns), - } - } - - fn reply(seq: u32, effective_ns: i128) -> ClientEvent { - ClientEvent::EchoReply { - seq, - remote: remote(), - sent_at: ts(Duration::from_millis(seq as u64)), - received_at: ts(Duration::from_millis(seq as u64) + Duration::from_micros(1500)), - rtt: rtt(effective_ns), - 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(100)), - }), - one_way: Some(OneWayDelaySample { - client_to_server: Some(SignedDuration::from_nanos(-20_000)), - server_to_client: Some(SignedDuration::from_nanos(30_000)), - }), - received_stats: None, - bytes: 64, - packet_meta: PacketMeta::default(), - } - } - - fn graph_sample(seq: u32, effective_ns: i128) -> GraphSample { - GraphSample { - timestamp: Instant::now() + Duration::from_secs(u64::from(seq)), - seq, - effective_ns, - raw_ns: effective_ns + 1_000, - adjusted_ns: Some(effective_ns), - client_to_server_ns: None, - server_to_client_ns: None, - server_processing_ns: None, - } - } - - fn series(data: Vec<(f64, f64)>) -> ChartSeries { - ChartSeries { - name: GraphMetric::EffectiveRtt.label().to_owned(), - style: GraphMetric::EffectiveRtt.style(), - data, - } - } - - fn viewport(start: Instant, end: Instant) -> GraphViewportRange { - GraphViewportRange { - start, - end, - window: end - .saturating_duration_since(start) - .max(Duration::from_secs(1)), - is_live: true, - } - } - - fn viewport_for_visible(visible: &[&GraphSample]) -> GraphViewportRange { - let start = visible.first().unwrap().timestamp; - let end = visible.last().unwrap().timestamp; - viewport(start, end) - } - - fn primary_target(state: &TuiState) -> &TuiTargetState { - &state.targets[0] - } - - fn target_instance(label: &str) -> TargetInstance { - TargetInstance { - id: TargetId::from(label), - generation: 1, - } - } - - fn target_outcome(label: &str, end_reason: ManagedTargetEndReason) -> ManagedTargetOutcome { - ManagedTargetOutcome { - target: target_instance(label), - server_addr: Arc::from("127.0.0.1:2112"), - remote: Some(remote()), - end_reason, - packets_sent: 0, - replies_received: 0, - duplicates: 0, - late: 0, - warning_events: 0, - cleanup_failure: None, - } - } - - #[test] - fn spans_stay_coarse_above_a_second_and_share_scalar_units_below_it() { - assert_eq!(format_span(Duration::ZERO), "0s"); - assert_eq!(format_span(Duration::from_nanos(750)), "750ns"); - assert_eq!(format_span(Duration::from_micros(500)), "500.0µs"); - assert_eq!(format_span(Duration::from_millis(25)), "25.0ms"); - assert_eq!(format_span(Duration::from_millis(1_500)), "1.5s"); - assert_eq!(format_span(Duration::from_secs(90)), "1m30s"); - } - - #[test] - fn session_started_sets_remote_session_and_running_status() { - let mut state = TuiState::default(); - let target = target_instance("target"); - let event = ClientEvent::SessionStarted { - remote: remote(), - token: 0xabc, - negotiated: NegotiatedParams { - params: irtt_proto::Params::default(), - restrictions: Vec::new(), - }, - at: ts(Duration::ZERO), - }; - state.process_target_event(&target, &event); - - let target = primary_target(&state); - assert_eq!(target.remote.as_deref(), Some("127.0.0.1:2112")); - assert_eq!(target.session.as_deref(), Some("0xabc")); - assert_eq!(state.status, TuiStatus::Running); - assert!(target.negotiated.is_some()); - } - - #[test] - fn echo_reply_appends_bounded_primary_history() { - let mut state = TuiState::default(); - let target = target_instance("target"); - for seq in 0..(HISTORY_LIMIT as u32 + 3) { - let event = reply(seq, i128::from(seq) * 1_000); - state.process_target_event(&target, &event); - } - - let target = primary_target(&state); - assert_eq!(target.graph_history.len(), HISTORY_LIMIT); - assert_eq!(target.graph_history.front().unwrap().seq, 3); - assert_eq!( - target.graph_history.back().unwrap().effective_ns, - i128::from(HISTORY_LIMIT as u32 + 2) * 1_000 - ); - assert_eq!( - target.stats.snapshot().packets.unique_replies, - HISTORY_LIMIT as u64 + 3 - ); - assert!( - target.graph_history.capacity() < HISTORY_LIMIT + HISTORY_LIMIT / 10, - "expected saturated history capacity to stay close to the limit, got {}", - target.graph_history.capacity() - ); - } - - #[test] - fn duplicate_and_late_replies_do_not_append_primary_history() { - let mut state = TuiState::default(); - let target = target_instance("target"); - let duplicate = ClientEvent::DuplicateReply { - seq: 7, - remote: remote(), - received_at: ts(Duration::from_secs(1)), - bytes: 64, - }; - state.process_target_event(&target, &duplicate); - let late = ClientEvent::LateReply { - seq: 8, - highest_seen: 9, - remote: remote(), - sent_at: Some(ts(Duration::from_secs(1))), - received_at: ts(Duration::from_secs(2)), - rtt: Some(rtt(2_000_000)), - server_timing: None, - one_way: None, - received_stats: None, - bytes: 64, - packet_meta: PacketMeta::default(), - }; - state.process_target_event(&target, &late); - - let target = primary_target(&state); - assert!(target.graph_history.is_empty()); - let snapshot = target.stats.snapshot(); - assert_eq!(snapshot.events.duplicate_replies, 1); - // A matched late reply is a late unique reply. The TUI suppresses its - // measurements through the late-reply policy, not by reclassifying it. - assert_eq!(snapshot.events.late_unique_replies, 1); - assert_eq!(snapshot.events.untracked_late_replies, 0); - assert_eq!(snapshot.packets.unique_replies, 1); - assert_eq!(snapshot.packets.duplicates, 1); - assert_eq!(snapshot.packets.late_packets, 1); - assert_eq!(snapshot.rtt.primary.count, 0); - assert_eq!(snapshot.ipdv.round_trip.count, 0); - assert!(state - .recent_events - .iter() - .any(|event| event.contains("duplicate seq=7"))); - assert!(state - .recent_events - .iter() - .any(|event| event.contains("late seq=8"))); - } - - #[test] - fn untracked_late_replies_stay_untracked_in_the_tui() { - let mut state = TuiState::default(); - let target = target_instance("target"); - let late = ClientEvent::LateReply { - seq: 8, - highest_seen: 9, - remote: remote(), - sent_at: None, - received_at: ts(Duration::from_secs(2)), - rtt: None, - server_timing: None, - one_way: None, - received_stats: None, - bytes: 64, - packet_meta: PacketMeta::default(), - }; - state.process_target_event(&target, &late); - - let snapshot = primary_target(&state).stats.snapshot(); - assert_eq!(snapshot.events.untracked_late_replies, 1); - assert_eq!(snapshot.events.late_unique_replies, 0); - assert_eq!(snapshot.packets.unique_replies, 0); - assert_eq!(snapshot.packets.late_packets, 1); - } - - #[test] - fn target_scoped_echo_reply_updates_only_that_target() { - let mut state = - TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned(), "b".to_owned()]); - - let target = target_instance("b"); - let event = reply(11, 2_500_000); - state.process_target_event(&target, &event); - - assert!(state.targets[0].graph_history.is_empty()); - assert_eq!(state.targets[0].stats.snapshot().packets.unique_replies, 0); - assert_eq!(state.targets[1].graph_history.len(), 1); - assert_eq!( - state.targets[1].graph_history.back().unwrap().effective_ns, - 2_500_000 - ); - assert_eq!(state.targets[1].last_sample.unwrap().seq, 11); - assert_eq!(state.targets[1].stats.snapshot().packets.unique_replies, 1); - } - - #[test] - fn target_scoped_duplicate_and_late_are_diagnostic_only() { - let mut state = - TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned(), "b".to_owned()]); - - let a = target_instance("a"); - let duplicate = ClientEvent::DuplicateReply { - seq: 7, - remote: remote(), - received_at: ts(Duration::from_secs(1)), - bytes: 64, - }; - state.process_target_event(&a, &duplicate); - let b = target_instance("b"); - let late = ClientEvent::LateReply { - seq: 8, - highest_seen: 9, - remote: remote(), - sent_at: Some(ts(Duration::from_secs(1))), - received_at: ts(Duration::from_secs(2)), - rtt: Some(rtt(2_000_000)), - server_timing: None, - one_way: None, - received_stats: None, - bytes: 64, - packet_meta: PacketMeta::default(), - }; - state.process_target_event(&b, &late); - - assert!(state.targets[0].graph_history.is_empty()); - assert!(state.targets[1].graph_history.is_empty()); - assert_eq!(state.targets[0].stats.snapshot().packets.duplicates, 1); - assert_eq!(state.targets[1].stats.snapshot().packets.late_packets, 1); - assert_eq!(state.targets[1].stats.snapshot().rtt.primary.count, 0); - } - - #[test] - fn target_scoped_loss_warning_and_terminal_status_update_correct_target() { - let mut state = - TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned(), "b".to_owned()]); - - let a = target_instance("a"); - let loss = ClientEvent::EchoLoss { - seq: 3, - sent_at: ts(Duration::from_millis(3)), - timeout_at: Instant::now(), - }; - state.process_target_event(&a, &loss); - let b = target_instance("b"); - let warning = ClientEvent::Warning { - kind: WarningKind::WrongToken, - message: "wrong token".to_owned(), - at: ts(Duration::ZERO), - }; - state.process_target_event(&b, &warning); - let no_test = ClientEvent::NoTestCompleted { - remote: remote(), - negotiated: NegotiatedParams { - params: irtt_proto::Params::default(), - restrictions: Vec::new(), - }, - at: ts(Duration::ZERO), - }; - state.process_target_event(&a, &no_test); - - assert_eq!(state.targets[0].stats.snapshot().events.loss_events, 1); - assert_eq!(state.targets[0].status, TargetStatus::NoTest); - assert_eq!(state.targets[1].stats.snapshot().events.warning_events, 1); - assert_eq!(state.targets[1].status, TargetStatus::Opening); - assert!(state.targets[1] - .last_warning - .as_deref() - .is_some_and(|warning| warning.contains("WrongToken"))); - } - - #[test] - fn multi_target_global_status_waits_for_all_targets_terminal() { - let mut state = - TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned(), "b".to_owned()]); - - let a = target_instance("a"); - let no_test = ClientEvent::NoTestCompleted { - remote: remote(), - negotiated: NegotiatedParams { - params: irtt_proto::Params::default(), - restrictions: Vec::new(), - }, - at: ts(Duration::ZERO), - }; - state.process_target_event(&a, &no_test); - - assert_eq!(state.targets[0].status, TargetStatus::NoTest); - assert_eq!(state.targets[1].status, TargetStatus::Opening); - assert_eq!(state.status, TuiStatus::Running); - - let b = target_instance("b"); - let closed = ClientEvent::SessionClosed { - remote: remote(), - token: 0xabc, - at: ts(Duration::ZERO), - }; - state.process_target_event(&b, &closed); - - assert_eq!(state.targets[1].status, TargetStatus::Closed); - assert_eq!(state.status, TuiStatus::Complete); - } - - #[test] - fn terminal_failure_marks_the_correct_target_failed() { - let mut state = - TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned(), "b".to_owned()]); - let failure = ManagedTargetFailure { - phase: ManagedTargetFailurePhase::Opening, - kind: ManagedTargetFailureKind::Timeout, - message: "all open requests timed out".into(), - }; - - state.process_target_outcome(&target_outcome( - "b", - ManagedTargetEndReason::Failed(failure), - )); - - assert_eq!(state.targets[0].status, TargetStatus::Opening); - assert_eq!(state.targets[1].status, TargetStatus::Failed); - assert!(state.targets[1] - .last_warning - .as_deref() - .is_some_and(|warning| warning.contains("timeout"))); - } - - #[test] - fn mixed_terminal_outcomes_preserve_success_and_show_failure() { - let mut state = - TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned(), "b".to_owned()]); - state.process_target_outcome(&target_outcome("a", ManagedTargetEndReason::PeerClosed)); - state.process_target_outcome(&target_outcome( - "b", - ManagedTargetEndReason::Failed(ManagedTargetFailure { - phase: ManagedTargetFailurePhase::Receiving, - kind: ManagedTargetFailureKind::Protocol, - message: "pending probe limit exceeded".into(), - }), - )); - - assert_eq!(state.targets[0].status, TargetStatus::Closed); - assert_eq!(state.targets[1].status, TargetStatus::Failed); - assert_eq!(state.status, TuiStatus::Complete); - assert!(state - .last_warning - .as_deref() - .is_some_and(|warning| warning.contains("pending probe limit"))); - } - - #[test] - fn cleanup_failure_warns_without_changing_successful_target_status() { - let mut state = TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned()]); - let mut outcome = target_outcome("a", ManagedTargetEndReason::TestComplete); - outcome.remote = None; - outcome.cleanup_failure = Some(ManagedTargetFailure { - phase: ManagedTargetFailurePhase::Closing, - kind: ManagedTargetFailureKind::Socket, - message: "close datagram failed".into(), - }); - - state.process_target_outcome(&outcome); - - assert_eq!(state.targets[0].status, TargetStatus::Closed); - assert_eq!(state.targets[0].remote, None); - assert!(state.targets[0] - .last_warning - .as_deref() - .is_some_and(|warning| warning.contains("cleanup failed (closing socket)"))); - assert!(state - .recent_events - .iter() - .any(|event| event.contains("cleanup failed (closing socket)"))); - } - - #[test] - fn primary_and_cleanup_failures_are_both_visible() { - let mut state = TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned()]); - let mut outcome = target_outcome( - "a", - ManagedTargetEndReason::Failed(ManagedTargetFailure { - phase: ManagedTargetFailurePhase::Receiving, - kind: ManagedTargetFailureKind::Protocol, - message: "primary failure".into(), - }), - ); - outcome.cleanup_failure = Some(ManagedTargetFailure { - phase: ManagedTargetFailurePhase::Closing, - kind: ManagedTargetFailureKind::Socket, - message: "cleanup failure".into(), - }); - - state.process_target_outcome(&outcome); - - assert_eq!(state.targets[0].status, TargetStatus::Failed); - assert!(state.targets[0] - .last_warning - .as_deref() - .is_some_and(|warning| warning.contains("primary failure") - && warning.contains("cleanup failure"))); - assert!(state - .recent_events - .iter() - .any(|event| event.contains("target failed: protocol: primary failure"))); - assert!(state - .recent_events - .iter() - .any(|event| event.contains("cleanup failed (closing socket): cleanup failure"))); - } - - #[test] - fn managed_outcomes_map_stopped_removed_and_replaced_statuses() { - for (reason, expected) in [ - (ManagedTargetEndReason::Stopped, TargetStatus::Stopped), - (ManagedTargetEndReason::Removed, TargetStatus::Removed), - (ManagedTargetEndReason::Replaced, TargetStatus::Replaced), - ] { - let mut state = TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned()]); - state.process_target_outcome(&target_outcome("a", reason)); - assert_eq!(state.targets[0].status, expected); - } - } - - #[test] - fn run_error_preserves_peer_closed_target_status() { - let mut state = TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned()]); - state.process_target_outcome(&target_outcome("a", ManagedTargetEndReason::PeerClosed)); - - state.set_run_error( - "continuous run ended because of peer closure (1 target session)".to_owned(), - ); - - assert_eq!(state.status, TuiStatus::Error); - assert_eq!(state.targets[0].status, TargetStatus::Closed); - assert!(state - .last_warning - .as_deref() - .is_some_and(|warning| warning.contains("peer closure"))); - } - - #[test] - fn all_failed_terminal_outcomes_set_error_status() { - let mut state = - TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned(), "b".to_owned()]); - for label in ["a", "b"] { - state.process_target_outcome(&target_outcome( - label, - ManagedTargetEndReason::Failed(ManagedTargetFailure { - phase: ManagedTargetFailurePhase::Opening, - kind: ManagedTargetFailureKind::Protocol, - message: "server returned a zero connection token".into(), - }), - )); - } - - assert_eq!(state.status, TuiStatus::Error); - assert!(state - .targets - .iter() - .all(|target| target.status == TargetStatus::Failed)); - } - - #[test] - fn reset_clears_graph_history_for_all_targets() { - let mut state = - TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned(), "b".to_owned()]); - - let a = target_instance("a"); - let a_reply = reply(1, 1_000_000); - state.process_target_event(&a, &a_reply); - let b = target_instance("b"); - let b_reply = reply(2, 2_000_000); - state.process_target_event(&b, &b_reply); - state.clear_visible_history(); - - assert!(state.targets[0].graph_history.is_empty()); - assert!(state.targets[1].graph_history.is_empty()); - assert_eq!( - state.recent_events.back().map(String::as_str), - Some("visible graph history reset") - ); - } - - #[test] - fn warning_updates_recent_events_and_last_warning() { - let mut state = TuiState::default(); - let target = target_instance("target"); - let event = ClientEvent::Warning { - kind: WarningKind::WrongToken, - message: "wrong token".to_owned(), - at: ts(Duration::ZERO), - }; - state.process_target_event(&target, &event); - - assert_eq!( - state.last_warning.as_deref(), - Some("WrongToken: wrong token") - ); - assert!(state - .recent_events - .back() - .unwrap() - .contains("warning WrongToken: wrong token")); - } - - #[test] - fn dropped_events_mark_tui_statistics_incomplete() { - let mut state = TuiState::default(); - - state.mark_dropped_managed_events(9); - - assert_eq!(state.dropped_events, 9); - assert!(state - .last_warning - .as_deref() - .is_some_and(|warning| warning.contains("statistics may be incomplete"))); - assert!(state - .recent_events - .back() - .is_some_and(|event| event.contains("dropped 9 managed run events"))); - } - - #[test] - fn dropped_single_client_events_are_visible() { - let mut state = TuiState::default(); - - state.mark_dropped_managed_events(3); - - assert_eq!(state.dropped_events, 3); - assert!(state - .recent_events - .back() - .is_some_and(|event| event.contains("dropped 3 managed run events"))); - } - - #[test] - fn recent_event_buffer_stays_bounded() { - let mut state = TuiState::default(); - for seq in 0..(RECENT_EVENT_LIMIT as u32 + 5) { - state.push_event(format!("event {seq}")); - } - - assert_eq!(state.recent_events.len(), RECENT_EVENT_LIMIT); - assert_eq!(state.recent_events.front().unwrap(), "event 5"); - } - - #[test] - fn visible_window_selects_samples_inside_viewport() { - let history: VecDeque<_> = (0..300).map(|seq| graph_sample(seq, seq.into())).collect(); - let start = history[100].timestamp; - let end = history[120].timestamp; - - let visible = visible_history_window(&history, viewport(start, end)); - - assert_eq!(visible.first().unwrap().seq, 100); - assert_eq!(visible.last().unwrap().seq, 120); - } - - #[test] - fn chart_bounds_use_only_viewport_local_series_data() { - let start = Instant::now(); - let history: VecDeque<_> = [ - GraphSample { - timestamp: start, - seq: 1, - effective_ns: 1_000_000_000, - raw_ns: 1_000_000_000, - adjusted_ns: Some(1_000_000_000), - client_to_server_ns: None, - server_to_client_ns: None, - server_processing_ns: None, - }, - GraphSample { - timestamp: start + Duration::from_secs(10), - seq: 2, - effective_ns: 10_000_000, - raw_ns: 10_000_000, - adjusted_ns: Some(10_000_000), - client_to_server_ns: None, - server_to_client_ns: None, - server_processing_ns: None, - }, - ] - .into(); - let viewport = viewport( - start + Duration::from_secs(5), - start + Duration::from_secs(15), - ); - let visible = visible_history_window(&history, viewport); - let plotted = graph_series(GraphMetric::EffectiveRtt, &visible, viewport); - - let (_min_y, max_y) = chart_y_bounds(&plotted, GraphMetric::EffectiveRtt.axis_kind()); - - assert!(max_y < 20.0, "max_y={max_y}"); - } - - #[test] - fn non_negative_chart_bounds_do_not_pad_below_zero() { - let series = [series(vec![(0.0, 0.05), (1.0, 0.08), (2.0, 0.1)])]; - let (min_y, max_y) = chart_y_bounds(&series, ChartAxisKind::NonNegative); - - assert_eq!(min_y, 0.0); - assert!(max_y > 0.1, "max_y={max_y}"); - } - - #[test] - fn signed_chart_bounds_can_include_negative_values() { - let series = [series(vec![(0.0, -3.0), (1.0, -1.5), (2.0, 0.5)])]; - let (min_y, max_y) = chart_y_bounds(&series, ChartAxisKind::Signed); - - assert!(min_y < -3.0, "min_y={min_y}"); - assert!(max_y > 0.5, "max_y={max_y}"); - } - - #[test] - fn chart_bounds_handle_empty_and_flat_series() { - assert_eq!(chart_y_bounds(&[], ChartAxisKind::NonNegative), (0.0, 10.0)); - assert_eq!(chart_y_bounds(&[], ChartAxisKind::Signed), (-1.0, 1.0)); - - let flat = [series(vec![(0.0, 12.0), (1.0, 12.0)])]; - let (min_y, max_y) = chart_y_bounds(&flat, ChartAxisKind::Signed); - assert!(min_y < 12.0, "min_y={min_y}"); - assert!(max_y > 12.0, "max_y={max_y}"); - } - - #[test] - fn optional_missing_one_way_series_are_omitted_not_zero_filled() { - let visible_samples = [graph_sample(1, 2_000_000)]; - let visible: Vec<_> = visible_samples.iter().collect(); - let viewport = viewport_for_visible(&visible); - - let one_way = graph_series(GraphMetric::ClientToServer, &visible, viewport); - let rtt = graph_series(GraphMetric::EffectiveRtt, &visible, viewport); - - assert!(one_way.is_empty()); - assert_eq!(rtt.len(), 1); - assert_eq!(rtt[0].name, "effective RTT"); - } - - #[test] - fn multi_target_metric_series_uses_selected_sample_field() { - let mut target = TuiTargetState::new("alpha".to_owned(), stats_config(true)); - let timestamp = Instant::now(); - target.push_graph_sample(GraphSample { - timestamp, - seq: 1, - effective_ns: 1_000_000, - raw_ns: 2_000_000, - adjusted_ns: Some(3_000_000), - client_to_server_ns: None, - server_to_client_ns: None, - server_processing_ns: Some(4_000_000), - }); - let viewport = viewport(timestamp, timestamp + Duration::from_secs(1)); - - assert_eq!( - target_metric_series(&target, 0, viewport, GraphMetric::EffectiveRtt) - .unwrap() - .data, - vec![(0.0, 1.0)] - ); - assert_eq!( - target_metric_series(&target, 0, viewport, GraphMetric::RawRtt) - .unwrap() - .data, - vec![(0.0, 2.0)] - ); - assert_eq!( - target_metric_series(&target, 0, viewport, GraphMetric::AdjustedRtt) - .unwrap() - .data, - vec![(0.0, 3.0)] - ); - assert_eq!( - target_metric_series(&target, 0, viewport, GraphMetric::ServerProcessing) - .unwrap() - .data, - vec![(0.0, 4.0)] - ); - } - - #[test] - fn multi_target_metric_series_skips_missing_optional_samples() { - let mut target = TuiTargetState::new("alpha".to_owned(), stats_config(true)); - let start = Instant::now(); - target.push_graph_sample(GraphSample { - timestamp: start, - seq: 1, - effective_ns: 1_000_000, - raw_ns: 2_000_000, - adjusted_ns: None, - client_to_server_ns: None, - server_to_client_ns: None, - server_processing_ns: None, - }); - target.push_graph_sample(GraphSample { - timestamp: start + Duration::from_secs(1), - seq: 2, - effective_ns: 2_000_000, - raw_ns: 3_000_000, - adjusted_ns: Some(4_000_000), - client_to_server_ns: None, - server_to_client_ns: None, - server_processing_ns: Some(5_000_000), - }); - let viewport = viewport(start, start + Duration::from_secs(2)); - - assert_eq!( - target_metric_series(&target, 0, viewport, GraphMetric::AdjustedRtt) - .unwrap() - .data, - vec![(1.0, 4.0)] - ); - assert_eq!( - target_metric_series(&target, 0, viewport, GraphMetric::ServerProcessing) - .unwrap() - .data, - vec![(1.0, 5.0)] - ); - } - - #[test] - fn pause_suppresses_scheduled_renders_but_allows_forced_controls() { - let now = Instant::now(); - let due = now - Duration::from_millis(1); - - assert!(!should_render(now, due, true, false)); - assert!(should_render(now, due, true, true)); - - let mut state = TuiState::default(); - state.toggle_pause(); - assert!(state.paused); - } - - #[test] - fn sample_details_preserve_signed_values() { - let mut state = TuiState::default(); - let target = target_instance("target"); - let event = reply(4, -1_250_000); - state.process_target_event(&target, &event); - - let target = primary_target(&state); - let sample = target.last_sample.unwrap(); - assert_eq!(sample.seq, 4); - assert_eq!(sample.effective_ns, -1_250_000); - assert_eq!(sample.client_to_server_ns, Some(-20_000)); - assert_eq!(sample.server_processing_ns, Some(100_000)); - - let graph = target.graph_history.back().unwrap(); - assert_eq!(graph.seq, 4); - assert_eq!(graph.effective_ns, -1_250_000); - assert_eq!(graph.client_to_server_ns, Some(-20_000)); - assert_eq!(graph.server_to_client_ns, Some(30_000)); - assert_eq!(graph.server_processing_ns, Some(100_000)); - } - - // Off-screen render coverage. These exercise draw_dashboard through a real - // Ratatui backend, so layout selection, panel presence, and the text a user - // actually sees are protected rather than only the pure state and viewport - // arithmetic covered above. - - /// Render `state` at the given terminal size and return the visible rows. - fn render_rows(state: &TuiState, width: u16, height: u16) -> Vec { - let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - terminal.draw(|frame| draw_dashboard(frame, state)).unwrap(); - let buffer = terminal.backend().buffer().clone(); - (0..buffer.area.height) - .map(|y| { - (0..buffer.area.width) - .map(|x| buffer.cell((x, y)).map(|cell| cell.symbol()).unwrap_or(" ")) - .collect::() - }) - .collect() - } - - fn render_text(state: &TuiState, width: u16, height: u16) -> String { - render_rows(state, width, height).join("\n") - } - - /// Whether a bordered panel with this title is on screen. - /// - /// A panel is identified by its title in a top border, so this cannot be - /// satisfied by body text that happens to mention the same word. - fn has_panel(rows: &[String], title: &str) -> bool { - let needle = format!("\u{250c}{title}"); - rows.iter().any(|row| row.contains(&needle)) - } - - /// The interior rows of one bordered panel, sliced to that panel's columns. - /// - /// Panels sit side by side, so a whole-row search matches text belonging to - /// a neighbour, and most panel content is echoed somewhere else on the - /// frame. Scoping assertions to the owning panel is what makes them - /// protect the panel they name. - fn panel_rows(rows: &[String], title: &str) -> Vec { - let needle: Vec = format!("\u{250c}{title}").chars().collect(); - let (top, start) = rows - .iter() - .enumerate() - .find_map(|(index, row)| { - let chars: Vec = row.chars().collect(); - chars - .windows(needle.len()) - .position(|window| window == needle.as_slice()) - .map(|column| (index, column)) - }) - .unwrap_or_else(|| panic!("no panel titled {title} in:\n{}", rows.join("\n"))); - let title_row: Vec = rows[top].chars().collect(); - let end = title_row[start..] - .iter() - .position(|glyph| *glyph == '\u{2510}') - .map(|offset| start + offset) - .expect("a panel top border ends with a corner"); - rows[top + 1..] - .iter() - .map(|row| { - row.chars() - .skip(start) - .take(end - start + 1) - .collect::() - }) - .take_while(|row| !row.starts_with('\u{2514}')) - .collect() - } - - /// Whether any interior row of `title`'s panel contains `text`. - fn panel_contains(rows: &[String], title: &str, text: &str) -> bool { - panel_rows(rows, title).iter().any(|row| row.contains(text)) - } - - /// Assert every target owns a row in the target table, scoped to that - /// panel's columns. - /// - /// A whole-frame search cannot prove this: the first target is also named - /// in the session header, and a target that replied appears in recent - /// events and as a chart series. - fn assert_target_table_rows(rows: &[String], labels: &[&str], replied: &str) { - let table = panel_rows(rows, "targets"); - let row_for = |label: &str| { - table - .iter() - .find(|row| { - row.trim_start_matches('\u{2502}') - .trim_start() - .starts_with(label) - }) - .unwrap_or_else(|| { - panic!("no target table row for {label} in:\n{}", table.join("\n")) - }) - .clone() - }; - for label in labels { - let row = row_for(label); - assert!( - row.contains("opening"), - "target row for {label} lost its status column: {row}" - ); - } - // The target that replied shows its last sample. - let replied_row = row_for(replied); - assert!( - replied_row.contains("3.0ms"), - "{replied} row lost its last sample: {replied_row}" - ); - } - - /// A single-target state carrying a session, one reply, and one warning. - /// - /// The TUI opens in the graph view, so dashboard tests toggle it. - fn active_single_target_state() -> TuiState { - let mut state = TuiState::default(); - let target = target_instance("target"); - state.process_target_event( - &target, - &ClientEvent::SessionStarted { - remote: remote(), - token: 0xabcd, - negotiated: NegotiatedParams { - params: irtt_proto::Params::default(), - restrictions: Vec::new(), - }, - at: ts(Duration::ZERO), - }, - ); - state.process_target_event(&target, &reply(11, 2_500_000)); - state.process_target_event( - &target, - &ClientEvent::Warning { - kind: WarningKind::WrongToken, - message: "wrong token".to_owned(), - at: ts(Duration::ZERO), - }, - ); - state - } - - fn active_dashboard_state() -> TuiState { - let mut state = active_single_target_state(); - state.toggle_view(); - assert_eq!(state.view, TuiView::Dashboard); - state - } - - #[test] - fn a_terminal_below_the_minimum_width_renders_only_the_resize_notice() { - let state = active_dashboard_state(); - - let text = render_text(&state, MIN_WIDTH - 1, MIN_HEIGHT); - - assert!(text.contains("terminal too small")); - assert!(text.contains("press q / Ctrl-C to quit")); - assert!(!text.contains("packets")); - assert!(!text.contains("q quit")); - } - - #[test] - fn a_terminal_below_the_minimum_height_renders_only_the_resize_notice() { - let state = active_dashboard_state(); - - let text = render_text(&state, MIN_WIDTH, MIN_HEIGHT - 1); - - assert!(text.contains("terminal too small")); - assert!(!text.contains("packets")); - assert!(!text.contains("q quit")); - } - - #[test] - fn the_smallest_usable_terminal_renders_the_compact_dashboard() { - let state = active_dashboard_state(); - - let rows = render_rows(&state, MIN_WIDTH, MIN_HEIGHT); - let text = rows.join("\n"); - - assert!(!text.contains("terminal too small")); - assert!(text.contains("session")); - assert!(text.contains("packets")); - assert!(rows[usize::from(MIN_HEIGHT) - 2].contains("q quit")); - } - - #[test] - fn a_compact_terminal_renders_the_header_packet_and_status_rows() { - let state = active_dashboard_state(); - - let rows = render_rows(&state, 80, 24); - let text = rows.join("\n"); - - assert!(text.contains("irtt-rs")); - assert!(text.contains("session")); - assert!(text.contains("packets")); - assert!(text.contains("sent")); - // The compact layout drops the timing, recent-events, and sample panels - // that only fit in the large layout. - assert!(!text.contains("recent events")); - assert!(!text.contains("last warning:")); - assert!(!text.contains("timing")); - // The status line is the bottom bordered row of the layout. - assert!(rows[22].contains("q quit")); - } - - #[test] - fn a_large_terminal_renders_every_dashboard_panel() { - let state = active_dashboard_state(); - - let rows = render_rows(&state, 180, 44); - let text = rows.join("\n"); - - for expected in [ - "session", - "packets", - "timing", - "recent events", - "sample", - "effective RTT", - "send delay", - "server process", - ] { - assert!(text.contains(expected), "missing {expected} in:\n{text}"); - } - assert!(rows[42].contains("q quit")); - } - - #[test] - fn active_sample_data_and_warnings_are_visible_in_the_large_layout() { - let state = active_dashboard_state(); - - let rows = render_rows(&state, 140, 40); - - // Session identity, on the header panel's own rows: the token is also - // echoed by the recent-events panel. - assert!(panel_contains(&rows, "session", "session: 0xabcd")); - assert!(panel_contains(&rows, "session", "remote: 127.0.0.1:2112")); - // The most recent sample, on the sample panel's own rows. - assert!(panel_contains(&rows, "sample", "last seq: 11")); - let sample = panel_rows(&rows, "sample"); - let sample_metrics = sample - .iter() - .find(|row| row.contains("raw / adjusted / effective:")) - .expect("the sample panel renders a metric row"); - // The whole row, so a regression in any one field is caught: the - // fixture's adjusted and effective RTT share a value, and asserting - // "2.5ms" alone would still pass with the effective field dropped. - assert!( - sample_metrics.contains("raw / adjusted / effective: 1.5ms / 2.5ms / 2.5ms"), - "unexpected sample metric row: {sample_metrics}" - ); - // The warning reaches the recent-events panel as an event line: the - // sample panel's own last-warning row would otherwise satisfy a loose - // "warning" plus "wrong token" search. - assert!(panel_contains( - &rows, - "recent events", - "warning WrongToken: wrong token" - )); - // ...and the sample panel's last-warning row carries the warning - // itself, not the absent-value placeholder. - let last_warning = sample - .iter() - .find(|row| row.contains("last warning:")) - .expect("the sample panel renders a last-warning row"); - assert!( - last_warning.contains("wrong token"), - "last-warning row did not carry the warning: {last_warning}" - ); - } - - #[test] - fn a_failed_run_renders_the_error_status_and_message() { - let mut state = active_dashboard_state(); - state.set_error("open timed out".to_owned()); - - let rows = render_rows(&state, 140, 40); - let text = rows.join("\n"); - - assert!(text.contains("open timed out")); - assert!(rows[38].contains(TuiStatus::Error.label())); - } - - #[test] - fn a_multi_target_state_renders_the_target_table_in_place_of_packet_counters() { - let mut state = TuiState::with_target_labels( - TuiConfig::default(), - ["alpha".to_owned(), "beta".to_owned(), "gamma".to_owned()], - ); - state.toggle_view(); - state.process_target_event(&target_instance("beta"), &reply(4, 3_000_000)); - - let rows = render_rows(&state, 140, 40); - - assert!(has_panel(&rows, "targets")); - assert_target_table_rows(&rows, &["alpha", "beta", "gamma"], "beta"); - assert!(has_panel(&rows, "timing - first target")); - assert!(has_panel(&rows, "sample - first target")); - // The table replaces the single-target packet counters. - assert!(!has_panel(&rows, "packets")); - } - - #[test] - fn the_graph_view_renders_a_chart_instead_of_the_dashboard_panels() { - let state = active_single_target_state(); - assert_eq!(state.view, TuiView::Graph); - - let rows = render_rows(&state, 140, 40); - let text = rows.join("\n"); - - assert!(text.contains(state.graph_metric.title())); - assert!(text.contains("live")); - assert!(rows[38].contains("q quit")); - // The graph view replaces every dashboard panel, not just some. - for panel in ["packets", "timing", "recent events", "sample"] { - assert!( - !has_panel(&rows, panel), - "{panel} panel rendered in the graph view" - ); - } - } - - #[test] - fn the_multi_target_graph_view_keeps_the_target_table_header() { - let mut state = TuiState::with_target_labels( - TuiConfig::default(), - ["alpha".to_owned(), "beta".to_owned()], - ); - state.process_target_event(&target_instance("beta"), &reply(4, 3_000_000)); - - let rows = render_rows(&state, 140, 40); - let text = rows.join("\n"); - - assert!(has_panel(&rows, "targets")); - // Scoped to the table: the replying target also names a chart series. - assert_target_table_rows(&rows, &["alpha", "beta"], "beta"); - assert!(text.contains(state.graph_metric.title())); - } - - #[test] - fn a_paused_display_with_dropped_events_is_reported_on_the_status_line() { - let mut state = active_dashboard_state(); - state.toggle_pause(); - state.mark_dropped_managed_events(3); - - let rows = render_rows(&state, 140, 40); - - assert!(rows[38].contains("display paused")); - assert!(rows[38].contains("incomplete:dropped=3")); - } -} diff --git a/crates/irtt-app/src/shared/client/session.rs b/crates/irtt-app/src/shared/client/session.rs index aa6e45c..26cde72 100644 --- a/crates/irtt-app/src/shared/client/session.rs +++ b/crates/irtt-app/src/shared/client/session.rs @@ -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, - )); - } -} diff --git a/crates/irtt-app/src/shared/client/targets.rs b/crates/irtt-app/src/shared/client/targets.rs index 392c4d3..40d3166 100644 --- a/crates/irtt-app/src/shared/client/targets.rs +++ b/crates/irtt-app/src/shared/client/targets.rs @@ -322,170 +322,3 @@ pub fn parse_stdin_target_set( let args = elements.into_iter().map(TargetArg::new).collect::>(); 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, String> { - target_specs(&inputs.iter().map(|input| target(input)).collect::>()) - } - - #[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()); - } -} diff --git a/crates/irtt-app/tests/cli.rs b/crates/irtt-app/tests/cli.rs new file mode 100644 index 0000000..3eb2285 --- /dev/null +++ b/crates/irtt-app/tests/cli.rs @@ -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\""))); +} diff --git a/crates/irtt-client/Cargo.toml b/crates/irtt-client/Cargo.toml index d171d8a..77d6bc2 100644 --- a/crates/irtt-client/Cargo.toml +++ b/crates/irtt-client/Cargo.toml @@ -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"] } diff --git a/crates/irtt-client/src/async_client.rs b/crates/irtt-client/src/async_client.rs index e6b607b..85d086a 100644 --- a/crates/irtt-client/src/async_client.rs +++ b/crates/irtt-client/src/async_client.rs @@ -886,6 +886,3 @@ fn poll_readable_until( }, } } - -#[cfg(test)] -mod tests; diff --git a/crates/irtt-client/src/async_client/in_tree_server_support.rs b/crates/irtt-client/src/async_client/in_tree_server_support.rs deleted file mode 100644 index 0375aa3..0000000 --- a/crates/irtt-client/src/async_client/in_tree_server_support.rs +++ /dev/null @@ -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>, - done: Option>>, -} - -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(); - } -} diff --git a/crates/irtt-client/src/client.rs b/crates/irtt-client/src/client.rs index 39329ec..5ef9b03 100644 --- a/crates/irtt-client/src/client.rs +++ b/crates/irtt-client/src/client.rs @@ -613,6 +613,3 @@ pub(crate) fn validate_datagram_length(expected: usize, actual: usize) -> Result Err(ClientError::DatagramLengthMismatch { expected, actual }) } } - -#[cfg(test)] -mod tests; diff --git a/crates/irtt-client/src/client/tests/dscp.rs b/crates/irtt-client/src/client/tests/dscp.rs deleted file mode 100644 index d4e0891..0000000 --- a/crates/irtt-client/src/client/tests/dscp.rs +++ /dev/null @@ -1,126 +0,0 @@ -use super::*; - -#[test] -#[cfg(not(any( - target_os = "fuchsia", - target_os = "redox", - target_os = "solaris", - target_os = "illumos", - target_os = "haiku", -)))] -fn normal_open_applies_negotiated_traffic_class_after_open_and_close_clears_it() { - let mut params = default_params(); - // Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF): a - // compliant server echoes back the raw byte it received, unchanged. - params.dscp = 184; - let server = start_fake_server(move |socket, tx| { - let (_, peer) = recv_request(&socket, &tx); - let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None); - socket.send_to(&reply, peer).unwrap(); - let _ = recv_request(&socket, &tx); - }); - let mut config = default_test_config(server.addr); - config.dscp = 46; - let mut client = Client::connect(config).unwrap(); - - assert_eq!( - socket2::SockRef::from(&client.socket).tos_v4().unwrap() & 0xfc, - 0 - ); - assert_open_started(client.open().unwrap()); - assert_eq!( - socket2::SockRef::from(&client.socket).tos_v4().unwrap() & 0xfc, - 184 - ); - - client.close().unwrap(); - assert_eq!(socket2::SockRef::from(&client.socket).tos_v4().unwrap(), 0); - server.join(); -} - -#[test] -#[cfg(not(any( - target_os = "fuchsia", - target_os = "redox", - target_os = "solaris", - target_os = "illumos", - target_os = "haiku", -)))] -fn normal_open_uses_negotiated_traffic_class_not_requested_dscp() { - let mut returned = default_params(); - returned.dscp = 0; - let server = open_success_server(returned); - - let mut config = default_test_config(server.addr); - config.dscp = 46; - config.negotiation_policy = NegotiationPolicy::Loose; - let mut client = Client::connect(config).unwrap(); - - let negotiated = assert_open_started(client.open().unwrap()); - assert_eq!(negotiated.params.dscp, 0); - assert_eq!( - negotiated.restrictions, - vec![crate::NegotiationRestriction::DscpChanged { - requested: 46, - negotiated: 0, - }] - ); - assert_eq!(socket2::SockRef::from(&client.socket).tos_v4().unwrap(), 0); - server.join(); -} - -#[test] -#[cfg(not(any( - target_os = "fuchsia", - target_os = "redox", - target_os = "solaris", - target_os = "illumos", - target_os = "haiku" -)))] -fn authenticated_peer_close_clears_negotiated_traffic_class() { - let mut params = default_params(); - // Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF). - params.dscp = 184; - let server = start_fake_server(move |socket, tx| { - let (_, peer) = recv_request(&socket, &tx); - socket - .send_to( - &open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None), - peer, - ) - .unwrap(); - let (request, _) = recv_request(&socket, &tx); - let seq = u32::from_le_bytes(request[12..16].try_into().unwrap()); - socket - .send_to( - &echo_reply_packet_with_flags( - TOKEN, - seq, - ¶ms, - &TimestampFields::default(), - None, - FLAG_REPLY | flags::FLAG_CLOSE, - ), - peer, - ) - .unwrap(); - }); - let mut config = default_test_config(server.addr); - config.dscp = 46; - let mut client = Client::connect(config).unwrap(); - assert_open_started(client.open().unwrap()); - client.send_probe().unwrap(); - - let events = client.recv_once().unwrap(); - - assert!(matches!( - events.as_slice(), - [ - ClientEvent::EchoReply { .. }, - ClientEvent::SessionClosed { .. } - ] - )); - - assert_eq!(socket2::SockRef::from(&client.socket).tos_v4().unwrap(), 0); - server.join(); -} diff --git a/crates/irtt-client/src/client/tests/hmac.rs b/crates/irtt-client/src/client/tests/hmac.rs deleted file mode 100644 index 0a4c6dd..0000000 --- a/crates/irtt-client/src/client/tests/hmac.rs +++ /dev/null @@ -1,207 +0,0 @@ -use super::*; - -#[test] -fn hmac_open_ignores_missing_hmac_before_valid_reply() { - let key = b"secret".to_vec(); - let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1))); - config.hmac_key = Some(key.clone()); - let params = params_from_config(&config).unwrap(); - let server = start_fake_server(move |socket, tx| { - let (_, peer) = recv_request(&socket, &tx); - let missing = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None); - let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&key)); - socket.send_to(&missing, peer).unwrap(); - socket.send_to(&valid, peer).unwrap(); - }); - config.server_addr = server.addr.to_string(); - let mut client = Client::connect(config).unwrap(); - assert_open_started(client.open().unwrap()); - assert_eq!(server.rx.iter().take(1).count(), 1); - assert!(server.rx.try_recv().is_err()); - server.join(); -} - -#[test] -fn hmac_open_ignores_bad_hmac_before_valid_reply() { - let key = b"secret".to_vec(); - let wrong_key = b"wrong".to_vec(); - let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1))); - config.hmac_key = Some(key.clone()); - let params = params_from_config(&config).unwrap(); - let server = start_fake_server(move |socket, tx| { - let (_, peer) = recv_request(&socket, &tx); - let bad = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&wrong_key)); - let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&key)); - socket.send_to(&bad, peer).unwrap(); - socket.send_to(&valid, peer).unwrap(); - }); - config.server_addr = server.addr.to_string(); - let mut client = Client::connect(config).unwrap(); - assert_open_started(client.open().unwrap()); - assert_eq!(server.rx.iter().take(1).count(), 1); - assert!(server.rx.try_recv().is_err()); - server.join(); -} - -#[test] -fn post_token_hmac_negotiation_failure_sends_authenticated_cleanup_close() { - let key = b"secret".to_vec(); - let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1))); - config.hmac_key = Some(key.clone()); - let mut returned = params_from_config(&config).unwrap(); - returned.interval_ns += 1; - let server_key = key.clone(); - let server = start_fake_server(move |socket, tx| { - let (_, peer) = recv_request(&socket, &tx); - let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, Some(&server_key)); - socket.send_to(&reply, peer).unwrap(); - let _ = recv_request(&socket, &tx); - }); - config.server_addr = server.addr.to_string(); - let mut client = Client::connect(config).unwrap(); - - assert!(matches!( - client.open(), - Err(ClientError::NegotiationRejected { .. }) - )); - let packets: Vec<_> = server.rx.iter().take(2).collect(); - let cleanup = &packets[1]; - assert_eq!(cleanup[3], flags::FLAG_CLOSE | FLAG_HMAC); - verify_packet_hmac(&key, cleanup).unwrap(); - assert_eq!( - u64::from_le_bytes(cleanup[4 + HMAC_SIZE..12 + HMAC_SIZE].try_into().unwrap()), - TOKEN - ); - server.join(); -} - -#[test] -fn hmac_close_packet_includes_valid_hmac() { - let key = b"secret".to_vec(); - let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1))); - config.hmac_key = Some(key.clone()); - let params = params_from_config(&config).unwrap(); - let server_key = key.clone(); - let server = start_fake_server(move |socket, tx| { - let (_, peer) = recv_request(&socket, &tx); - let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&server_key)); - socket.send_to(&reply, peer).unwrap(); - let _ = recv_request(&socket, &tx); - }); - config.server_addr = server.addr.to_string(); - let mut client = Client::connect(config).unwrap(); - assert_open_started(client.open().unwrap()); - client.close().unwrap(); - let packets: Vec<_> = server.rx.iter().take(2).collect(); - let close = &packets[1]; - assert_eq!(close[3], flags::FLAG_CLOSE | FLAG_HMAC); - verify_packet_hmac(&key, close).unwrap(); - assert_eq!( - u64::from_le_bytes(close[4 + HMAC_SIZE..12 + HMAC_SIZE].try_into().unwrap()), - TOKEN - ); - server.join(); -} - -#[test] -fn bad_hmac_reply_is_dropped() { - let key = b"secret".to_vec(); - let wrong_key = b"wrong".to_vec(); - let params = default_params(); - let server_key = key.clone(); - let server = start_fake_server(move |socket, tx| { - let (_, peer) = recv_request(&socket, &tx); - let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&server_key)); - socket.send_to(&reply, peer).unwrap(); - - socket - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); - let mut buf = [0_u8; 2048]; - if let Ok((size, _)) = socket.recv_from(&mut buf) { - tx.send(buf[..size].to_vec()).unwrap(); - let seq = u32::from_le_bytes( - buf[4 + HMAC_SIZE + 8..4 + HMAC_SIZE + 12] - .try_into() - .unwrap(), - ); - let ts = TimestampFields::default(); - let reply_packet = echo_reply_packet_with_flags( - TOKEN, - seq, - ¶ms, - &ts, - Some(&wrong_key), - FLAG_REPLY | flags::FLAG_CLOSE, - ); - socket.send_to(&reply_packet, peer).unwrap(); - } - }); - let config = ClientConfig { - hmac_key: Some(key), - socket_config: crate::SocketConfig { - recv_timeout: Some(Duration::from_millis(200)), - ..Default::default() - }, - ..default_test_config(server.addr) - }; - let mut client = Client::connect(config).unwrap(); - assert_open_started(client.open().unwrap()); - client.send_probe().unwrap(); - thread::sleep(Duration::from_millis(30)); - let events = client.recv_once().unwrap(); - assert_eq!(events.len(), 1); - assert!(matches!(&events[0], ClientEvent::Warning { .. })); - assert!(!client.is_peer_closed()); - client.close().unwrap(); - server.join(); -} - -#[test] -fn hmac_echo_request_reply_works() { - let key = b"testkey".to_vec(); - let params = default_params(); - let server_key = key.clone(); - let server = start_fake_server(move |socket, tx| { - let (_, peer) = recv_request(&socket, &tx); - let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&server_key)); - socket.send_to(&reply, peer).unwrap(); - - socket - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); - let mut buf = [0_u8; 2048]; - if let Ok((size, _)) = socket.recv_from(&mut buf) { - tx.send(buf[..size].to_vec()).unwrap(); - verify_packet_hmac(&server_key, &buf[..size]).unwrap(); - let seq = u32::from_le_bytes( - buf[4 + HMAC_SIZE + 8..4 + HMAC_SIZE + 12] - .try_into() - .unwrap(), - ); - let ts = TimestampFields { - recv_mono: Some(100), - send_mono: Some(200), - ..Default::default() - }; - let reply_packet = echo_reply_packet(TOKEN, seq, ¶ms, &ts, Some(&server_key)); - socket.send_to(&reply_packet, peer).unwrap(); - } - }); - let config = ClientConfig { - hmac_key: Some(key), - socket_config: crate::SocketConfig { - recv_timeout: Some(Duration::from_millis(200)), - ..Default::default() - }, - ..default_test_config(server.addr) - }; - let mut client = Client::connect(config).unwrap(); - assert_open_started(client.open().unwrap()); - client.send_probe().unwrap(); - thread::sleep(Duration::from_millis(50)); - let events = client.recv_once().unwrap(); - assert_eq!(events.len(), 1); - assert!(matches!(&events[0], ClientEvent::EchoReply { .. })); - server.join(); -} diff --git a/crates/irtt-client/src/client/tests/mod.rs b/crates/irtt-client/src/client/tests/mod.rs deleted file mode 100644 index 3a1f0d8..0000000 --- a/crates/irtt-client/src/client/tests/mod.rs +++ /dev/null @@ -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; diff --git a/crates/irtt-client/src/client/tests/negotiation.rs b/crates/irtt-client/src/client/tests/negotiation.rs deleted file mode 100644 index fa48adb..0000000 --- a/crates/irtt-client/src/client/tests/negotiation.rs +++ /dev/null @@ -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" - ); -} diff --git a/crates/irtt-client/src/client/tests/probes_replies/timing_math.rs b/crates/irtt-client/src/client/tests/probes_replies/timing_math.rs deleted file mode 100644 index de4056f..0000000 --- a/crates/irtt-client/src/client/tests/probes_replies/timing_math.rs +++ /dev/null @@ -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)) - ); -} diff --git a/crates/irtt-client/src/client/tests/sequence.rs b/crates/irtt-client/src/client/tests/sequence.rs deleted file mode 100644 index 52dc8f5..0000000 --- a/crates/irtt-client/src/client/tests/sequence.rs +++ /dev/null @@ -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)); -} diff --git a/crates/irtt-client/src/client/tests/ttl.rs b/crates/irtt-client/src/client/tests/ttl.rs deleted file mode 100644 index 16d89ca..0000000 --- a/crates/irtt-client/src/client/tests/ttl.rs +++ /dev/null @@ -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 { .. }) - )); - } -} diff --git a/crates/irtt-client/src/managed/mod.rs b/crates/irtt-client/src/managed/mod.rs index 89b449d..a87a2ad 100644 --- a/crates/irtt-client/src/managed/mod.rs +++ b/crates/irtt-client/src/managed/mod.rs @@ -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; diff --git a/crates/irtt-client/src/managed/schedule.rs b/crates/irtt-client/src/managed/schedule.rs index 854314d..4401abc 100644 --- a/crates/irtt-client/src/managed/schedule.rs +++ b/crates/irtt-client/src/managed/schedule.rs @@ -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) - )); - } -} diff --git a/crates/irtt-client/src/managed/task.rs b/crates/irtt-client/src/managed/task.rs index 1a09358..a007269 100644 --- a/crates/irtt-client/src/managed/task.rs +++ b/crates/irtt-client/src/managed/task.rs @@ -177,74 +177,6 @@ pub struct ManagedClientHandle { max_live_target_generations: usize, status: watch::Receiver>, events: broadcast::WeakSender, - #[cfg(test)] - update_submission_hook: Option>, -} - -// Pauses between admission and enqueue to exercise stop/seal linearization. -#[cfg(test)] -#[derive(Default)] -struct UpdateSubmissionTestHook { - state: std::sync::Mutex, - 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> + Send + 'static>>; type WakeFuture = Pin>> + Send + 'static>>; -// Snapshots at publication verify status is visible before the corresponding event. -#[cfg(test)] -type EventObservations = Arc)>>>; fn arm_wake(mut receiver: watch::Receiver<()>) -> WakeFuture { Box::pin(async move { @@ -710,20 +634,6 @@ pub struct ManagedClientTask { final_outcome: Option>, next_generation: u64, applied_command_sequence: u64, - #[cfg(test)] - event_observations: Option, - #[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; diff --git a/crates/irtt-client/src/managed/tests.rs b/crates/irtt-client/src/managed/tests.rs deleted file mode 100644 index 3127a84..0000000 --- a/crates/irtt-client/src/managed/tests.rs +++ /dev/null @@ -1,1932 +0,0 @@ -use std::{ - future::{poll_fn, Future}, - net::{SocketAddr, UdpSocket}, - pin::Pin, - sync::{Arc, Condvar, 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 super::*; -use crate::{ClientAuthConfig, RunMode}; - -const TOKEN: u64 = 0x1234_5678_90ab_cdef; - -#[derive(Clone, Copy)] -enum ServerBehavior { - Echo, - NoTest, - PeerClose, - InvalidOpen, - DelayedEcho(Duration), - DeferredBurst(usize), - NoisyOpen(usize), -} - -#[derive(Clone, Debug)] -enum PacketKind { - Open, - Probe, - Close, -} - -#[derive(Clone, Debug)] -struct PacketRecord { - kind: PacketKind, - at: Instant, -} - -struct TestServer { - addr: SocketAddr, - records: Arc>>, - probe_seen: Arc<(Mutex, Condvar)>, - reply_sent: Arc<(Mutex, Condvar)>, - open_reply_sent: Arc<(Mutex, Condvar)>, - thread: JoinHandle<()>, -} - -impl TestServer { - fn finish(self) -> Vec { - self.thread.join().unwrap(); - self.records.lock().unwrap().clone() - } -} - -#[derive(Default)] -struct PacketGate { - seen: Mutex, - seen_ready: Condvar, - released: Mutex, - release_ready: Condvar, -} - -impl PacketGate { - fn arrive_and_wait(&self) { - *self.seen.lock().unwrap() = true; - self.seen_ready.notify_all(); - let released = self.released.lock().unwrap(); - let (released, timeout) = self - .release_ready - .wait_timeout_while(released, Duration::from_secs(2), |released| !*released) - .unwrap(); - assert!(*released && !timeout.timed_out()); - } - fn release(&self) { - *self.released.lock().unwrap() = true; - self.release_ready.notify_all(); - } -} - -/// 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> { - 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>) -> TestServer { - start_server_negotiating(behavior, key, None) -} -fn start_server_negotiating( - behavior: ServerBehavior, - key: Option>, - interval: Option, -) -> TestServer { - start_server_with_gates(behavior, key, interval, None, None) -} - -fn start_server_with_gates( - behavior: ServerBehavior, - key: Option>, - interval: Option, - open_gate: Option>, - reply_gate: Option>, -) -> 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 probe_seen = Arc::new((Mutex::new(false), Condvar::new())); - let thread_probe_seen = Arc::clone(&probe_seen); - let reply_sent = Arc::new((Mutex::new(false), Condvar::new())); - let thread_reply_sent = Arc::clone(&reply_sent); - let open_reply_sent = Arc::new((Mutex::new(false), Condvar::new())); - let thread_open_reply_sent = Arc::clone(&open_reply_sent); - 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 mut negotiated = decode_open_params(&buffer[..open_len], key.as_deref()); - if let Some(gate) = &open_gate { - gate.arrive_and_wait(); - } - if matches!(behavior, ServerBehavior::InvalidOpen) { - socket.send_to(&[0_u8], peer).unwrap(); - return; - } - if let Some(interval) = interval { - negotiated.interval_ns = i64::try_from(interval.as_nanos()).unwrap(); - } - 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(); - let noisy_open = matches!(behavior, ServerBehavior::NoisyOpen(_)); - if let ServerBehavior::NoisyOpen(count) = behavior { - for _ in 0..count { - socket.send_to(&[0_u8], peer).unwrap(); - } - } - socket.send_to(&open_reply, peer).unwrap(); - let (sent, ready) = &*thread_open_reply_sent; - *sent.lock().unwrap() = true; - ready.notify_all(); - if no_test || noisy_open { - return; - } - - let mut deferred_burst_sent = false; - 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(), - }); - let (seen, ready) = &*thread_probe_seen; - *seen.lock().unwrap() = true; - ready.notify_all(); - if let ServerBehavior::DelayedEcho(delay) = behavior { - thread::sleep(delay); - } - if let ServerBehavior::DeferredBurst(count) = behavior { - if deferred_burst_sent { - continue; - } - deferred_burst_sent = true; - thread::sleep(Duration::from_millis(30)); - for reply_index in 0..count { - let reply = encode_echo_reply( - &EchoReply { - flags: flags::FLAG_REPLY, - token: TOKEN, - sequence: if reply_index < 2 { sequence } else { u32::MAX }, - recv_count: None, - recv_window: None, - timestamps: TimestampFields::default(), - payload: Vec::new(), - }, - &negotiated, - key.as_deref(), - ) - .unwrap(); - socket.send_to(&reply, packet_peer).unwrap(); - } - continue; - } - if let Some(gate) = &reply_gate { - gate.arrive_and_wait(); - } - 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(); - let (sent, ready) = &*thread_reply_sent; - *sent.lock().unwrap() = true; - ready.notify_all(); - 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, - probe_seen, - reply_sent, - open_reply_sent, - 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(future: Pin<&mut F>) -> Poll { - Future::poll(future, &mut Context::from_waker(Waker::noop())) -} -fn drive_task_until( - runtime: &Runtime, - task: &mut Pin>, - condition: impl Fn(&ManagedClientTask) -> bool, -) { - runtime.block_on(poll_fn(|cx| { - assert!( - task.as_mut().poll(cx).is_pending(), - "task completed before condition" - ); - if condition(task.as_ref().get_ref()) { - Poll::Ready(()) - } else { - Poll::Pending - } - })); -} - -fn wait_flag(flag: &Arc<(Mutex, Condvar)>) { - let (flag, ready) = &**flag; - let value = flag.lock().unwrap(); - let (value, timeout) = ready - .wait_timeout_while(value, Duration::from_secs(2), |value| !*value) - .unwrap(); - assert!(*value && !timeout.timed_out()); -} - -fn probes(records: &[PacketRecord]) -> Vec { - 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)) -} - -fn echo_sends( - observations: &Mutex)>>, -) -> Vec<(TargetInstance, u32, Instant, Instant)> { - observations - .lock() - .unwrap() - .iter() - .filter_map(|(event, _)| match event { - ManagedEvent::Client { - target, - event: - ClientEvent::EchoSent { - seq, - scheduled_at, - sent_at, - .. - }, - } => Some(( - target.clone(), - *seq, - scheduled_at.expect("managed sends carry schedule metadata"), - sent_at.mono, - )), - _ => None, - }) - .collect() -} - -fn assert_rotated_without_catchup(sends: &[(TargetInstance, u32, Instant, Instant)]) { - assert!(sends.len() >= 4); - assert_ne!(sends[0].0, sends[1].0); - for target in [&sends[0].0, &sends[1].0] { - let target_sends = sends - .iter() - .filter(|(instance, ..)| instance == target) - .collect::>(); - assert!(target_sends.len() >= 2); - assert!(target_sends[1].2 > target_sends[0].3); - } -} - -fn run_deferred_drain_burst(packet_count: usize) -> ManagedOutcome { - let server = start_server(ServerBehavior::DeferredBurst(packet_count), None); - let mut managed = config(ManagedPacing::Staggered); - managed.completion = ManagedCompletionPolicy::ExplicitStop; - managed.client.duration = None; - managed.client.interval = Duration::from_millis(100); - managed.client.probe_timeout = Duration::from_millis(60); - managed.final_drain = Duration::from_millis(20); - let (mut task, handle) = - ManagedClient::task(managed, vec![target("one", server.addr)]).unwrap(); - task.drain_test_hook.defer_work_until_deadline = true; - let seen = Arc::clone(&server.probe_seen); - let stopper = thread::spawn(move || { - let (flag, ready) = &*seen; - let guard = flag.lock().unwrap(); - let (guard, timeout) = ready - .wait_timeout_while(guard, Duration::from_secs(2), |seen| !*seen) - .unwrap(); - assert!(*guard && !timeout.timed_out()); - drop(handle.stop()); - }); - let outcome = runtime().block_on(async { - tokio::time::timeout(Duration::from_secs(2), task) - .await - .unwrap() - }); - stopper.join().unwrap(); - server.finish(); - outcome -} - -#[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 dynamic_addition_opens_and_operates() { - let server = start_server(ServerBehavior::Echo, None); - let mut managed = config(ManagedPacing::Staggered); - managed.completion = ManagedCompletionPolicy::ExplicitStop; - managed.client.duration = None; - let (task, handle) = ManagedClient::task(managed, vec![]).unwrap(); - let receipt = handle - .update_targets(vec![target("added", server.addr)]) - .unwrap(); - let runtime = runtime(); - let mut task = Box::pin(task); - drive_task_until(&runtime, &mut task, |task| { - task.targets[0].counters.packets_sent != 0 - }); - let acknowledgement = runtime.block_on(receipt).unwrap(); - assert_eq!(acknowledgement.sequence, 1); - assert_eq!(acknowledgement.status.targets[0].target.generation, 1); - assert!(acknowledgement.status.targets[0].desired); - let stop = handle.stop(); - let outcome = runtime.block_on(task); - runtime.block_on(stop); - assert_eq!(outcome.applied_command_sequence, 1); - assert!(!probes(&server.finish()).is_empty()); -} - -#[test] -fn dynamic_identical_open_target_preserves_session_without_pending_event() { - let server = start_server(ServerBehavior::Echo, None); - let mut managed = config(ManagedPacing::Staggered); - managed.completion = ManagedCompletionPolicy::ExplicitStop; - managed.client.duration = None; - let configured = target("same", server.addr); - let (mut task, handle) = ManagedClient::task(managed, vec![configured.clone()]).unwrap(); - let observations = Arc::new(Mutex::new(Vec::new())); - task.event_observations = Some(Arc::clone(&observations)); - let runtime = runtime(); - let mut task = Box::pin(task); - drive_task_until(&runtime, &mut task, |task| { - task.targets[0].counters.packets_sent >= 2 - }); - let instance = task.targets[0].instance.clone(); - let sent = task.targets[0].counters.packets_sent; - let receipt = handle.update_targets(vec![configured]).unwrap(); - drive_task_until(&runtime, &mut task, |task| { - task.applied_command_sequence == 1 - }); - let acknowledgement = runtime.block_on(receipt).unwrap(); - assert_eq!(acknowledgement.status.targets[0].target, instance); - assert_eq!(acknowledgement.status.targets.len(), 1); - assert!(!observations.lock().unwrap().iter().any(|(event, _)| { - matches!(event, ManagedEvent::TargetFinished { outcome } if outcome.target == instance) - })); - drive_task_until(&runtime, &mut task, |task| { - task.targets[0].counters.packets_sent > sent - }); - assert!(!observations.lock().unwrap().iter().any(|(event, _)| { - matches!(event, ManagedEvent::TargetStateChanged { target, lifecycle: ManagedTargetLifecycle::Pending } if *target == instance) - })); - let stop = handle.stop(); - runtime.block_on(task); - runtime.block_on(stop); - assert_eq!( - server - .finish() - .iter() - .filter(|record| matches!(record.kind, PacketKind::Open)) - .count(), - 1 - ); -} - -#[test] -fn dynamic_identical_terminal_target_restarts_without_duplicate_outcome_at_live_limit() { - let server = start_server(ServerBehavior::InvalidOpen, None); - let mut managed = config(ManagedPacing::Staggered); - managed.completion = ManagedCompletionPolicy::ExplicitStop; - managed.max_live_target_generations = 1; - let configured = target("same", server.addr); - let (mut task, handle) = ManagedClient::task(managed, vec![configured.clone()]).unwrap(); - let observations = Arc::new(Mutex::new(Vec::new())); - task.event_observations = Some(Arc::clone(&observations)); - let runtime = runtime(); - let mut task = Box::pin(task); - - drive_task_until(&runtime, &mut task, |task| { - matches!(task.targets[0].state, TargetState::Terminal) - }); - let old = task.targets[0].instance.clone(); - assert!(task.targets[0].desired); - assert_eq!(handle.status().desired_target_count, 1); - assert_eq!(handle.status().terminal_target_count, 1); - let original_outcome = task.history.recent.back().unwrap().clone(); - assert_eq!(original_outcome.target, old); - assert!(matches!( - original_outcome.end_reason, - ManagedTargetEndReason::Failed(_) - )); - - let receipt = handle.update_targets(vec![configured]).unwrap(); - drive_task_until(&runtime, &mut task, |task| { - task.applied_command_sequence == 1 - }); - let acknowledgement = runtime.block_on(receipt).unwrap(); - assert_eq!(acknowledgement.sequence, 1); - assert_eq!(acknowledgement.status.desired_target_count, 1); - assert_eq!(acknowledgement.status.terminal_target_count, 0); - assert_eq!(acknowledgement.status.targets.len(), 1); - let new = acknowledgement.status.targets[0].target.clone(); - assert!(new.generation > old.generation); - assert_eq!( - acknowledgement.status.targets[0].lifecycle, - ManagedTargetLifecycle::Pending - ); - assert_eq!(task.targets.len(), 1); - assert_eq!(task.targets[0].instance, new); - assert!(task.targets[0].desired); - assert!(!matches!(task.targets[0].state, TargetState::Terminal)); - assert!(observations.lock().unwrap().iter().any(|(event, _)| { - matches!(event, ManagedEvent::TargetStateChanged { target, lifecycle: ManagedTargetLifecycle::Pending } if *target == new) - })); - assert_eq!(task.history.recent.len(), 1); - assert_eq!(task.history.recent[0], original_outcome); - assert_eq!( - observations - .lock() - .unwrap() - .iter() - .filter(|(event, _)| { - matches!(event, ManagedEvent::TargetFinished { outcome } if outcome.target == old) - }) - .count(), - 1 - ); - - let stop = handle.stop(); - runtime.block_on(task); - runtime.block_on(stop); - server.finish(); -} - -#[test] -fn dynamic_retiring_active_is_ineligible_before_target_cleanup() { - let server = start_server(ServerBehavior::Echo, None); - let mut managed = config(ManagedPacing::Staggered); - managed.completion = ManagedCompletionPolicy::ExplicitStop; - managed.client.duration = None; - let (task, handle) = ManagedClient::task(managed, vec![target("old", server.addr)]).unwrap(); - let runtime = runtime(); - let mut task = Box::pin(task); - drive_task_until(&runtime, &mut task, |task| { - task.targets[0].counters.packets_sent != 0 - }); - let old = task.targets[0].instance.clone(); - let sent = task.targets[0].counters.packets_sent; - let acknowledgement = task - .as_mut() - .get_mut() - .apply_targets(vec![ManagedTargetConfig::new("old", "127.0.0.1:9")]) - .unwrap(); - assert!(acknowledgement - .status - .targets - .iter() - .any(|target| target.target == old && !target.desired)); - assert!(matches!(task.targets[0].state, TargetState::Active { .. })); - assert_eq!( - task.targets[0].retirement, - Some(ManagedTargetEndReason::Replaced) - ); - let result = runtime.block_on(poll_fn(|cx| { - Poll::Ready( - task.as_mut() - .get_mut() - .poll_one_send(0, cx, Instant::now(), None), - ) - })); - assert!(matches!(result, SendResult::NotAttempted)); - assert_eq!(task.targets[0].counters.packets_sent, sent); - let stop = handle.stop(); - runtime.block_on(task); - runtime.block_on(stop); - server.finish(); -} - -#[test] -fn dynamic_replacement_counts_sync_and_async_live_generations() { - let mut managed = config(ManagedPacing::Staggered); - managed.completion = ManagedCompletionPolicy::ExplicitStop; - managed.max_live_target_generations = 1; - let (mut pending, _) = ManagedClient::task( - managed.clone(), - vec![ManagedTargetConfig::new("same", "127.0.0.1:9")], - ) - .unwrap(); - let mut changed_auth = ManagedTargetConfig::new("same", "127.0.0.1:9"); - changed_auth.auth = Some(ClientAuthConfig { - hmac_key: Some(vec![1, 2, 3]), - }); - let acknowledgement = pending.apply_targets(vec![changed_auth]).unwrap(); - assert_eq!(acknowledgement.status.targets.len(), 1); - assert_eq!(acknowledgement.status.targets[0].target.generation, 2); - assert!(matches!( - acknowledgement.status.recent_target_outcomes[0].end_reason, - ManagedTargetEndReason::Replaced - )); - - let server = start_server(ServerBehavior::Echo, None); - let replacement = start_server(ServerBehavior::Echo, None); - managed.client.duration = None; - let (task, handle) = ManagedClient::task(managed, vec![target("same", server.addr)]).unwrap(); - let runtime = runtime(); - let mut task = Box::pin(task); - drive_task_until(&runtime, &mut task, |task| { - task.targets[0].counters.packets_sent != 0 - }); - let before = task.targets[0].instance.clone(); - let before_status = task.snapshot(); - let before_generation = task.next_generation; - let before_sequence = task.applied_command_sequence; - assert!(matches!( - task.as_mut() - .get_mut() - .apply_targets(vec![target("same", replacement.addr)]), - Err(ManagedCommandApplyError::LiveGenerationLimitExceeded { - required: 2, - limit: 1 - }) - )); - assert_eq!(task.snapshot(), before_status); - assert_eq!(task.next_generation, before_generation); - assert_eq!(task.applied_command_sequence, before_sequence); - assert_eq!(task.targets.len(), 1); - task.config.max_live_target_generations = 2; - let acknowledgement = task - .as_mut() - .get_mut() - .apply_targets(vec![target("same", replacement.addr)]) - .unwrap(); - assert!(acknowledgement - .status - .targets - .iter() - .any(|target| target.target == before && !target.desired)); - assert_eq!(acknowledgement.status.targets.len(), 2); - drive_task_until(&runtime, &mut task, |task| { - task.targets - .iter() - .any(|target| target.desired && target.counters.packets_sent != 0) - }); - let stop = handle.stop(); - let outcome = runtime.block_on(task); - runtime.block_on(stop); - assert!(outcome.recent_target_outcomes.iter().any(|outcome| { - outcome.target == before && matches!(outcome.end_reason, ManagedTargetEndReason::Replaced) - })); - server.finish(); - replacement.finish(); -} - -#[test] -fn terminal_targets_count_toward_retained_generation_limit() { - let mut managed = config(ManagedPacing::Staggered); - managed.completion = ManagedCompletionPolicy::ExplicitStop; - managed.max_live_target_generations = 1; - - let (mut task, _) = ManagedClient::task( - managed.clone(), - vec![ManagedTargetConfig::new("same", "127.0.0.1:9")], - ) - .unwrap(); - task.targets[0].state = TargetState::Terminal; - assert!(matches!( - task.apply_targets(vec![ - ManagedTargetConfig::new("same", "127.0.0.1:9"), - ManagedTargetConfig::new("other", "127.0.0.1:10"), - ]), - Err(ManagedCommandApplyError::LiveGenerationLimitExceeded { - required: 2, - limit: 1, - }) - )); - - let (mut task, _) = ManagedClient::task( - managed.clone(), - vec![ManagedTargetConfig::new("same", "127.0.0.1:9")], - ) - .unwrap(); - task.targets[0].state = TargetState::Terminal; - let acknowledgement = task - .apply_targets(vec![ManagedTargetConfig::new("same", "127.0.0.1:10")]) - .unwrap(); - assert_eq!(task.targets.len(), 1); - assert_eq!(acknowledgement.status.targets.len(), 1); - assert_eq!(acknowledgement.status.targets[0].target.generation, 2); - - let (mut task, _) = ManagedClient::task( - managed, - vec![ManagedTargetConfig::new("same", "127.0.0.1:9")], - ) - .unwrap(); - task.targets[0].state = TargetState::Terminal; - task.apply_targets(Vec::new()).unwrap(); - assert!(task.targets.is_empty()); - assert!(task.snapshot().targets.is_empty()); -} - -#[test] -fn dynamic_rejections_leave_transaction_state_unchanged() { - let mut managed = config(ManagedPacing::Staggered); - managed.completion = ManagedCompletionPolicy::ExplicitStop; - let (mut task, _) = ManagedClient::task( - managed, - vec![ManagedTargetConfig::new("same", "127.0.0.1:9")], - ) - .unwrap(); - let status = task.snapshot(); - let generation = task.next_generation; - let sequence = task.applied_command_sequence; - let instances = task - .targets - .iter() - .map(|target| (target.instance.clone(), target.desired)) - .collect::>(); - let duplicate = vec![ - ManagedTargetConfig::new("same", "127.0.0.1:10"), - ManagedTargetConfig::new("same", "127.0.0.1:11"), - ]; - assert!(task.apply_targets(duplicate).is_err()); - task.config.client.open_timeouts.clear(); - assert!(task - .apply_targets(vec![ManagedTargetConfig::new("same", "127.0.0.1:10")]) - .is_err()); - assert_eq!(task.next_generation, generation); - assert_eq!(task.applied_command_sequence, sequence); - assert_eq!(task.snapshot(), status); - assert_eq!( - task.targets - .iter() - .map(|target| (target.instance.clone(), target.desired)) - .collect::>(), - instances - ); -} - -#[test] -fn dynamic_empty_update_orders_stopping_before_acknowledgement() { - let mut managed = config(ManagedPacing::Staggered); - managed.command_capacity = 2; - let (mut task, handle) = ManagedClient::task( - managed, - vec![ManagedTargetConfig::new("gone", "127.0.0.1:9")], - ) - .unwrap(); - let observations = Arc::new(Mutex::new(Vec::new())); - task.event_observations = Some(Arc::clone(&observations)); - let removal = handle.update_targets(vec![]).unwrap(); - let later = handle - .update_targets(vec![ManagedTargetConfig::new("later", "127.0.0.1:9")]) - .unwrap(); - let outcome = runtime().block_on(task); - let acknowledgement = runtime().block_on(removal).unwrap(); - assert_eq!(acknowledgement.status.lifecycle, ManagedLifecycle::Stopping); - assert_eq!(acknowledgement.status.desired_target_count, 0); - assert!(matches!( - runtime().block_on(later), - Err(ManagedCommandApplyError::Stopping) - )); - assert_eq!(outcome.end_reason, ManagedEndReason::TargetsComplete); - let events = observations.lock().unwrap(); - let stopping = events - .iter() - .position(|(event, _)| matches!(event, ManagedEvent::Stopping)) - .unwrap(); - let finished = events - .iter() - .position(|(event, _)| matches!(event, ManagedEvent::TargetFinished { .. })) - .unwrap(); - assert!(stopping < finished); - assert_eq!(events[stopping].1.lifecycle, ManagedLifecycle::Stopping); - assert!(Arc::ptr_eq(&acknowledgement.status, &events[stopping].1)); -} - -#[test] -fn noisy_opening_yields_before_stop_observation() { - let open_gate = Arc::new(PacketGate::default()); - let server = start_server_with_gates( - ServerBehavior::NoisyOpen(256), - None, - None, - Some(Arc::clone(&open_gate)), - None, - ); - let mut managed = config(ManagedPacing::Staggered); - managed.completion = ManagedCompletionPolicy::ExplicitStop; - managed.client.duration = None; - let (task, handle) = ManagedClient::task(managed, vec![target("noisy", server.addr)]).unwrap(); - let runtime = runtime(); - let mut task = Box::pin(task); - - drive_task_until(&runtime, &mut task, |task| { - matches!(task.targets[0].state, TargetState::Opening { .. }) - }); - drive_task_until(&runtime, &mut task, |task| { - matches!( - task.targets[0].state, - TargetState::Opening { ref open, .. } if open.has_in_flight_work() - ) - }); - let seen = open_gate.seen.lock().unwrap(); - let (seen, timeout) = open_gate - .seen_ready - .wait_timeout_while(seen, Duration::from_secs(2), |seen| !*seen) - .unwrap(); - assert!(*seen && !timeout.timed_out()); - drop(seen); - open_gate.release(); - wait_flag(&server.open_reply_sent); - - // One managed task poll must leave a valid open reply behind the noisy - // queue, rather than letting this target consume all immediate work. - let poll = runtime.block_on(poll_fn(|cx| Poll::Ready(task.as_mut().poll(cx)))); - assert!(poll.is_pending()); - assert!(matches!(task.targets[0].state, TargetState::Opening { .. })); - - let receipt = handle.stop(); - let poll = runtime.block_on(poll_fn(|cx| Poll::Ready(task.as_mut().poll(cx)))); - assert!(poll.is_pending()); - assert!(handle.status().stop_requested); - assert_eq!(handle.status().lifecycle, ManagedLifecycle::Stopping); - let outcome = runtime.block_on(task); - runtime.block_on(receipt); - assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested); - server.finish(); -} - -#[test] -fn stop_during_quiescent_stopping_is_durable_without_duplicate_event() { - let server = start_server(ServerBehavior::Echo, None); - let mut managed = config(ManagedPacing::Staggered); - managed.client.duration = None; - managed.final_drain = Duration::from_millis(80); - let (mut task, handle) = - ManagedClient::task(managed, vec![target("old", server.addr)]).unwrap(); - let observations = Arc::new(Mutex::new(Vec::new())); - task.event_observations = Some(Arc::clone(&observations)); - let runtime = runtime(); - let mut task = Box::pin(task); - drive_task_until(&runtime, &mut task, |task| task.active_count() == 1); - - let removal = handle.update_targets(vec![]).unwrap(); - drive_task_until(&runtime, &mut task, |task| { - task.state == DriverState::Stopping - && !task.stop_observed - && task.targets.iter().any(|target| !target.desired) - }); - let acknowledgement = runtime.block_on(removal).unwrap(); - assert_eq!(acknowledgement.status.lifecycle, ManagedLifecycle::Stopping); - - let stop = handle.stop(); - let outcome = runtime.block_on(task); - runtime.block_on(stop); - assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested); - assert!(handle.status().stop_requested); - assert!(outcome - .recent_target_outcomes - .iter() - .any(|outcome| { matches!(outcome.end_reason, ManagedTargetEndReason::Removed) })); - assert_eq!( - observations - .lock() - .unwrap() - .iter() - .filter(|(event, _)| matches!(event, ManagedEvent::Stopping)) - .count(), - 1 - ); - server.finish(); -} - -#[test] -fn stop_before_enqueue_rejects_without_allocating_a_generation() { - let mut managed = config(ManagedPacing::Staggered); - managed.completion = ManagedCompletionPolicy::ExplicitStop; - let (task, handle) = ManagedClient::task(managed, vec![]).unwrap(); - let initial = handle.status(); - let stop = handle.stop(); - assert!(matches!( - handle.update_targets(vec![ManagedTargetConfig::new("later", "127.0.0.1:9")]), - Err(ManagedCommandError::Stopping) - )); - assert_eq!(task.next_generation, 1); - assert_eq!(task.applied_command_sequence, 0); - assert_eq!(handle.status(), initial); - let outcome = runtime().block_on(task); - runtime().block_on(stop); - assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested); -} - -#[test] -fn paused_submission_cannot_outlive_terminal_seal() { - let mut managed = config(ManagedPacing::Staggered); - managed.completion = ManagedCompletionPolicy::ExplicitStop; - let (task, mut handle) = ManagedClient::task(managed, vec![]).unwrap(); - let hook = Arc::new(UpdateSubmissionTestHook::default()); - hook.arm(); - handle.update_submission_hook = Some(Arc::clone(&hook)); - let submitter = handle.clone(); - let sender = thread::spawn(move || { - submitter.update_targets(vec![ManagedTargetConfig::new("late", "127.0.0.1:9")]) - }); - hook.wait_until_arrived(); - - let stop = handle.stop(); - let runtime = runtime(); - let mut task = Box::pin(task); - let Poll::Ready(outcome) = runtime.block_on(poll_fn(|cx| Poll::Ready(task.as_mut().poll(cx)))) - else { - panic!("empty stopped task did not seal") - }; - runtime.block_on(stop); - assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested); - - hook.release(); - assert!(matches!( - sender.join().unwrap(), - Err(ManagedCommandError::DriverClosed) - )); - drop(task); -} - -#[test] -fn paused_submission_can_enqueue_during_stopping() { - let reply_gate = Arc::new(PacketGate::default()); - let server = start_server_with_gates( - ServerBehavior::Echo, - None, - None, - None, - Some(Arc::clone(&reply_gate)), - ); - let mut managed = config(ManagedPacing::Staggered); - managed.completion = ManagedCompletionPolicy::ExplicitStop; - managed.client.duration = None; - managed.client.interval = Duration::from_secs(1); - managed.client.probe_timeout = Duration::from_secs(1); - let (task, mut handle) = - ManagedClient::task(managed, vec![target("old", server.addr)]).unwrap(); - let hook = Arc::new(UpdateSubmissionTestHook::default()); - hook.arm(); - handle.update_submission_hook = Some(Arc::clone(&hook)); - let runtime = runtime(); - let mut task = Box::pin(task); - drive_task_until(&runtime, &mut task, |task| { - task.targets[0].counters.packets_sent == 1 - }); - wait_flag(&server.probe_seen); - - let next_generation = task.next_generation; - let applied_command_sequence = task.applied_command_sequence; - let desired_membership = task - .targets - .iter() - .map(|target| (target.instance.clone(), target.desired)) - .collect::>(); - let submitter = handle.clone(); - let sender = thread::spawn(move || { - submitter.update_targets(vec![ManagedTargetConfig::new("new", "127.0.0.1:9")]) - }); - hook.wait_until_arrived(); - - let stop = handle.stop(); - drive_task_until(&runtime, &mut task, |task| { - task.state == DriverState::Stopping - && task.stop_observed - && task.resources().stop.update_admission() == UpdateAdmission::Stopping - && matches!(task.targets[0].state, TargetState::Draining { .. }) - }); - let stopping_status = handle.status(); - assert_eq!(stopping_status.lifecycle, ManagedLifecycle::Stopping); - - hook.release(); - let receipt = sender - .join() - .unwrap() - .expect("send must succeed while the receiver remains open during stopping"); - - runtime.block_on(poll_fn(|cx| { - assert!(task.as_mut().poll(cx).is_pending()); - Poll::Ready(()) - })); - let result = runtime.block_on(receipt); - assert!(!matches!( - &result, - Err(ManagedCommandApplyError::AcknowledgementDisconnected) - )); - assert!(matches!(result, Err(ManagedCommandApplyError::Stopping))); - assert_eq!(task.applied_command_sequence, applied_command_sequence); - assert_eq!(task.next_generation, next_generation); - assert_eq!( - task.targets - .iter() - .map(|target| (target.instance.clone(), target.desired)) - .collect::>(), - desired_membership - ); - assert_eq!(handle.status(), stopping_status); - - reply_gate.release(); - wait_flag(&server.reply_sent); - let outcome = runtime.block_on(task); - runtime.block_on(stop); - assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested); - server.finish(); -} - -#[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 oversized_desired_set_is_rejected_before_enqueue() { - let mut managed = config(ManagedPacing::Staggered); - managed.completion = ManagedCompletionPolicy::ExplicitStop; - managed.max_live_target_generations = 1; - let (task, handle) = ManagedClient::task(managed, vec![]).unwrap(); - let initial = handle.status(); - let capacity = handle.commands.capacity(); - assert!(matches!( - handle.update_targets(vec![ - ManagedTargetConfig::new("one", "127.0.0.1:9"), - ManagedTargetConfig::new("two", "127.0.0.1:10"), - ]), - Err(ManagedCommandError::TooManyTargets { - configured: 2, - limit: 1 - }) - )); - assert_eq!(handle.commands.capacity(), capacity); - assert_eq!(task.next_generation, 1); - assert_eq!(task.applied_command_sequence, 0); - assert_eq!(handle.status(), initial); -} - -#[test] -fn legal_desired_set_can_still_fail_at_overlap_application() { - let server = start_server(ServerBehavior::Echo, None); - let mut managed = config(ManagedPacing::Staggered); - managed.completion = ManagedCompletionPolicy::ExplicitStop; - managed.client.duration = None; - managed.max_live_target_generations = 1; - let (task, handle) = ManagedClient::task(managed, vec![target("same", server.addr)]).unwrap(); - let runtime = runtime(); - let mut task = Box::pin(task); - drive_task_until(&runtime, &mut task, |task| task.active_count() == 1); - - let receipt = handle - .update_targets(vec![ManagedTargetConfig::new("same", "127.0.0.1:10")]) - .unwrap(); - let mut receipt = Box::pin(receipt); - let result = runtime.block_on(poll_fn(|cx| { - assert!(task.as_mut().poll(cx).is_pending()); - match receipt.as_mut().poll(cx) { - Poll::Ready(result) => Poll::Ready(result), - Poll::Pending => Poll::Pending, - } - })); - assert!(matches!( - result, - Err(ManagedCommandApplyError::LiveGenerationLimitExceeded { - required: 2, - limit: 1 - }) - )); - let stop = handle.stop(); - runtime.block_on(task); - runtime.block_on(stop); - server.finish(); -} - -#[test] -fn dynamic_explicit_remove_and_readd_uses_new_generation() { - let first = start_server(ServerBehavior::Echo, None); - let second = start_server(ServerBehavior::Echo, None); - let mut managed = config(ManagedPacing::Staggered); - managed.completion = ManagedCompletionPolicy::ExplicitStop; - managed.client.duration = None; - let (task, handle) = ManagedClient::task(managed, vec![target("same", first.addr)]).unwrap(); - let runtime = runtime(); - let mut task = Box::pin(task); - drive_task_until(&runtime, &mut task, |task| task.active_count() == 1); - let first_instance = task.targets[0].instance.clone(); - let removal = handle.update_targets(vec![]).unwrap(); - drive_task_until(&runtime, &mut task, |task| task.targets.is_empty()); - let acknowledgement = runtime.block_on(removal).unwrap(); - assert_eq!(acknowledgement.status.lifecycle, ManagedLifecycle::Running); - assert!(acknowledgement - .status - .targets - .iter() - .any(|target| target.target == first_instance && !target.desired)); - assert!(matches!( - task.history.recent.back().unwrap().end_reason, - ManagedTargetEndReason::Removed - )); - let addition = handle - .update_targets(vec![target("same", second.addr)]) - .unwrap(); - drive_task_until(&runtime, &mut task, |task| { - task.targets[0].counters.packets_sent != 0 - }); - let acknowledgement = runtime.block_on(addition).unwrap(); - assert!(acknowledgement.status.targets[0].target.generation > first_instance.generation); - let stop = handle.stop(); - runtime.block_on(task); - runtime.block_on(stop); - assert!(has_close(&first.finish())); - second.finish(); -} - -#[test] -fn dynamic_queue_stop_and_dropped_receipt_are_linearized() { - let mut managed = config(ManagedPacing::Staggered); - managed.completion = ManagedCompletionPolicy::ExplicitStop; - managed.command_capacity = 1; - let (task, handle) = ManagedClient::task(managed.clone(), vec![]).unwrap(); - let receipt = handle - .update_targets(vec![ManagedTargetConfig::new("queued", "127.0.0.1:9")]) - .unwrap(); - assert!(matches!( - handle.update_targets(vec![]), - Err(ManagedCommandError::QueueFull) - )); - 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) - )); - - let server = start_server(ServerBehavior::Echo, None); - managed.command_capacity = 2; - managed.client.duration = None; - let (task, handle) = ManagedClient::task(managed, vec![]).unwrap(); - drop( - handle - .update_targets(vec![target("dropped", server.addr)]) - .unwrap(), - ); - let runtime = runtime(); - let mut task = Box::pin(task); - drive_task_until(&runtime, &mut task, |task| { - task.targets[0].counters.packets_sent != 0 - }); - assert_eq!(handle.status().applied_command_sequence, 1); - assert_eq!(handle.status().targets[0].target.generation, 1); - let stop = handle.stop(); - runtime.block_on(task); - runtime.block_on(stop); - assert!(!probes(&server.finish()).is_empty()); -} - -#[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 peer_close_leaves_other_explicitly_managed_targets_running() { - let closing = start_server(ServerBehavior::PeerClose, None); - let continuing = start_server(ServerBehavior::Echo, None); - let mut managed = config(ManagedPacing::Staggered); - managed.completion = ManagedCompletionPolicy::ExplicitStop; - managed.client.duration = None; - let (task, handle) = ManagedClient::task( - managed, - vec![ - target("closing", closing.addr), - target("continuing", continuing.addr), - ], - ) - .unwrap(); - let runtime = runtime(); - let mut task = Box::pin(task); - - drive_task_until(&runtime, &mut task, |task| { - handle.status().peer_closed_target_outcomes == 1 - && task.targets[1].counters.packets_sent != 0 - }); - assert_eq!(handle.status().lifecycle, ManagedLifecycle::Running); - - let stop = handle.stop(); - runtime.block_on(task); - runtime.block_on(stop); - closing.finish(); - assert!(!probes(&continuing.finish()).is_empty()); -} - -#[test] -fn peer_close_during_stop_drain_remains_authoritative() { - let key = b"managed-drain-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 mut managed = config(ManagedPacing::Staggered); - managed.completion = ManagedCompletionPolicy::ExplicitStop; - managed.client.duration = None; - managed.client.interval = Duration::from_secs(1); - let (task, handle) = ManagedClient::task(managed, vec![configured]).unwrap(); - let runtime = runtime(); - let mut task = Box::pin(task); - drive_task_until(&runtime, &mut task, |task| { - task.targets[0].counters.packets_sent == 1 - }); - drop(handle.stop()); - wait_flag(&server.reply_sent); - let outcome = runtime.block_on(task); - let records = server.finish(); - assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested); - 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 burst_pacing_fairness() { - let first = start_server(ServerBehavior::Echo, None); - let second = start_server(ServerBehavior::Echo, None); - let (mut task, _) = ManagedClient::task( - config(ManagedPacing::Burst), - vec![target("first", first.addr), target("second", second.addr)], - ) - .unwrap(); - let observations = Arc::new(Mutex::new(Vec::new())); - task.event_observations = Some(Arc::clone(&observations)); - runtime().block_on(task); - assert_rotated_without_catchup(&echo_sends(&observations)); - first.finish(); - second.finish(); -} - -#[test] -fn staggered_pacing_fairness() { - let first = start_server(ServerBehavior::Echo, None); - let second = start_server(ServerBehavior::Echo, None); - let (mut task, _) = ManagedClient::task( - config(ManagedPacing::Staggered), - vec![target("first", first.addr), target("second", second.addr)], - ) - .unwrap(); - let observations = Arc::new(Mutex::new(Vec::new())); - task.event_observations = Some(Arc::clone(&observations)); - runtime().block_on(task); - assert_rotated_without_catchup(&echo_sends(&observations)); - first.finish(); - second.finish(); -} - -/// The stagger gate must not fold each send's observed wakeup latency into -/// every later slot. -/// -/// The gate is the binding constraint on when the *group* may send next (see -/// `next_deadline`), while each target's own schedule stays on an absolute -/// grid anchored at its open. Re-anchoring the gate on every observed accept -/// time therefore made the whole group's cadence drift later without bound: -/// `EchoSent::timer_error` grew monotonically, and once the drift exceeded one -/// interval `skip_missed_probe_slots_at` started dropping probe slots -/// outright. Measured before this was fixed, a single-target 20 s run at a -/// 50 ms interval sent 381 probes instead of 400. -/// -/// This asserts the invariant directly rather than through a real-time run, -/// because reproducing accumulation behaviorally needs seconds of wall clock -/// and a jitter assumption. Two properties, both durable: -/// -/// - while the pacer keeps up, N accepted sends advance the gate by exactly -/// N spacings from the first anchor, whatever lateness each send is -/// observed with; -/// - a gate the pacer has fallen a full spacing behind re-anchors on the -/// accept time instead, so a stranded gate cannot release a burst of -/// catch-up sends. -#[test] -fn the_stagger_gate_advances_by_one_spacing_per_send_without_accumulating_lateness() { - // No socket is involved: the gate is driven directly and the task is never - // polled, so an unreachable address is all a valid target set needs here. - let (mut task, _) = ManagedClient::task( - config(ManagedPacing::Staggered), - vec![target("only", "127.0.0.1:9".parse().unwrap())], - ) - .unwrap(); - - let spacing = Duration::from_millis(50); - let start = Instant::now(); - let lateness = Duration::from_millis(3); - - task.record_stagger_acceptance(SendResult::Ready { accepted: true }, Some(spacing), start); - assert_eq!(task.send_gate, Some(start + spacing), "first send anchors"); - - // Every later send is observed `lateness` after the gate that released it. - // The gate must still advance one spacing per send from `start`, not - // `spacing + lateness`. - for slot in 1..=8_u32 { - let released_at = start + spacing * slot; - task.record_stagger_acceptance( - SendResult::Ready { accepted: true }, - Some(spacing), - released_at + lateness, - ); - assert_eq!( - task.send_gate, - Some(start + spacing * (slot + 1)), - "slot {slot} accumulated observed lateness into the gate" - ); - } - - // A full spacing behind is no longer the same cadence, so the gate - // re-anchors rather than staying stranded in the past. - let stalled_gate = task.send_gate.expect("the gate is set"); - let resumed_at = stalled_gate + spacing; - task.record_stagger_acceptance( - SendResult::Ready { accepted: true }, - Some(spacing), - resumed_at, - ); - assert_eq!(task.send_gate, Some(resumed_at + spacing)); -} - -#[test] -fn timeout_discovery_inspects_at_most_one_budget_of_targets() { - let target_count = TIMEOUT_WORK_BUDGET + 1; - let mut managed = config(ManagedPacing::Burst); - managed.max_live_target_generations = target_count; - let targets = (0..target_count) - .map(|index| ManagedTargetConfig::new(format!("target-{index}"), "127.0.0.1:9")) - .collect(); - let (mut task, _) = ManagedClient::task(managed, targets).unwrap(); - - task.timeout_inspections = 0; - assert!(!task.poll_timeout_pass(Instant::now())); - - assert_eq!(task.timeout_inspections, TIMEOUT_WORK_BUDGET); -} - -#[test] -fn retained_probe_deadline_shortens_after_late_reply() { - let reply_gate = Arc::new(PacketGate::default()); - let server = start_server_with_gates( - ServerBehavior::Echo, - None, - None, - None, - Some(Arc::clone(&reply_gate)), - ); - let mut managed = config(ManagedPacing::Staggered); - managed.completion = ManagedCompletionPolicy::ExplicitStop; - managed.client.duration = None; - managed.client.interval = Duration::from_secs(1); - managed.client.probe_timeout = Duration::from_millis(100); - managed.final_drain = Duration::from_millis(200); - let final_drain = managed.final_drain; - let (task, handle) = ManagedClient::task(managed, vec![target("one", server.addr)]).unwrap(); - let runtime = runtime(); - let mut task = Box::pin(task); - drive_task_until(&runtime, &mut task, |task| { - task.targets[0].counters.packets_sent == 1 - }); - drop(handle.stop()); - drive_task_until(&runtime, &mut task, |task| { - matches!(task.targets[0].state, TargetState::Draining { .. }) - }); - let (drain_started_at, initial_deadline, timeout_at) = match &task.targets[0].state { - TargetState::Draining { - client, - drain_started_at, - deadline, - .. - } => ( - *drain_started_at, - *deadline, - client.next_probe_timeout_deadline().unwrap(), - ), - _ => unreachable!(), - }; - assert_eq!( - initial_deadline, - timeout_at.checked_add(final_drain).unwrap() - ); - thread::sleep(timeout_at.saturating_duration_since(Instant::now())); - while Instant::now() <= timeout_at { - thread::yield_now(); - } - drive_task_until(&runtime, &mut task, |task| match &task.targets[0].state { - TargetState::Draining { client, .. } => { - client.next_probe_timeout_deadline().is_none() - && client.latest_probe_timeout_deadline() == Some(timeout_at) - } - _ => false, - }); - let retained_deadline = match &task.targets[0].state { - TargetState::Draining { deadline, .. } => *deadline, - _ => unreachable!(), - }; - assert_eq!(retained_deadline, initial_deadline); - reply_gate.release(); - wait_flag(&server.reply_sent); - let shortened_deadline = drain_started_at.checked_add(final_drain).unwrap(); - drive_task_until(&runtime, &mut task, |task| { - matches!( - task.targets[0].state, - TargetState::Draining { deadline, .. } if deadline == shortened_deadline - ) - }); - assert!(shortened_deadline < initial_deadline); - let outcome = runtime.block_on(task); - let records = server.finish(); - let target = &outcome.recent_target_outcomes[0]; - assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested); - assert!(matches!(target.end_reason, ManagedTargetEndReason::Stopped)); - assert_eq!(target.packets_sent, 1); - assert_eq!(target.replies_received, 0); - assert_eq!(target.late, 1); - assert_eq!(probes(&records).len(), 1); -} - -#[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 final_drain_overflow_is_rejected_and_preserves_success() { - let mut invalid = config(ManagedPacing::Staggered); - invalid.final_drain = Duration::MAX; - assert!(matches!( - ManagedClient::task( - invalid, - vec![target("invalid", "127.0.0.1:9".parse().unwrap())] - ), - Err(ManagedConfigError::UnschedulableFinalDrain { - duration: Duration::MAX - }) - )); - - let server = start_server(ServerBehavior::Echo, None); - let (mut task, _) = ManagedClient::task( - config(ManagedPacing::Staggered), - vec![target("completed", server.addr)], - ) - .unwrap(); - task.config.final_drain = Duration::MAX; - let outcome = runtime().block_on(task); - let target = &outcome.recent_target_outcomes[0]; - assert!(matches!( - target.end_reason, - ManagedTargetEndReason::TestComplete - )); - assert_eq!(outcome.successful_target_outcomes, 1); - assert_eq!(outcome.failed_target_outcomes, 0); - assert!(matches!( - target.cleanup_failure, - Some(ManagedTargetFailure { - phase: ManagedTargetFailurePhase::Timing, - kind: ManagedTargetFailureKind::ResourceExhausted, - .. - }) - )); - assert!(has_close(&server.finish())); -} - -#[test] -fn post_deadline_receive_sweep_drains_queued_packets_and_is_bounded() { - let drained = run_deferred_drain_burst(3); - let drained_target = &drained.recent_target_outcomes[0]; - assert!(matches!( - drained_target.end_reason, - ManagedTargetEndReason::Stopped - )); - assert_eq!(drained_target.replies_received, 0); - assert_eq!(drained_target.duplicates, 1); - assert_eq!(drained_target.late, 2); - - let bounded = run_deferred_drain_burst(POST_DEADLINE_RECEIVE_BUDGET + 64); - let bounded_target = &bounded.recent_target_outcomes[0]; - assert!(matches!( - bounded_target.end_reason, - ManagedTargetEndReason::Stopped - )); - assert_eq!( - bounded_target.replies_received + bounded_target.duplicates + bounded_target.late, - u64::try_from(POST_DEADLINE_RECEIVE_BUDGET).unwrap() - ); - assert_eq!(bounded.failed_target_outcomes, 0); -} - -#[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 outcome_history_eviction_preserves_aggregates() { - let servers = [ - start_server(ServerBehavior::NoTest, None), - start_server(ServerBehavior::NoTest, None), - start_server(ServerBehavior::NoTest, None), - ]; - let mut config = config(ManagedPacing::Staggered); - config.client.run_mode = RunMode::NoTest; - config.outcome_history_limit = 1; - let targets = servers - .iter() - .enumerate() - .map(|(index, server)| target(&format!("target-{index}"), server.addr)) - .collect(); - let (mut task, _) = ManagedClient::task(config, targets).unwrap(); - let observations = Arc::new(Mutex::new(Vec::new())); - task.event_observations = Some(Arc::clone(&observations)); - let outcome = runtime().block_on(task); - assert_eq!(outcome.total_target_outcomes, 3); - assert_eq!(outcome.successful_target_outcomes, 3); - assert_eq!(outcome.discarded_target_outcomes, 2); - assert_eq!(outcome.recent_target_outcomes.len(), 1); - let last_finished = observations - .lock() - .unwrap() - .iter() - .filter_map(|(event, _)| match event { - ManagedEvent::TargetFinished { outcome } => Some(outcome.target.clone()), - _ => None, - }) - .next_back() - .unwrap(); - assert_eq!(outcome.recent_target_outcomes[0].target, last_finished); - for server in servers { - server.finish(); - } -} - -#[test] -fn status_precedes_corresponding_events() { - let server = start_server(ServerBehavior::NoTest, None); - let mut config = config(ManagedPacing::Staggered); - config.client.run_mode = RunMode::NoTest; - let (mut task, _) = ManagedClient::task(config, vec![target("one", server.addr)]).unwrap(); - let observations = Arc::new(Mutex::new(Vec::new())); - task.event_observations = Some(Arc::clone(&observations)); - runtime().block_on(task); - let mut saw_started = false; - let mut saw_state = false; - let mut saw_finished = false; - let mut saw_stopping = false; - let mut saw_completed = false; - for (event, status) in observations.lock().unwrap().iter() { - match event { - ManagedEvent::Started => { - saw_started = true; - assert_eq!(status.lifecycle, ManagedLifecycle::Running); - } - ManagedEvent::TargetStateChanged { target, lifecycle } => { - saw_state = true; - assert!(status - .targets - .iter() - .any(|entry| entry.target == *target && entry.lifecycle == *lifecycle)); - } - ManagedEvent::TargetFinished { .. } => { - saw_finished = true; - assert!(status.total_target_outcomes >= 1); - } - ManagedEvent::Stopping => { - saw_stopping = true; - assert_eq!(status.lifecycle, ManagedLifecycle::Stopping); - } - ManagedEvent::Completed { .. } => { - saw_completed = true; - assert_eq!(status.lifecycle, ManagedLifecycle::Completed); - } - _ => {} - } - } - assert!(saw_started && saw_state && saw_finished && saw_stopping && saw_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) - )); -} diff --git a/crates/irtt-client/src/metadata.rs b/crates/irtt-client/src/metadata.rs index 4a0f371..1597f7c 100644 --- a/crates/irtt-client/src/metadata.rs +++ b/crates/irtt-client/src/metadata.rs @@ -56,136 +56,3 @@ impl From 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)); - } -} diff --git a/crates/irtt-client/src/probe.rs b/crates/irtt-client/src/probe.rs index 4080139..acf098a 100644 --- a/crates/irtt-client/src/probe.rs +++ b/crates/irtt-client/src/probe.rs @@ -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 { 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::>(); - - 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![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![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); } } } diff --git a/crates/irtt-client/src/receive.rs b/crates/irtt-client/src/receive.rs index f05aeec..3a39eb3 100644 --- a/crates/irtt-client/src/receive.rs +++ b/crates/irtt-client/src/receive.rs @@ -111,42 +111,3 @@ pub(crate) fn drain_tx_timestamps( ) -> 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); - }); - } -} diff --git a/crates/irtt-client/src/receive/linux.rs b/crates/irtt-client/src/receive/linux.rs index 0311f31..3bbae6d 100644 --- a/crates/irtt-client/src/receive/linux.rs +++ b/crates/irtt-client/src/receive/linux.rs @@ -215,333 +215,3 @@ fn system_time_from_timespec(timespec: TimeSpec) -> Option { /// `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); - } -} diff --git a/crates/irtt-client/src/receive/linux/error_queue.rs b/crates/irtt-client/src/receive/linux/error_queue.rs index f2d3703..be9c6ab 100644 --- a/crates/irtt-client/src/receive/linux/error_queue.rs +++ b/crates/irtt-client/src/receive/linux/error_queue.rs @@ -181,448 +181,3 @@ pub(crate) fn try_recv_error_queue_record(fd: RawFd) -> io::Result 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 { - 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. -} diff --git a/crates/irtt-client/src/session/machine.rs b/crates/irtt-client/src/session/machine.rs index ea90460..ee68096 100644 --- a/crates/irtt-client/src/session/machine.rs +++ b/crates/irtt-client/src/session/machine.rs @@ -1133,1208 +1133,3 @@ fn config_duration_to_ns(field: &str, duration: Duration) -> Result SessionMachine { - let config = ClientConfig { - max_pending_probes, - probe_timeout, - ..ClientConfig::default() - }; - let remote = "127.0.0.1:2112".parse().unwrap(); - let mut machine = SessionMachine::new(config, remote).unwrap(); - let prepared = machine - .prepare_open_acceptance( - normal_open_reply(&machine, 0x0102_0304_0506_0708), - timestamp(Instant::now()), - ) - .unwrap(); - machine.commit_open(prepared); - machine - } - - fn active(machine: &SessionMachine) -> &ActiveSession { - match &machine.state { - MachineState::Open(session) => session, - _ => panic!("test machine must be open"), - } - } - - fn active_mut(machine: &mut SessionMachine) -> &mut ActiveSession { - match &mut machine.state { - MachineState::Open(session) => session, - _ => panic!("test machine must be open"), - } - } - - fn timestamp(mono: Instant) -> ClientTimestamp { - ClientTimestamp { - mono, - wall: SystemTime::now(), - } - } - - fn normal_open_reply(machine: &SessionMachine, token: u64) -> OpenReply { - OpenReply { - flags: flags::FLAG_OPEN | flags::FLAG_REPLY, - token, - params: machine.requested.clone(), - } - } - - #[test] - fn uncommitted_probe_preparation_changes_no_authoritative_state() { - let mut machine = open_machine(4, Duration::from_secs(1)); - let prepared = machine.prepare_probe().unwrap(); - assert_eq!(prepared.seq, 0); - - // Repeating preflight, and finalizing a commit that is never applied, - // must both leave the session exactly as it was. - { - let _discarded_preflight = machine.preflight_probe_commit(&prepared).unwrap(); - } - { - let preflight = machine.preflight_probe_commit(&prepared).unwrap(); - let _discarded_commit = machine - .finalize_probe_commit(preflight, timestamp(Instant::now())) - .unwrap(); - } - - let session = active(&machine); - assert_eq!(session.next_wire_seq, 0); - assert_eq!(session.packets_sent, 0); - assert_eq!(session.pending.len(), 0); - } - - #[test] - fn repeated_would_block_style_preflight_commits_once() { - let mut machine = open_machine(4, Duration::from_secs(1)); - let prepared = machine.prepare_probe().unwrap(); - let sent_at = timestamp(Instant::now()); - - for _ in 0..3 { - let preflight = machine.preflight_probe_commit(&prepared).unwrap(); - let _would_block_commit = machine.finalize_probe_commit(preflight, sent_at).unwrap(); - } - let preflight = machine.preflight_probe_commit(&prepared).unwrap(); - let commit = machine.finalize_probe_commit(preflight, sent_at).unwrap(); - let sent = machine.commit_probe_sent(commit, sent_at, prepared.bytes.len()); - - assert_eq!(sent.seq, 0); - let session = active(&machine); - assert_eq!(session.next_wire_seq, 1); - assert_eq!(session.packets_sent, 1); - assert_eq!(session.pending.len(), 1); - } - - #[test] - fn pending_sequence_collision_is_detected_before_commit() { - let mut machine = open_machine(2, Duration::from_secs(1)); - let first = machine.prepare_probe().unwrap(); - let sent_at = timestamp(Instant::now()); - let preflight = machine.preflight_probe_commit(&first).unwrap(); - let commit = machine.finalize_probe_commit(preflight, sent_at).unwrap(); - machine.commit_probe_sent(commit, sent_at, first.bytes.len()); - active_mut(&mut machine).next_wire_seq = 0; - - let reused = machine.prepare_probe().unwrap(); - assert!(matches!( - machine.preflight_probe_commit(&reused), - Err(ClientError::PendingSequenceCollision { seq: 0 }) - )); - assert_eq!(active(&machine).packets_sent, 1); - } - - #[test] - fn counter_overflow_is_detected_before_commit() { - let mut machine = open_machine(2, Duration::from_secs(1)); - active_mut(&mut machine).packets_sent = u64::MAX; - let prepared = machine.prepare_probe().unwrap(); - - assert!(matches!( - machine.preflight_probe_commit(&prepared), - Err(ClientError::CounterOverflow { - counter: "packets_sent" - }) - )); - assert_eq!(active(&machine).pending.len(), 0); - } - - #[test] - fn timeout_overflow_is_detected_before_commit() { - let mut machine = open_machine(2, Duration::MAX); - let prepared = machine.prepare_probe().unwrap(); - let preflight = machine.preflight_probe_commit(&prepared).unwrap(); - - assert!(matches!( - machine.finalize_probe_commit(preflight, timestamp(Instant::now())), - Err(ClientError::DurationOverflow) - )); - assert_eq!(active(&machine).pending.len(), 0); - } - - #[test] - fn wrapping_sequence_from_max_to_zero_remains_valid() { - let mut machine = open_machine(2, Duration::from_secs(1)); - active_mut(&mut machine).next_wire_seq = u32::MAX; - let prepared = machine.prepare_probe().unwrap(); - let preflight = machine.preflight_probe_commit(&prepared).unwrap(); - let commit = machine - .finalize_probe_commit(preflight, timestamp(Instant::now())) - .unwrap(); - machine.commit_probe_sent(commit, timestamp(Instant::now()), prepared.bytes.len()); - - assert_eq!(active(&machine).next_wire_seq, 0); - assert_eq!(machine.prepare_probe().unwrap().seq, 0); - } - - #[test] - fn successful_wrapped_reuse_purges_obsolete_history_only_on_commit() { - let mut machine = open_machine(3, Duration::from_secs(1)); - let now = Instant::now(); - let obsolete_sent_at = timestamp(now - Duration::from_secs(1)); - let obsolete = PendingProbe { - wire_seq: 0, - sent_at: obsolete_sent_at, - timeout_at: now, - tx_not_before_wall: obsolete_sent_at.wall, - kernel_tx_timestamp: None, - }; - let session = active_mut(&mut machine); - session.next_wire_seq = 0; - session.timed_out.insert(obsolete); - session.completed.insert(0); - - let prepared = machine.prepare_probe().unwrap(); - let preflight = machine.preflight_probe_commit(&prepared).unwrap(); - let commit = machine - .finalize_probe_commit(preflight, timestamp(now)) - .unwrap(); - assert!(active(&machine).timed_out.contains(0)); - assert!(active(&machine).completed.contains(0)); - - machine.commit_probe_sent(commit, timestamp(now), prepared.bytes.len()); - let session = active(&machine); - assert!(!session.timed_out.contains(0)); - assert!(!session.completed.contains(0)); - assert!(session.pending.contains(0)); - assert_eq!(session.next_wire_seq, 1); - assert_eq!(session.packets_sent, 1); - } - - // Downstream one-way delay endpoint selection. - // - // All values are anchored at a fixed wall-clock base so the expected - // delays are exact: the client sends at the base, the server receives 5 ms - // later and sends 10 ms later, userspace observes the reply at 30 ms - // (20 ms downstream) and the kernel observed it at 25 ms (15 ms - // downstream). - const OWD_BASE_WALL_NS: i64 = 10_000_000_000; - const OWD_SERVER_RECV_WALL_NS: i64 = OWD_BASE_WALL_NS + 5_000_000; - const OWD_SERVER_SEND_WALL_NS: i64 = OWD_BASE_WALL_NS + 10_000_000; - - fn owd_wall(offset_ns: u64) -> SystemTime { - UNIX_EPOCH + Duration::from_nanos(u64::try_from(OWD_BASE_WALL_NS).unwrap() + offset_ns) - } - - fn owd_sent_at(mono: Instant) -> ClientTimestamp { - ClientTimestamp { - mono, - wall: owd_wall(0), - } - } - - fn owd_received_at(mono: Instant) -> ClientTimestamp { - ClientTimestamp { - mono: mono + Duration::from_millis(40), - wall: owd_wall(30_000_000), - } - } - - fn owd_timestamps() -> TimestampFields { - TimestampFields { - recv_wall: Some(OWD_SERVER_RECV_WALL_NS), - send_wall: Some(OWD_SERVER_SEND_WALL_NS), - ..Default::default() - } - } - - fn owd_reply(timestamps: TimestampFields) -> EchoReply { - EchoReply { - flags: flags::FLAG_REPLY, - token: 0x0102_0304_0506_0708, - sequence: 0, - recv_count: None, - recv_window: None, - timestamps, - payload: Vec::new(), - } - } - - fn kernel_rx_meta(offset_ns: u64) -> ReceiveMeta { - ReceiveMeta { - traffic_class: None, - kernel_rx_timestamp: Some(owd_wall(offset_ns)), - } - } - - /// Machine with one outstanding probe for sequence 0. The pre-send - /// `tx_not_before_wall` bound defaults to `sent_at.wall`, i.e. this - /// helper does not exercise the pre-/post-send distinction; use - /// [`machine_with_pending_probe_anchored`] where that distinction - /// matters. - fn machine_with_pending_probe(sent_at: ClientTimestamp) -> SessionMachine { - machine_with_pending_probe_anchored(sent_at.wall, sent_at) - } - - /// Machine with one outstanding probe for sequence 0, with an explicit - /// pre-send `tx_not_before_wall` bound independent of `sent_at.wall`. - fn machine_with_pending_probe_anchored( - tx_not_before_wall: SystemTime, - sent_at: ClientTimestamp, - ) -> SessionMachine { - let mut machine = open_machine(4, Duration::from_secs(1)); - let session = active_mut(&mut machine); - session.pending.preflight_insert(0).unwrap(); - session.pending.commit_insert(PendingProbe { - wire_seq: 0, - sent_at, - timeout_at: sent_at.mono + Duration::from_secs(1), - tx_not_before_wall, - kernel_tx_timestamp: None, - }); - session.next_wire_seq = 1; - machine - } - - /// Machine whose probe for sequence 0 already timed out, so a reply for it - /// is a measurable late reply. - fn machine_with_timed_out_probe(sent_at: ClientTimestamp) -> SessionMachine { - let mut machine = open_machine(4, Duration::from_secs(1)); - let session = active_mut(&mut machine); - session.timed_out.insert(PendingProbe { - wire_seq: 0, - sent_at, - timeout_at: sent_at.mono + Duration::from_secs(1), - tx_not_before_wall: sent_at.wall, - kernel_tx_timestamp: None, - }); - session.next_wire_seq = 1; - machine - } - - fn reply_one_way(events: &[ClientEvent]) -> Option { - match events { - [ClientEvent::EchoReply { one_way, .. } | ClientEvent::LateReply { one_way, .. }] => { - *one_way - } - other => panic!("expected a single measurable reply event, got {other:?}"), - } - } - - fn reply_rtt(events: &[ClientEvent]) -> Option { - match events { - [ClientEvent::EchoReply { rtt, .. }] => Some(*rtt), - [ClientEvent::LateReply { rtt, .. }] => *rtt, - other => panic!("expected a single measurable reply event, got {other:?}"), - } - } - - fn process_owd_reply( - machine: &mut SessionMachine, - timestamps: TimestampFields, - meta: ReceiveMeta, - received_at: ClientTimestamp, - ) -> Vec { - machine - .process_echo_reply(owd_reply(timestamps), 64, received_at, meta) - .unwrap() - } - - #[test] - fn downstream_one_way_delay_prefers_valid_kernel_receive_time() { - let mono = Instant::now(); - let mut machine = machine_with_pending_probe(owd_sent_at(mono)); - - let events = process_owd_reply( - &mut machine, - owd_timestamps(), - kernel_rx_meta(25_000_000), - owd_received_at(mono), - ); - - let one_way = reply_one_way(&events).unwrap(); - assert_eq!( - one_way.server_to_client, - Some(SignedDuration::from_nanos(15_000_000)) - ); - assert_eq!( - one_way.client_to_server, - Some(SignedDuration::from_nanos(5_000_000)) - ); - } - - #[test] - fn downstream_one_way_delay_uses_userspace_receive_time_without_kernel_metadata() { - let mono = Instant::now(); - let mut machine = machine_with_pending_probe(owd_sent_at(mono)); - - let events = process_owd_reply( - &mut machine, - owd_timestamps(), - ReceiveMeta::default(), - owd_received_at(mono), - ); - - let one_way = reply_one_way(&events).unwrap(); - assert_eq!( - one_way.server_to_client, - Some(SignedDuration::from_nanos(20_000_000)) - ); - assert_eq!( - one_way.client_to_server, - Some(SignedDuration::from_nanos(5_000_000)) - ); - } - - #[test] - fn downstream_one_way_delay_falls_back_for_implausible_kernel_receive_time() { - let mono = Instant::now(); - // Later than the userspace sample that observed the datagram. - let mut future = machine_with_pending_probe(owd_sent_at(mono)); - let future_events = process_owd_reply( - &mut future, - owd_timestamps(), - kernel_rx_meta(35_000_000), - owd_received_at(mono), - ); - - // Lagging the userspace sample by far more than MAX_KERNEL_RX_LAG. - let mut stale = machine_with_pending_probe(owd_sent_at(mono)); - let stale_meta = ReceiveMeta { - traffic_class: None, - kernel_rx_timestamp: Some(UNIX_EPOCH), - }; - let stale_events = process_owd_reply( - &mut stale, - owd_timestamps(), - stale_meta, - owd_received_at(mono), - ); - - for events in [&future_events, &stale_events] { - assert_eq!( - reply_one_way(events).unwrap().server_to_client, - Some(SignedDuration::from_nanos(20_000_000)) - ); - } - } - - #[test] - fn measurable_late_reply_uses_the_same_receive_wall_selection() { - let mono = Instant::now(); - let mut kernel = machine_with_timed_out_probe(owd_sent_at(mono)); - let kernel_events = process_owd_reply( - &mut kernel, - owd_timestamps(), - kernel_rx_meta(25_000_000), - owd_received_at(mono), - ); - - let mut userspace = machine_with_timed_out_probe(owd_sent_at(mono)); - let userspace_events = process_owd_reply( - &mut userspace, - owd_timestamps(), - ReceiveMeta::default(), - owd_received_at(mono), - ); - - assert!(matches!( - kernel_events.as_slice(), - [ClientEvent::LateReply { .. }] - )); - assert_eq!( - reply_one_way(&kernel_events).unwrap().server_to_client, - Some(SignedDuration::from_nanos(15_000_000)) - ); - assert_eq!( - reply_one_way(&userspace_events).unwrap().server_to_client, - Some(SignedDuration::from_nanos(20_000_000)) - ); - } - - #[test] - fn untracked_late_reply_reports_no_one_way_delay_with_kernel_metadata() { - let mono = Instant::now(); - let mut machine = open_machine(4, Duration::from_secs(1)); - active_mut(&mut machine).highest_received_seq = Some(5); - - let events = process_owd_reply( - &mut machine, - owd_timestamps(), - kernel_rx_meta(25_000_000), - owd_received_at(mono), - ); - - match events.as_slice() { - [ClientEvent::LateReply { - sent_at, - rtt, - one_way, - .. - }] => { - assert!(sent_at.is_none()); - assert!(rtt.is_none()); - assert!(one_way.is_none()); - } - other => panic!("expected an untracked LateReply, got {other:?}"), - } - } - - #[test] - fn kernel_receive_time_changes_no_measurement_other_than_downstream_delay() { - let mono = Instant::now(); - let timestamps = TimestampFields { - recv_mono: Some(1_000_000), - send_mono: Some(3_000_000), - ..owd_timestamps() - }; - - let mut kernel = machine_with_pending_probe(owd_sent_at(mono)); - let kernel_events = process_owd_reply( - &mut kernel, - timestamps.clone(), - kernel_rx_meta(25_000_000), - owd_received_at(mono), - ); - - let mut userspace = machine_with_pending_probe(owd_sent_at(mono)); - let userspace_events = process_owd_reply( - &mut userspace, - timestamps, - ReceiveMeta::default(), - owd_received_at(mono), - ); - - // RTT stays a purely monotonic userspace measurement. - assert_eq!( - reply_rtt(&kernel_events), - reply_rtt(&userspace_events), - "kernel receive metadata must not affect RTT" - ); - assert_eq!( - reply_rtt(&kernel_events).unwrap().raw, - Duration::from_millis(40) - ); - - // Upstream delay uses the client send wall time only. - assert_eq!( - reply_one_way(&kernel_events).unwrap().client_to_server, - reply_one_way(&userspace_events).unwrap().client_to_server - ); - assert_ne!( - reply_one_way(&kernel_events).unwrap().server_to_client, - reply_one_way(&userspace_events).unwrap().server_to_client - ); - } - - #[test] - fn kernel_receive_time_applies_against_a_server_midpoint_timestamp() { - let mono = Instant::now(); - let midpoint = TimestampFields { - midpoint_wall: Some(OWD_SERVER_SEND_WALL_NS), - ..Default::default() - }; - let mut machine = machine_with_pending_probe(owd_sent_at(mono)); - - let events = process_owd_reply( - &mut machine, - midpoint, - kernel_rx_meta(25_000_000), - owd_received_at(mono), - ); - - let one_way = reply_one_way(&events).unwrap(); - assert_eq!( - one_way.server_to_client, - Some(SignedDuration::from_nanos(15_000_000)) - ); - assert_eq!( - one_way.client_to_server, - Some(SignedDuration::from_nanos(10_000_000)) - ); - } - - #[test] - fn kernel_receive_time_does_not_create_missing_cross_instant_directions() { - let mono = Instant::now(); - let receive_only = TimestampFields { - recv_wall: Some(OWD_SERVER_RECV_WALL_NS), - ..Default::default() - }; - let send_only = TimestampFields { - send_wall: Some(OWD_SERVER_SEND_WALL_NS), - ..Default::default() - }; - - let mut receive_machine = machine_with_pending_probe(owd_sent_at(mono)); - let receive_events = process_owd_reply( - &mut receive_machine, - receive_only, - kernel_rx_meta(25_000_000), - owd_received_at(mono), - ); - let receive_sample = reply_one_way(&receive_events).unwrap(); - assert_eq!( - receive_sample.client_to_server, - Some(SignedDuration::from_nanos(5_000_000)) - ); - assert_eq!(receive_sample.server_to_client, None); - - let mut send_machine = machine_with_pending_probe(owd_sent_at(mono)); - let send_events = process_owd_reply( - &mut send_machine, - send_only, - kernel_rx_meta(25_000_000), - owd_received_at(mono), - ); - let send_sample = reply_one_way(&send_events).unwrap(); - assert_eq!(send_sample.client_to_server, None); - assert_eq!( - send_sample.server_to_client, - Some(SignedDuration::from_nanos(15_000_000)) - ); - } - - // Kernel TX timestamp correlation (`record_kernel_tx_timestamp`). - // - // These probe `SessionMachine`'s private association state directly. - // The kernel TX timestamp is dormant metadata in this change: none of - // these tests assert anything about RTT/OWD/IPDV output, only about - // where `PendingProbe::kernel_tx_timestamp` ends up. - - fn tx_ts(offset_secs: u64) -> SystemTime { - UNIX_EPOCH + Duration::from_secs(offset_secs) - } - - fn send_probe(machine: &mut SessionMachine, sent_at: ClientTimestamp) -> u32 { - let prepared = machine.prepare_probe().unwrap(); - let preflight = machine.preflight_probe_commit(&prepared).unwrap(); - let commit = machine.finalize_probe_commit(preflight, sent_at).unwrap(); - machine - .commit_probe_sent(commit, sent_at, prepared.bytes.len()) - .seq - } - - #[test] - fn kernel_tx_timestamp_attaches_to_pending_probe() { - let mut machine = open_machine(4, Duration::from_secs(1)); - let now = Instant::now(); - let seq = send_probe(&mut machine, timestamp(now)); - - machine.record_kernel_tx_timestamp(seq, tx_ts(1)); - - assert_eq!( - active_mut(&mut machine) - .pending - .get_mut(seq) - .unwrap() - .kernel_tx_timestamp, - Some(tx_ts(1)) - ); - } - - #[test] - fn kernel_tx_timestamp_for_unknown_id_does_nothing() { - let mut machine = open_machine(4, Duration::from_secs(1)); - let now = Instant::now(); - let seq = send_probe(&mut machine, timestamp(now)); - - // No probe was ever sent for sequence 41; recording against it must - // not panic, allocate a phantom entry, or affect the real probe. - machine.record_kernel_tx_timestamp(41, tx_ts(9)); - - assert!(active_mut(&mut machine) - .pending - .get_mut(seq) - .unwrap() - .kernel_tx_timestamp - .is_none()); - assert_eq!(active(&machine).pending.len(), 1); - } - - #[test] - fn kernel_tx_timestamps_associate_correctly_when_observed_out_of_order() { - let mut machine = open_machine(4, Duration::from_secs(1)); - let now = Instant::now(); - let seq10 = send_probe(&mut machine, timestamp(now)); - let seq11 = send_probe(&mut machine, timestamp(now)); - let seq12 = send_probe(&mut machine, timestamp(now)); - assert_eq!((seq10, seq11, seq12), (0, 1, 2)); - - // Linux does not guarantee MSG_ERRQUEUE dequeue order matches send - // order; observe them out of order (12, 10, 11). - machine.record_kernel_tx_timestamp(seq12, tx_ts(12)); - machine.record_kernel_tx_timestamp(seq10, tx_ts(10)); - machine.record_kernel_tx_timestamp(seq11, tx_ts(11)); - - let session = active_mut(&mut machine); - assert_eq!( - session.pending.get_mut(seq10).unwrap().kernel_tx_timestamp, - Some(tx_ts(10)) - ); - assert_eq!( - session.pending.get_mut(seq11).unwrap().kernel_tx_timestamp, - Some(tx_ts(11)) - ); - assert_eq!( - session.pending.get_mut(seq12).unwrap().kernel_tx_timestamp, - Some(tx_ts(12)) - ); - } - - #[test] - fn first_valid_kernel_tx_timestamp_wins_over_a_later_duplicate() { - let mut machine = open_machine(4, Duration::from_secs(1)); - let now = Instant::now(); - let seq = send_probe(&mut machine, timestamp(now)); - - machine.record_kernel_tx_timestamp(seq, tx_ts(1)); - machine.record_kernel_tx_timestamp(seq, tx_ts(2)); - - assert_eq!( - active_mut(&mut machine) - .pending - .get_mut(seq) - .unwrap() - .kernel_tx_timestamp, - Some(tx_ts(1)) - ); - } - - #[test] - fn probe_timeout_preserves_an_existing_kernel_tx_timestamp() { - let mut machine = open_machine(4, Duration::from_secs(1)); - let now = Instant::now(); - let seq = send_probe(&mut machine, timestamp(now)); - machine.record_kernel_tx_timestamp(seq, tx_ts(1)); - - let events = machine - .poll_timeouts_at(now + Duration::from_secs(2)) - .unwrap(); - assert!(matches!(events.as_slice(), [ClientEvent::EchoLoss { .. }])); - - let session = active_mut(&mut machine); - assert!(!session.pending.contains(seq)); - assert_eq!( - session.timed_out.get_mut(seq).unwrap().kernel_tx_timestamp, - Some(tx_ts(1)) - ); - } - - #[test] - fn kernel_tx_timestamp_arriving_after_timeout_attaches_to_timed_out_probe() { - let mut machine = open_machine(4, Duration::from_secs(1)); - let now = Instant::now(); - let seq = send_probe(&mut machine, timestamp(now)); - - machine - .poll_timeouts_at(now + Duration::from_secs(2)) - .unwrap(); - assert!(active(&machine).timed_out.contains(seq)); - - machine.record_kernel_tx_timestamp(seq, tx_ts(3)); - - assert_eq!( - active_mut(&mut machine) - .timed_out - .get_mut(seq) - .unwrap() - .kernel_tx_timestamp, - Some(tx_ts(3)) - ); - } - - #[test] - fn measurable_late_reply_retains_the_kernel_tx_timestamp() { - let now = Instant::now(); - let mut machine = machine_with_timed_out_probe(owd_sent_at(now)); - machine.record_kernel_tx_timestamp(0, tx_ts(4)); - assert_eq!( - active_mut(&mut machine) - .timed_out - .get_mut(0) - .unwrap() - .kernel_tx_timestamp, - Some(tx_ts(4)) - ); - - // The reply is measurable (a late reply with a retained sent_at); - // OWD/RTT still come from sent_at only, per NO_OWD_CHANGE, but the - // dormant kernel timestamp must have already been retained above - // and this call must not disturb that. - let events = process_owd_reply( - &mut machine, - owd_timestamps(), - kernel_rx_meta(25_000_000), - owd_received_at(now), - ); - assert!(matches!(events.as_slice(), [ClientEvent::LateReply { .. }])); - } - - #[test] - fn completed_probe_ignores_a_later_kernel_tx_timestamp() { - let mut machine = open_machine(4, Duration::from_secs(1)); - let now = Instant::now(); - let seq = send_probe(&mut machine, timestamp(now)); - let reply = EchoReply { - flags: 0, - token: active(&machine).token, - sequence: seq, - recv_count: None, - recv_window: None, - timestamps: TimestampFields::default(), - payload: Vec::new(), - }; - machine - .process_echo_reply( - reply, - 64, - ClientTimestamp { - mono: now, - wall: SystemTime::now(), - }, - ReceiveMeta::default(), - ) - .unwrap(); - assert!(active(&machine).completed.contains(seq)); - assert!(!active(&machine).pending.contains(seq)); - - // Must not resurrect the probe into either map. - machine.record_kernel_tx_timestamp(seq, tx_ts(5)); - - let session = active_mut(&mut machine); - assert!(session.pending.get_mut(seq).is_none()); - assert!(session.timed_out.get_mut(seq).is_none()); - } - - #[test] - fn evicted_timed_out_probe_ignores_a_later_kernel_tx_timestamp() { - let mut machine = open_machine(1, Duration::from_secs(1)); - let now = Instant::now(); - let first = send_probe(&mut machine, timestamp(now)); - machine - .poll_timeouts_at(now + Duration::from_secs(2)) - .unwrap(); - assert!(active(&machine).timed_out.contains(first)); - - // capacity 1: sending and timing out a second probe evicts the first - // from the bounded TimedOutMap. - let second = send_probe(&mut machine, timestamp(now + Duration::from_secs(2))); - machine - .poll_timeouts_at(now + Duration::from_secs(4)) - .unwrap(); - assert!(active(&machine).timed_out.contains(second)); - assert!(!active(&machine).timed_out.contains(first)); - - // Recording against the evicted ID must not panic or resurrect it. - machine.record_kernel_tx_timestamp(first, tx_ts(6)); - assert!(active_mut(&mut machine).timed_out.get_mut(first).is_none()); - } - - #[test] - fn kernel_tx_timestamp_ids_wrap_like_wire_seq() { - let mut machine = open_machine(4, Duration::from_secs(1)); - active_mut(&mut machine).next_wire_seq = u32::MAX; - let now = Instant::now(); - let last = send_probe(&mut machine, timestamp(now)); - let wrapped = send_probe(&mut machine, timestamp(now)); - assert_eq!(last, u32::MAX); - assert_eq!(wrapped, 0); - - machine.record_kernel_tx_timestamp(u32::MAX, tx_ts(1)); - machine.record_kernel_tx_timestamp(0, tx_ts(2)); - - let session = active_mut(&mut machine); - assert_eq!( - session - .pending - .get_mut(u32::MAX) - .unwrap() - .kernel_tx_timestamp, - Some(tx_ts(1)) - ); - assert_eq!( - session.pending.get_mut(0).unwrap().kernel_tx_timestamp, - Some(tx_ts(2)) - ); - } - - #[test] - fn kernel_tx_timestamp_changes_only_upstream_one_way_delay() { - let mono = Instant::now(); - let mut without = machine_with_pending_probe(owd_sent_at(mono)); - let mut with = machine_with_pending_probe(owd_sent_at(mono)); - // Plausible: strictly between sent_at and received_at on the - // client's own wall clock. - with.record_kernel_tx_timestamp(0, owd_wall(2_000_000)); - - let without_events = process_owd_reply( - &mut without, - owd_timestamps(), - kernel_rx_meta(25_000_000), - owd_received_at(mono), - ); - let with_events = process_owd_reply( - &mut with, - owd_timestamps(), - kernel_rx_meta(25_000_000), - owd_received_at(mono), - ); - - let without_reply = match without_events.as_slice() { - [ClientEvent::EchoReply { rtt, one_way, .. }] => (*rtt, *one_way), - other => panic!("expected one EchoReply, got {other:?}"), - }; - let with_reply = match with_events.as_slice() { - [ClientEvent::EchoReply { rtt, one_way, .. }] => (*rtt, *one_way), - other => panic!("expected one EchoReply, got {other:?}"), - }; - - // RTT is a purely monotonic userspace measurement. - assert_eq!(without_reply.0, with_reply.0, "RTT must be unaffected"); - // Downstream delay is governed solely by the kernel RX selection. - assert_eq!( - without_reply.1.unwrap().server_to_client, - with_reply.1.unwrap().server_to_client, - "downstream delay must be unaffected" - ); - // Upstream delay is the one thing the kernel TX timestamp changes. - assert_ne!( - without_reply.1.unwrap().client_to_server, - with_reply.1.unwrap().client_to_server - ); - assert_eq!( - with_reply.1.unwrap().client_to_server, - Some(SignedDuration::from_nanos(3_000_000)) - ); - } - - // Upstream one-way delay: preferred client send wall selection. - // - // `preferred_send_wall` is exercised directly first (pure plausibility - // logic), then through `compute_one_way`/`process_echo_reply` to pin the - // end-to-end measurement behavior described in the client crate's - // `AGENTS.md`. - - fn send_wall_ms(offset_ms: u64) -> SystemTime { - UNIX_EPOCH + Duration::from_millis(offset_ms) - } - - #[test] - fn preferred_send_wall_accepts_kernel_timestamp_equal_to_lower_bound() { - let anchor = send_wall_ms(1_000); - let received_at = send_wall_ms(1_040); - assert_eq!( - preferred_send_wall(anchor, anchor, Some(anchor), received_at), - anchor - ); - } - - #[test] - fn preferred_send_wall_accepts_kernel_timestamp_equal_to_received_at() { - let anchor = send_wall_ms(1_000); - let received_at = send_wall_ms(1_040); - assert_eq!( - preferred_send_wall(anchor, anchor, Some(received_at), received_at), - received_at - ); - } - - fn upstream_probe(sent_wall: SystemTime, mono: Instant) -> SessionMachine { - machine_with_pending_probe(ClientTimestamp { - mono, - wall: sent_wall, - }) - } - - fn upstream_reply( - machine: &mut SessionMachine, - server_recv_wall_ms: u64, - received_wall: SystemTime, - mono: Instant, - ) -> Vec { - let timestamps = TimestampFields { - recv_wall: Some( - i64::try_from(Duration::from_millis(server_recv_wall_ms).as_nanos()).unwrap(), - ), - ..Default::default() - }; - let received_at = ClientTimestamp { - mono: mono + Duration::from_millis(40), - wall: received_wall, - }; - process_owd_reply(machine, timestamps, ReceiveMeta::default(), received_at) - } - - #[test] - fn upstream_one_way_delay_prefers_plausible_kernel_tx_time() { - let mono = Instant::now(); - let mut machine = upstream_probe(send_wall_ms(1_000), mono); - machine.record_kernel_tx_timestamp(0, send_wall_ms(1_005)); - - let events = upstream_reply(&mut machine, 1_020, send_wall_ms(1_040), mono); - - assert_eq!( - reply_one_way(&events).unwrap().client_to_server, - Some(SignedDuration::from_nanos(15_000_000)) - ); - } - - #[test] - fn invalidated_kernel_tx_correlation_uses_userspace_send_time() { - let mono = Instant::now(); - let mut machine = upstream_probe(send_wall_ms(1_000), mono); - - // A failed send can consume a kernel ID without advancing wire_seq. - // A later timestamp whose ID happens to match a pending wire sequence - // must not be associated after that gap. - machine.invalidate_kernel_tx_correlation(); - machine.record_kernel_tx_timestamp(0, send_wall_ms(1_005)); - - let events = upstream_reply(&mut machine, 1_020, send_wall_ms(1_040), mono); - - assert_eq!( - reply_one_way(&events).unwrap().client_to_server, - Some(SignedDuration::from_nanos(20_000_000)) - ); - } - - #[test] - fn upstream_one_way_delay_falls_back_without_kernel_tx() { - let mono = Instant::now(); - let mut machine = upstream_probe(send_wall_ms(1_000), mono); - - let events = upstream_reply(&mut machine, 1_020, send_wall_ms(1_040), mono); - - assert_eq!( - reply_one_way(&events).unwrap().client_to_server, - Some(SignedDuration::from_nanos(20_000_000)) - ); - } - - #[test] - fn upstream_one_way_delay_falls_back_for_kernel_tx_earlier_than_sent_at() { - let mono = Instant::now(); - let mut machine = upstream_probe(send_wall_ms(1_000), mono); - machine.record_kernel_tx_timestamp(0, send_wall_ms(999)); - - let events = upstream_reply(&mut machine, 1_020, send_wall_ms(1_040), mono); - - assert_eq!( - reply_one_way(&events).unwrap().client_to_server, - Some(SignedDuration::from_nanos(20_000_000)) - ); - } - - #[test] - fn upstream_one_way_delay_falls_back_for_kernel_tx_later_than_received_at() { - let mono = Instant::now(); - let mut machine = upstream_probe(send_wall_ms(1_000), mono); - machine.record_kernel_tx_timestamp(0, send_wall_ms(1_041)); - - let events = upstream_reply(&mut machine, 1_020, send_wall_ms(1_040), mono); - - assert_eq!( - reply_one_way(&events).unwrap().client_to_server, - Some(SignedDuration::from_nanos(20_000_000)) - ); - } - - #[test] - fn upstream_one_way_delay_measurable_late_reply_uses_the_retained_kernel_tx_time() { - let mono = Instant::now(); - let mut machine = machine_with_timed_out_probe(ClientTimestamp { - mono, - wall: send_wall_ms(1_000), - }); - machine.record_kernel_tx_timestamp(0, send_wall_ms(1_005)); - - let events = upstream_reply(&mut machine, 1_020, send_wall_ms(1_040), mono); - - assert!(matches!(events.as_slice(), [ClientEvent::LateReply { .. }])); - assert_eq!( - reply_one_way(&events).unwrap().client_to_server, - Some(SignedDuration::from_nanos(15_000_000)) - ); - } - - #[test] - fn upstream_one_way_delay_untracked_late_reply_stays_unmeasurable() { - let mono = Instant::now(); - let mut machine = open_machine(4, Duration::from_secs(1)); - active_mut(&mut machine).highest_received_seq = Some(5); - - let events = upstream_reply(&mut machine, 1_020, send_wall_ms(1_040), mono); - - match events.as_slice() { - [ClientEvent::LateReply { one_way, .. }] => assert!(one_way.is_none()), - other => panic!("expected an untracked LateReply, got {other:?}"), - } - } - - #[test] - fn upstream_one_way_delay_applies_against_a_server_midpoint_timestamp() { - let mono = Instant::now(); - let mut machine = upstream_probe(send_wall_ms(100), mono); - machine.record_kernel_tx_timestamp(0, send_wall_ms(105)); - - let timestamps = TimestampFields { - midpoint_wall: Some(120_000_000), - ..Default::default() - }; - let received_at = ClientTimestamp { - mono: mono + Duration::from_millis(40), - wall: send_wall_ms(140), - }; - let events = process_owd_reply( - &mut machine, - timestamps, - ReceiveMeta::default(), - received_at, - ); - - assert_eq!( - reply_one_way(&events).unwrap().client_to_server, - Some(SignedDuration::from_nanos(15_000_000)) - ); - } - - #[test] - fn upstream_one_way_delay_remains_unavailable_for_send_only_server_timestamps() { - let mono = Instant::now(); - let mut machine = upstream_probe(send_wall_ms(1_000), mono); - machine.record_kernel_tx_timestamp(0, send_wall_ms(1_005)); - - let timestamps = TimestampFields { - send_wall: Some(1_010_000_000), - ..Default::default() - }; - let received_at = ClientTimestamp { - mono: mono + Duration::from_millis(40), - wall: send_wall_ms(1_040), - }; - let events = process_owd_reply( - &mut machine, - timestamps, - ReceiveMeta::default(), - received_at, - ); - - assert_eq!(reply_one_way(&events).unwrap().client_to_server, None); - } - - // F-04: post-send `sent_at` measurement timing. - // - // `sent_at` moved from immediately before timeout finalization/socket - // send to immediately after a successful send. These tests pin the - // resulting end-to-end behavior: raw RTT and the userspace upstream OWD - // fallback both shrink because they no longer include the send-call - // interval, while kernel TX plausibility keeps a separate pre-send - // lower bound so a legitimate `TX_SOFTWARE` timestamp generated during - // the send path is not rejected merely for preceding the post-send - // sample. - - #[test] - fn raw_rtt_uses_post_send_sent_at_not_pre_send_anchor() { - let base = Instant::now(); - // A pre-send anchor at base+100ms is deliberately not used here: - // raw RTT must be measured from the post-send `sent_at` at - // base+110ms, giving 50ms, not the 60ms a pre-send anchor would - // have produced. - let sent_at = ClientTimestamp { - mono: base + Duration::from_millis(110), - wall: SystemTime::now(), - }; - let received_at = ClientTimestamp { - mono: base + Duration::from_millis(160), - wall: SystemTime::now(), - }; - - let rtt = compute_rtt(&sent_at, &received_at, &TimestampFields::default()); - - assert_eq!(rtt.raw, Duration::from_millis(50)); - assert_eq!(rtt.effective, SignedDuration::from_duration(rtt.raw)); - } - - #[test] - fn userspace_upstream_owd_fallback_uses_post_send_sent_at() { - let mono = Instant::now(); - let mut machine = machine_with_pending_probe_anchored( - send_wall_ms(1_000), // pre-send anchor - ClientTimestamp { - mono, - wall: send_wall_ms(1_008), // post-send sent_at - }, - ); - - let events = upstream_reply(&mut machine, 1_020, send_wall_ms(1_040), mono); - - // Old behavior would have used the pre-send 1000ms sample, giving - // 20ms. The new fallback uses the post-send 1008ms sample. - assert_eq!( - reply_one_way(&events).unwrap().client_to_server, - Some(SignedDuration::from_nanos(12_000_000)) - ); - } - - #[test] - fn kernel_tx_accepted_even_though_earlier_than_post_send_sent_at() { - let mono = Instant::now(); - let mut machine = machine_with_pending_probe_anchored( - send_wall_ms(1_000), // pre-send anchor / tx_not_before_wall - ClientTimestamp { - mono, - wall: send_wall_ms(1_008), // post-send sent_at - }, - ); - // The kernel TX timestamp is after the pre-send anchor and before - // the reply, but before the post-send `sent_at` sample. It must - // remain accepted: rejecting it here would be the regression this - // test guards against. - machine.record_kernel_tx_timestamp(0, send_wall_ms(1_005)); - - let events = upstream_reply(&mut machine, 1_020, send_wall_ms(1_040), mono); - - assert_eq!( - reply_one_way(&events).unwrap().client_to_server, - Some(SignedDuration::from_nanos(15_000_000)) - ); - } - - #[test] - fn kernel_tx_below_pre_send_anchor_falls_back_to_post_send_not_pre_send() { - let mono = Instant::now(); - let mut machine = machine_with_pending_probe_anchored( - send_wall_ms(1_000), // pre-send anchor / tx_not_before_wall - ClientTimestamp { - mono, - wall: send_wall_ms(1_008), // post-send sent_at - }, - ); - // Earlier than even the pre-send anchor: implausible, rejected. - machine.record_kernel_tx_timestamp(0, send_wall_ms(999)); - - let events = upstream_reply(&mut machine, 1_020, send_wall_ms(1_040), mono); - - // Fallback is the post-send 1008ms sample (12ms), not the pre-send - // 1000ms anchor (which would have given 20ms). - assert_eq!( - reply_one_way(&events).unwrap().client_to_server, - Some(SignedDuration::from_nanos(12_000_000)) - ); - } -} diff --git a/crates/irtt-client/src/session/negotiation.rs b/crates/irtt-client/src/session/negotiation.rs index 00a48af..d1e3fc7 100644 --- a/crates/irtt-client/src/session/negotiation.rs +++ b/crates/irtt-client/src/session/negotiation.rs @@ -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()); - } -} diff --git a/crates/irtt-client/src/socket.rs b/crates/irtt-client/src/socket.rs index 249b1c7..7753e78 100644 --- a/crates/irtt-client/src/socket.rs +++ b/crates/irtt-client/src/socket.rs @@ -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 - ) - } -} diff --git a/crates/irtt-client/tests/client.rs b/crates/irtt-client/tests/client.rs new file mode 100644 index 0000000..9d33ffe --- /dev/null +++ b/crates/irtt-client/tests/client.rs @@ -0,0 +1,8 @@ +use irtt_client::*; +use std::{ + net::SocketAddr, + time::{Duration, Instant}, +}; + +#[path = "client/mod.rs"] +mod client; diff --git a/crates/irtt-client/src/client/tests/close.rs b/crates/irtt-client/tests/client/close.rs similarity index 97% rename from crates/irtt-client/src/client/tests/close.rs rename to crates/irtt-client/tests/client/close.rs index 21ae27b..128172c 100644 --- a/crates/irtt-client/src/client/tests/close.rs +++ b/crates/irtt-client/tests/client/close.rs @@ -2,8 +2,7 @@ use super::*; #[test] fn open_fails_after_close() { - let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1))); - let params = params_from_config(&config).unwrap(); + let params = default_params(); let server = start_fake_server(move |socket, tx| { let (_, peer) = recv_request(&socket, &tx); let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None); @@ -123,7 +122,7 @@ fn close_flagged_duplicate_emits_duplicate_then_closes() { } }); let mut client = Client::connect(ClientConfig { - socket_config: crate::SocketConfig { + socket_config: irtt_client::SocketConfig { recv_timeout: Some(Duration::from_millis(200)), ..Default::default() }, @@ -181,7 +180,7 @@ fn close_flagged_retained_timeout_emits_late_then_closes() { }); let mut client = Client::connect(ClientConfig { probe_timeout: Duration::from_millis(50), - socket_config: crate::SocketConfig { + socket_config: irtt_client::SocketConfig { recv_timeout: Some(Duration::from_millis(200)), ..Default::default() }, @@ -276,7 +275,7 @@ fn close_flagged_evicted_sequence_emits_late_then_closes() { }); let mut client = Client::connect(ClientConfig { max_pending_probes: 1, - socket_config: crate::SocketConfig { + socket_config: irtt_client::SocketConfig { recv_timeout: Some(Duration::from_millis(200)), ..Default::default() }, @@ -340,7 +339,7 @@ fn close_flagged_untracked_sequence_emits_warning_then_closes() { } }); let mut client = Client::connect(ClientConfig { - socket_config: crate::SocketConfig { + socket_config: irtt_client::SocketConfig { recv_timeout: Some(Duration::from_millis(200)), ..Default::default() }, @@ -398,7 +397,7 @@ fn wrong_token_close_flag_does_not_close_session() { } }); let mut client = Client::connect(ClientConfig { - socket_config: crate::SocketConfig { + socket_config: irtt_client::SocketConfig { recv_timeout: Some(Duration::from_millis(200)), ..Default::default() }, @@ -453,7 +452,7 @@ fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() { } }); let config = ClientConfig { - socket_config: crate::SocketConfig { + socket_config: irtt_client::SocketConfig { recv_timeout: Some(Duration::from_millis(200)), ..Default::default() }, diff --git a/crates/irtt-client/src/client/tests/config.rs b/crates/irtt-client/tests/client/config.rs similarity index 100% rename from crates/irtt-client/src/client/tests/config.rs rename to crates/irtt-client/tests/client/config.rs diff --git a/crates/irtt-client/tests/client/hmac.rs b/crates/irtt-client/tests/client/hmac.rs new file mode 100644 index 0000000..da4a310 --- /dev/null +++ b/crates/irtt-client/tests/client/hmac.rs @@ -0,0 +1,76 @@ +use super::*; + +#[test] +fn hmac_open_ignores_missing_hmac_before_valid_reply() { + let key = b"secret".to_vec(); + let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1))); + config.hmac_key = Some(key.clone()); + let params = default_params(); + let server = start_fake_server(move |socket, tx| { + let (_, peer) = recv_request(&socket, &tx); + let missing = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None); + let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&key)); + socket.send_to(&missing, peer).unwrap(); + socket.send_to(&valid, peer).unwrap(); + }); + config.server_addr = server.addr.to_string(); + let mut client = Client::connect(config).unwrap(); + assert_open_started(client.open().unwrap()); + assert_eq!(server.rx.iter().take(1).count(), 1); + assert!(server.rx.try_recv().is_err()); + server.join(); +} + +#[test] +fn hmac_open_ignores_bad_hmac_before_valid_reply() { + let key = b"secret".to_vec(); + let wrong_key = b"wrong".to_vec(); + let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1))); + config.hmac_key = Some(key.clone()); + let params = default_params(); + let server = start_fake_server(move |socket, tx| { + let (_, peer) = recv_request(&socket, &tx); + let bad = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&wrong_key)); + let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&key)); + socket.send_to(&bad, peer).unwrap(); + socket.send_to(&valid, peer).unwrap(); + }); + config.server_addr = server.addr.to_string(); + let mut client = Client::connect(config).unwrap(); + assert_open_started(client.open().unwrap()); + assert_eq!(server.rx.iter().take(1).count(), 1); + assert!(server.rx.try_recv().is_err()); + server.join(); +} + +#[test] +fn post_token_hmac_negotiation_failure_sends_authenticated_cleanup_close() { + let key = b"secret".to_vec(); + let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1))); + config.hmac_key = Some(key.clone()); + let mut returned = default_params(); + returned.interval_ns += 1; + let server_key = key.clone(); + let server = start_fake_server(move |socket, tx| { + let (_, peer) = recv_request(&socket, &tx); + let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, Some(&server_key)); + socket.send_to(&reply, peer).unwrap(); + let _ = recv_request(&socket, &tx); + }); + config.server_addr = server.addr.to_string(); + let mut client = Client::connect(config).unwrap(); + + assert!(matches!( + client.open(), + Err(ClientError::NegotiationRejected { .. }) + )); + let packets: Vec<_> = server.rx.iter().take(2).collect(); + let cleanup = &packets[1]; + assert_eq!(cleanup[3], flags::FLAG_CLOSE | FLAG_HMAC); + verify_packet_hmac(&key, cleanup).unwrap(); + assert_eq!( + u64::from_le_bytes(cleanup[4 + HMAC_SIZE..12 + HMAC_SIZE].try_into().unwrap()), + TOKEN + ); + server.join(); +} diff --git a/crates/irtt-client/tests/client/mod.rs b/crates/irtt-client/tests/client/mod.rs new file mode 100644 index 0000000..a084b7f --- /dev/null +++ b/crates/irtt-client/tests/client/mod.rs @@ -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; diff --git a/crates/irtt-client/src/client/tests/no_test.rs b/crates/irtt-client/tests/client/no_test.rs similarity index 88% rename from crates/irtt-client/src/client/tests/no_test.rs rename to crates/irtt-client/tests/client/no_test.rs index 00bc9c8..b90f0b7 100644 --- a/crates/irtt-client/src/client/tests/no_test.rs +++ b/crates/irtt-client/tests/client/no_test.rs @@ -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(); diff --git a/crates/irtt-client/src/client/tests/open.rs b/crates/irtt-client/tests/client/open.rs similarity index 84% rename from crates/irtt-client/src/client/tests/open.rs rename to crates/irtt-client/tests/client/open.rs index 9e99995..dbd97b2 100644 --- a/crates/irtt-client/src/client/tests/open.rs +++ b/crates/irtt-client/tests/client/open.rs @@ -2,8 +2,7 @@ use super::*; #[test] fn open_fails_when_already_open() { - let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1))); - let params = params_from_config(&config).unwrap(); + let params = default_params(); let server = open_success_server(params); let mut client = Client::connect(default_test_config(server.addr)).unwrap(); assert_open_started(client.open().unwrap()); @@ -36,7 +35,7 @@ fn open_retries_after_first_timeout() { fn protocol_version_mismatch_fails() { let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1))); config.negotiation_policy = NegotiationPolicy::Loose; - let mut params = params_from_config(&config).unwrap(); + let mut params = default_params(); params.protocol_version = 2; let server = open_success_server(params); let mut client = Client::connect(default_test_config(server.addr)).unwrap(); @@ -45,14 +44,12 @@ fn protocol_version_mismatch_fails() { Err(ClientError::ProtocolVersionMismatch { received: 2, .. }) )); assert_eq!(server.rx.iter().take(1).count(), 1); - assert!(client.runtime.prepare_open_request().is_ok()); server.join(); } #[test] fn server_rejection_fails_in_normal_mode() { - let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1))); - let params = params_from_config(&config).unwrap(); + let params = default_params(); let server = start_fake_server(move |socket, tx| { let (_, peer) = recv_request(&socket, &tx); let reply = open_reply(FLAG_OPEN | FLAG_REPLY | flags::FLAG_CLOSE, 0, ¶ms, None); @@ -61,7 +58,6 @@ fn server_rejection_fails_in_normal_mode() { let mut client = Client::connect(default_test_config(server.addr)).unwrap(); assert!(matches!(client.open(), Err(ClientError::ServerRejected))); assert_eq!(server.rx.iter().take(1).count(), 1); - assert!(client.runtime.prepare_open_request().is_ok()); server.join(); } @@ -132,25 +128,6 @@ fn post_token_negotiation_failure_sends_cleanup_close() { u64::from_le_bytes(packets[1][4..12].try_into().unwrap()), TOKEN ); - assert!(client.runtime.prepare_open_request().is_ok()); - server.join(); -} - -#[test] -fn successful_open_restores_configured_receive_timeout() { - let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1))); - let params = params_from_config(&config).unwrap(); - let server = open_success_server(params); - let mut config = default_test_config(server.addr); - config.socket_config.recv_timeout = Some(Duration::from_millis(75)); - let mut client = Client::connect(config).unwrap(); - - assert_open_started(client.open().unwrap()); - - assert_eq!( - client.socket.read_timeout().unwrap(), - Some(Duration::from_millis(75)) - ); server.join(); } @@ -159,7 +136,7 @@ fn open_timeout_after_all_timeouts() { let server = timeout_server(Duration::from_millis(700)); let config = ClientConfig { open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)], - socket_config: crate::SocketConfig { + socket_config: irtt_client::SocketConfig { recv_timeout: Some(Duration::from_millis(50)), ..Default::default() }, @@ -168,10 +145,6 @@ fn open_timeout_after_all_timeouts() { let mut client = Client::connect(config).unwrap(); assert!(matches!(client.open(), Err(ClientError::OpenTimeout))); assert_eq!(server.rx.iter().take(2).count(), 2); - assert_eq!( - client.socket.read_timeout().unwrap(), - Some(Duration::from_millis(50)) - ); server.join(); } @@ -222,7 +195,7 @@ fn only_ignored_open_datagrams_eventually_time_out() { }); let config = ClientConfig { open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)], - socket_config: crate::SocketConfig { + socket_config: irtt_client::SocketConfig { recv_timeout: Some(Duration::from_millis(50)), ..Default::default() }, @@ -233,10 +206,6 @@ fn only_ignored_open_datagrams_eventually_time_out() { assert!(matches!(client.open(), Err(ClientError::OpenTimeout))); assert_eq!(server.rx.iter().take(2).count(), 2); assert!(server.rx.try_recv().is_err()); - assert_eq!( - client.socket.read_timeout().unwrap(), - Some(Duration::from_millis(50)) - ); server.join(); } @@ -259,7 +228,6 @@ fn trusted_zero_token_normal_reply_is_terminal() { Err(ClientError::Protocol(irtt_proto::ProtoError::ZeroToken)) )); assert_eq!(server.rx.iter().take(1).count(), 1); - assert!(client.runtime.prepare_open_request().is_ok()); server.join(); } diff --git a/crates/irtt-client/src/client/tests/probes_replies/burst_recv.rs b/crates/irtt-client/tests/client/probes_replies/burst_recv.rs similarity index 100% rename from crates/irtt-client/src/client/tests/probes_replies/burst_recv.rs rename to crates/irtt-client/tests/client/probes_replies/burst_recv.rs diff --git a/crates/irtt-client/src/client/tests/probes_replies/mod.rs b/crates/irtt-client/tests/client/probes_replies/mod.rs similarity index 90% rename from crates/irtt-client/src/client/tests/probes_replies/mod.rs rename to crates/irtt-client/tests/client/probes_replies/mod.rs index 8f84f75..a2064cd 100644 --- a/crates/irtt-client/src/client/tests/probes_replies/mod.rs +++ b/crates/irtt-client/tests/client/probes_replies/mod.rs @@ -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; diff --git a/crates/irtt-client/src/client/tests/probes_replies/packet_length.rs b/crates/irtt-client/tests/client/probes_replies/packet_length.rs similarity index 67% rename from crates/irtt-client/src/client/tests/probes_replies/packet_length.rs rename to crates/irtt-client/tests/client/probes_replies/packet_length.rs index b577ce9..7c34a6e 100644 --- a/crates/irtt-client/src/client/tests/probes_replies/packet_length.rs +++ b/crates/irtt-client/tests/client/probes_replies/packet_length.rs @@ -1,62 +1,5 @@ use super::*; -#[test] -fn minimal_negotiated_layout_works() { - let params = Params { - protocol_version: 1, - duration_ns: 3_000_000_000, - interval_ns: 1_000_000_000, - received_stats: ReceivedStats::None, - stamp_at: StampAt::None, - clock: Clock::Both, - ..Params::default() - }; - let server = start_fake_server(move |socket, tx| { - let (_, peer) = recv_request(&socket, &tx); - let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None); - socket.send_to(&reply, peer).unwrap(); - - socket - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); - let mut buf = [0_u8; 2048]; - if let Ok((size, _)) = socket.recv_from(&mut buf) { - tx.send(buf[..size].to_vec()).unwrap(); - let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap()); - let ts = TimestampFields::default(); - let reply_packet = echo_reply_packet(TOKEN, seq, ¶ms, &ts, None); - socket.send_to(&reply_packet, peer).unwrap(); - } - }); - let config = ClientConfig { - received_stats: ReceivedStats::None, - stamp_at: StampAt::None, - socket_config: crate::SocketConfig { - recv_timeout: Some(Duration::from_millis(200)), - ..Default::default() - }, - ..default_test_config(server.addr) - }; - let mut client = Client::connect(config).unwrap(); - assert_open_started(client.open().unwrap()); - client.send_probe().unwrap(); - thread::sleep(Duration::from_millis(30)); - let events = client.recv_once().unwrap(); - assert_eq!(events.len(), 1); - if let ClientEvent::EchoReply { - received_stats, - server_timing, - .. - } = &events[0] - { - assert!(received_stats.is_none()); - assert!(server_timing.is_none()); - } else { - panic!("expected EchoReply"); - } - server.join(); -} - // ---------- Regression tests ---------- #[test] @@ -81,7 +24,7 @@ fn short_echo_reply_does_not_emit_echo_reply() { }); let config = ClientConfig { length: 64, - socket_config: crate::SocketConfig { + socket_config: irtt_client::SocketConfig { recv_timeout: Some(Duration::from_millis(200)), ..Default::default() }, @@ -127,7 +70,7 @@ fn overlong_datagram_detection_uses_extra_receive_byte() { }); let config = ClientConfig { length: 4096, - socket_config: crate::SocketConfig { + socket_config: irtt_client::SocketConfig { recv_timeout: Some(Duration::from_millis(200)), ..Default::default() }, @@ -170,7 +113,7 @@ fn exact_length_echo_reply_still_emits_echo_reply() { }); let config = ClientConfig { length: 4096, - socket_config: crate::SocketConfig { + socket_config: irtt_client::SocketConfig { recv_timeout: Some(Duration::from_millis(200)), ..Default::default() }, diff --git a/crates/irtt-client/src/client/tests/probes_replies/reply_classification.rs b/crates/irtt-client/tests/client/probes_replies/reply_classification.rs similarity index 97% rename from crates/irtt-client/src/client/tests/probes_replies/reply_classification.rs rename to crates/irtt-client/tests/client/probes_replies/reply_classification.rs index 198ed77..a85c6cc 100644 --- a/crates/irtt-client/src/client/tests/probes_replies/reply_classification.rs +++ b/crates/irtt-client/tests/client/probes_replies/reply_classification.rs @@ -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() }, diff --git a/crates/irtt-client/src/client/tests/probes_replies/reply_decode.rs b/crates/irtt-client/tests/client/probes_replies/reply_decode.rs similarity index 53% rename from crates/irtt-client/src/client/tests/probes_replies/reply_decode.rs rename to crates/irtt-client/tests/client/probes_replies/reply_decode.rs index 7ec1f19..6a49c63 100644 --- a/crates/irtt-client/src/client/tests/probes_replies/reply_decode.rs +++ b/crates/irtt-client/tests/client/probes_replies/reply_decode.rs @@ -5,7 +5,7 @@ fn recv_once_returns_empty_on_timeout() { let params = default_params(); let server = open_success_server(params); let config = ClientConfig { - socket_config: crate::SocketConfig { + socket_config: irtt_client::SocketConfig { recv_timeout: Some(Duration::from_millis(50)), ..Default::default() }, @@ -18,66 +18,6 @@ fn recv_once_returns_empty_on_timeout() { server.join(); } -#[test] -fn recv_once_decodes_echo_reply_and_emits_event() { - let params = default_params(); - let (mut client, server) = open_client_with_echo_server(¶ms); - - client.send_probe().unwrap(); - thread::sleep(Duration::from_millis(50)); - - let events = client.recv_once().unwrap(); - assert_eq!(events.len(), 1); - match &events[0] { - ClientEvent::EchoReply { - seq, - rtt, - received_stats, - server_timing, - bytes, - packet_meta, - .. - } => { - assert_eq!(*seq, 0); - assert_eq!(*bytes, echo_packet_len(false, ¶ms)); - assert!(rtt.raw > Duration::ZERO); - assert_eq!( - rtt.effective, - rtt.adjusted - .unwrap_or_else(|| SignedDuration::from_duration(rtt.raw)) - ); - assert!(received_stats.is_some()); - let stats = received_stats.as_ref().unwrap(); - assert_eq!(stats.count, Some(42)); - assert_eq!(stats.window, Some(0x07)); - assert!(server_timing.is_some()); - let st = server_timing.as_ref().unwrap(); - assert!(st.processing.is_some()); - #[cfg(not(all(target_os = "linux", feature = "ancillary")))] - { - assert_eq!(packet_meta.traffic_class, None); - assert_eq!(packet_meta.dscp, None); - assert_eq!(packet_meta.ecn, None); - assert_eq!(packet_meta.kernel_rx_timestamp, None); - } - #[cfg(all(target_os = "linux", feature = "ancillary"))] - { - assert_eq!( - packet_meta.dscp, - packet_meta.traffic_class.map(|tc| tc >> 2) - ); - assert_eq!( - packet_meta.ecn, - packet_meta.traffic_class.map(|tc| tc & 0b11) - ); - } - } - other => panic!("expected EchoReply, got {other:?}"), - } - client.close().unwrap(); - server.join(); -} - #[test] fn recv_once_decodes_only_received_bytes_after_longer_datagram() { let params = default_params(); @@ -113,7 +53,7 @@ fn recv_once_decodes_only_received_bytes_after_longer_datagram() { socket.send_to(&shorter, peer).unwrap(); }); let config = ClientConfig { - socket_config: crate::SocketConfig { + socket_config: irtt_client::SocketConfig { recv_timeout: Some(Duration::from_millis(200)), ..Default::default() }, diff --git a/crates/irtt-client/src/client/tests/probes_replies/reply_metadata.rs b/crates/irtt-client/tests/client/probes_replies/reply_metadata.rs similarity index 95% rename from crates/irtt-client/src/client/tests/probes_replies/reply_metadata.rs rename to crates/irtt-client/tests/client/probes_replies/reply_metadata.rs index a6f1d94..d11cae7 100644 --- a/crates/irtt-client/src/client/tests/probes_replies/reply_metadata.rs +++ b/crates/irtt-client/tests/client/probes_replies/reply_metadata.rs @@ -14,13 +14,13 @@ fn echo_reply_metadata_propagates_observed_dscp_with_ancillary() { let (size, _) = socket.recv_from(&mut buf).unwrap(); tx.send(buf[..size].to_vec()).unwrap(); let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap()); - crate::socket_options::apply_traffic_class_to_socket(&socket, peer, 184).unwrap(); + socket2::SockRef::from(&socket).set_tos_v4(184).unwrap(); let reply_packet = echo_reply_packet(TOKEN, seq, ¶ms, &TimestampFields::default(), None); socket.send_to(&reply_packet, peer).unwrap(); }); let config = ClientConfig { - socket_config: crate::SocketConfig { + socket_config: irtt_client::SocketConfig { recv_timeout: Some(Duration::from_millis(200)), ..Default::default() }, diff --git a/crates/irtt-client/src/client/tests/probes_replies/timeouts.rs b/crates/irtt-client/tests/client/probes_replies/timeouts.rs similarity index 97% rename from crates/irtt-client/src/client/tests/probes_replies/timeouts.rs rename to crates/irtt-client/tests/client/probes_replies/timeouts.rs index 3b938cd..6d9a7f3 100644 --- a/crates/irtt-client/src/client/tests/probes_replies/timeouts.rs +++ b/crates/irtt-client/tests/client/probes_replies/timeouts.rs @@ -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() }, diff --git a/crates/irtt-client/src/client/tests/support.rs b/crates/irtt-client/tests/client/support.rs similarity index 99% rename from crates/irtt-client/src/client/tests/support.rs rename to crates/irtt-client/tests/client/support.rs index 0826134..8f99479 100644 --- a/crates/irtt-client/src/client/tests/support.rs +++ b/crates/irtt-client/tests/client/support.rs @@ -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() }, diff --git a/crates/irtt-client/tests/client/ttl.rs b/crates/irtt-client/tests/client/ttl.rs new file mode 100644 index 0000000..5e2aeac --- /dev/null +++ b/crates/irtt-client/tests/client/ttl.rs @@ -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 { .. }) + )); + } +} diff --git a/crates/irtt-integration-tests/tests/async_client.rs b/crates/irtt-integration-tests/tests/async_client.rs index 661ebb2..1b93282 100644 --- a/crates/irtt-integration-tests/tests/async_client.rs +++ b/crates/irtt-integration-tests/tests/async_client.rs @@ -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 diff --git a/crates/irtt-client/src/async_client/tests.rs b/crates/irtt-integration-tests/tests/async_client_lifecycle.rs similarity index 97% rename from crates/irtt-client/src/async_client/tests.rs rename to crates/irtt-integration-tests/tests/async_client_lifecycle.rs index 4c90633..e1baf91 100644 --- a/crates/irtt-client/src/async_client/tests.rs +++ b/crates/irtt-integration-tests/tests/async_client_lifecycle.rs @@ -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); } diff --git a/crates/irtt-client/src/managed/blocking_tests.rs b/crates/irtt-integration-tests/tests/blocking_managed.rs similarity index 87% rename from crates/irtt-client/src/managed/blocking_tests.rs rename to crates/irtt-integration-tests/tests/blocking_managed.rs index a5ea559..3f65168 100644 --- a/crates/irtt-client/src/managed/blocking_tests.rs +++ b/crates/irtt-integration-tests/tests/blocking_managed.rs @@ -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(); diff --git a/crates/irtt-integration-tests/tests/managed_behavior.rs b/crates/irtt-integration-tests/tests/managed_behavior.rs new file mode 100644 index 0000000..b202b04 --- /dev/null +++ b/crates/irtt-integration-tests/tests/managed_behavior.rs @@ -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> = 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"); +} diff --git a/crates/irtt-integration-tests/tests/managed_lifecycle.rs b/crates/irtt-integration-tests/tests/managed_lifecycle.rs new file mode 100644 index 0000000..a9a15ad --- /dev/null +++ b/crates/irtt-integration-tests/tests/managed_lifecycle.rs @@ -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>>, + thread: JoinHandle<()>, +} + +impl TestServer { + fn finish(self) -> Vec { + 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> { + 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>) -> 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(future: Pin<&mut F>) -> Poll { + Future::poll(future, &mut Context::from_waker(Waker::noop())) +} + +fn probes(records: &[PacketRecord]) -> Vec { + 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) + )); +} diff --git a/crates/irtt-server/src/clock.rs b/crates/irtt-server/src/clock.rs index e377733..644cc00 100644 --- a/crates/irtt-server/src/clock.rs +++ b/crates/irtt-server/src/clock.rs @@ -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); - } -} diff --git a/crates/irtt-server/src/core.rs b/crates/irtt-server/src/core.rs index 9517f86..eb7907e 100644 --- a/crates/irtt-server/src/core.rs +++ b/crates/irtt-server/src/core.rs @@ -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, tokens: Box, - clock: Box, + 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) -> 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, - clock: Box, - ) -> Self { Self { config, sessions: HashMap::new(), tokens, - clock, + clock: SystemClock::new(), } } diff --git a/crates/irtt-server/src/fill.rs b/crates/irtt-server/src/fill.rs index b5c9ab8..9924da6 100644 --- a/crates/irtt-server/src/fill.rs +++ b/crates/irtt-server/src/fill.rs @@ -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 { } } -/// `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(len: usize, draw: impl FnOnce(&mut [u8]) -> Result<(), E>) -> Vec { +fn random_payload(len: usize) -> Vec { 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]); - } -} diff --git a/crates/irtt-server/src/lib.rs b/crates/irtt-server/src/lib.rs index 72821d0..a9f568d 100644 --- a/crates/irtt-server/src/lib.rs +++ b/crates/irtt-server/src/lib.rs @@ -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`] diff --git a/crates/irtt-server/src/runtime.rs b/crates/irtt-server/src/runtime.rs index 4c5d88d..91c204b 100644 --- a/crates/irtt-server/src/runtime.rs +++ b/crates/irtt-server/src/runtime.rs @@ -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 diff --git a/crates/irtt-server/src/set.rs b/crates/irtt-server/src/set.rs index a8cc0f3..bf11232 100644 --- a/crates/irtt-server/src/set.rs +++ b/crates/irtt-server/src/set.rs @@ -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. /// diff --git a/crates/irtt-server/src/socket_io.rs b/crates/irtt-server/src/socket_io.rs index cc2d2e2..dd369ba 100644 --- a/crates/irtt-server/src/socket_io.rs +++ b/crates/irtt-server/src/socket_io.rs @@ -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>, Vec) { - 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, Vec) { - 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); - } - } -} diff --git a/crates/irtt-server/src/socket_io/ancillary.rs b/crates/irtt-server/src/socket_io/ancillary.rs index f3953c7..d451421 100644 --- a/crates/irtt-server/src/socket_io/ancillary.rs +++ b/crates/irtt-server/src/socket_io/ancillary.rs @@ -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)); - } -} diff --git a/crates/irtt-server/src/socket_options.rs b/crates/irtt-server/src/socket_options.rs index 3a40232..53112d3 100644 --- a/crates/irtt-server/src/socket_options.rs +++ b/crates/irtt-server/src/socket_options.rs @@ -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" - ); - } - } -} diff --git a/crates/irtt-server/src/tests/admission.rs b/crates/irtt-server/src/tests/admission.rs deleted file mode 100644 index 917f685..0000000 --- a/crates/irtt-server/src/tests/admission.rs +++ /dev/null @@ -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)); -} diff --git a/crates/irtt-server/src/tests/close.rs b/crates/irtt-server/src/tests/close.rs deleted file mode 100644 index 8a27e1a..0000000 --- a/crates/irtt-server/src/tests/close.rs +++ /dev/null @@ -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()); -} diff --git a/crates/irtt-server/src/tests/echo.rs b/crates/irtt-server/src/tests/echo.rs deleted file mode 100644 index bfb5baf..0000000 --- a/crates/irtt-server/src/tests/echo.rs +++ /dev/null @@ -1,818 +0,0 @@ -//! Normal echo processing. -//! -//! An echo request that authenticates, fits the configured maximum, names a -//! live session and arrives from that session's endpoint is answered from the -//! session's negotiated parameters. Everything else is silence that leaves the -//! receive state alone, which these tests prove from the wire — the count and -//! window in the *next* accepted reply are what show that a rejected datagram -//! changed nothing. -//! -//! The count and window vectors come from the clean -//! `test-vectors/SERVER_BEHAVIORAL_VECTORS.md` Section 1 and are hard-coded -//! here rather than recomputed, so that replacing the observed semantics with a -//! conventional selective-acknowledgement bitmap fails these tests. - -use std::net::{SocketAddr, SocketAddrV6}; - -use irtt_proto::{ - echo_packet_len, verify_packet_hmac, Clock, Params, ReceivedStats, StampAt, TimestampFields, -}; - -use super::support::{ - core_for, core_with_sources, core_with_tokens, echo_params, echo_request, expect_echo_reply, - open_negotiated, other_peer, peer, sample, unthrottled, ScriptedClock, ScriptedTokens, KEY, - OTHER_KEY, -}; -use crate::{clock::ClockSample, ServerConfig, TimestampAllowance}; - -const TOKEN_A: u64 = 0x0102_0304_0506_0708; -const UNKNOWN_TOKEN: u64 = 0x2122_2324_2526_2728; - -/// The lifecycle instant an open consumes before any echo is sent. -/// -/// The core reads the clock once per authenticated request, to judge idle expiry -/// and to start the new session's deadlines. It is earlier than every echo -/// sample below, as a real monotonic reading would be, and no reply ever carries -/// it: an open reply has no timestamp fields. -fn open_sample() -> ClockSample { - sample(900, 9_000) -} - -/// Params selecting no optional field at all, so the echo layout is the -/// mandatory field block and nothing else. -fn bare_params() -> Params { - echo_params( - ReceivedStats::None, - StampAt::None, - Clock::Unspecified, - /* length */ 0, - ) -} - -/// Params reporting both statistics, which is what most vectors are stated in. -fn counting_params(length: i64) -> Params { - echo_params( - ReceivedStats::Both, - StampAt::None, - Clock::Unspecified, - length, - ) -} - -/// Opens one session and returns the core, the token and the negotiated params. -/// -/// Rate limiting is disabled: these tests drive a whole vector of echoes at once -/// against the production clock, and what they are about is the reply, not the -/// allowance. `rate` covers the limiter. -fn session_with(params: &Params) -> (crate::ServerCore, u64, Params) { - let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A])); - let (token, negotiated) = open_negotiated(&mut core, peer(), params, None); - (core, token, negotiated) -} - -#[test] -fn a_valid_echo_is_answered_from_the_negotiated_params() { - let (mut core, token, negotiated) = session_with(&bare_params()); - - let packet = core - .handle_datagram(peer(), &echo_request(token, 42, &negotiated, &[], None)) - .unwrap() - .expect("an admissible echo must be answered"); - - let reply = expect_echo_reply(&packet, &negotiated, None); - assert_eq!(reply.token, token, "the session token is copied through"); - assert_eq!( - reply.sequence, 42, - "the sequence number is copied unchanged" - ); - assert_eq!(reply.recv_count, None, "no statistics were negotiated"); - assert_eq!(reply.recv_window, None); - assert_eq!( - reply.timestamps, - TimestampFields::default(), - "no timestamps were negotiated, so none are emitted" - ); - assert!(reply.payload.is_empty()); - assert_eq!( - packet.bytes().len(), - echo_packet_len(false, &negotiated).unwrap() - ); - assert_eq!( - packet.bytes().len(), - 16, - "magic, flags, token and sequence only" - ); - - assert_eq!( - core.session_count(), - 1, - "an echo neither creates nor releases a session" - ); -} - -#[test] -fn a_reply_is_sized_by_the_session_not_by_the_request() { - // A request may be shorter or longer than the negotiated length, and its - // bytes past the sequence number are opaque either way. - let (mut core, token, negotiated) = session_with(&counting_params(64)); - assert_eq!(negotiated.length, 64); - - let short = Params { - length: 0, - ..negotiated.clone() - }; - // Requesting no statistics moves this request's payload region up to where - // the *session's* reply keeps its count and window, so the opaque bytes - // land squarely on fields a server must not read from a request. - let long = Params { - length: 100, - received_stats: ReceivedStats::None, - ..negotiated.clone() - }; - - for (sequence, name, sizing, payload, expected) in [ - ( - 0, - "a request shorter than the negotiated length", - &short, - &[][..], - (1, 0x1), - ), - ( - 1, - "a request longer than the negotiated length, with opaque bytes", - &long, - &[0xa5; 40][..], - (2, 0x3), - ), - ] { - let request = echo_request(token, sequence, sizing, payload, None); - assert_ne!(request.len(), 64, "{name} must not be the reply's length"); - - let packet = core - .handle_datagram(peer(), &request) - .unwrap() - .unwrap_or_else(|| panic!("{name} must be answered")); - let reply = expect_echo_reply(&packet, &negotiated, None); - - assert_eq!( - packet.bytes().len(), - 64, - "{name}: the reply is the negotiated length" - ); - assert_eq!(reply.sequence, sequence); - assert_eq!( - (reply.recv_count, reply.recv_window), - (Some(expected.0), Some(expected.1)), - "{name}: the opaque tail must not influence the reply" - ); - } -} - -#[test] -fn an_echo_over_the_configured_maximum_is_dropped_before_anything_advances() { - let config = ServerConfig::default().with_max_packet_length(64); - let mut core = core_with_tokens(config, ScriptedTokens::new([TOKEN_A])); - let (token, negotiated) = open_negotiated(&mut core, peer(), &counting_params(64), None); - assert_eq!(negotiated.length, 64); - - let at_maximum = echo_request(token, 0, &negotiated, &[], None); - assert_eq!(at_maximum.len(), 64); - let over_maximum = echo_request( - token, - 1, - &Params { - length: 65, - ..negotiated.clone() - }, - &[], - None, - ); - assert_eq!(over_maximum.len(), 65); - - // The comparison is strict, so exactly the maximum is served. - let packet = core - .handle_datagram(peer(), &at_maximum) - .unwrap() - .expect("a request of exactly the maximum must be answered"); - let reply = expect_echo_reply(&packet, &negotiated, None); - assert_eq!((reply.recv_count, reply.recv_window), (Some(1), Some(0x1))); - - assert_eq!( - core.handle_datagram(peer(), &over_maximum).unwrap(), - None, - "one byte over the maximum must be answered with silence" - ); - - // The next admissible request is what proves the drop advanced nothing: had - // the oversized one counted, this would report 3 rather than 2. - let packet = core - .handle_datagram(peer(), &echo_request(token, 1, &negotiated, &[], None)) - .unwrap() - .expect("an admissible echo after a dropped one must be answered"); - let reply = expect_echo_reply(&packet, &negotiated, None); - assert_eq!((reply.recv_count, reply.recv_window), (Some(2), Some(0x3))); -} - -#[test] -fn an_echo_that_owns_no_session_here_is_answered_with_silence() { - let (mut core, token, negotiated) = session_with(&counting_params(0)); - - for (name, endpoint, request_token) in [ - ("an unknown token", peer(), UNKNOWN_TOKEN), - ("a zero token", peer(), 0), - ( - "a valid token from another source port", - other_peer(), - token, - ), - ] { - let request = echo_request(request_token, 0, &negotiated, &[], None); - assert_eq!( - core.handle_datagram(endpoint, &request).unwrap(), - None, - "{name} must be answered with silence" - ); - } - - // Count 1 and window 0x1 are the first-request values, so none of the three - // rejected datagrams touched this session's receive state. - let packet = core - .handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None)) - .unwrap() - .expect("the session's own echo must still be answered"); - let reply = expect_echo_reply(&packet, &negotiated, None); - assert_eq!((reply.recv_count, reply.recv_window), (Some(1), Some(0x1))); - assert_eq!(core.session_count(), 1); -} - -#[test] -fn an_echo_resolves_ipv6_endpoints_by_the_same_identity_rule_as_a_close() { - // The comparator itself is pinned by the close tests; this is the echo path - // reaching the same one rather than growing a second rule. - let global = "2001:db8::1".parse().unwrap(); - let link_local = "fe80::1".parse().unwrap(); - let unlabeled = SocketAddr::V6(SocketAddrV6::new(global, 2112, 0, 0)); - let labeled = SocketAddr::V6(SocketAddrV6::new(global, 2112, 0x000f_1234, 0)); - let zone_three = SocketAddr::V6(SocketAddrV6::new(link_local, 2112, 0, 3)); - let zone_four = SocketAddr::V6(SocketAddrV6::new(link_local, 2112, 0, 4)); - - for (bound, sender, answered) in [ - // Flow information is routing metadata that identifies no endpoint. - (unlabeled, labeled, true), - (labeled, unlabeled, true), - // The zone is identity by `irtt-rs` policy, not by observed behavior. - (zone_three, zone_four, false), - ] { - let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A])); - let (token, negotiated) = open_negotiated(&mut core, bound, &counting_params(0), None); - - let reply = core - .handle_datagram(sender, &echo_request(token, 0, &negotiated, &[], None)) - .unwrap(); - - assert_eq!( - reply.is_some(), - answered, - "an echo from {sender} on a session opened from {bound}" - ); - } -} - -#[test] -fn received_count_and_window_follow_the_observed_vectors() { - // Every row is `(request sequence, reply count, reply window)` exactly as - // measured in Section 1 of the behavioral vectors. - for (name, vectors) in [ - ( - "1.1 sequential, no loss", - &[ - (0, 1, 0x1), - (1, 2, 0x3), - (2, 3, 0x7), - (3, 4, 0xf), - (4, 5, 0x1f), - (5, 6, 0x3f), - ][..], - ), - ( - "1.2 one gap", - &[(0, 1, 0x1), (1, 2, 0x3), (3, 3, 0xd), (4, 4, 0x1b)][..], - ), - ( - "1.3 multiple gaps", - &[(0, 1, 0x1), (3, 2, 0x9), (7, 3, 0x91), (8, 4, 0x123)][..], - ), - ( - // Filling a gap late discards the history rather than setting the - // bit the gap left clear. - "1.4 later gap fill", - &[(0, 1, 0x1), (1, 2, 0x3), (5, 3, 0x31), (3, 4, 0x1)][..], - ), - ( - "1.5 duplicate sequence number", - &[(0, 1, 0x1), (1, 2, 0x3), (1, 3, 0x3), (2, 4, 0x7)][..], - ), - ( - // Row 5 rebuilds from the reset: bit 1 stays clear even though - // sequence 2 was received before the reordering. - "1.6 reordering", - &[ - (0, 1, 0x1), - (1, 2, 0x3), - (2, 3, 0x7), - (1, 4, 0x1), - (3, 5, 0x5), - ][..], - ), - ( - "1.7 strictly descending sequence numbers", - &[ - (5, 1, 0x1), - (4, 2, 0x1), - (3, 3, 0x1), - (2, 4, 0x1), - (1, 5, 0x1), - (0, 6, 0x1), - ][..], - ), - ( - "1.8 a gap of exactly 63", - &[(0, 1, 0x1), (63, 2, 0x8000_0000_0000_0001)][..], - ), - ("1.8 a gap of 64", &[(0, 1, 0x1), (64, 2, 0x1)][..]), - ("1.8 a gap of 65", &[(0, 1, 0x1), (65, 2, 0x1)][..]), - ("1.8 a gap of 1000", &[(0, 1, 0x1), (1000, 2, 0x1)][..]), - ( - "1.9 a non-zero starting sequence number", - &[(100, 1, 0x1), (101, 2, 0x3), (102, 3, 0x7)][..], - ), - ( - "1.10 32-bit sequence wraparound", - &[ - (0xffff_fffd, 1, 0x1), - (0xffff_fffe, 2, 0x3), - (0xffff_ffff, 3, 0x7), - (0, 4, 0xf), - (1, 5, 0x1f), - ][..], - ), - ] { - let (mut core, token, negotiated) = session_with(&counting_params(0)); - - for &(sequence, count, window) in vectors { - let packet = core - .handle_datagram( - peer(), - &echo_request(token, sequence, &negotiated, &[], None), - ) - .unwrap() - .unwrap_or_else(|| panic!("{name}: sequence {sequence} must be answered")); - let reply = expect_echo_reply(&packet, &negotiated, None); - - assert_eq!( - reply.recv_count, - Some(count), - "{name}: count after sequence {sequence}" - ); - assert_eq!( - reply.recv_window, - Some(window), - "{name}: window after sequence {sequence}" - ); - } - } -} - -#[test] -fn only_the_negotiated_received_statistics_are_reported() { - // Field presence only; the transition itself is pinned by the vectors - // above, so two in-order requests are enough here. - for (stats, count, window) in [ - (ReceivedStats::None, None, None), - (ReceivedStats::Count, Some(2), None), - (ReceivedStats::Window, None, Some(0x3)), - (ReceivedStats::Both, Some(2), Some(0x3)), - ] { - let params = echo_params(stats, StampAt::None, Clock::Unspecified, 0); - let (mut core, token, negotiated) = session_with(¶ms); - - let mut reply = None; - for sequence in 0..2 { - let packet = core - .handle_datagram( - peer(), - &echo_request(token, sequence, &negotiated, &[], None), - ) - .unwrap() - .unwrap_or_else(|| panic!("{stats:?}: sequence {sequence} must be answered")); - reply = Some(expect_echo_reply(&packet, &negotiated, None)); - } - - let reply = reply.expect("two requests were sent"); - assert_eq!(reply.recv_count, count, "count presence for {stats:?}"); - assert_eq!(reply.recv_window, window, "window presence for {stats:?}"); - } -} - -#[test] -fn an_authenticated_session_answers_only_authenticated_echoes() { - let mut core = core_for(Some(KEY), ScriptedTokens::new([TOKEN_A])); - let (token, negotiated) = open_negotiated(&mut core, peer(), &counting_params(0), Some(KEY)); - - let packet = core - .handle_datagram(peer(), &echo_request(token, 3, &negotiated, &[], Some(KEY))) - .unwrap() - .expect("a correctly authenticated echo must be answered"); - assert!( - verify_packet_hmac(KEY, packet.bytes()).is_ok(), - "the reply must carry a MAC that verifies with the server's key" - ); - // `expect_echo_reply` pins the HMAC flag and verifies the MAC again while - // decoding, so a reply that authenticated but omitted the flag would fail - // here — the failure mode that silently breaks upstream clients. - let reply = expect_echo_reply(&packet, &negotiated, Some(KEY)); - assert_eq!(reply.token, token); - assert_eq!(reply.sequence, 3); - assert_eq!((reply.recv_count, reply.recv_window), (Some(1), Some(0x1))); - - let mut corrupted = echo_request(token, 4, &negotiated, &[], Some(KEY)); - corrupted[5] ^= 0x80; - for (name, packet) in [ - ( - "an echo with no MAC field at all", - echo_request(token, 4, &negotiated, &[], None), - ), - ( - "an echo signed with a different key", - echo_request(token, 4, &negotiated, &[], Some(OTHER_KEY)), - ), - ("an echo with one bit flipped in the MAC", corrupted), - ] { - assert_eq!( - core.handle_datagram(peer(), &packet).unwrap(), - None, - "{name} must be answered with silence" - ); - } - - let packet = core - .handle_datagram(peer(), &echo_request(token, 4, &negotiated, &[], Some(KEY))) - .unwrap() - .expect("an authenticated echo after failed ones must be answered"); - let reply = expect_echo_reply(&packet, &negotiated, Some(KEY)); - assert_eq!( - (reply.recv_count, reply.recv_window), - (Some(2), Some(0x3)), - "none of the packets that failed authentication may be counted" - ); -} - -#[test] -fn only_the_negotiated_timestamps_are_emitted() { - let received = sample(1_000, 10_000); - let sent = sample(1_200, 10_300); - - for (stamp_at, clock, expected) in [ - ( - StampAt::Send, - Clock::Wall, - TimestampFields { - send_wall: Some(1_200), - ..TimestampFields::default() - }, - ), - ( - StampAt::Send, - Clock::Monotonic, - TimestampFields { - send_mono: Some(10_300), - ..TimestampFields::default() - }, - ), - ( - StampAt::Send, - Clock::Both, - TimestampFields { - send_wall: Some(1_200), - send_mono: Some(10_300), - ..TimestampFields::default() - }, - ), - ( - StampAt::Receive, - Clock::Wall, - TimestampFields { - recv_wall: Some(1_000), - ..TimestampFields::default() - }, - ), - ( - StampAt::Receive, - Clock::Monotonic, - TimestampFields { - recv_mono: Some(10_000), - ..TimestampFields::default() - }, - ), - ( - StampAt::Receive, - Clock::Both, - TimestampFields { - recv_wall: Some(1_000), - recv_mono: Some(10_000), - ..TimestampFields::default() - }, - ), - ( - StampAt::Both, - Clock::Wall, - TimestampFields { - recv_wall: Some(1_000), - send_wall: Some(1_200), - ..TimestampFields::default() - }, - ), - ( - StampAt::Both, - Clock::Monotonic, - TimestampFields { - recv_mono: Some(10_000), - send_mono: Some(10_300), - ..TimestampFields::default() - }, - ), - ( - StampAt::Both, - Clock::Both, - TimestampFields { - recv_wall: Some(1_000), - recv_mono: Some(10_000), - send_wall: Some(1_200), - send_mono: Some(10_300), - ..TimestampFields::default() - }, - ), - ( - StampAt::Midpoint, - Clock::Wall, - TimestampFields { - midpoint_wall: Some(1_100), - ..TimestampFields::default() - }, - ), - ( - StampAt::Midpoint, - Clock::Monotonic, - TimestampFields { - midpoint_mono: Some(10_150), - ..TimestampFields::default() - }, - ), - ( - StampAt::Midpoint, - Clock::Both, - TimestampFields { - midpoint_wall: Some(1_100), - midpoint_mono: Some(10_150), - ..TimestampFields::default() - }, - ), - ] { - let clock_source = ScriptedClock::new([open_sample(), received, sent]); - let mut core = core_with_sources( - ServerConfig::default(), - ScriptedTokens::new([TOKEN_A]), - clock_source.clone(), - ); - let params = echo_params(ReceivedStats::None, stamp_at, clock, 0); - let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None); - assert_eq!( - clock_source.remaining(), - 2, - "an open consumes exactly its own lifecycle sample" - ); - - let packet = core - .handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None)) - .unwrap() - .unwrap_or_else(|| panic!("{stamp_at:?} on {clock:?} must be answered")); - let reply = expect_echo_reply(&packet, &negotiated, None); - - // Equality over the whole struct, so an unselected field appearing is a - // failure as much as a selected one carrying the wrong instant. - assert_eq!(reply.timestamps, expected, "{stamp_at:?} on {clock:?}"); - assert_eq!( - packet.bytes().len(), - echo_packet_len(false, &negotiated).unwrap(), - "{stamp_at:?} on {clock:?} must be the negotiated layout's length" - ); - assert_eq!( - clock_source.remaining(), - 0, - "{stamp_at:?} on {clock:?}: one receive and one send sample" - ); - } -} - -#[test] -fn a_backward_wall_step_never_reports_receive_after_send() { - // A reply's receive instant must not be later than its send instant, and - // the wall clock can be stepped backwards between the two readings by NTP, - // a hypervisor or an administrator. The pair settles on the later reading, - // and only the wall domain moves: the monotonic one was already ordered. - let mut core = core_with_sources( - ServerConfig::default(), - ScriptedTokens::new([TOKEN_A]), - ScriptedClock::new([open_sample(), sample(1_000, 10_000), sample(990, 10_300)]), - ); - let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0); - let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None); - - let packet = core - .handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None)) - .unwrap() - .expect("a backward wall step must not stop the echo being answered"); - let reply = expect_echo_reply(&packet, &negotiated, None); - - assert_eq!( - reply.timestamps, - TimestampFields { - recv_wall: Some(990), - recv_mono: Some(10_000), - send_wall: Some(990), - send_mono: Some(10_300), - ..TimestampFields::default() - }, - "the receive wall value is held back to the send reading, and only it" - ); - assert!(reply.timestamps.recv_wall <= reply.timestamps.send_wall); - assert!(reply.timestamps.recv_mono <= reply.timestamps.send_mono); - - // A midpoint drawn from the same stepped pair is ordered for the same - // reason: it is the mean of two values that are no longer inverted. - let mut core = core_with_sources( - ServerConfig::default(), - ScriptedTokens::new([TOKEN_A]), - ScriptedClock::new([open_sample(), sample(1_000, 10_000), sample(990, 10_300)]), - ); - let params = echo_params(ReceivedStats::None, StampAt::Midpoint, Clock::Wall, 0); - let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None); - - let packet = core - .handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None)) - .unwrap() - .expect("a backward wall step must not stop the echo being answered"); - let reply = expect_echo_reply(&packet, &negotiated, None); - - assert_eq!( - reply.timestamps, - TimestampFields { - midpoint_wall: Some(990), - ..TimestampFields::default() - } - ); -} - -#[test] -fn a_single_clock_midpoint_emits_exactly_one_field() { - // A guard against reproducing upstream 0.9.1's dual-field midpoint, which - // emits both midpoint fields whatever the negotiated clock. `irtt-proto` - // still decodes that form from a peer; this is about what this server - // sends. - for (clock, expected) in [ - ( - Clock::Wall, - TimestampFields { - midpoint_wall: Some(1_100), - ..TimestampFields::default() - }, - ), - ( - Clock::Monotonic, - TimestampFields { - midpoint_mono: Some(10_150), - ..TimestampFields::default() - }, - ), - ] { - let mut core = core_with_sources( - ServerConfig::default(), - ScriptedTokens::new([TOKEN_A]), - ScriptedClock::new([open_sample(), sample(1_000, 10_000), sample(1_200, 10_300)]), - ); - let params = echo_params(ReceivedStats::None, StampAt::Midpoint, clock, 0); - let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None); - - let packet = core - .handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None)) - .unwrap() - .unwrap_or_else(|| panic!("a midpoint echo on {clock:?} must be answered")); - let reply = expect_echo_reply(&packet, &negotiated, None); - - assert_eq!( - packet.bytes().len(), - echo_packet_len(false, &negotiated).unwrap(), - "{clock:?}: the negotiated length, not the compatibility one" - ); - assert_eq!( - packet.bytes().len(), - 24, - "{clock:?}: one 8-byte midpoint field, not the upstream two" - ); - assert_eq!(reply.timestamps, expected); - } -} - -#[test] -fn a_reply_payload_is_server_generated_and_never_reflects_the_request() { - // The inbound tail is opaque, and ServerFill describes the *server's* bytes: - // a request's payload is neither echoed, mixed in, nor used to seed - // anything. This session expressed no fill preference, so the region - // carries the server's default fill; `fill` covers the modes themselves. - let params = echo_params(ReceivedStats::None, StampAt::None, Clock::Unspecified, 64); - let (mut core, token, negotiated) = session_with(¶ms); - - let request = echo_request(token, 0, &negotiated, &[0xa5; 48], None); - assert!( - request[16..].iter().all(|byte| *byte == 0xa5), - "the request must carry non-zero payload bytes to reflect" - ); - - let packet = core - .handle_datagram(peer(), &request) - .unwrap() - .expect("an admissible echo must be answered"); - let reply = expect_echo_reply(&packet, &negotiated, None); - - assert_eq!(packet.bytes().len(), 64); - assert_eq!(reply.payload.len(), 48); - assert!( - !reply.payload.contains(&0xa5), - "no request byte may appear in the reply payload" - ); - assert!( - reply - .payload - .chunks(4) - .all(|chunk| chunk == &b"irtt"[..chunk.len()]), - "the region carries the server's default fill" - ); -} - -#[test] -fn a_restricted_session_emits_the_restricted_timestamps_and_not_the_requested_ones() { - // Timestamp generation follows the session's *negotiated* placement, which - // is the point of restricting it during negotiation: a client asking for - // both instants from both clocks gets one midpoint field per clock under a - // single-timestamp allowance, and nothing at all under none — with the reply - // laid out and sized from the restriction either way, not from the request. - for (allowance, expected_stamp_at, expected, expected_len) in [ - ( - TimestampAllowance::Single, - StampAt::Midpoint, - TimestampFields { - midpoint_wall: Some(1_100), - midpoint_mono: Some(10_150), - ..TimestampFields::default() - }, - 32, - ), - ( - TimestampAllowance::None, - StampAt::None, - TimestampFields::default(), - 16, - ), - ] { - let mut core = core_with_sources( - unthrottled().with_timestamp_allowance(allowance), - ScriptedTokens::new([TOKEN_A]), - ScriptedClock::new([open_sample(), sample(1_000, 10_000), sample(1_200, 10_300)]), - ); - let requested = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0); - let (token, negotiated) = open_negotiated(&mut core, peer(), &requested, None); - assert_eq!(negotiated.stamp_at, expected_stamp_at, "{allowance:?}"); - assert_eq!(negotiated.clock, Clock::Both, "{allowance:?}"); - - let packet = core - .handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None)) - .unwrap() - .unwrap_or_else(|| panic!("{allowance:?}: an admissible echo must be answered")); - let reply = expect_echo_reply(&packet, &negotiated, None); - - // Equality over the whole struct, so a leftover receive or send field is - // a failure as much as a missing midpoint one. - assert_eq!(reply.timestamps, expected, "{allowance:?}"); - assert_eq!( - packet.bytes().len(), - echo_packet_len(false, &negotiated).unwrap(), - "{allowance:?}: the restricted layout's length" - ); - assert_eq!( - packet.bytes().len(), - expected_len, - "{allowance:?}: no empty slot is kept for a restricted field" - ); - } -} diff --git a/crates/irtt-server/src/tests/fill.rs b/crates/irtt-server/src/tests/fill.rs deleted file mode 100644 index 6c49944..0000000 --- a/crates/irtt-server/src/tests/fill.rs +++ /dev/null @@ -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 { - 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 { - let tokens = ScriptedTokens::new([TOKEN_A]); - let mut core = core_with_tokens(unthrottled(), tokens.clone()); - let packet = core - .handle_datagram(peer(), &no_test_request(&requesting(descriptor), None)) - .unwrap() - .expect("the no-test open must be answered"); - - let reply = expect_no_test_reply(&packet, None); - assert_eq!(core.session_count(), 0, "no-test creates no session"); - assert_eq!(tokens.remaining(), 1, "no-test draws no session token"); - reply.params.server_fill.map(|fill| fill.value) -} - -#[test] -fn an_explicitly_empty_descriptor_is_preserved_and_uses_the_default_fill() { - // A low-level peer can send this even though `ClientConfig` rejects an - // empty fill before it reaches the wire. The clean evidence groups empty - // with absent as "no preference", so it is neither refused nor rewritten: - // the requested wire value comes back exactly, and the server picks its own - // behavior behind it. - let returned = negotiated(Some("")); - assert_eq!( - returned.as_deref(), - Some(""), - "an empty request stays empty" - ); -} - -#[test] -fn an_unknown_or_malformed_descriptor_is_replaced_by_the_default() { - // Here the server really did change what was asked for, so the reply says - // so and a strict client may refuse the session. The requested string is - // not retained anywhere. - for descriptor in [ - "bogus", - "RAND", - "None", - "Pattern:aabb", - "pattern:", - "pattern:f", - "pattern:abc", - "pattern:zz", - "pattern:0g", - "0123456789abcdef0123456789abcdef", - ] { - let returned = negotiated(Some(descriptor)); - assert_eq!( - returned.as_deref(), - Some(crate::fill::DEFAULT_FILL_DESCRIPTOR), - "{descriptor}" - ); - } -} - -#[test] -fn a_no_test_open_negotiates_fill_exactly_as_a_normal_open_does() { - // Same negotiation, no session, and therefore no effective mode to retain. - assert_eq!(no_test_negotiated(None), None); - assert_eq!(no_test_negotiated(Some("")).as_deref(), Some("")); - assert_eq!( - no_test_negotiated(Some("pattern:AaBb")).as_deref(), - Some("pattern:AaBb") - ); - assert_eq!(no_test_negotiated(Some("none")).as_deref(), Some("none")); - assert_eq!( - no_test_negotiated(Some("pattern:zz")).as_deref(), - Some(crate::fill::DEFAULT_FILL_DESCRIPTOR) - ); -} - -/// Params for a session requesting `descriptor` whose echo replies have exactly -/// `payload_len` payload bytes. -/// -/// The negotiated length is the mandatory field block plus the region wanted, -/// asked of `irtt-proto` rather than written as a constant: the block grows with -/// the negotiated statistics, the timestamps and authentication's 16 bytes, and -/// a test that hard-coded an offset would silently drift from the encoder. -fn payload_params(descriptor: Option<&str>, payload_len: usize, hmac: bool) -> Params { - let mut params = Params { - server_fill: requesting(descriptor).server_fill, - ..echo_params(ReceivedStats::None, StampAt::None, Clock::Unspecified, 0) - }; - params.length = (echo_header_len(hmac, ¶ms) + payload_len) as i64; - params -} - -/// The payload regions of `count` successive echo replies on one session -/// requesting `descriptor`. -/// -/// Every request carries [`REQUEST_FILLER`] payload bytes, so a reply that -/// reflected its request would be visible in any of these tests rather than only -/// in the one about it. -fn echo_payloads( - descriptor: Option<&str>, - payload_len: usize, - hmac_key: Option<&[u8]>, - count: u32, -) -> Vec> { - 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 { - 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 { - 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)); -} diff --git a/crates/irtt-server/src/tests/hmac.rs b/crates/irtt-server/src/tests/hmac.rs deleted file mode 100644 index ff90cc8..0000000 --- a/crates/irtt-server/src/tests/hmac.rs +++ /dev/null @@ -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)); -} diff --git a/crates/irtt-server/src/tests/kernel_rx.rs b/crates/irtt-server/src/tests/kernel_rx.rs deleted file mode 100644 index 8185201..0000000 --- a/crates/irtt-server/src/tests/kernel_rx.rs +++ /dev/null @@ -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, -) -> TimestampFields { - let mut core = core_with_sources( - ServerConfig::default(), - ScriptedTokens::new([TOKEN_A]), - ScriptedClock::new([open_sample(), received(), sent()]), - ); - let params = echo_params(ReceivedStats::None, stamp_at, clock, /* length */ 0); - let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None); - - let packet = core - .handle_received_datagram( - peer(), - &echo_request(token, 0, &negotiated, &[], None), - kernel_rx_timestamp, - ) - .unwrap() - .unwrap_or_else(|| panic!("{stamp_at:?} on {clock:?} must be answered")); - expect_echo_reply(&packet, &negotiated, None).timestamps -} - -#[test] -fn the_preferred_receive_wall_is_the_kernel_reading_only_when_it_is_plausible() { - let userspace = received(); - let userspace_wall = userspace.wall_ns; - let bound_ns = i64::try_from(MAX_KERNEL_RX_LAG.as_nanos()).unwrap(); - - // No observation at all: the server's own reading, which is the whole of - // the pre-existing behavior and what every non-Linux target sees. - assert_eq!(preferred_receive_wall_ns(None, userspace), userspace_wall); - - // Earlier, and inside the bound. - assert_eq!( - preferred_receive_wall_ns(Some(kernel_rx()), userspace), - 900_000 - ); - - // Equal is accepted: the two clocks may simply have read the same - // nanosecond. - assert_eq!( - preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall)), userspace), - userspace_wall - ); - - // Later than the sample that observed the same datagram is impossible, so - // it is metadata this server will not measure against. This is the shape a - // backward wall step between the kernel's reading and the core's sample - // produces. - assert_eq!( - preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall + 1)), userspace), - userspace_wall - ); - - // The lag bound is inclusive. - assert_eq!( - preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall - bound_ns)), userspace), - userspace_wall - bound_ns - ); - assert_eq!( - preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall - bound_ns - 1)), userspace), - userspace_wall - ); - - // A pre-epoch reading is representable as a negative value and is judged - // by exactly the same rule, so against a host clock set anywhere near the - // present it simply lags far too much. Nothing here special-cases the sign. - let present = sample(1_700_000_000 * SECOND, 10_000); - assert_eq!( - preferred_receive_wall_ns(Some(UNIX_EPOCH - Duration::from_nanos(1)), present), - present.wall_ns - ); - assert_eq!( - preferred_receive_wall_ns(Some(at_epoch_ns(present.wall_ns - bound_ns)), present), - present.wall_ns - bound_ns, - "and a realistic wall clock accepts a reading at the bound like any other" - ); -} - -#[test] -fn an_unrepresentable_kernel_reading_falls_back_without_panicking() { - // Far outside the wire's signed-nanosecond field in both directions, and - // judged against a userspace sample at the field's own limits, so nothing - // here can be rescued by a lucky comparison. A conversion that saturated - // instead of reporting failure would turn one of these into an ordinary - // instant. - let beyond = Duration::from_nanos(u64::MAX) + Duration::from_secs(1); - - for userspace_wall in [0, i64::MAX, i64::MIN] { - let userspace = sample(userspace_wall, 10_000); - for kernel in [UNIX_EPOCH + beyond, UNIX_EPOCH - beyond] { - assert_eq!( - preferred_receive_wall_ns(Some(kernel), userspace), - userspace_wall, - "an unrepresentable reading is never a measurement endpoint" - ); - } - } -} - -#[test] -fn only_the_receive_wall_field_can_come_from_the_kernel() { - // The whole StampAt x Clock matrix, each asserted as one struct so that a - // field appearing where it should not is as loud as a wrong instant. Every - // expectation below is what the same session emits with no kernel metadata, - // except `recv_wall` under Receive and Both on a wall-reporting clock. - for (stamp_at, clock, expected) in [ - // A: the kernel reading is the reported arrival time. - ( - StampAt::Receive, - Clock::Wall, - TimestampFields { - recv_wall: Some(900_000), - ..TimestampFields::default() - }, - ), - // B: and the monotonic half of the same arrival stays the core's own. - // The two fields deliberately name different physical instants. - ( - StampAt::Receive, - Clock::Both, - TimestampFields { - recv_wall: Some(900_000), - recv_mono: Some(10_000), - ..TimestampFields::default() - }, - ), - // C: nothing about the departure side moves, in either domain. - ( - StampAt::Both, - Clock::Both, - TimestampFields { - recv_wall: Some(900_000), - recv_mono: Some(10_000), - send_wall: Some(1_200_000), - send_mono: Some(10_300), - ..TimestampFields::default() - }, - ), - // D: wall only. The interval between these two now includes the - // kernel-to-userspace part of the server's residency, which is the one - // intended behavior change. - ( - StampAt::Both, - Clock::Wall, - TimestampFields { - recv_wall: Some(900_000), - send_wall: Some(1_200_000), - ..TimestampFields::default() - }, - ), - // E: no wall field exists, so there is nothing to improve. - ( - StampAt::Both, - Clock::Monotonic, - TimestampFields { - recv_mono: Some(10_000), - send_mono: Some(10_300), - ..TimestampFields::default() - }, - ), - ( - StampAt::Receive, - Clock::Monotonic, - TimestampFields { - recv_mono: Some(10_000), - ..TimestampFields::default() - }, - ), - // F: a reply reporting only its departure never learns of an arrival - // observation. - ( - StampAt::Send, - Clock::Wall, - TimestampFields { - send_wall: Some(1_200_000), - ..TimestampFields::default() - }, - ), - ( - StampAt::Send, - Clock::Both, - TimestampFields { - send_wall: Some(1_200_000), - send_mono: Some(10_300), - ..TimestampFields::default() - }, - ), - // G: no timestamp at all. - (StampAt::None, Clock::Both, TimestampFields::default()), - // H and I: a midpoint is the mean of the core's own paired samples and - // stays exactly that. Substituting only the wall arrival endpoint would - // pull `midpoint_wall` to 1_050_000 — half the kernel-to-userspace - // delay early — improving apparent upstream delay by precisely as much - // as it worsened apparent downstream delay, and leaving the two - // midpoint fields describing different midpoints. - ( - StampAt::Midpoint, - Clock::Wall, - TimestampFields { - midpoint_wall: Some(1_100_000), - ..TimestampFields::default() - }, - ), - ( - StampAt::Midpoint, - Clock::Both, - TimestampFields { - midpoint_wall: Some(1_100_000), - midpoint_mono: Some(10_150), - ..TimestampFields::default() - }, - ), - ( - StampAt::Midpoint, - Clock::Monotonic, - TimestampFields { - midpoint_mono: Some(10_150), - ..TimestampFields::default() - }, - ), - ] { - assert_eq!( - echo_timestamps(stamp_at, clock, Some(kernel_rx())), - expected, - "{stamp_at:?} on {clock:?} with a plausible kernel reading" - ); - } -} - -#[test] -fn a_datagram_with_no_kernel_reading_emits_what_it_always_did() { - // The same matrix without metadata, which is every non-Linux target and - // every Linux listener whose kernel offered nothing. `recv_wall` is the - // core's own 1_000_000 throughout, and the two paths are otherwise the same - // implementation. - for (stamp_at, clock, expected) in [ - ( - StampAt::Receive, - Clock::Wall, - TimestampFields { - recv_wall: Some(1_000_000), - ..TimestampFields::default() - }, - ), - ( - StampAt::Both, - Clock::Both, - TimestampFields { - recv_wall: Some(1_000_000), - recv_mono: Some(10_000), - send_wall: Some(1_200_000), - send_mono: Some(10_300), - ..TimestampFields::default() - }, - ), - ( - StampAt::Midpoint, - Clock::Both, - TimestampFields { - midpoint_wall: Some(1_100_000), - midpoint_mono: Some(10_150), - ..TimestampFields::default() - }, - ), - ] { - assert_eq!( - echo_timestamps(stamp_at, clock, None), - expected, - "{stamp_at:?} on {clock:?} without kernel metadata" - ); - } -} - -#[test] -fn an_implausible_kernel_reading_falls_back_to_the_servers_own_sample() { - // Case A of the wall-step analysis: the wall clock stepped *backwards* - // between the kernel's reading and the core's sample, so the kernel value - // is later than a sample that observed the same datagram. Case B: it - // stepped forwards so far that the reading lags beyond the bound. Neither - // is a reason to drop the datagram or to answer differently in any other - // respect. - let userspace_wall = received().wall_ns; - for kernel in [ - at_epoch_ns(userspace_wall + 1), - at_epoch_ns(userspace_wall - i64::try_from(MAX_KERNEL_RX_LAG.as_nanos()).unwrap() - 1), - ] { - assert_eq!( - echo_timestamps(StampAt::Both, Clock::Both, Some(kernel)), - TimestampFields { - recv_wall: Some(1_000_000), - recv_mono: Some(10_000), - send_wall: Some(1_200_000), - send_mono: Some(10_300), - ..TimestampFields::default() - }, - ); - } -} - -#[test] -fn a_backward_wall_step_after_receive_never_reports_receive_after_send() { - // The kernel's reading and the send sample no longer come from one paired - // source, so the ordering rule has to reach the reported arrival value on - // its own: an accepted kernel reading of 900_000, against a send sample the - // wall clock stepped back to 850_000, must still settle on the send - // reading. - let mut core = core_with_sources( - ServerConfig::default(), - ScriptedTokens::new([TOKEN_A]), - ScriptedClock::new([ - open_sample(), - sample(1_000_000, 10_000), - sample(850_000, 10_300), - ]), - ); - let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0); - let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None); - - let packet = core - .handle_received_datagram( - peer(), - &echo_request(token, 0, &negotiated, &[], None), - Some(at_epoch_ns(900_000)), - ) - .unwrap() - .expect("a backward wall step must not stop the echo being answered"); - let timestamps = expect_echo_reply(&packet, &negotiated, None).timestamps; - - assert_eq!( - timestamps, - TimestampFields { - recv_wall: Some(850_000), - recv_mono: Some(10_000), - send_wall: Some(850_000), - send_mono: Some(10_300), - ..TimestampFields::default() - }, - "the reported arrival wall value is held back to the send reading" - ); - assert!(timestamps.recv_wall <= timestamps.send_wall); - // The monotonic pair is ordered by its own source and owes nothing to what - // the wall clock did. - assert_eq!(timestamps.recv_mono, Some(10_000)); - assert!(timestamps.recv_mono <= timestamps.send_mono); -} - -#[test] -fn the_public_core_path_emits_exactly_the_userspace_receive_wall() { - // A direct `ServerCore` user has no transport metadata and must see the - // behavior it always did, from the same implementation the runtime path - // uses. - let mut core = core_with_sources( - ServerConfig::default(), - ScriptedTokens::new([TOKEN_A]), - ScriptedClock::new([open_sample(), received(), sent()]), - ); - let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0); - let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None); - - let packet = core - .handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None)) - .unwrap() - .expect("the public path must answer an ordinary echo"); - - assert_eq!( - expect_echo_reply(&packet, &negotiated, None).timestamps, - TimestampFields { - recv_wall: Some(1_000_000), - recv_mono: Some(10_000), - send_wall: Some(1_200_000), - send_mono: Some(10_300), - ..TimestampFields::default() - }, - ); -} - -/// One echo's outcome, in the terms a lifecycle test cares about: whether it was -/// answered at all, what it counted, and how much session state the server still -/// holds afterwards. -#[derive(Debug, PartialEq, Eq)] -struct Outcome { - answered: bool, - closing: bool, - recv_count: Option, - recv_window: Option, - session_count: usize, - recv_wall: Option, -} - -fn outcome_of( - core: &ServerCore, - reply: Option, - 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 { - if !with_kernel_rx { - return echo_at(core, clock, at_ns, token, sequence, params, None); - } - clock.set(at_ns); - core.handle_received_datagram( - peer(), - &echo_request(token, sequence, params, &[], None), - Some(at_epoch_ns(at_ns - 1_000)), - ) - .expect("a rejected or rate-limited echo is not an internal error") -} - -#[test] -fn kernel_metadata_changes_no_lifecycle_rate_or_deadline_decision() { - // Two identical runs over one scripted arrival schedule, one with a - // plausible kernel reading on every echo and one with none. The schedule - // crosses a rate-limit burst, a refill, an idle refresh that only a - // rate-limited request can have produced, and the maximum-duration - // boundary — and the two runs must agree on every one of those outcomes. - // - // The session reports its receive wall, so the one permitted difference is - // visible rather than merely assumed absent. - let params = Params { - interval_ns: 100 * MS, - ..echo_params(ReceivedStats::Both, StampAt::Receive, Clock::Wall, 0) - }; - let config = ServerConfig::default() - .with_min_send_interval(Duration::from_millis(100)) - .with_burst_allowance(2) - .with_idle_timeout(Duration::from_secs(2)) - .with_max_test_duration(Duration::from_secs(1)); - - let run = |with_kernel_rx: bool| { - let (mut core, clock, token, negotiated) = manual_core( - config.clone(), - ¶ms, - ScriptedTokens::new([TOKEN_A]), - None, - ); - let mut outcomes = Vec::new(); - let mut send = |core: &mut ServerCore, at_ns: i64, sequence: u32, closing: bool| { - let reply = echo_at_with_kernel_rx( - core, - &clock, - at_ns, - token, - sequence, - &negotiated, - with_kernel_rx, - ); - outcomes.push(outcome_of(core, reply, &negotiated, closing)); - }; - - // A burst of three against an allowance of two: the third is - // rate-limited, answers nothing and advances no statistic, but does - // refresh the idle deadline. - send(&mut core, 10 * MS, 0, false); - send(&mut core, 10 * MS, 1, false); - send(&mut core, 10 * MS, 2, false); - // Past the refill, and out of order, so the window transition is - // exercised too. - send(&mut core, 200 * MS, 4, false); - send(&mut core, 400 * MS, 3, false); - // The idle timeout is 2 s and the last activity was at 400 ms, so this - // request at 2.3 s finds the session alive only because activity — not - // the open — is what the deadline runs from. - send(&mut core, 2_300 * MS, 5, false); - // The maximum-duration deadline is the first served echo plus one - // second plus the two-second grace: 10 ms + 3 s. This crosses it and - // carries the close, releasing the session. - send(&mut core, 3_100 * MS, 6, true); - // And every later request is an unknown token. - send(&mut core, 3_200 * MS, 7, false); - assert_eq!(core.session_count(), 0); - outcomes - }; - - let without = run(false); - let with = run(true); - - assert_eq!( - without.len(), - 8, - "the schedule must actually have run to the end" - ); - for (index, (without, with)) in without.iter().zip(&with).enumerate() { - assert_eq!( - ( - with.answered, - with.closing, - with.recv_count, - with.recv_window, - with.session_count - ), - ( - without.answered, - without.closing, - without.recv_count, - without.recv_window, - without.session_count - ), - "echo {index}: kernel metadata may not change any same-host decision" - ); - } - - // The permitted difference, and proof the runs were not simply identical: - // every answered reply reported the kernel's reading rather than the - // server's own, which is 1 µs later. - let reported: Vec<_> = with - .iter() - .filter_map(|outcome| outcome.recv_wall) - .collect(); - let userspace: Vec<_> = without - .iter() - .filter_map(|outcome| outcome.recv_wall) - .collect(); - assert_eq!(reported.len(), 6, "six of the eight echoes were answered"); - assert_eq!( - reported, - userspace - .iter() - .map(|wall| wall - 1_000) - .collect::>(), - "each answered reply reported the kernel's arrival reading" - ); -} - -#[test] -fn an_open_or_close_ignores_kernel_metadata_entirely() { - // Neither emits a receive timestamp, and neither may take a different - // lifecycle decision for having been observed. The clock script is what - // pins that: an open consumes exactly its own lifecycle sample whatever the - // transport reported. - let clock = ScriptedClock::new([open_sample(), sample(1_000_000, 10_000)]); - let mut core = core_with_sources( - ServerConfig::default(), - ScriptedTokens::new([TOKEN_A]), - clock.clone(), - ); - let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0); - - let packet = core - .handle_received_datagram(peer(), &open_request(¶ms, None), Some(kernel_rx())) - .unwrap() - .expect("an open must be answered"); - let reply = expect_normal_open_reply(&packet, None); - assert_eq!( - clock.remaining(), - 1, - "an open consumes one lifecycle sample" - ); - assert_eq!(core.session_count(), 1); - - let closed: SocketAddr = peer(); - assert!(core - .handle_received_datagram(closed, &close_request(reply.token, None), Some(kernel_rx())) - .unwrap() - .is_none()); - assert_eq!(core.session_count(), 0, "the close released its session"); - assert_eq!( - clock.remaining(), - 0, - "and a close consumes one of its own, whatever the transport observed" - ); -} diff --git a/crates/irtt-server/src/tests/lifecycle.rs b/crates/irtt-server/src/tests/lifecycle.rs deleted file mode 100644 index 068515b..0000000 --- a/crates/irtt-server/src/tests/lifecycle.rs +++ /dev/null @@ -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, params: &Params) -> Option { - let reply = reply?; - expect_echo_reply(reply, params, None).recv_count -} - -#[test] -fn a_session_that_never_carried_an_echo_still_expires() { - // `irtt-rs` policy. The reference server starts the idle clock at the first - // echo request, so a session left unused never expires at all; a session is - // created by one unauthenticated datagram and opens are never deduplicated, - // which makes that an unbounded-state hazard rather than a compatibility - // feature. Here the deadline runs from the open. - // - // A one-session table makes the reclamation externally visible: the second - // open can only succeed if the first session was released before capacity - // was judged. - let config = idle_after(Duration::from_secs(2)).with_max_sessions(1); - let clock = ManualClock::at(0); - let mut core = core_with_sources( - config, - ScriptedTokens::new([TOKEN_A, TOKEN_B]), - clock.clone(), - ); - let requested = params_at(SECOND); - - clock.set(0); - assert!(core - .handle_datagram(peer(), &open_request(&requested, None)) - .unwrap() - .is_some()); - assert_eq!(core.session_count(), 1); - - // Still inside the deadline: the table is full and a second open is refused. - clock.set(2 * SECOND - 1); - assert_eq!( - core.handle_datagram(other_peer(), &open_request(&requested, None)) - .unwrap(), - None, - "a live session must still occupy the table" - ); - assert_eq!(core.session_count(), 1); - - // At the deadline the unused session is reclaimed, before capacity is - // judged, so the new open fits. - clock.set(2 * SECOND); - assert!( - core.handle_datagram(other_peer(), &open_request(&requested, None)) - .unwrap() - .is_some(), - "an expired session must not deny capacity to a new one" - ); - assert_eq!(core.session_count(), 1, "one replaced the other"); - - // And the reclaimed token names nothing. - assert!( - echo_at(&mut core, &clock, 2 * SECOND, TOKEN_A, 0, &requested, None).is_none(), - "the released token must be unusable" - ); -} - -#[test] -fn maintenance_reclaims_an_idle_session_without_traffic() { - let clock = ManualClock::at(0); - let mut core = core_with_sources( - idle_after(Duration::from_secs(2)), - ScriptedTokens::new([TOKEN_A]), - clock.clone(), - ); - - assert!(core - .handle_datagram(peer(), &open_request(¶ms_at(SECOND), None)) - .unwrap() - .is_some()); - assert_eq!(core.session_count(), 1); - - clock.set(2 * SECOND); - core.maintain(); - assert_eq!(core.session_count(), 0); -} - -#[test] -fn a_served_echo_moves_the_idle_deadline_along() { - let (mut core, clock, token, negotiated) = manual_core( - idle_after(Duration::from_secs(2)), - ¶ms_at(SECOND), - ScriptedTokens::new([TOKEN_A]), - None, - ); - - // Inside the deadline the open set, and past the one it would have had. - assert_eq!( - count_of( - echo_at(&mut core, &clock, SECOND, token, 0, &negotiated, None), - &negotiated - ), - Some(1) - ); - assert_eq!( - count_of( - echo_at(&mut core, &clock, 2_900 * MS, token, 1, &negotiated, None), - &negotiated - ), - Some(2), - "the echo at 1 s moved the deadline from 2 s to 3 s" - ); - - // Exactly at the deadline the last served echo set: gone, and silently. - assert!( - echo_at(&mut core, &clock, 4_900 * MS, token, 2, &negotiated, None).is_none(), - "the deadline is inclusive" - ); - assert_eq!(core.session_count(), 0); -} - -#[test] -fn an_expired_session_answers_nothing_at_all() { - // `irtt-rs` policy, and the reason this is its own test. The reference - // server was observed answering the first otherwise-serviceable echo to - // reach an expired session — a final lazy-release reply — and only dropping - // the one after it, with the release boundary a further five seconds past - // the configured timeout. `irtt-rs` reproduces neither: at the deadline the - // session is simply gone, and the client observes silence, which is the only - // thing the specification actually requires of expiry. - let (mut core, clock, token, negotiated) = manual_core( - idle_after(Duration::from_secs(2)), - ¶ms_at(SECOND), - ScriptedTokens::new([TOKEN_A]), - None, - ); - - assert_eq!( - count_of( - echo_at( - &mut core, - &clock, - 2 * SECOND - 1, - token, - 0, - &negotiated, - None - ), - &negotiated - ), - Some(1), - "one nanosecond short of the deadline is still served" - ); - - // Two seconds after that echo, to the nanosecond. - assert!( - echo_at( - &mut core, - &clock, - 4 * SECOND - 1, - token, - 1, - &negotiated, - None - ) - .is_none(), - "no final reply: an expired session is simply gone" - ); - assert_eq!(core.session_count(), 0); - assert!( - echo_at(&mut core, &clock, 4 * SECOND, token, 2, &negotiated, None).is_none(), - "and stays gone" - ); -} - -#[test] -fn a_rate_limited_echo_keeps_its_session_alive_without_counting() { - // The one drop class the clean evidence records as refreshing the idle - // deadline. It was established against every other tested class, which does - // not, so `irtt-rs` keeps it. - // - // A 2 s minimum interval against an 8 s idle timeout negotiates 2 s and - // refills every 2 s; a burst of one then makes the echo at 8.5 s — 1.5 s - // after the previous one — rate-limited. - let config = ServerConfig::default() - .with_min_send_interval(Duration::from_secs(2)) - .with_idle_timeout(Duration::from_secs(8)) - .with_burst_allowance(1); - let (mut core, clock, token, negotiated) = manual_core( - config, - ¶ms_at(2 * SECOND), - ScriptedTokens::new([TOKEN_A]), - None, - ); - assert_eq!(negotiated.interval_ns, 2 * SECOND); - - // Served, spending the burst. The idle deadline is now 15 s. - assert_eq!( - count_of( - echo_at(&mut core, &clock, 7 * SECOND, token, 0, &negotiated, None), - &negotiated - ), - Some(1) - ); - - // Rate-limited, 1.5 s later: no reply, and the deadline moves to 16.5 s. - assert!( - echo_at(&mut core, &clock, 8_500 * MS, token, 1, &negotiated, None).is_none(), - "no allowance, so no reply" - ); - - // Past the deadline the served echo alone would have set, and short of the - // one the rate-limited echo set. - assert_eq!( - count_of( - echo_at(&mut core, &clock, 16 * SECOND, token, 2, &negotiated, None), - &negotiated - ), - Some(2), - "the rate-limited echo kept the session alive, and was not counted" - ); -} - -#[test] -fn a_rejected_echo_does_not_move_the_idle_deadline() { - // Two representative classes; the admission rules themselves are pinned by - // the echo and hmac tests, and are not repeated here. - let config = idle_after(Duration::from_secs(2)).with_max_packet_length(64); - let (mut core, clock, token, negotiated) = manual_core( - config, - ¶ms_at(SECOND), - ScriptedTokens::new([TOKEN_A]), - None, - ); - - let oversized = echo_request( - token, - 0, - &Params { - length: 65, - ..negotiated.clone() - }, - &[], - None, - ); - assert_eq!(oversized.len(), 65); - - clock.set(2 * SECOND - 1); - assert_eq!(core.handle_datagram(peer(), &oversized).unwrap(), None); - assert_eq!( - core.handle_datagram( - other_peer(), - &echo_request(token, 0, &negotiated, &[], None) - ) - .unwrap(), - None, - "a foreign endpoint is refused whatever the session's state" - ); - assert_eq!(core.session_count(), 1, "neither released it early either"); - - // The deadline the open set, unmoved. - assert!( - echo_at(&mut core, &clock, 2 * SECOND, token, 0, &negotiated, None).is_none(), - "a rejected echo must not have extended the session" - ); - assert_eq!(core.session_count(), 0); -} - -#[test] -fn any_authenticated_request_reclaims_expired_sessions_including_a_no_test_open() { - // `irtt-rs` policy. Expiry is a decision about the table, so it runs before - // a request is dispatched and does not depend on what that request is for. - // The reference server was observed releasing an expired session on a burst - // of ordinary opens but not on no-test ones; a client cannot tell, since - // expiry is never signalled, and the distinction is not worth reproducing. - // - // A no-test open still creates nothing of its own, which the token source - // and the session count both check here. - let tokens = ScriptedTokens::new([TOKEN_A]); - let clock = ManualClock::at(0); - let mut core = core_with_sources( - idle_after(Duration::from_secs(2)), - tokens.clone(), - clock.clone(), - ); - let requested = params_at(SECOND); - - assert!(core - .handle_datagram(peer(), &open_request(&requested, None)) - .unwrap() - .is_some()); - assert_eq!(core.session_count(), 1); - assert_eq!(tokens.remaining(), 0); - - clock.set(2 * SECOND); - let packet = core - .handle_datagram(other_peer(), &no_test_request(&requested, None)) - .unwrap() - .expect("a no-test open is answered whatever the table holds"); - expect_no_test_reply(&packet, None); - - assert_eq!( - core.session_count(), - 0, - "the expired session was reclaimed, and no-test created none of its own" - ); -} - -#[test] -fn a_client_close_still_releases_its_session_without_a_reply() { - // Unchanged by this slice, and worth pinning next to the close the *server* - // sends: a client close is answered with nothing at all. - let (mut core, clock, token, negotiated) = manual_core( - idle_after(Duration::from_secs(60)), - ¶ms_at(SECOND), - ScriptedTokens::new([TOKEN_A]), - None, - ); - assert!(echo_at(&mut core, &clock, SECOND, token, 0, &negotiated, None).is_some()); - - clock.set(2 * SECOND); - assert_eq!( - core.handle_datagram(peer(), &close_request(token, None)) - .unwrap(), - None - ); - assert_eq!(core.session_count(), 0); -} - -/// A configuration whose sessions run for `maximum` and are never idle-expired -/// within a test's timescale. -fn max_duration_of(maximum: Duration) -> ServerConfig { - ServerConfig::default() - .with_max_test_duration(maximum) - .with_idle_timeout(Duration::from_secs(600)) -} - -#[test] -fn the_maximum_duration_deadline_starts_at_the_first_served_echo() { - // Measured behavior: opening a session well before its first echo does not - // move the deadline, and neither a rejected nor a rate-limited first request - // starts it. - let (mut core, clock, token, negotiated) = manual_core( - max_duration_of(Duration::from_secs(1)), - ¶ms_at(SECOND), - ScriptedTokens::new([TOKEN_A]), - None, - ); - assert_eq!( - negotiated.duration_ns, SECOND, - "the requested duration was reduced to the maximum" - ); - - // Ten seconds after the open — far past maximum + grace, had the open - // started the clock. - let packet = echo_at(&mut core, &clock, 10 * SECOND, token, 0, &negotiated, None) - .expect("the first echo must be answered"); - let reply = expect_echo_reply(&packet, &negotiated, None); - assert_eq!(reply.recv_count, Some(1)); - - // The deadline is 13 s: this echo, three seconds after the open but two - // before the origin's own deadline, is ordinary. - let packet = echo_at(&mut core, &clock, 12_999 * MS, token, 1, &negotiated, None) - .expect("an echo inside the deadline must be answered"); - assert_eq!( - expect_echo_reply(&packet, &negotiated, None).recv_count, - Some(2) - ); - assert_eq!(core.session_count(), 1); -} - -#[test] -fn the_first_echo_past_the_maximum_duration_carries_close_and_ends_the_session() { - // The session negotiates a fill and a payload region, so this covers the - // close's payload as well: a server close is an ordinary echo reply with a - // flag added, and it fills exactly as the session's other replies do. - let requested = Params { - server_fill: Some(ServerFill { - value: "pattern:aabb".to_owned(), - }), - length: (echo_header_len(false, ¶ms_at(SECOND)) + 5) as i64, - ..params_at(SECOND) - }; - let patterned = vec![0xaa, 0xbb, 0xaa, 0xbb, 0xaa]; - let (mut core, clock, token, negotiated) = manual_core( - max_duration_of(Duration::from_secs(1)), - &requested, - ScriptedTokens::new([TOKEN_A]), - None, - ); - - assert!(echo_at(&mut core, &clock, 10 * SECOND, token, 0, &negotiated, None).is_some()); - // One nanosecond short of maximum + 2 s grace, measured from that echo. - let packet = echo_at( - &mut core, - &clock, - 13 * SECOND - 1, - token, - 1, - &negotiated, - None, - ) - .expect("short of the deadline must be answered normally"); - let reply = expect_echo_reply(&packet, &negotiated, None); - assert_eq!(reply.flags & FLAG_CLOSE, 0, "not yet"); - assert_eq!(reply.payload, patterned, "an ordinary reply's fill"); - - // At the deadline exactly. - let packet = echo_at(&mut core, &clock, 13 * SECOND, token, 2, &negotiated, None) - .expect("the deadline-crossing echo is answered, with Close"); - let reply = expect_closing_echo_reply(&packet, &negotiated, None); - assert_eq!(reply.token, token, "an ordinary reply in every other way"); - assert_eq!(reply.sequence, 2, "the triggering sequence, copied through"); - assert_eq!( - (reply.recv_count, reply.recv_window), - (Some(3), Some(0x7)), - "the triggering request is included in the statistics it reports" - ); - assert_eq!( - reply.payload, patterned, - "the close keeps the session's fill; it is not a special packet" - ); - assert_eq!( - packet.bytes().len(), - irtt_proto::echo_packet_len(false, &negotiated).unwrap(), - "the negotiated layout, unchanged by the flag" - ); - assert_eq!( - i64::from(packet.traffic_class()), - negotiated.dscp, - "a server close is an echo reply and keeps the session's marking" - ); - - // The token is unusable from that reply onward. - assert_eq!(core.session_count(), 0); - assert!( - echo_at( - &mut core, - &clock, - 13 * SECOND + 1, - token, - 3, - &negotiated, - None - ) - .is_none(), - "every later request is an unknown token" - ); -} - -#[test] -fn a_rate_limited_echo_at_the_deadline_defers_the_close_rather_than_losing_it() { - // The measured dropped-trigger case, and the reason rate allowance is - // judged before the deadline: a deadline-crossing echo with no allowance is - // silently dropped, the session stays usable, and the next echo that *is* - // served carries the close. - // - // A 5 s minimum interval against a 600 s idle timeout negotiates 5 s and - // refills every 5 s, so the echo three seconds after the first is limited. - let config = max_duration_of(Duration::from_secs(1)) - .with_min_send_interval(Duration::from_secs(5)) - .with_burst_allowance(1); - let (mut core, clock, token, negotiated) = manual_core( - config, - ¶ms_at(5 * SECOND), - ScriptedTokens::new([TOKEN_A]), - None, - ); - assert_eq!(negotiated.interval_ns, 5 * SECOND); - - assert_eq!( - count_of( - echo_at(&mut core, &clock, 10 * SECOND, token, 0, &negotiated, None), - &negotiated - ), - Some(1), - "the origin, which also spends the burst" - ); - - assert!( - echo_at(&mut core, &clock, 13 * SECOND, token, 1, &negotiated, None).is_none(), - "past the deadline but with no allowance: dropped, not closed" - ); - assert_eq!(core.session_count(), 1, "and the session is still usable"); - - // Two seconds later the allowance is back. - let packet = echo_at(&mut core, &clock, 15 * SECOND, token, 2, &negotiated, None) - .expect("the next served echo carries the close"); - let reply = expect_closing_echo_reply(&packet, &negotiated, None); - assert_eq!( - reply.recv_count, - Some(2), - "the rate-limited request was never counted" - ); - assert_eq!(core.session_count(), 0); -} - -#[test] -fn a_rejected_echo_past_the_deadline_neither_closes_nor_releases() { - // The close trigger runs only after ordinary admission and rate allowance - // succeed, so a datagram the server would refuse at any time refuses in the - // ordinary way here too. The specification records the corresponding - // upstream behavior as untested for classes other than rate limiting, so - // this is the deterministic `irtt-rs` reading rather than an observed one. - let (mut core, clock, token, negotiated) = manual_core( - max_duration_of(Duration::from_secs(1)), - ¶ms_at(SECOND), - ScriptedTokens::new([TOKEN_A]), - None, - ); - assert!(echo_at(&mut core, &clock, 10 * SECOND, token, 0, &negotiated, None).is_some()); - - clock.set(14 * SECOND); - assert_eq!( - core.handle_datagram( - other_peer(), - &echo_request(token, 1, &negotiated, &[], None) - ) - .unwrap(), - None, - "a foreign endpoint gets silence, not a close" - ); - assert_eq!(core.session_count(), 1, "and does not release the session"); - - let packet = echo_at(&mut core, &clock, 14_100 * MS, token, 2, &negotiated, None) - .expect("the owning endpoint still receives the close"); - let reply = expect_closing_echo_reply(&packet, &negotiated, None); - assert_eq!( - reply.recv_count, - Some(2), - "the foreign request advanced nothing" - ); - assert_eq!(core.session_count(), 0); -} - -#[test] -fn an_authenticated_session_closes_through_the_ordinary_reply_path() { - // No special close authentication exists, and none should: the flag rides on - // a reply the normal encoder and key path produced. - let config = max_duration_of(Duration::from_secs(1)).with_hmac_key(KEY); - let (mut core, clock, token, negotiated) = manual_core( - config, - ¶ms_at(SECOND), - ScriptedTokens::new([TOKEN_A]), - Some(KEY), - ); - - assert!(echo_at( - &mut core, - &clock, - 10 * SECOND, - token, - 0, - &negotiated, - Some(KEY) - ) - .is_some()); - - let packet = echo_at( - &mut core, - &clock, - 13 * SECOND, - token, - 1, - &negotiated, - Some(KEY), - ) - .expect("the deadline-crossing echo is answered"); - // Decoding verifies the MAC, and the flag comparison pins HMAC and Close - // together. - let reply = expect_closing_echo_reply(&packet, &negotiated, Some(KEY)); - assert_eq!(reply.recv_count, Some(2)); - irtt_proto::verify_packet_hmac(KEY, packet.bytes()) - .expect("the close-flagged reply is authenticated"); - assert_eq!(core.session_count(), 0); -} diff --git a/crates/irtt-server/src/tests/mod.rs b/crates/irtt-server/src/tests/mod.rs index 4d002e5..e11075b 100644 --- a/crates/irtt-server/src/tests/mod.rs +++ b/crates/irtt-server/src/tests/mod.rs @@ -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; diff --git a/crates/irtt-server/src/tests/negotiation.rs b/crates/irtt-server/src/tests/negotiation.rs deleted file mode 100644 index 5e9c838..0000000 --- a/crates/irtt-server/src/tests/negotiation.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Parameter restrictions the server applies to an open request. -//! -//! Every case drives a real open through the core and reads the restriction out -//! of the open reply, because the reply is the contract: a client enforces its -//! own restriction policy against exactly these values, and the server has to -//! honor what it returned. - -use std::time::Duration; - -use irtt_proto::{Clock, Params, StampAt}; - -use super::support::{ - client_params, core_with_tokens, echo_request, expect_echo_reply, expect_normal_open_reply, - open_request, peer, unthrottled, ScriptedTokens, -}; -use crate::{ServerConfig, TimestampAllowance}; - -const TOKEN_A: u64 = 0x2f45_9a01_c37d_6e88; - -/// Negotiates one open against `config` and returns the restricted parameters. -fn negotiated(config: ServerConfig, requested: &Params) -> Params { - let mut core = core_with_tokens(config, ScriptedTokens::new([TOKEN_A])); - let packet = core - .handle_datagram(peer(), &open_request(requested, None)) - .unwrap() - .expect("the open must be answered"); - expect_normal_open_reply(&packet, None).params -} - -fn requesting(duration_ns: i64, interval_ns: i64) -> Params { - Params { - duration_ns, - interval_ns, - ..client_params() - } -} - -#[test] -fn a_requested_duration_is_reduced_to_the_configured_maximum() { - let config = || ServerConfig::default().with_max_test_duration(Duration::from_secs(2)); - - assert_eq!( - negotiated(config(), &requesting(10_000_000_000, 1_000_000_000)).duration_ns, - 2_000_000_000, - "a longer duration is reduced to the maximum" - ); - assert_eq!( - negotiated(config(), &requesting(1_000_000_000, 1_000_000_000)).duration_ns, - 1_000_000_000, - "a duration under the maximum is left alone" - ); - // Duration zero is absent from the request, which means continuous. A - // server with a finite maximum is restricting continuous mode to a finite - // test, which is a reduction the client already models. - assert_eq!( - negotiated(config(), &requesting(0, 1_000_000_000)).duration_ns, - 2_000_000_000, - "a continuous request takes the maximum" - ); -} - -#[test] -fn without_a_configured_maximum_a_duration_is_never_restricted() { - let config = || ServerConfig::default(); - - assert_eq!( - negotiated(config(), &requesting(86_400_000_000_000, 1_000_000_000)).duration_ns, - 86_400_000_000_000, - "a day-long test is the operator's business, not negotiation's" - ); - assert_eq!( - negotiated(config(), &requesting(0, 1_000_000_000)).duration_ns, - 0, - "continuous stays continuous" - ); - - // A zero maximum is stored as no maximum rather than negotiated: Duration - // zero on the wire means continuous, so a finite maximum of zero could only - // be expressed by turning a client's finite test into an endless one. - let zero = ServerConfig::default().with_max_test_duration(Duration::ZERO); - assert_eq!(zero.max_test_duration(), None); - assert_eq!( - negotiated(zero, &requesting(1_000_000_000, 1_000_000_000)).duration_ns, - 1_000_000_000 - ); -} - -#[test] -fn a_requested_interval_is_raised_to_the_configured_minimum() { - let config = || ServerConfig::default().with_min_send_interval(Duration::from_millis(100)); - - assert_eq!( - negotiated(config(), &requesting(3_000_000_000, 20_000_000)).interval_ns, - 100_000_000, - "a faster interval is raised to the minimum" - ); - assert_eq!( - negotiated(config(), &requesting(3_000_000_000, 500_000_000)).interval_ns, - 500_000_000, - "an interval above the minimum is left alone" - ); - // An absent Interval is the wire default zero, not a refusal — an explicit - // zero is rejected during request admission and never reaches negotiation — - // so it is raised like any other value below the floor. - assert_eq!( - negotiated(config(), &requesting(3_000_000_000, 0)).interval_ns, - 100_000_000, - "an absent interval takes the minimum" - ); -} - -#[test] -fn a_negotiated_interval_is_capped_at_a_quarter_of_the_idle_timeout() { - // Without the cap, a client sending at the interval it was handed would - // idle its own session out. - let config = ServerConfig::default().with_idle_timeout(Duration::from_secs(8)); - - assert_eq!( - negotiated(config, &requesting(3_000_000_000, 5_000_000_000)).interval_ns, - 2_000_000_000 - ); - - // A timeout whose quarter is past what the wire can express caps nothing, - // because no representable interval reaches it. The quarter is taken before - // the conversion to nanoseconds for exactly this reason: saturating first - // would put the cap at a quarter of `i64::MAX` and reduce a long interval - // the configured timeout never meant to touch. - let absurd = ServerConfig::default().with_idle_timeout(Duration::MAX); - assert_eq!( - negotiated(absurd, &requesting(3_000_000_000, i64::MAX)).interval_ns, - i64::MAX - ); -} - -#[test] -fn the_idle_cap_wins_over_a_larger_configured_minimum_interval() { - // The floor is applied first and the cap second, so a minimum above a - // quarter of the idle timeout produces a negotiated interval below that - // minimum. This is the configuration the specification records as an - // upstream hazard: upstream returns 2 s here and still replenishes reply - // allowance every 5 s, so a client obeying the reply is rate-limited. - // - // `irtt-rs` returns the same 2 s — the cap has to win, or the session - // cannot stay alive — and refills at 2 s instead. `rate` proves the - // enforcement half; this pins the value the client is told. - let config = ServerConfig::default() - .with_min_send_interval(Duration::from_secs(5)) - .with_idle_timeout(Duration::from_secs(8)); - - assert_eq!( - negotiated(config, &requesting(3_000_000_000, 10_000_000)).interval_ns, - 2_000_000_000 - ); -} - -#[test] -fn a_zero_idle_timeout_caps_nothing() { - // A zero timeout expires a session at the first evaluation, so there is no - // liveness to protect and a quarter of zero must not be read as a cap of - // zero — which would negotiate an interval the client would reject. - let config = ServerConfig::default() - .with_idle_timeout(Duration::ZERO) - .with_min_send_interval(Duration::from_millis(100)); - - assert_eq!( - negotiated(config, &requesting(3_000_000_000, 3_600_000_000_000)).interval_ns, - 3_600_000_000_000 - ); -} - -#[test] -fn the_default_configuration_restricts_neither_capability() { - // Both controls are opt-in, so an existing configuration negotiates exactly - // what it did before they existed. - let config = ServerConfig::default(); - - assert_eq!(config.timestamp_allowance(), TimestampAllowance::Dual); - assert!(config.dscp_allowed()); - assert_eq!(TimestampAllowance::default(), TimestampAllowance::Dual); - - let restricted = config - .clone() - .with_timestamp_allowance(TimestampAllowance::Single) - .with_dscp_allowed(false); - assert_eq!(restricted.timestamp_allowance(), TimestampAllowance::Single); - assert!(!restricted.dscp_allowed()); - assert_ne!( - restricted, config, - "the policy is part of the configuration" - ); -} - -#[test] -fn a_requested_stamp_at_is_reduced_to_the_configured_allowance() { - // The whole observed mapping, driven through real opens and read out of the - // reply, because the reply is what a client enforces its own restriction - // policy against. Only one row substitutes rather than removes: a request - // for both instants under a single-timestamp allowance is answered with the - // *midpoint*, which still describes both, rather than with whichever of the - // two the server preferred. - for (requested, allowance, expected) in [ - (StampAt::None, TimestampAllowance::Dual, StampAt::None), - (StampAt::None, TimestampAllowance::Single, StampAt::None), - (StampAt::None, TimestampAllowance::None, StampAt::None), - (StampAt::Send, TimestampAllowance::Dual, StampAt::Send), - (StampAt::Send, TimestampAllowance::Single, StampAt::Send), - (StampAt::Send, TimestampAllowance::None, StampAt::None), - (StampAt::Receive, TimestampAllowance::Dual, StampAt::Receive), - ( - StampAt::Receive, - TimestampAllowance::Single, - StampAt::Receive, - ), - (StampAt::Receive, TimestampAllowance::None, StampAt::None), - (StampAt::Both, TimestampAllowance::Dual, StampAt::Both), - (StampAt::Both, TimestampAllowance::Single, StampAt::Midpoint), - (StampAt::Both, TimestampAllowance::None, StampAt::None), - ( - StampAt::Midpoint, - TimestampAllowance::Dual, - StampAt::Midpoint, - ), - ( - StampAt::Midpoint, - TimestampAllowance::Single, - StampAt::Midpoint, - ), - (StampAt::Midpoint, TimestampAllowance::None, StampAt::None), - ] { - let params = Params { - stamp_at: requested, - clock: Clock::Both, - ..client_params() - }; - let negotiated = negotiated( - ServerConfig::default().with_timestamp_allowance(allowance), - ¶ms, - ); - - assert_eq!( - negotiated.stamp_at, expected, - "{requested:?} under {allowance:?}" - ); - assert_eq!( - negotiated.clock, - Clock::Both, - "{requested:?} under {allowance:?}: the allowance restricts placement, not the clock" - ); - } -} - -#[test] -fn a_timestamp_restriction_never_rewrites_the_requested_clock() { - // The clean evidence describes a timestamp *allowance*, and says nothing - // about restricting clock domains. A server that quietly changed the clock - // too would report a session the client never asked for — and a client - // enforcing strict negotiation would refuse an otherwise serviceable one. - let single = negotiated( - ServerConfig::default().with_timestamp_allowance(TimestampAllowance::Single), - &Params { - stamp_at: StampAt::Both, - clock: Clock::Wall, - ..client_params() - }, - ); - assert_eq!(single.stamp_at, StampAt::Midpoint); - assert_eq!(single.clock, Clock::Wall); - - let none = negotiated( - ServerConfig::default().with_timestamp_allowance(TimestampAllowance::None), - &Params { - stamp_at: StampAt::Both, - clock: Clock::Both, - ..client_params() - }, - ); - assert_eq!(none.stamp_at, StampAt::None); - assert_eq!( - none.clock, - Clock::Both, - "the clock tag stays exactly as requested, selecting nothing" - ); -} - -#[test] -fn an_allowed_dscp_is_negotiated_stored_and_transported_unchanged() { - // The default, and the behavioral compatibility this policy must not - // disturb: the raw byte reaches the reply, the session and the transport. - let requested = Params { - dscp: 0xbb, - ..client_params() - }; - let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A])); - let open = core - .handle_datagram(peer(), &open_request(&requested, None)) - .unwrap() - .expect("the open must be answered"); - - let negotiated = expect_normal_open_reply(&open, None).params; - assert_eq!(negotiated.dscp, 0xbb); - assert_eq!( - core.session(TOKEN_A) - .expect("the session must be live") - .params() - .dscp, - 0xbb - ); - - let reply = core - .handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None)) - .unwrap() - .expect("an admissible echo must be answered"); - expect_echo_reply(&reply, &negotiated, None); - assert_eq!(i64::from(reply.traffic_class()), 0xbb); -} - -#[test] -fn a_disallowed_dscp_is_negotiated_to_zero_and_the_session_runs_unmarked() { - // The open is not refused: the server simply will not provide the marking, - // and says so in the value it returns. Nothing downstream needs a second - // record of the policy — the negotiated zero is what the transport reads. - let requested = Params { - dscp: 0xb8, - ..client_params() - }; - let mut core = core_with_tokens( - unthrottled().with_dscp_allowed(false), - ScriptedTokens::new([TOKEN_A]), - ); - let open = core - .handle_datagram(peer(), &open_request(&requested, None)) - .unwrap() - .expect("a disallowed DSCP must not refuse the open"); - - let negotiated = expect_normal_open_reply(&open, None).params; - assert_eq!(negotiated.dscp, 0, "restricted to zero"); - assert_eq!( - core.session(TOKEN_A) - .expect("the session must be live") - .params() - .dscp, - 0, - "and stored as the value the session will be run with" - ); - - let reply = core - .handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None)) - .unwrap() - .expect("an unmarked session is an ordinary session"); - expect_echo_reply(&reply, &negotiated, None); - assert_eq!(reply.traffic_class(), 0); -} - -#[test] -fn a_disallowed_dscp_restricts_a_value_no_socket_could_carry_too() { - // Restriction runs during negotiation, before the transport's - // "out-of-range means unmarked" fallback is relevant at all: these values - // never reach the session, so the reply is honest rather than merely - // harmless. - for dscp in [-1, 256, i64::MAX] { - let requested = Params { - dscp, - ..client_params() - }; - let mut core = core_with_tokens( - unthrottled().with_dscp_allowed(false), - ScriptedTokens::new([TOKEN_A]), - ); - let open = core - .handle_datagram(peer(), &open_request(&requested, None)) - .unwrap() - .unwrap_or_else(|| panic!("an open requesting DSCP {dscp} must still be answered")); - - let negotiated = expect_normal_open_reply(&open, None).params; - assert_eq!(negotiated.dscp, 0, "requested {dscp}"); - assert_eq!( - core.session(TOKEN_A) - .expect("the session must be live") - .params() - .dscp, - 0, - "requested {dscp}" - ); - - let reply = core - .handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None)) - .unwrap() - .unwrap_or_else(|| panic!("the session must serve echoes after restricting {dscp}")); - expect_echo_reply(&reply, &negotiated, None); - assert_eq!(reply.traffic_class(), 0, "requested {dscp}"); - } -} diff --git a/crates/irtt-server/src/tests/no_test.rs b/crates/irtt-server/src/tests/no_test.rs deleted file mode 100644 index f2bc691..0000000 --- a/crates/irtt-server/src/tests/no_test.rs +++ /dev/null @@ -1,258 +0,0 @@ -use irtt_proto::{verify_packet_hmac, Clock, Params, StampAt}; - -use super::support::{ - client_params, core_with_tokens, expect_no_test_reply, no_test_request, open_request, - open_request_with_raw_params, param_int, peer, unthrottled, ScriptedTokens, KEY, -}; -use crate::{ServerConfig, TimestampAllowance}; - -const TOKEN_A: u64 = 0x0102_0304_0506_0708; -const TOKEN_B: u64 = 0x1112_1314_1516_1718; - -#[test] -fn a_no_test_open_replies_with_a_zero_token_and_creates_nothing() { - let tokens = ScriptedTokens::new([TOKEN_A]); - let mut core = core_with_tokens(ServerConfig::default(), tokens.clone()); - - let packet = core - .handle_datagram(peer(), &no_test_request(&client_params(), None)) - .unwrap() - .expect("a no-test open must be answered"); - - let reply = expect_no_test_reply(&packet, None); - assert_eq!( - reply.params, - Params { - protocol_version: 1, - ..client_params() - } - ); - assert_eq!(core.session_count(), 0, "no-test creates no session"); - assert_eq!( - tokens.remaining(), - 1, - "no-test must not draw a session token" - ); -} - -#[test] -fn a_no_test_open_negotiates_exactly_as_a_normal_open_does() { - let requested = client_params(); - - let mut normal_core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A])); - let normal = normal_core - .handle_datagram(peer(), &open_request(&requested, None)) - .unwrap() - .expect("normal open must be answered"); - - let mut no_test_core = - core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A])); - let no_test = no_test_core - .handle_datagram(peer(), &no_test_request(&requested, None)) - .unwrap() - .expect("no-test open must be answered"); - - assert_eq!( - expect_no_test_reply(&no_test, None).params, - super::support::decode_reply(&normal, None).params - ); -} - -#[test] -fn a_no_test_open_is_rejected_on_the_same_parameters_a_normal_open_is() { - let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A])); - - let request = { - let mut packet = no_test_request(&client_params(), None); - // Replace the parameter payload with an explicit zero duration. - packet.truncate(4); - packet.extend_from_slice(¶m_int(2, 0)); - packet - }; - - assert_eq!(core.handle_datagram(peer(), &request).unwrap(), None); - assert_eq!(core.session_count(), 0); -} - -#[test] -fn a_no_test_open_selecting_timestamps_without_a_clock_is_silently_refused() { - // No-test exists to tell a client what the session would be, so it must not - // report a session that could not exist. The effective-parameter check runs - // before the no-test path branches off, so both open forms refuse the same - // incoherent timestamp request. - let tokens = ScriptedTokens::new([TOKEN_A]); - let mut core = core_with_tokens(ServerConfig::default(), tokens.clone()); - - let requested = Params { - clock: Clock::Unspecified, - stamp_at: StampAt::Both, - ..client_params() - }; - - assert_eq!( - core.handle_datagram(peer(), &no_test_request(&requested, None)) - .expect("an unusable timestamp request is not a server error"), - None - ); - assert_eq!(core.session_count(), 0); - assert_eq!(tokens.remaining(), 1, "no token may be drawn"); -} - -#[test] -fn a_no_test_open_reports_the_reduced_packet_length() { - // No-test answers what the session *would* be, so it has to report the same - // reduced length a session-creating open would enforce — while still - // creating nothing and drawing no token. - let tokens = ScriptedTokens::new([TOKEN_A]); - let mut core = core_with_tokens( - ServerConfig::default().with_max_packet_length(64), - tokens.clone(), - ); - let requested = Params { - length: 1000, - ..client_params() - }; - - let packet = core - .handle_datagram(peer(), &no_test_request(&requested, None)) - .unwrap() - .expect("a reducible length must still be answered"); - - assert_eq!(expect_no_test_reply(&packet, None).params.length, 64); - assert_eq!(core.session_count(), 0); - assert_eq!(tokens.remaining(), 1, "no-test must not draw a token"); -} - -#[test] -fn a_no_test_open_is_refused_when_its_echo_field_block_exceeds_the_maximum() { - // A maximum below the mandatory echo block describes a session that could - // not exist at all, so no-test must not report one. Both open forms run the - // same effective-parameter check before no-test branches off. - let tokens = ScriptedTokens::new([TOKEN_A]); - let mut core = core_with_tokens( - ServerConfig::default().with_max_packet_length(0), - tokens.clone(), - ); - - assert_eq!( - core.handle_datagram(peer(), &no_test_request(&client_params(), None)) - .expect("an unservable packet size is not a server error"), - None - ); - assert_eq!(core.session_count(), 0); - assert_eq!(tokens.remaining(), 1, "no token may be drawn"); -} - -#[test] -fn a_no_test_open_is_still_answered_when_no_session_could_be_created() { - // No-test consumes no capacity, so it must keep working exactly where a - // session-creating open would be refused. - let mut refuses_everything = core_with_tokens( - ServerConfig::default().with_max_sessions(0), - ScriptedTokens::new([TOKEN_A]), - ); - assert_eq!( - refuses_everything - .handle_datagram(peer(), &open_request(&client_params(), None)) - .unwrap(), - None, - "max_sessions 0 refuses a session-creating open" - ); - let packet = refuses_everything - .handle_datagram(peer(), &no_test_request(&client_params(), None)) - .unwrap() - .expect("no-test must be served at max_sessions 0"); - expect_no_test_reply(&packet, None); - - let mut full = core_with_tokens( - ServerConfig::default().with_max_sessions(1), - ScriptedTokens::new([TOKEN_A, TOKEN_B]), - ); - full.handle_datagram(peer(), &open_request(&client_params(), None)) - .unwrap() - .expect("the first open fills the table"); - assert_eq!(full.session_count(), 1); - - let packet = full - .handle_datagram(peer(), &no_test_request(&client_params(), None)) - .unwrap() - .expect("no-test must be served with a full session table"); - expect_no_test_reply(&packet, None); - assert_eq!(full.session_count(), 1, "no-test disturbed the table"); -} - -#[test] -fn an_authenticated_no_test_reply_is_authenticated_and_still_creates_nothing() { - let mut core = core_with_tokens( - ServerConfig::default().with_hmac_key(KEY), - ScriptedTokens::new([TOKEN_A]), - ); - - let packet = core - .handle_datagram(peer(), &no_test_request(&client_params(), Some(KEY))) - .unwrap() - .expect("an authenticated no-test open must be answered"); - - expect_no_test_reply(&packet, Some(KEY)); - verify_packet_hmac(KEY, packet.bytes()).expect("the no-test reply must carry a valid MAC"); - assert_eq!(core.session_count(), 0); -} - -#[test] -fn an_empty_no_test_open_is_answered() { - // No interval floor, so the reply shows the wire defaults an empty payload - // decoded to rather than the timing policy negotiation would apply to them. - let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A])); - - let mut request = open_request_with_raw_params(&[], None); - request[3] |= irtt_proto::FLAG_CLOSE; - - let packet = core - .handle_datagram(peer(), &request) - .unwrap() - .expect("an empty no-test open must be answered"); - - let reply = expect_no_test_reply(&packet, None); - assert_eq!( - reply.params, - Params { - protocol_version: 1, - ..Params::default() - } - ); - assert_eq!(core.session_count(), 0); -} - -#[test] -fn a_no_test_open_reports_the_restricted_capabilities() { - // No-test exists to tell a client what the session would be, so it reports - // exactly what the same open would have negotiated under this policy — - // while still creating nothing and drawing no token. Capability policy is - // not skipped merely because no measurement session follows. - let tokens = ScriptedTokens::new([TOKEN_A]); - let mut core = core_with_tokens( - ServerConfig::default() - .with_timestamp_allowance(TimestampAllowance::Single) - .with_dscp_allowed(false), - tokens.clone(), - ); - let requested = Params { - stamp_at: StampAt::Both, - clock: Clock::Both, - dscp: 184, - ..client_params() - }; - - let packet = core - .handle_datagram(peer(), &no_test_request(&requested, None)) - .unwrap() - .expect("a no-test open must be answered under a restricting policy"); - - let reply = expect_no_test_reply(&packet, None); - assert_eq!(reply.params.stamp_at, StampAt::Midpoint); - assert_eq!(reply.params.clock, Clock::Both, "the clock is untouched"); - assert_eq!(reply.params.dscp, 0); - assert_eq!(reply.token, 0); - assert_eq!(core.session_count(), 0, "no-test creates no session"); - assert_eq!(tokens.remaining(), 1, "no-test must not draw a token"); -} diff --git a/crates/irtt-server/src/tests/open.rs b/crates/irtt-server/src/tests/open.rs deleted file mode 100644 index 5d95f24..0000000 --- a/crates/irtt-server/src/tests/open.rs +++ /dev/null @@ -1,458 +0,0 @@ -use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt, TimestampFields}; - -use super::support::{ - client_params, core_with_tokens, echo_request, expect_echo_reply, expect_normal_open_reply, - open_request, open_request_with_raw_params, param_int, peer, unthrottled, ScriptedTokens, KEY, -}; -use crate::{ServerConfig, TimestampAllowance, DEFAULT_MAX_PACKET_LENGTH}; - -const TOKEN_A: u64 = 0x7896_b6ab_8771_5213; - -#[test] -fn an_ordinary_open_is_answered_and_creates_one_session() { - let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A])); - - let packet = core - .handle_datagram(peer(), &open_request(&client_params(), None)) - .unwrap() - .expect("an ordinary open must be answered"); - - let reply = expect_normal_open_reply(&packet, None); - assert_eq!(reply.token, TOKEN_A); - assert_eq!(core.session_count(), 1); - - let session = core.session(TOKEN_A).expect("the session must be live"); - assert_eq!(session.params(), &reply.params); -} - -#[test] -fn a_session_records_the_exact_source_endpoint() { - // Address family, address, port and — where present — the IPv6 scope all - // form part of session identity, so the endpoint is stored verbatim rather - // than normalized to an address. - for endpoint in [ - "198.51.100.7:41234", - "198.51.100.7:41235", - "[2001:db8::1]:2112", - "[fe80::1%3]:2112", - ] { - let endpoint = endpoint.parse().unwrap(); - let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A])); - - core.handle_datagram(endpoint, &open_request(&client_params(), None)) - .unwrap() - .expect("open must be answered"); - - assert_eq!(core.session(TOKEN_A).unwrap().peer(), endpoint); - } -} - -#[test] -fn any_requested_protocol_version_is_accepted_and_answered_with_one() { - // Version mismatch is detected client-side only: the server rewrites the - // version and creates a normal session whatever was asked for. - for requested in [Some(1), Some(0), Some(2), Some(-1), None] { - let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A])); - let params = Params { - protocol_version: requested.unwrap_or(0), - ..client_params() - }; - let request = match requested { - // An explicit zero cannot be encoded by `Params::encode`, which - // omits zero-valued integers, so encode that tag by hand. - Some(0) => open_request_with_raw_params(¶m_int(1, 0), None), - Some(_) => open_request(¶ms, None), - None => open_request( - &Params { - protocol_version: 0, - ..params - }, - None, - ), - }; - - let packet = core - .handle_datagram(peer(), &request) - .unwrap() - .unwrap_or_else(|| panic!("protocol version {requested:?} must be answered")); - - let reply = expect_normal_open_reply(&packet, None); - assert_eq!(reply.params.protocol_version, 1); - assert_eq!(core.session_count(), 1); - } -} - -#[test] -fn an_empty_parameter_payload_is_answered_with_only_a_version() { - // The interval floor is disabled here so that the reply carries nothing but - // the version: with one configured, an absent Interval negotiates up to it - // and is encoded, which is interval policy rather than what this test is - // about. - let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A])); - - let packet = core - .handle_datagram(peer(), &open_request_with_raw_params(&[], None)) - .unwrap() - .expect("an empty parameter payload is a valid open"); - - // header (4) + token (8) + ProtocolVersion=1 (2). Every other parameter - // negotiates to its wire-default zero and is omitted entirely, which is - // only reachable if absent parameters really did decode as zero. - assert_eq!(packet.bytes().len(), 14); - - let reply = expect_normal_open_reply(&packet, None); - assert_eq!( - reply.params, - Params { - protocol_version: 1, - ..Params::default() - } - ); - // An unspecified clock is not itself a defect. Only pairing it with a - // selected `stamp_at` is, so a session requesting no timestamps at all is - // fully valid with no Clock tag. - assert_eq!(reply.params.stamp_at, StampAt::None); - assert_eq!(reply.params.clock, Clock::Unspecified); - assert_eq!(core.session_count(), 1); -} - -#[test] -fn unknown_parameter_tags_are_ignored_and_not_reflected_in_the_reply() { - let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A])); - - let mut payload = param_int(1, 1); - payload.extend_from_slice(¶m_int(42, 7)); - payload.extend_from_slice(¶m_int(200, 9)); - - let packet = core - .handle_datagram(peer(), &open_request_with_raw_params(&payload, None)) - .unwrap() - .expect("unknown tags must not prevent an open"); - - let reply = expect_normal_open_reply(&packet, None); - assert_eq!( - reply.params, - Params { - protocol_version: 1, - ..Params::default() - } - ); - // The reply is the same 14 bytes an empty payload produces: no unknown tag - // was copied through. - assert_eq!(packet.bytes().len(), 14); - assert_eq!(core.session_count(), 1); -} - -#[test] -fn selecting_timestamps_without_a_clock_is_silently_refused() { - // Timestamps from no clock at all describe a session the server could not - // run: the negotiated layout carries no timestamp field to fill. The open - // is discarded rather than repaired — synthesizing a clock would answer - // with a session the client never asked for, and dropping `stamp_at` would - // silently discard the measurement it did ask for. - // - // Only an *absent* Clock tag reaches this: an explicit zero is already out - // of range for the decoder, and a conforming client always sends a clock - // when it selects timestamps. - // - // A server whose timestamp allowance is none accepts the very same request, - // because then no timestamp is negotiated to be unproducible; see - // `a_timestamp_disallowing_server_accepts_a_request_that_omitted_its_clock`. - for stamp_at in [ - StampAt::Send, - StampAt::Receive, - StampAt::Both, - StampAt::Midpoint, - ] { - let requested = Params { - clock: Clock::Unspecified, - stamp_at, - ..client_params() - }; - let tokens = ScriptedTokens::new([TOKEN_A]); - let mut core = core_with_tokens(ServerConfig::default(), tokens.clone()); - - let reply = core - .handle_datagram(peer(), &open_request(&requested, None)) - .expect("an unusable timestamp request is not a server error"); - - assert!( - reply.is_none(), - "{stamp_at:?} without a clock must be silent" - ); - assert_eq!( - core.session_count(), - 0, - "{stamp_at:?} without a clock must create no session" - ); - assert_eq!( - tokens.remaining(), - 1, - "{stamp_at:?} without a clock must not consume a token" - ); - } -} - -#[test] -fn a_timestamp_disallowing_server_accepts_a_request_that_omitted_its_clock() { - // The same request the test above refuses, against a server that provides no - // timestamps at all. The restriction runs before the effective-session - // check, so `stamp_at` is already none by the time executability is judged: - // the absent clock then selects nothing that was going to be reported, and - // the session is one the server can run exactly as negotiated. - // - // This is what the ordering exists for. Validating the *request* instead - // would refuse an open that this policy makes perfectly serviceable. - let requested = Params { - clock: Clock::Unspecified, - stamp_at: StampAt::Both, - ..client_params() - }; - let mut core = core_with_tokens( - unthrottled().with_timestamp_allowance(TimestampAllowance::None), - ScriptedTokens::new([TOKEN_A]), - ); - - let packet = core - .handle_datagram(peer(), &open_request(&requested, None)) - .unwrap() - .expect("an omitted clock is safe once no timestamps are negotiated"); - - let reply = expect_normal_open_reply(&packet, None); - assert_eq!(reply.params.stamp_at, StampAt::None); - assert_eq!( - reply.params.clock, - Clock::Unspecified, - "the absent clock tag stays absent" - ); - assert_eq!(core.session_count(), 1); - - let echo = core - .handle_datagram( - peer(), - &echo_request(reply.token, 0, &reply.params, &[], None), - ) - .unwrap() - .expect("the session must serve echoes"); - assert_eq!( - expect_echo_reply(&echo, &reply.params, None).timestamps, - TimestampFields::default(), - "no timestamps were negotiated, so the reply carries none" - ); -} - -#[test] -fn current_non_length_parameters_survive_negotiation_unchanged() { - // Statistics, timestamps and DSCP are what the capability restrictions - // change when they are configured; this is the default configuration, which - // restricts none of them. Version, an oversized packet - // length, the duration maximum and the interval floor and cap are already - // restricted and have their own tests; the length here is deliberately - // under the default maximum, and the configuration below leaves duration - // and interval unrestricted, so this test says nothing about either policy. - // - // The server fill below is a *valid* descriptor, which is what survives - // unchanged. Fill negotiation has its own restriction rule and its own - // tests in `fill`. - // - // This is about what *negotiation* rewrites, which is a separate question - // from whether the core will acknowledge the result: an open whose - // effective parameters are incoherent is still discarded, as - // `selecting_timestamps_without_a_clock_is_silently_refused` shows. - let unrestricted = Params { - protocol_version: 2, - duration_ns: 3_600_000_000_000, - interval_ns: 1, - length: 1472, - received_stats: ReceivedStats::Window, - stamp_at: StampAt::Midpoint, - clock: Clock::Monotonic, - dscp: 184, - server_fill: Some(ServerFill { - value: "pattern:abcd".to_owned(), - }), - }; - - // No duration maximum is configured by default, and the interval floor is - // disabled, so a one-nanosecond interval is left where it was asked for. - let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A])); - let packet = core - .handle_datagram(peer(), &open_request(&unrestricted, None)) - .unwrap() - .expect("open must be answered"); - - let reply = expect_normal_open_reply(&packet, None); - assert_eq!( - reply.params, - Params { - protocol_version: 1, - ..unrestricted - } - ); -} - -#[test] -fn a_positive_length_above_the_configured_maximum_is_reduced_to_it() { - // The reduced value is what the client is told and what the session holds, - // because it is what the server will actually emit. Both have to agree: an - // upstream client treats a reply shorter than the negotiated length as - // fatal, so an honest reduction at open is the only serviceable answer. - let tokens = ScriptedTokens::new([TOKEN_A]); - let mut core = core_with_tokens( - ServerConfig::default().with_max_packet_length(64), - tokens.clone(), - ); - let requested = Params { - length: 1000, - ..client_params() - }; - - let packet = core - .handle_datagram(peer(), &open_request(&requested, None)) - .unwrap() - .expect("a reducible length must still be answered"); - - let reply = expect_normal_open_reply(&packet, None); - assert_eq!(reply.params.length, 64); - assert_eq!(core.session_count(), 1); - assert_eq!(tokens.remaining(), 0, "one token was consumed"); - let session = core.session(TOKEN_A).expect("the session must be live"); - assert_eq!( - session.params().length, - 64, - "the session must enforce the reduced length" - ); -} - -#[test] -fn the_default_maximum_reduces_an_absurd_requested_length() { - // The largest length the wire can carry is representable as a `usize` on a - // 64-bit target, so packet sizing would happily have turned it into an - // allocation; the configured maximum is what stops it. Restriction happens - // during negotiation, before any sizing, so this holds on a 32-bit target - // too — the request never reaches a conversion that could fail. - let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A])); - let requested = Params { - length: i64::MAX, - ..client_params() - }; - - let packet = core - .handle_datagram(peer(), &open_request(&requested, None)) - .unwrap() - .expect("an absurd length is reduced, not refused"); - - let reply = expect_normal_open_reply(&packet, None); - assert_eq!(reply.params.length, DEFAULT_MAX_PACKET_LENGTH as i64); - let session = core.session(TOKEN_A).expect("the session must be live"); - assert_eq!(session.params().length, DEFAULT_MAX_PACKET_LENGTH as i64); -} - -#[test] -fn an_open_whose_echo_field_block_exceeds_the_maximum_is_silently_refused() { - // Length itself satisfies the policy — zero asks for nothing beyond the - // mandatory fields — but the received-statistics fields alone make the - // smallest compliant echo reply larger than 20 bytes. Capping the Length - // parameter is therefore not enough on its own: this session could never be - // answered, so it is never acknowledged. - let requested = Params { - protocol_version: 1, - length: 0, - received_stats: ReceivedStats::Both, - stamp_at: StampAt::None, - clock: Clock::Unspecified, - ..Params::default() - }; - let tokens = ScriptedTokens::new([TOKEN_A]); - let mut core = core_with_tokens( - ServerConfig::default().with_max_packet_length(20), - tokens.clone(), - ); - - assert_eq!( - core.handle_datagram(peer(), &open_request(&requested, None)) - .expect("an unservable packet size is not a server error"), - None - ); - assert_eq!(core.session_count(), 0); - assert_eq!(tokens.remaining(), 1, "no token may be drawn"); -} - -#[test] -fn authentication_counts_toward_the_executable_echo_size() { - // An authentication field adds 16 bytes to the mandatory echo block, so a - // maximum between the two floors admits the same minimal session on an - // unauthenticated server and refuses it on an authenticated one. The sizes - // themselves come from `irtt_proto`; what this pins is that the server asks - // about its own authentication mode rather than assuming the smaller floor. - let minimal = Params { - protocol_version: 1, - ..Params::default() - }; - - let tokens = ScriptedTokens::new([TOKEN_A]); - let mut authenticated = core_with_tokens( - ServerConfig::default() - .with_hmac_key(KEY) - .with_max_packet_length(31), - tokens.clone(), - ); - assert_eq!( - authenticated - .handle_datagram(peer(), &open_request(&minimal, Some(KEY))) - .expect("an unservable packet size is not a server error"), - None - ); - assert_eq!(authenticated.session_count(), 0); - assert_eq!(tokens.remaining(), 1, "no token may be drawn"); - - let mut unauthenticated = core_with_tokens( - ServerConfig::default().with_max_packet_length(31), - ScriptedTokens::new([TOKEN_A]), - ); - let packet = unauthenticated - .handle_datagram(peer(), &open_request(&minimal, None)) - .unwrap() - .expect("the same session fits without an authentication field"); - expect_normal_open_reply(&packet, None); - assert_eq!(unauthenticated.session_count(), 1); -} - -#[test] -fn negative_and_out_of_range_length_and_dscp_survive_negotiation() { - // These lengths are all at or below the default maximum, and DSCP is - // accepted as decoded, including values a socket could never carry: - // negotiation restricts neither, and a value that is not a byte is simply - // transported unmarked (see `traffic_class`). - // - // A negative length in particular stays negative in both the reply and the - // stored session; the packet-length policy reduces oversized *positive* - // lengths and touches nothing else. It does not need clamping here to be - // executable: echo packet sizing in `irtt-proto` floors it at the required - // field block, so the session this creates can be run as negotiated. - for (length, dscp) in [(-1, -1), (0, 0), (1472, 184), (-4096, 512)] { - let requested = Params { - protocol_version: 1, - length, - dscp, - ..Params::default() - }; - let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A])); - - let packet = core - .handle_datagram(peer(), &open_request(&requested, None)) - .unwrap() - .unwrap_or_else(|| panic!("length {length} dscp {dscp} must be answered")); - - let reply = expect_normal_open_reply(&packet, None); - assert_eq!(reply.params.length, length); - assert_eq!(reply.params.dscp, dscp); - assert_eq!(core.session_count(), 1); - let session = core.session(TOKEN_A).expect("the session must be live"); - assert_eq!( - session.params(), - &reply.params, - "the session must hold the negotiated values verbatim" - ); - } -} diff --git a/crates/irtt-server/src/tests/params.rs b/crates/irtt-server/src/tests/params.rs deleted file mode 100644 index a524847..0000000 --- a/crates/irtt-server/src/tests/params.rs +++ /dev/null @@ -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) { - let tokens = ScriptedTokens::new([TOKEN_A]); - let mut core = core_with_tokens(ServerConfig::default(), tokens.clone()); - - let reply = core - .handle_datagram(peer(), &open_request_with_raw_params(&payload, None)) - .expect("rejected parameters are not a server error"); - - assert!(reply.is_none(), "{name} must be answered with silence"); - assert_eq!(core.session_count(), 0, "{name} must create no session"); - assert_eq!( - tokens.remaining(), - 1, - "{name} must not consume a session token" - ); -} - -#[test] -fn malformed_or_out_of_range_parameters_are_dropped_without_a_session() { - // Truncation and overflow. - reject_case("truncated tag varint", vec![0x80]); - reject_case("truncated value varint", vec![2]); - reject_case("truncated multi-byte value", vec![2, 0x80]); - reject_case( - "varint overflow", - vec![ - 1, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - ], - ); - - // Duration and Interval: present and not positive. - reject_case("explicit duration zero", param_int(2, 0)); - reject_case("negative duration", param_int(2, -1)); - reject_case("explicit interval zero", param_int(3, 0)); - reject_case("negative interval", param_int(3, -5)); - - // Enum values outside their defined range. - reject_case("received stats above range", param_int(5, 4)); - reject_case("negative received stats", param_int(5, -1)); - reject_case("stamp at above range", param_int(6, 5)); - reject_case("explicit clock zero", param_int(7, 0)); - reject_case("clock above range", param_int(7, 4)); - - // Server fill. - reject_case( - "server fill declaring more than the payload holds", - param_server_fill(10, b"ab"), - ); - reject_case( - "server fill longer than 32 bytes", - param_server_fill(33, b"0123456789abcdef0123456789abcdefx"), - ); - reject_case( - "server fill that is not utf-8", - param_server_fill(1, &[0xff]), - ); -} - -#[test] -fn absent_duration_interval_and_clock_are_accepted_as_wire_defaults() { - // The whole reason the decoder reports presence: an omitted tag and one - // explicitly encoded as zero share a value but not a verdict. - // - // The interval floor is disabled so that the accepted wire default is - // visible in the reply. An absent Interval is a value like any other to - // negotiation, and the default floor would raise it; that is interval - // policy, and `negotiation` tests it. - let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A])); - - let packet = core - .handle_datagram( - peer(), - &open_request_with_raw_params(¶m_int(1, 1), None), - ) - .unwrap() - .expect("absent duration, interval and clock are all valid"); - - let reply = expect_normal_open_reply(&packet, None); - assert_eq!(reply.params.duration_ns, 0); - assert_eq!(reply.params.interval_ns, 0); - assert_eq!(reply.params.clock, Clock::Unspecified); - assert_eq!(core.session_count(), 1); -} - -#[test] -fn valid_optional_parameter_values_survive_unchanged() { - for (name, requested) in [ - ( - "clock wall", - Params { - clock: Clock::Wall, - ..Params::default() - }, - ), - ( - "clock monotonic", - Params { - clock: Clock::Monotonic, - ..Params::default() - }, - ), - ( - "clock both", - Params { - clock: Clock::Both, - ..Params::default() - }, - ), - ( - "received stats and stamp at", - Params { - received_stats: ReceivedStats::Both, - stamp_at: StampAt::Midpoint, - clock: Clock::Wall, - ..Params::default() - }, - ), - ( - "positive duration and interval", - Params { - duration_ns: 1, - interval_ns: 1, - ..Params::default() - }, - ), - ( - // Exactly `MAX_SERVER_FILL_BYTES`, and a valid descriptor: the - // longest one a client can send is also one this server honors, so - // the row stays about the length bound rather than about fill - // policy, which `fill` tests on its own. - "maximum length server fill", - Params { - server_fill: Some(ServerFill { - value: "pattern:0123456789abcdef01234567".to_owned(), - }), - ..Params::default() - }, - ), - ] { - // No interval floor: these rows are about which *values* survive - // decoding and negotiation, not about the timing policy applied to - // them. - let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A])); - - let packet = core - .handle_datagram(peer(), &open_request(&requested, None)) - .unwrap() - .unwrap_or_else(|| panic!("{name} must be answered")); - - let reply = expect_normal_open_reply(&packet, None); - assert_eq!( - reply.params, - Params { - protocol_version: 1, - ..requested - }, - "{name} must survive negotiation unchanged" - ); - } -} diff --git a/crates/irtt-server/src/tests/rate.rs b/crates/irtt-server/src/tests/rate.rs deleted file mode 100644 index 4a3dccb..0000000 --- a/crates/irtt-server/src/tests/rate.rs +++ /dev/null @@ -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, params: &Params) -> Option { - let reply = reply?; - expect_echo_reply(reply, params, None).recv_count -} - -#[test] -fn a_blast_is_answered_up_to_the_burst_and_then_replenishes() { - // The measured case: a 100 ms minimum interval and a burst of 5 answered - // exactly 5 of a blast of 12, and the next 3 requests after a 600 ms pause - // were all answered, with the count running 1..5 and then 6, 7, 8. - let config = ServerConfig::default() - .with_min_send_interval(Duration::from_millis(100)) - .with_burst_allowance(5); - let (mut core, clock, token, negotiated) = manual_core( - config, - ¶ms_at(SECOND), - ScriptedTokens::new([TOKEN_A]), - None, - ); - // The refill cadence is the configured minimum here, not the negotiated - // interval: the ordinary case, where the minimum is the shorter of the two. - assert_eq!(negotiated.interval_ns, SECOND); - - for sequence in 0..12 { - let count = count_of( - echo_at(&mut core, &clock, 0, token, sequence, &negotiated, None), - &negotiated, - ); - let expected = (sequence < 5).then_some(sequence + 1); - assert_eq!(count, expected, "request {sequence} of the blast"); - } - - // A pause long enough to refill the whole burst, then three requests spaced - // by one refill interval each. The counts continuing 6, 7, 8 is what proves - // the seven dropped requests advanced nothing. - for (offset, sequence, expected) in [(600, 12, 6), (700, 13, 7), (800, 14, 8)] { - let count = count_of( - echo_at( - &mut core, - &clock, - offset * MS, - token, - sequence, - &negotiated, - None, - ), - &negotiated, - ); - assert_eq!(count, Some(expected), "request at {offset} ms"); - } -} - -#[test] -fn a_burst_of_one_answers_one_request_per_interval() { - // The other measured case: with a burst of 1, a blast of 4 produced 1 reply. - let config = ServerConfig::default() - .with_min_send_interval(Duration::from_millis(100)) - .with_burst_allowance(1); - let (mut core, clock, token, negotiated) = manual_core( - config, - ¶ms_at(SECOND), - ScriptedTokens::new([TOKEN_A]), - None, - ); - - for sequence in 0..4 { - let count = count_of( - echo_at(&mut core, &clock, 0, token, sequence, &negotiated, None), - &negotiated, - ); - assert_eq!(count, (sequence == 0).then_some(1), "request {sequence}"); - } - - // Exactly one interval later the allowance is back, and the count says the - // three dropped requests were never counted. - let count = count_of( - echo_at(&mut core, &clock, 100 * MS, token, 4, &negotiated, None), - &negotiated, - ); - assert_eq!(count, Some(2)); -} - -#[test] -fn the_refill_cadence_is_never_slower_than_the_negotiated_interval() { - // The deliberate `irtt-rs` divergence, and the reason this test exists. - // - // A 5 s minimum interval against an 8 s idle timeout negotiates 2 s, because - // the idle cap is applied after the floor. The clean specification records - // the reference server continuing to replenish every 5 s in that - // configuration, so a fully conforming client sending at the 2 s interval it - // was handed is rate-limited on every request. `irtt-rs` refills at the - // interval it advertised instead. - let config = ServerConfig::default() - .with_min_send_interval(Duration::from_secs(5)) - .with_idle_timeout(Duration::from_secs(8)) - .with_burst_allowance(1); - let (mut core, clock, token, negotiated) = manual_core( - config, - ¶ms_at(10 * MS), - ScriptedTokens::new([TOKEN_A]), - None, - ); - assert_eq!( - negotiated.interval_ns, - 2 * SECOND, - "the idle cap wins over the larger configured minimum" - ); - - assert_eq!( - count_of( - echo_at(&mut core, &clock, 0, token, 0, &negotiated, None), - &negotiated - ), - Some(1), - "the first request spends the burst" - ); - - // One negotiated interval later — and well short of the 5 s the reference - // server would have made this client wait. - assert_eq!( - count_of( - echo_at(&mut core, &clock, 2 * SECOND, token, 1, &negotiated, None), - &negotiated - ), - Some(2), - "a client sending at the interval it was given must not be limited" - ); - assert_eq!( - count_of( - echo_at(&mut core, &clock, 4 * SECOND, token, 2, &negotiated, None), - &negotiated - ), - Some(3), - "and must keep being answered at that cadence" - ); -} - -#[test] -fn a_zero_burst_refuses_every_echo_and_a_zero_interval_refuses_none() { - // The two zero cases are deliberately not the same. A zero burst is no - // allowance at all; a zero interval is no waiting for allowance. - let (mut core, clock, token, negotiated) = manual_core( - ServerConfig::default().with_burst_allowance(0), - ¶ms_at(SECOND), - ScriptedTokens::new([TOKEN_A]), - None, - ); - for (offset, sequence) in [(0, 0), (SECOND, 1), (60 * SECOND, 2)] { - assert!( - echo_at( - &mut core, - &clock, - offset, - token, - sequence, - &negotiated, - None - ) - .is_none(), - "a zero burst answers nothing, however long the wait" - ); - } - - let (mut core, clock, token, negotiated) = manual_core( - ServerConfig::default().with_min_send_interval(Duration::ZERO), - ¶ms_at(SECOND), - ScriptedTokens::new([TOKEN_A]), - None, - ); - for sequence in 0..12 { - assert_eq!( - count_of( - echo_at(&mut core, &clock, 0, token, sequence, &negotiated, None), - &negotiated - ), - Some(sequence + 1), - "a zero interval never withholds allowance, whatever the burst" - ); - } -} - -#[test] -fn sibling_sessions_hold_their_allowance_independently() { - let config = ServerConfig::default() - .with_min_send_interval(Duration::from_millis(100)) - .with_burst_allowance(1); - let clock = ManualClock::at(0); - let mut core = core_with_sources( - config, - ScriptedTokens::new([TOKEN_A, TOKEN_B]), - clock.clone(), - ); - let requested = params_at(SECOND); - let (token_a, negotiated) = open_negotiated(&mut core, peer(), &requested, None); - let (token_b, _) = open_negotiated(&mut core, other_peer(), &requested, None); - - // The first session spends its whole allowance and is then limited. - assert!(echo_at(&mut core, &clock, 0, token_a, 0, &negotiated, None).is_some()); - assert!(echo_at(&mut core, &clock, 0, token_a, 1, &negotiated, None).is_none()); - - // The second still has its own, from its own endpoint. - clock.set(0); - let packet = core - .handle_datagram( - other_peer(), - &echo_request(token_b, 0, &negotiated, &[], None), - ) - .unwrap() - .expect("a sibling session's allowance is its own"); - assert_eq!( - expect_echo_reply(&packet, &negotiated, None).recv_count, - Some(1) - ); -} diff --git a/crates/irtt-server/src/tests/sessions.rs b/crates/irtt-server/src/tests/sessions.rs deleted file mode 100644 index 2461eda..0000000 --- a/crates/irtt-server/src/tests/sessions.rs +++ /dev/null @@ -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); -} diff --git a/crates/irtt-server/src/tests/support.rs b/crates/irtt-server/src/tests/support.rs index e42ef74..db27115 100644 --- a/crates/irtt-server/src/tests/support.rs +++ b/crates/irtt-server/src/tests/support.rs @@ -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>>>, @@ -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>>, +pub(crate) fn peer() -> SocketAddr { + "198.51.100.7:41234".parse().unwrap() } -impl ScriptedClock { - pub(crate) fn new(samples: I) -> Self - where - I: IntoIterator, - { - 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>, -} - -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 { - 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 { - 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 { encode_request( RequestToEncode::Open { @@ -358,86 +63,6 @@ pub(crate) fn open_request(params: &Params, hmac_key: Option<&[u8]>) -> Vec .unwrap() } -/// Encodes a no-test open request carrying `params`. -pub(crate) fn no_test_request(params: &Params, hmac_key: Option<&[u8]>) -> Vec { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 -} diff --git a/crates/irtt-server/src/tests/tokens.rs b/crates/irtt-server/src/tests/tokens.rs index 62b1b18..4dcc436 100644 --- a/crates/irtt-server/src/tests/tokens.rs +++ b/crates/irtt-server/src/tests/tokens.rs @@ -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] diff --git a/crates/irtt-server/src/tests/traffic_class.rs b/crates/irtt-server/src/tests/traffic_class.rs deleted file mode 100644 index 3620b8d..0000000 --- a/crates/irtt-server/src/tests/traffic_class.rs +++ /dev/null @@ -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"); - } -} diff --git a/crates/irtt-server/tests/runtime.rs b/crates/irtt-server/tests/runtime.rs new file mode 100644 index 0000000..a19270d --- /dev/null +++ b/crates/irtt-server/tests/runtime.rs @@ -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) +} diff --git a/crates/irtt-server/tests/server_set.rs b/crates/irtt-server/tests/server_set.rs index 49aeab0..4a09e1c 100644 --- a/crates/irtt-server/tests/server_set.rs +++ b/crates/irtt-server/tests/server_set.rs @@ -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) + )); +} diff --git a/crates/irtt-server/tests/session_behavior.rs b/crates/irtt-server/tests/session_behavior.rs new file mode 100644 index 0000000..fcf8983 --- /dev/null +++ b/crates/irtt-server/tests/session_behavior.rs @@ -0,0 +1,118 @@ +use std::{net::SocketAddr, time::Duration}; + +use irtt_proto::{decode_echo_reply, decode_open_reply, encode_request, Params, RequestToEncode}; +use irtt_server::{ServerConfig, ServerCore}; + +// Admission, authentication, endpoint ownership and capacity are checked through +// the public packet interface, with actual OS-generated session tokens. +#[test] +fn authenticated_sessions_are_bounded_and_owned_by_the_opening_endpoint() { + let key = b"session-behavior"; + let peer: SocketAddr = "127.0.0.1:40000".parse().unwrap(); + let other: SocketAddr = "127.0.0.1:40001".parse().unwrap(); + let mut server = ServerCore::new( + ServerConfig::default() + .with_hmac_key(key) + .with_max_sessions(1) + .with_min_send_interval(Duration::ZERO), + ); + let params = Params { + received_stats: irtt_proto::ReceivedStats::Both, + ..Params::with_protocol_defaults() + }; + let open = encode_request( + RequestToEncode::Open { + params: ¶ms, + no_test: false, + }, + Some(key), + ) + .unwrap(); + let unauthenticated = encode_request( + RequestToEncode::Open { + params: ¶ms, + no_test: false, + }, + None, + ) + .unwrap(); + for packet in [ + &[][..], + &[0; 16][..], + &open[..open.len() - 1], + &unauthenticated, + ] { + assert!(server.handle_datagram(peer, packet).unwrap().is_none()); + assert_eq!(server.session_count(), 0); + } + let reply = server.handle_datagram(peer, &open).unwrap().unwrap(); + let opened = decode_open_reply(reply.bytes(), Some(key)).unwrap(); + assert_ne!(opened.token, 0); + assert!(server.handle_datagram(other, &open).unwrap().is_none()); + assert_eq!(server.session_count(), 1); + let echo = encode_request( + RequestToEncode::Echo { + token: opened.token, + sequence: 0, + params: &opened.params, + payload: &[], + }, + Some(key), + ) + .unwrap(); + let close = encode_request( + RequestToEncode::Close { + token: opened.token, + }, + Some(key), + ) + .unwrap(); + assert!(server.handle_datagram(other, &echo).unwrap().is_none()); + assert!(server.handle_datagram(other, &close).unwrap().is_none()); + let mut corrupted = echo.clone(); + corrupted[4] ^= 1; + assert!(server.handle_datagram(peer, &corrupted).unwrap().is_none()); + let reply = server.handle_datagram(peer, &echo).unwrap().unwrap(); + let echoed = decode_echo_reply(reply.bytes(), &opened.params, Some(key)).unwrap(); + assert_eq!(echoed.sequence, 0); + assert_eq!(echoed.recv_count, Some(1)); + assert!(server.handle_datagram(peer, &close).unwrap().is_none()); + assert_eq!(server.session_count(), 0); + assert!(server.handle_datagram(peer, &echo).unwrap().is_none()); + assert!(server.handle_datagram(other, &open).unwrap().is_some()); +} + +// Zero allowance and zero idle lifetime expose the boundary behavior directly, +// without scripting clock reads or inspecting session/rate representations. +#[test] +fn zero_burst_and_zero_idle_timeout_silently_refuse_echoes() { + let peer: SocketAddr = "127.0.0.1:40000".parse().unwrap(); + for config in [ + ServerConfig::default().with_burst_allowance(0), + ServerConfig::default().with_idle_timeout(Duration::ZERO), + ] { + let mut server = ServerCore::new(config); + let params = Params::with_protocol_defaults(); + let open = encode_request( + RequestToEncode::Open { + params: ¶ms, + no_test: false, + }, + None, + ) + .unwrap(); + let reply = server.handle_datagram(peer, &open).unwrap().unwrap(); + let opened = decode_open_reply(reply.bytes(), None).unwrap(); + let echo = encode_request( + RequestToEncode::Echo { + token: opened.token, + sequence: 0, + params: &opened.params, + payload: &[], + }, + None, + ) + .unwrap(); + assert!(server.handle_datagram(peer, &echo).unwrap().is_none()); + } +} diff --git a/crates/irtt-stats/src/ipdv.rs b/crates/irtt-stats/src/ipdv.rs index 66683f7..81c2bfb 100644 --- a/crates/irtt-stats/src/ipdv.rs +++ b/crates/irtt-stats/src/ipdv.rs @@ -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" - ); - } - } - } } } } diff --git a/crates/irtt-stats/src/normalization.rs b/crates/irtt-stats/src/normalization.rs index 4194098..c3f8726 100644 --- a/crates/irtt-stats/src/normalization.rs +++ b/crates/irtt-stats/src/normalization.rs @@ -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); - } -} diff --git a/crates/irtt-stats/tests/rolling_properties.rs b/crates/irtt-stats/tests/rolling_properties.rs index 52a60b2..561cdb4 100644 --- a/crates/irtt-stats/tests/rolling_properties.rs +++ b/crates/irtt-stats/tests/rolling_properties.rs @@ -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,