diff --git a/AGENTS.md b/AGENTS.md index d09aba2..1649e49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,6 +96,11 @@ Prefer production interfaces and component/integration tests at the lowest archi Do not move library behavior into application-binary tests merely to make it more end-to-end. For example, pacing and multi-target scheduling belong in `irtt-client` even though the CLI exposes them. +Concurrency correctness is a production requirement, but deterministic coverage +does not by itself justify a test-only synchronization hook. Prefer simplifying +the concurrency machinery or observing receipts/status/events through the public +control surface before introducing or restoring internal race gates. + ### Every persistent test must earn its place Before adding or retaining a test, be able to state the durable behavior, compatibility guarantee, regression, or invariant it protects. Other agents and humans will review tests on that basis. Do not add persistent tests merely because code changed, a branch exists, coverage would increase, or the test was useful during implementation. 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/run.rs b/crates/irtt-app/src/cmd/tui/run.rs index afca9d1..4cbe24b 100644 --- a/crates/irtt-app/src/cmd/tui/run.rs +++ b/crates/irtt-app/src/cmd/tui/run.rs @@ -87,12 +87,18 @@ pub fn run_tui( ) { drop(handle.stop()); } + let previous_dropped = dropped_events; let drain_state = drain_tui_events( &mut events, &mut state, &mut terminal_targets, &mut dropped_events, ); + if dropped_events != previous_dropped { + // Data loss changes the meaning of displayed statistics, even while + // ordinary measurement refreshes are paused. + render_if_due(&mut terminal, &state, &mut next_render, true)?; + } match drain_state { ManagedDrainState::Empty => { thread::sleep(managed_tui_wait_duration(&next_render, state.paused)); @@ -178,11 +184,16 @@ fn drain_tui_events( terminal_targets: &mut HashSet, dropped_events: &mut u64, ) -> ManagedDrainState { - drain_managed_events(events, dropped_events, |event| { + let previous_dropped = *dropped_events; + let drained = drain_managed_events(events, dropped_events, |event| { process_tui_event(event, state, terminal_targets); Ok::<(), std::convert::Infallible>(()) }) - .expect("processing TUI managed events is infallible") + .expect("processing TUI managed events is infallible"); + if *dropped_events != previous_dropped { + state.mark_dropped_managed_events(*dropped_events); + } + drained } fn drain_final_tui_events( @@ -301,3 +312,45 @@ fn managed_tui_wait_duration(next_render: &Instant, paused: bool) -> Duration { }; render_wait.min(TUI_WAIT_SLICE) } + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::{backend::TestBackend, Terminal}; + + #[test] + fn subscription_lag_keeps_incomplete_statistics_visible_while_paused() { + for paused in [false, true] { + // The production subscription is a broadcast receiver. Overflow the + // real channel rather than synthesizing a lag result or UI state. + let (sender, mut events) = tokio::sync::broadcast::channel(1); + sender.send(ManagedEvent::Started).unwrap(); + sender.send(ManagedEvent::Started).unwrap(); + let mut state = TuiState::default(); + if paused { + state.toggle_pause(); + } + let mut dropped = 0; + let mut terminal_targets = HashSet::new(); + drain_tui_events(&mut events, &mut state, &mut terminal_targets, &mut dropped); + assert_eq!(dropped, 1); + let mut terminal = Terminal::new(TestBackend::new(200, 40)).unwrap(); + for _ in 0..2 { + // Empty drains must not clear the warning on later redraws. + drain_tui_events(&mut events, &mut state, &mut terminal_targets, &mut dropped); + terminal + .draw(|frame| super::super::ui::draw_dashboard(frame, &state)) + .unwrap(); + let text = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(text.contains("incomplete:dropped=1")); + assert_eq!(text.contains("display paused"), paused); + } + } + } +} 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..494f9be --- /dev/null +++ b/crates/irtt-app/tests/cli.rs @@ -0,0 +1,479 @@ +#[cfg(feature = "client")] +#[path = "support/in_tree_server.rs"] +mod in_tree_server; + +#[cfg(feature = "client")] +use irtt_server::ServerConfig; +use std::process::Command; + +#[cfg(feature = "client")] +struct ClientProcess(std::process::Child); + +#[cfg(feature = "client")] +impl Drop for ClientProcess { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +#[cfg(feature = "client")] +impl ClientProcess { + fn wait(&mut self) -> std::process::ExitStatus { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + if let Some(status) = self.0.try_wait().unwrap() { + return status; + } + assert!( + std::time::Instant::now() < deadline, + "client did not terminate within five seconds" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } +} + +// Dispatch/help/availability belong to the application and run in every build. +#[test] +fn dispatcher_reports_and_runs_the_enabled_applets() { + let dispatcher = env!("CARGO_BIN_EXE_irtt-rs"); + let help = Command::new(dispatcher).arg("--help").output().unwrap(); + assert!(help.status.success()); + let help = String::from_utf8(help.stdout).unwrap(); + for (applet, enabled) in [ + ("client", cfg!(feature = "client")), + ("server", cfg!(feature = "server")), + ("tui", cfg!(feature = "tui")), + ] { + let row = help + .lines() + .find(|line| line.trim_start().starts_with(applet)) + .unwrap(); + assert_eq!(row.contains("not available"), !enabled); + let output = Command::new(dispatcher) + .args([applet, "--help"]) + .output() + .unwrap(); + assert_eq!(output.status.success(), enabled, "{applet}"); + if !enabled { + assert!(String::from_utf8(output.stderr) + .unwrap() + .contains("not available")); + } + } + let binaries = [ + dispatcher, + #[cfg(feature = "client")] + env!("CARGO_BIN_EXE_irtt-client"), + #[cfg(feature = "server")] + env!("CARGO_BIN_EXE_irtt-server"), + #[cfg(feature = "tui")] + env!("CARGO_BIN_EXE_irtt-tui"), + ]; + for binary in binaries { + assert!(Command::new(binary) + .arg("--help") + .output() + .unwrap() + .status + .success()); + assert!(!Command::new(binary) + .arg("--invalid-option") + .output() + .unwrap() + .status + .success()); + } +} + +// The default presentation must reach a meaningful packet summary for a finite run. +#[cfg(feature = "client")] +#[test] +fn default_client_output_emits_a_packet_summary() { + use std::{io::Read, process::Stdio, thread}; + let server = in_tree_server::InTreeServer::start(ServerConfig::default()); + let mut client = ClientProcess( + Command::new(env!("CARGO_BIN_EXE_irtt-client")) + .env_clear() + .args([ + "--duration", + "100ms", + "--interval", + "20ms", + &server.addr.to_string(), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(), + ); + let mut stdout = client.0.stdout.take().unwrap(); + let stdout = thread::spawn(move || { + let mut text = String::new(); + stdout.read_to_string(&mut text).unwrap(); + text + }); + let mut stderr = client.0.stderr.take().unwrap(); + let stderr = thread::spawn(move || { + let mut text = String::new(); + stderr.read_to_string(&mut text).unwrap(); + text + }); + let status = client.wait(); + let stdout = stdout.join().unwrap(); + let stderr = stderr.join().unwrap(); + assert!(status.success(), "{stderr}"); + assert!(stdout.contains("irtt-rs summary"), "{stdout}"); + let packets = stdout + .lines() + .find(|line| line.starts_with("packets:")) + .expect("default output must include the packet summary"); + for field in ["sent=", "received="] { + let count = packets + .split_whitespace() + .find_map(|word| word.strip_prefix(field)) + .unwrap() + .parse::() + .unwrap(); + assert!( + count > 0, + "summary must report {field} a positive count: {packets}" + ); + } +} + +#[cfg(feature = "client")] +#[test] +fn target_authentication_inherits_overrides_or_disables_the_global_key() { + use clap::Parser; + use irtt_app::cmd::client::args::ClientArgs; + let args = ClientArgs::try_parse_from([ + "irtt-client", + "--hmac", + "global-test-key", + "inherit=127.0.0.1:2112", + "override=127.0.0.1:2113@hmac=target-test-key", + "disable=127.0.0.1:2114@hmac=", + ]) + .unwrap(); + let setup = args.prepare().unwrap(); + assert_eq!( + setup.managed_config().client.hmac_key.as_deref(), + Some(b"global-test-key".as_slice()) + ); + let targets = setup.managed_targets(); + let authentication: Vec<_> = targets + .iter() + .map(|target| target.auth.as_ref().map(|auth| auth.hmac_key.as_deref())) + .collect(); + assert_eq!( + authentication, + [None, Some(Some(b"target-test-key".as_slice())), Some(None)] + ); +} + +// A real authenticated server detects broken client argument-to-config mapping. +#[cfg(feature = "client")] +#[test] +fn client_arguments_export_a_real_client_run() { + use std::{io::Read, process::Stdio, thread}; + let dispatcher = env!("CARGO_BIN_EXE_irtt-rs"); + for option in ["--dscp", "--ttl"] { + assert!(!Command::new(dispatcher) + .args(["client", option, "256", "127.0.0.1:9"]) + .output() + .unwrap() + .status + .success()); + } + let server = + in_tree_server::InTreeServer::start(ServerConfig::default().with_hmac_key(b"cli-test")); + let mut client = ClientProcess( + 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), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(), + ); + // Drain both pipes while waiting so output cannot block client termination. + let mut stdout = client.0.stdout.take().unwrap(); + let stdout = thread::spawn(move || { + let mut bytes = Vec::new(); + stdout.read_to_end(&mut bytes).unwrap(); + bytes + }); + let mut stderr = client.0.stderr.take().unwrap(); + let stderr = thread::spawn(move || { + let mut bytes = Vec::new(); + stderr.read_to_end(&mut bytes).unwrap(); + bytes + }); + let status = client.wait(); + let stdout = stdout.join().unwrap(); + let stderr = stderr.join().unwrap(); + assert!(status.success(), "{}", String::from_utf8_lossy(&stderr)); + let stdout = String::from_utf8(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\""))); +} + +#[cfg(any(feature = "client", feature = "tui"))] +#[test] +fn malformed_target_diagnostics_do_not_expose_authentication_keys() { + let secret = "malformed-target-secret-92817"; + for applet in [ + #[cfg(feature = "client")] + "client", + #[cfg(feature = "tui")] + "tui", + ] { + let output = Command::new(env!("CARGO_BIN_EXE_irtt-rs")) + .args([applet, &format!(r"edge=127.0.0.1:9@hmac={secret}\q")]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + !stderr.trim().is_empty(), + "malformed target must report a diagnostic" + ); + assert!(!stderr.contains(secret), "target authentication key leaked"); + assert!(!String::from_utf8_lossy(&output.stdout).contains(secret)); + } +} + +#[cfg(feature = "server")] +#[test] +fn server_arguments_resolve_binds_and_map_policy() { + use clap::{CommandFactory, FromArgMatches, Parser}; + use irtt_app::cmd::server::ServerArgs; + use irtt_server::TimestampAllowance; + use std::time::Duration; + // Isolate zero-argument defaults from the test runner's environment without + // mutating process-global environment in this parallel test process. + let defaults = ServerArgs::from_arg_matches( + &ServerArgs::command() + .mut_args(|arg| arg.env(None::<&str>)) + .try_get_matches_from(["irtt-server"]) + .unwrap(), + ) + .unwrap(); + assert_eq!( + defaults.resolve_binds(), + vec![ + "[::]:2112".parse().unwrap(), + "0.0.0.0:2112".parse().unwrap() + ] + ); + assert_eq!( + defaults.server_config(), + irtt_server::ServerConfig::default() + ); + let args = ServerArgs::try_parse_from([ + "irtt-server", + "--bind", + "127.0.0.1:2113", + "--bind", + "[::1]:2114", + "--hmac", + "server-cli-test", + "--max-sessions", + "3", + "--max-packet-length", + "96", + "--min-interval", + "25ms", + "--burst", + "2", + "--idle-timeout", + "8s", + "--max-duration", + "1m", + "--timestamp-allowance", + "single", + "--no-dscp", + ]) + .unwrap(); + assert_eq!( + args.resolve_binds(), + vec![ + "127.0.0.1:2113".parse().unwrap(), + "[::1]:2114".parse().unwrap() + ] + ); + let config = args.server_config(); + assert_eq!(config.hmac_key(), Some(b"server-cli-test".as_slice())); + assert_eq!(config.max_sessions(), 3); + assert_eq!(config.max_packet_length(), 96); + assert_eq!(config.min_send_interval(), Duration::from_millis(25)); + assert_eq!(config.burst_allowance(), 2); + assert_eq!(config.idle_timeout(), Duration::from_secs(8)); + assert_eq!(config.max_test_duration(), Some(Duration::from_secs(60))); + assert_eq!(config.timestamp_allowance(), TimestampAllowance::Single); + assert!(!config.dscp_allowed()); +} + +#[cfg(feature = "server")] +#[test] +fn server_command_line_binds_replace_the_environment_bind() { + use std::net::UdpSocket; + // Occupied ports give bounded, observable startup failures, without starting + // a daemon or needing a process/signal harness merely to inspect arguments. + let environment = UdpSocket::bind("127.0.0.1:0").unwrap(); + let explicit = UdpSocket::bind("127.0.0.1:0").unwrap(); + let environment_addr = environment.local_addr().unwrap().to_string(); + let explicit_addr = explicit.local_addr().unwrap().to_string(); + for use_explicit in [false, true] { + let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-server")); + command + .env_clear() + .env("IRTT_SERVER_BIND", &environment_addr) + .env("IRTT_SERVER_MAX_SESSIONS", "invalid") + .args(["--max-sessions", "3"]); + if use_explicit { + command.args(["--bind", "127.0.0.1:0", "--bind", &explicit_addr]); + } + let output = command.output().unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + let expected = if use_explicit { + &explicit_addr + } else { + &environment_addr + }; + assert!( + stderr.contains(expected), + "startup must attempt the selected bind: {stderr}" + ); + assert!( + !stderr.contains("listening on"), + "partial startup must not announce listeners" + ); + } +} + +// Exercise stdin replacement and EOF through the actual application process. +#[cfg(feature = "client")] +#[test] +fn targets_stdin_supersedes_the_active_set_and_stops_on_eof() { + use std::{ + io::{BufRead, BufReader, Read, Write}, + process::Stdio, + sync::mpsc, + thread, + time::{Duration, Instant}, + }; + let server = in_tree_server::InTreeServer::start(ServerConfig::default()); + let mut client = ClientProcess( + Command::new(env!("CARGO_BIN_EXE_irtt-client")) + .env_clear() + .args([ + "--duration", + "0", + "--targets-stdin", + "--interval", + "20ms", + "--format", + "jsonl", + "--columns", + "target,event", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(), + ); + let stdout = client.0.stdout.take().unwrap(); + let (tx, rx) = mpsc::channel(); + let reader = thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + if tx.send(line.unwrap()).is_err() { + break; + } + } + }); + let mut stdin = client.0.stdin.take().unwrap(); + let mut rows = Vec::new(); + for target in ["first", "second"] { + writeln!(stdin, "{target}={}", server.addr).unwrap(); + stdin.flush().unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let row = rx + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .expect("stdin target set did not produce a reply"); + let replied = row.contains(&format!("\"target\":\"{target}\"")) + && row.contains("\"event\":\"echo_reply\""); + rows.push(row); + if replied { + break; + } + } + } + // Retirement can finish after the newer target's first reply. + let deadline = Instant::now() + Duration::from_secs(5); + while !rows.iter().any(|row| { + row.contains("\"target\":\"first\"") && row.contains("\"event\":\"session_closed\"") + }) { + rows.push( + rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) + .expect("superseded stdin target did not retire"), + ); + } + drop(stdin); + let status = client.wait(); + reader.join().unwrap(); + rows.extend(rx.try_iter()); + let mut stderr = String::new(); + client + .0 + .stderr + .take() + .unwrap() + .read_to_string(&mut stderr) + .unwrap(); + assert!(status.success(), "{stderr}"); + assert!(rows + .iter() + .any(|row| row.contains("\"target\":\"second\"") + && row.contains("\"event\":\"session_closed\""))); +} diff --git a/crates/irtt-client/src/async_client/in_tree_server_support.rs b/crates/irtt-app/tests/support/in_tree_server.rs similarity index 83% rename from crates/irtt-client/src/async_client/in_tree_server_support.rs rename to crates/irtt-app/tests/support/in_tree_server.rs index 0375aa3..faf4fcd 100644 --- a/crates/irtt-client/src/async_client/in_tree_server_support.rs +++ b/crates/irtt-app/tests/support/in_tree_server.rs @@ -1,11 +1,3 @@ -//! 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, diff --git a/crates/irtt-client/AGENTS.md b/crates/irtt-client/AGENTS.md index e739435..f171d74 100644 --- a/crates/irtt-client/AGENTS.md +++ b/crates/irtt-client/AGENTS.md @@ -93,6 +93,29 @@ risks clearing readiness state that the normal Echo receive path depends on. There is no background task or second `AsyncFd` around the Tokio-owned socket. +### Testing kernel timestamp behavior + +The requirements above describe production behavior, not a required unit-test +architecture. + +Prefer Linux tests that exercise the real socket and kernel timestamp path +through production APIs. A test intended to prove TX timestamp support must +actually require and observe a kernel TX timestamp when running on a supported +Linux environment; accepting the userspace fallback does not prove that path. + +Likewise, prefer observable socket/client behavior for genuine error-queue +errors and bounded draining where it can reasonably be induced. + +Do not restore synthetic `ControlMessageOwned` permutations, classifier-unit +suites, raw ancillary fixtures, test-only socket hooks, or other internal seams +solely to cover every `MSG_ERRQUEUE` branch. `nix` owns ancillary-message +decoding and ABI safety; `irtt-client` tests should primarily prove its own +observable interpretation and behavior through the real path. + +If some invariant cannot reasonably be induced through a production interface, +a small focused internal test may be retained, but the burden is on that test to +justify why integration coverage is insufficient. + ## Retained state `PendingProbe` carries an optional `kernel_tx_timestamp: Option` diff --git a/crates/irtt-client/Cargo.toml b/crates/irtt-client/Cargo.toml index d171d8a..c3cc153 100644 --- a/crates/irtt-client/Cargo.toml +++ b/crates/irtt-client/Cargo.toml @@ -25,6 +25,9 @@ default = [] ancillary = ["dep:nix"] tokio = ["dep:tokio"] +[dev-dependencies] +irtt-server.workspace = true + [package.metadata.docs.rs] all-features = true cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"] @@ -42,9 +45,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/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..bc45e7e 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 { @@ -688,6 +612,8 @@ enum OpenSessionFailureCleanup { /// The sole authoritative zero-or-many target driver. #[must_use = "ManagedClientTask must be awaited or deliberately dropped"] pub struct ManagedClientTask { + #[cfg(test)] + timeout_inspections: std::cell::RefCell>, state: DriverState, lifecycle: ManagedLifecycle, config: ManagedClientConfig, @@ -710,20 +636,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 +646,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 +1281,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 +1788,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); @@ -1950,6 +1835,8 @@ impl ManagedClientTask { } fn target_has_due_timeout(&self, index: usize, now: Instant) -> bool { + #[cfg(test)] + self.timeout_inspections.borrow_mut().push(index); match &self.targets[index].state { TargetState::Active { client } => { self.state != DriverState::Stopping @@ -1958,20 +1845,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 +1952,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(); } @@ -2454,6 +2322,8 @@ fn build_task( stop: Arc::clone(&stop), }; let task = ManagedClientTask { + #[cfg(test)] + timeout_inspections: std::cell::RefCell::new(Vec::new()), state: DriverState::NotStarted, lifecycle: ManagedLifecycle::NotStarted, config, @@ -2476,12 +2346,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 +2353,6 @@ fn build_task( max_live_target_generations, status: status_receiver, events: weak_events, - #[cfg(test)] - update_submission_hook: None, }; Ok((task, handle)) } @@ -2533,5 +2395,39 @@ fn close_timeout_failure() -> ManagedTargetFailure { } #[cfg(test)] -#[path = "tests.rs"] -mod tests; +mod tests { + use super::*; + + // Poll latency through public events cannot distinguish an O(n) discovery + // scan from a bounded scan reliably. This tiny inspection trace measures + // work directly, including empty targets, and checks eventual coverage + // without asserting a cursor value or a particular visitation order. + #[test] + fn timeout_discovery_has_bounded_work_and_eventually_visits_every_target() { + let count = TIMEOUT_WORK_BUDGET * 2 + 1; + let targets = (0..count) + .map(|i| ManagedTargetConfig::new(i.to_string(), "127.0.0.1:2112")) + .collect(); + let (mut task, _handle) = ManagedClient::task( + ManagedClientConfig { + max_live_target_generations: count, + ..ManagedClientConfig::default() + }, + targets, + ) + .unwrap(); + let mut visited = HashSet::new(); + for _ in 0..count.div_ceil(TIMEOUT_WORK_BUDGET) { + task.timeout_inspections.borrow_mut().clear(); + task.poll_timeout_pass(Instant::now()); + let inspected = task.timeout_inspections.borrow(); + assert!(inspected.len() <= TIMEOUT_WORK_BUDGET); + visited.extend(inspected.iter().copied()); + } + assert_eq!( + visited.len(), + count, + "timeout discovery must reach all targets" + ); + } +} 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..04976f2 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,69 @@ mod tests { } } - fn insert(map: &mut PendingMap, probe: PendingProbe) { - map.preflight_insert(probe.wire_seq).unwrap(); - map.commit_insert(probe); - } - #[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; + fn pending_removal_and_sequence_reuse_preserve_expiry_and_capacity() { + let start = Instant::now(); + // Remove the head, middle or tail, then reuse that sequence at a later + // deadline. Observe expiry and capacity, not private list links. + for removed in 0..3 { + let mut pending_map = PendingMap::new(3); + for seq in 0..3 { + pending_map.preflight_insert(seq).unwrap(); + pending_map + .commit_insert(pending(seq, start + Duration::from_secs(u64::from(seq)))); } + assert!(matches!( + pending_map.preflight_insert(3), + Err(ClientError::PendingLimitExceeded { .. }) + )); + assert_eq!(pending_map.remove(removed).unwrap().wire_seq, removed); + pending_map.preflight_insert(removed).unwrap(); + let reused_deadline = start + Duration::from_secs(4); + pending_map.commit_insert(pending(removed, reused_deadline)); + + let remaining = (0..3).filter(|seq| *seq != removed).collect::>(); + for (index, seq) in remaining.iter().enumerate() { + assert_eq!( + pending_map.next_timeout_deadline(), + Some(start + Duration::from_secs(u64::from(*seq))) + ); + let expired = pending_map.drain_expired_bounded(start + Duration::from_secs(3), 1); + assert_eq!( + expired + .probes + .iter() + .map(|probe| probe.wire_seq) + .collect::>(), + vec![*seq] + ); + assert_eq!(expired.more_due, index == 0); + } + assert_eq!(pending_map.next_timeout_deadline(), Some(reused_deadline)); + let expired = pending_map.drain_expired_bounded(reused_deadline, 3); + assert_eq!(expired.probes.len(), 1); + assert_eq!(expired.probes[0].wire_seq, removed); + assert!(!expired.more_due); + assert!(pending_map.is_empty()); + assert_eq!(pending_map.next_timeout_deadline(), None); + pending_map.preflight_insert(removed).unwrap(); + pending_map.commit_insert(pending(removed, reused_deadline)); + assert_eq!(pending_map.remove(removed).unwrap().wire_seq, removed); + assert!(pending_map.is_empty()); } - 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]); } + // Public loss/reply events cannot reveal leaked retention bookkeeping. #[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..3c74dd0 100644 --- a/crates/irtt-client/src/session/machine.rs +++ b/crates/irtt-client/src/session/machine.rs @@ -1137,1204 +1137,291 @@ fn config_duration_to_ns(field: &str, duration: Duration) -> Result SessionMachine { - let config = ClientConfig { - max_pending_probes, - probe_timeout, - ..ClientConfig::default() + #[test] + fn timing_arithmetic_preserves_negative_samples() { + let mono = Instant::now(); + let sent = ClientTimestamp { + mono, + wall: UNIX_EPOCH + Duration::from_nanos(100), }; - 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()), + let received = ClientTimestamp { + mono: mono + Duration::from_nanos(10), + wall: UNIX_EPOCH + Duration::from_nanos(110), + }; + // Processing longer than raw RTT must remain negative with either clock. + for timestamps in [ + TimestampFields { + recv_mono: Some(100), + send_mono: Some(120), + ..TimestampFields::default() + }, + TimestampFields { + recv_wall: Some(100), + send_wall: Some(120), + ..TimestampFields::default() + }, + ] { + let rtt = compute_rtt(&sent, &received, ×tamps); + assert_eq!(rtt.raw, Duration::from_nanos(10)); + assert_eq!(rtt.adjusted.unwrap().as_nanos(), -10); + assert_eq!(rtt.effective.as_nanos(), -10); + } + // A server clock behind or ahead can make either direction negative. + for (recv_wall, send_wall, upstream, downstream) in [(90, 95, -10, 15), (115, 120, 15, -10)] + { + let sample = compute_one_way( + &sent, + sent.wall, + &received, + None, + &ReceiveMeta::default(), + &TimestampFields { + recv_wall: Some(recv_wall), + send_wall: Some(send_wall), + ..TimestampFields::default() + }, ) .unwrap(); - machine.commit_open(prepared); - machine - } - - fn active(machine: &SessionMachine) -> &ActiveSession { - match &machine.state { - MachineState::Open(session) => session, - _ => panic!("test machine must be open"), + assert_eq!(sample.client_to_server.unwrap().as_nanos(), upstream); + assert_eq!(sample.server_to_client.unwrap().as_nanos(), downstream); } } - 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())) + fn opened() -> SessionMachine { + let mut machine = SessionMachine::new( + ClientConfig { + max_pending_probes: 2, + clock: Clock::Wall, + stamp_at: irtt_proto::StampAt::Receive, + ..ClientConfig::default() + }, + "127.0.0.1:2112".parse().unwrap(), + ) + .unwrap(); + let acceptance = machine + .prepare_open_acceptance( + OpenReply { + flags: FLAG_OPEN | FLAG_REPLY, + token: 1, + params: machine.requested.clone(), + }, + ClientTimestamp::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); + machine.commit_open(acceptance); + machine } - #[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(); + fn send(machine: &mut SessionMachine) -> ProbeSent { + let probe = machine.prepare_probe().unwrap(); + let preflight = machine.preflight_probe_commit(&probe).unwrap(); let commit = machine - .finalize_probe_commit(preflight, timestamp(now)) + .finalize_probe_commit(preflight, ClientTimestamp::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); + machine.commit_probe_sent(commit, ClientTimestamp::now(), probe.bytes.len()) } - // 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 reply(machine: &mut SessionMachine, seq: u32) -> ClientEvent { + let events = machine + .process_echo_reply( + EchoReply { + flags: FLAG_REPLY, + token: 1, + sequence: seq, + recv_count: None, + recv_window: None, + timestamps: TimestampFields { + recv_wall: Some(1), + ..TimestampFields::default() + }, + payload: vec![], + }, + 32, + ClientTimestamp::now(), + ReceiveMeta::default(), + ) + .unwrap(); + events.into_iter().next().unwrap() } - 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 + // Advancing a real client through 2^32 submissions is unreasonable. Only + // move the send/receive sequence epoch; construct completion/timeout history through + // ordinary machine transitions and assert emitted behavior on reuse. + #[test] + fn sequence_wrap_reuses_completed_and_timed_out_ids_as_new_probes() { + for timed_out in [false, true] { + let mut machine = opened(); + assert_eq!(send(&mut machine).seq, 0); + if timed_out { + let deadline = machine.next_probe_timeout_deadline().unwrap(); + assert!(matches!( + machine.poll_timeouts_at(deadline).unwrap()[0], + ClientEvent::EchoLoss { seq: 0, .. } + )); + } else { + assert!(matches!( + reply(&mut machine, 0), + ClientEvent::EchoReply { seq: 0, .. } + )); } - 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)) + let session = machine.open_session_mut().unwrap(); + session.next_wire_seq = u32::MAX; + session.highest_received_seq = Some(u32::MAX - 1); + assert_eq!(send(&mut machine).seq, u32::MAX); + assert!(matches!( + reply(&mut machine, u32::MAX), + ClientEvent::EchoReply { seq: u32::MAX, .. } + )); + assert_eq!(send(&mut machine).seq, 0); + assert!(matches!( + reply(&mut machine, 0), + ClientEvent::EchoReply { seq: 0, .. } + )); + assert!(matches!( + reply(&mut machine, 0), + ClientEvent::DuplicateReply { seq: 0, .. } + )); + assert_eq!(send(&mut machine).seq, 1); + assert!(matches!( + reply(&mut machine, 1), + ClientEvent::EchoReply { seq: 1, .. } + )); + assert!( + matches!( + reply(&mut machine, 0), + ClientEvent::DuplicateReply { seq: 0, .. } + ), + "reused sequence must receive a fresh completion retention lifetime" + ); + assert_eq!(send(&mut machine).seq, 2); + assert!(matches!( + reply(&mut machine, 2), + ClientEvent::EchoReply { seq: 2, .. } + )); + assert!( + matches!( + reply(&mut machine, 0), + ClientEvent::LateReply { + sent_at: None, + rtt: None, + .. + } + ), + "evicted reused sequence must not resurrect a previous epoch's timeout" ); } } + // Kernel completion order relative to timeout cannot be forced reliably + // through a real socket. Drive both transitions through production methods + // and observe the selected endpoint on the emitted measurable LateReply. #[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()); + fn late_replies_use_tx_timestamps_attached_before_or_after_timeout() { + for attach_before_timeout in [true, false] { + let mut machine = opened(); + let anchor = ClientTimestamp { + mono: Instant::now(), + wall: UNIX_EPOCH + Duration::from_secs(10), + }; + let sent_at = ClientTimestamp { + mono: anchor.mono + Duration::from_millis(1), + wall: anchor.wall + Duration::from_millis(1), + }; + let kernel_tx = anchor.wall + Duration::from_micros(500); + let probe = machine.prepare_probe().unwrap(); + let preflight = machine.preflight_probe_commit(&probe).unwrap(); + let commit = machine.finalize_probe_commit(preflight, anchor).unwrap(); + let sent = machine.commit_probe_sent(commit, sent_at, probe.bytes.len()); + if attach_before_timeout { + machine.record_kernel_tx_timestamp(sent.seq, kernel_tx); } - other => panic!("expected an untracked LateReply, got {other:?}"), + let deadline = machine.next_probe_timeout_deadline().unwrap(); + assert!(matches!( + machine.poll_timeouts_at(deadline).unwrap()[0], + ClientEvent::EchoLoss { seq: 0, .. } + )); + if !attach_before_timeout { + machine.record_kernel_tx_timestamp(sent.seq, kernel_tx); + } + let events = machine + .process_echo_reply( + EchoReply { + flags: FLAG_REPLY, + token: 1, + sequence: sent.seq, + recv_count: None, + recv_window: None, + timestamps: TimestampFields { + recv_wall: Some(12_000_000_000), + ..TimestampFields::default() + }, + payload: vec![], + }, + probe.bytes.len(), + ClientTimestamp { + mono: deadline + Duration::from_millis(1), + wall: anchor.wall + Duration::from_secs(10), + }, + ReceiveMeta::default(), + ) + .unwrap(); + let ClientEvent::LateReply { + sent_at: Some(reported_send), + server_timing: Some(timing), + one_way: Some(one_way), + .. + } = &events[0] + else { + panic!("expected measurable LateReply: {events:?}"); + }; + assert_eq!(*reported_send, sent_at); + let selected = i128::from(timing.receive_wall_ns.unwrap()) + - one_way.client_to_server.unwrap().as_nanos(); + assert_eq!( + selected, 10_000_500_000, + "kernel TX endpoint must survive timeout" + ); } } + // A recoverable send failure that consumes an OPT_ID, followed by a + // plausible wrong-ID completion, cannot be induced reliably with public + // sockets. Exercise that failure boundary directly, without a socket hook + // or inspecting the stored correlation flag/metadata. #[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:?}"), + fn failed_submission_disables_later_tx_endpoints_for_the_session() { + let mut machine = opened(); + for attempt in 0..3 { + if attempt == 1 { + machine.invalidate_kernel_tx_correlation(); + } + let prepared = machine.prepare_probe().unwrap(); + let preflight = machine.preflight_probe_commit(&prepared).unwrap(); + let pre_send = ClientTimestamp::now(); + let commit = machine.finalize_probe_commit(preflight, pre_send).unwrap(); + std::thread::sleep(Duration::from_millis(1)); + let sent = + machine.commit_probe_sent(commit, ClientTimestamp::now(), prepared.bytes.len()); + machine.record_kernel_tx_timestamp(sent.seq, pre_send.wall); + let event = reply(&mut machine, sent.seq); + let ClientEvent::EchoReply { + one_way: Some(one_way), + .. + } = event + else { + panic!("expected measured reply") + }; + let expected_wall = if attempt == 0 { + pre_send.wall + } else { + sent.sent_at.wall + }; + let wall_ns = + i128::try_from(expected_wall.duration_since(UNIX_EPOCH).unwrap().as_nanos()) + .unwrap(); + assert_eq!(one_way.client_to_server.unwrap().as_nanos(), 1 - wall_ns); } } - - #[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..d6f47cd 100644 --- a/crates/irtt-client/src/client/tests/open.rs +++ b/crates/irtt-client/tests/client/open.rs @@ -1,9 +1,29 @@ use super::*; +// Untrusted returned intervals must be rejected before managed scheduling, +// even when loose negotiation permits legitimate server restrictions. +#[test] +fn loose_open_rejects_nonpositive_returned_intervals() { + for interval_ns in [0, -1] { + let mut returned = default_params(); + returned.interval_ns = interval_ns; + let server = open_success_server(returned); + let config = ClientConfig { + negotiation_policy: NegotiationPolicy::Loose, + ..default_test_config(server.addr) + }; + let mut client = Client::connect(config).unwrap(); + assert!( + matches!(client.open(), Err(ClientError::NegotiationRejected { .. })), + "returned interval {interval_ns} must be rejected" + ); + server.join(); + } +} + #[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 +56,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 +65,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 +79,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 +149,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 +157,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 +166,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 +216,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 +227,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 +249,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 78% 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..7e1a88c 100644 --- a/crates/irtt-client/src/client/tests/probes_replies/timeouts.rs +++ b/crates/irtt-client/tests/client/probes_replies/timeouts.rs @@ -1,12 +1,52 @@ use super::*; +#[test] +fn probe_timeout_overflow_transmits_no_echo() { + let params = default_params(); + let server = start_fake_server(move |socket, tx| { + socket + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let (_, peer) = recv_request(&socket, &tx); + socket + .send_to( + &open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None), + peer, + ) + .unwrap(); + // CLOSE is a completion barrier: any incorrectly transmitted ECHO + // precedes it on this connected loopback socket. + let _ = recv_request(&socket, &tx); + }); + let config = ClientConfig { + probe_timeout: Duration::MAX, + ..default_test_config(server.addr) + }; + let mut client = Client::connect(config).unwrap(); + assert_open_started(client.open().unwrap()); + assert!(matches!( + client.send_probe(), + Err(ClientError::DurationOverflow) + )); + assert!(!client.has_pending_probes()); + client.close().unwrap(); + let packets: Vec<_> = server.rx.iter().collect(); + assert_eq!(packets.len(), 2); + assert_eq!( + packets[1][3], + flags::FLAG_CLOSE, + "overflow must not transmit an ECHO" + ); + server.join(); +} + #[test] fn poll_timeouts_emits_echo_loss() { let params = default_params(); 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 +97,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 +159,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-client/tests/linux_transport.rs b/crates/irtt-client/tests/linux_transport.rs new file mode 100644 index 0000000..bf9de55 --- /dev/null +++ b/crates/irtt-client/tests/linux_transport.rs @@ -0,0 +1,259 @@ +//! Real Linux socket observations through the public client adapters. +#![cfg(all(target_os = "linux", feature = "ancillary"))] + +use std::{ + io::IoSliceMut, + net::{SocketAddr, UdpSocket}, + sync::mpsc, + thread, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use irtt_client::{Client, ClientConfig, ClientEvent, NegotiationPolicy, SocketConfig}; +use irtt_proto::{decode_request, Clock, DecodedRequestKind, ReceivedStats, StampAt}; +use irtt_server::{ServerConfig, ServerCore}; +use nix::sys::socket::{recvmsg, ControlMessageOwned, MsgFlags}; + +const TEST_TTL: u32 = 37; + +// A compliant packet peer uses the real server core. Its receive-side IP_TOS +// observation is independent of the client/server parameter agreement. +fn peer(dscp_allowed: bool, probes: usize) -> (SocketAddr, thread::JoinHandle>) { + let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + socket2::SockRef::from(&socket) + .set_recv_tos_v4(true) + .unwrap(); + nix::sys::socket::setsockopt(&socket, nix::sys::socket::sockopt::Ipv4RecvTtl, &true).unwrap(); + let addr = socket.local_addr().unwrap(); + let task = thread::spawn(move || { + let mut core = ServerCore::new( + ServerConfig::default() + .with_min_send_interval(Duration::ZERO) + .with_dscp_allowed(dscp_allowed), + ); + let mut markings = Vec::new(); + // OPEN, every ECHO, then CLOSE. + for _ in 0..probes + 2 { + let mut bytes = [0; 2048]; + let mut control = nix::cmsg_space!(u8, i32); + let mut iov = [IoSliceMut::new(&mut bytes)]; + let msg = recvmsg::( + std::os::fd::AsRawFd::as_raw_fd(&socket), + &mut iov, + Some(&mut control), + MsgFlags::empty(), + ) + .unwrap(); + let size = msg.bytes; + let source = msg.address.unwrap(); + let marking = msg + .cmsgs() + .unwrap() + .find_map(|message| match message { + ControlMessageOwned::Ipv4Tos(tos) => Some(tos), + _ => None, + }) + .expect("Linux must report IP_TOS"); + let ttl = msg + .cmsgs() + .unwrap() + .find_map(|message| match message { + ControlMessageOwned::Ipv4Ttl(ttl) => Some(ttl), + _ => None, + }) + .expect("Linux must report IP_TTL"); + assert_eq!( + ttl, TEST_TTL as i32, + "configured TTL must survive OPEN, ECHO and CLOSE" + ); + markings.push(marking); + let source = SocketAddr::from((source.ip(), source.port())); + if let Some(reply) = core.handle_datagram(source, &bytes[..size]).unwrap() { + socket.send_to(reply.bytes(), source).unwrap(); + } + } + assert_eq!(core.session_count(), 0); + markings + }); + (addr, task) +} + +fn config(addr: SocketAddr) -> ClientConfig { + ClientConfig { + server_addr: addr.to_string(), + dscp: 46, + negotiation_policy: NegotiationPolicy::Loose, + duration: None, + interval: Duration::from_millis(10), + clock: Clock::Both, + stamp_at: StampAt::Both, + received_stats: ReceivedStats::Both, + length: 128, + open_timeouts: vec![Duration::from_secs(1)], + socket_config: SocketConfig { + ttl: Some(TEST_TTL), + recv_timeout: Some(Duration::from_secs(1)), + ..SocketConfig::default() + }, + ..ClientConfig::default() + } +} + +// TX timestamps have no separate public field: the upstream OWD exposes the +// selected send endpoint. Reconstruct it and require a real kernel endpoint +// rather than accepting the userspace fallback. RTT must still use sent_at. +fn assert_kernel_send_endpoint(event: &ClientEvent, before_send: SystemTime) { + let ClientEvent::EchoReply { + sent_at, + received_at, + rtt, + server_timing: Some(timing), + one_way: Some(one_way), + packet_meta, + .. + } = event + else { + panic!("expected measured reply: {event:?}") + }; + let ns = |wall: SystemTime| { + i128::try_from(wall.duration_since(UNIX_EPOCH).unwrap().as_nanos()).unwrap() + }; + let selected = + i128::from(timing.receive_wall_ns.unwrap()) - one_way.client_to_server.unwrap().as_nanos(); + assert!(selected >= ns(before_send)); + // TX_SOFTWARE may arrive before or after send returns; only equality + // identifies the userspace fallback. Receipt remains the upper bound. + assert_ne!( + selected, + ns(sent_at.wall), + "TX timestamp was not observed; userspace fallback is insufficient" + ); + assert!(selected <= ns(received_at.wall)); + let selected_receive = + i128::from(timing.send_wall_ns.unwrap()) + one_way.server_to_client.unwrap().as_nanos(); + assert_eq!( + selected_receive, + ns(packet_meta + .kernel_rx_timestamp + .expect("Linux RX timestamp required")), + "downstream OWD must use the kernel receive endpoint" + ); + assert_eq!(rtt.raw, received_at.mono.duration_since(sent_at.mono)); +} + +#[test] +fn blocking_probes_use_kernel_tx_timing_and_negotiated_packet_marking() { + for allowed in [true, false] { + const PROBES: usize = 3; + let (addr, peer) = peer(allowed, PROBES); + let mut client = Client::connect(config(addr)).unwrap(); + client.open().unwrap(); + assert_eq!( + client.negotiated_params().unwrap().params.dscp, + if allowed { 184 } else { 0 } + ); + for _ in 0..PROBES { + let before = SystemTime::now(); + client.send_probe().unwrap(); + let events = client.recv_once().unwrap(); + assert_eq!(events.len(), 1); + assert_kernel_send_endpoint(&events[0], before); + } + client.close().unwrap(); + let markings = peer.join().unwrap(); + assert_eq!(markings[0], 0, "OPEN is unmarked"); + assert_eq!( + &markings[1..=PROBES], + vec![if allowed { 184 } else { 0 }; PROBES] + ); + assert_eq!( + markings[PROBES + 1], + 0, + "local CLOSE clears the negotiated marking" + ); + } +} + +#[cfg(feature = "tokio")] +#[test] +fn async_probes_use_kernel_tx_timing_and_negotiated_packet_marking() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + for allowed in [true, false] { + const PROBES: usize = 3; + let (addr, peer) = peer(allowed, PROBES); + runtime.block_on(async { + tokio::time::timeout(Duration::from_secs(5), async { + let mut client = irtt_client::AsyncClient::connect(config(addr)) + .await + .unwrap(); + client.open().await.unwrap(); + assert_eq!( + client.negotiated_params().unwrap().params.dscp, + if allowed { 184 } else { 0 } + ); + for _ in 0..PROBES { + let before = SystemTime::now(); + client.send_probe().await.unwrap(); + let events = client.recv().await.unwrap(); + assert_eq!(events.len(), 1); + assert_kernel_send_endpoint(&events[0], before); + } + client.close().await.unwrap(); + }) + .await + .expect("async transport observations timed out"); + }); + let markings = peer.join().unwrap(); + assert_eq!(markings[0], 0); + assert_eq!( + &markings[1..=PROBES], + vec![if allowed { 184 } else { 0 }; PROBES] + ); + assert_eq!(markings[PROBES + 1], 0); + } +} + +// Real ICMP refusal remains a socket error, never optional timestamp metadata. +// The bound peer closes only after the client has accepted a valid OPEN. +#[test] +fn a_closed_udp_peer_surfaces_a_socket_error() { + let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); + let addr = socket.local_addr().unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let (close_tx, close_rx) = mpsc::channel(); + let task = thread::spawn(move || { + let mut core = ServerCore::new(ServerConfig::default()); + let mut bytes = [0; 2048]; + let (size, source) = socket.recv_from(&mut bytes).unwrap(); + assert!(matches!( + decode_request(&bytes[..size]).unwrap().kind, + DecodedRequestKind::Open { .. } + )); + let reply = core + .handle_datagram(source, &bytes[..size]) + .unwrap() + .unwrap(); + socket.send_to(reply.bytes(), source).unwrap(); + close_rx.recv_timeout(Duration::from_secs(3)).unwrap(); + drop(socket); + }); + let mut client = Client::connect(config(addr)).unwrap(); + client.open().unwrap(); + close_tx.send(()).unwrap(); + task.join().unwrap(); + // Refusal can arrive during send's TX drain or the following receive. + let result = client.send_probe().and_then(|_| client.recv_once()); + assert!( + matches!(result, Err(irtt_client::ClientError::Socket(ref error)) + if error.kind() == std::io::ErrorKind::ConnectionRefused), + "{result:?}" + ); +} diff --git a/crates/irtt-client/tests/managed_control.rs b/crates/irtt-client/tests/managed_control.rs new file mode 100644 index 0000000..1e7b80d --- /dev/null +++ b/crates/irtt-client/tests/managed_control.rs @@ -0,0 +1,160 @@ +#![cfg(feature = "tokio")] + +use std::{ + future::Future, + pin::pin, + task::{Context, Poll, Waker}, + time::Duration, +}; + +use irtt_client::managed::*; + +fn config() -> ManagedClientConfig { + ManagedClientConfig { + completion: ManagedCompletionPolicy::ExplicitStop, + command_capacity: 3, + ..ManagedClientConfig::default() + } +} + +#[test] +fn stop_resolves_every_accepted_update_and_closes_admission() { + let (task, handle) = ManagedClient::task(config(), vec![]).unwrap(); + let receipts: Vec<_> = (0..3) + .map(|index| { + handle + .update_targets(vec![ManagedTargetConfig::new( + format!("queued-{index}"), + "127.0.0.1:9", + )]) + .unwrap() + }) + .collect(); + assert!(matches!( + handle.update_targets(vec![]), + Err(ManagedCommandError::QueueFull) + )); + + let stop = handle.stop(); + assert!(matches!( + handle.update_targets(vec![]), + Err(ManagedCommandError::Stopping) + )); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + tokio::time::timeout(Duration::from_secs(2), async { + let outcome = task.await; + stop.await; + for receipt in receipts { + assert!(matches!( + receipt.await, + Err(ManagedCommandApplyError::Stopping) + )); + } + assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested); + assert_eq!(outcome.applied_command_sequence, 0); + assert_eq!(outcome.total_target_outcomes, 0); + let status = handle.status(); + assert_eq!(status.lifecycle, ManagedLifecycle::Completed); + assert!(status.stop_requested); + assert_eq!(status.final_outcome.as_deref(), Some(&outcome)); + assert!(matches!( + handle.update_targets(vec![]), + Err(ManagedCommandError::DriverClosed) + )); + handle.stop().await; + }) + .await + .expect("stop must resolve every buffered update and repeated stop receipt"); + }); +} + +#[test] +fn failed_driver_resolves_every_accepted_update_with_its_terminal_failure() { + let (task, handle) = ManagedClient::task(config(), vec![]).unwrap(); + let receipts: Vec<_> = (0..3) + .map(|index| { + handle + .update_targets(vec![ManagedTargetConfig::new( + format!("queued-{index}"), + "127.0.0.1:9", + )]) + .unwrap() + }) + .collect(); + let mut context = Context::from_waker(Waker::noop()); + let Poll::Ready(outcome) = pin!(task).as_mut().poll(&mut context) else { + panic!("polling without Tokio must fail immediately"); + }; + assert_eq!( + outcome.end_reason, + ManagedEndReason::DriverFailed(ManagedDriverFailure::NoTokioRuntime) + ); + for receipt in receipts { + assert!(matches!( + pin!(receipt).as_mut().poll(&mut context), + Poll::Ready(Err(ManagedCommandApplyError::DriverFailed( + ManagedDriverFailure::NoTokioRuntime + ))) + )); + } + let status = handle.status(); + assert_eq!(status.lifecycle, ManagedLifecycle::Failed); + assert_eq!(status.final_outcome.as_deref(), Some(&outcome)); + assert!(matches!( + handle.update_targets(vec![]), + Err(ManagedCommandError::DriverClosed) + )); + assert!(pin!(handle.stop()).as_mut().poll(&mut context).is_ready()); +} + +#[test] +fn concurrent_submission_and_stop_or_seal_never_lose_an_accepted_receipt() { + let (task, handle) = ManagedClient::task(config(), vec![]).unwrap(); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let submit_barrier = std::sync::Arc::clone(&barrier); + let submit_handle = handle.clone(); + let (sender, receiver) = std::sync::mpsc::channel(); + let submit = std::thread::spawn(move || { + submit_barrier.wait(); + sender.send(submit_handle.update_targets(vec![])).unwrap(); + }); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + // Stop is latched before the driver starts, while submission may cross + // admission before stop, after stop, or after terminal sealing. + barrier.wait(); + let stop = handle.stop(); + runtime.block_on(async { + tokio::time::timeout(Duration::from_secs(2), async { + let outcome = task.await; + stop.await; + assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested); + assert_eq!(outcome.applied_command_sequence, 0); + assert_eq!(outcome.total_target_outcomes, 0); + let result = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("concurrent submission must return"); + submit.join().unwrap(); + match result { + Ok(receipt) => assert!(matches!( + receipt.await, + Err(ManagedCommandApplyError::Stopping) + )), + Err(ManagedCommandError::Stopping | ManagedCommandError::DriverClosed) => {} + Err(error) => panic!("unexpected concurrent submission result: {error:?}"), + } + assert!(matches!( + handle.update_targets(vec![]), + Err(ManagedCommandError::DriverClosed) + )); + }) + .await + .expect("every accepted concurrent submission must receive a terminal disposition"); + }); +} 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..8349de9 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); } @@ -378,7 +381,7 @@ fn recv_after_local_close_and_no_test_fails_on_first_poll() { } #[test] -fn authenticated_async_lifecycle_applies_negotiated_dscp() { +fn authenticated_async_lifecycle_negotiates_dscp() { let key = b"async-lifecycle-key".to_vec(); let server = InTreeServer::start(ServerConfig::default().with_hmac_key(key.clone())); runtime().block_on(async { @@ -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..10bd4fa --- /dev/null +++ b/crates/irtt-integration-tests/tests/managed_behavior.rs @@ -0,0 +1,248 @@ +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(10), async { + let server = InTreeServer::start(ServerConfig::default()); + for pacing in [ManagedPacing::Burst, ManagedPacing::Staggered] { + let mut observed_pacing = false; + for _ in 0..3 { + let config = ManagedClientConfig { + client: ClientConfig { + duration: Some(Duration::from_millis(600)), + interval: Duration::from_millis(100), + 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(); + let mut actual_sends = Vec::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); + actual_sends.push((target.clone(), sent_at.mono)); + 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(100).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); + } + // Use only the initial pair: later stagger gates can legitimately + // compress after a delayed wake. Startup may instead serve one + // target twice before the other opens, so retry a fresh run then. + if actual_sends.len() < 2 || actual_sends[0].0 == actual_sends[1].0 { + continue; + } + let gap = actual_sends[1].1.duration_since(actual_sends[0].1); + match pacing { + ManagedPacing::Staggered => { + assert!( + gap >= Duration::from_millis(50), + "first stagger gate was bypassed: {gap:?}" + ); + observed_pacing = true; + } + ManagedPacing::Burst => { + // A pause between burst sends makes this observation + // inconclusive; it must not fail an otherwise valid run. + observed_pacing = gap < Duration::from_millis(25); + } + } + if observed_pacing { + break; + } + } + assert!( + observed_pacing, + "could not observe {pacing:?} startup pacing in three fresh runs" + ); + } + }) + .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, + outcome_history_limit: 1, + ..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() { + let status = handle.status(); + assert_eq!(outcome.target, first); + assert_eq!(outcome.end_reason, ManagedTargetEndReason::Removed); + assert_eq!(outcome.cleanup_failure, None); + assert_eq!(status.total_target_outcomes, 1); + assert_eq!(status.successful_target_outcomes, 1); + assert_eq!( + status.recent_target_outcomes.as_ref(), + &[(*outcome).clone()] + ); + assert!(status.targets.iter().all(|target| target.target != first + || target.lifecycle == ManagedTargetLifecycle::Terminal)); + 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(); + loop { + match events.recv().await.unwrap() { + ManagedEvent::TargetFinished { outcome } => { + let status = handle.status(); + assert_eq!(outcome.target, second); + assert_eq!(status.total_target_outcomes, 2); + assert_eq!( + status.recent_target_outcomes.as_ref(), + &[(*outcome).clone()] + ); + assert!(status.targets.iter().all(|target| target.target != second + || target.lifecycle == ManagedTargetLifecycle::Terminal)); + } + ManagedEvent::Completed { outcome } => { + let status = handle.status(); + assert_eq!(status.lifecycle, ManagedLifecycle::Completed); + assert_eq!(status.final_outcome.as_ref(), Some(&outcome)); + break; + } + _ => {} + } + } + let outcome = run.await.unwrap(); + stop.await; + assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested); + assert_eq!(outcome.total_target_outcomes, 2); + assert_eq!(outcome.successful_target_outcomes, 2); + assert_eq!(outcome.failed_target_outcomes, 0); + assert_eq!(outcome.discarded_target_outcomes, 1); + assert_eq!(outcome.recent_target_outcomes.len(), 1); + let status = handle.status(); + assert_eq!(status.total_target_outcomes, 2); + assert_eq!(status.successful_target_outcomes, 2); + assert_eq!(status.failed_target_outcomes, 0); + assert_eq!(status.discarded_target_outcomes, 1); + assert_eq!(status.recent_target_outcomes.len(), 1); + assert_eq!(status.recent_target_outcomes[0].target, second); + 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..6b8d054 --- /dev/null +++ b/crates/irtt-integration-tests/tests/managed_lifecycle.rs @@ -0,0 +1,506 @@ +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 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/AGENTS.md b/crates/irtt-server/AGENTS.md index d3804b3..ce0f8e4 100644 --- a/crates/irtt-server/AGENTS.md +++ b/crates/irtt-server/AGENTS.md @@ -333,9 +333,9 @@ effective parameters must be safe for the server to run. - The transition is pure and is committed only after the reply has encoded, so an internal encoding failure cannot leave a session claiming to have answered a request it never did. -- Clock sampling is a private injected seam, like token generation, not a - runtime abstraction: the receive instant is taken as soon as a datagram is - classified as an echo and the send instant just before the reply is built, so +- Clock sampling uses the system clock directly, not a runtime abstraction: the + receive instant is taken as soon as a datagram is classified as an echo and + the send instant just before the reply is built, so they bracket the server's own handling. The monotonic origin belongs to the clock source and is stable for its life. - A reply's receive instant is held back to its send instant where the wall @@ -601,16 +601,22 @@ half-created session. maintain shadow encoders for well-formed wire data. - Hand-build packets only where the point is a payload a compliant encoder cannot produce — truncated varints, out-of-range enums, corrupted MACs. -- Token generation and clock sampling are the only nondeterministic parts; tests - inject a scripted source for each, so identity, collisions, allocation failure - and timestamp values are all assertable. Keep both seams private, and keep - timestamp, rate and lifetime tests free of sleeps, tolerances and real - wall-clock assertions. -- Two clock fakes, for two different questions. A scripted clock returns a fixed - list of samples and is for tests that assert individual timestamp *readings*; a - hand-moved clock stands still until the test moves it, and is for rate and - lifetime tests, which care when a datagram arrived and not how many times the - core read the clock on the way. Prefer the latter for anything about deadlines. +- Prefer the public `ServerCore` packet interface and real runtime sockets for + durable behavior. Use production encoders and decoders, actual OS-generated + tokens, and observable replies/session capacity rather than private state. +- Observe nonzero rate refill and lifetime deadlines with real time when an + oversleep cannot invalidate the assertion. Bound observation loops and report + useful failures. Do not restore fake clocks, random-source injection, race + gates, synthetic ancillary classifiers or private observability merely to + reproduce the deleted test structure. Focused fault injection remains allowed + under the root policy only when no reasonable production-interface alternative + can protect an important invariant. +- Random fill's OS-source failure cannot be forced through the packet interface. + Its zero-fill fallback remains an explicitly unexercised error path; ordinary + random-fill wire tests must not claim to cover it or add an injection seam + merely for coverage. Existing token-allocation fault tests protect bounded + allocation and transactional failure, which cannot be driven reliably with + actual random draws. - Assert rate and lifetime behavior from replies: which datagrams came back, the count and window they reported, and `session_count`. The token bucket is a black-box inference about the reference server, not a protocol requirement, so 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/protocol_contracts.rs b/crates/irtt-server/tests/protocol_contracts.rs new file mode 100644 index 0000000..319be48 --- /dev/null +++ b/crates/irtt-server/tests/protocol_contracts.rs @@ -0,0 +1,377 @@ +//! Durable server contracts observed through encoded requests and public replies. +use std::{ + net::SocketAddr, + thread, + time::{Duration, Instant}, +}; + +use irtt_proto::{ + decode_echo_reply, decode_open_reply, encode_request, Clock, EchoReply, OpenReply, Params, + ReceivedStats, RequestToEncode, StampAt, FLAG_CLOSE, +}; +use irtt_server::{ServerConfig, ServerCore, TimestampAllowance}; + +fn peer() -> SocketAddr { + "127.0.0.1:40000".parse().unwrap() +} +fn open_packet(params: &Params, no_test: bool, key: Option<&[u8]>) -> Vec { + encode_request(RequestToEncode::Open { params, no_test }, key).unwrap() +} +fn open(server: &mut ServerCore, params: &Params) -> OpenReply { + let reply = server + .handle_datagram(peer(), &open_packet(params, false, None)) + .unwrap() + .unwrap(); + decode_open_reply(reply.bytes(), None).unwrap() +} +fn echo(server: &mut ServerCore, opened: &OpenReply, sequence: u32) -> Option { + let request = encode_request( + RequestToEncode::Echo { + token: opened.token, + sequence, + params: &opened.params, + payload: &[], + }, + None, + ) + .unwrap(); + server + .handle_datagram(peer(), &request) + .unwrap() + .map(|reply| decode_echo_reply(reply.bytes(), &opened.params, None).unwrap()) +} +fn unthrottled() -> ServerConfig { + ServerConfig::default().with_min_send_interval(Duration::ZERO) +} + +#[test] +fn no_test_authenticates_negotiates_and_uses_no_session_capacity() { + let key = b"no-test-contract"; + let mut server = ServerCore::new( + ServerConfig::default() + .with_hmac_key(key) + .with_max_sessions(0) + .with_max_packet_length(96) + .with_min_send_interval(Duration::from_millis(50)) + .with_timestamp_allowance(TimestampAllowance::Single) + .with_dscp_allowed(false), + ); + let params = Params { + protocol_version: 99, + length: 128, + dscp: 185, + stamp_at: StampAt::Both, + clock: Clock::Both, + ..Params::default() + }; + let request = open_packet(¶ms, true, Some(key)); + assert!(server + .handle_datagram(peer(), &open_packet(¶ms, true, None)) + .unwrap() + .is_none()); + let mut bad_mac = request.clone(); + bad_mac[4] ^= 1; + assert!(server.handle_datagram(peer(), &bad_mac).unwrap().is_none()); + let reply = server.handle_datagram(peer(), &request).unwrap().unwrap(); + assert_eq!(reply.traffic_class(), 0); + let negotiated = decode_open_reply(reply.bytes(), Some(key)).unwrap(); + assert_eq!(negotiated.token, 0); + assert_eq!(negotiated.params.protocol_version, 1); + assert_ne!(negotiated.flags & FLAG_CLOSE, 0); + assert_eq!(negotiated.params.length, 96); + assert_eq!(negotiated.params.interval_ns, 50_000_000); + assert_eq!(negotiated.params.stamp_at, StampAt::Midpoint); + assert_eq!(negotiated.params.clock, Clock::Both); + assert_eq!(negotiated.params.dscp, 0); + assert_eq!(server.session_count(), 0); + assert!(server + .handle_datagram(peer(), &open_packet(¶ms, false, Some(key))) + .unwrap() + .is_none()); +} + +#[test] +fn no_test_refuses_unexecutable_layout_but_validates_after_restriction() { + let params = Params { + stamp_at: StampAt::Both, + ..Params::with_protocol_defaults() + }; + let mut server = ServerCore::new(unthrottled()); + assert!(server + .handle_datagram(peer(), &open_packet(¶ms, true, None)) + .unwrap() + .is_none()); + let mut restricted = + ServerCore::new(unthrottled().with_timestamp_allowance(TimestampAllowance::None)); + let reply = restricted + .handle_datagram(peer(), &open_packet(¶ms, true, None)) + .unwrap() + .unwrap(); + assert_eq!( + decode_open_reply(reply.bytes(), None) + .unwrap() + .params + .stamp_at, + StampAt::None + ); + let rich = Params { + received_stats: ReceivedStats::Both, + stamp_at: StampAt::Both, + clock: Clock::Both, + ..Params::with_protocol_defaults() + }; + let mut too_small = ServerCore::new(unthrottled().with_max_packet_length(32)); + assert!(too_small + .handle_datagram(peer(), &open_packet(&rich, true, None)) + .unwrap() + .is_none()); + assert_eq!(too_small.session_count(), 0); + let invalid = Params { + interval_ns: -1, + ..Params::with_protocol_defaults() + }; + assert!(server + .handle_datagram(peer(), &open_packet(&invalid, true, None)) + .unwrap() + .is_none()); + assert_eq!(server.session_count(), 0); +} + +#[test] +fn receive_statistics_follow_verified_gap_duplicate_reordering_and_wrap_vectors() { + // SERVER_BEHAVIORAL_VECTORS.md Sections 1.2, 1.5, 1.6, 1.8 and 1.10. + let vectors: &[&[(u32, u64)]] = &[ + &[(0, 1), (1, 3), (3, 13), (4, 27)], + &[(0, 1), (1, 3), (1, 3), (2, 7)], + &[(0, 1), (1, 3), (2, 7), (1, 1), (3, 5)], + &[(0, 1), (63, 0x8000_0000_0000_0001)], + &[(0, 1), (64, 1)], + &[ + (u32::MAX - 2, 1), + (u32::MAX - 1, 3), + (u32::MAX, 7), + (0, 15), + (1, 31), + ], + ]; + for vector in vectors { + let mut server = ServerCore::new(unthrottled()); + let opened = open( + &mut server, + &Params { + received_stats: ReceivedStats::Both, + ..Params::with_protocol_defaults() + }, + ); + for (index, &(sequence, window)) in vector.iter().enumerate() { + let reply = echo(&mut server, &opened, sequence).unwrap(); + assert_eq!(reply.recv_count, Some(index as u32 + 1)); + assert_eq!( + reply.recv_window, + Some(window), + "sequence {sequence} in {vector:?}" + ); + } + } +} + +#[test] +fn a_spent_nonzero_burst_refills_without_counting_dropped_echoes() { + let refill = Duration::from_millis(100); + let mut server = ServerCore::new( + ServerConfig::default() + .with_min_send_interval(refill) + .with_burst_allowance(1) + .with_idle_timeout(Duration::MAX), + ); + let opened = open( + &mut server, + &Params { + received_stats: ReceivedStats::Both, + ..Params::with_protocol_defaults() + }, + ); + let mut count = echo(&mut server, &opened, 0).unwrap().recv_count.unwrap(); + // Observe actual refusal rather than assuming two calls fit a timing window. + let deadline = Instant::now() + Duration::from_secs(2); + while let Some(reply) = echo(&mut server, &opened, 0) { + count = reply.recv_count.unwrap(); + assert!( + Instant::now() < deadline, + "could not observe a spent burst within two seconds" + ); + } + thread::sleep(refill); // Oversleep only replenishes more; idle expiry is disabled in practice. + let reply = echo(&mut server, &opened, 1).unwrap(); + assert_eq!(reply.recv_count, Some(count + 1)); + assert_eq!(reply.recv_window, Some(3)); +} + +#[test] +fn a_nonzero_idle_deadline_reclaims_an_unused_session_before_another_open() { + let idle = Duration::from_millis(10); + let mut server = ServerCore::new(unthrottled().with_max_sessions(1).with_idle_timeout(idle)); + let params = Params::with_protocol_defaults(); + let old = open(&mut server, ¶ms); + thread::sleep(idle); // Any oversleep still requires silent expiry and capacity recovery. + assert!(echo(&mut server, &old, 0).is_none()); + assert_eq!(server.session_count(), 0); + open(&mut server, ¶ms); + assert_eq!(server.session_count(), 1); +} + +#[test] +fn maximum_duration_starts_at_the_first_served_echo_and_closes_with_its_reply() { + let maximum = Duration::from_millis(1); + let mut server = ServerCore::new( + unthrottled() + .with_idle_timeout(Duration::MAX) + .with_max_test_duration(maximum), + ); + let opened = open( + &mut server, + &Params { + received_stats: ReceivedStats::Both, + stamp_at: StampAt::Both, + clock: Clock::Both, + dscp: 185, + ..Params::with_protocol_defaults() + }, + ); + assert_eq!(opened.params.duration_ns, 1_000_000); + let elapsed_deadline = maximum + Duration::from_secs(2); + thread::sleep(elapsed_deadline); + let first = echo(&mut server, &opened, 0).unwrap(); + assert_eq!( + first.flags & FLAG_CLOSE, + 0, + "OPEN must not start the maximum-duration clock" + ); + thread::sleep(elapsed_deadline); // Oversleep still requires the next served echo to close. + let request = encode_request( + RequestToEncode::Echo { + token: opened.token, + sequence: 1, + params: &opened.params, + payload: &[], + }, + None, + ) + .unwrap(); + let reply = server.handle_datagram(peer(), &request).unwrap().unwrap(); + assert_eq!(reply.traffic_class(), 185); + let closed = decode_echo_reply(reply.bytes(), &opened.params, None).unwrap(); + assert_ne!(closed.flags & FLAG_CLOSE, 0); + assert_eq!(closed.sequence, 1); + assert_eq!(closed.recv_count, Some(2)); + assert_eq!(closed.recv_window, Some(3)); + assert!(closed.timestamps.recv_wall.is_some()); + assert!(closed.timestamps.send_mono.is_some()); + assert_eq!(server.session_count(), 0); + assert!(echo(&mut server, &opened, 2).is_none()); +} + +#[test] +fn negotiated_timestamp_modes_emit_only_the_selected_fields_in_order() { + for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] { + for placement in [ + StampAt::None, + StampAt::Receive, + StampAt::Send, + StampAt::Both, + StampAt::Midpoint, + ] { + let mut server = ServerCore::new(unthrottled()); + let opened = open( + &mut server, + &Params { + stamp_at: placement, + clock, + ..Params::with_protocol_defaults() + }, + ); + let t = echo(&mut server, &opened, 0).unwrap().timestamps; + let wall = clock != Clock::Monotonic; + let mono = clock != Clock::Wall; + let recv = matches!(placement, StampAt::Receive | StampAt::Both); + let send = matches!(placement, StampAt::Send | StampAt::Both); + let midpoint = placement == StampAt::Midpoint; + assert_eq!(t.recv_wall.is_some(), wall && recv); + assert_eq!(t.recv_mono.is_some(), mono && recv); + assert_eq!(t.send_wall.is_some(), wall && send); + assert_eq!(t.send_mono.is_some(), mono && send); + assert_eq!(t.midpoint_wall.is_some(), wall && midpoint); + assert_eq!(t.midpoint_mono.is_some(), mono && midpoint); + if let (Some(recv), Some(send)) = (t.recv_wall, t.send_wall) { + assert!(recv <= send); + } + if let (Some(recv), Some(send)) = (t.recv_mono, t.send_mono) { + assert!(0 <= recv && recv <= send); + let next = echo(&mut server, &opened, 1).unwrap().timestamps; + assert!( + next.recv_mono.unwrap() >= send, + "monotonic origin must remain stable" + ); + } + } + } +} + +#[test] +fn duration_close_waits_for_rate_allowance_after_the_deadline() { + let refill = Duration::from_secs(4); + let maximum = Duration::from_millis(1); + for _ in 0..3 { + let mut server = ServerCore::new( + ServerConfig::default() + .with_min_send_interval(refill) + .with_burst_allowance(1) + .with_idle_timeout(Duration::MAX) + .with_max_test_duration(maximum), + ); + let started = Instant::now(); + let opened = open( + &mut server, + &Params { + received_stats: ReceivedStats::Both, + ..Params::with_protocol_defaults() + }, + ); + let first = echo(&mut server, &opened, 0).unwrap(); + assert_eq!(first.flags & FLAG_CLOSE, 0); + thread::sleep(maximum + Duration::from_secs(2)); + let refused = echo(&mut server, &opened, 63); + // Include open/setup and the request itself in the elapsed bound. If the + // scheduler carried us past refill, the reply may legitimately close. + if started.elapsed() >= refill { + continue; + } + assert!( + refused.is_none(), + "exhausted allowance must suppress the deadline-crossing reply" + ); + assert_eq!( + server.session_count(), + 1, + "rate refusal must not release the session" + ); + thread::sleep(refill); + let closed = echo(&mut server, &opened, 1).unwrap(); + assert_ne!(closed.flags & FLAG_CLOSE, 0); + assert_eq!(closed.sequence, 1); + assert_eq!(closed.recv_count, Some(2)); + assert_eq!(closed.recv_window, Some(3)); + assert_eq!(server.session_count(), 0); + return; + } + panic!("scheduler crossed the refill boundary in all three fresh sessions"); +} + +#[test] +fn default_server_resource_limits_remain_bounded() { + let config = ServerConfig::default(); + // Upper resource budgets, not an exact default or a comparison against + // the same constants used by the implementation. Smaller bounds are fine. + assert!((1..=1024).contains(&config.max_sessions())); + assert!((1..=65_507).contains(&config.max_packet_length())); +} diff --git a/crates/irtt-server/tests/runtime.rs b/crates/irtt-server/tests/runtime.rs new file mode 100644 index 0000000..9b3af99 --- /dev/null +++ b/crates/irtt-server/tests/runtime.rs @@ -0,0 +1,283 @@ +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) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "freebsd")))] +#[tokio::test(flavor = "current_thread")] +async fn wildcard_construction_is_refused_without_reply_source_selection() { + let addr = "0.0.0.0:0".parse().unwrap(); + assert!(matches!( + Server::bind(addr, unthrottled()).await, + Err(ServerRuntimeError::WildcardSourceSelectionUnsupported { .. }) + )); +} + +// A current-thread runtime cannot receive while this test holds its thread. +// Queue real packets before delaying the server: no synthetic ancillary data or +// transport hooks. Observed queue timing determines whether kernel wall time +// is plausible under the one-second bound, including scheduler overshoot. Monotonic and +// midpoint timestamps must always remain at the later userspace observation. +#[cfg(target_os = "linux")] +#[tokio::test(flavor = "current_thread")] +async fn queued_echoes_select_kernel_receive_wall_only_when_plausible() { + use std::time::{SystemTime, UNIX_EPOCH}; + let ns = || { + i64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(), + ) + .unwrap() + }; + for (placement, delay) in [ + (StampAt::Both, Duration::from_millis(100)), + (StampAt::Both, Duration::from_millis(1100)), + (StampAt::Midpoint, Duration::from_millis(100)), + ] { + let mut server = Server::bind("127.0.0.1:0".parse().unwrap(), unthrottled()) + .await + .unwrap(); + let addr = server.local_addr().unwrap(); + let client = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + client.connect(addr).await.unwrap(); + let (stop_tx, stop_rx) = oneshot::channel(); + let run = tokio::spawn(async move { + server + .run(async { + let _ = stop_rx.await; + }) + .await + }); + let params = Params { + stamp_at: placement, + clock: Clock::Both, + received_stats: ReceivedStats::Both, + ..Params::with_protocol_defaults() + }; + let open = encode_request( + RequestToEncode::Open { + params: ¶ms, + no_test: false, + }, + None, + ) + .unwrap(); + client.send(&open).await.unwrap(); + let mut buffer = [0; 2048]; + let len = timeout(Duration::from_secs(2), client.recv(&mut buffer)) + .await + .unwrap() + .unwrap(); + let opened = decode_open_reply(&buffer[..len], None).unwrap(); + let echo = encode_request( + RequestToEncode::Echo { + token: opened.token, + sequence: 0, + params: &opened.params, + payload: &[], + }, + None, + ) + .unwrap(); + client.writable().await.unwrap(); + let before_send = ns(); + client.try_send(&echo).unwrap(); // No await from submission through resume marker. + let queued_by = ns(); + std::thread::sleep(delay); + let resumed_at = ns(); + let len = timeout(Duration::from_secs(2), client.recv(&mut buffer)) + .await + .unwrap() + .unwrap(); + let reply = decode_echo_reply(&buffer[..len], &opened.params, None).unwrap(); + assert_eq!(reply.recv_count, Some(1)); + assert_eq!(reply.recv_window, Some(1)); + let timestamps = reply.timestamps; + if placement == StampAt::Both { + let receive = timestamps.recv_wall.unwrap(); + let send = timestamps.send_wall.unwrap(); + let kernel_endpoint = (before_send..=queued_by).contains(&receive); + let userspace_endpoint = (resumed_at..=send).contains(&receive); + // Arrival lies in [before_send, queued_by], and core observation + // lies in [resumed_at, send]. Only demand one selection where these + // observed bounds put the whole lag interval on one side of 1 s. + // Scheduler overshoot can legitimately change the expected path. + if send - before_send <= 1_000_000_000 { + assert!( + kernel_endpoint, + "plausible arrival must select kernel wall time" + ); + } else if resumed_at - queued_by > 1_000_000_000 { + assert!( + userspace_endpoint, + "stale arrival must fall back to userspace" + ); + } else { + assert!( + kernel_endpoint || userspace_endpoint, + "boundary observation must identify a real endpoint" + ); + } + if kernel_endpoint { + let wall_elapsed = send - receive; + let mono_elapsed = timestamps.send_mono.unwrap() - timestamps.recv_mono.unwrap(); + assert!( + wall_elapsed - mono_elapsed >= 50_000_000, + "queue delay must not move the monotonic endpoint" + ); + } + } else { + assert!( + timestamps.midpoint_wall.unwrap() >= resumed_at, + "midpoint must use userspace time" + ); + } + stop_tx.send(()).unwrap(); + run.await.unwrap().unwrap(); + } +} 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..c647b94 --- /dev/null +++ b/crates/irtt-server/tests/session_behavior.rs @@ -0,0 +1,264 @@ +use std::{ + net::{SocketAddr, SocketAddrV6}, + 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 mut unkeyed = ServerCore::new(ServerConfig::default()); + assert!(unkeyed.handle_datagram(peer, &open).unwrap().is_none()); + assert_eq!(unkeyed.session_count(), 0); + 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()); + } +} + +#[test] +fn ipv6_flow_labels_share_a_session_but_zones_do_not() { + let endpoint = |flow, scope| { + SocketAddr::V6(SocketAddrV6::new( + "fe80::1234".parse().unwrap(), + 40000, + flow, + scope, + )) + }; + let mut server = + ServerCore::new(ServerConfig::default().with_min_send_interval(Duration::ZERO)); + let params = Params { + received_stats: irtt_proto::ReceivedStats::Both, + ..Params::with_protocol_defaults() + }; + let packet = encode_request( + RequestToEncode::Open { + params: ¶ms, + no_test: false, + }, + None, + ) + .unwrap(); + let reply = server + .handle_datagram(endpoint(1, 2), &packet) + .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(); + let close = encode_request( + RequestToEncode::Close { + token: opened.token, + }, + None, + ) + .unwrap(); + assert!(server + .handle_datagram(endpoint(1, 3), &echo) + .unwrap() + .is_none()); + assert!(server + .handle_datagram(endpoint(1, 3), &close) + .unwrap() + .is_none()); + assert_eq!(server.session_count(), 1); + let reply = server + .handle_datagram(endpoint(99, 2), &echo) + .unwrap() + .unwrap(); + assert_eq!( + decode_echo_reply(reply.bytes(), &opened.params, None) + .unwrap() + .recv_count, + Some(1) + ); + assert!(server + .handle_datagram(endpoint(99, 2), &close) + .unwrap() + .is_none()); + assert_eq!(server.session_count(), 0); +} + +#[test] +fn oversized_authenticated_echo_does_not_advance_session_state() { + let key = b"oversized-echo-test"; + let peer = "127.0.0.1:40000".parse().unwrap(); + let mut server = ServerCore::new( + ServerConfig::default() + .with_hmac_key(key) + .with_max_packet_length(96) + .with_burst_allowance(1) + .with_min_send_interval(Duration::from_secs(60)) + .with_max_test_duration(Duration::from_millis(1)) + .with_idle_timeout(Duration::MAX), + ); + let params = Params { + length: 96, + 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 reply = server.handle_datagram(peer, &open).unwrap().unwrap(); + let opened = decode_open_reply(reply.bytes(), Some(key)).unwrap(); + let oversized_params = Params { + length: 128, + ..opened.params.clone() + }; + let oversized = encode_request( + RequestToEncode::Echo { + token: opened.token, + sequence: 63, + params: &oversized_params, + payload: &[], + }, + Some(key), + ) + .unwrap(); + assert!(oversized.len() > 96); + assert!(server.handle_datagram(peer, &oversized).unwrap().is_none()); + // If the oversized request started maximum duration, the first valid + // echo would now close. Oversleep preserves this distinction. + std::thread::sleep(Duration::from_millis(2001)); + let valid = encode_request( + RequestToEncode::Echo { + token: opened.token, + sequence: 0, + params: &opened.params, + payload: &[], + }, + Some(key), + ) + .unwrap(); + let reply = server + .handle_datagram(peer, &valid) + .unwrap() + .expect("oversized request must not consume allowance"); + let reply = decode_echo_reply(reply.bytes(), &opened.params, Some(key)).unwrap(); + assert_eq!(reply.flags & irtt_proto::FLAG_CLOSE, 0); + assert_eq!(reply.recv_count, Some(1)); + assert_eq!(reply.recv_window, Some(1)); + assert_eq!(server.session_count(), 1); +} diff --git a/crates/irtt-server/tests/traffic_class.rs b/crates/irtt-server/tests/traffic_class.rs new file mode 100644 index 0000000..ce26d67 --- /dev/null +++ b/crates/irtt-server/tests/traffic_class.rs @@ -0,0 +1,133 @@ +//! Observe the emitted IP byte, independently of protocol negotiation. +#![cfg(target_os = "linux")] + +use std::{io::IoSliceMut, net::UdpSocket as StdSocket, os::fd::AsRawFd, time::Duration}; + +use irtt_proto::{ + decode_echo_reply, decode_open_reply, encode_request, OpenReply, Params, RequestToEncode, + FLAG_CLOSE, +}; +use irtt_server::{Server, ServerConfig}; +use nix::sys::socket::{recvmsg, ControlMessageOwned, MsgFlags}; +use tokio::{net::UdpSocket, sync::oneshot, time::timeout}; + +async fn exchange(client: &UdpSocket, request: &[u8], expected: u8) -> Vec { + client.send(request).await.unwrap(); + loop { + client.readable().await.unwrap(); + let packet = client.try_io(tokio::io::Interest::READABLE, || { + let mut bytes = [0; 2048]; + let mut control = nix::cmsg_space!(u8); + let mut iov = [IoSliceMut::new(&mut bytes)]; + let msg = recvmsg::<()>( + client.as_raw_fd(), + &mut iov, + Some(&mut control), + MsgFlags::MSG_DONTWAIT, + )?; + let size = msg.bytes; + let class = msg.cmsgs().unwrap().find_map(|message| match message { + ControlMessageOwned::Ipv4Tos(class) => Some(class), + _ => None, + }); + assert_eq!(class, Some(expected)); + Ok(bytes[..size].to_vec()) + }); + match packet { + Ok(packet) => return packet, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} + Err(error) => panic!("receive failed: {error}"), + } + } +} + +fn echo(opened: &OpenReply, sequence: u32) -> Vec { + encode_request( + RequestToEncode::Echo { + token: opened.token, + sequence, + params: &opened.params, + payload: &[], + }, + None, + ) + .unwrap() +} + +#[tokio::test(flavor = "current_thread")] +async fn every_open_echo_and_server_close_applies_its_own_raw_traffic_class() { + timeout(Duration::from_secs(8), async { + let maximum = Duration::from_millis(1); + let mut server = Server::bind( + "127.0.0.1:0".parse().unwrap(), + ServerConfig::default() + .with_min_send_interval(Duration::ZERO) + .with_idle_timeout(Duration::MAX) + .with_max_test_duration(maximum), + ) + .await + .unwrap(); + let addr = server.local_addr().unwrap(); + let (shutdown, stopped) = oneshot::channel(); + let task = tokio::spawn(async move { + server + .run(async { + let _ = stopped.await; + }) + .await + }); + let socket = StdSocket::bind("127.0.0.1:0").unwrap(); + socket2::SockRef::from(&socket) + .set_recv_tos_v4(true) + .unwrap(); + socket.set_nonblocking(true).unwrap(); + let client = UdpSocket::from_std(socket).unwrap(); + client.connect(addr).await.unwrap(); + let mut sessions = Vec::new(); + // Each new OPEN follows a prior ECHO. The first raw byte also includes + // ECN bits: neither a codepoint shift nor masking would preserve it. + for (requested, class) in [(185, 185), (32, 32), (0, 0), (256, 0), (-1, 0)] { + let params = Params { + dscp: requested, + ..Params::with_protocol_defaults() + }; + let request = encode_request( + RequestToEncode::Open { + params: ¶ms, + no_test: false, + }, + None, + ) + .unwrap(); + let packet = exchange(&client, &request, 0).await; + let opened = decode_open_reply(&packet, None).unwrap(); + assert_eq!(opened.params.dscp, requested); + let packet = exchange(&client, &echo(&opened, 0), class).await; + assert_eq!( + decode_echo_reply(&packet, &opened.params, None) + .unwrap() + .flags + & FLAG_CLOSE, + 0 + ); + sessions.push((opened, class)); + } + // Every session has now served its first echo. Waiting past all duration + // deadlines is safe under oversleep; idle expiry cannot intervene. + tokio::time::sleep(maximum + Duration::from_secs(2)).await; + for (opened, class) in sessions { + let packet = exchange(&client, &echo(&opened, 1), class).await; + assert_ne!( + decode_echo_reply(&packet, &opened.params, None) + .unwrap() + .flags + & FLAG_CLOSE, + 0 + ); + } + shutdown.send(()).unwrap(); + task.await.unwrap().unwrap(); + }) + .await + .expect("marked runtime exchanges did not complete"); +} diff --git a/crates/irtt-stats/src/ipdv.rs b/crates/irtt-stats/src/ipdv.rs index 66683f7..606e465 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, @@ -251,34 +199,26 @@ mod tests { ) { let mut tracker = IpdvTracker::new(Some(limit)); for seq in seqs { - tracker.insert(ipdv_sample(seq, i128::from(seq))); + let completed_before = tracker.completed_pairs.clone(); + let pairs = tracker.insert(ipdv_sample(seq, i128::from(seq))); + let emitted: HashSet<_> = pairs.iter().map(|pair| pair.current_seq).collect(); + prop_assert_eq!(emitted.len(), pairs.len()); + prop_assert!(emitted.is_disjoint(&completed_before), "a retained pair must not be emitted twice"); prop_assert!(tracker.samples.len() <= limit); + prop_assert!(tracker.sample_order.len() <= limit); + prop_assert!(tracker.completed_pairs.len() <= limit); + // Eviction must remove stale pair markers as well as samples; + // otherwise retained state can prevent future adjacent pairs. prop_assert_eq!(tracker.sample_order.len(), tracker.samples.len()); - prop_assert!(tracker.completed_pairs.len() <= tracker.samples.len()); + prop_assert_eq!(tracker.sample_order.iter().collect::>().len(), tracker.samples.len()); + prop_assert!(tracker.sample_order.iter().all(|seq| tracker.samples.contains_key(seq))); + prop_assert!(tracker.completed_pairs.iter().all(|seq| { + tracker.samples.contains_key(seq) + && tracker.samples.contains_key(&seq.wrapping_sub(1)) + }), "completed pairs must retain both sequence endpoints"); } } - /// 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/ipdv.rs b/crates/irtt-stats/tests/ipdv.rs new file mode 100644 index 0000000..2480e1a --- /dev/null +++ b/crates/irtt-stats/tests/ipdv.rs @@ -0,0 +1,112 @@ +use std::time::{Duration, Instant, UNIX_EPOCH}; + +use irtt_client::{ + ClientEvent, ClientTimestamp, PacketMeta, RttSample, ServerTiming, SignedDuration, +}; +use irtt_stats::{StatsCollector, StatsConfig}; + +fn reply( + base: Instant, + seq: u32, + send_ms: u64, + receive_ms: u64, + timing: ServerTiming, +) -> ClientEvent { + let raw = Duration::from_millis(receive_ms - send_ms); + ClientEvent::EchoReply { + seq, + remote: "127.0.0.1:2112".parse().unwrap(), + sent_at: ClientTimestamp { + mono: base + Duration::from_millis(send_ms), + wall: UNIX_EPOCH + Duration::from_millis(200 + u64::from(seq) * 10), + }, + received_at: ClientTimestamp { + mono: base + Duration::from_millis(receive_ms), + wall: UNIX_EPOCH + Duration::from_millis(240 + u64::from(seq) * 20), + }, + rtt: RttSample { + raw, + adjusted: None, + effective: SignedDuration::from_duration(raw), + }, + server_timing: Some(timing), + one_way: None, + received_stats: None, + bytes: 64, + packet_meta: PacketMeta::default(), + } +} + +#[test] +fn directional_ipdv_uses_matching_clock_domains_and_preserves_zero_and_absence() { + let first = ServerTiming { + receive_mono_ns: Some(100_000_000), + send_mono_ns: Some(101_000_000), + receive_wall_ns: Some(1_000_000_000), + send_wall_ns: Some(1_001_000_000), + midpoint_mono_ns: None, + midpoint_wall_ns: None, + processing: None, + }; + let second = ServerTiming { + receive_mono_ns: Some(105_000_000), + send_mono_ns: Some(108_000_000), + receive_wall_ns: Some(1_020_000_000), + send_wall_ns: Some(1_026_000_000), + ..first + }; + // Wall and monotonic deltas deliberately disagree: availability alone + // cannot prove the formulas or which clock domain wins. + let mut wall_only = second; + wall_only.receive_mono_ns = None; + wall_only.send_mono_ns = None; + let mut no_send = wall_only; + no_send.receive_wall_ns = None; + let mut no_receive = wall_only; + no_receive.send_wall_ns = None; + let zero = ServerTiming { + receive_mono_ns: Some(110_000_000), + send_mono_ns: Some(111_000_000), + ..second + }; + for (label, current, send_ms, expected_send_ms, expected_receive_ms) in [ + ("monotonic preferred", second, 30, Some(5), Some(3)), + ("wall fallback", wall_only, 30, Some(10), Some(5)), + ("missing send endpoint", no_send, 30, None, Some(5)), + ("missing receive endpoint", no_receive, 30, Some(10), None), + ("reordered client send times", second, 10, Some(15), Some(3)), + ("zero variation", zero, 30, Some(0), Some(0)), + ] { + let base = Instant::now(); + let mut collector = StatsCollector::new(StatsConfig::finite()); + assert!(collector + .process(&reply(base, 0, 20, 40, first)) + .ipdv_pairs + .is_empty()); + let update = collector.process(&reply(base, 1, send_ms, 50, current)); + assert_eq!(update.ipdv_pairs.len(), 1, "{label}"); + let pair = &update.ipdv_pairs[0]; + assert_eq!( + pair.send_ipdv, + expected_send_ms.map(Duration::from_millis), + "{label}" + ); + assert_eq!( + pair.receive_ipdv, + expected_receive_ms.map(Duration::from_millis), + "{label}" + ); + let snapshot = collector.snapshot(); + for (expected, actual) in [ + (expected_send_ms, snapshot.ipdv.send), + (expected_receive_ms, snapshot.ipdv.receive), + ] { + assert_eq!(actual.count, u64::from(expected.is_some()), "{label}"); + assert_eq!( + actual.total_ns, + i128::from(expected.unwrap_or(0)) * 1_000_000, + "{label}" + ); + } + } +} diff --git a/crates/irtt-stats/tests/rolling_properties.rs b/crates/irtt-stats/tests/rolling_properties.rs index 52a60b2..91222e5 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 have non-decreasing normalized window timestamps. use std::time::{Duration, Instant, UNIX_EPOCH}; @@ -208,7 +170,7 @@ fn build_event(op: &Op, seq: u32, clock_ms: u64, base: Instant) -> ClientEvent { Op::Loss => ClientEvent::EchoLoss { seq, sent_at: ts_at(base, clock_ms), - timeout_at: ts_at(base, clock_ms).mono, + timeout_at: ts_at(base, clock_ms).mono - Duration::from_millis(10), }, Op::Warning => ClientEvent::Warning { kind: irtt_client::WarningKind::UntrackedReply, @@ -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, @@ -275,6 +233,11 @@ proptest! { // scratch after every step. let mut history: Vec<(ClientEvent, u64)> = Vec::new(); + // Force a loss whose clamped time evicts the initial send, while its + // raw pre-send deadline would retain it. Every generated case must + // therefore distinguish the two normalization choices. + let ops = [Op::Send, Op::Advance(u16::try_from(time_limit_ms + 5).unwrap()), Op::Loss, Op::Advance(10), Op::Warning] + .into_iter().chain(ops).collect::>(); for (idx, op) in ops.iter().enumerate() { if let Op::Advance(delta) = op { clock_ms += u64::from(*delta); diff --git a/crates/irtt-stats/tests/stats.rs b/crates/irtt-stats/tests/stats.rs index 4818210..6a2d1e5 100644 --- a/crates/irtt-stats/tests/stats.rs +++ b/crates/irtt-stats/tests/stats.rs @@ -319,6 +319,19 @@ fn ipdv_wraparound_sequence_is_adjacent() { assert!(wrapped.contributed_sample); assert_one_ipdv_pair(&wrapped, u32::MAX, 0, Duration::from_millis(4)); + // The client classifies duplicate datagrams before publishing events; + // their accounting must not emit an adjacent pair a second time. + for seq in [u32::MAX, 0] { + let duplicate = collector.process(&ClientEvent::DuplicateReply { + seq, + remote: "127.0.0.1:2112".parse().unwrap(), + received_at: ts(30), + bytes: 64, + }); + assert!(!duplicate.contributed_sample); + assert_no_ipdv_pairs(&duplicate); + } + let snapshot = collector.snapshot(); assert_eq!(snapshot.ipdv.round_trip.count, 1); assert_eq!(snapshot.ipdv.round_trip.total_ns, 4_000_000); @@ -355,19 +368,6 @@ fn late_reply_can_complete_ipdv_pair() { assert_eq!(snapshot.ipdv.round_trip.total_ns, 10_000_000); } -#[test] -fn update_exposes_directional_ipdv_when_available() { - let mut collector = StatsCollector::new(StatsConfig::finite()); - collector.process(&unadjusted_reply(0, 10)); - let update = collector.process(&unadjusted_reply(1, 13)); - - assert!(update.contributed_sample); - - let pair = assert_one_ipdv_pair(&update, 0, 1, Duration::from_millis(3)); - assert!(pair.send_ipdv.is_some()); - assert!(pair.receive_ipdv.is_some()); -} - #[test] fn gap_fill_update_exposes_both_completed_ipdv_pairs() { let mut collector = StatsCollector::new(StatsConfig::finite());