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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- **QUIC tests now warn about ignored flags** — `-b/--bitrate`, `-w/--window`, `--congestion`, and `--tcp-nodelay` have never applied to QUIC (pacing and socket tuning are not implemented on that path), but the client accepted them silently, so a rate-limited or buffer-tuned QUIC test ran untuned with no indication. Each now prints a warning, matching what `--dscp` already did. Actually implementing QUIC pacing remains on the roadmap.
- **The server log now answers "which client was that"** — every control connection logs the client software string and protocol version, plus a line listing any server capabilities the client doesn't share (i.e., the features that silently fall back for that session). Previously the log showed only the peer address.
- **`--tcp-nodelay` now propagates to the server** — the flag previously shaped only client-created sockets; the client now forwards the request in `TestStart.tcp_nodelay` (wire-additive, absent = false) and the server ORs it into its data-socket configuration. The server has always enabled `TCP_NODELAY` on its data sockets, so the OR cannot regress existing behavior — the field makes the client's intent explicit on the wire and keeps it honored if the server-side default ever changes. Older servers ignore the field, an acceptable degradation since they already run with nodelay on (same call as `window_size`; no new capability).

### Library API (pre-1.0 break)
- `client::ClientConfig` gains `connect_timeout: Option<Duration>` (`Default` is `None`); struct-literal constructors must supply it.
- `protocol::ControlMessage::TestStart` gains `tcp_nodelay: bool` (serde-default, omitted when false); struct-literal constructors must supply it.
- `protocol::ControlMessage::TestAck` gains `udp_token: Option<String>` (serde-default, wire-additive); struct-literal constructors must supply it.
- `udp::receive_udp` and `udp::respond_mtu_probes` gain a trailing `single_port_ack: Option<[u8; 24]>` parameter.
- New public items: `udp::{UdpHelloPacket, SharedSocketLane, classify_shared_datagram, single_port_udp_handshake, respond_single_port_hellos, encode_hello_token, parse_hello_token, UDP_HELLO_*}`; `net::{create_shared_udp_socket, create_connected_udp_same_port, single_port_udp_self_test}`; `quic::XfrHelloDatagram`; `protocol::{SINGLE_PORT_UDP_CAPABILITY, supported_capabilities, supported_capabilities_without}`; `ControlMessage::{server_hello_with_capabilities, server_hello_with_auth_and_capabilities}`.
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@
- [ ] **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). `--probe-mtu` (v0.9.17) answers "what size survives" — this is the complement: actually *running the test* at that size
- [ ] **QUIC silently drops TestStart parameters** - the QUIC server path pattern-matches TestStart with `..` and ignores `bitrate`, `window_size`, and `dscp`; `-b 100M -Q` runs unpaced with no warning. Minimum fix: warn on every ignored flag (like `--dscp` already does client-side). Real fix: pace QUIC sends with the same byte-budget loop UDP uses
- [ ] **Connect timeout** (`--connect-timeout`) - a client pointed at a dead or filtered server waits on OS TCP defaults (can be minutes). CI/scripted use needs a bounded, configurable timeout with a clean error. Verify current behavior per-protocol first (TCP control connect, QUIC handshake, UDP hello budget)
- [ ] **Propagate `--tcp-nodelay` to the server** - today it shapes client sends only; in `-R`/`--bidir` the server is the latency-relevant sender and never hears about it. Wire-additive TestStart field + capability fallback, same pattern as `zerocopy`
- [x] **Propagate `--tcp-nodelay` to the server** - shipped as `TestStart.tcp_nodelay` (wire-additive, same threading as `zerocopy`). The client's request ORs into the server's data-socket nodelay default; no capability was added because the server already runs its data sockets with `TCP_NODELAY` always-on, so an old server ignoring the field degrades to identical behavior (same call as `window_size`)
- [ ] **Test by amount** (`-n`/`--bytes`) - "send exactly 1 GiB then stop" instead of time-based; gives comparable runs across link speeds for CI and benchmarking. iperf3 parity (`-n`/`-k`)
- [ ] **Result metadata passthrough** (`--title`, `--extra-data`) - tag JSON/CSV results with a run label or arbitrary metadata for CI log ingestion; pairs with the JSON-schema item below
- [ ] **Get server output** (`--get-server-output`) - return server's JSON result to client (iperf3 parity)
Expand Down
10 changes: 6 additions & 4 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,7 @@ impl Client {
window_size: self.config.window_size.map(|w| w as u64),
zerocopy: self.config.protocol == Protocol::Tcp && self.config.zerocopy.enabled(),
mtu_probe: self.config.mtu_probe,
tcp_nodelay: self.config.protocol == Protocol::Tcp && self.config.tcp_nodelay,
};
writer
.write_all(format!("{}\n", test_start.serialize()?).as_bytes())
Expand Down Expand Up @@ -1852,10 +1853,11 @@ impl Client {
bitrate: self.config.bitrate,
congestion: None,
mptcp: false,
dscp: None, // QUIC manages its own TOS via the transport layer
window_size: None, // QUIC flow control is handled at the transport layer
zerocopy: false, // sendfile is incompatible with QUIC's userspace encryption
mtu_probe: false, // probe mode is UDP-only
dscp: None, // QUIC manages its own TOS via the transport layer
window_size: None, // QUIC flow control is handled at the transport layer
zerocopy: false, // sendfile is incompatible with QUIC's userspace encryption
mtu_probe: false, // probe mode is UDP-only
tcp_nodelay: false, // QUIC manages its own transport; Nagle is a TCP concept
};
ctrl_send
.write_all(format!("{}\n", test_start.serialize()?).as_bytes())
Expand Down
61 changes: 61 additions & 0 deletions src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,16 @@ pub enum ControlMessage {
/// probes as junk data and never reply).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
mtu_probe: bool,
/// Propagate the client's `--tcp-nodelay` to the server's TCP data
/// sockets — in download/bidir the server is the latency-relevant
/// bulk sender and previously never heard about the flag. The
/// server ORs this into its own nodelay default rather than
/// replacing it, so the field can only ever add `TCP_NODELAY`,
/// never disable it. Wire-additive: absent means false, and older
/// servers ignore the field and keep their default (same
/// acceptable-degradation call as `window_size`, no capability).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
tcp_nodelay: bool,
},
TestAck {
id: String,
Expand Down Expand Up @@ -613,6 +623,7 @@ mod tests {
window_size: None,
zerocopy: false,
mtu_probe: false,
tcp_nodelay: false,
};
let json = msg.serialize().unwrap();
let decoded = ControlMessage::deserialize(&json).unwrap();
Expand Down Expand Up @@ -662,6 +673,7 @@ mod tests {
window_size: Some(262_144),
zerocopy: false,
mtu_probe: false,
tcp_nodelay: false,
};
let json = msg.serialize().unwrap();
assert!(json.contains("\"window_size\":262144"));
Expand Down Expand Up @@ -692,6 +704,7 @@ mod tests {
window_size: Some(big),
zerocopy: false,
mtu_probe: false,
tcp_nodelay: false,
};
let json = msg.serialize().unwrap();
let decoded = ControlMessage::deserialize(&json).unwrap();
Expand Down Expand Up @@ -720,6 +733,7 @@ mod tests {
window_size: None,
zerocopy: false,
mtu_probe: false,
tcp_nodelay: false,
};
let json = msg.serialize().unwrap();
assert!(
Expand All @@ -732,6 +746,52 @@ mod tests {
"false zerocopy must be skipped in serialization, got: {}",
json
);
assert!(
!json.contains("tcp_nodelay"),
"false tcp_nodelay must be skipped in serialization, got: {}",
json
);
}

#[test]
fn test_test_start_backward_compat_without_tcp_nodelay() {
// Older peers don't send the tcp_nodelay field; it must default to false.
let json = r#"{"type":"test_start","id":"x","protocol":"tcp","streams":1,"duration_secs":5,"direction":"download"}"#;
let decoded = ControlMessage::deserialize(json).unwrap();
match decoded {
ControlMessage::TestStart { tcp_nodelay, .. } => {
assert!(!tcp_nodelay, "absent field must deserialize to false");
}
_ => panic!("wrong message type"),
}
}

#[test]
fn test_test_start_roundtrip_with_tcp_nodelay() {
let msg = ControlMessage::TestStart {
id: "x".to_string(),
protocol: Protocol::Tcp,
streams: 1,
duration_secs: 5,
direction: Direction::Download,
bitrate: None,
congestion: None,
mptcp: false,
dscp: None,
window_size: None,
zerocopy: false,
mtu_probe: false,
tcp_nodelay: true,
};
let json = msg.serialize().unwrap();
assert!(json.contains("\"tcp_nodelay\":true"));
let decoded = ControlMessage::deserialize(&json).unwrap();
match decoded {
ControlMessage::TestStart { tcp_nodelay, .. } => {
assert!(tcp_nodelay);
}
_ => panic!("wrong message type"),
}
}

#[test]
Expand Down Expand Up @@ -762,6 +822,7 @@ mod tests {
window_size: None,
zerocopy: true,
mtu_probe: false,
tcp_nodelay: false,
};
let json = msg.serialize().unwrap();
assert!(json.contains("\"zerocopy\":true"));
Expand Down
39 changes: 37 additions & 2 deletions src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,26 @@ fn client_window_to_host(window: Option<u64>) -> Option<usize> {
})
}

/// The server has always enabled `TCP_NODELAY` on its data sockets,
/// independent of any client setting — harmless for bulk transfers
/// (Nagle never delays full-MSS writes) and it keeps the final partial
/// segment from stalling at end of test.
const SERVER_DATA_NODELAY: bool = true;

/// Effective nodelay for a server-side TCP data socket.
///
/// The client's `--tcp-nodelay` request (`TestStart.tcp_nodelay`) ORs
/// into the server's own default rather than replacing it: a client
/// that passes the flag is guaranteed nodelay on the server's sends
/// (the latency-relevant direction in `-R`/`--bidir`), while a client
/// that doesn't cannot turn off the historical always-on behavior.
/// With today's default the OR is identity-true; the threading exists
/// so the client's request stays honored if the server-side default
/// ever changes.
fn server_data_nodelay(client_requested: bool) -> bool {
client_requested || SERVER_DATA_NODELAY
}

fn initial_read_timeout_for_peer(
active_tests: &HashMap<String, ActiveTest>,
peer_ip: std::net::IpAddr,
Expand Down Expand Up @@ -1636,6 +1656,7 @@ async fn handle_test_request(
window_size,
zerocopy,
mtu_probe,
tcp_nodelay,
} => {
// Validate stream count
if streams == 0 || streams > MAX_STREAMS {
Expand Down Expand Up @@ -1747,6 +1768,7 @@ async fn handle_test_request(
window_size,
zerocopy,
mtu_probe,
tcp_nodelay,
)
.await;

Expand Down Expand Up @@ -2138,6 +2160,7 @@ async fn run_test(
client_window_size: Option<u64>,
zerocopy: bool,
mtu_probe: bool,
tcp_nodelay: bool,
) -> anyhow::Result<(u64, u64, f64)> {
let mut line = String::new();

Expand Down Expand Up @@ -2350,6 +2373,7 @@ async fn run_test(
dscp,
client_window_size,
zerocopy,
tcp_nodelay,
)
.await
});
Expand Down Expand Up @@ -2700,7 +2724,7 @@ async fn spawn_tcp_handlers(
// Respect the client's window_size if they passed -w; otherwise leave
// kernel autotuning alone. Keep nodelay on to match historical behavior.
let config = TcpConfig {
nodelay: true,
nodelay: SERVER_DATA_NODELAY,
window_size: client_window_to_host(client_window_size),
congestion: congestion.clone(),
random_payload: true,
Expand Down Expand Up @@ -2831,6 +2855,7 @@ async fn spawn_tcp_stream_handlers(
dscp: Option<u8>,
client_window_size: Option<u64>,
zerocopy: bool,
tcp_nodelay: bool,
) -> Vec<JoinHandle<()>> {
let per_stream_bitrate = bitrate.map(|b| {
if b == 0 {
Expand Down Expand Up @@ -2898,7 +2923,7 @@ async fn spawn_tcp_stream_handlers(
let congestion = congestion.clone();
let handle = tokio::spawn(async move {
let config = TcpConfig {
nodelay: true,
nodelay: server_data_nodelay(tcp_nodelay),
window_size: client_window_to_host(client_window_size),
congestion,
random_payload: true,
Expand Down Expand Up @@ -3414,6 +3439,16 @@ mod tests {
}
}

#[test]
fn test_server_data_nodelay_honors_client_and_never_disables() {
// A client that passed --tcp-nodelay must end up with nodelay on the
// server's data sockets (the bulk-send direction in -R/--bidir)...
assert!(server_data_nodelay(true));
// ...and a client that didn't gets exactly the server default, which
// is always-on today — so the historical behavior cannot regress.
assert_eq!(server_data_nodelay(false), SERVER_DATA_NODELAY);
}

#[test]
fn test_initial_read_timeout_for_peer_default() {
let tests: HashMap<String, ActiveTest> = HashMap::new();
Expand Down
1 change: 1 addition & 0 deletions tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2619,6 +2619,7 @@ impl RawControl {
window_size: None,
zerocopy: false,
mtu_probe: false,
tcp_nodelay: false,
};
self.writer
.write_all(format!("{}\n", start.serialize().unwrap()).as_bytes())
Expand Down
2 changes: 2 additions & 0 deletions tests/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ fn test_test_start_roundtrip() {
window_size: None,
zerocopy: false,
mtu_probe: false,
tcp_nodelay: false,
};

let json = msg.serialize().unwrap();
Expand Down Expand Up @@ -105,6 +106,7 @@ fn test_udp_test_start() {
window_size: None,
zerocopy: false,
mtu_probe: false,
tcp_nodelay: false,
};

let json = msg.serialize().unwrap();
Expand Down
Loading