feat(cli): add server applet #83
No files matched your search
@@ -51,7 +51,7 @@ Main boundary:
|
||||
- `BlockingManagedClient`: synchronous owner using a dedicated current-thread Tokio runtime.
|
||||
- `irtt-server`: exists; see "Server architecture (`irtt-server`)" below.
|
||||
- `irtt-stats`: cumulative/rolling/finite statistics over client events. No wire/session state machine logic.
|
||||
- `irtt-cli`: argument parsing, managed orchestration, output, summaries, TUI, diagnostics, and exit behavior. Do not duplicate protocol/session state machines here.
|
||||
- `irtt-cli`: argument parsing, managed orchestration, output, summaries, TUI, server applet orchestration, diagnostics, and exit behavior. Do not duplicate protocol/session state machines here.
|
||||
|
||||
Tokio must remain optional for `irtt-client`; default builds must remain runtime-free and Tokio-free unless an explicit architectural change says otherwise. This rule is specific to `irtt-client` — do not generalize it to `irtt-server`, which has the opposite policy below.
|
||||
|
||||
@@ -65,7 +65,7 @@ Tokio must remain optional for `irtt-client`; default builds must remain runtime
|
||||
- There is currently no requirement for a blocking server API, alternate async runtimes, a runtime abstraction layer, or a runtime-free server feature. Do not build blocking/runtime variants merely for symmetry with the client, and do not invent a mirrored client-style API family (`BlockingServer`, `AsyncServer`, `ManagedServer`, `BlockingManagedServer`, or similar) ahead of agreement.
|
||||
- Protocol/session logic should be separated from socket/runtime orchestration where that materially improves deterministic testing, ownership clarity, state-machine reasoning, or maintainability. A `ServerCore`-like internal boundary is therefore desirable, but its exact name/API is not prescribed, it need not be public, it is not a promise of runtime independence, and simple code should not be tortured merely to make the core artificially pure.
|
||||
- Runtime/server orchestration belongs around that core and may freely use Tokio for UDP sockets, `recv_from`/`send_to`, IPv4/IPv6 listeners, timers, session expiry, max-duration deadlines, shutdown, bounded control/config channels where useful, and `select!`-style orchestration.
|
||||
- The CLI server applet, when added, should remain thin orchestration/configuration over reusable `irtt-server`, not contain a second server state machine.
|
||||
- The CLI server applet exists behind the `irtt-cli` `server` feature, which is part of that crate's default build. It is thin orchestration/configuration over reusable `irtt-server` — one current-thread Tokio runtime, one explicitly bound listener, one `Server` — and must not grow a second server state machine. Multi-listener orchestration, hostname resolution, daemonization, and configuration files are deliberately absent.
|
||||
- Resource policy: the server should use bounded session/resource policy. Upstream's observed lack of useful default session/per-peer bounds, and its never expiring a session that has not carried an echo request (see `IRTT_SERVER_PROTOCOL_SPEC.md`), are not interoperability requirements and must not be reproduced merely for compatibility.
|
||||
- Testing: prefer `irtt-server` for normal compliant fake-peer behavior where practical, as far as its implemented behavior reaches. Keep narrow adversarial/raw peers for intentionally malformed, ambiguous, or non-compliant wire behavior.
|
||||
|
||||
|
||||
Generated
+2
@@ -701,8 +701,10 @@ dependencies = [
|
||||
"ctrlc",
|
||||
"irtt-client",
|
||||
"irtt-proto",
|
||||
"irtt-server",
|
||||
"irtt-stats",
|
||||
"ratatui",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# irtt-rs
|
||||
|
||||
`irtt-rs` is a Rust implementation of an IRTT-compatible client.
|
||||
`irtt-rs` is a Rust implementation of an IRTT-compatible client and server.
|
||||
|
||||
It provides both a command-line client and a reusable Rust library for finite or continuous latency probing. It is intended for interactive diagnostics, scripting, monitoring, and integration into applications such as network autorate controllers.
|
||||
It provides command-line applets and reusable Rust libraries for finite or continuous latency probing, plus the server side those probes can run against. It is intended for interactive diagnostics, scripting, monitoring, and integration into applications such as network autorate controllers.
|
||||
|
||||
This is not the upstream IRTT project. For the original implementation, protocol background, and broader documentation, see [heistp/irtt](https://github.com/heistp/irtt).
|
||||
|
||||
@@ -16,10 +16,10 @@ This is not the upstream IRTT project. For the original implementation, protocol
|
||||
* Selectable output columns
|
||||
* Automatic final summaries for eligible table-output runs
|
||||
* Optional terminal UI
|
||||
* Reusable Tokio-native server library
|
||||
* UDP server applet with explicit bind and session policy options
|
||||
* Router-friendly multi-applet binary layout
|
||||
|
||||
Server support is not implemented.
|
||||
|
||||
The installed dispatcher is named `irtt-rs` rather than `irtt` to avoid conflicting with or being mistaken for upstream IRTT.
|
||||
|
||||
## Installation
|
||||
@@ -38,6 +38,7 @@ This installs:
|
||||
|
||||
* `irtt-rs`, the canonical multi-applet dispatcher
|
||||
* `irtt-cli`, the stream and text client
|
||||
* `irtt-server`, the UDP server
|
||||
|
||||
To also install the optional terminal UI:
|
||||
|
||||
@@ -165,6 +166,51 @@ irtt-tui host-a:2112 host-b:2112 --pacing burst
|
||||
|
||||
Quit with `q` or `Ctrl-C`.
|
||||
|
||||
## Server
|
||||
|
||||
The server applet serves one UDP listener and requires an explicit bind address:
|
||||
|
||||
```sh
|
||||
irtt-server --bind 127.0.0.1:2112
|
||||
```
|
||||
|
||||
The same applet is reachable through the dispatcher:
|
||||
|
||||
```sh
|
||||
irtt-rs server --bind 192.0.2.10:2112
|
||||
```
|
||||
|
||||
An explicit interface address is preferred. A wildcard bind such as
|
||||
`--bind 0.0.0.0:2112` is accepted, but reply source-address selection is then
|
||||
left to the kernel: per-packet destination-address handling for multi-homed
|
||||
hosts is not implemented. One process serves exactly one address, so run a
|
||||
second process to serve a second address or family.
|
||||
|
||||
The bound endpoint is printed on startup, which also resolves a port of `0`.
|
||||
`Ctrl-C` stops the server gracefully.
|
||||
|
||||
Session policy is set with options that map directly onto the server library's
|
||||
configuration; anything left unset keeps the library default:
|
||||
|
||||
```sh
|
||||
irtt-server \
|
||||
--bind 192.0.2.10:2112 \
|
||||
--max-sessions 512 \
|
||||
--idle-timeout 30s
|
||||
```
|
||||
|
||||
To require authentication, pass the shared key both sides use:
|
||||
|
||||
```sh
|
||||
irtt-server --bind 192.0.2.10:2112 --hmac secret
|
||||
```
|
||||
|
||||
For the full option list:
|
||||
|
||||
```sh
|
||||
irtt-server --help
|
||||
```
|
||||
|
||||
## Library usage
|
||||
|
||||
`irtt-client` exposes the client session and event layer independently of CLI formatting and statistics.
|
||||
@@ -222,16 +268,22 @@ synchronous callers. Callers that already own Tokio can drive
|
||||
## Binaries and features
|
||||
|
||||
| Build | Binaries |
|
||||
| ----------------------- | --------------------------------- |
|
||||
| ----------------------------------------- | ------------------------------------------------- |
|
||||
| `--no-default-features` | `irtt-rs` |
|
||||
| Default features | `irtt-rs`, `irtt-cli` |
|
||||
| `--features tui` | `irtt-rs`, `irtt-cli`, `irtt-tui` |
|
||||
| `--all-features` | `irtt-rs`, `irtt-cli`, `irtt-tui` |
|
||||
| `--no-default-features --features server` | `irtt-rs`, `irtt-server` |
|
||||
| Default features | `irtt-rs`, `irtt-cli`, `irtt-server` |
|
||||
| `--features tui` | `irtt-rs`, `irtt-cli`, `irtt-server`, `irtt-tui` |
|
||||
| `--all-features` | `irtt-rs`, `irtt-cli`, `irtt-server`, `irtt-tui` |
|
||||
|
||||
`irtt-cli` requires the `client` feature, `irtt-server` the `server` feature,
|
||||
and `irtt-tui` the `tui` feature. The `irtt-rs` dispatcher is always built, and
|
||||
reports which applets the build actually contains.
|
||||
|
||||
For available command-line options:
|
||||
|
||||
```sh
|
||||
irtt-cli --help
|
||||
irtt-server --help
|
||||
irtt-tui --help
|
||||
```
|
||||
|
||||
@@ -250,4 +302,8 @@ cargo build -p irtt-cli --all-features --release
|
||||
|
||||
The client, event stream, machine-readable output, multi-target execution, local statistics, and optional TUI are implemented.
|
||||
|
||||
Server support is not implemented.
|
||||
The server library and the server applet are implemented: open negotiation,
|
||||
session state, echo processing, per-session rate limiting, idle expiry, the
|
||||
maximum-duration close, and HMAC authentication. DSCP application, the full
|
||||
server fill policy, per-packet source-address selection for wildcard binds on
|
||||
multi-homed hosts, and multiple listeners in one process are not implemented.
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "irtt-cli"
|
||||
version = "0.4.0"
|
||||
description = "Command-line IRTT-compatible client"
|
||||
description = "Command-line IRTT-compatible client and server"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
@@ -28,13 +28,22 @@ name = "irtt-tui"
|
||||
path = "src/bin/irtt-tui.rs"
|
||||
required-features = ["tui"]
|
||||
|
||||
[[bin]]
|
||||
name = "irtt-server"
|
||||
path = "src/bin/irtt-server.rs"
|
||||
required-features = ["server"]
|
||||
# This applet shares its name with the irtt-server library crate, whose rustdoc
|
||||
# output it would otherwise collide with.
|
||||
doc = false
|
||||
|
||||
[features]
|
||||
default = ["client", "stats"]
|
||||
default = ["client", "server", "stats"]
|
||||
client-runtime = ["dep:irtt-client", "irtt-client/tokio", "dep:irtt-proto"]
|
||||
client = ["client-runtime"]
|
||||
server = ["dep:irtt-server", "dep:tokio"]
|
||||
stats = ["client-runtime", "dep:irtt-stats"]
|
||||
tui = ["client-runtime", "stats", "dep:ratatui", "dep:crossterm"]
|
||||
full = ["client", "stats", "tui"]
|
||||
full = ["client", "server", "stats", "tui"]
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.6.6", features = ["derive"] }
|
||||
@@ -42,5 +51,7 @@ crossterm = { version = "0.29.0", optional = true }
|
||||
ctrlc = "3.5.2"
|
||||
irtt-client = { workspace = true, optional = true }
|
||||
irtt-proto = { workspace = true, optional = true }
|
||||
irtt-server = { workspace = true, optional = true }
|
||||
irtt-stats = { workspace = true, optional = true }
|
||||
ratatui = { version = "0.30.2", optional = true }
|
||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "time"], optional = true }
|
||||
@@ -81,7 +81,7 @@ pub fn dispatcher_help() -> String {
|
||||
.about("IRTT-compatible multi-applet dispatcher")
|
||||
.subcommand(Command::new("client").about(client_applet_about()))
|
||||
.subcommand(Command::new("tui").about(tui_applet_about()))
|
||||
.subcommand(Command::new("server").about("Server applet is not available in this build"))
|
||||
.subcommand(Command::new("server").about(server_applet_about()))
|
||||
.after_help(dispatcher_after_help());
|
||||
command.render_help().to_string()
|
||||
}
|
||||
@@ -102,6 +102,14 @@ fn tui_applet_about() -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn server_applet_about() -> &'static str {
|
||||
if cfg!(feature = "server") {
|
||||
"Run the UDP server applet"
|
||||
} else {
|
||||
"UDP server applet is not available in this build"
|
||||
}
|
||||
}
|
||||
|
||||
fn known_applet_binary_names() -> &'static str {
|
||||
"irtt-rs, irtt-cli, irtt-tui, irtt-server"
|
||||
}
|
||||
@@ -110,14 +118,26 @@ fn applet_command_names() -> &'static str {
|
||||
"client, tui, or server"
|
||||
}
|
||||
|
||||
fn dispatcher_after_help() -> &'static str {
|
||||
if cfg!(feature = "tui") {
|
||||
"Recognized applet binary names: irtt-cli, irtt-tui, irtt-server"
|
||||
} else if cfg!(feature = "client") {
|
||||
"Recognized applet binary names: irtt-cli, irtt-tui, irtt-server\nOptional applet: irtt-tui requires the tui feature"
|
||||
} else {
|
||||
"Recognized applet binary names: irtt-cli, irtt-tui, irtt-server\nOptional applets: irtt-cli requires the client feature; irtt-tui requires the tui feature"
|
||||
fn dispatcher_after_help() -> String {
|
||||
let mut help = "Recognized applet binary names: irtt-cli, irtt-tui, irtt-server".to_owned();
|
||||
|
||||
let mut missing = Vec::new();
|
||||
if !cfg!(feature = "client") {
|
||||
missing.push("irtt-cli requires the client feature");
|
||||
}
|
||||
if !cfg!(feature = "server") {
|
||||
missing.push("irtt-server requires the server feature");
|
||||
}
|
||||
if !cfg!(feature = "tui") {
|
||||
missing.push("irtt-tui requires the tui feature");
|
||||
}
|
||||
|
||||
match missing.len() {
|
||||
0 => {}
|
||||
1 => help.push_str(&format!("\nOptional applet: {}", missing[0])),
|
||||
_ => help.push_str(&format!("\nOptional applets: {}", missing.join("; "))),
|
||||
}
|
||||
help
|
||||
}
|
||||
|
||||
fn applet_basename(argv0: &str) -> &str {
|
||||
@@ -170,6 +190,11 @@ mod tests {
|
||||
assert!(help.contains("Terminal UI applet is not available in this build"));
|
||||
}
|
||||
assert!(help.contains("server"));
|
||||
if cfg!(feature = "server") {
|
||||
assert!(help.contains("Run the UDP server applet"));
|
||||
} else {
|
||||
assert!(help.contains("UDP server applet is not available in this build"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
irtt_cli::entry::main()
|
||||
|
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
#[cfg(feature = "client")]
|
||||
pub mod client;
|
||||
#[cfg(feature = "server")]
|
||||
pub mod server;
|
||||
#[cfg(feature = "tui")]
|
||||
pub mod tui;
|
||||
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
|
||||
use clap::Parser;
|
||||
use irtt_server::ServerConfig;
|
||||
|
||||
// Every policy option is optional. An omitted option leaves the corresponding
|
||||
// `ServerConfig` default in place rather than restating it here, so library and
|
||||
// CLI defaults cannot drift apart.
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
#[command(
|
||||
name = "irtt-server",
|
||||
about = "Minimal IRTT-compatible UDP server",
|
||||
after_help = "Policy options left unset keep the irtt-server library defaults.\nOne process serves exactly one listener; run a second process for a second address."
|
||||
)]
|
||||
pub struct ServerArgs {
|
||||
/// Local UDP address to bind, as ADDR:PORT.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "ADDR",
|
||||
long_help = "Local UDP address to bind, as ADDR:PORT, for example 127.0.0.1:2112 or [::1]:2112. Host names are not resolved.\n\nAn explicit interface address is preferred. A wildcard bind such as 0.0.0.0:2112 is allowed, but reply source-address selection is then left to the kernel: per-packet destination-address handling for multi-homed hosts is not implemented."
|
||||
)]
|
||||
pub bind: SocketAddr,
|
||||
|
||||
/// HMAC key; every request must then carry a valid MAC.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "KEY",
|
||||
long_help = "HMAC key, taken as the UTF-8 bytes of this argument.\n\nAuthentication is global: with a key configured, every request must carry a valid MAC and every reply is authenticated. Without one, authenticated requests are dropped. The key is visible in the process arguments."
|
||||
)]
|
||||
pub hmac: Option<String>,
|
||||
|
||||
/// Maximum number of simultaneously live sessions.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "COUNT",
|
||||
long_help = "Maximum number of simultaneously live sessions. Once the table is full, a session-creating open is dropped silently; nothing is evicted. Zero refuses every session-creating open."
|
||||
)]
|
||||
pub max_sessions: Option<usize>,
|
||||
|
||||
/// Maximum echo datagram size a session may negotiate, in bytes.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "BYTES",
|
||||
long_help = "Maximum echo datagram size a session may negotiate, in bytes. A longer requested length is reduced during negotiation, an open whose mandatory field block would not fit is refused, and inbound echo requests are admitted against the same limit. This is a resource bound, not an MTU."
|
||||
)]
|
||||
pub max_packet_length: Option<usize>,
|
||||
|
||||
/// Floor on the negotiated send interval, and the allowance refill cadence.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "DURATION",
|
||||
value_parser = parse_duration_allow_zero,
|
||||
long_help = "Floor on the send interval a session may negotiate, and the cadence its reply allowance refills at. The negotiated interval is still capped at a quarter of the idle timeout afterwards. Use 0 for no time-based throttling."
|
||||
)]
|
||||
pub min_interval: Option<Duration>,
|
||||
|
||||
/// Echo replies a session may burst before its allowance refills.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "COUNT",
|
||||
long_help = "Echo replies one session may have answered before its allowance has to refill. Use 0 for no allowance at all, which rate-limits every echo request whatever the interval is."
|
||||
)]
|
||||
pub burst: Option<u32>,
|
||||
|
||||
/// Release a session after this long without an echo request.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "DURATION",
|
||||
value_parser = parse_duration_allow_zero,
|
||||
long_help = "Release a session after this long without a served or rate-limited echo request. The deadline runs from the open, and release is silent. Use 0 to expire a session at the next evaluation; it is not a way to disable expiry."
|
||||
)]
|
||||
pub idle_timeout: Option<Duration>,
|
||||
|
||||
/// Maximum test duration a session may negotiate.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "DURATION",
|
||||
value_parser = parse_positive_duration,
|
||||
long_help = "Maximum test duration a session may negotiate. A longer request is reduced to it, and a continuous request is answered with it. Omit this flag for no maximum; a maximum of zero cannot be expressed, because a negotiated duration of zero means continuous."
|
||||
)]
|
||||
pub max_duration: Option<Duration>,
|
||||
}
|
||||
|
||||
impl ServerArgs {
|
||||
/// Builds the server configuration this invocation asks for.
|
||||
#[must_use]
|
||||
pub fn server_config(&self) -> ServerConfig {
|
||||
let mut config = ServerConfig::default();
|
||||
if let Some(key) = self.hmac.as_ref() {
|
||||
config = config.with_hmac_key(key.as_bytes());
|
||||
}
|
||||
if let Some(value) = self.max_sessions {
|
||||
config = config.with_max_sessions(value);
|
||||
}
|
||||
if let Some(value) = self.max_packet_length {
|
||||
config = config.with_max_packet_length(value);
|
||||
}
|
||||
if let Some(value) = self.min_interval {
|
||||
config = config.with_min_send_interval(value);
|
||||
}
|
||||
if let Some(value) = self.burst {
|
||||
config = config.with_burst_allowance(value);
|
||||
}
|
||||
if let Some(value) = self.idle_timeout {
|
||||
config = config.with_idle_timeout(value);
|
||||
}
|
||||
if let Some(value) = self.max_duration {
|
||||
config = config.with_max_test_duration(value);
|
||||
}
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a duration whose zero is meaningful server policy.
|
||||
fn parse_duration_allow_zero(input: &str) -> Result<Duration, String> {
|
||||
if input == "0" {
|
||||
return Ok(Duration::ZERO);
|
||||
}
|
||||
parse_duration_units(input)
|
||||
}
|
||||
|
||||
/// Parses a duration that must be positive, where absence means "no limit".
|
||||
fn parse_positive_duration(input: &str) -> Result<Duration, String> {
|
||||
let value = if input == "0" {
|
||||
Duration::ZERO
|
||||
} else {
|
||||
parse_duration_units(input)?
|
||||
};
|
||||
if value.is_zero() {
|
||||
return Err(
|
||||
"duration must be greater than zero; omit the option for no maximum".to_owned(),
|
||||
);
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn parse_duration_units(input: &str) -> Result<Duration, String> {
|
||||
let split = input
|
||||
.find(|ch: char| !ch.is_ascii_digit())
|
||||
.ok_or_else(|| "duration must include a unit: ms, s, or m".to_owned())?;
|
||||
let (number, unit) = input.split_at(split);
|
||||
if number.is_empty() {
|
||||
return Err("duration must include a value and a unit: ms, s, or m".to_owned());
|
||||
}
|
||||
let value: u64 = number
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid duration value {input:?}"))?;
|
||||
match unit {
|
||||
"ms" => Ok(Duration::from_millis(value)),
|
||||
"s" => Ok(Duration::from_secs(value)),
|
||||
"m" => value
|
||||
.checked_mul(60)
|
||||
.map(Duration::from_secs)
|
||||
.ok_or_else(|| "duration is too large".to_owned()),
|
||||
_ => Err(format!(
|
||||
"unsupported duration unit {unit:?}; use ms, s, or m"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn parse(args: &[&str]) -> Result<ServerArgs, clap::Error> {
|
||||
let mut argv = vec!["irtt-server"];
|
||||
argv.extend_from_slice(args);
|
||||
ServerArgs::try_parse_from(argv)
|
||||
}
|
||||
|
||||
fn bound(args: &[&str]) -> ServerArgs {
|
||||
let mut argv = vec!["--bind", "127.0.0.1:2112"];
|
||||
argv.extend_from_slice(args);
|
||||
parse(&argv).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_is_required_and_parses_both_families() {
|
||||
assert!(parse(&[]).is_err());
|
||||
|
||||
let ipv4 = parse(&["--bind", "127.0.0.1:2112"]).unwrap();
|
||||
assert_eq!(ipv4.bind, SocketAddr::from((Ipv4Addr::LOCALHOST, 2112)));
|
||||
|
||||
let ipv6 = parse(&["--bind", "[::1]:2112"]).unwrap();
|
||||
assert_eq!(ipv6.bind, SocketAddr::from((Ipv6Addr::LOCALHOST, 2112)));
|
||||
|
||||
assert!(parse(&["--bind", "127.0.0.1"]).is_err());
|
||||
assert!(parse(&["--bind", "localhost:2112"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_policy_options_keep_the_library_defaults() {
|
||||
assert_eq!(bound(&[]).server_config(), ServerConfig::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_options_map_onto_the_server_config() {
|
||||
let config = bound(&[
|
||||
"--hmac",
|
||||
"secret",
|
||||
"--max-sessions",
|
||||
"512",
|
||||
"--max-packet-length",
|
||||
"1472",
|
||||
"--min-interval",
|
||||
"20ms",
|
||||
"--burst",
|
||||
"3",
|
||||
"--idle-timeout",
|
||||
"30s",
|
||||
"--max-duration",
|
||||
"2m",
|
||||
])
|
||||
.server_config();
|
||||
|
||||
assert_eq!(config.hmac_key(), Some(b"secret".as_slice()));
|
||||
assert_eq!(config.max_sessions(), 512);
|
||||
assert_eq!(config.max_packet_length(), 1472);
|
||||
assert_eq!(config.min_send_interval(), Duration::from_millis(20));
|
||||
assert_eq!(config.burst_allowance(), 3);
|
||||
assert_eq!(config.idle_timeout(), Duration::from_secs(30));
|
||||
assert_eq!(config.max_test_duration(), Some(Duration::from_secs(120)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_zero_policy_values_are_preserved() {
|
||||
let config = bound(&[
|
||||
"--min-interval",
|
||||
"0",
|
||||
"--idle-timeout",
|
||||
"0ms",
|
||||
"--burst",
|
||||
"0",
|
||||
"--max-sessions",
|
||||
"0",
|
||||
"--max-packet-length",
|
||||
"0",
|
||||
])
|
||||
.server_config();
|
||||
|
||||
assert_eq!(config.min_send_interval(), Duration::ZERO);
|
||||
assert_eq!(config.idle_timeout(), Duration::ZERO);
|
||||
assert_eq!(config.burst_allowance(), 0);
|
||||
assert_eq!(config.max_sessions(), 0);
|
||||
assert_eq!(config.max_packet_length(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durations_require_a_supported_unit() {
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--idle-timeout", "30"]).is_err());
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--min-interval", "20us"]).is_err());
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "2h"]).is_err());
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--idle-timeout", "ms"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_maximum_duration_is_not_a_spelling_of_no_maximum() {
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0"]).is_err());
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0s"]).is_err());
|
||||
assert_eq!(bound(&[]).server_config().max_test_duration(), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod args;
|
||||
pub mod run;
|
||||
|
||||
pub use args::ServerArgs;
|
||||
pub use run::run_server;
|
||||
@@ -0,0 +1,60 @@
|
||||
use std::{
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use irtt_server::Server;
|
||||
|
||||
use super::args::ServerArgs;
|
||||
|
||||
/// How often the shutdown future samples the process-wide signal flag.
|
||||
///
|
||||
/// The flag a signal handler sets has no async wake mechanism, so the future
|
||||
/// polls it on a timer rather than spinning. `Server::run` owns everything after
|
||||
/// that future becomes ready.
|
||||
const SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
/// Runs one listener until the process is asked to shut down.
|
||||
///
|
||||
/// The applet owns exactly one current-thread Tokio runtime, one bound socket
|
||||
/// and one reusable [`Server`]; the server's own loop is the event loop.
|
||||
pub fn run_server(
|
||||
args: ServerArgs,
|
||||
shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
runtime.block_on(serve(args, shutdown_requested))
|
||||
}
|
||||
|
||||
async fn serve(
|
||||
args: ServerArgs,
|
||||
shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut server = Server::bind(args.bind, args.server_config()).await?;
|
||||
eprintln!("irtt-server: listening on {}", server.local_addr()?);
|
||||
server.run(shutdown_signal(shutdown_requested)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown_signal(shutdown_requested: &AtomicBool) {
|
||||
while !shutdown_requested.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(SHUTDOWN_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use clap::Parser;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_already_requested_shutdown_binds_and_returns() {
|
||||
let args = ServerArgs::try_parse_from(["irtt-server", "--bind", "127.0.0.1:0"]).unwrap();
|
||||
let shutdown_requested = AtomicBool::new(true);
|
||||
|
||||
run_server(args, &shutdown_requested).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ fn run_from_env() -> Result<(), Box<dyn std::error::Error>> {
|
||||
match requested {
|
||||
RequestedApplet::Client => run_client_applet(argv, shutdown_requested.as_ref()),
|
||||
RequestedApplet::Tui => run_tui_applet(argv, shutdown_requested.as_ref()),
|
||||
RequestedApplet::Server => Err("server applet is not available in this build".into()),
|
||||
RequestedApplet::Server => run_server_applet(argv, shutdown_requested.as_ref()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,25 @@ fn run_client_applet(
|
||||
Err("client applet is not available; rebuild with the client feature".into())
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
fn run_server_applet(
|
||||
argv: Vec<OsString>,
|
||||
shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use clap::Parser;
|
||||
|
||||
let args = crate::cmd::server::ServerArgs::parse_from(argv);
|
||||
crate::cmd::server::run_server(args, shutdown_requested)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "server"))]
|
||||
fn run_server_applet(
|
||||
_argv: Vec<OsString>,
|
||||
_shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Err("server applet is not available; rebuild with the server feature".into())
|
||||
}
|
||||
|
||||
#[cfg(feature = "tui")]
|
||||
fn run_tui_applet(
|
||||
argv: Vec<OsString>,
|
||||
|
||||
@@ -69,6 +69,46 @@ fn canonical_client_subcommand_reports_unavailable_when_disabled() {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
#[test]
|
||||
fn canonical_server_subcommand_dispatches_to_server() {
|
||||
let output = irtt_rs().args(["server", "--help"]).output().unwrap();
|
||||
let text = output_text(&output);
|
||||
|
||||
assert!(output.status.success(), "{text}");
|
||||
assert!(text.contains("Minimal IRTT-compatible UDP server"));
|
||||
assert!(text.contains("--bind <ADDR>"));
|
||||
assert!(text.contains("--max-sessions <COUNT>"));
|
||||
assert!(text.contains("--idle-timeout <DURATION>"));
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "server"))]
|
||||
#[test]
|
||||
fn canonical_server_subcommand_reports_unavailable_when_disabled() {
|
||||
let output = irtt_rs().args(["server", "--help"]).output().unwrap();
|
||||
let text = output_text(&output);
|
||||
|
||||
assert!(!output.status.success());
|
||||
assert!(
|
||||
text.contains("server applet is not available; rebuild with the server feature"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
#[test]
|
||||
fn standalone_server_binary_exposes_the_same_arguments() {
|
||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_irtt-server"))
|
||||
.arg("--help")
|
||||
.output()
|
||||
.unwrap();
|
||||
let text = output_text(&output);
|
||||
|
||||
assert!(output.status.success(), "{text}");
|
||||
assert!(text.contains("Minimal IRTT-compatible UDP server"));
|
||||
assert!(text.contains("--bind <ADDR>"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(feature = "client")]
|
||||
#[test]
|
||||
@@ -128,9 +168,23 @@ fn tui_applet_name_reports_unavailable_when_disabled() {
|
||||
assert!(text.contains("TUI applet is not available"), "{text}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, feature = "server"))]
|
||||
#[test]
|
||||
fn server_applet_name_reports_unavailable() {
|
||||
fn server_applet_name_dispatches_to_server_when_enabled() {
|
||||
let output = irtt_rs_with_arg0("irtt-server")
|
||||
.arg("--help")
|
||||
.output()
|
||||
.unwrap();
|
||||
let text = output_text(&output);
|
||||
|
||||
assert!(output.status.success(), "{text}");
|
||||
assert!(text.contains("Minimal IRTT-compatible UDP server"));
|
||||
assert!(text.contains("--bind <ADDR>"));
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(feature = "server")))]
|
||||
#[test]
|
||||
fn server_applet_name_reports_unavailable_when_disabled() {
|
||||
let output = irtt_rs_with_arg0("irtt-server")
|
||||
.arg("--help")
|
||||
.output()
|
||||
|
||||
Reference in new issue
Block a user
On Windows, this new binary reaches the shared dispatcher with an
argv[0]basename such asirtt-server.exe, butdetect_applet_from_argv0recognizes onlyirtt-server; the subsequentstarts_with("irtt-")branch therefore rejects every invocation as an unknown applet before Clap or the server runner can execute. Strip the platform executable suffix or recognize the.exeapplet names so the advertised server applet works on Windows.AGENTS.md reference: AGENTS.md:L68-L68
Useful? React with 👍 / 👎.
Fixed in
565d7fb:applet_basenamenow strips the platformEXE_SUFFIX(case-insensitively, no-op on Unix), soirtt-server.exedispatches correctly — the same bug also brokeirtt-cli.exe,irtt-tui.exeand even theirtt-rs.exedispatcher itself, which all fell into the unknown-applet branch, and Windows is a shipped target perdist-workspace.toml.🤖 Addressed by Claude Code