Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Separate send/recv reporting in bidir tests** (issue #56) — `--bidir` now reports per-direction bytes and throughput in the summary instead of just the combined total, which was useless on asymmetric links. Plain text shows `Send: X Recv: Y (Total: Z)`; JSON adds `bytes_sent`, `bytes_received`, `throughput_send_mbps`, `throughput_recv_mbps`; CSV gets four new columns; TUI shows `↑ X / ↓ Y` in the throughput panel. Unidirectional tests are unchanged (the existing `bytes_total`/`throughput_mbps` is already the single-direction number).

### Fixed
- **Fast, accurate TCP teardown** (issue #54) — replaced the blocking `shutdown()` drain on the send path with `SO_LINGER=0` on Linux, so cancel and natural end-of-test no longer wait for bufferbloated send buffers to ACK through rate-limited paths. Fixes the "Timed out waiting 2s for N data streams to stop" warning matttbe reported with `-P 4 --mptcp -t 1sec`.
- **Sender-side byte-count accuracy** — `stats.bytes_sent` is now clamped to `tcpi_bytes_acked` before abortive close, removing a quiet ~5-10% overcount where the send-buffer tail discarded by RST was being reported as transferred. Download and bidir tests are the primary beneficiaries.
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
- [x] **CSV output format** (`--csv`) - frequently requested iperf2 feature never added to iperf3
- [x] **JSON streaming output** (`--json-stream`) - iperf3 3.18 added this, one JSON object per line
- [x] **Quiet mode** (`-q`) - suppress interval output, show only summary
- [ ] **Separate send/recv in bidir summary** (issue #56) — currently `--bidir` reports combined `bytes_total` and `throughput_mbps`. For asymmetric links this collapses two different numbers into one; users have to re-run with and without `-R`. Expose split `bytes_sent`/`bytes_received` + per-direction throughput in plain/JSON/CSV/TUI outputs while keeping the combined total for backward compat
- [x] **Separate send/recv in bidir summary** (issue #56) — `--bidir` now reports per-direction bytes and throughput in plain text, JSON, CSV, and TUI outputs. Combined total kept for backward compat

### Usability
- [x] **Server max duration** (`--max-duration`) - limit test length server-side
Expand Down
12 changes: 12 additions & 0 deletions benches/throughput.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,10 @@ fn bench_protocol_serialize_interval(c: &mut Criterion) {
lost: None,
rtt_us: None,
cwnd: None,
bytes_sent: None,
bytes_received: None,
throughput_send_mbps: None,
throughput_recv_mbps: None,
},
};

Expand Down Expand Up @@ -195,6 +199,10 @@ fn bench_protocol_deserialize_interval(c: &mut Criterion) {
lost: None,
rtt_us: None,
cwnd: None,
bytes_sent: None,
bytes_received: None,
throughput_send_mbps: None,
throughput_recv_mbps: None,
},
};
let json = msg.serialize().unwrap();
Expand All @@ -213,6 +221,10 @@ fn bench_protocol_serialize_result(c: &mut Criterion) {
streams: vec![],
tcp_info: None,
udp_stats: None,
bytes_sent: None,
bytes_received: None,
throughput_send_mbps: None,
throughput_recv_mbps: None,
};
let msg = ControlMessage::Result(result);

Expand Down
14 changes: 13 additions & 1 deletion docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,19 @@ xfr <host> --bidir # Bidirectional
xfr <host> --bidir -P 2 # Bidir with 2 streams each direction
```

Reports separate statistics for upload and download.
Plain text summary shows per-direction bytes and throughput plus the
combined total:

```
Transfer: Send: 5.00 GB Recv: 7.00 GB (Total: 12.00 GB)
Throughput: Send: 40.0 Gbps Recv: 56.0 Gbps (Total: 96.0 Gbps)
```

JSON adds `bytes_sent`, `bytes_received`, `throughput_send_mbps`, and
`throughput_recv_mbps` fields; CSV gets four matching columns; the TUI
shows `↑` / `↓` throughput in the stats panel. Unidirectional tests
continue to report only the combined number (which equals the
single-direction throughput).

## Direction Control

Expand Down
14 changes: 14 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,12 @@ pub struct TestProgress {
pub cwnd: Option<u32>,
/// Cumulative retransmits from local TCP_INFO (sender-side, for upload/bidir)
pub total_retransmits: Option<u64>,
/// Per-direction interval bytes (bidirectional tests only, from client's
/// perspective). `bytes_sent` is what the client sent this interval.
pub bytes_sent: Option<u64>,
pub bytes_received: Option<u64>,
pub throughput_send_mbps: Option<f64>,
pub throughput_recv_mbps: Option<f64>,
}

pub struct Client {
Expand Down Expand Up @@ -531,6 +537,10 @@ impl Client {
cwnd,
total_retransmits,
streams,
bytes_sent: aggregate.bytes_sent,
bytes_received: aggregate.bytes_received,
throughput_send_mbps: aggregate.throughput_send_mbps,
throughput_recv_mbps: aggregate.throughput_recv_mbps,
})
.await;
}
Expand Down Expand Up @@ -1324,6 +1334,10 @@ impl Client {
cwnd: aggregate.cwnd,
total_retransmits: None,
streams,
bytes_sent: aggregate.bytes_sent,
bytes_received: aggregate.bytes_received,
throughput_send_mbps: aggregate.throughput_send_mbps,
throughput_recv_mbps: aggregate.throughput_recv_mbps,
})
.await;
}
Expand Down
4 changes: 4 additions & 0 deletions src/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ mod tests {
bytes_acked: None,
}),
udp_stats: None,
bytes_sent: None,
bytes_received: None,
throughput_send_mbps: None,
throughput_recv_mbps: None,
}
}

Expand Down
8 changes: 8 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1295,6 +1295,10 @@ fn build_fallback_result(cumulative_bytes: u64, elapsed_ms: u64) -> xfr::protoco
streams: vec![],
tcp_info: None,
udp_stats: None,
bytes_sent: None,
bytes_received: None,
throughput_send_mbps: None,
throughput_recv_mbps: None,
}
}

Expand Down Expand Up @@ -2097,6 +2101,10 @@ mod tests {
rtt_us: None,
cwnd: None,
total_retransmits,
bytes_sent: None,
bytes_received: None,
throughput_send_mbps: None,
throughput_recv_mbps: None,
}
}

Expand Down
17 changes: 14 additions & 3 deletions src/output/csv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,19 @@ use crate::protocol::{TestResult, TimestampFormat};
pub fn output_csv(result: &TestResult) -> String {
let mut output = String::new();

// Header
output.push_str("test_id,duration_secs,transfer_bytes,throughput_mbps,retransmits,jitter_ms,lost,lost_percent\n");
// Header. bytes_sent / bytes_received / throughput_send_mbps /
// throughput_recv_mbps are populated only for bidirectional tests;
// unidirectional tests leave those columns empty.
output.push_str(
"test_id,duration_secs,transfer_bytes,throughput_mbps,retransmits,jitter_ms,lost,lost_percent,bytes_sent,bytes_received,throughput_send_mbps,throughput_recv_mbps\n",
);

let fmt_u64 = |v: Option<u64>| v.map(|n| n.to_string()).unwrap_or_default();
let fmt_f64 = |v: Option<f64>| v.map(|n| format!("{:.2}", n)).unwrap_or_default();

// Summary row
output.push_str(&format!(
"{},{:.2},{},{:.2},{},{:.2},{},{:.2}\n",
"{},{:.2},{},{:.2},{},{:.2},{},{:.2},{},{},{},{}\n",
result.id,
result.duration_ms as f64 / 1000.0,
result.bytes_total,
Expand All @@ -28,6 +35,10 @@ pub fn output_csv(result: &TestResult) -> String {
.as_ref()
.map(|u| u.lost_percent)
.unwrap_or(0.0),
fmt_u64(result.bytes_sent),
fmt_u64(result.bytes_received),
fmt_f64(result.throughput_send_mbps),
fmt_f64(result.throughput_recv_mbps),
));

output
Expand Down
72 changes: 64 additions & 8 deletions src/output/plain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,38 @@ pub fn output_plain(result: &TestResult, mptcp: bool) -> String {
" Duration: {:.2}s\n",
result.duration_ms as f64 / 1000.0
));
output.push_str(&format!(
" Transfer: {}\n",
bytes_to_human(result.bytes_total)
));
output.push_str(&format!(
" Throughput: {}\n",
mbps_to_human(result.throughput_mbps)
));

// Bidirectional tests: show per-direction bytes/throughput alongside the total.
// Unidirectional tests report only the combined number (which equals the
// single-direction throughput anyway).
if let (Some(sent), Some(recv), Some(ts_send), Some(ts_recv)) = (
result.bytes_sent,
result.bytes_received,
result.throughput_send_mbps,
result.throughput_recv_mbps,
) {
output.push_str(&format!(
" Transfer: Send: {} Recv: {} (Total: {})\n",
bytes_to_human(sent),
bytes_to_human(recv),
bytes_to_human(result.bytes_total)
));
output.push_str(&format!(
" Throughput: Send: {} Recv: {} (Total: {})\n",
mbps_to_human(ts_send),
mbps_to_human(ts_recv),
mbps_to_human(result.throughput_mbps)
));
} else {
output.push_str(&format!(
" Transfer: {}\n",
bytes_to_human(result.bytes_total)
));
output.push_str(&format!(
" Throughput: {}\n",
mbps_to_human(result.throughput_mbps)
));
}
output.push('\n');

if let Some(ref tcp_info) = result.tcp_info {
Expand Down Expand Up @@ -158,6 +182,10 @@ mod tests {
bytes_acked: None,
}),
udp_stats: None,
bytes_sent: None,
bytes_received: None,
throughput_send_mbps: None,
throughput_recv_mbps: None,
}
}

Expand All @@ -174,6 +202,34 @@ mod tests {
assert!(output.contains("Sender TCP Info (initial subflow):\n"));
}

#[test]
fn test_output_plain_bidir_shows_split() {
// Bidirectional result populates the split fields — output should render
// Send/Recv/Total lines so asymmetric throughput is visible.
let mut result = make_result_with_tcp_info();
result.bytes_sent = Some(5_000_000_000);
result.bytes_received = Some(7_000_000_000);
result.throughput_send_mbps = Some(40_000.0);
result.throughput_recv_mbps = Some(56_000.0);
result.bytes_total = 12_000_000_000;
result.throughput_mbps = 96_000.0;

let output = output_plain(&result, false);
assert!(output.contains("Send:"), "expected split 'Send:' line");
assert!(output.contains("Recv:"), "expected split 'Recv:' line");
assert!(output.contains("(Total:"), "expected combined total");
}

#[test]
fn test_output_plain_unidir_no_split() {
// Unidirectional result leaves Options None — output stays single-line.
let output = output_plain(&make_result_with_tcp_info(), false);
assert!(
!output.contains("Send:") && !output.contains("Recv:"),
"unidir output must not show split lines"
);
}

#[test]
fn test_interval_plain_tcp_shows_rtx_rtt() {
let output = output_interval_plain(
Expand Down
26 changes: 26 additions & 0 deletions src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,19 @@ pub struct AggregateInterval {
pub rtt_us: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwnd: Option<u32>,
/// Bidirectional test: bytes sent by the reporting side during this interval.
/// Populated only for bidir tests; None for unidirectional (where `bytes` is authoritative).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bytes_sent: Option<u64>,
/// Bidirectional test: bytes received by the reporting side during this interval.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bytes_received: Option<u64>,
/// Bidirectional test: per-direction throughput (upload) in Mbps.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub throughput_send_mbps: Option<f64>,
/// Bidirectional test: per-direction throughput (download) in Mbps.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub throughput_recv_mbps: Option<f64>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand All @@ -224,6 +237,19 @@ pub struct TestResult {
pub tcp_info: Option<TcpInfoSnapshot>,
#[serde(skip_serializing_if = "Option::is_none")]
pub udp_stats: Option<UdpStats>,
/// Bidirectional test: total bytes sent by the reporting side.
/// Populated only for bidir tests; None for unidirectional (where `bytes_total` is authoritative).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bytes_sent: Option<u64>,
/// Bidirectional test: total bytes received by the reporting side.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bytes_received: Option<u64>,
/// Bidirectional test: per-direction throughput (upload) in Mbps.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub throughput_send_mbps: Option<f64>,
/// Bidirectional test: per-direction throughput (download) in Mbps.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub throughput_recv_mbps: Option<f64>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
Loading