chore(app): remove unnecessary tests
This commit is contained in:
6 files changed
-1261
No files matched your search
@@ -228,41 +228,3 @@ fn process_tui_event(
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::{backend::TestBackend, Terminal};
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn subscription_lag_keeps_incomplete_statistics_visible_while_paused() {
|
||||
for paused in [false, true] {
|
||||
let (sender, mut events) = tokio::sync::broadcast::channel(1);
|
||||
sender.send(ManagedEvent::Started).unwrap();
|
||||
sender.send(ManagedEvent::Started).unwrap();
|
||||
let mut state = TuiState::default();
|
||||
if paused {
|
||||
state.toggle_pause();
|
||||
}
|
||||
let Err(RecvError::Lagged(dropped)) = events.recv().await else {
|
||||
panic!("subscription must report real broadcast overflow");
|
||||
};
|
||||
state.mark_dropped_managed_events(dropped);
|
||||
let mut terminal = Terminal::new(TestBackend::new(200, 40)).unwrap();
|
||||
for _ in 0..2 {
|
||||
terminal
|
||||
.draw(|frame| super::super::ui::draw_dashboard(frame, &state))
|
||||
.unwrap();
|
||||
let text = terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content()
|
||||
.iter()
|
||||
.map(|cell| cell.symbol())
|
||||
.collect::<String>();
|
||||
assert!(text.contains("incomplete:dropped=1"));
|
||||
assert_eq!(text.contains("display paused"), paused);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1558,229 +1558,3 @@ fn format_optional_hex(value: Option<u64>) -> String {
|
||||
fn duration_ns(value: Duration) -> i128 {
|
||||
i128::try_from(value.as_nanos()).unwrap_or(i128::MAX)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use irtt_client::{ClientTimestamp, OneWayDelaySample, PacketMeta, RttSample, ServerTiming};
|
||||
use ratatui::backend::TestBackend;
|
||||
use std::time::SystemTime;
|
||||
|
||||
fn measure(state: &mut TuiState, label: &str, seq: u32, at: Instant, effective_ns: i128) {
|
||||
let target = TargetInstance {
|
||||
id: label.into(),
|
||||
generation: 1,
|
||||
};
|
||||
let received_at = ClientTimestamp {
|
||||
mono: at,
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(1),
|
||||
};
|
||||
let sent_at = ClientTimestamp {
|
||||
mono: at - Duration::from_millis(10),
|
||||
wall: received_at.wall - Duration::from_millis(10),
|
||||
};
|
||||
let remote = "127.0.0.1:2112".parse().unwrap();
|
||||
state.process_target_event(
|
||||
&target,
|
||||
&ClientEvent::EchoSent {
|
||||
seq,
|
||||
remote,
|
||||
scheduled_at: None,
|
||||
sent_at,
|
||||
bytes: 64,
|
||||
send_call: Duration::ZERO,
|
||||
timer_error: None,
|
||||
},
|
||||
);
|
||||
state.process_target_event(
|
||||
&target,
|
||||
&ClientEvent::EchoReply {
|
||||
seq,
|
||||
remote,
|
||||
sent_at,
|
||||
received_at,
|
||||
rtt: RttSample {
|
||||
raw: Duration::from_millis(10),
|
||||
adjusted: Some(SignedDuration::from_nanos(effective_ns)),
|
||||
effective: SignedDuration::from_nanos(effective_ns),
|
||||
},
|
||||
server_timing: Some(ServerTiming {
|
||||
receive_wall_ns: None,
|
||||
receive_mono_ns: None,
|
||||
send_wall_ns: None,
|
||||
send_mono_ns: None,
|
||||
midpoint_wall_ns: None,
|
||||
midpoint_mono_ns: None,
|
||||
processing: Some(Duration::from_nanos((10_000_000 - effective_ns) as u64)),
|
||||
}),
|
||||
one_way: Some(OneWayDelaySample {
|
||||
client_to_server: Some(SignedDuration::from_nanos(-3_000_000)),
|
||||
server_to_client: Some(SignedDuration::from_nanos(effective_ns + 3_000_000)),
|
||||
}),
|
||||
received_stats: None,
|
||||
bytes: 64,
|
||||
packet_meta: PacketMeta::default(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn screen(state: &TuiState, width: u16, height: u16) -> String {
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||||
terminal.draw(|frame| draw_dashboard(frame, state)).unwrap();
|
||||
terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content()
|
||||
.chunks(usize::from(width))
|
||||
.map(|row| row.iter().map(|cell| cell.symbol()).collect::<String>())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_focus_and_details_remain_usable_across_sizes() {
|
||||
let mut state = TuiState::with_target_labels(
|
||||
TuiConfig::default(),
|
||||
(0..8).map(|i| format!("target{i}")),
|
||||
);
|
||||
let now = Instant::now();
|
||||
measure(
|
||||
&mut state,
|
||||
"target7",
|
||||
0,
|
||||
now - Duration::from_secs(5),
|
||||
8_000_000,
|
||||
);
|
||||
measure(&mut state, "target7", 1, now, 5_000_000);
|
||||
for (width, height) in [(56, 18), (80, 24), (120, 40)] {
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
state.handle_key(KeyCode::Home, area);
|
||||
state.handle_key(KeyCode::BackTab, area);
|
||||
let text = screen(&state, width, height);
|
||||
assert!(text.contains(">target7"), "{text}");
|
||||
assert!(
|
||||
text.contains("jitter σ") && text.contains("loss %"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(&format_optional_ns_i128(Some(-3_000_000)))
|
||||
&& text.contains(&format_optional_ns_i128(Some(5_000_000))),
|
||||
"{text}"
|
||||
);
|
||||
state.handle_key(KeyCode::Char('d'), area);
|
||||
assert!(screen(&state, width, height).contains("target: target7"));
|
||||
state.handle_key(KeyCode::End, area);
|
||||
assert!(screen(&state, width, height).contains("target7: reply"));
|
||||
state.handle_key(KeyCode::Tab, area);
|
||||
// Selecting another target resets detail scrolling and exposes its own data.
|
||||
assert!(screen(&state, width, height).contains("target: target0"));
|
||||
state.handle_key(KeyCode::Esc, area);
|
||||
}
|
||||
assert!(screen(&state, 40, 10).contains("terminal too small"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_reset_keeps_latest_summary_and_statistics_while_paused() {
|
||||
let mut state = TuiState::default();
|
||||
let area = Rect::new(0, 0, 100, 40);
|
||||
state.handle_key(KeyCode::Char('p'), area);
|
||||
measure(&mut state, "target", 0, Instant::now(), 8_000_000);
|
||||
state.handle_key(KeyCode::Char('r'), area);
|
||||
let text = screen(&state, 100, 40);
|
||||
assert!(
|
||||
text.contains(&format_optional_ns_i128(Some(8_000_000)))
|
||||
&& text.contains("display paused")
|
||||
);
|
||||
assert!(text.contains("waiting for primary replies"));
|
||||
state.handle_key(KeyCode::Char('d'), area);
|
||||
let text = screen(&state, 100, 40);
|
||||
assert!(text.contains("sent 1 received 1 unique 1"));
|
||||
assert!(text.contains(&format!(
|
||||
"effective RTT: {}",
|
||||
format_optional_ns_i128(Some(8_000_000))
|
||||
)));
|
||||
state.handle_key(KeyCode::Esc, area);
|
||||
measure(&mut state, "target", 1, Instant::now(), 5_000_000);
|
||||
assert!(!screen(&state, 100, 40).contains("waiting for primary replies"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_controls_preserve_signed_metrics_and_history_range() {
|
||||
let mut state =
|
||||
TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned(), "b".to_owned()]);
|
||||
let area = Rect::new(0, 0, 100, 40);
|
||||
let now = Instant::now();
|
||||
for label in ["a", "b"] {
|
||||
measure(
|
||||
&mut state,
|
||||
label,
|
||||
0,
|
||||
now - Duration::from_secs(120),
|
||||
-2_000_000,
|
||||
);
|
||||
measure(
|
||||
&mut state,
|
||||
label,
|
||||
1,
|
||||
now - Duration::from_secs(60),
|
||||
-2_000_000,
|
||||
);
|
||||
measure(&mut state, label, 2, now, -2_000_000);
|
||||
}
|
||||
state.handle_key(KeyCode::Home, area);
|
||||
let viewport = state
|
||||
.graph_viewport
|
||||
.range(now, state.newest_graph_sample_time());
|
||||
assert_eq!(viewport.start, now - Duration::from_secs(120));
|
||||
assert_eq!(viewport.end, now - Duration::from_secs(60));
|
||||
for metric in [
|
||||
GraphMetric::EffectiveRtt,
|
||||
GraphMetric::RawRtt,
|
||||
GraphMetric::AdjustedRtt,
|
||||
GraphMetric::ClientToServer,
|
||||
GraphMetric::ServerToClient,
|
||||
GraphMetric::ServerProcessing,
|
||||
] {
|
||||
assert_eq!(state.graph_metric, metric);
|
||||
let series = state
|
||||
.targets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, target)| target_metric_series(target, i, viewport, metric).unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
// Both endpoints belong to the viewport, for both targets.
|
||||
assert!(series.iter().all(|series| series.data.len() == 2));
|
||||
if matches!(
|
||||
metric,
|
||||
GraphMetric::EffectiveRtt | GraphMetric::AdjustedRtt | GraphMetric::ClientToServer
|
||||
) {
|
||||
assert!(series
|
||||
.iter()
|
||||
.all(|series| series.data.iter().all(|(_, y)| *y < 0.0)));
|
||||
assert!(chart_y_bounds(&series, metric.axis_kind()).0 < 0.0);
|
||||
}
|
||||
state.handle_key(KeyCode::Char('m'), area);
|
||||
}
|
||||
state.handle_key(KeyCode::PageDown, area);
|
||||
assert_eq!(state.graph_viewport.range(now, Some(now)).end, now);
|
||||
state.handle_key(KeyCode::Left, area);
|
||||
assert_eq!(
|
||||
state.graph_viewport.range(now, Some(now)).end,
|
||||
now - Duration::from_secs(15)
|
||||
);
|
||||
state.handle_key(KeyCode::Right, area);
|
||||
state.handle_key(KeyCode::PageUp, area);
|
||||
assert_eq!(
|
||||
state.graph_viewport.range(now, Some(now)).end,
|
||||
now - Duration::from_secs(60)
|
||||
);
|
||||
state.handle_key(KeyCode::Char('+'), area);
|
||||
assert_eq!(state.graph_viewport.window, Duration::from_secs(40));
|
||||
state.handle_key(KeyCode::Char('-'), area);
|
||||
assert_eq!(state.graph_viewport.window, DEFAULT_GRAPH_WINDOW);
|
||||
state.handle_key(KeyCode::Char('0'), area);
|
||||
assert!(!state.graph_viewport.range(now, Some(now)).is_live);
|
||||
state.handle_key(KeyCode::End, area);
|
||||
assert!(state.graph_viewport.range(now, Some(now)).is_live);
|
||||
}
|
||||
}
|
||||
@@ -65,69 +65,3 @@ impl Drop for ManagedWorker {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use irtt_client::managed::{
|
||||
ManagedClient, ManagedClientConfig, ManagedCompletionPolicy, ManagedEndReason,
|
||||
};
|
||||
|
||||
// The frontend executor must be able to stop a worker while awaiting its
|
||||
// completion; a blocking join here would deadlock this current-thread runtime.
|
||||
#[test]
|
||||
fn completion_leaves_frontend_free_to_stop_and_preserves_outcome() {
|
||||
let (task, handle) = ManagedClient::task(
|
||||
ManagedClientConfig {
|
||||
completion: ManagedCompletionPolicy::ExplicitStop,
|
||||
..Default::default()
|
||||
},
|
||||
vec![],
|
||||
)
|
||||
.unwrap();
|
||||
let worker = ManagedWorker::start(task, handle.clone()).unwrap();
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
let (outcome, ()) = tokio::time::timeout(Duration::from_secs(2), async {
|
||||
tokio::join!(worker.join(), handle.stop())
|
||||
})
|
||||
.await
|
||||
.expect("worker completion must allow frontend stop");
|
||||
let outcome = outcome.unwrap();
|
||||
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
|
||||
assert_eq!(handle.status().final_outcome.as_deref(), Some(&outcome));
|
||||
});
|
||||
}
|
||||
|
||||
// Cancelling the frontend's completion wait must stop and join the worker,
|
||||
// rather than detaching an ongoing measurement task.
|
||||
#[test]
|
||||
fn cancelling_completion_stops_and_joins_worker() {
|
||||
use std::{
|
||||
future::Future,
|
||||
task::{Context, Waker},
|
||||
};
|
||||
let (task, handle) = ManagedClient::task(
|
||||
ManagedClientConfig {
|
||||
completion: ManagedCompletionPolicy::ExplicitStop,
|
||||
..Default::default()
|
||||
},
|
||||
vec![],
|
||||
)
|
||||
.unwrap();
|
||||
let worker = ManagedWorker::start(task, handle.clone()).unwrap();
|
||||
let mut completion = Box::pin(worker.join());
|
||||
assert!(completion
|
||||
.as_mut()
|
||||
.poll(&mut Context::from_waker(Waker::noop()))
|
||||
.is_pending());
|
||||
drop(completion);
|
||||
assert_eq!(
|
||||
handle.status().final_outcome.as_ref().unwrap().end_reason,
|
||||
ManagedEndReason::StopRequested
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,530 +0,0 @@
|
||||
#[cfg(feature = "client")]
|
||||
#[path = "support/in_tree_server.rs"]
|
||||
mod in_tree_server;
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
use irtt_server::ServerConfig;
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
struct ClientProcess(std::process::Child);
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
impl Drop for ClientProcess {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
impl ClientProcess {
|
||||
fn wait(&mut self) -> std::process::ExitStatus {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
if let Some(status) = self.0.try_wait().unwrap() {
|
||||
return status;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"client did not terminate within five seconds"
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch/help/availability belong to the application and run in every build.
|
||||
#[test]
|
||||
fn dispatcher_reports_and_runs_the_enabled_applets() {
|
||||
let dispatcher = env!("CARGO_BIN_EXE_irtt-rs");
|
||||
let help = Command::new(dispatcher).arg("--help").output().unwrap();
|
||||
assert!(help.status.success());
|
||||
let help = String::from_utf8(help.stdout).unwrap();
|
||||
for (applet, enabled) in [
|
||||
("client", cfg!(feature = "client")),
|
||||
("server", cfg!(feature = "server")),
|
||||
("tui", cfg!(feature = "tui")),
|
||||
] {
|
||||
let row = help
|
||||
.lines()
|
||||
.find(|line| line.trim_start().starts_with(applet))
|
||||
.unwrap();
|
||||
assert_eq!(row.contains("not available"), !enabled);
|
||||
let output = Command::new(dispatcher)
|
||||
.args([applet, "--help"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.success(), enabled, "{applet}");
|
||||
if !enabled {
|
||||
assert!(String::from_utf8(output.stderr)
|
||||
.unwrap()
|
||||
.contains("not available"));
|
||||
}
|
||||
}
|
||||
let binaries = [
|
||||
dispatcher,
|
||||
#[cfg(feature = "client")]
|
||||
env!("CARGO_BIN_EXE_irtt-client"),
|
||||
#[cfg(feature = "server")]
|
||||
env!("CARGO_BIN_EXE_irtt-server"),
|
||||
#[cfg(feature = "tui")]
|
||||
env!("CARGO_BIN_EXE_irtt-tui"),
|
||||
];
|
||||
for binary in binaries {
|
||||
assert!(Command::new(binary)
|
||||
.arg("--help")
|
||||
.output()
|
||||
.unwrap()
|
||||
.status
|
||||
.success());
|
||||
assert!(!Command::new(binary)
|
||||
.arg("--invalid-option")
|
||||
.output()
|
||||
.unwrap()
|
||||
.status
|
||||
.success());
|
||||
}
|
||||
}
|
||||
|
||||
// The default presentation must reach a meaningful packet summary for a finite run.
|
||||
#[cfg(feature = "client")]
|
||||
#[test]
|
||||
fn default_client_output_emits_a_packet_summary() {
|
||||
use std::{io::Read, process::Stdio, thread};
|
||||
let server = in_tree_server::InTreeServer::start(ServerConfig::default());
|
||||
let mut client = ClientProcess(
|
||||
Command::new(env!("CARGO_BIN_EXE_irtt-client"))
|
||||
.env_clear()
|
||||
.args([
|
||||
"--duration",
|
||||
"100ms",
|
||||
"--interval",
|
||||
"20ms",
|
||||
&server.addr.to_string(),
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let mut stdout = client.0.stdout.take().unwrap();
|
||||
let stdout = thread::spawn(move || {
|
||||
let mut text = String::new();
|
||||
stdout.read_to_string(&mut text).unwrap();
|
||||
text
|
||||
});
|
||||
let mut stderr = client.0.stderr.take().unwrap();
|
||||
let stderr = thread::spawn(move || {
|
||||
let mut text = String::new();
|
||||
stderr.read_to_string(&mut text).unwrap();
|
||||
text
|
||||
});
|
||||
let status = client.wait();
|
||||
let stdout = stdout.join().unwrap();
|
||||
let stderr = stderr.join().unwrap();
|
||||
assert!(status.success(), "{stderr}");
|
||||
assert!(stdout.contains("irtt-rs summary"), "{stdout}");
|
||||
let packets = stdout
|
||||
.lines()
|
||||
.find(|line| line.starts_with("packets:"))
|
||||
.expect("default output must include the packet summary");
|
||||
for field in ["sent=", "received="] {
|
||||
let count = packets
|
||||
.split_whitespace()
|
||||
.find_map(|word| word.strip_prefix(field))
|
||||
.unwrap()
|
||||
.parse::<u64>()
|
||||
.unwrap();
|
||||
assert!(
|
||||
count > 0,
|
||||
"summary must report {field} a positive count: {packets}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
#[test]
|
||||
fn target_authentication_inherits_overrides_or_disables_the_global_key() {
|
||||
use clap::Parser;
|
||||
use irtt_app::cmd::client::args::ClientArgs;
|
||||
use irtt_client::{managed::TargetAuth, Authentication, HmacKey};
|
||||
let args = ClientArgs::try_parse_from([
|
||||
"irtt-client",
|
||||
"--hmac",
|
||||
"CONSPICUOUS_GLOBAL_HMAC_KEY",
|
||||
"inherit=127.0.0.1:2112",
|
||||
"override=127.0.0.1:2113@hmac=CONSPICUOUS_TARGET_HMAC_KEY",
|
||||
"disable=127.0.0.1:2114@hmac=",
|
||||
])
|
||||
.unwrap();
|
||||
let setup = args.prepare().unwrap();
|
||||
assert_eq!(
|
||||
setup.managed_config().client.auth,
|
||||
Authentication::Hmac(HmacKey::new(b"CONSPICUOUS_GLOBAL_HMAC_KEY".as_slice()))
|
||||
);
|
||||
let targets = setup.managed_targets();
|
||||
let authentication: Vec<_> = targets.iter().map(|target| target.auth.clone()).collect();
|
||||
assert_eq!(
|
||||
authentication,
|
||||
[
|
||||
TargetAuth::Inherit,
|
||||
TargetAuth::Override(Authentication::Hmac(HmacKey::new(
|
||||
b"CONSPICUOUS_TARGET_HMAC_KEY".as_slice()
|
||||
))),
|
||||
TargetAuth::Override(Authentication::Unauthenticated)
|
||||
]
|
||||
);
|
||||
for output in [format!("{args:?}"), format!("{setup:?}")] {
|
||||
assert!(!output.contains("CONSPICUOUS_GLOBAL_HMAC_KEY"));
|
||||
assert!(!output.contains("CONSPICUOUS_TARGET_HMAC_KEY"));
|
||||
}
|
||||
}
|
||||
|
||||
// A real authenticated server detects broken client argument-to-config mapping.
|
||||
#[cfg(feature = "client")]
|
||||
#[test]
|
||||
fn client_arguments_export_a_real_client_run() {
|
||||
use std::{io::Read, process::Stdio, thread};
|
||||
let dispatcher = env!("CARGO_BIN_EXE_irtt-rs");
|
||||
for option in ["--dscp", "--ttl"] {
|
||||
assert!(!Command::new(dispatcher)
|
||||
.args(["client", option, "256", "127.0.0.1:9"])
|
||||
.output()
|
||||
.unwrap()
|
||||
.status
|
||||
.success());
|
||||
}
|
||||
let server =
|
||||
in_tree_server::InTreeServer::start(ServerConfig::default().with_hmac_key(b"cli-test"));
|
||||
let mut client = ClientProcess(
|
||||
Command::new(dispatcher)
|
||||
.args([
|
||||
"client",
|
||||
"--duration",
|
||||
"100ms",
|
||||
"--interval",
|
||||
"20ms",
|
||||
"--hmac",
|
||||
"cli-test",
|
||||
"--length",
|
||||
"128",
|
||||
"--sfill",
|
||||
"pattern:ab",
|
||||
"--clock",
|
||||
"wall",
|
||||
"--tstamp",
|
||||
"both",
|
||||
"--stats",
|
||||
"both",
|
||||
"--ttl",
|
||||
"64",
|
||||
"--format",
|
||||
"jsonl",
|
||||
"--columns",
|
||||
"target,event,bytes",
|
||||
&format!("edge={}", server.addr),
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
// Drain both pipes while waiting so output cannot block client termination.
|
||||
let mut stdout = client.0.stdout.take().unwrap();
|
||||
let stdout = thread::spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
stdout.read_to_end(&mut bytes).unwrap();
|
||||
bytes
|
||||
});
|
||||
let mut stderr = client.0.stderr.take().unwrap();
|
||||
let stderr = thread::spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
stderr.read_to_end(&mut bytes).unwrap();
|
||||
bytes
|
||||
});
|
||||
let status = client.wait();
|
||||
let stdout = stdout.join().unwrap();
|
||||
let stderr = stderr.join().unwrap();
|
||||
assert!(status.success(), "{}", String::from_utf8_lossy(&stderr));
|
||||
let stdout = String::from_utf8(stdout).unwrap();
|
||||
let rows: Vec<_> = stdout.lines().collect();
|
||||
assert!(!rows.is_empty());
|
||||
assert!(rows.iter().all(|row| row.starts_with('{')
|
||||
&& row.ends_with('}')
|
||||
&& row.contains("\"target\":\"edge\"")));
|
||||
assert!(rows
|
||||
.iter()
|
||||
.any(|row| row.contains("\"event\":\"echo_reply\"") && row.contains("\"bytes\":128")));
|
||||
assert!(rows
|
||||
.iter()
|
||||
.any(|row| row.contains("\"event\":\"session_closed\"")));
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "client", feature = "tui"))]
|
||||
#[test]
|
||||
fn malformed_target_diagnostics_do_not_expose_authentication_keys() {
|
||||
let secret = "malformed-target-secret-92817";
|
||||
for applet in [
|
||||
#[cfg(feature = "client")]
|
||||
"client",
|
||||
#[cfg(feature = "tui")]
|
||||
"tui",
|
||||
] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_irtt-rs"))
|
||||
.args([applet, &format!(r"edge=127.0.0.1:9@hmac={secret}\q")])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(
|
||||
!stderr.trim().is_empty(),
|
||||
"malformed target must report a diagnostic"
|
||||
);
|
||||
assert!(!stderr.contains(secret), "target authentication key leaked");
|
||||
assert!(!String::from_utf8_lossy(&output.stdout).contains(secret));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
#[test]
|
||||
fn server_arguments_resolve_binds_and_map_policy() {
|
||||
use clap::{CommandFactory, FromArgMatches, Parser};
|
||||
use irtt_app::cmd::server::ServerArgs;
|
||||
use irtt_server::TimestampAllowance;
|
||||
use std::time::Duration;
|
||||
// Isolate zero-argument defaults from the test runner's environment without
|
||||
// mutating process-global environment in this parallel test process.
|
||||
let defaults = ServerArgs::from_arg_matches(
|
||||
&ServerArgs::command()
|
||||
.mut_args(|arg| arg.env(None::<&str>))
|
||||
.try_get_matches_from(["irtt-server"])
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
defaults.resolve_binds(),
|
||||
vec![
|
||||
"[::]:2112".parse().unwrap(),
|
||||
"0.0.0.0:2112".parse().unwrap()
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
defaults.server_config(),
|
||||
irtt_server::ServerConfig::default()
|
||||
);
|
||||
let args = ServerArgs::try_parse_from([
|
||||
"irtt-server",
|
||||
"--bind",
|
||||
"127.0.0.1:2113",
|
||||
"--bind",
|
||||
"[::1]:2114",
|
||||
"--hmac",
|
||||
"server-cli-test",
|
||||
"--max-sessions",
|
||||
"3",
|
||||
"--max-packet-length",
|
||||
"96",
|
||||
"--min-interval",
|
||||
"25ms",
|
||||
"--burst",
|
||||
"2",
|
||||
"--idle-timeout",
|
||||
"8s",
|
||||
"--max-duration",
|
||||
"1m",
|
||||
"--timestamp-allowance",
|
||||
"single",
|
||||
"--no-dscp",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
args.resolve_binds(),
|
||||
vec![
|
||||
"127.0.0.1:2113".parse().unwrap(),
|
||||
"[::1]:2114".parse().unwrap()
|
||||
]
|
||||
);
|
||||
let config = args.server_config();
|
||||
assert_eq!(config.hmac_key(), Some(b"server-cli-test".as_slice()));
|
||||
assert_eq!(config.max_sessions(), 3);
|
||||
assert_eq!(config.max_packet_length(), 96);
|
||||
assert_eq!(config.min_send_interval(), Duration::from_millis(25));
|
||||
assert_eq!(config.burst_allowance(), 2);
|
||||
assert_eq!(config.idle_timeout(), Duration::from_secs(8));
|
||||
assert_eq!(config.max_test_duration(), Some(Duration::from_secs(60)));
|
||||
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Single);
|
||||
assert!(!config.dscp_allowed());
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
#[test]
|
||||
fn server_command_line_binds_replace_the_environment_bind() {
|
||||
use std::net::UdpSocket;
|
||||
// Occupied ports give bounded, observable startup failures, without starting
|
||||
// a daemon or needing a process/signal harness merely to inspect arguments.
|
||||
let environment = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let explicit = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let environment_addr = environment.local_addr().unwrap().to_string();
|
||||
let explicit_addr = explicit.local_addr().unwrap().to_string();
|
||||
for use_explicit in [false, true] {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-server"));
|
||||
command
|
||||
.env_clear()
|
||||
.env("IRTT_SERVER_BIND", &environment_addr)
|
||||
.env("IRTT_SERVER_MAX_SESSIONS", "invalid")
|
||||
.args(["--max-sessions", "3"]);
|
||||
if use_explicit {
|
||||
command.args(["--bind", "127.0.0.1:0", "--bind", &explicit_addr]);
|
||||
}
|
||||
let output = command.output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
let expected = if use_explicit {
|
||||
&explicit_addr
|
||||
} else {
|
||||
&environment_addr
|
||||
};
|
||||
assert!(
|
||||
stderr.contains(expected),
|
||||
"startup must attempt the selected bind: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
!stderr.contains("listening on"),
|
||||
"partial startup must not announce listeners"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Exercise stdin replacement and EOF through the actual application process.
|
||||
#[cfg(feature = "client")]
|
||||
#[test]
|
||||
fn targets_stdin_supersedes_the_active_set_and_stops_on_eof() {
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Read, Write},
|
||||
process::Stdio,
|
||||
sync::mpsc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
let server = in_tree_server::InTreeServer::start(ServerConfig::default());
|
||||
let mut client = ClientProcess(
|
||||
Command::new(env!("CARGO_BIN_EXE_irtt-client"))
|
||||
.env_clear()
|
||||
.args([
|
||||
"--duration",
|
||||
"0",
|
||||
"--targets-stdin",
|
||||
"--interval",
|
||||
"20ms",
|
||||
"--format",
|
||||
"jsonl",
|
||||
"--columns",
|
||||
"target,event",
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let stdout = client.0.stdout.take().unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let reader = thread::spawn(move || {
|
||||
for line in BufReader::new(stdout).lines() {
|
||||
if tx.send(line.unwrap()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut stdin = client.0.stdin.take().unwrap();
|
||||
let mut rows = Vec::new();
|
||||
for target in ["first", "second"] {
|
||||
write!(stdin, "\r\n{target}={}\r\n", server.addr).unwrap();
|
||||
stdin.flush().unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let row = rx
|
||||
.recv_timeout(deadline.saturating_duration_since(Instant::now()))
|
||||
.expect("stdin target set did not produce a reply");
|
||||
let replied = row.contains(&format!("\"target\":\"{target}\""))
|
||||
&& row.contains("\"event\":\"echo_reply\"");
|
||||
rows.push(row);
|
||||
if replied {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Retirement can finish after the newer target's first reply.
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while !rows.iter().any(|row| {
|
||||
row.contains("\"target\":\"first\"") && row.contains("\"event\":\"session_closed\"")
|
||||
}) {
|
||||
rows.push(
|
||||
rx.recv_timeout(deadline.saturating_duration_since(Instant::now()))
|
||||
.expect("superseded stdin target did not retire"),
|
||||
);
|
||||
}
|
||||
drop(stdin);
|
||||
let status = client.wait();
|
||||
reader.join().unwrap();
|
||||
rows.extend(rx.try_iter());
|
||||
let mut stderr = String::new();
|
||||
client
|
||||
.0
|
||||
.stderr
|
||||
.take()
|
||||
.unwrap()
|
||||
.read_to_string(&mut stderr)
|
||||
.unwrap();
|
||||
assert!(status.success(), "{stderr}");
|
||||
assert!(rows
|
||||
.iter()
|
||||
.any(|row| row.contains("\"target\":\"second\"")
|
||||
&& row.contains("\"event\":\"session_closed\"")));
|
||||
}
|
||||
|
||||
// Record bounds and decoding errors must terminate the real stdin frontend,
|
||||
// even when no managed events are available to wake it.
|
||||
#[cfg(feature = "client")]
|
||||
#[test]
|
||||
fn targets_stdin_invalid_records_are_fatal_and_empty_eof_is_normal() {
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
process::Stdio,
|
||||
};
|
||||
for (record, success, diagnostic) in [
|
||||
(Vec::new(), true, ""),
|
||||
(b"\r\n\n".to_vec(), true, ""),
|
||||
(b"\n=127.0.0.1:9\n".to_vec(), false, "line 2"),
|
||||
(vec![0xff, b'\n'], false, "line 1"),
|
||||
(vec![b'x'; 64 * 1024 + 2], false, "line 1"),
|
||||
] {
|
||||
let mut client = ClientProcess(
|
||||
Command::new(env!("CARGO_BIN_EXE_irtt-client"))
|
||||
.env_clear()
|
||||
.args(["--duration", "0", "--targets-stdin"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let mut stdin = client.0.stdin.take().unwrap();
|
||||
stdin.write_all(&record).unwrap();
|
||||
drop(stdin);
|
||||
let status = client.wait();
|
||||
let mut stderr = String::new();
|
||||
client
|
||||
.0
|
||||
.stderr
|
||||
.take()
|
||||
.unwrap()
|
||||
.read_to_string(&mut stderr)
|
||||
.unwrap();
|
||||
assert_eq!(status.success(), success, "{stderr}");
|
||||
assert!(stderr.contains(diagnostic), "{stderr}");
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
sync::mpsc,
|
||||
thread::{self, JoinHandle},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use irtt_server::{Server, ServerConfig};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
const STARTUP_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
pub struct InTreeServer {
|
||||
pub addr: SocketAddr,
|
||||
shutdown: Option<oneshot::Sender<()>>,
|
||||
done: Option<JoinHandle<Result<(), irtt_server::ServerRuntimeError>>>,
|
||||
}
|
||||
|
||||
impl InTreeServer {
|
||||
pub fn start(config: ServerConfig) -> Self {
|
||||
let (startup_tx, startup_rx) = mpsc::sync_channel(1);
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
let done = thread::spawn(move || {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("failed to build in-tree test server runtime");
|
||||
runtime.block_on(async move {
|
||||
let bind_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
|
||||
let mut server = Server::bind(bind_addr, config).await?;
|
||||
startup_tx
|
||||
.send(server.local_addr()?)
|
||||
.expect("test stopped waiting for in-tree server startup");
|
||||
server
|
||||
.run(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
})
|
||||
});
|
||||
|
||||
let addr = match startup_rx.recv_timeout(STARTUP_TIMEOUT) {
|
||||
Ok(addr) => addr,
|
||||
Err(error) => {
|
||||
let _ = shutdown_tx.send(());
|
||||
let result = done.join().expect("in-tree test server thread panicked");
|
||||
panic!("in-tree test server failed before startup ({error}): {result:?}");
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
addr,
|
||||
shutdown: Some(shutdown_tx),
|
||||
done: Some(done),
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_inner(&mut self) {
|
||||
if let Some(shutdown) = self.shutdown.take() {
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
if let Some(done) = self.done.take() {
|
||||
done.join()
|
||||
.expect("in-tree test server thread panicked")
|
||||
.expect("in-tree test server stopped with an error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InTreeServer {
|
||||
fn drop(&mut self) {
|
||||
self.stop_inner();
|
||||
}
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
#![cfg(all(unix, feature = "tui"))]
|
||||
|
||||
#[path = "support/in_tree_server.rs"]
|
||||
mod in_tree_server;
|
||||
|
||||
use irtt_server::ServerConfig;
|
||||
use nix::{
|
||||
poll::{poll, PollFd, PollFlags},
|
||||
pty::{openpty, Winsize},
|
||||
sys::{
|
||||
signal::{kill, Signal},
|
||||
termios::{tcgetattr, Termios},
|
||||
},
|
||||
unistd::Pid,
|
||||
};
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{Read, Write},
|
||||
os::{
|
||||
fd::{AsFd, AsRawFd},
|
||||
unix::process::CommandExt,
|
||||
},
|
||||
process::{Child, Command, ExitStatus, Stdio},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
// Real terminal ownership: keep both ends open to inspect restoration after exit,
|
||||
// drain output while waiting, and always reap the child on assertion failures.
|
||||
struct TuiProcess {
|
||||
child: Child,
|
||||
master: File,
|
||||
slave: File,
|
||||
original: Termios,
|
||||
output: Vec<u8>,
|
||||
cursor_answered: bool,
|
||||
}
|
||||
impl TuiProcess {
|
||||
fn start(mut command: Command) -> Self {
|
||||
let pty = openpty(
|
||||
Some(&Winsize {
|
||||
ws_row: 40,
|
||||
ws_col: 160,
|
||||
ws_xpixel: 0,
|
||||
ws_ypixel: 0,
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let slave = File::from(pty.slave);
|
||||
let original = tcgetattr(&slave).unwrap();
|
||||
command
|
||||
.env("TERM", "xterm-256color")
|
||||
.stdin(Stdio::from(slave.try_clone().unwrap()))
|
||||
.stdout(Stdio::from(slave.try_clone().unwrap()))
|
||||
.stderr(Stdio::from(slave.try_clone().unwrap()));
|
||||
// Only async-signal-safe operations run between fork and exec.
|
||||
unsafe {
|
||||
command.pre_exec(|| {
|
||||
if nix::libc::setsid() == -1 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
Self {
|
||||
child: command.spawn().unwrap(),
|
||||
master: File::from(pty.master),
|
||||
slave,
|
||||
original,
|
||||
output: vec![],
|
||||
cursor_answered: false,
|
||||
}
|
||||
}
|
||||
fn pump(&mut self) {
|
||||
let mut fd = [PollFd::new(self.master.as_fd(), PollFlags::POLLIN)];
|
||||
poll(&mut fd, 20_u16).unwrap();
|
||||
if fd[0].revents().unwrap().contains(PollFlags::POLLIN) {
|
||||
let mut buffer = [0; 65536];
|
||||
if let Ok(count) = self.master.read(&mut buffer) {
|
||||
self.output.extend_from_slice(&buffer[..count]);
|
||||
if !self.cursor_answered && self.output.windows(4).any(|bytes| bytes == b"\x1b[6n")
|
||||
{
|
||||
self.master.write_all(b"\x1b[1;1R").unwrap();
|
||||
self.cursor_answered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn await_text(&mut self, text: &str) {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while !String::from_utf8_lossy(&self.output).contains(text) {
|
||||
self.pump();
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"missing {text:?}: {}",
|
||||
String::from_utf8_lossy(&self.output)
|
||||
);
|
||||
assert!(
|
||||
self.child.try_wait().unwrap().is_none(),
|
||||
"TUI exited before {text:?}: {}",
|
||||
String::from_utf8_lossy(&self.output)
|
||||
);
|
||||
}
|
||||
}
|
||||
fn keys(&mut self, keys: &[u8]) {
|
||||
self.master.write_all(keys).unwrap();
|
||||
}
|
||||
fn signal(&self, signal: Signal) {
|
||||
kill(Pid::from_raw(self.child.id() as i32), signal).unwrap();
|
||||
}
|
||||
fn finish(&mut self, success: bool) -> ExitStatus {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let status = loop {
|
||||
self.pump();
|
||||
if let Some(status) = self.child.try_wait().unwrap() {
|
||||
break status;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"TUI did not finish: {}",
|
||||
String::from_utf8_lossy(&self.output[self.output.len().saturating_sub(3000)..])
|
||||
);
|
||||
};
|
||||
self.pump();
|
||||
assert_eq!(
|
||||
status.success(),
|
||||
success,
|
||||
"{}",
|
||||
String::from_utf8_lossy(&self.output)
|
||||
);
|
||||
let restored = tcgetattr(&self.master).unwrap();
|
||||
assert_eq!(restored, self.original, "raw mode was not restored");
|
||||
let output = String::from_utf8_lossy(&self.output);
|
||||
assert!(
|
||||
output.contains("\x1b[?1049l"),
|
||||
"alternate screen was not left: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("\x1b[?25h"),
|
||||
"cursor was not restored: {output}"
|
||||
);
|
||||
status
|
||||
}
|
||||
}
|
||||
impl Drop for TuiProcess {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
fn tui(target: &str, duration: &str) -> Command {
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_irtt-tui"));
|
||||
cmd.args(["--duration", duration, "--interval", "20ms", target]);
|
||||
cmd
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_controls_and_exit_paths_restore_the_terminal() {
|
||||
let server = in_tree_server::InTreeServer::start(ServerConfig::default());
|
||||
let target = server.addr.to_string();
|
||||
let mut command = tui(&format!("first={target}"), "0");
|
||||
command.arg(format!("second={target}"));
|
||||
let mut process = TuiProcess::start(command);
|
||||
process.await_text("active");
|
||||
process.keys(b"p");
|
||||
process.await_text("paused");
|
||||
let size = Winsize {
|
||||
ws_row: 12,
|
||||
ws_col: 50,
|
||||
ws_xpixel: 0,
|
||||
ws_ypixel: 0,
|
||||
};
|
||||
assert_eq!(
|
||||
unsafe { nix::libc::ioctl(process.slave.as_raw_fd(), nix::libc::TIOCSWINSZ, &size) },
|
||||
0
|
||||
);
|
||||
process.signal(Signal::SIGWINCH);
|
||||
process.await_text("small");
|
||||
let size = Winsize {
|
||||
ws_row: 40,
|
||||
ws_col: 160,
|
||||
ws_xpixel: 0,
|
||||
ws_ypixel: 0,
|
||||
};
|
||||
assert_eq!(
|
||||
unsafe { nix::libc::ioctl(process.slave.as_raw_fd(), nix::libc::TIOCSWINSZ, &size) },
|
||||
0
|
||||
);
|
||||
process.signal(Signal::SIGWINCH);
|
||||
process.keys(b"\td");
|
||||
process.await_text("arrows");
|
||||
// Each key waits for a real draw, avoiding a timing-dependent escape batch.
|
||||
for key in [
|
||||
b"\x1b[B".as_slice(),
|
||||
b"\x1b[C",
|
||||
b"\x1b[6~",
|
||||
b"\x1b[F",
|
||||
b"\x1b[H",
|
||||
b"d",
|
||||
b"m",
|
||||
b"+",
|
||||
b"-",
|
||||
b"0",
|
||||
b"\x1b[D",
|
||||
b"\x1b[C",
|
||||
b"\x1b[5~",
|
||||
b"\x1b[6~",
|
||||
b"\x1b[H",
|
||||
b"\x1b[F",
|
||||
b"r",
|
||||
b"p",
|
||||
] {
|
||||
let frames = process
|
||||
.output
|
||||
.windows(6)
|
||||
.filter(|bytes| *bytes == b"\x1b[?25l")
|
||||
.count();
|
||||
process.keys(key);
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while process
|
||||
.output
|
||||
.windows(6)
|
||||
.filter(|bytes| *bytes == b"\x1b[?25l")
|
||||
.count()
|
||||
== frames
|
||||
{
|
||||
process.pump();
|
||||
assert!(Instant::now() < deadline, "input did not redraw: {key:?}");
|
||||
}
|
||||
}
|
||||
process.keys(b"q");
|
||||
process.finish(true);
|
||||
|
||||
for action in [None, Some(b"\x03".as_slice()), Some(b"signal".as_slice())] {
|
||||
let mut process =
|
||||
TuiProcess::start(tui(&target, if action.is_none() { "100ms" } else { "0" }));
|
||||
process.await_text("active");
|
||||
match action {
|
||||
Some(b"signal") => process.signal(Signal::SIGTERM),
|
||||
Some(keys) => process.keys(keys),
|
||||
None => {}
|
||||
}
|
||||
process.finish(true);
|
||||
}
|
||||
// An adverse early-close proxy forwards the real Rust server's OPEN and
|
||||
// echo bytes. Only the documented CLOSE flag is added to the first echo.
|
||||
// This exercises the application's continuous peer-close exit policy.
|
||||
std::thread::scope(|scope| {
|
||||
use irtt_proto::{decode_request, DecodedRequestKind, FLAG_CLOSE};
|
||||
use std::net::UdpSocket;
|
||||
let proxy = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let proxy_addr = proxy.local_addr().unwrap();
|
||||
proxy
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let upstream = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
upstream.connect(server.addr).unwrap();
|
||||
upstream
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let peer = scope.spawn(move || {
|
||||
let mut packet = [0; 65536];
|
||||
loop {
|
||||
let (count, client) = proxy.recv_from(&mut packet).unwrap();
|
||||
let echo = matches!(
|
||||
decode_request(&packet[..count]).unwrap().kind,
|
||||
DecodedRequestKind::Echo { .. }
|
||||
);
|
||||
upstream.send(&packet[..count]).unwrap();
|
||||
let count = upstream.recv(&mut packet).unwrap();
|
||||
if echo {
|
||||
packet[3] |= FLAG_CLOSE;
|
||||
}
|
||||
proxy.send_to(&packet[..count], client).unwrap();
|
||||
if echo {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut process = TuiProcess::start(tui(&proxy_addr.to_string(), "0"));
|
||||
process.finish(false);
|
||||
assert!(String::from_utf8_lossy(&process.output)
|
||||
.contains("continuous run ended because of peer closure"));
|
||||
peer.join().unwrap();
|
||||
});
|
||||
|
||||
let mut command = Command::new(std::env::current_exe().unwrap());
|
||||
command
|
||||
.args(["--exact", "cancelled_frontend_fixture", "--nocapture"])
|
||||
.env("IRTT_TUI_CANCEL_TARGET", &target);
|
||||
TuiProcess::start(command).finish(true);
|
||||
|
||||
// Opening failure must be reported and restore the terminal too.
|
||||
let mut process = TuiProcess::start(tui("127.0.0.1:99999", "100ms"));
|
||||
process.finish(false);
|
||||
assert!(String::from_utf8_lossy(&process.output)
|
||||
.contains("no managed target completed successfully"));
|
||||
}
|
||||
|
||||
// The parent runs this fixture in its own PTY. Dropping the
|
||||
// actual frontend future must restore that terminal and join measurement.
|
||||
#[test]
|
||||
fn cancelled_frontend_fixture() {
|
||||
let Ok(target) = std::env::var("IRTT_TUI_CANCEL_TARGET") else {
|
||||
return;
|
||||
};
|
||||
use clap::Parser;
|
||||
use std::{
|
||||
future::{poll_fn, Future},
|
||||
task::Poll,
|
||||
};
|
||||
let args = irtt_app::cmd::tui::TuiArgs::parse_from(["irtt-tui", "--duration", "0", &target]);
|
||||
let (_shutdown, receiver) = tokio::sync::watch::channel(false);
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
let mut frontend = Box::pin(irtt_app::cmd::tui::run_tui(args, receiver));
|
||||
poll_fn(|cx| {
|
||||
assert!(frontend.as_mut().poll(cx).is_pending());
|
||||
Poll::Ready(())
|
||||
})
|
||||
.await;
|
||||
drop(frontend);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user