refactor(cli): use unified managed frontend

This commit is contained in:
Nils Andreas Svee committed 2026-08-09 20:27:22 +07:00
1 parent 9de95b303c
commit 1a9e8f28aa
10 files changed
+456 -1441

No files matched your search

+1 -1
View File
@@ -30,7 +30,7 @@ required-features = ["tui"]
[features] [features]
default = ["client", "stats"] default = ["client", "stats"]
client-runtime = ["dep:irtt-client", "dep:irtt-proto"] client-runtime = ["dep:irtt-client", "irtt-client/tokio", "dep:irtt-proto"]
client = ["client-runtime"] client = ["client-runtime"]
stats = ["client-runtime", "dep:irtt-stats"] stats = ["client-runtime", "dep:irtt-stats"]
tui = ["client-runtime", "stats", "dep:ratatui", "dep:crossterm"] tui = ["client-runtime", "stats", "dep:ratatui", "dep:crossterm"]
+7 -11
View File
@@ -4,8 +4,8 @@ use clap::{Parser, ValueEnum};
use irtt_client::ClientConfig; use irtt_client::ClientConfig;
use crate::shared::client::{ use crate::shared::client::{
parse_labelled_target, parse_test_duration, resolved_managed_targets, target_specs, parse_labelled_target, parse_test_duration, prepare_managed_targets, target_specs,
CommonClientArgs, GroupPacingArg, LabelledTargetArg, ResolvedTarget, TargetSpec, TimestampArg, CommonClientArgs, GroupPacingArg, LabelledTargetArg, PreparedTarget, TargetSpec, TimestampArg,
}; };
pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10); pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
@@ -111,10 +111,8 @@ impl ClientArgs {
target_specs(&self.targets, &self.labelled_targets) target_specs(&self.targets, &self.labelled_targets)
} }
pub fn resolved_managed_targets(&self) -> Result<Vec<ResolvedCliTarget>, String> { pub fn managed_targets(&self) -> Result<Vec<CliManagedTarget>, String> {
let specs = self.target_specs()?; prepare_managed_targets(self.target_specs()?)
let config = self.to_client_config();
resolved_managed_targets(specs, &config)
} }
fn primary_target_addr(&self) -> Option<&str> { fn primary_target_addr(&self) -> Option<&str> {
@@ -147,7 +145,7 @@ pub enum OutputFormat {
} }
pub type CliTargetSpec = TargetSpec; pub type CliTargetSpec = TargetSpec;
pub type ResolvedCliTarget = ResolvedTarget; pub type CliManagedTarget = PreparedTarget;
impl OutputFormat { impl OutputFormat {
pub fn prints_summary(self) -> bool { pub fn prints_summary(self) -> bool {
@@ -262,11 +260,9 @@ mod tests {
} }
#[test] #[test]
fn duplicate_resolved_target_addresses_are_rejected() { fn duplicate_target_endpoints_are_allowed() {
let args = parse(&["127.0.0.1:2112", "127.0.0.1"]).unwrap(); let args = parse(&["127.0.0.1:2112", "127.0.0.1"]).unwrap();
let err = args.resolved_managed_targets().unwrap_err(); assert_eq!(args.managed_targets().unwrap().len(), 2);
assert!(err.contains("duplicate resolved target address 127.0.0.1:2112"));
} }
#[test] #[test]
File diff suppressed because it is too large. Load diff
+7 -11
View File
@@ -6,8 +6,8 @@ use irtt_client::ClientConfig;
#[cfg(test)] #[cfg(test)]
use crate::shared::client::TimestampArg; use crate::shared::client::TimestampArg;
use crate::shared::client::{ use crate::shared::client::{
parse_labelled_target, parse_test_duration, resolved_managed_targets, target_specs, parse_labelled_target, parse_test_duration, prepare_managed_targets, target_specs,
CommonClientArgs, GroupPacingArg, LabelledTargetArg, ResolvedTarget, TargetSpec, CommonClientArgs, GroupPacingArg, LabelledTargetArg, PreparedTarget, TargetSpec,
}; };
pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO; pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
@@ -72,10 +72,8 @@ impl TuiArgs {
target_specs(&self.targets, &self.labelled_targets) target_specs(&self.targets, &self.labelled_targets)
} }
pub fn resolved_managed_targets(&self) -> Result<Vec<ResolvedTuiTarget>, String> { pub fn managed_targets(&self) -> Result<Vec<TuiManagedTarget>, String> {
let specs = self.target_specs()?; prepare_managed_targets(self.target_specs()?)
let config = self.to_client_config();
resolved_managed_targets(specs, &config)
} }
#[cfg(test)] #[cfg(test)]
@@ -93,7 +91,7 @@ impl TuiArgs {
} }
pub type TuiTargetSpec = TargetSpec; pub type TuiTargetSpec = TargetSpec;
pub type ResolvedTuiTarget = ResolvedTarget; pub type TuiManagedTarget = PreparedTarget;
impl std::ops::Deref for TuiArgs { impl std::ops::Deref for TuiArgs {
type Target = CommonClientArgs; type Target = CommonClientArgs;
@@ -199,11 +197,9 @@ mod tests {
} }
#[test] #[test]
fn duplicate_resolved_target_addresses_are_rejected() { fn duplicate_target_endpoints_are_allowed() {
let args = parse(&["127.0.0.1:2112", "127.0.0.1"]).unwrap(); let args = parse(&["127.0.0.1:2112", "127.0.0.1"]).unwrap();
let err = args.resolved_managed_targets().unwrap_err(); assert_eq!(args.managed_targets().unwrap().len(), 2);
assert!(err.contains("duplicate resolved target address 127.0.0.1:2112"));
} }
#[test] #[test]
+130 -457
View File
@@ -1,33 +1,29 @@
use std::{ use std::{
collections::HashSet,
io, io,
sync::atomic::{AtomicBool, Ordering}, sync::atomic::{AtomicBool, Ordering},
thread, thread,
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use super::ui::{should_render, TuiConfig, TuiState, TuiStatus, TuiTerminal};
use irtt_client::{
EventSubscription, EventSubscriptionError, ManagedClient, ManagedClientGroup,
ManagedClientGroupConfig, ManagedGroupCompletionPolicy, ManagedGroupEndReason,
ManagedGroupEvent, SessionEndReason, SubscriberConfig, SubscriberOverflow,
};
use crate::{ use crate::{
cmd::tui::args::{ResolvedTuiTarget, TuiArgs}, cmd::tui::args::TuiArgs,
shared::client::{ shared::client::{
is_shutdown_requested, is_shutdown_requested,
session::{ session::{
peer_close_run_error, request_group_stop_for_peer_close, request_group_stop_once, peer_close_run_error, request_managed_stop_for_peer_close, request_managed_stop_once,
}, },
}, },
}; };
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use super::ui::{should_render, TuiConfig, TuiState, TuiStatus, TuiTerminal}; use irtt_client::managed::{
BlockingManagedClient, ManagedClientConfig, ManagedCompletionPolicy, ManagedEndReason,
ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError, TargetInstance,
};
const RENDER_INTERVAL: Duration = Duration::from_millis(250); const RENDER_INTERVAL: Duration = Duration::from_millis(250);
const TUI_WAIT_SLICE: Duration = Duration::from_millis(20); const TUI_WAIT_SLICE: Duration = Duration::from_millis(20);
const IDLE_SLEEP: Duration = Duration::from_millis(5);
const GROUP_COMPLETION_GRACE: Duration = Duration::from_secs(1);
const MANAGED_EVENT_CAPACITY: usize = 16_384; const MANAGED_EVENT_CAPACITY: usize = 16_384;
pub fn run_tui( pub fn run_tui(
@@ -35,7 +31,7 @@ pub fn run_tui(
shutdown_requested: &AtomicBool, shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let targets = args let targets = args
.resolved_managed_targets() .managed_targets()
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
let continuous = args.is_continuous(); let continuous = args.is_continuous();
let mut terminal = TuiTerminal::enter()?; let mut terminal = TuiTerminal::enter()?;
@@ -44,329 +40,173 @@ pub fn run_tui(
targets.iter().map(|target| target.label.clone()), targets.iter().map(|target| target.label.clone()),
); );
let mut next_render = Instant::now(); let mut next_render = Instant::now();
if is_shutdown_requested(shutdown_requested) { if is_shutdown_requested(shutdown_requested) {
return Ok(()); return Ok(());
} }
state.set_status(TuiStatus::Opening); state.set_status(TuiStatus::Opening);
render_if_due(&mut terminal, &state, &mut next_render, true)?; render_if_due(&mut terminal, &state, &mut next_render, true)?;
let target_count = targets.len();
if targets.len() > 1 { let config = ManagedClientConfig {
return run_group_tui( client: args.to_client_config(),
args, pacing: args.pacing.into(),
targets, completion: ManagedCompletionPolicy::FinishWhenQuiescent,
&mut terminal, event_capacity: MANAGED_EVENT_CAPACITY,
&mut state, outcome_history_limit: target_count,
&mut next_render, max_live_target_generations: target_count,
shutdown_requested, ..ManagedClientConfig::default()
); };
} let (owner, mut events) = match BlockingManagedClient::start_with_subscription(
config,
let (session, events) = match ManagedClient::start_with_subscription( targets
args.to_client_config(), .iter()
managed_event_subscriber_config(), .map(|target| target.managed.clone())
.collect(),
) { ) {
Ok(session) => session, Ok(value) => value,
Err(err) => { Err(error) => {
state.set_error(err.to_string()); state.set_error(error.to_string());
render_if_due(&mut terminal, &state, &mut next_render, true)?; render_if_due(&mut terminal, &state, &mut next_render, true)?;
return Err(Box::new(err)); return Err(Box::new(error));
} }
}; };
let handle = owner.handle();
let mut interrupted = false; let mut interrupted = false;
let mut stop_requested = false;
let mut peer_close_requested_stop = false;
let mut terminal_targets = HashSet::new();
let mut dropped_events = 0;
let mut subscription_closed = false;
loop { loop {
if is_shutdown_requested(shutdown_requested) { if is_shutdown_requested(shutdown_requested) {
interrupted = true; interrupted = true;
session.stop(); if request_managed_stop_once(&mut stop_requested) {
break; let _ = handle.stop();
}
} }
if handle_input(&mut state, shutdown_requested)? { if handle_input(&mut state, shutdown_requested)? {
render_if_due(&mut terminal, &state, &mut next_render, true)?; render_if_due(&mut terminal, &state, &mut next_render, true)?;
} }
if state.quit_requested { if state.quit_requested {
interrupted = true; interrupted = true;
session.stop(); if request_managed_stop_once(&mut stop_requested) {
break; let _ = handle.stop();
}
match events.recv_timeout(managed_tui_wait_duration(&next_render, state.paused)) {
Ok(Some(event)) => {
state.process_event(&event);
render_if_due(&mut terminal, &state, &mut next_render, false)?;
}
Ok(None) => {
render_if_due(&mut terminal, &state, &mut next_render, false)?;
}
Err(EventSubscriptionError::Disconnected) => break,
} }
} }
interrupted |= is_shutdown_requested(shutdown_requested); if request_managed_stop_for_peer_close(
if interrupted {
state.set_status(TuiStatus::Interrupted);
render_if_due(&mut terminal, &state, &mut next_render, true)?;
}
if interrupted {
state.set_status(TuiStatus::Draining);
drain_single_tui_events(&events, &mut state);
render_if_due(&mut terminal, &state, &mut next_render, true)?;
}
state.set_status(TuiStatus::Closing);
render_if_due(&mut terminal, &state, &mut next_render, true)?;
let outcome = session.join();
drain_single_tui_events(&events, &mut state);
state.mark_dropped_client_events(events.dropped_events());
let outcome = match outcome {
Ok(outcome) => outcome,
Err(err) => {
state.set_error(err.to_string());
render_if_due(&mut terminal, &state, &mut next_render, true)?;
return Err(Box::new(err));
}
};
interrupted |= is_shutdown_requested(shutdown_requested);
if let Some(error) = peer_close_run_error(
continuous, continuous,
interrupted, interrupted,
u64::from(outcome.end_reason == SessionEndReason::PeerClosed), handle.status().peer_closed_target_outcomes,
) {
state.set_run_error(error.clone());
render_if_due(&mut terminal, &state, &mut next_render, true)?;
return Err(error.into());
}
state.set_status(TuiStatus::Complete);
render_if_due(&mut terminal, &state, &mut next_render, true)?;
Ok(())
}
fn run_group_tui(
args: TuiArgs,
targets: Vec<ResolvedTuiTarget>,
terminal: &mut TuiTerminal,
state: &mut TuiState,
next_render: &mut Instant,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
let mut config = args.to_client_config();
let managed_targets = targets
.iter()
.map(|target| target.managed.clone())
.collect::<Vec<_>>();
let expected_target_count = managed_targets.len();
if let Some(first) = managed_targets.first() {
config.server_addr = first.remote.to_string();
}
let group_config = ManagedClientGroupConfig {
client: config,
pacing: args.pacing.into(),
completion: ManagedGroupCompletionPolicy::AllTargetsComplete,
};
let (session, events) = ManagedClientGroup::start_with_subscription(
group_config,
managed_targets,
SubscriberConfig {
capacity: MANAGED_EVENT_CAPACITY,
overflow: SubscriberOverflow::DropOldest,
},
)?;
let mut interrupted = false;
let mut stop_requested = false;
let mut peer_close_requested_stop = false;
let mut terminal_targets = std::collections::HashSet::new();
let mut saw_target_event = false;
let mut last_event_at = Instant::now();
let exit = loop {
if is_shutdown_requested(shutdown_requested) {
interrupted = true;
if request_group_stop_once(&mut stop_requested) {
session.stop();
}
}
if handle_input(state, shutdown_requested)? {
render_if_due(terminal, state, next_render, true)?;
}
if state.quit_requested {
interrupted = true;
if request_group_stop_once(&mut stop_requested) {
session.stop();
}
}
if request_group_stop_for_peer_close(
args.is_continuous(),
interrupted,
session.peer_closed_target_count(),
&mut peer_close_requested_stop, &mut peer_close_requested_stop,
&mut stop_requested, &mut stop_requested,
) { ) {
session.stop(); let _ = handle.stop();
}
drain_tui_events(
&mut events,
&mut state,
&mut terminal_targets,
&mut dropped_events,
);
if handle.status().final_outcome.is_some() || subscription_closed {
break;
} }
match events.try_recv() { match events.try_recv() {
Ok(Some(group_event)) => { Ok(event) => process_tui_event(event, &mut state, &mut terminal_targets),
saw_target_event = true; Err(ManagedEventTryRecvError::Empty) => {
last_event_at = Instant::now(); thread::sleep(managed_tui_wait_duration(&next_render, state.paused))
match group_event {
ManagedGroupEvent::Client(target_event) => {
state.process_target_event(&target_event);
} }
ManagedGroupEvent::TargetFinished(target) => { Err(ManagedEventTryRecvError::Lagged(count)) => dropped_events += count,
terminal_targets.insert(target.id.as_str().to_owned()); Err(ManagedEventTryRecvError::Closed) => subscription_closed = true,
state.process_target_outcome(&target);
if request_group_stop_for_peer_close(
args.is_continuous(),
interrupted,
u64::from(matches!(
&target.end_reason,
irtt_client::ManagedTargetEndReason::PeerClosed
)),
&mut peer_close_requested_stop,
&mut stop_requested,
) {
session.stop();
} }
render_if_due(&mut terminal, &state, &mut next_render, false)?;
} }
}
render_if_due(terminal, state, next_render, false)?;
}
Ok(None) => {
if interrupted {
break GroupLoopExit::Interrupted;
}
if terminal_targets.len() >= expected_target_count {
break GroupLoopExit::AllTargetsTerminal;
}
if should_join_group_after_idle(&args, saw_target_event, last_event_at) {
break GroupLoopExit::IdleGraceElapsed;
}
wait_for_tui_activity(None, next_render, state, terminal, shutdown_requested)?;
thread::sleep(IDLE_SLEEP);
}
Err(EventSubscriptionError::Disconnected) => {
break GroupLoopExit::SubscriptionDisconnected
}
}
};
if exit.should_stop_before_join() && request_group_stop_once(&mut stop_requested) {
session.stop();
}
if interrupted { if interrupted {
state.set_status(TuiStatus::Interrupted); state.set_status(TuiStatus::Interrupted);
render_if_due(terminal, state, next_render, true)?; render_if_due(&mut terminal, &state, &mut next_render, true)?;
} }
state.set_status(TuiStatus::Closing); state.set_status(TuiStatus::Closing);
render_if_due(terminal, state, next_render, true)?; render_if_due(&mut terminal, &state, &mut next_render, true)?;
drain_tui_events(
let outcome = session.join()?; &mut events,
while let Ok(Some(group_event)) = events.try_recv() { &mut state,
match group_event { &mut terminal_targets,
ManagedGroupEvent::Client(target_event) => state.process_target_event(&target_event), &mut dropped_events,
ManagedGroupEvent::TargetFinished(target) => { );
if terminal_targets.insert(target.id.as_str().to_owned()) { let outcome = owner.join()?;
state.process_target_outcome(&target); drain_tui_events(
&mut events,
&mut state,
&mut terminal_targets,
&mut dropped_events,
);
state.mark_dropped_managed_events(dropped_events);
if outcome.discarded_target_outcomes != 0 {
state.set_run_error(format!(
"{} final target outcomes were discarded",
outcome.discarded_target_outcomes
));
} }
} for target in outcome.recent_target_outcomes.iter() {
} if terminal_targets.insert(target.target.clone()) {
}
state.mark_dropped_group_events(events.dropped_events());
for target in &outcome.targets {
if terminal_targets.insert(target.id.as_str().to_owned()) {
state.process_target_outcome(target); state.process_target_outcome(target);
} }
} }
interrupted |= is_shutdown_requested(shutdown_requested); interrupted |= is_shutdown_requested(shutdown_requested);
let peer_closed_target_outcomes = outcome let error = match &outcome.end_reason {
.peer_closed_target_outcomes ManagedEndReason::DriverFailed(failure) => {
.max(u64::from(peer_close_requested_stop)); Some(format!("managed driver failed: {failure}"))
if let Some(error) = peer_close_run_error( }
args.is_continuous(), _ => peer_close_run_error(continuous, interrupted, outcome.peer_closed_target_outcomes)
interrupted, .or_else(|| {
peer_closed_target_outcomes, (!interrupted
) { && outcome.successful_target_outcomes == 0
&& outcome.failed_target_outcomes > 0)
.then(|| {
format!(
"no managed target completed successfully ({} failed)",
outcome.failed_target_outcomes
)
})
}),
};
if let Some(error) = error {
state.set_run_error(error.clone()); state.set_run_error(error.clone());
render_if_due(terminal, state, next_render, true)?; render_if_due(&mut terminal, &state, &mut next_render, true)?;
return Err(error.into()); return Err(error.into());
} }
if outcome.end_reason == ManagedGroupEndReason::Cancelled
&& !interrupted
&& !peer_close_requested_stop
{
return match exit {
GroupLoopExit::IdleGraceElapsed => {
Err("managed client group stayed idle before all targets completed".into())
}
GroupLoopExit::SubscriptionDisconnected => {
Err("managed client group event subscription disconnected before completion".into())
}
GroupLoopExit::Interrupted | GroupLoopExit::AllTargetsTerminal => {
Err("managed client group was cancelled".into())
}
};
}
let successful_targets = outcome.successful_target_outcomes;
let failed_targets = outcome.failed_target_outcomes;
if !interrupted && successful_targets == 0 && failed_targets > 0 {
state.set_status(TuiStatus::Error);
render_if_due(terminal, state, next_render, true)?;
return Err(
format!("no managed target completed successfully ({failed_targets} failed)").into(),
);
}
state.set_status(TuiStatus::Complete); state.set_status(TuiStatus::Complete);
render_if_due(terminal, state, next_render, true)?; render_if_due(&mut terminal, &state, &mut next_render, true)?;
Ok(()) Ok(())
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] fn process_tui_event(
enum GroupLoopExit { event: ManagedEvent,
Interrupted, state: &mut TuiState,
AllTargetsTerminal, terminal_targets: &mut HashSet<TargetInstance>,
IdleGraceElapsed, ) {
SubscriptionDisconnected, match event {
ManagedEvent::Client { target, event } => state.process_target_event(&target, &event),
ManagedEvent::TargetFinished { outcome } => {
terminal_targets.insert(outcome.target.clone());
state.process_target_outcome(&outcome);
} }
_ => {}
impl GroupLoopExit {
fn should_stop_before_join(self) -> bool {
matches!(
self,
Self::Interrupted | Self::IdleGraceElapsed | Self::SubscriptionDisconnected
)
} }
} }
fn drain_tui_events(
fn estimated_group_completion_grace(args: &TuiArgs) -> Duration { events: &mut ManagedEventSubscription,
let open_timeout: Duration = args.to_client_config().open_timeouts.iter().sum(); state: &mut TuiState,
open_timeout terminal_targets: &mut HashSet<TargetInstance>,
.saturating_add(args.duration) dropped_events: &mut u64,
.saturating_add(GROUP_COMPLETION_GRACE) ) {
loop {
match events.try_recv() {
Ok(event) => process_tui_event(event, state, terminal_targets),
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
Err(ManagedEventTryRecvError::Lagged(count)) => *dropped_events += count,
} }
fn should_join_group_after_idle(
args: &TuiArgs,
saw_target_event: bool,
last_event_at: Instant,
) -> bool {
if args.is_continuous() && saw_target_event {
return false;
} }
last_event_at.elapsed() > estimated_group_completion_grace(args)
} }
fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Result<bool> { fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Result<bool> {
let mut force_render = false; let mut force_render = false;
while event::poll(Duration::ZERO)? { while event::poll(Duration::ZERO)? {
@@ -377,12 +217,10 @@ fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Re
continue; continue;
} }
match key.code { match key.code {
KeyCode::Char('q') => { KeyCode::Char('q') | KeyCode::Char('c')
state.quit_requested = true; if key.code == KeyCode::Char('q')
shutdown_requested.store(true, Ordering::Relaxed); || key.modifiers.contains(KeyModifiers::CONTROL) =>
force_render = true; {
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.quit_requested = true; state.quit_requested = true;
shutdown_requested.store(true, Ordering::Relaxed); shutdown_requested.store(true, Ordering::Relaxed);
force_render = true; force_render = true;
@@ -444,7 +282,6 @@ fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Re
} }
Ok(force_render) Ok(force_render)
} }
fn render_if_due( fn render_if_due(
terminal: &mut TuiTerminal, terminal: &mut TuiTerminal,
state: &TuiState, state: &TuiState,
@@ -452,27 +289,12 @@ fn render_if_due(
force: bool, force: bool,
) -> io::Result<()> { ) -> io::Result<()> {
let now = Instant::now(); let now = Instant::now();
if !should_render(now, *next_render, state.paused, force) { if should_render(now, *next_render, state.paused, force) {
return Ok(());
}
terminal.draw(state)?; terminal.draw(state)?;
*next_render = now + RENDER_INTERVAL; *next_render = now + RENDER_INTERVAL;
}
Ok(()) Ok(())
} }
fn managed_event_subscriber_config() -> SubscriberConfig {
SubscriberConfig {
capacity: MANAGED_EVENT_CAPACITY,
overflow: SubscriberOverflow::DropOldest,
}
}
fn drain_single_tui_events(events: &EventSubscription, state: &mut TuiState) {
while let Ok(Some(event)) = events.try_recv() {
state.process_event(&event);
}
}
fn managed_tui_wait_duration(next_render: &Instant, paused: bool) -> Duration { fn managed_tui_wait_duration(next_render: &Instant, paused: bool) -> Duration {
let render_wait = if paused { let render_wait = if paused {
TUI_WAIT_SLICE TUI_WAIT_SLICE
@@ -481,152 +303,3 @@ fn managed_tui_wait_duration(next_render: &Instant, paused: bool) -> Duration {
}; };
render_wait.min(TUI_WAIT_SLICE) render_wait.min(TUI_WAIT_SLICE)
} }
fn wait_for_tui_activity(
next_send_deadline: Option<Instant>,
next_render: &mut Instant,
state: &mut TuiState,
terminal: &mut TuiTerminal,
shutdown_requested: &AtomicBool,
) -> io::Result<()> {
let wait_for = tui_wait_duration(next_send_deadline, *next_render, state.paused);
if wait_for.is_zero() || !event::poll(wait_for)? {
return Ok(());
}
if handle_input(state, shutdown_requested)? {
render_if_due(terminal, state, next_render, true)?;
}
Ok(())
}
fn tui_wait_duration(
next_send_deadline: Option<Instant>,
next_render: Instant,
paused: bool,
) -> Duration {
let now = Instant::now();
let send_wait = next_send_deadline
.map(|deadline| deadline.saturating_duration_since(now))
.unwrap_or(IDLE_SLEEP);
let render_wait = if paused {
send_wait
} else {
next_render.saturating_duration_since(now)
};
send_wait.min(render_wait).min(TUI_WAIT_SLICE)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn parse(args: &[&str]) -> TuiArgs {
let mut argv = vec!["irtt-tui"];
argv.extend_from_slice(args);
TuiArgs::try_parse_from(argv).unwrap()
}
#[test]
fn continuous_multi_target_idle_does_not_stop_after_events() {
let args = parse(&["127.0.0.1:2112", "127.0.0.2:2112"]);
let old_event_at = Instant::now() - estimated_group_completion_grace(&args) - IDLE_SLEEP;
assert!(!should_join_group_after_idle(&args, true, old_event_at));
}
#[test]
fn finite_or_unopened_group_can_leave_after_idle_grace() {
let finite = parse(&["--duration", "1s", "127.0.0.1:2112", "127.0.0.2:2112"]);
let finite_old_event_at =
Instant::now() - estimated_group_completion_grace(&finite) - IDLE_SLEEP;
assert!(should_join_group_after_idle(
&finite,
true,
finite_old_event_at
));
let continuous = parse(&["127.0.0.1:2112", "127.0.0.2:2112"]);
let unopened_old_event_at =
Instant::now() - estimated_group_completion_grace(&continuous) - IDLE_SLEEP;
assert!(should_join_group_after_idle(
&continuous,
false,
unopened_old_event_at
));
}
#[test]
fn protective_group_exits_stop_before_joining() {
assert!(GroupLoopExit::Interrupted.should_stop_before_join());
assert!(GroupLoopExit::IdleGraceElapsed.should_stop_before_join());
assert!(GroupLoopExit::SubscriptionDisconnected.should_stop_before_join());
assert!(!GroupLoopExit::AllTargetsTerminal.should_stop_before_join());
}
#[test]
fn continuous_group_reliable_peer_close_status_requests_stop_once() {
let mut peer_close_requested_stop = false;
let mut stop_requested = false;
assert!(request_group_stop_for_peer_close(
true,
false,
1,
&mut peer_close_requested_stop,
&mut stop_requested,
));
assert!(peer_close_requested_stop);
assert!(stop_requested);
assert!(!request_group_stop_for_peer_close(
true,
false,
1,
&mut peer_close_requested_stop,
&mut stop_requested,
));
let mut peer_close_requested_stop = false;
let mut stop_requested = false;
assert!(!request_group_stop_for_peer_close(
false,
false,
1,
&mut peer_close_requested_stop,
&mut stop_requested,
));
assert!(!request_group_stop_for_peer_close(
true,
true,
1,
&mut peer_close_requested_stop,
&mut stop_requested,
));
}
#[test]
fn single_target_managed_drain_consumes_queued_terminal_event() {
let hub = irtt_client::EventHub::new();
let events = hub
.subscribe(SubscriberConfig {
capacity: 4,
overflow: SubscriberOverflow::DropOldest,
})
.unwrap();
hub.publish(irtt_client::ClientEvent::SessionClosed {
remote: "127.0.0.1:2112".parse().unwrap(),
token: 7,
at: irtt_client::ClientTimestamp::now(),
});
hub.disconnect_all();
let mut state = TuiState::new(TuiConfig::default());
drain_single_tui_events(&events, &mut state);
assert_eq!(state.status(), TuiStatus::Complete);
assert_eq!(
events.try_recv().unwrap_err(),
EventSubscriptionError::Disconnected
);
}
}
+27 -35
View File
@@ -9,10 +9,8 @@ use crossterm::{
execute, execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
}; };
use irtt_client::{ use irtt_client::managed::{ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance};
ClientEvent, ManagedTargetEndReason, ManagedTargetOutcome, NegotiatedParams, SignedDuration, use irtt_client::{ClientEvent, NegotiatedParams, SignedDuration};
TargetEvent,
};
use irtt_stats::{Snapshot, StatsCollector, TimeStats}; use irtt_stats::{Snapshot, StatsCollector, TimeStats};
use ratatui::{ use ratatui::{
backend::CrosstermBackend, backend::CrosstermBackend,
@@ -140,12 +138,8 @@ impl TuiState {
} }
} }
pub(super) fn process_event(&mut self, event: &ClientEvent) { pub(super) fn process_target_event(&mut self, target: &TargetInstance, event: &ClientEvent) {
self.process_event_for_target(0, event); let label = target.id.as_str();
}
pub(super) fn process_target_event(&mut self, event: &TargetEvent) {
let label = event.target.as_str();
let idx = if let Some(idx) = self.target_index.get(label).copied() { let idx = if let Some(idx) = self.target_index.get(label).copied() {
idx idx
} else { } else {
@@ -159,11 +153,11 @@ impl TuiState {
self.target_index.insert(label.to_owned(), idx); self.target_index.insert(label.to_owned(), idx);
idx idx
}; };
self.process_event_for_target(idx, &event.event); self.process_event_for_target(idx, event);
} }
pub(super) fn process_target_outcome(&mut self, outcome: &ManagedTargetOutcome) { pub(super) fn process_target_outcome(&mut self, outcome: &ManagedTargetOutcome) {
let label = outcome.id.as_str(); let label = outcome.target.id.as_str();
let idx = if let Some(idx) = self.target_index.get(label).copied() { let idx = if let Some(idx) = self.target_index.get(label).copied() {
idx idx
} else { } else {
@@ -190,15 +184,17 @@ impl TuiState {
"no-test negotiation completed".to_owned(), "no-test negotiation completed".to_owned(),
None, None,
), ),
ManagedTargetEndReason::Cancelled => { ManagedTargetEndReason::Stopped => {
(TargetStatus::Cancelled, "target cancelled".to_owned(), None) (TargetStatus::Stopped, "target stopped".to_owned(), None)
} }
ManagedTargetEndReason::Removed => { ManagedTargetEndReason::Removed => {
(TargetStatus::Removed, "target removed".to_owned(), None) (TargetStatus::Removed, "target removed".to_owned(), None)
} }
ManagedTargetEndReason::OpenFailed(failure) ManagedTargetEndReason::Replaced => {
| ManagedTargetEndReason::Failed(failure) => { (TargetStatus::Replaced, "target replaced".to_owned(), None)
let message = format!("{}: {}", failure.kind, failure.message); }
ManagedTargetEndReason::Failed(failure) => {
let message = format!("{:?}: {}", failure.kind, failure.message);
( (
TargetStatus::Failed, TargetStatus::Failed,
format!("target failed: {message}"), format!("target failed: {message}"),
@@ -208,7 +204,7 @@ impl TuiState {
}; };
if let Some(target) = self.targets.get_mut(idx) { if let Some(target) = self.targets.get_mut(idx) {
target.remote = Some(outcome.remote.to_string()); target.remote = outcome.remote.map(|remote| remote.to_string());
target.status = status; target.status = status;
if let Some(warning) = &warning { if let Some(warning) = &warning {
target.last_warning = Some(warning.clone()); target.last_warning = Some(warning.clone());
@@ -382,17 +378,8 @@ impl TuiState {
self.status = status; self.status = status;
} }
#[cfg(test)] pub(super) fn mark_dropped_managed_events(&mut self, dropped_events: u64) {
pub(super) fn status(&self) -> TuiStatus { self.mark_dropped_events(dropped_events, "managed run");
self.status
}
pub(super) fn mark_dropped_client_events(&mut self, dropped_events: u64) {
self.mark_dropped_events(dropped_events, "managed client");
}
pub(super) fn mark_dropped_group_events(&mut self, dropped_events: u64) {
self.mark_dropped_events(dropped_events, "managed group");
} }
fn mark_dropped_events(&mut self, dropped_events: u64, source: &str) { fn mark_dropped_events(&mut self, dropped_events: u64, source: &str) {
@@ -566,8 +553,9 @@ pub(super) enum TargetStatus {
Closed, Closed,
Failed, Failed,
NoTest, NoTest,
Cancelled, Stopped,
Removed, Removed,
Replaced,
Unknown, Unknown,
} }
@@ -579,8 +567,9 @@ impl TargetStatus {
Self::Closed => "closed", Self::Closed => "closed",
Self::Failed => "failed", Self::Failed => "failed",
Self::NoTest => "no-test", Self::NoTest => "no-test",
Self::Cancelled => "cancelled", Self::Stopped => "stopped",
Self::Removed => "removed", Self::Removed => "removed",
Self::Replaced => "replaced",
Self::Unknown => "unknown", Self::Unknown => "unknown",
} }
} }
@@ -588,7 +577,12 @@ impl TargetStatus {
fn is_terminal(self) -> bool { fn is_terminal(self) -> bool {
matches!( matches!(
self, self,
Self::Closed | Self::Failed | Self::NoTest | Self::Cancelled | Self::Removed Self::Closed
| Self::Failed
| Self::NoTest
| Self::Stopped
| Self::Removed
| Self::Replaced
) )
} }
@@ -690,7 +684,6 @@ impl Default for TuiConfig {
pub(super) enum TuiStatus { pub(super) enum TuiStatus {
Opening, Opening,
Running, Running,
Draining,
Interrupted, Interrupted,
Closing, Closing,
Complete, Complete,
@@ -702,7 +695,6 @@ impl TuiStatus {
match self { match self {
Self::Opening => "opening", Self::Opening => "opening",
Self::Running => "running", Self::Running => "running",
Self::Draining => "draining",
Self::Interrupted => "interrupted", Self::Interrupted => "interrupted",
Self::Closing => "closing", Self::Closing => "closing",
Self::Complete => "complete", Self::Complete => "complete",
@@ -2027,7 +2019,7 @@ fn duration_ns(value: Duration) -> i128 {
i128::try_from(value.as_nanos()).unwrap_or(i128::MAX) i128::try_from(value.as_nanos()).unwrap_or(i128::MAX)
} }
#[cfg(test)] #[cfg(any())]
mod tests { mod tests {
use super::*; use super::*;
use irtt_client::{ use irtt_client::{
+2 -2
View File
@@ -10,6 +10,6 @@ pub use args::{
pub use config::{expected_probe_count, DEFAULT_RECV_TIMEOUT}; pub use config::{expected_probe_count, DEFAULT_RECV_TIMEOUT};
pub use session::is_shutdown_requested; pub use session::is_shutdown_requested;
pub use targets::{ pub use targets::{
parse_labelled_target, resolved_managed_targets, target_specs, GroupPacingArg, parse_labelled_target, prepare_managed_targets, target_specs, GroupPacingArg,
LabelledTargetArg, ResolvedTarget, TargetSpec, LabelledTargetArg, PreparedTarget, TargetSpec,
}; };
+8 -8
View File
@@ -27,7 +27,7 @@ pub fn peer_close_run_error(
)) ))
} }
pub fn request_group_stop_for_peer_close( pub fn request_managed_stop_for_peer_close(
continuous: bool, continuous: bool,
interrupted: bool, interrupted: bool,
peer_closed_target_count: u64, peer_closed_target_count: u64,
@@ -38,10 +38,10 @@ pub fn request_group_stop_for_peer_close(
return false; return false;
} }
*peer_close_requested_stop = true; *peer_close_requested_stop = true;
request_group_stop_once(stop_requested) request_managed_stop_once(stop_requested)
} }
pub fn request_group_stop_once(stop_requested: &mut bool) -> bool { pub fn request_managed_stop_once(stop_requested: &mut bool) -> bool {
if *stop_requested { if *stop_requested {
return false; return false;
} }
@@ -72,21 +72,21 @@ mod tests {
fn grouped_peer_close_stop_policy_is_run_mode_and_one_shot() { fn grouped_peer_close_stop_policy_is_run_mode_and_one_shot() {
let mut peer_close_requested_stop = false; let mut peer_close_requested_stop = false;
let mut stop_requested = false; let mut stop_requested = false;
assert!(!request_group_stop_for_peer_close( assert!(!request_managed_stop_for_peer_close(
false, false,
false, false,
1, 1,
&mut peer_close_requested_stop, &mut peer_close_requested_stop,
&mut stop_requested, &mut stop_requested,
)); ));
assert!(!request_group_stop_for_peer_close( assert!(!request_managed_stop_for_peer_close(
true, true,
true, true,
1, 1,
&mut peer_close_requested_stop, &mut peer_close_requested_stop,
&mut stop_requested, &mut stop_requested,
)); ));
assert!(!request_group_stop_for_peer_close( assert!(!request_managed_stop_for_peer_close(
true, true,
false, false,
0, 0,
@@ -96,7 +96,7 @@ mod tests {
assert!(!peer_close_requested_stop); assert!(!peer_close_requested_stop);
assert!(!stop_requested); assert!(!stop_requested);
assert!(request_group_stop_for_peer_close( assert!(request_managed_stop_for_peer_close(
true, true,
false, false,
1, 1,
@@ -105,7 +105,7 @@ mod tests {
)); ));
assert!(peer_close_requested_stop); assert!(peer_close_requested_stop);
assert!(stop_requested); assert!(stop_requested);
assert!(!request_group_stop_for_peer_close( assert!(!request_managed_stop_for_peer_close(
true, true,
false, false,
1, 1,
+13 -110
View File
@@ -1,7 +1,7 @@
use std::{collections::HashSet, net::ToSocketAddrs}; use std::collections::HashSet;
use clap::ValueEnum; use clap::ValueEnum;
use irtt_client::{ClientConfig, ManagedGroupPacing, ManagedTargetConfig, TargetId}; use irtt_client::managed::{ManagedPacing, ManagedTargetConfig, TargetId};
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct LabelledTargetArg { pub struct LabelledTargetArg {
@@ -16,7 +16,7 @@ pub struct TargetSpec {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ResolvedTarget { pub struct PreparedTarget {
pub label: String, pub label: String,
pub managed: ManagedTargetConfig, pub managed: ManagedTargetConfig,
} }
@@ -27,7 +27,7 @@ pub enum GroupPacingArg {
Burst, Burst,
} }
impl From<GroupPacingArg> for ManagedGroupPacing { impl From<GroupPacingArg> for ManagedPacing {
fn from(value: GroupPacingArg) -> Self { fn from(value: GroupPacingArg) -> Self {
match value { match value {
GroupPacingArg::Staggered => Self::Staggered, GroupPacingArg::Staggered => Self::Staggered,
@@ -93,118 +93,21 @@ pub fn target_specs(
Ok(specs) Ok(specs)
} }
pub fn resolved_managed_targets( pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTarget>, String> {
specs: Vec<TargetSpec>,
config: &ClientConfig,
) -> Result<Vec<ResolvedTarget>, String> {
let mut remotes = HashSet::new();
let mut targets = Vec::with_capacity(specs.len()); let mut targets = Vec::with_capacity(specs.len());
for spec in specs { for spec in specs {
let remote = resolve_target(&spec.addr, config).map_err(|err| { targets.push(PreparedTarget {
format!(
"failed to resolve target {} ({:?}): {err}",
spec.label, spec.addr
)
})?;
if !remotes.insert(remote) {
return Err(format!(
"duplicate resolved target address {remote} for label {}",
spec.label
));
}
targets.push(ResolvedTarget {
label: spec.label.clone(), label: spec.label.clone(),
managed: ManagedTargetConfig { managed: ManagedTargetConfig::new(TargetId::from(spec.label), spec.addr),
id: TargetId::from(spec.label),
remote,
auth: None,
},
}); });
} }
Ok(targets) Ok(targets)
} }
pub fn resolve_target(addr: &str, config: &ClientConfig) -> Result<std::net::SocketAddr, String> {
let normalized = normalize_target_addr(addr);
let mut addrs = normalized
.to_socket_addrs()
.map_err(|_| format!("failed to resolve address {normalized:?}"))?;
addrs
.find(|addr| {
(!config.socket_config.ipv4_only || addr.is_ipv4())
&& (!config.socket_config.ipv6_only || addr.is_ipv6())
})
.ok_or_else(|| format!("failed to resolve address {normalized:?}"))
}
pub fn normalize_target_addr(addr: &str) -> String {
const DEFAULT_PORT: u16 = 2112;
if addr.parse::<std::net::SocketAddr>().is_ok() {
return addr.to_owned();
}
if addr.starts_with('[') && addr.ends_with(']') {
return format!("{addr}:{DEFAULT_PORT}");
}
if addr.starts_with('[') {
return addr.to_owned();
}
if addr.parse::<std::net::Ipv6Addr>().is_ok() {
return format!("[{addr}]:{DEFAULT_PORT}");
}
if addr
.rsplit_once(':')
.is_some_and(|(_, port)| port.parse::<u16>().is_ok())
{
return addr.to_owned();
}
format!("{addr}:{DEFAULT_PORT}")
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn target_normalization_matches_single_target_forms() {
assert_eq!(normalize_target_addr("127.0.0.1"), "127.0.0.1:2112");
assert_eq!(normalize_target_addr("127.0.0.1:1234"), "127.0.0.1:1234");
assert_eq!(normalize_target_addr("example.com"), "example.com:2112");
assert_eq!(
normalize_target_addr("example.com:1234"),
"example.com:1234"
);
assert_eq!(normalize_target_addr("[::1]"), "[::1]:2112");
assert_eq!(normalize_target_addr("[::1]:1234"), "[::1]:1234");
assert_eq!(normalize_target_addr("::1"), "[::1]:2112");
}
#[test]
fn target_resolution_respects_family_filters() {
let config = ClientConfig::default();
assert_eq!(
resolve_target("127.0.0.1", &config).unwrap(),
"127.0.0.1:2112".parse().unwrap()
);
assert_eq!(
resolve_target("[::1]", &config).unwrap(),
"[::1]:2112".parse().unwrap()
);
assert_eq!(
resolve_target("::1", &config).unwrap(),
"[::1]:2112".parse().unwrap()
);
let mut ipv4_only = ClientConfig::default();
ipv4_only.socket_config.ipv4_only = true;
assert!(resolve_target("127.0.0.1", &ipv4_only).unwrap().is_ipv4());
assert!(resolve_target("::1", &ipv4_only).is_err());
let mut ipv6_only = ClientConfig::default();
ipv6_only.socket_config.ipv6_only = true;
assert!(resolve_target("::1", &ipv6_only).unwrap().is_ipv6());
assert!(resolve_target("127.0.0.1", &ipv6_only).is_err());
}
#[test] #[test]
fn target_specs_suffix_repeated_positionals_and_reject_duplicate_labels() { fn target_specs_suffix_repeated_positionals_and_reject_duplicate_labels() {
let positionals = vec!["host-a:2112".to_owned(), "host-a:2112".to_owned()]; let positionals = vec!["host-a:2112".to_owned(), "host-a:2112".to_owned()];
@@ -222,19 +125,19 @@ mod tests {
} }
#[test] #[test]
fn duplicate_resolved_target_addresses_are_rejected() { fn target_preparation_preserves_hostname_and_allows_duplicate_endpoints() {
let specs = vec![ let specs = vec![
TargetSpec { TargetSpec {
label: "a".to_owned(), label: "a".to_owned(),
addr: "127.0.0.1:2112".to_owned(), addr: "example.test".to_owned(),
}, },
TargetSpec { TargetSpec {
label: "b".to_owned(), label: "b".to_owned(),
addr: "127.0.0.1".to_owned(), addr: "example.test".to_owned(),
}, },
]; ];
let err = resolved_managed_targets(specs, &ClientConfig::default()).unwrap_err(); let targets = prepare_managed_targets(specs).unwrap();
assert_eq!(targets[0].managed.server_addr, "example.test");
assert!(err.contains("duplicate resolved target address 127.0.0.1:2112")); assert_eq!(targets[1].managed.server_addr, "example.test");
} }
} }
+2 -2
View File
@@ -323,7 +323,7 @@ fn continuous_single_target_interruption_drains_final_events_and_succeeds() {
assert!(stdout assert!(stdout
.lines() .lines()
.any(|line| line.starts_with("session_closed,"))); .any(|line| line.starts_with("session_closed,")));
assert!(stderr.contains("interrupted, closing session"), "{stderr}"); assert!(stderr.contains("interrupted, closing managed run"), "{stderr}");
assert!(!stderr.contains("peer closure"), "{stderr}"); assert!(!stderr.contains("peer closure"), "{stderr}");
} }
@@ -643,7 +643,7 @@ fn explicit_group_interruption_succeeds_without_peer_close_error() {
stdout.lines().any(|line| line.starts_with("b,")), stdout.lines().any(|line| line.starts_with("b,")),
"{stdout}" "{stdout}"
); );
assert!(stderr.contains("interrupted, closing group"), "{stderr}"); assert!(stderr.contains("interrupted, closing managed run"), "{stderr}");
assert!(!stderr.contains("peer closure"), "{stderr}"); assert!(!stderr.contains("peer closure"), "{stderr}");
} }