tests: clean up more tests across the project
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 33s
Rust CI / cargo clippy (push) Successful in 34s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Failing after 33s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo doc (push) Successful in 22s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 27s
Supply chain / cargo-deny advisories (push) Successful in 7s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / actionlint (push) Successful in 3s
Netem Interop / netem interop (push) Successful in 1m3s

This commit is contained in:
Nils Andreas Svee committed 2026-10-02 12:47:27 +07:00
1 parent dc5b19059e
commit 74e7532965
17 files changed
+27 -1434

No files matched your search

+12 -102
View File
@@ -1264,15 +1264,6 @@ mod tests {
assert_eq!(header.split_whitespace().next(), Some("target")); assert_eq!(header.split_whitespace().next(), Some("target"));
} }
#[test]
fn default_structured_columns_always_include_target_first() {
for format in [OutputFormat::Csv, OutputFormat::Tsv, OutputFormat::Jsonl] {
let columns = default_columns(format, false);
assert_eq!(columns.first(), Some(&Column::Target));
assert_eq!(columns, ALL_COLUMNS);
}
}
#[test] #[test]
fn columns_default_keyword_matches_omitted_columns() { fn columns_default_keyword_matches_omitted_columns() {
for format in [ for format in [
@@ -1329,83 +1320,24 @@ mod tests {
assert!(line.starts_with("{\"target\":\"ams\"")); assert!(line.starts_with("{\"target\":\"ams\""));
} }
#[test]
fn truncate_for_table_leaves_short_value_unchanged() {
assert_eq!(truncate_for_table("ams", 18), "ams");
}
#[test]
fn truncate_for_table_leaves_exact_width_value_unchanged() {
let value = "a".repeat(18);
assert_eq!(truncate_for_table(&value, 18), value);
}
#[test]
fn truncate_for_table_shortens_long_value_to_width() {
let value = "a".repeat(40);
let truncated = truncate_for_table(&value, 18);
assert_eq!(truncated.chars().count(), 18);
assert!(truncated.len() <= 18); // ASCII: byte len matches char len
}
#[test]
fn truncate_for_table_keeps_prefix_and_appends_ellipsis_deterministically() {
let value = "abcdefghijklmnopqrstuvwxyz";
let truncated = truncate_for_table(value, 18);
assert_eq!(truncated, "abcdefghijklmno...");
assert_eq!(truncated.chars().count(), 18);
}
#[test]
fn truncate_for_table_does_not_split_multibyte_code_points() {
// Each character below is multibyte in UTF-8; a byte-index slice
// would panic or corrupt the string on a non-boundary cut.
let value = "\u{1F600}".repeat(40); // 😀 repeated, well past width 18
let truncated = truncate_for_table(&value, 18);
// Must not panic (validated by test completing) and must remain
// valid UTF-8 with exactly 18 chars (15 prefix chars + "...").
assert_eq!(truncated.chars().count(), 18);
assert!(truncated.ends_with("..."));
assert_eq!(truncated.chars().filter(|c| *c == '\u{1F600}').count(), 15);
}
#[test] #[test]
fn table_row_with_long_target_keeps_next_column_aligned() { fn table_row_with_long_target_keeps_next_column_aligned() {
let config = output_config(OutputFormat::Table, None); let config = output_config(OutputFormat::Table, None);
let long_target = "a".repeat(40);
let header = config.render_header().unwrap(); 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 let line = config
.render_event( .render_event(&reply(), Some(&label), Some(&EventRenderStats::default()))
&reply(),
Some(&long_target),
Some(&EventRenderStats::default()),
)
.unwrap(); .unwrap();
let prefix = line.split("echo_reply").next().unwrap();
// Target column is truncated to its table width (18), so the assert_eq!(prefix.chars().count(), event_offset, "{line}");
// "event" column starts at the same offset for both a normal and an if label.chars().count() > event_offset {
// overlong target label. assert!(prefix.trim_end().ends_with("..."), "{line}");
let short_config = output_config(OutputFormat::Table, None); } else {
let short_line = short_config assert!(prefix.starts_with(&label), "{line}");
.render_event(&reply(), Some("ams"), Some(&EventRenderStats::default())) }
.unwrap(); }
let target_width = Column::Target.table_width();
let expected_event_offset = target_width + " ".len();
assert_eq!(&header[expected_event_offset..][..5], "event");
// The event column ("echo_reply") starts at the same offset
// regardless of whether the target label was short or overlong.
assert_eq!(
&line[expected_event_offset..][.."echo_reply".len()],
"echo_reply"
);
assert_eq!(
&short_line[expected_event_offset..][.."echo_reply".len()],
"echo_reply"
);
} }
#[test] #[test]
@@ -1452,26 +1384,4 @@ mod tests {
assert_eq!(line, format!("{{\"target\":\"{long_target}\",\"seq\":7}}")); assert_eq!(line, format!("{{\"target\":\"{long_target}\",\"seq\":7}}"));
} }
#[test]
fn explicit_table_columns_with_target_truncate_same_as_default() {
let config = output_config(OutputFormat::Table, Some("target,seq"));
let long_target = "d".repeat(40);
let line = config
.render_event(
&reply(),
Some(&long_target),
Some(&EventRenderStats::default()),
)
.unwrap();
let target_width = Column::Target.table_width();
let expected_prefix: String = long_target
.chars()
.take(target_width - 3)
.chain("...".chars())
.collect();
assert!(line.starts_with(&expected_prefix));
assert_eq!(&line[..target_width], expected_prefix.as_str());
}
} }
-32
View File
@@ -1052,26 +1052,6 @@ mod tests {
assert!(mailbox.latest().is_none()); assert!(mailbox.latest().is_none());
} }
#[test]
fn eof_and_shutdown_stop_without_waiting_for_the_stdin_reader() {
let mailbox = StdinMailbox::default();
let shutdown_requested = AtomicBool::new(false);
assert!(stdin_stop_request(&shutdown_requested, &mailbox).is_none());
mailbox.finish();
assert!(matches!(
stdin_stop_request(&shutdown_requested, &mailbox),
Some(StdinStop::Eof)
));
let mailbox = StdinMailbox::default();
shutdown_requested.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(matches!(
stdin_stop_request(&shutdown_requested, &mailbox),
Some(StdinStop::Interrupted)
));
}
fn status_with_targets(targets: &[TargetInstance]) -> ManagedStatus { fn status_with_targets(targets: &[TargetInstance]) -> ManagedStatus {
ManagedStatus { ManagedStatus {
lifecycle: ManagedLifecycle::Running, lifecycle: ManagedLifecycle::Running,
@@ -1209,18 +1189,6 @@ mod tests {
assert!(finite_stats_memory_warning(&long_finite_args(), usize::MAX).is_some()); assert!(finite_stats_memory_warning(&long_finite_args(), usize::MAX).is_some());
} }
#[test]
fn lagged_events_are_counted_saturatingly() {
let mut dropped = 0_u64;
dropped = dropped.saturating_add(7);
dropped = dropped.saturating_add(3);
assert_eq!(dropped, 10);
assert_eq!(u64::MAX.saturating_add(1), u64::MAX);
assert!(dropped_event_warning(dropped)
.unwrap()
.contains("dropped 10 managed run events"));
}
#[test] #[test]
fn target_failure_diagnostics_include_primary_and_cleanup_failures() { fn target_failure_diagnostics_include_primary_and_cleanup_failures() {
let target = ManagedTargetOutcome { let target = ManagedTargetOutcome {
-44
View File
@@ -2,8 +2,6 @@ use std::time::Duration;
use clap::Parser; use clap::Parser;
#[cfg(test)]
use crate::shared::client::TimestampArg;
use crate::shared::client::{ use crate::shared::client::{
parse_target, parse_test_duration, prepare_managed_run, CommonClientArgs, GroupPacingArg, parse_target, parse_test_duration, prepare_managed_run, CommonClientArgs, GroupPacingArg,
ManagedRunSetup, TargetArg, TargetSelection, ManagedRunSetup, TargetArg, TargetSelection,
@@ -66,11 +64,6 @@ impl TuiArgs {
pub fn is_continuous(&self) -> bool { pub fn is_continuous(&self) -> bool {
self.duration == Duration::ZERO self.duration == Duration::ZERO
} }
#[cfg(test)]
pub fn timestamp_mode(&self) -> TimestampArg {
self.common.tstamp
}
} }
impl std::ops::Deref for TuiArgs { impl std::ops::Deref for TuiArgs {
@@ -156,35 +149,6 @@ mod tests {
assert!(parse(&["ams=ams.example.com:2112"]).is_ok()); assert!(parse(&["ams=ams.example.com:2112"]).is_ok());
} }
#[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] #[test]
fn old_target_option_is_rejected() { fn old_target_option_is_rejected() {
assert!(parse(&["--target", "eu=host.example"]).is_err()); assert!(parse(&["--target", "eu=host.example"]).is_err());
@@ -270,14 +234,6 @@ mod tests {
assert_eq!(shared.socket_config.ttl, tui.socket_config.ttl); assert_eq!(shared.socket_config.ttl, tui.socket_config.ttl);
} }
#[test]
fn an_empty_hmac_key_is_rejected_via_the_shared_args() {
// CommonClientArgs is flattened into both the client and TUI parsers,
// so proving rejection once here covers both without duplicating it.
let err = parse(&["--hmac", "", "127.0.0.1:2112"]).unwrap_err();
assert!(err.to_string().contains("HMAC key must not be empty"));
}
#[test] #[test]
fn tui_help_lists_multi_target_options() { fn tui_help_lists_multi_target_options() {
let help = TuiArgs::command().render_help().to_string(); let help = TuiArgs::command().render_help().to_string();
-99
View File
@@ -2057,19 +2057,6 @@ mod tests {
} }
} }
fn graph_sample_with_timing(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 + 500),
client_to_server_ns: Some(effective_ns / 3),
server_to_client_ns: Some(effective_ns / 2),
server_processing_ns: Some(100_000),
}
}
fn series(data: Vec<(f64, f64)>) -> ChartSeries { fn series(data: Vec<(f64, f64)>) -> ChartSeries {
ChartSeries { ChartSeries {
name: GraphMetric::EffectiveRtt.label().to_owned(), name: GraphMetric::EffectiveRtt.label().to_owned(),
@@ -2131,18 +2118,6 @@ mod tests {
assert_eq!(format_span(Duration::from_secs(90)), "1m30s"); assert_eq!(format_span(Duration::from_secs(90)), "1m30s");
} }
#[test]
fn formats_signed_durations_and_missing_values() {
assert_eq!(format_optional_ns_i128(Some(-1_500_000)), "-1.5ms");
assert_eq!(format_optional_ns_i128(Some(750)), "750ns");
assert_eq!(format_optional_ns_i128(None), "-");
assert_eq!(format_optional_ns_i128(Some(1_500)), "1.5µs");
assert_eq!(format_optional_span(None), "-");
assert_eq!(format_duration(Duration::from_millis(25)), "25.0ms");
assert_eq!(format_percent_ratio(1, 4), "25.00%");
assert_eq!(format_optional_hex(Some(0x1f)), "0x1f");
}
#[test] #[test]
fn session_started_sets_remote_session_and_running_status() { fn session_started_sets_remote_session_and_running_status() {
let mut state = TuiState::default(); let mut state = TuiState::default();
@@ -2636,13 +2611,6 @@ mod tests {
assert_eq!(state.recent_events.front().unwrap(), "event 5"); assert_eq!(state.recent_events.front().unwrap(), "event 5");
} }
#[test]
fn recent_event_visible_count_tracks_panel_inner_height() {
assert_eq!(recent_events_visible_count(0), 0);
assert_eq!(recent_events_visible_count(2), 0);
assert_eq!(recent_events_visible_count(9), 7);
}
#[test] #[test]
fn visible_window_selects_samples_inside_viewport() { fn visible_window_selects_samples_inside_viewport() {
let history: VecDeque<_> = (0..300).map(|seq| graph_sample(seq, seq.into())).collect(); let history: VecDeque<_> = (0..300).map(|seq| graph_sample(seq, seq.into())).collect();
@@ -2722,20 +2690,6 @@ mod tests {
assert!(max_y > 12.0, "max_y={max_y}"); assert!(max_y > 12.0, "max_y={max_y}");
} }
#[test]
fn y_axis_tick_labels_use_label_count_minus_one_spacing() {
let labels = y_axis_labels(-1.0, 1.0, 5);
let rendered = labels
.iter()
.map(|span| span.content.as_ref())
.collect::<Vec<_>>();
assert_eq!(
rendered,
vec!["-1.00ms", "-500µs", "0.0µs", "500µs", "1.00ms"]
);
}
#[test] #[test]
fn optional_missing_one_way_series_are_omitted_not_zero_filled() { fn optional_missing_one_way_series_are_omitted_not_zero_filled() {
let visible_samples = [graph_sample(1, 2_000_000)]; let visible_samples = [graph_sample(1, 2_000_000)];
@@ -2750,22 +2704,6 @@ mod tests {
assert_eq!(rtt[0].name, "effective RTT"); assert_eq!(rtt[0].name, "effective RTT");
} }
#[test]
fn graph_metrics_use_readable_default_series() {
let visible_samples = [graph_sample_with_timing(1, 3_000_000)];
let visible: Vec<_> = visible_samples.iter().collect();
let viewport = viewport_for_visible(&visible);
assert_eq!(
graph_series(GraphMetric::EffectiveRtt, &visible, viewport)[0].data,
vec![(0.0, 3.0)]
);
assert_eq!(
graph_series(GraphMetric::ClientToServer, &visible, viewport)[0].data,
vec![(0.0, 1.0)]
);
}
#[test] #[test]
fn multi_target_metric_series_uses_selected_sample_field() { fn multi_target_metric_series_uses_selected_sample_field() {
let mut target = TuiTargetState::new("alpha".to_owned(), stats_config(true)); let mut target = TuiTargetState::new("alpha".to_owned(), stats_config(true));
@@ -2848,43 +2786,6 @@ mod tests {
); );
} }
#[test]
fn graph_metric_cycling_walks_all_metrics() {
let cases = [
GraphMetric::EffectiveRtt,
GraphMetric::RawRtt,
GraphMetric::AdjustedRtt,
GraphMetric::ClientToServer,
GraphMetric::ServerToClient,
GraphMetric::ServerProcessing,
GraphMetric::EffectiveRtt,
];
let mut state = TuiState::default();
for metric in cases {
assert_eq!(state.graph_metric, metric);
state.cycle_graph_metric();
}
}
#[test]
fn view_toggle_switches_between_graph_and_dashboard() {
let mut state = TuiState::default();
assert_eq!(state.view, TuiView::Graph);
state.toggle_view();
assert_eq!(state.view, TuiView::Dashboard);
state.toggle_view();
assert_eq!(state.view, TuiView::Graph);
}
#[test]
fn target_label_span_uses_graph_series_style() {
let span = target_label_span("alpha-target", 3, 16);
assert_eq!(span.style, target_style(3).add_modifier(Modifier::BOLD));
assert_eq!(span.content.as_ref(), "alpha-target ");
}
#[test] #[test]
fn pause_suppresses_scheduled_renders_but_allows_forced_controls() { fn pause_suppresses_scheduled_renders_but_allows_forced_controls() {
let now = Instant::now(); let now = Instant::now();
@@ -121,156 +121,3 @@ pub fn prepare_managed_run(
stdin_controlled: selection.stdin_controlled, stdin_controlled: selection.stdin_controlled,
}) })
} }
#[cfg(test)]
mod tests {
use clap::Parser;
use super::*;
#[derive(Parser)]
struct CommonOnlyArgs {
#[command(flatten)]
common: CommonClientArgs,
}
fn common(args: &[&str]) -> CommonClientArgs {
let mut argv = vec!["common-only"];
argv.extend_from_slice(args);
CommonOnlyArgs::try_parse_from(argv).unwrap().common
}
fn unlabeled(addr: &str) -> TargetArg {
TargetArg::new(addr)
}
fn labeled(label: &str, addr: &str) -> TargetArg {
TargetArg::new(format!("{label}={addr}"))
}
fn prepare(
targets: &[TargetArg],
pacing: GroupPacingArg,
duration: Duration,
) -> Result<ManagedRunSetup, String> {
prepare_managed_run(
&common(&[]),
duration,
TargetSelection {
targets,
pacing,
stdin_controlled: false,
},
)
}
#[test]
fn a_single_target_prepares_one_managed_target_and_a_finite_duration() {
let setup = prepare(
&[unlabeled("127.0.0.1:2112")],
GroupPacingArg::Staggered,
Duration::from_secs(30),
)
.unwrap();
assert_eq!(setup.target_count(), 1);
assert!(!setup.is_multi_target());
assert_eq!(setup.targets[0].label, "127.0.0.1:2112");
assert_eq!(setup.targets[0].managed.server_addr, "127.0.0.1:2112");
assert_eq!(setup.client.duration, Some(Duration::from_secs(30)));
assert_eq!(setup.pacing, ManagedPacing::Staggered);
}
#[test]
fn a_zero_duration_prepares_a_continuous_client_config() {
let setup = prepare(
&[unlabeled("127.0.0.1:2112")],
GroupPacingArg::Staggered,
Duration::ZERO,
)
.unwrap();
assert_eq!(setup.client.duration, None);
}
#[test]
fn multiple_targets_keep_argument_order_and_share_one_client_config() {
let setup = prepare(
&[
unlabeled("host-a:2112"),
unlabeled("host-b:2112"),
labeled("sg", "sg.example.test:2112"),
],
GroupPacingArg::Burst,
Duration::from_secs(10),
)
.unwrap();
assert!(setup.is_multi_target());
let labels: Vec<_> = setup
.targets
.iter()
.map(|target| target.label.as_str())
.collect();
assert_eq!(labels, ["host-a:2112", "host-b:2112", "sg"]);
assert_eq!(setup.targets[2].managed.server_addr, "sg.example.test:2112");
assert_eq!(setup.pacing, ManagedPacing::Burst);
let config = setup.managed_config();
assert_eq!(config.outcome_history_limit, 3);
assert_eq!(config.max_live_target_generations, 3);
assert_eq!(config.event_capacity, MANAGED_EVENT_CAPACITY);
assert_eq!(config.pacing, ManagedPacing::Burst);
assert_eq!(config.client, setup.client);
assert_eq!(setup.managed_targets().len(), 3);
}
#[test]
fn the_shared_client_config_carries_no_target_of_its_own() {
// The managed driver replaces the address, and the CLI's key, per
// target, so the template must not pin either to one target.
let targets = vec![unlabeled("host-a:2112"), unlabeled("host-b:2112")];
let setup = prepare_managed_run(
&common(&["--hmac", "secret"]),
Duration::from_secs(10),
TargetSelection {
targets: &targets,
pacing: GroupPacingArg::Staggered,
stdin_controlled: false,
},
)
.unwrap();
assert_eq!(
setup.client.server_addr,
ClientConfig::default().server_addr
);
assert_eq!(setup.client.hmac_key, Some(b"secret".to_vec()));
// Per-target authentication is not exposed on the command line, so no
// target carries an override of the shared key.
assert!(setup
.targets
.iter()
.all(|target| target.managed.auth.is_none()));
}
#[test]
fn an_empty_target_set_is_rejected_with_the_list_columns_hint() {
let err = prepare(&[], GroupPacingArg::Staggered, Duration::from_secs(10)).unwrap_err();
assert!(err.contains("at least one target is required"));
assert!(err.contains("--list-columns"));
}
#[test]
fn a_duplicate_label_is_rejected_before_any_configuration_is_built() {
let targets = vec![
unlabeled("host-a:2112"),
labeled("host-a:2112", "host-b:2112"),
];
let err =
prepare(&targets, GroupPacingArg::Staggered, Duration::from_secs(10)).unwrap_err();
assert!(err.contains("duplicate target label"));
}
}
+4 -71
View File
@@ -1,74 +1,7 @@
use super::*; use super::*;
#[test] #[test]
fn params_from_config_maps_compatibility_fields() { fn connect_rejects_invalid_configuration() {
let config = ClientConfig {
duration: Some(Duration::from_secs(5)),
interval: Duration::from_millis(250),
length: 1472,
received_stats: ReceivedStats::Window,
stamp_at: StampAt::Midpoint,
clock: Clock::Wall,
dscp: 46,
hmac_key: Some(b"secret".to_vec()),
server_fill: Some("rand".to_owned()),
..ClientConfig::default()
};
let params = params_from_config(&config).unwrap();
assert_eq!(params.protocol_version, PROTOCOL_VERSION);
assert_eq!(params.duration_ns, 5_000_000_000);
assert_eq!(params.interval_ns, 250_000_000);
assert_eq!(params.length, 1472);
assert_eq!(params.received_stats, ReceivedStats::Window);
assert_eq!(params.stamp_at, StampAt::Midpoint);
assert_eq!(params.clock, Clock::Wall);
assert_eq!(
params.dscp, 184,
"config DSCP codepoint 46 must be shifted into raw wire byte 184"
);
assert_eq!(
params.server_fill.as_ref().map(|fill| fill.value.as_str()),
Some("rand")
);
assert_eq!(config.hmac_key.as_deref(), Some(b"secret".as_slice()));
}
#[test]
fn params_from_config_accepts_boundary_values() {
for length in [0, 1, 1472, 4096, MAX_UDP_PAYLOAD_LENGTH] {
let config = ClientConfig {
length,
..ClientConfig::default()
};
assert_eq!(
params_from_config(&config).unwrap().length,
i64::from(length)
);
}
let config = ClientConfig {
dscp: 63,
..ClientConfig::default()
};
assert_eq!(
params_from_config(&config).unwrap().dscp,
252,
"boundary codepoint 63 must be shifted into raw wire byte 252"
);
}
#[test]
fn params_from_config_encodes_continuous_duration_as_zero() {
let config = ClientConfig {
duration: None,
..ClientConfig::default()
};
assert_eq!(params_from_config(&config).unwrap().duration_ns, 0);
}
#[test]
fn params_from_config_rejects_invalid_values() {
let i64_max_ns = u64::try_from(i64::MAX).unwrap(); let i64_max_ns = u64::try_from(i64::MAX).unwrap();
let too_large = Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1); let too_large = Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1);
let cases = [ let cases = [
@@ -151,10 +84,10 @@ fn params_from_config_rejects_invalid_values() {
for (name, config, expected_reason) in cases { for (name, config, expected_reason) in cases {
assert!( assert!(
matches!( matches!(
params_from_config(&config), Client::connect(config),
Err(ClientError::InvalidConfig { reason }) if reason.contains(expected_reason) Err(ClientError::InvalidConfig { .. })
), ),
"{name} should fail with InvalidConfig containing {expected_reason:?}" "{name} should fail with InvalidConfig ({expected_reason})"
); );
} }
} }
+1 -121
View File
@@ -1,6 +1,5 @@
use std::{ use std::{
future::{poll_fn, Future}, future::{poll_fn, Future},
mem,
net::{SocketAddr, UdpSocket}, net::{SocketAddr, UdpSocket},
pin::Pin, pin::Pin,
sync::{Arc, Condvar, Mutex}, sync::{Arc, Condvar, Mutex},
@@ -16,7 +15,7 @@ use irtt_proto::{
use tokio::runtime::{Builder, Runtime}; use tokio::runtime::{Builder, Runtime};
use super::*; use super::*;
use crate::{ClientAuthConfig, NegotiationPolicy, RunMode}; use crate::{ClientAuthConfig, RunMode};
const TOKEN: u64 = 0x1234_5678_90ab_cdef; const TOKEN: u64 = 0x1234_5678_90ab_cdef;
@@ -766,52 +765,6 @@ fn terminal_targets_count_toward_retained_generation_limit() {
assert!(task.snapshot().targets.is_empty()); assert!(task.snapshot().targets.is_empty());
} }
#[test]
fn pruning_leaves_no_stale_target_work() {
let (mut task, _) = ManagedClient::task(
config(ManagedPacing::Staggered),
vec![
ManagedTargetConfig::new("one", "127.0.0.1:9"),
ManagedTargetConfig::new("two", "127.0.0.1:10"),
ManagedTargetConfig::new("three", "127.0.0.1:11"),
],
)
.unwrap();
task.cursor = 2;
task.timeout_cursor = 2;
task.send_cursor = 2;
task.scan_remaining = 3;
task.burst_remaining = 3;
task.stagger_remaining = 3;
task.targets[2].desired = false;
task.targets[2].state = TargetState::Terminal;
task.prune_undesired_terminal();
// Timeout discovery indexes `targets` with `timeout_cursor` directly, so a
// prune must leave it safe to use; the target and send passes modulo their
// own cursors. Pending per-pass work must not outlive the targets it was
// scheduled for either.
assert_eq!(task.targets.len(), 2);
assert!(task.timeout_cursor < task.targets.len());
assert!(task.scan_remaining <= task.targets.len());
assert!(task.burst_remaining <= task.targets.len());
assert!(task.stagger_remaining <= task.targets.len());
assert!(!task.poll_timeout_pass(Instant::now()));
for target in &mut task.targets {
target.desired = false;
target.state = TargetState::Terminal;
}
task.prune_undesired_terminal();
assert!(task.targets.is_empty());
assert_eq!(task.scan_remaining, 0);
assert_eq!(task.burst_remaining, 0);
assert_eq!(task.stagger_remaining, 0);
assert!(!task.poll_timeout_pass(Instant::now()));
}
#[test] #[test]
fn dynamic_rejections_leave_transaction_state_unchanged() { fn dynamic_rejections_leave_transaction_state_unchanged() {
let mut managed = config(ManagedPacing::Staggered); let mut managed = config(ManagedPacing::Staggered);
@@ -1562,79 +1515,6 @@ fn staggered_pacing_fairness() {
second.finish(); second.finish();
} }
#[test]
fn stagger_gate_tracks_active_membership() {
let first =
start_server_negotiating(ServerBehavior::Echo, None, Some(Duration::from_millis(100)));
let second_gate = Arc::new(PacketGate::default());
let second = start_server_with_gates(
ServerBehavior::Echo,
None,
Some(Duration::from_secs(1)),
Some(Arc::clone(&second_gate)),
None,
);
let mut managed = config(ManagedPacing::Staggered);
managed.client.duration = Some(Duration::from_millis(1_250));
managed.client.interval = Duration::from_secs(1);
managed.client.negotiation_policy = NegotiationPolicy::Loose;
managed.client.open_timeouts = vec![Duration::from_secs(2)];
let (task, handle) = ManagedClient::task(
managed,
vec![target("first", first.addr), target("second", second.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
});
let one_target_last = task.last_stagger_send.unwrap();
let one_target_gate = task.send_gate.unwrap();
assert_eq!(
task.send_gate,
one_target_last.checked_add(Duration::from_millis(100))
);
second_gate.release();
drive_task_until(&runtime, &mut task, |task| task.active_count() == 2);
let last = task.last_stagger_send.unwrap();
let gate = task.send_gate.unwrap();
assert_eq!(gate, last.checked_add(Duration::from_millis(50)).unwrap());
assert!(gate <= one_target_gate);
assert!(gate > last && gate < last.checked_add(Duration::from_millis(100)).unwrap());
drive_task_until(&runtime, &mut task, |task| {
task.targets[1].counters.packets_sent == 1
});
let accepted_at = Instant::now();
task.record_stagger_acceptance(
SendResult::Failed { accepted: true },
Some(Duration::from_millis(50)),
accepted_at,
);
assert_eq!(
task.send_gate,
accepted_at.checked_add(Duration::from_millis(50))
);
let preserved_gate = task.send_gate;
let TargetState::Active { client } =
mem::replace(&mut task.targets[0].state, TargetState::Terminal)
else {
panic!("fast target was not active before removal");
};
assert!(task.begin_drain(0, client, ManagedTargetEndReason::Stopped, Instant::now()));
assert_eq!(task.send_gate, preserved_gate);
drive_task_until(&runtime, &mut task, |task| {
task.targets[1].counters.packets_sent == 2
});
drop(handle.stop());
let outcome = runtime.block_on(task);
assert_eq!(outcome.failed_target_outcomes, 0);
first.finish();
second.finish();
}
/// The stagger gate must not fold each send's observed wakeup latency into /// The stagger gate must not fold each send's observed wakeup latency into
/// every later slot. /// every later slot.
/// ///
-278
View File
@@ -1177,10 +1177,6 @@ mod tests {
} }
} }
fn connected_machine(config: ClientConfig) -> SessionMachine {
SessionMachine::new(config, "127.0.0.1:2112".parse().unwrap()).unwrap()
}
fn normal_open_reply(machine: &SessionMachine, token: u64) -> OpenReply { fn normal_open_reply(machine: &SessionMachine, token: u64) -> OpenReply {
OpenReply { OpenReply {
flags: flags::FLAG_OPEN | flags::FLAG_REPLY, flags: flags::FLAG_OPEN | flags::FLAG_REPLY,
@@ -1189,210 +1185,6 @@ mod tests {
} }
} }
fn encoded_open_reply(
machine: &SessionMachine,
reply: &OpenReply,
) -> Result<Vec<u8>, irtt_proto::ProtoError> {
irtt_proto::encode_open_reply(reply, machine.config.hmac_key.as_deref())
}
#[test]
fn malformed_wrong_direction_and_invalid_flags_are_ignored() {
let machine = connected_machine(ClientConfig::default());
assert!(matches!(
machine.inspect_open_datagram(&[0_u8]),
Ok(OpenDatagramDisposition::Ignore)
));
let request = machine.prepare_open_request().unwrap();
assert!(matches!(
machine.inspect_open_datagram(&request.bytes),
Ok(OpenDatagramDisposition::Ignore)
));
let reply = normal_open_reply(&machine, 0x1020_3040_5060_7080);
let mut reserved = encoded_open_reply(&machine, &reply).unwrap();
reserved[3] |= 0x10;
assert!(matches!(
machine.inspect_open_datagram(&reserved),
Ok(OpenDatagramDisposition::Ignore)
));
assert!(matches!(machine.state, MachineState::Connected));
}
#[test]
fn missing_unexpected_and_bad_hmac_are_ignored() {
let key = b"open-key".to_vec();
let authenticated = connected_machine(ClientConfig {
hmac_key: Some(key.clone()),
..ClientConfig::default()
});
let reply = normal_open_reply(&authenticated, 0x1020_3040_5060_7080);
let plain = irtt_proto::encode_open_reply(&reply, None).unwrap();
assert!(matches!(
authenticated.inspect_open_datagram(&plain),
Ok(OpenDatagramDisposition::Ignore)
));
let mut bad = encoded_open_reply(&authenticated, &reply).unwrap();
*bad.last_mut().unwrap() ^= 0x80;
assert!(matches!(
authenticated.inspect_open_datagram(&bad),
Ok(OpenDatagramDisposition::Ignore)
));
let plain_machine = connected_machine(ClientConfig::default());
let unexpected = irtt_proto::encode_open_reply(&reply, Some(&key)).unwrap();
assert!(matches!(
plain_machine.inspect_open_datagram(&unexpected),
Ok(OpenDatagramDisposition::Ignore)
));
}
#[test]
fn authenticated_zero_token_is_trusted_and_terminal() {
let key = b"open-key".to_vec();
let machine = connected_machine(ClientConfig {
hmac_key: Some(key.clone()),
..ClientConfig::default()
});
let reply = normal_open_reply(&machine, 0x1020_3040_5060_7080);
let mut packet = encoded_open_reply(&machine, &reply).unwrap();
let token_offset = 4 + irtt_proto::HMAC_SIZE;
packet[token_offset..token_offset + 8].fill(0);
irtt_proto::compute_hmac_in_place(&key, &mut packet, 4).unwrap();
assert!(matches!(
machine.inspect_open_datagram(&packet),
Err(ClientError::Protocol(irtt_proto::ProtoError::ZeroToken))
));
assert!(matches!(machine.state, MachineState::Connected));
}
#[test]
fn trusted_invalid_parameter_encoding_is_terminal() {
let machine = connected_machine(ClientConfig::default());
let mut packet = irtt_proto::MAGIC.to_vec();
packet.push(flags::FLAG_OPEN | flags::FLAG_REPLY);
packet.extend_from_slice(&0x1020_3040_5060_7080_u64.to_le_bytes());
packet.push(0x80);
assert!(matches!(
machine.inspect_open_datagram(&packet),
Err(ClientError::Protocol(
irtt_proto::ProtoError::TruncatedVarint
))
));
assert!(matches!(machine.state, MachineState::Connected));
}
#[test]
fn trusted_rejection_version_and_run_mode_failures_do_not_open() {
let machine = connected_machine(ClientConfig::default());
let rejection = OpenReply {
flags: flags::FLAG_OPEN | flags::FLAG_REPLY | flags::FLAG_CLOSE,
token: 0,
params: machine.requested.clone(),
};
assert!(matches!(
machine
.prepare_open_acceptance(rejection, timestamp(Instant::now()))
.unwrap_err()
.primary,
ClientError::ServerRejected
));
let mut version = normal_open_reply(&machine, 0x1020_3040_5060_7080);
version.params.protocol_version += 1;
assert!(matches!(
machine
.prepare_open_acceptance(version, timestamp(Instant::now()))
.unwrap_err()
.primary,
ClientError::ProtocolVersionMismatch { .. }
));
let no_test = connected_machine(ClientConfig {
run_mode: RunMode::NoTest,
..ClientConfig::default()
});
assert!(matches!(
no_test
.prepare_open_acceptance(
normal_open_reply(&no_test, 0x1020_3040_5060_7080),
timestamp(Instant::now()),
)
.unwrap_err()
.primary,
ClientError::UnexpectedNoTestReply
));
assert!(matches!(machine.state, MachineState::Connected));
assert!(matches!(no_test.state, MachineState::Connected));
}
#[test]
fn no_test_non_close_reply_prepares_authenticated_cleanup_without_state_change() {
let key = b"cleanup-key".to_vec();
let machine = connected_machine(ClientConfig {
run_mode: RunMode::NoTest,
hmac_key: Some(key.clone()),
..ClientConfig::default()
});
let token = 0x1020_3040_5060_7080;
let failure = machine
.prepare_open_acceptance(
normal_open_reply(&machine, token),
timestamp(Instant::now()),
)
.unwrap_err();
assert!(matches!(
failure.primary,
ClientError::UnexpectedNoTestReply
));
let cleanup = failure.cleanup_close.as_deref().unwrap();
assert_eq!(cleanup[3], flags::FLAG_CLOSE | flags::FLAG_HMAC);
irtt_proto::verify_hmac(&key, cleanup, 4).unwrap();
assert_eq!(
u64::from_le_bytes(
cleanup[4 + irtt_proto::HMAC_SIZE..12 + irtt_proto::HMAC_SIZE]
.try_into()
.unwrap()
),
token
);
drop(failure);
assert!(matches!(machine.state, MachineState::Connected));
}
#[test]
fn no_test_close_replies_do_not_prepare_cleanup() {
let machine = connected_machine(ClientConfig {
run_mode: RunMode::NoTest,
..ClientConfig::default()
});
let reply = |token| OpenReply {
flags: flags::FLAG_OPEN | flags::FLAG_REPLY | flags::FLAG_CLOSE,
token,
params: machine.requested.clone(),
};
let prepared = machine
.prepare_open_acceptance(reply(0), timestamp(Instant::now()))
.unwrap();
assert!(prepared.cleanup_close_packet().is_none());
let failure = machine
.prepare_open_acceptance(reply(0x1020_3040_5060_7080), timestamp(Instant::now()))
.unwrap_err();
assert!(matches!(
failure.primary,
ClientError::NonZeroNoTestToken { .. }
));
assert!(failure.cleanup_close.is_none());
}
#[test] #[test]
fn uncommitted_probe_preparation_changes_no_authoritative_state() { fn uncommitted_probe_preparation_changes_no_authoritative_state() {
let mut machine = open_machine(4, Duration::from_secs(1)); let mut machine = open_machine(4, Duration::from_secs(1));
@@ -1417,30 +1209,6 @@ mod tests {
assert_eq!(session.pending.len(), 0); assert_eq!(session.pending.len(), 0);
} }
#[test]
fn stale_prepared_probe_is_rejected_without_changing_state() {
let mut machine = open_machine(4, Duration::from_secs(1));
let stale = machine.prepare_probe().unwrap();
let accepted = machine.prepare_probe().unwrap();
let sent_at = timestamp(Instant::now());
let preflight = machine.preflight_probe_commit(&accepted).unwrap();
let commit = machine.finalize_probe_commit(preflight, sent_at).unwrap();
machine.commit_probe_sent(commit, sent_at, accepted.bytes.len());
assert!(matches!(
machine.preflight_probe_commit(&stale),
Err(ClientError::StalePreparedProbe {
prepared_seq: 0,
next_wire_seq: 1,
})
));
let session = active(&machine);
assert_eq!(session.next_wire_seq, 1);
assert_eq!(session.packets_sent, 1);
assert_eq!(session.pending.len(), 1);
}
#[test] #[test]
fn repeated_would_block_style_preflight_commits_once() { fn repeated_would_block_style_preflight_commits_once() {
let mut machine = open_machine(4, Duration::from_secs(1)); let mut machine = open_machine(4, Duration::from_secs(1));
@@ -1462,23 +1230,6 @@ mod tests {
assert_eq!(session.pending.len(), 1); assert_eq!(session.pending.len(), 1);
} }
#[test]
fn pending_capacity_exhaustion_is_detected_before_commit() {
let mut machine = open_machine(1, 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());
let second = machine.prepare_probe().unwrap();
assert!(matches!(
machine.preflight_probe_commit(&second),
Err(ClientError::PendingLimitExceeded { limit: 1 })
));
assert_eq!(active(&machine).packets_sent, 1);
}
#[test] #[test]
fn pending_sequence_collision_is_detected_before_commit() { fn pending_sequence_collision_is_detected_before_commit() {
let mut machine = open_machine(2, Duration::from_secs(1)); let mut machine = open_machine(2, Duration::from_secs(1));
@@ -1525,35 +1276,6 @@ mod tests {
assert_eq!(active(&machine).pending.len(), 0); assert_eq!(active(&machine).pending.len(), 0);
} }
#[test]
fn exhaustive_timeout_polling_returns_every_due_loss() {
let mut machine = open_machine(4, Duration::from_secs(1));
let now = Instant::now();
for seq in [2, 1, 0] {
let sent_at = timestamp(now - Duration::from_secs(u64::from(seq) + 1));
let session = active_mut(&mut machine);
session.pending.preflight_insert(seq).unwrap();
session.pending.commit_insert(PendingProbe {
wire_seq: seq,
sent_at,
timeout_at: sent_at.mono + Duration::from_secs(1),
tx_not_before_wall: sent_at.wall,
kernel_tx_timestamp: None,
});
}
let events = machine.poll_timeouts_at(now).unwrap();
assert!(matches!(
events.as_slice(),
[
ClientEvent::EchoLoss { seq: 2, .. },
ClientEvent::EchoLoss { seq: 1, .. },
ClientEvent::EchoLoss { seq: 0, .. },
]
));
assert_eq!(active(&machine).pending.len(), 0);
}
#[test] #[test]
fn wrapping_sequence_from_max_to_zero_remains_valid() { fn wrapping_sequence_from_max_to_zero_remains_valid() {
let mut machine = open_machine(2, Duration::from_secs(1)); let mut machine = open_machine(2, Duration::from_secs(1));
@@ -396,73 +396,6 @@ mod tests {
} }
} }
#[test]
fn strict_rejects_changed_negotiated_fields() {
let requested = default_params();
let mut returned = requested.clone();
returned.length = 128;
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::LengthReduced {
requested: requested.length,
negotiated: returned.length,
}
.message()
);
let mut returned = requested.clone();
returned.dscp = 0;
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::DscpChanged {
requested: requested.dscp >> 2,
negotiated: returned.dscp >> 2,
}
.message()
);
let mut returned = requested.clone();
returned.received_stats = ReceivedStats::Count;
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::ReceivedStatsChanged {
requested: requested.received_stats,
negotiated: returned.received_stats,
}
.message()
);
let mut returned = requested.clone();
returned.stamp_at = StampAt::Midpoint;
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::StampAtChanged {
requested: requested.stamp_at,
negotiated: returned.stamp_at,
}
.message()
);
let mut returned = requested.clone();
returned.clock = Clock::Wall;
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::ClockChanged {
requested: requested.clock,
negotiated: returned.clock,
}
.message()
);
let mut returned = requested.clone();
returned.server_fill = None;
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::ServerFillChanged.message()
);
}
#[test] #[test]
fn loose_duration_negotiation_uses_run_duration_semantics() { fn loose_duration_negotiation_uses_run_duration_semantics() {
let requested = default_params(); let requested = default_params();
-19
View File
@@ -174,25 +174,6 @@ mod tests {
assert_eq!(normalize_server_addr("[::1]:1234"), "[::1]:1234"); assert_eq!(normalize_server_addr("[::1]:1234"), "[::1]:1234");
} }
#[test]
fn address_family_filtering_remains_unchanged() {
let ipv4 = SocketAddr::from(([127, 0, 0, 1], DEFAULT_PORT));
let ipv6 = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], DEFAULT_PORT));
let mut config = ClientConfig::default();
assert!(address_family_allowed(&config, ipv4));
assert!(address_family_allowed(&config, ipv6));
config.socket_config.ipv4_only = true;
assert!(address_family_allowed(&config, ipv4));
assert!(!address_family_allowed(&config, ipv6));
config.socket_config.ipv4_only = false;
config.socket_config.ipv6_only = true;
assert!(!address_family_allowed(&config, ipv4));
assert!(address_family_allowed(&config, ipv6));
}
#[cfg(feature = "tokio")] #[cfg(feature = "tokio")]
#[test] #[test]
fn tokio_ipv6_only_is_applied_before_bind() { fn tokio_ipv6_only_is_applied_before_bind() {
-90
View File
@@ -228,75 +228,6 @@ pub(crate) fn echo_payload_len(hmac: bool, params: &Params) -> usize {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn recognized_descriptors_parse_to_their_mode() {
for (descriptor, expected) in [
("none", FillMode::None),
("rand", FillMode::Random),
("pattern:00", FillMode::Pattern(vec![0x00])),
("pattern:ff00", FillMode::Pattern(vec![0xff, 0x00])),
// Only the hexadecimal body is case-insensitive.
("pattern:AaBb", FillMode::Pattern(vec![0xaa, 0xbb])),
(
"pattern:69727474",
FillMode::Pattern(DEFAULT_FILL_PATTERN.to_vec()),
),
] {
assert_eq!(FillMode::parse(descriptor), Some(expected), "{descriptor}");
}
}
#[test]
fn unrecognized_and_malformed_descriptors_parse_to_nothing() {
for descriptor in [
"",
"bogus",
// The mode names themselves are case-sensitive.
"RAND",
"None",
"Pattern:aabb",
"rand ",
"pattern:",
"pattern:f",
"pattern:abc",
"pattern:zz",
"pattern:0g",
// A multi-byte character is not two hexadecimal digits, whatever
// its byte length.
"pattern:é",
] {
assert_eq!(FillMode::parse(descriptor), None, "{descriptor:?}");
}
}
#[test]
fn the_default_descriptor_and_the_default_pattern_agree() {
// The two constants are held separately so nothing has to parse a
// string per open; this is what keeps them from drifting.
assert_eq!(
FillMode::parse(DEFAULT_FILL_DESCRIPTOR),
Some(FillMode::default_fill())
);
}
#[test]
fn a_pattern_repeats_from_its_first_byte_and_stops_at_the_region() {
let mode = FillMode::Pattern(vec![0xaa, 0xbb]);
assert_eq!(mode.payload(0), Vec::<u8>::new());
assert_eq!(mode.payload(1), vec![0xaa]);
assert_eq!(
mode.payload(7),
vec![0xaa, 0xbb, 0xaa, 0xbb, 0xaa, 0xbb, 0xaa]
);
}
#[test]
fn no_fill_leaves_the_region_to_the_encoder() {
// Not a length-sized run of zeroes: the encoder has already zeroed the
// region, and copying zeroes over zeroes would only cost an allocation.
assert!(FillMode::None.payload(16).is_empty());
}
#[test] #[test]
fn a_failed_random_draw_yields_zeroes() { fn a_failed_random_draw_yields_zeroes() {
// The real source cannot be made to fail without a production hook, and // The real source cannot be made to fail without a production hook, and
@@ -309,25 +240,4 @@ mod tests {
}; };
assert_eq!(random_payload(6, scribble), vec![0; 6]); assert_eq!(random_payload(6, scribble), vec![0; 6]);
} }
#[test]
fn a_successful_random_draw_is_propagated_whole() {
// Deterministic on purpose. Nothing here asserts that real random bytes
// are nonzero, distinct or well distributed; those are properties of
// the operating system's source, and testing them would be a coin flip
// dressed up as an assertion.
let drawn = [1, 2, 3, 4, 5];
let source = |buffer: &mut [u8]| -> Result<(), ()> {
assert_eq!(buffer.len(), drawn.len(), "the whole region is offered");
buffer.copy_from_slice(&drawn);
Ok(())
};
assert_eq!(random_payload(drawn.len(), source), drawn);
}
#[test]
fn an_empty_region_never_reaches_the_random_source() {
let source = |_: &mut [u8]| -> Result<(), ()> { panic!("no bytes were needed") };
assert!(random_payload(0, source).is_empty());
}
} }
-77
View File
@@ -393,83 +393,6 @@ mod tests {
use super::*; use super::*;
use crate::{token::TokenSource, ServerCore, ServerError}; use crate::{token::TokenSource, ServerCore, ServerError};
/// Which listener of a requested set has to be bound IPv6-only.
///
/// The interesting rows are the mapped ones. `[::ffff:0.0.0.0]` is an IPv4
/// wildcard bind wearing an IPv6 address, so it is neither a candidate for
/// `IPV6_V6ONLY` — which would refuse the traffic it exists to serve — nor
/// the IPv6 half of a same-port pair. A genuine IPv6 address paired with it
/// still is.
#[test]
fn only_a_genuine_ipv6_listener_sharing_an_explicit_port_is_bound_ipv6_only() {
for (addr, requested, ipv6_only, why) in [
(
"[::]:2112",
vec!["0.0.0.0:2112", "[::]:2112"],
true,
"the wildcard pair is the reason this exists",
),
(
"[::1]:2112",
vec!["127.0.0.1:2112", "[::1]:2112"],
true,
"and so is the explicit loopback pair",
),
(
"0.0.0.0:2112",
vec!["0.0.0.0:2112", "[::]:2112"],
false,
"the IPv4 half is bound exactly as it always was",
),
(
"[::]:2112",
vec!["[::]:2112"],
false,
"an IPv6 listener alone keeps ordinary bind semantics",
),
(
"[::]:2112",
vec!["0.0.0.0:2113", "[::]:2112"],
false,
"different ports cannot collide",
),
(
"[::]:0",
vec!["0.0.0.0:0", "[::]:0"],
false,
"two ephemeral requests resolve independently",
),
(
"[::ffff:0.0.0.0]:2112",
vec!["0.0.0.0:2112", "[::ffff:0.0.0.0]:2112"],
false,
"a mapped wildcard is an IPv4 listener, not an IPv6 one",
),
(
"[::]:2112",
vec!["[::ffff:0.0.0.0]:2112", "[::]:2112"],
true,
"and it is the IPv4 half a genuine IPv6 listener must avoid",
),
(
"[::ffff:127.0.0.1]:2112",
vec!["[::ffff:127.0.0.1]:2112", "[::1]:2112"],
false,
"a mapped host address is explicit IPv4 too",
),
] {
let requested: Vec<SocketAddr> = requested
.iter()
.map(|addr| addr.parse().expect("a test address must parse"))
.collect();
assert_eq!(
needs_ipv6_only(addr.parse().unwrap(), &requested),
ipv6_only,
"{addr} among {requested:?}: {why}"
);
}
}
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
async fn a_set_serving_nothing_is_refused() { async fn a_set_serving_nothing_is_refused() {
assert!(matches!( assert!(matches!(
+10 -54
View File
@@ -1,12 +1,9 @@
//! Server fill: what an open reply reports, what the session will fill with, //! Server fill: what an open reply reports, what the session will fill with,
//! and the bytes an echo reply actually carries. //! and the bytes an echo reply actually carries.
//! //!
//! The first two are deliberately different questions. A client that expressed //! A client that expressed no preference keeps its absent or empty descriptor
//! no preference keeps its absent or empty descriptor in the negotiated //! in the negotiated parameters. Payload tests assert the effective fill from
//! parameters while the session uses this server's default fill, and only a //! reply bytes, without inspecting the session's internal representation.
//! descriptor the server could not honor is rewritten. Asserting the reply alone
//! would not separate those, so the negotiation tests read the session's
//! effective mode too, and the payload tests then pin the bytes on the wire.
//! //!
//! Payload lengths here are derived from `irtt-proto`'s own layout rather than //! 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. //! written as offsets, and are deliberately not multiples of the pattern length.
@@ -18,7 +15,6 @@ use super::support::{
expect_no_test_reply, expect_normal_open_reply, no_test_request, open_negotiated, open_request, expect_no_test_reply, expect_normal_open_reply, no_test_request, open_negotiated, open_request,
other_peer, peer, unthrottled, ScriptedTokens, KEY, other_peer, peer, unthrottled, ScriptedTokens, KEY,
}; };
use crate::{core::ServerCore, fill::FillMode};
const TOKEN_A: u64 = 0x0102_0304_0506_0708; const TOKEN_A: u64 = 0x0102_0304_0506_0708;
const TOKEN_B: u64 = 0x1112_1314_1516_1718; const TOKEN_B: u64 = 0x1112_1314_1516_1718;
@@ -38,8 +34,8 @@ fn requesting(descriptor: Option<&str>) -> Params {
} }
/// Opens a session requesting `descriptor` and returns the descriptor the reply /// Opens a session requesting `descriptor` and returns the descriptor the reply
/// carried together with the fill the session actually holds. /// carried.
fn negotiated(descriptor: Option<&str>) -> (Option<String>, FillMode) { fn negotiated(descriptor: Option<&str>) -> Option<String> {
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A])); let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
let packet = core let packet = core
.handle_datagram(peer(), &open_request(&requesting(descriptor), None)) .handle_datagram(peer(), &open_request(&requesting(descriptor), None))
@@ -47,16 +43,7 @@ fn negotiated(descriptor: Option<&str>) -> (Option<String>, FillMode) {
.expect("the open must be answered"); .expect("the open must be answered");
let reply = expect_normal_open_reply(&packet, None); let reply = expect_normal_open_reply(&packet, None);
let fill = effective_fill(&core, reply.token); reply.params.server_fill.map(|fill| fill.value)
(reply.params.server_fill.map(|fill| fill.value), fill)
}
/// The effective fill of the session `token` names.
fn effective_fill(core: &ServerCore, token: u64) -> FillMode {
core.session(token)
.expect("the open must have created a session")
.fill()
.clone()
} }
/// The descriptor a no-test open reports for `descriptor`, which creates no /// The descriptor a no-test open reports for `descriptor`, which creates no
@@ -75,17 +62,6 @@ fn no_test_negotiated(descriptor: Option<&str>) -> Option<String> {
reply.params.server_fill.map(|fill| fill.value) reply.params.server_fill.map(|fill| fill.value)
} }
#[test]
fn no_preference_keeps_an_absent_descriptor_and_uses_the_default_fill() {
// The important half is the first: a client that asked for nothing must be
// answered with nothing, or a strict client rejects a restriction the
// server never actually imposed. The server's own default is an internal
// choice and stays internal.
let (returned, fill) = negotiated(None);
assert_eq!(returned, None, "an absent request stays absent");
assert_eq!(fill, FillMode::default_fill());
}
#[test] #[test]
fn an_explicitly_empty_descriptor_is_preserved_and_uses_the_default_fill() { fn an_explicitly_empty_descriptor_is_preserved_and_uses_the_default_fill() {
// A low-level peer can send this even though `ClientConfig` rejects an // A low-level peer can send this even though `ClientConfig` rejects an
@@ -93,31 +69,12 @@ fn an_explicitly_empty_descriptor_is_preserved_and_uses_the_default_fill() {
// with absent as "no preference", so it is neither refused nor rewritten: // 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 // the requested wire value comes back exactly, and the server picks its own
// behavior behind it. // behavior behind it.
let (returned, fill) = negotiated(Some("")); let returned = negotiated(Some(""));
assert_eq!( assert_eq!(
returned.as_deref(), returned.as_deref(),
Some(""), Some(""),
"an empty request stays empty" "an empty request stays empty"
); );
assert_eq!(fill, FillMode::default_fill());
}
#[test]
fn a_valid_descriptor_is_returned_exactly_as_requested() {
for (descriptor, expected) in [
("none", FillMode::None),
("rand", FillMode::Random),
("pattern:00", FillMode::Pattern(vec![0x00])),
("pattern:ff00", FillMode::Pattern(vec![0xff, 0x00])),
// Returned with its own hexadecimal case, not normalized to the bytes
// it decodes to: rewriting it would report a restriction that did not
// happen, and a strict client would reject the session for it.
("pattern:AaBb", FillMode::Pattern(vec![0xaa, 0xbb])),
] {
let (returned, fill) = negotiated(Some(descriptor));
assert_eq!(returned.as_deref(), Some(descriptor), "{descriptor}");
assert_eq!(fill, expected, "{descriptor}");
}
} }
#[test] #[test]
@@ -137,13 +94,12 @@ fn an_unknown_or_malformed_descriptor_is_replaced_by_the_default() {
"pattern:0g", "pattern:0g",
"0123456789abcdef0123456789abcdef", "0123456789abcdef0123456789abcdef",
] { ] {
let (returned, fill) = negotiated(Some(descriptor)); let returned = negotiated(Some(descriptor));
assert_eq!( assert_eq!(
returned.as_deref(), returned.as_deref(),
Some(crate::fill::DEFAULT_FILL_DESCRIPTOR), Some(crate::fill::DEFAULT_FILL_DESCRIPTOR),
"{descriptor}" "{descriptor}"
); );
assert_eq!(fill, FillMode::default_fill(), "{descriptor}");
} }
} }
@@ -229,8 +185,8 @@ fn repeated(pattern: &[u8], len: usize) -> Vec<u8> {
#[test] #[test]
fn no_preference_fills_with_the_default_pattern() { fn no_preference_fills_with_the_default_pattern() {
// The wire consequence of the negotiation test above: absent descriptor, // An absent descriptor uses default bytes. 7 is not a multiple of 4, so
// default bytes. 7 is not a multiple of 4, so the last repeat is partial. // the last repeat is partial.
assert_eq!(echo_payload(None, 7), b"irttirt".to_vec()); assert_eq!(echo_payload(None, 7), b"irttirt".to_vec());
} }
-9
View File
@@ -204,15 +204,6 @@ mod tests {
); );
} }
#[test]
fn ipdv_tracker_duplicate_sequence_does_not_emit_pair_again() {
let mut tracker = IpdvTracker::new(None);
assert!(tracker.insert(ipdv_sample(0, 10)).is_empty());
assert_eq!(tracker.insert(ipdv_sample(1, 14)).len(), 1);
assert!(tracker.insert(ipdv_sample(1, 18)).is_empty());
}
#[test] #[test]
fn ipdv_tracker_bounded_mode_limits_sequence_state() { fn ipdv_tracker_bounded_mode_limits_sequence_state() {
let limit = 4; let limit = 4;
-18
View File
@@ -91,21 +91,3 @@ pub(crate) fn loss_stats(packets: PacketCounts) -> LossStats {
fn percent(numerator: f64, denominator: f64) -> f64 { fn percent(numerator: f64, denominator: f64) -> f64 {
100.0 * numerator / denominator 100.0 * numerator / denominator
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn duplicate_and_late_percentages_use_packets_received_denominator() {
let loss = loss_stats(PacketCounts {
packets_received: 4,
duplicates: 1,
late_packets: 2,
..PacketCounts::default()
});
assert_eq!(loss.duplicate_percent, 25.0);
assert_eq!(loss.late_packets_percent, 50.0);
}
}
-152
View File
@@ -34,11 +34,6 @@ const HASH_ENTRY_OVERHEAD: u64 = 1;
/// Adjusted RTT, one-way delay, send/receive IPDV, and server processing only /// Adjusted RTT, one-way delay, send/receive IPDV, and server processing only
/// receive a sample when the negotiated session supplies the corresponding /// receive a sample when the negotiated session supplies the corresponding
/// optional measurement, so counting all eleven is the upper bound. /// optional measurement, so counting all eleven is the upper bound.
///
/// This count must track the metrics `CoreStats::new` builds with exact
/// retention. `exact_mode_retains_one_sample_per_counted_metric` below counts
/// the metrics a fully measured probe actually retains, so the two cannot
/// drift apart unnoticed.
pub(crate) const EXACT_SAMPLES_PER_PROBE: u64 = 11; pub(crate) const EXACT_SAMPLES_PER_PROBE: u64 = 11;
/// Normalized events a probe usually contributes to a rolling window: one send /// Normalized events a probe usually contributes to a rolling window: one send
@@ -104,150 +99,3 @@ fn rolling_count_bytes(config: &StatsConfig, probe_count: u64) -> u64 {
fn rolling_bytes_per_event() -> u64 { fn rolling_bytes_per_event() -> u64 {
(size_of::<StatsEvent>() + size_of::<ReplySample>()) as u64 (size_of::<StatsEvent>() + size_of::<ReplySample>()) as u64
} }
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant, UNIX_EPOCH};
use irtt_client::{
ClientEvent, ClientTimestamp, OneWayDelaySample, PacketMeta, ReceivedStatsSample,
RttSample, ServerTiming, SignedDuration,
};
use super::*;
use crate::{Snapshot, StatsCollector, TimeStats};
/// Every public timing metric a snapshot exposes.
fn timing_metrics(snapshot: &Snapshot) -> Vec<&TimeStats> {
vec![
&snapshot.send_call,
&snapshot.timer_error,
&snapshot.rtt.primary,
&snapshot.rtt.raw,
&snapshot.rtt.adjusted,
&snapshot.ipdv.round_trip,
&snapshot.ipdv.send,
&snapshot.ipdv.receive,
&snapshot.one_way_delay.send_delay,
&snapshot.one_way_delay.receive_delay,
&snapshot.server_processing.processing,
]
}
fn timestamp(offset_ms: u64) -> ClientTimestamp {
ClientTimestamp {
mono: Instant::now() + Duration::from_millis(offset_ms),
wall: UNIX_EPOCH + Duration::from_millis(offset_ms),
}
}
fn sent(seq: u32) -> ClientEvent {
let sent_at = timestamp(u64::from(seq) * 10);
ClientEvent::EchoSent {
seq,
remote: "127.0.0.1:2112".parse().unwrap(),
scheduled_at: Some(sent_at.mono),
sent_at,
bytes: 32,
send_call: Duration::from_micros(10 + u64::from(seq)),
timer_error: Some(Duration::from_micros(2 + u64::from(seq))),
}
}
/// A reply carrying every optional measurement, so one probe exercises the
/// upper bound the estimate assumes.
fn fully_measured_reply(seq: u32) -> ClientEvent {
let sent_at = timestamp(u64::from(seq) * 10);
let raw = Duration::from_millis(10 + u64::from(seq));
let received_at = ClientTimestamp {
mono: sent_at.mono + raw,
wall: sent_at.wall + raw,
};
let base_ns = i64::from(seq) * 10_000_000;
ClientEvent::EchoReply {
seq,
remote: "127.0.0.1:2112".parse().unwrap(),
sent_at,
received_at,
rtt: RttSample {
raw,
adjusted: Some(SignedDuration::from_duration(raw)),
effective: SignedDuration::from_duration(raw),
},
server_timing: Some(ServerTiming {
receive_mono_ns: Some(base_ns + 1_000_000),
send_mono_ns: Some(base_ns + 2_000_000),
receive_wall_ns: Some(base_ns + 1_000_000),
send_wall_ns: Some(base_ns + 2_000_000),
midpoint_mono_ns: None,
midpoint_wall_ns: None,
processing: Some(Duration::from_millis(1 + u64::from(seq))),
}),
one_way: Some(OneWayDelaySample {
client_to_server: Some(SignedDuration::from_nanos(1_000_000 + i128::from(seq))),
server_to_client: Some(SignedDuration::from_nanos(2_000_000 + i128::from(seq))),
}),
received_stats: Some(ReceivedStatsSample {
count: Some(seq + 1),
window: Some(0xff),
}),
bytes: 64,
packet_meta: PacketMeta::default(),
}
}
/// Two probes fill every metric, including the IPDV pair metrics that need
/// adjacent replies.
fn fully_measured_snapshot(config: StatsConfig) -> Snapshot {
let mut collector = StatsCollector::new(config);
for seq in 0..2 {
collector.process(&sent(seq));
collector.process(&fully_measured_reply(seq));
}
collector.snapshot()
}
/// The estimate is only honest while it counts the metrics that actually
/// retain samples. Adding or removing an exact-retaining public timing
/// metric without revisiting [`EXACT_SAMPLES_PER_PROBE`] fails here.
#[test]
fn exact_mode_retains_one_sample_per_counted_metric() {
let snapshot = fully_measured_snapshot(StatsConfig::finite());
let with_medians = timing_metrics(&snapshot)
.into_iter()
.filter(|stats| stats.median_ns.is_some())
.count() as u64;
assert_eq!(
with_medians, EXACT_SAMPLES_PER_PROBE,
"the estimate counts {EXACT_SAMPLES_PER_PROBE} exact sample streams per probe, \
but {with_medians} public timing metrics retained samples"
);
}
#[test]
fn running_only_retains_no_exact_samples() {
let snapshot = fully_measured_snapshot(StatsConfig::continuous());
for stats in timing_metrics(&snapshot) {
assert!(stats.count > 0, "the fixture should populate every metric");
assert_eq!(
stats.median_ns, None,
"running-only mode retains no exact samples"
);
}
}
#[test]
fn per_sample_components_are_non_zero() {
assert!(ipdv_tracker_bytes_per_sample() > 0);
assert!(rolling_bytes_per_event() > 0);
}
#[test]
fn running_only_stops_growing_at_the_bounded_sequence_limit() {
let limit = CONTINUOUS_SEQUENCE_LIMIT as u64;
assert_eq!(running_only_bytes(limit), running_only_bytes(limit * 100));
assert!(running_only_bytes(limit - 1) < running_only_bytes(limit));
}
}
-48
View File
@@ -27,33 +27,6 @@ fn exact_mode_grows_with_probe_count() {
} }
} }
#[test]
fn exact_mode_scales_linearly_with_probe_count() {
let config = StatsConfig::finite();
let single = config.estimated_retained_bytes(1);
assert_eq!(config.estimated_retained_bytes(1_000), single * 1_000);
}
/// Exact mode retains eleven `i128` timing streams per probe alongside its
/// IPDV tracker state, so the timing samples alone are at least that much.
///
/// This checks the estimate against the storage it claims to model. It is an
/// implementation test, not a stable public constant: the figure is free to
/// move whenever the retention model does.
#[test]
fn exact_estimate_covers_every_retained_timing_sample() {
const EXACT_TIMING_STREAMS: u64 = 11;
let per_probe = StatsConfig::finite().estimated_retained_bytes(1);
let timing_sample_bytes = EXACT_TIMING_STREAMS * std::mem::size_of::<i128>() as u64;
assert!(
per_probe > timing_sample_bytes,
"the estimate should cover {EXACT_TIMING_STREAMS} retained timing samples plus \
IPDV tracker state, but a probe estimated only {per_probe} bytes"
);
}
#[test] #[test]
fn enormous_probe_counts_saturate_instead_of_wrapping() { fn enormous_probe_counts_saturate_instead_of_wrapping() {
for config in [StatsConfig::finite(), StatsConfig::continuous()] { for config in [StatsConfig::finite(), StatsConfig::continuous()] {
@@ -90,27 +63,6 @@ fn running_only_does_not_report_unbounded_per_probe_retention() {
); );
} }
#[test]
fn finite_config_estimate_is_deterministic_and_usable_for_planning() {
let config = StatsConfig::finite();
let probes = 1_000_000;
let first = config.estimated_retained_bytes(probes);
assert_eq!(first, config.estimated_retained_bytes(probes));
// A million probes should land in a range a caller can reason about: well
// over a megabyte, and well under a terabyte. The exact figure is free to
// move with the retention model.
assert!(
first > 1024 * 1024,
"a million probes should estimate more than a MiB, got {first}"
);
assert!(
first < 1024 * 1024 * 1024 * 1024,
"a million probes should estimate less than a TiB, got {first}"
);
}
#[test] #[test]
fn rolling_count_adds_bounded_retention() { fn rolling_count_adds_bounded_retention() {
let probes = 1_000_000; let probes = 1_000_000;