diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aab570..249a39f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **`--dscp` flag** — set DSCP/TOS marking on TCP and UDP client sockets for QoS policy testing. Accepts numeric values (0-255) or standard DSCP names (EF, AF11-AF43, CS0-CS7). QUIC warns and ignores the flag; non-Unix platforms warn instead of applying socket marking. +- **`omit_secs` config support** (issue #43) — `[client] omit_secs = N` in config file sets default `--omit` value. + ## [0.9.5] - 2026-03-17 ### Added diff --git a/README.md b/README.md index 88411df..2e5db3b 100644 --- a/README.md +++ b/README.md @@ -378,6 +378,7 @@ no_tui = false theme = "default" # or dracula, catppuccin, nord, matrix, etc. timestamp_format = "relative" # or "iso8601", "unix" address_family = "dual" # "ipv4", "ipv6", or "dual" +omit_secs = 0 # omit first N seconds (TCP ramp-up) psk = "my-secret-key" log_file = "~/.config/xfr/xfr.log" log_level = "info" @@ -398,6 +399,8 @@ log_file = "~/.config/xfr/xfr-server.log" log_level = "info" ``` +`[client] omit_secs` sets the default for `--omit`. An explicit CLI `--omit` value, including `--omit 0`, takes precedence over the config file. + Environment variables override config file: ```bash @@ -438,6 +441,7 @@ See `examples/grafana-dashboard.json` for a sample Grafana dashboard. | `--ipv6` | `-6` | false | Force IPv6 only | | `--bind` | | none | Local address to bind (e.g., 192.168.1.100) | | `--cport` | | none | Client source port for firewall traversal (UDP/QUIC/TCP data streams) | +| `--dscp` | | none | DSCP/TOS marking for TCP/UDP QoS testing (0-255 or name: EF, AF11, CS1, etc.) | | `--mptcp` | | false | MPTCP mode (client-only, Linux 5.6+; server auto-enables) | | `--random` | | true | Use random payload data for client-sent TCP/UDP traffic (default) | | `--zeros` | | false | Use zero-filled payload data (client-sent traffic only) | @@ -473,6 +477,8 @@ See `examples/grafana-dashboard.json` for a sample Grafana dashboard. TCP and UDP tests use random payloads by default to avoid inflated results on WAN-optimized or compressing paths. `--random` and `--zeros` control client-sent traffic. Server-sent TCP/UDP traffic also defaults to random, but payload mode is not negotiated over the wire. +`--dscp` applies to TCP and UDP client sockets. QUIC ignores it because the underlying socket is managed by Quinn, and non-Unix platforms currently warn instead of applying socket marking. + ## Security Considerations ### Transport Encryption diff --git a/ROADMAP.md b/ROADMAP.md index 1f455e6..fa552ea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,7 +129,7 @@ - [ ] **Suppress UDP/QUIC send errors on graceful shutdown** - UDP shows `Connection refused (os error 111)`, QUIC shows `sending stopped by peer: error 0` when server tears down sockets before client finishes sending; cosmetic but noisy - [ ] **Summary on early exit** (issue #35) - catch SIGINT/SIGTERM, trigger cancel path, and display test summary with accumulated stats instead of silently exiting. Server-side disconnect errors on client kill are a separate cleanup - [x] **Delta retransmits in plain-text interval output** (issue #36) - plain-text interval reports now show retransmit deltas instead of cumulative totals while the final summary remains cumulative. TUI stats remain cumulative by design, and server-reported per-stream interval deltas continue in JSON/CSV output -- [ ] **`omit_secs` in config file** (issue #43) — add `omit_secs` to `[client]` config so `--omit` can be set as a default +- [x] **`omit_secs` in config file** (issue #43) — `[client] omit_secs` in config.toml sets default `--omit` value - [ ] **Group CLI help by client/server** (issue #43) — restructure `--help` output into client-only, server-only, and shared sections like iperf3 ### Code Quality @@ -175,7 +175,7 @@ - [x] **Random payload data** (`--random`) - fill send buffers with random bytes to defeat WAN optimizer/compression/dedup bias (issue #34). Both client and server TCP/UDP; QUIC skipped (already encrypted). Fill-once per buffer, no per-write overhead. `--zeros` only affects client-sent traffic; server payload mode not yet negotiated over wire - [ ] **Configurable UDP packet size** (`--packet-size`) - set UDP datagram size for jumbo frame validation and MTU path testing; iperf3 `--set-mss` is TCP-only (issue esnet/iperf#861) - [ ] **Get server output** (`--get-server-output`) - return server's JSON result to client (iperf3 parity) -- [ ] **DSCP/TOS marking** (`--dscp`) - set IP_TOS on sockets for QoS policy testing; single `setsockopt` call, same pattern as `--congestion`. iperf3 has `-S` +- [x] **DSCP/TOS marking** (`--dscp`) - set DSCP/TOS on client TCP/UDP sockets for QoS policy testing; QUIC ignores it. Accepts numeric (0-255) or DSCP names (EF, AF11-AF43, CS0-CS7) - [ ] **TCP Fast Open** (`--fast-open`) - reduce handshake latency for short tests; `setsockopt(TCP_FASTOPEN)` on server, `MSG_FASTOPEN` on client connect - [ ] **CC algorithm A/B comparison** (`xfr cca-compare `) - run back-to-back tests with different congestion control algorithms (BBR, CUBIC, Reno, etc.) and produce side-by-side comparison (throughput, retransmits, RTT). Leverages existing `--congestion` and `xfr diff`. BBR vs CUBIC is one of the most common network testing questions - [ ] **Server-side bandwidth caps** (`--max-bandwidth`) - per-test server-enforced bandwidth limit to prevent abuse of public servers. iperf3 issue #937 is highly requested. Distinct from `--rate-limit` (which limits concurrent tests per IP) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index d00edf8..c9f0349 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -198,22 +198,40 @@ xfr --bind 10.0.0.1:0 # IP with auto-assigned port xfr --bind [::1] # IPv6 address ``` -**Note:** TCP mode does not support explicit port binding (use IP only). +**Note:** Plain `--bind IP:PORT` is still not supported for TCP control sockets. Use `--bind IP` for source-IP selection, or combine `--bind IP --cport PORT` to pin TCP data-stream source ports. ## Client Source Port (`--cport`) -Pin the client's local source port for firewall traversal (UDP and QUIC only): +Pin the client's local source port for firewall traversal: ```bash +xfr --cport 5300 # TCP data stream with source port 5300 +xfr --cport 5300 -P 4 # 4 TCP data streams on ports 5300-5303 xfr -u --cport 5300 # UDP with source port 5300 xfr -u --cport 5300 -P 4 # 4 UDP streams on ports 5300-5303 xfr --quic --cport 5300 # QUIC with source port 5300 xfr --bind 10.0.0.1 --cport 5300 # Combine with --bind for IP + port ``` -Multi-stream UDP assigns sequential ports starting from the specified port. QUIC multiplexes all streams on a single port, so only one port is needed regardless of `-P`. +Multi-stream TCP and UDP assign sequential ports starting from the specified port. QUIC multiplexes all streams on a single port, so only one port is needed regardless of `-P`. -**Not supported with TCP** — TCP already uses single-port mode (all connections go through port 5201), so source port pinning is unnecessary. Stateful firewalls handle TCP ephemeral source ports automatically. +For TCP, `--cport` only applies to data streams. The control connection still uses an ephemeral source port, and xfr fails fast if that ephemeral port overlaps the requested TCP data-port range. + +## DSCP / TOS Marking (`--dscp`) + +Mark client TCP or UDP sockets for QoS / DSCP testing: + +```bash +xfr --dscp EF # Expedited Forwarding (46 << 2) +xfr -u --dscp AF31 # Assured Forwarding class 3 drop precedence 1 +xfr --dscp 184 # Raw TOS byte value +``` + +`--dscp` accepts either a raw TOS byte (`0-255`) or DSCP names such as `EF`, `AF11-AF43`, `CS0-CS7`, and `VA`. + +- TCP and UDP apply the marking on client sockets after connect. +- QUIC ignores `--dscp` because the underlying UDP socket is owned by Quinn. +- On non-Unix platforms, xfr currently warns instead of applying socket marking. ## Dual-Stack and IPv6 Support @@ -288,6 +306,7 @@ no_tui = false theme = "default" timestamp_format = "relative" # relative, iso8601, unix address_family = "dual" # ipv4, ipv6, dual +omit_secs = 0 # omit first N seconds (TCP ramp-up) psk = "my-secret-key" log_file = "~/.config/xfr/xfr.log" log_level = "info" @@ -308,6 +327,8 @@ log_file = "~/.config/xfr/xfr-server.log" log_level = "info" ``` +`[client] omit_secs` sets the default for `--omit`. An explicit CLI `--omit` value, including `--omit 0`, overrides the config file. + ### Environment Variables Environment variables override config file settings: @@ -524,9 +545,11 @@ See `xfr --help` for complete CLI documentation. | `--ipv4` | `-4` | false | Force IPv4 only | | `--ipv6` | `-6` | false | Force IPv6 only | | `--bind` | | none | Local address to bind (IP or IP:port) | -| `--cport` | | none | Client source port for firewall traversal (UDP/QUIC only) | +| `--cport` | | none | Client source port for firewall traversal (UDP/QUIC/TCP data streams) | +| `--dscp` | | none | DSCP/TOS marking for TCP/UDP QoS testing (0-255 or name: EF, AF11, CS1, etc.) | | `--mptcp` | | false | MPTCP mode (Multi-Path TCP, Linux 5.6+) | -| `--random` | | false | Use random client payload bytes (TCP/UDP upload; ignored for QUIC, partial in reverse/bidir) | +| `--random` | | true | Use random payload data for client-sent TCP/UDP traffic (default) | +| `--zeros` | | false | Use zero-filled payload data (client-sent traffic only) | ### Server-Specific Flags diff --git a/docs/xfr.1 b/docs/xfr.1 index c38a889..8207806 100644 --- a/docs/xfr.1 +++ b/docs/xfr.1 @@ -107,6 +107,9 @@ Local address to bind to (e.g., 192.168.1.100 or ::1) .BI \-\-cport " PORT" Client source port for firewall traversal (UDP/QUIC/TCP data streams) .TP +.BI \-\-dscp " VALUE" +Set DSCP/TOS marking on sockets for QoS policy testing. Accepts numeric values (0\-255) or standard DSCP names: EF, AF11\-AF43, CS0\-CS7, VA. +.TP .B \-\-mptcp Use MPTCP (Multi\-Path TCP, Linux 5.6+) .TP diff --git a/examples/config.toml b/examples/config.toml index f6ae08b..d42c941 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -20,6 +20,9 @@ json_output = false # Disable TUI by default no_tui = false +# Omit first N seconds from interval output (TCP ramp-up) +# omit_secs = 3 + [server] # Default port port = 5201 diff --git a/src/client.rs b/src/client.rs index afcc23b..edd566f 100644 --- a/src/client.rs +++ b/src/client.rs @@ -90,6 +90,8 @@ pub struct ClientConfig { pub mptcp: bool, /// Use random payload data for client-sent traffic pub random_payload: bool, + /// DSCP/TOS value for IP_TOS socket option + pub dscp: Option, } impl Default for ClientConfig { @@ -111,6 +113,7 @@ impl Default for ClientConfig { sequential_ports: false, mptcp: false, random_payload: true, + dscp: None, } } } @@ -658,6 +661,7 @@ impl Client { let duration = self.config.duration; let bind_addr = stream_bind_addr(base_bind_addr, self.config.sequential_ports, i); let mptcp = self.config.mptcp; + let dscp = self.config.dscp; let test_id = test_id.clone(); let stream_index = i as u16; let handshake_limiter = handshake_limiter.clone(); @@ -690,6 +694,13 @@ impl Client { Ok(mut stream) => { debug!("Connected to data port {}", port); + // Set DSCP/TOS marking if requested + if let Some(tos) = dscp + && let Err(e) = net::set_tos_on_tcp(&stream, tos) + { + warn!("Failed to set IP_TOS: {}", e); + } + // Single-port mode: send DataHello to identify stream if single_port_mode { let hello = ControlMessage::DataHello { @@ -866,6 +877,7 @@ impl Client { let duration = self.config.duration; let random_payload = self.config.random_payload; let bind_addr = stream_bind_addr(base_bind_addr, self.config.sequential_ports, i); + let dscp = self.config.dscp; handles.push(tokio::spawn(async move { // Create UDP socket matching the server's address family for cross-platform compatibility. @@ -896,6 +908,13 @@ impl Client { debug!("UDP connected to {}", server_port); + // Set DSCP/TOS marking if requested + if let Some(tos) = dscp + && let Err(e) = net::set_tos_on_udp(&socket, tos) + { + warn!("Failed to set IP_TOS on UDP socket: {}", e); + } + match direction { Direction::Upload => { if let Err(e) = udp::send_udp_paced( diff --git a/src/config.rs b/src/config.rs index 6c268c8..78169a9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -59,6 +59,9 @@ pub struct ClientDefaults { /// Address family preference (ipv4, ipv6, dual) pub address_family: Option, + + /// Omit first N seconds from interval output (TCP ramp-up) + pub omit_secs: Option, } /// Default settings for server mode @@ -193,6 +196,17 @@ allowed_clients = ["192.168.1.0/24"] assert_eq!(config.presets[0].bandwidth_limit, Some("100M".to_string())); } + #[test] + fn test_parse_client_omit_secs() { + let toml = r#" +[client] +omit_secs = 3 +"#; + + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.client.omit_secs, Some(3)); + } + #[test] fn test_parse_server_no_mdns() { let toml = r#" diff --git a/src/main.rs b/src/main.rs index a9ef67b..d74aa4c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -221,8 +221,8 @@ struct Cli { interval: f64, /// Omit first N seconds from interval output (TCP ramp-up) - #[arg(long, default_value = "0")] - omit: u64, + #[arg(long)] + omit: Option, /// Disable Nagle algorithm #[arg(long)] @@ -232,6 +232,10 @@ struct Cli { #[arg(long = "congestion", value_name = "ALGO")] congestion: Option, + /// DSCP/TOS marking: raw TOS byte (0-255) or DSCP name (EF, AF11, CS1, etc.) + #[arg(long, value_name = "VALUE")] + dscp: Option, + /// TCP window size (e.g., 512K, 1M) #[arg(long, value_parser = parse_size)] window: Option, @@ -447,6 +451,45 @@ fn parse_timestamp_format(s: &str) -> Result { s.parse::() } +fn parse_dscp(s: &str) -> Result { + // Try numeric first (0-255) + if let Ok(n) = s.parse::() { + if n > 255 { + return Err(format!("TOS value {} exceeds 255", n)); + } + return Ok(n as u8); + } + // DSCP name → TOS byte (DSCP value << 2) + match s.to_uppercase().as_str() { + "CS0" => Ok(0), + "CS1" => Ok(32), + "CS2" => Ok(64), + "CS3" => Ok(96), + "CS4" => Ok(128), + "CS5" => Ok(160), + "CS6" => Ok(192), + "CS7" => Ok(224), + "AF11" => Ok(40), + "AF12" => Ok(48), + "AF13" => Ok(56), + "AF21" => Ok(72), + "AF22" => Ok(80), + "AF23" => Ok(88), + "AF31" => Ok(104), + "AF32" => Ok(112), + "AF33" => Ok(120), + "AF41" => Ok(136), + "AF42" => Ok(144), + "AF43" => Ok(152), + "EF" => Ok(184), + "VA" => Ok(172), + _ => Err(format!( + "Unknown DSCP name '{}'. Use a number (0-255) or a name: EF, AF11-AF43, CS0-CS7", + s + )), + } +} + /// Parse a bind address string (can be "IP" or "IP:port") fn parse_bind_address(s: &str) -> anyhow::Result { use std::net::SocketAddr; @@ -836,6 +879,10 @@ async fn main() -> Result<()> { eprintln!("Warning: {msg}"); } + if cli.dscp.is_some() && protocol == xfr::protocol::Protocol::Quic { + eprintln!("Warning: --dscp is ignored with QUIC (QUIC manages its own socket)"); + } + if cli.mptcp { xfr::net::validate_mptcp().map_err(|e| anyhow::anyhow!("{}", e))?; } @@ -970,6 +1017,11 @@ async fn main() -> Result<()> { sequential_ports: protocol != Protocol::Quic && cport.is_some() && streams > 1, mptcp: cli.mptcp, random_payload, + dscp: cli + .dscp + .as_ref() + .map(|s| parse_dscp(s).map_err(|e| anyhow::anyhow!(e))) + .transpose()?, }; // Determine output format @@ -978,7 +1030,9 @@ async fn main() -> Result<()> { json_stream: cli.json_stream, csv: cli.csv, quiet: cli.quiet, - omit_secs: cli.omit, + omit_secs: cli + .omit + .unwrap_or_else(|| file_config.client.omit_secs.unwrap_or(0)), interval_secs: cli.interval, timestamp_format, }; @@ -1058,8 +1112,27 @@ async fn run_client_plain( continue; } last_printed_interval = current_interval; - let jitter_ms = progress.streams.first().and_then(|s| s.jitter_ms); - let lost = progress.streams.first().and_then(|s| s.lost); + // Aggregate jitter (average) and lost (sum) across all streams + let jitter_ms = { + let jitters: Vec = progress + .streams + .iter() + .filter_map(|s| s.jitter_ms) + .collect(); + if jitters.is_empty() { + None + } else { + Some(jitters.iter().sum::() / jitters.len() as f64) + } + }; + let lost = { + let has_any = progress.streams.iter().any(|s| s.lost.is_some()); + if has_any { + Some(progress.streams.iter().filter_map(|s| s.lost).sum()) + } else { + None + } + }; let rtt_us = progress.rtt_us; let cwnd = progress.cwnd; @@ -1100,6 +1173,8 @@ async fn run_client_plain( progress.throughput_mbps, progress.total_bytes, retransmits, + jitter_ms, + lost, rtt_us, ) }; @@ -1930,4 +2005,32 @@ mod tests { assert_eq!(interval_retransmits(&progress, &mut last), None); assert_eq!(last, 7); } + + #[test] + fn test_parse_dscp_numeric() { + assert_eq!(parse_dscp("0").unwrap(), 0); + assert_eq!(parse_dscp("46").unwrap(), 46); + assert_eq!(parse_dscp("255").unwrap(), 255); + assert!(parse_dscp("256").is_err()); + } + + #[test] + fn test_parse_dscp_names() { + // CS values: DSCP << 2 + assert_eq!(parse_dscp("CS0").unwrap(), 0); + assert_eq!(parse_dscp("CS1").unwrap(), 32); + assert_eq!(parse_dscp("CS7").unwrap(), 224); + // AF values + assert_eq!(parse_dscp("AF11").unwrap(), 40); // DSCP 10 << 2 + assert_eq!(parse_dscp("AF43").unwrap(), 152); // DSCP 38 << 2 + // EF + assert_eq!(parse_dscp("EF").unwrap(), 184); // DSCP 46 << 2 + // VA (VOICE-ADMIT) + assert_eq!(parse_dscp("VA").unwrap(), 172); // DSCP 44 << 2 + // Case insensitive + assert_eq!(parse_dscp("ef").unwrap(), 184); + assert_eq!(parse_dscp("af21").unwrap(), 72); + // Unknown name + assert!(parse_dscp("BOGUS").is_err()); + } } diff --git a/src/net.rs b/src/net.rs index ccc8ac5..10ad2f3 100644 --- a/src/net.rs +++ b/src/net.rs @@ -467,6 +467,59 @@ pub async fn connect_tcp_with_bind( } } +/// Set DSCP/TOS marking on a raw fd. Uses IP_TOS for IPv4, IPV6_TCLASS for IPv6. +#[cfg(unix)] +fn set_tos_on_fd(fd: std::os::unix::io::RawFd, tos: u8, ipv6: bool) -> io::Result<()> { + let tos_val = tos as libc::c_int; + let (level, optname) = if ipv6 { + (libc::IPPROTO_IPV6, libc::IPV6_TCLASS) + } else { + (libc::IPPROTO_IP, libc::IP_TOS) + }; + // SAFETY: fd is a valid file descriptor, tos_val is a valid c_int on the stack. + let ret = unsafe { + libc::setsockopt( + fd, + level, + optname, + &tos_val as *const libc::c_int as *const libc::c_void, + std::mem::size_of::() as libc::socklen_t, + ) + }; + if ret != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +/// Set IP_TOS / IPV6_TCLASS on a TCP stream for DSCP/QoS marking. +#[cfg(unix)] +pub fn set_tos_on_tcp(stream: &TcpStream, tos: u8) -> io::Result<()> { + use std::os::unix::io::AsRawFd; + let ipv6 = stream.local_addr().map(|a| a.is_ipv6()).unwrap_or(false); + set_tos_on_fd(stream.as_raw_fd(), tos, ipv6) +} + +#[cfg(not(unix))] +pub fn set_tos_on_tcp(_stream: &TcpStream, _tos: u8) -> io::Result<()> { + tracing::warn!("--dscp is not supported on this platform"); + Ok(()) +} + +/// Set IP_TOS / IPV6_TCLASS on a UDP socket for DSCP/QoS marking. +#[cfg(unix)] +pub fn set_tos_on_udp(socket: &UdpSocket, tos: u8) -> io::Result<()> { + use std::os::unix::io::AsRawFd; + let ipv6 = socket.local_addr().map(|a| a.is_ipv6()).unwrap_or(false); + set_tos_on_fd(socket.as_raw_fd(), tos, ipv6) +} + +#[cfg(not(unix))] +pub fn set_tos_on_udp(_socket: &UdpSocket, _tos: u8) -> io::Result<()> { + tracing::warn!("--dscp is not supported on this platform"); + Ok(()) +} + /// Set IPv6 flow label on a socket (Linux only) #[cfg(target_os = "linux")] pub fn set_flow_label(socket: &Socket, flow_label: u32) -> io::Result<()> { diff --git a/src/output/plain.rs b/src/output/plain.rs index 4b19dd6..20b1862 100644 --- a/src/output/plain.rs +++ b/src/output/plain.rs @@ -94,12 +94,15 @@ pub fn output_plain(result: &TestResult, mptcp: bool) -> String { output } +#[allow(clippy::too_many_arguments)] pub fn output_interval_plain( timestamp: &str, _elapsed_secs: f64, throughput_mbps: f64, bytes: u64, retransmits: Option, + jitter_ms: Option, + lost: Option, rtt_us: Option, ) -> String { let mut output = format!( @@ -109,12 +112,19 @@ pub fn output_interval_plain( bytes_to_human(bytes) ); - if let Some(rtx) = retransmits { - output.push_str(&format!(" rtx: {}", rtx)); - } - - if let Some(rtt) = rtt_us { - output.push_str(&format!(" rtt: {:.2}ms", rtt as f64 / 1000.0)); + // UDP shows jitter/lost; TCP shows rtx/rtt. Use jitter presence to distinguish. + if let Some(jitter) = jitter_ms { + output.push_str(&format!(" jitter: {:.2}ms", jitter)); + if let Some(l) = lost { + output.push_str(&format!(" lost: {}", l)); + } + } else { + if let Some(rtx) = retransmits { + output.push_str(&format!(" rtx: {}", rtx)); + } + if let Some(rtt) = rtt_us { + output.push_str(&format!(" rtt: {:.2}ms", rtt as f64 / 1000.0)); + } } output.push('\n'); @@ -162,4 +172,40 @@ mod tests { let output = output_plain(&make_result_with_tcp_info(), true); assert!(output.contains("Sender TCP Info (initial subflow):\n")); } + + #[test] + fn test_interval_plain_tcp_shows_rtx_rtt() { + let output = output_interval_plain( + "1.001", + 1.0, + 48000.0, + 6_000_000_000, + Some(5), + None, + None, + Some(50), + ); + assert!(output.contains("rtx: 5")); + assert!(output.contains("rtt: 0.05ms")); + assert!(!output.contains("jitter:")); + assert!(!output.contains("lost:")); + } + + #[test] + fn test_interval_plain_udp_shows_jitter_lost() { + let output = output_interval_plain( + "1.001", + 1.0, + 1000.0, + 125_000_000, + Some(0), + Some(1.42), + Some(3), + None, + ); + assert!(output.contains("jitter: 1.42ms")); + assert!(output.contains("lost: 3")); + assert!(!output.contains("rtx:")); + assert!(!output.contains("rtt:")); + } } diff --git a/src/tui/app.rs b/src/tui/app.rs index 5161564..8a6df3f 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -26,6 +26,7 @@ pub struct StreamData { pub bytes: u64, pub throughput_mbps: f64, pub retransmits: u64, + pub jitter_ms: Option, } #[derive(Clone)] @@ -121,6 +122,7 @@ impl App { bytes: 0, throughput_mbps: 0.0, retransmits: 0, + jitter_ms: None, }) .collect(), @@ -270,6 +272,7 @@ impl App { // Use 1-second interval for throughput calculation (intervals are 1s apart) stream.throughput_mbps = (interval.bytes as f64 * 8.0) / 1_000_000.0; stream.retransmits = interval.retransmits.unwrap_or(0); + stream.jitter_ms = interval.jitter_ms; } // Accumulate UDP stats from intervals diff --git a/src/tui/ui.rs b/src/tui/ui.rs index f8b5f3a..97ad1d6 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -446,6 +446,7 @@ fn draw_streams(frame: &mut Frame, app: &App, theme: &Theme, area: Rect) { max_throughput, stream.retransmits, ) + .jitter(stream.jitter_ms) .bar_color(theme.graph_primary) .text_color(theme.text); diff --git a/src/tui/widgets.rs b/src/tui/widgets.rs index a73738f..acd31d6 100644 --- a/src/tui/widgets.rs +++ b/src/tui/widgets.rs @@ -127,12 +127,13 @@ impl Widget for ProgressBar { } } -/// A bar showing per-stream throughput with retransmit count +/// A bar showing per-stream throughput with retransmit count or jitter pub struct StreamBar { pub stream_id: u8, pub throughput_mbps: f64, pub max_throughput: f64, pub retransmits: u64, + pub jitter_ms: Option, pub bar_color: Color, pub text_color: Color, } @@ -144,11 +145,17 @@ impl StreamBar { throughput_mbps, max_throughput, retransmits, + jitter_ms: None, bar_color: Color::Green, text_color: Color::White, } } + pub fn jitter(mut self, jitter_ms: Option) -> Self { + self.jitter_ms = jitter_ms; + self + } + pub fn bar_color(mut self, color: Color) -> Self { self.bar_color = color; self @@ -166,10 +173,13 @@ impl Widget for StreamBar { return; } - // Format: [0] ████████████──── 35.2 Gbps rtx: 0 + // Format: [0] ████████████──── 35.2 Gbps rtx: 0 (TCP) + // [0] ████████████──── 1.2 Gbps jitter: 0.42ms (UDP) let label = format!("[{}] ", self.stream_id); let throughput_str = mbps_to_human(self.throughput_mbps); - let stats = if self.retransmits > 0 { + let stats = if let Some(jitter) = self.jitter_ms { + format!(" {} jitter: {:.2}ms", throughput_str, jitter) + } else if self.retransmits > 0 { format!(" {} rtx: {}", throughput_str, self.retransmits) } else { format!(" {}", throughput_str) diff --git a/tests/integration.rs b/tests/integration.rs index e79f4fb..4d0d7fc 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -57,6 +57,7 @@ async fn test_tcp_single_stream() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -96,6 +97,7 @@ async fn test_tcp_multi_stream() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -128,6 +130,7 @@ async fn test_connection_refused() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -160,6 +163,7 @@ async fn test_tcp_download() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -197,6 +201,7 @@ async fn test_tcp_bidir() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -234,6 +239,7 @@ async fn test_udp_upload() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -272,6 +278,7 @@ async fn test_udp_download() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -307,6 +314,7 @@ async fn test_udp_bidir() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -344,6 +352,7 @@ async fn test_udp_multi_stream() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -386,6 +395,7 @@ async fn test_multi_client_concurrent() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let config2 = ClientConfig { @@ -405,6 +415,7 @@ async fn test_multi_client_concurrent() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client1 = Client::new(config1); @@ -490,6 +501,7 @@ async fn test_psk_auth_success() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -531,6 +543,7 @@ async fn test_psk_auth_failure() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -572,6 +585,7 @@ async fn test_psk_auth_missing_client_key() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -611,6 +625,7 @@ async fn test_acl_allow() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -647,6 +662,7 @@ async fn test_rate_limit() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client1 = Client::new(config1.clone()); @@ -673,6 +689,7 @@ async fn test_rate_limit() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client2 = Client::new(config2); @@ -720,6 +737,7 @@ async fn test_quic_upload() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -758,6 +776,7 @@ async fn test_quic_download() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -795,6 +814,7 @@ async fn test_quic_multi_stream() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -836,6 +856,7 @@ async fn test_quic_bidir() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -874,6 +895,7 @@ async fn test_quic_with_psk() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -914,6 +936,7 @@ async fn test_acl_deny() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -969,6 +992,7 @@ async fn test_ipv6_localhost() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -1011,6 +1035,7 @@ async fn test_tcp_infinite_duration_with_cancel() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -1061,6 +1086,7 @@ async fn test_udp_infinite_duration_with_cancel() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -1107,6 +1133,7 @@ async fn test_quic_infinite_duration_with_cancel() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -1175,6 +1202,7 @@ async fn test_udp_ipv4_explicit() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -1230,6 +1258,7 @@ async fn test_udp_ipv6_explicit() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -1285,6 +1314,7 @@ async fn test_udp_cport_dualstack_ipv6_target() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -1324,6 +1354,7 @@ async fn test_tcp_cport_single_stream() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -1366,6 +1397,7 @@ async fn test_tcp_cport_multi_stream() { sequential_ports: true, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -1424,6 +1456,7 @@ async fn test_tcp_cport_dualstack_ipv6_target() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -1478,6 +1511,7 @@ async fn test_quic_cport_dualstack_ipv6_target() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -1517,6 +1551,7 @@ async fn test_udp_invalid_sequential_ports_config_fails() { sequential_ports: true, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -1563,6 +1598,7 @@ async fn test_udp_bitrate_underflow_regression() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -1632,6 +1668,7 @@ async fn test_quic_ipv6() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -1687,6 +1724,7 @@ async fn test_tcp_one_off_multi_stream() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -1751,6 +1789,7 @@ async fn test_quic_one_off() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config); @@ -1794,6 +1833,7 @@ fn test_pause_not_ready_before_test_run() { sequential_ports: false, mptcp: false, random_payload: false, + dscp: None, }; let client = Client::new(config);