feat(client): adopt an asynchronous Tokio runtime for the client frontend

This commit is contained in:
Nils Andreas Svee committed 2026-10-05 21:01:54 +07:00
1 parent 1f7df1ebc9
commit 66abb792ce
8 files changed
+354 -330

No files matched your search

+2 -2
View File
@@ -42,7 +42,7 @@ doc = false
[features] [features]
default = ["client", "server", "tui"] default = ["client", "server", "tui"]
client = ["dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats"] client = ["dep:tokio", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats"]
server = ["dep:irtt-server", "dep:tokio"] server = ["dep:irtt-server", "dep:tokio"]
tui =["dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm"] tui =["dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm"]
@@ -55,7 +55,7 @@ irtt-proto = { workspace = true, optional = true }
irtt-server = { workspace = true, optional = true } irtt-server = { workspace = true, optional = true }
irtt-stats = { workspace = true, optional = true } irtt-stats = { workspace = true, optional = true }
ratatui = { version = "0.30.2", optional = true } ratatui = { version = "0.30.2", optional = true }
tokio = { version = "1.53.2", default-features = false, features = ["rt", "time"], optional = true } tokio = { version = "1.53.2", default-features = false, features = ["rt", "time", "sync", "macros"], optional = true }
[dev-dependencies] [dev-dependencies]
irtt-server.workspace = true irtt-server.workspace = true
+1
View File
@@ -2,6 +2,7 @@ pub mod args;
pub mod output; pub mod output;
pub mod run; pub mod run;
pub mod summary; pub mod summary;
mod worker;
pub use args::{ClientArgs, HeaderMode, OutputFormat}; pub use args::{ClientArgs, HeaderMode, OutputFormat};
pub use run::run_stream; pub use run::run_stream;
+148 -316
View File
@@ -1,16 +1,15 @@
use std::{ use std::{
collections::{BTreeMap, BTreeSet, HashSet, VecDeque}, collections::{BTreeMap, BTreeSet, HashSet, VecDeque},
io::{self, BufRead, Write}, io::{self, BufRead, Write},
sync::{atomic::AtomicBool, Arc, Mutex}, sync::Arc,
thread, thread,
time::Duration,
}; };
use irtt_client::{ use irtt_client::{
managed::{ managed::{
BlockingManagedClient, ManagedClientHandle, ManagedCommandApplyError, ManagedEndReason, ManagedClient, ManagedCommandApplyError, ManagedEndReason, ManagedEvent,
ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError, ManagedStatus, ManagedEventSubscription, ManagedEventTryRecvError, ManagedStatus, ManagedTargetConfig,
ManagedTargetConfig, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
}, },
ClientEvent, ClientEvent,
}; };
@@ -18,13 +17,14 @@ use irtt_client::{
use super::{ use super::{
args::ClientArgs, args::ClientArgs,
output::{EventRenderStats, OutputConfig}, output::{EventRenderStats, OutputConfig},
worker::ManagedWorker,
}; };
use crate::shared::client::{ use crate::shared::client::{
expected_probe_count, is_shutdown_requested, parse_stdin_target_set, expected_probe_count, parse_stdin_target_set,
session::{ session::{
drain_managed_events, peer_close_run_error, request_managed_stop_for_peer_close, peer_close_run_error, request_managed_stop_for_peer_close, request_managed_stop_once,
request_managed_stop_once, should_print_final_summary, ManagedDrainState, should_print_final_summary,
}, },
ManagedRunSetup, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT, ManagedRunSetup, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT,
}; };
@@ -40,12 +40,11 @@ const FINITE_STATS_MEMORY_WARNING_BYTES: u64 = 128 * MIB;
const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB; const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB;
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB; const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
const MANAGED_EVENT_WAIT_SLICE: Duration = Duration::from_millis(20);
const MAX_STDIN_RECORD_BYTES: usize = 64 * 1024; const MAX_STDIN_RECORD_BYTES: usize = 64 * 1024;
pub fn run_stream( pub async fn run_stream(
args: ClientArgs, args: ClientArgs,
shutdown_requested: &AtomicBool, mut shutdown: tokio::sync::watch::Receiver<bool>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
if args.list_columns { if args.list_columns {
print!("{}", OutputConfig::list_columns()); print!("{}", OutputConfig::list_columns());
@@ -63,7 +62,7 @@ pub fn run_stream(
) )
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
if setup.stdin_controlled { if setup.stdin_controlled {
return run_stdin_stream(setup, output_config, shutdown_requested); return run_stdin_stream(setup, output_config, shutdown).await;
} }
let continuous = args.is_continuous(); let continuous = args.is_continuous();
let target_count = setup.target_count(); let target_count = setup.target_count();
@@ -71,14 +70,13 @@ pub fn run_stream(
if let Some(warning) = finite_stats_memory_warning(&args, target_count) { if let Some(warning) = finite_stats_memory_warning(&args, target_count) {
eprintln!("{warning}"); eprintln!("{warning}");
} }
if is_shutdown_requested(shutdown_requested) { if *shutdown.borrow() {
return Ok(()); return Ok(());
} }
let (owner, mut events) = BlockingManagedClient::start_with_subscription( let (task, handle) = ManagedClient::task(setup.managed_config(), setup.managed_targets())?;
setup.managed_config(), let mut events = handle.subscribe()?;
setup.managed_targets(), let mut status = handle.subscribe_status();
)?; let owner = ManagedWorker::start(task, handle.clone())?;
let handle = owner.handle();
let mut stdout = io::LineWriter::new(io::stdout().lock()); let mut stdout = io::LineWriter::new(io::stdout().lock());
let mut stream_output = StreamOutput { let mut stream_output = StreamOutput {
config: output_config, config: output_config,
@@ -102,9 +100,9 @@ pub fn run_stream(
let mut dropped_events = 0_u64; let mut dropped_events = 0_u64;
let mut interrupted = false; let mut interrupted = false;
let mut stop_requested = false; let mut stop_requested = false;
let mut subscription_closed = false;
loop { loop {
if is_shutdown_requested(shutdown_requested) { let snapshot = status.borrow_and_update().clone();
if *shutdown.borrow() {
interrupted = true; interrupted = true;
if request_managed_stop_once(&mut stop_requested) { if request_managed_stop_once(&mut stop_requested) {
drop(handle.stop()); drop(handle.stop());
@@ -113,38 +111,33 @@ pub fn run_stream(
if request_managed_stop_for_peer_close( if request_managed_stop_for_peer_close(
continuous, continuous,
interrupted, interrupted,
handle.status().peer_closed_target_outcomes, snapshot.peer_closed_target_outcomes,
&mut stop_requested, &mut stop_requested,
) { ) {
drop(handle.stop()); drop(handle.stop());
} }
let drain_state = drain_events( if snapshot.final_outcome.is_some() {
&mut events,
&mut stream_output,
&mut stats,
&mut terminal_targets,
&mut dropped_events,
)?;
match drain_state {
ManagedDrainState::Empty => thread::sleep(MANAGED_EVENT_WAIT_SLICE),
ManagedDrainState::Closed => subscription_closed = true,
ManagedDrainState::BudgetExhausted => {}
}
if handle.status().final_outcome.is_some() || subscription_closed {
break; break;
} }
tokio::select! {
_ = shutdown.changed(), if !interrupted => {}
changed = status.changed() => { if changed.is_err() { break; } }
event = events.recv() => match event {
Ok(event) => process_event(event, &mut stream_output, &mut stats, &mut terminal_targets)?,
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
dropped_events = dropped_events.saturating_add(count);
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
} }
if interrupted { if interrupted {
eprintln!("interrupted, closing managed run..."); eprintln!("interrupted, closing managed run...");
} }
let outcome = owner.join()?; let outcome = owner.join().await?;
drain_final_events( drain_final_events(&mut events, &mut dropped_events, |event| {
&mut events, process_event(event, &mut stream_output, &mut stats, &mut terminal_targets)
&mut stream_output, })?;
&mut stats,
&mut terminal_targets,
&mut dropped_events,
)?;
if let Some(warning) = dropped_event_warning(dropped_events) { if let Some(warning) = dropped_event_warning(dropped_events) {
eprintln!("{warning}"); eprintln!("{warning}");
} }
@@ -159,7 +152,7 @@ pub fn run_stream(
report_target_failure(target); report_target_failure(target);
} }
} }
interrupted |= is_shutdown_requested(shutdown_requested); interrupted |= *shutdown.borrow();
let terminal_error = match &outcome.end_reason { let terminal_error = match &outcome.end_reason {
ManagedEndReason::DriverFailed(failure) => { ManagedEndReason::DriverFailed(failure) => {
Some(format!("managed driver failed: {failure}")) Some(format!("managed driver failed: {failure}"))
@@ -201,98 +194,9 @@ pub fn run_stream(
} }
#[derive(Clone)] #[derive(Clone)]
struct StdinTargetSet { enum StdinUpdate {
revision: u64, Targets(Vec<ManagedTargetConfig>),
targets: Vec<ManagedTargetConfig>, Stop(StdinStop),
}
#[derive(Default)]
struct StdinMailboxState {
next_revision: u64,
latest: Option<StdinTargetSet>,
eof: bool,
fatal: Option<String>,
}
#[derive(Default)]
struct StdinMailbox {
state: Mutex<StdinMailboxState>,
}
impl StdinMailbox {
fn publish(&self, targets: Vec<ManagedTargetConfig>) {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.next_revision = state.next_revision.saturating_add(1);
state.latest = Some(StdinTargetSet {
revision: state.next_revision,
targets,
});
}
fn finish(&self) {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.eof = true;
}
fn fail(&self, error: String) {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.fatal = Some(error);
state.latest = None;
}
fn latest(&self) -> Option<StdinTargetSet> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.latest
.clone()
}
fn clear_if_current(&self, revision: u64) {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state
.latest
.as_ref()
.is_some_and(|set| set.revision == revision)
{
state.latest = None;
}
}
fn is_current(&self, revision: u64) -> bool {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.latest
.as_ref()
.is_some_and(|set| set.revision == revision)
}
fn fatal(&self) -> Option<String> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.fatal
.clone()
}
fn is_finished(&self) -> bool {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.eof
}
} }
fn read_stdin_record<R: BufRead>( fn read_stdin_record<R: BufRead>(
@@ -371,7 +275,10 @@ fn read_stdin_record<R: BufRead>(
} }
} }
fn read_stdin_target_sets<R: BufRead>(reader: &mut R, mailbox: &StdinMailbox) { fn read_stdin_target_sets<R: BufRead>(
reader: &mut R,
updates: &tokio::sync::watch::Sender<StdinUpdate>,
) {
let mut line = 0_u64; let mut line = 0_u64;
let mut record = Vec::with_capacity(MAX_STDIN_RECORD_BYTES + 1); let mut record = Vec::with_capacity(MAX_STDIN_RECORD_BYTES + 1);
loop { loop {
@@ -383,101 +290,43 @@ fn read_stdin_target_sets<R: BufRead>(reader: &mut R, mailbox: &StdinMailbox) {
} }
match parse_stdin_target_set(&record, STDIN_MAX_DESIRED_TARGETS) { match parse_stdin_target_set(&record, STDIN_MAX_DESIRED_TARGETS) {
Ok(targets) => { Ok(targets) => {
mailbox.publish(targets.into_iter().map(|target| target.managed).collect()) updates.send_replace(StdinUpdate::Targets(
targets.into_iter().map(|target| target.managed).collect(),
));
} }
Err(error) => { Err(error) => {
mailbox.fail(format!("invalid --targets-stdin line {line}: {error}")); updates.send_replace(StdinUpdate::Stop(StdinStop::Fatal(format!(
"invalid --targets-stdin line {line}: {error}"
))));
return; return;
} }
} }
} }
Ok(None) => { Ok(None) => {
mailbox.finish(); updates.send_replace(StdinUpdate::Stop(StdinStop::Eof));
return; return;
} }
Err(_) => { Err(_) => {
mailbox.fail(format!("failed to read --targets-stdin line {}", line + 1)); updates.send_replace(StdinUpdate::Stop(StdinStop::Fatal(format!(
"failed to read --targets-stdin line {}",
line + 1
))));
return; return;
} }
} }
} }
} }
fn spawn_stdin_target_reader(mailbox: Arc<StdinMailbox>) -> io::Result<()> { fn spawn_stdin_target_reader(updates: tokio::sync::watch::Sender<StdinUpdate>) -> io::Result<()> {
thread::Builder::new() thread::Builder::new()
.name("irtt-targets-stdin".to_owned()) .name("irtt-targets-stdin".to_owned())
.spawn(move || { .spawn(move || {
let stdin = io::stdin(); let stdin = io::stdin();
read_stdin_target_sets(&mut stdin.lock(), &mailbox); read_stdin_target_sets(&mut stdin.lock(), &updates);
}) })
.map(|_| ()) .map(|_| ())
} }
#[derive(Clone)]
struct RetryAfterStatus {
revision: u64,
status: Arc<ManagedStatus>,
}
fn retry_waits_for_status_change(
retry_after: &mut Option<RetryAfterStatus>,
target_set: &StdinTargetSet,
status: &Arc<ManagedStatus>,
) -> bool {
let Some(retry) = retry_after else {
return false;
};
if retry.revision != target_set.revision {
*retry_after = None;
return false;
}
Arc::ptr_eq(&retry.status, status)
}
fn retry_after_capacity_rejection(
mailbox: &StdinMailbox,
target_set: &StdinTargetSet,
status: Arc<ManagedStatus>,
) -> Option<RetryAfterStatus> {
mailbox
.is_current(target_set.revision)
.then_some(RetryAfterStatus {
revision: target_set.revision,
status,
})
}
fn apply_latest_stdin_target_set(
handle: &ManagedClientHandle,
mailbox: &StdinMailbox,
retry_after: &mut Option<RetryAfterStatus>,
) -> Result<(), String> {
let Some(target_set) = mailbox.latest() else {
*retry_after = None;
return Ok(());
};
let status = handle.status();
if retry_waits_for_status_change(retry_after, &target_set, &status) {
return Ok(());
}
let receipt = handle
.update_targets(target_set.targets.clone())
.map_err(|_| "failed to submit --targets-stdin update".to_owned())?;
match receipt.blocking_wait() {
Ok(_) => {
mailbox.clear_if_current(target_set.revision);
*retry_after = None;
Ok(())
}
Err(ManagedCommandApplyError::LiveGenerationLimitExceeded { .. }) => {
*retry_after = retry_after_capacity_rejection(mailbox, &target_set, status);
Ok(())
}
Err(_) => Err("--targets-stdin update was rejected".to_owned()),
}
}
struct BoundedTargetSet { struct BoundedTargetSet {
limit: usize, limit: usize,
order: VecDeque<TargetInstance>, order: VecDeque<TargetInstance>,
@@ -542,37 +391,6 @@ fn process_stdin_event<W: Write>(
Ok(()) Ok(())
} }
fn drain_stdin_events<W: Write>(
events: &mut ManagedEventSubscription,
stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
terminal_targets: &mut BoundedTargetSet,
dropped_events: &mut u64,
) -> io::Result<ManagedDrainState> {
drain_managed_events(events, dropped_events, |event| {
process_stdin_event(event, stream_output, stats, terminal_targets)
})
}
fn drain_final_stdin_events<W: Write>(
events: &mut ManagedEventSubscription,
stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
terminal_targets: &mut BoundedTargetSet,
dropped_events: &mut u64,
) -> io::Result<()> {
loop {
match events.try_recv() {
Ok(event) => process_stdin_event(event, stream_output, stats, terminal_targets)?,
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
Err(ManagedEventTryRecvError::Lagged(count)) => {
*dropped_events = dropped_events.saturating_add(count);
}
}
}
Ok(())
}
fn reconcile_stdin_stats( fn reconcile_stdin_stats(
stats: &mut BTreeMap<TargetInstance, StatsCollector>, stats: &mut BTreeMap<TargetInstance, StatsCollector>,
status: &ManagedStatus, status: &ManagedStatus,
@@ -613,36 +431,20 @@ enum StdinStop {
Fatal(String), Fatal(String),
} }
fn stdin_stop_request( async fn run_stdin_stream(
shutdown_requested: &AtomicBool,
mailbox: &StdinMailbox,
) -> Option<StdinStop> {
if is_shutdown_requested(shutdown_requested) {
Some(StdinStop::Interrupted)
} else if let Some(error) = mailbox.fatal() {
Some(StdinStop::Fatal(error))
} else if mailbox.is_finished() {
Some(StdinStop::Eof)
} else {
None
}
}
fn run_stdin_stream(
setup: ManagedRunSetup, setup: ManagedRunSetup,
output_config: OutputConfig, output_config: OutputConfig,
shutdown_requested: &AtomicBool, mut shutdown: tokio::sync::watch::Receiver<bool>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
if is_shutdown_requested(shutdown_requested) { if *shutdown.borrow() {
return Ok(()); return Ok(());
} }
let (owner, mut events) = BlockingManagedClient::start_with_subscription( let (task, handle) = ManagedClient::task(setup.managed_config(), setup.managed_targets())?;
setup.managed_config(), let mut events = handle.subscribe()?;
setup.managed_targets(), let mut status = handle.subscribe_status();
)?; let owner = ManagedWorker::start(task, handle.clone())?;
let handle = owner.handle(); let (updates, mut stdin) = tokio::sync::watch::channel(StdinUpdate::Targets(Vec::new()));
let mailbox = Arc::new(StdinMailbox::default()); spawn_stdin_target_reader(updates)?;
spawn_stdin_target_reader(Arc::clone(&mailbox))?;
let mut stdout = io::LineWriter::new(io::stdout().lock()); let mut stdout = io::LineWriter::new(io::stdout().lock());
let mut stream_output = StreamOutput { let mut stream_output = StreamOutput {
@@ -654,59 +456,103 @@ fn run_stdin_stream(
}; };
let mut stats = BTreeMap::new(); let mut stats = BTreeMap::new();
let mut terminal_targets = BoundedTargetSet::new(STDIN_OUTCOME_HISTORY_LIMIT); let mut terminal_targets = BoundedTargetSet::new(STDIN_OUTCOME_HISTORY_LIMIT);
let mut stdin_changed = false;
let mut desired = None;
let mut pending = None;
let mut submitted = Vec::new();
let mut retry_after = None; let mut retry_after = None;
let mut dropped_events = 0_u64; let mut dropped_events = 0_u64;
let mut stop_requested = false; let mut stop_requested = false;
let mut stop = None; let mut stop = None;
let mut summary_targets = None; let mut summary_targets = None;
let mut subscription_closed = false;
loop { loop {
let status = handle.status(); let snapshot = status.borrow_and_update().clone();
if stop.is_none() { if stop.is_none() {
if let Some(requested_stop) = stdin_stop_request(shutdown_requested, &mailbox) { if *shutdown.borrow() {
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status)); stop = Some(StdinStop::Interrupted);
stop = Some(requested_stop); } else if stdin_changed || stdin.has_changed().unwrap_or(true) {
if request_managed_stop_once(&mut stop_requested) { stdin_changed = false;
drop(handle.stop()); match stdin.borrow_and_update().clone() {
StdinUpdate::Targets(targets) => {
desired = Some(targets);
retry_after = None;
} }
} else if let Err(error) = StdinUpdate::Stop(reason) => stop = Some(reason),
apply_latest_stdin_target_set(&handle, &mailbox, &mut retry_after) }
}
if stop.is_none()
&& pending.is_none()
&& !retry_after
.as_ref()
.is_some_and(|previous| Arc::ptr_eq(previous, &snapshot))
{ {
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status)); if let Some(targets) = desired.take() {
stop = Some(StdinStop::Fatal(error)); submitted = targets;
match handle.update_targets(submitted.clone()) {
Ok(receipt) => {
pending = Some(receipt);
retry_after = Some(snapshot.clone());
}
Err(_) => {
stop = Some(StdinStop::Fatal(
"failed to submit --targets-stdin update".to_owned(),
))
}
}
}
}
if stop.is_some() {
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &snapshot));
if request_managed_stop_once(&mut stop_requested) { if request_managed_stop_once(&mut stop_requested) {
drop(handle.stop()); drop(handle.stop());
} }
} }
} }
reconcile_stdin_stats(&mut stats, &snapshot, summary_targets.as_ref());
let drain_state = drain_stdin_events( if snapshot.final_outcome.is_some() {
&mut events,
&mut stream_output,
&mut stats,
&mut terminal_targets,
&mut dropped_events,
)?;
reconcile_stdin_stats(&mut stats, &handle.status(), summary_targets.as_ref());
match drain_state {
ManagedDrainState::Empty => thread::sleep(MANAGED_EVENT_WAIT_SLICE),
ManagedDrainState::Closed => subscription_closed = true,
ManagedDrainState::BudgetExhausted => {}
}
if handle.status().final_outcome.is_some() || subscription_closed {
break; break;
} }
tokio::select! {
_ = shutdown.changed(), if stop.is_none() => {}
_ = stdin.changed(), if stop.is_none() => { stdin_changed = true; }
changed = status.changed() => { if changed.is_err() { break; } }
result = async { pending.as_mut().expect("guarded pending receipt").await }, if pending.is_some() && stop.is_none() => {
pending = None;
match result {
Ok(_) => retry_after = None,
Err(ManagedCommandApplyError::LiveGenerationLimitExceeded { .. }) => {
// A newer desired set supersedes this rejected set. Otherwise,
// retry only after the driver's status advances from submission.
if desired.is_none() {
desired = Some(std::mem::take(&mut submitted));
} else {
retry_after = None;
}
}
Err(_) => {
stop = Some(StdinStop::Fatal("--targets-stdin update was rejected".to_owned()));
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status.borrow()));
if request_managed_stop_once(&mut stop_requested) { drop(handle.stop()); }
}
}
}
event = events.recv() => match event {
Ok(event) => {
process_stdin_event(event, &mut stream_output, &mut stats, &mut terminal_targets)?;
reconcile_stdin_stats(&mut stats, &status.borrow(), summary_targets.as_ref());
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
dropped_events = dropped_events.saturating_add(count);
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
} }
let outcome = owner.join()?; let outcome = owner.join().await?;
drain_final_stdin_events( drain_final_events(&mut events, &mut dropped_events, |event| {
&mut events, process_stdin_event(event, &mut stream_output, &mut stats, &mut terminal_targets)
&mut stream_output, })?;
&mut stats,
&mut terminal_targets,
&mut dropped_events,
)?;
if let Some(warning) = dropped_event_warning(dropped_events) { if let Some(warning) = dropped_event_warning(dropped_events) {
eprintln!("{warning}"); eprintln!("{warning}");
} }
@@ -782,28 +628,14 @@ fn process_event<W: Write>(
Ok(()) Ok(())
} }
fn drain_events<W: Write>( fn drain_final_events<E>(
events: &mut ManagedEventSubscription, events: &mut ManagedEventSubscription,
stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<String, StatsCollector>,
terminal_targets: &mut HashSet<TargetInstance>,
dropped_events: &mut u64, dropped_events: &mut u64,
) -> io::Result<ManagedDrainState> { mut process: impl FnMut(ManagedEvent) -> Result<(), E>,
drain_managed_events(events, dropped_events, |event| { ) -> Result<(), E> {
process_event(event, stream_output, stats, terminal_targets)
})
}
fn drain_final_events<W: Write>(
events: &mut ManagedEventSubscription,
stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<String, StatsCollector>,
terminal_targets: &mut HashSet<TargetInstance>,
dropped_events: &mut u64,
) -> io::Result<()> {
loop { loop {
match events.try_recv() { match events.try_recv() {
Ok(event) => process_event(event, stream_output, stats, terminal_targets)?, Ok(event) => process(event)?,
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break, Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
Err(ManagedEventTryRecvError::Lagged(count)) => { Err(ManagedEventTryRecvError::Lagged(count)) => {
*dropped_events = dropped_events.saturating_add(count); *dropped_events = dropped_events.saturating_add(count);
+133
View File
@@ -0,0 +1,133 @@
use std::{io, thread, time::Duration};
use irtt_client::managed::{ManagedClientHandle, ManagedClientTask, ManagedOutcome};
use tokio::sync::oneshot;
/// Keeps measurement scheduling separate from frontend output and statistics.
/// Drop also stops and joins on frontend errors or cancellation.
pub(super) struct ManagedWorker {
handle: ManagedClientHandle,
thread: Option<thread::JoinHandle<()>>,
completion: oneshot::Receiver<io::Result<ManagedOutcome>>,
}
impl ManagedWorker {
pub(super) fn start(task: ManagedClientTask, handle: ManagedClientHandle) -> io::Result<Self> {
let (completed, completion) = oneshot::channel();
let thread = thread::Builder::new()
.name("irtt-managed".to_owned())
.spawn(move || {
// Construct and destroy the runtime on its owning thread, including
// startup failures; never drop a runtime inside the frontend executor.
let outcome = (|| {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
runtime.block_on(task)
}));
runtime.shutdown_timeout(Duration::from_millis(100));
match outcome {
Ok(outcome) => Ok(outcome),
Err(panic) => std::panic::resume_unwind(panic),
}
})();
let _ = completed.send(outcome);
})?;
Ok(Self {
handle,
thread: Some(thread),
completion,
})
}
pub(super) async fn join(mut self) -> Result<ManagedOutcome, Box<dyn std::error::Error>> {
let outcome = (&mut self.completion).await;
self.join_thread()?;
Ok(outcome
.map_err(|_| io::Error::other("managed worker completed without an outcome"))??)
}
fn join_thread(&mut self) -> io::Result<()> {
self.thread
.take()
.expect("managed worker is joined once")
.join()
.map_err(|_| io::Error::other("managed worker thread panicked"))
}
}
impl Drop for ManagedWorker {
fn drop(&mut self) {
if self.thread.is_some() {
drop(self.handle.stop());
let _ = self.join_thread();
}
}
}
#[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
);
}
}
+10 -7
View File
@@ -1,9 +1,9 @@
#[cfg(any(feature = "client", feature = "server", feature = "tui"))] #[cfg(any(feature = "server", feature = "tui"))]
use std::sync::{atomic::AtomicBool, Arc}; use std::sync::{atomic::AtomicBool, Arc};
use std::{env, ffi::OsString, process::ExitCode}; use std::{env, ffi::OsString, process::ExitCode};
use crate::applet::{dispatch_from_argv, AppletDispatch, RequestedApplet}; use crate::applet::{dispatch_from_argv, AppletDispatch, RequestedApplet};
#[cfg(any(feature = "client", feature = "server", feature = "tui"))] #[cfg(any(feature = "server", feature = "tui"))]
use crate::signal::install_signal_handler; use crate::signal::install_signal_handler;
/// Entry point for the `irtt-rs` multicall dispatcher binary. /// Entry point for the `irtt-rs` multicall dispatcher binary.
@@ -75,9 +75,9 @@ fn run_dispatcher_from_env() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
/// Installs the shutdown signal handler shared by every applet and hands the /// Installs the shutdown signal handler for synchronous applets and hands the
/// resulting flag to `f`. /// resulting flag to `f`.
#[cfg(any(feature = "client", feature = "server", feature = "tui"))] #[cfg(any(feature = "server", feature = "tui"))]
fn with_shutdown_flag( fn with_shutdown_flag(
f: impl FnOnce(&AtomicBool) -> Result<(), Box<dyn std::error::Error>>, f: impl FnOnce(&AtomicBool) -> Result<(), Box<dyn std::error::Error>>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
@@ -91,10 +91,13 @@ fn with_shutdown_flag(
fn run_client_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> { fn run_client_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser; use clap::Parser;
with_shutdown_flag(|shutdown_requested| { let shutdown = crate::signal::install_async_signal_handler()
.map_err(|err| format!("failed to install signal handler: {err}"))?;
let args = crate::cmd::client::ClientArgs::parse_from(argv); let args = crate::cmd::client::ClientArgs::parse_from(argv);
crate::cmd::client::run_stream(args, shutdown_requested) tokio::runtime::Builder::new_current_thread()
}) .enable_all()
.build()?
.block_on(crate::cmd::client::run_stream(args, shutdown))
} }
#[cfg(not(feature = "client"))] #[cfg(not(feature = "client"))]
+4 -4
View File
@@ -1,12 +1,12 @@
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(any(feature = "client", feature = "tui"))] #[cfg(feature = "tui")]
use irtt_client::managed::{ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError}; use irtt_client::managed::{ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError};
#[cfg(any(feature = "client", feature = "tui"))] #[cfg(feature = "tui")]
pub(crate) const MANAGED_EVENT_WORK_BUDGET: usize = 128; pub(crate) const MANAGED_EVENT_WORK_BUDGET: usize = 128;
#[cfg(any(feature = "client", feature = "tui"))] #[cfg(feature = "tui")]
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ManagedDrainState { pub(crate) enum ManagedDrainState {
Empty, Empty,
@@ -16,7 +16,7 @@ pub(crate) enum ManagedDrainState {
/// Drain a bounded amount of lossy presentation work before returning to the /// Drain a bounded amount of lossy presentation work before returning to the
/// frontend control loop. /// frontend control loop.
#[cfg(any(feature = "client", feature = "tui"))] #[cfg(feature = "tui")]
pub(crate) fn drain_managed_events<E>( pub(crate) fn drain_managed_events<E>(
events: &mut ManagedEventSubscription, events: &mut ManagedEventSubscription,
dropped_events: &mut u64, dropped_events: &mut u64,
+12
View File
@@ -1,10 +1,22 @@
#[cfg(any(feature = "server", feature = "tui"))]
use std::sync::{ use std::sync::{
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
Arc, Arc,
}; };
#[cfg(any(feature = "server", feature = "tui"))]
pub fn install_signal_handler(shutdown_requested: Arc<AtomicBool>) -> Result<(), ctrlc::Error> { pub fn install_signal_handler(shutdown_requested: Arc<AtomicBool>) -> Result<(), ctrlc::Error> {
ctrlc::set_handler(move || { ctrlc::set_handler(move || {
shutdown_requested.store(true, Ordering::Relaxed); shutdown_requested.store(true, Ordering::Relaxed);
}) })
} }
/// Preserve ctrlc's SIGINT/SIGTERM policy while waking the async client.
#[cfg(feature = "client")]
pub fn install_async_signal_handler() -> Result<tokio::sync::watch::Receiver<bool>, ctrlc::Error> {
let (shutdown, receiver) = tokio::sync::watch::channel(false);
ctrlc::set_handler(move || {
shutdown.send_replace(true);
})?;
Ok(receiver)
}
+44 -1
View File
@@ -442,7 +442,7 @@ fn targets_stdin_supersedes_the_active_set_and_stops_on_eof() {
let mut stdin = client.0.stdin.take().unwrap(); let mut stdin = client.0.stdin.take().unwrap();
let mut rows = Vec::new(); let mut rows = Vec::new();
for target in ["first", "second"] { for target in ["first", "second"] {
writeln!(stdin, "{target}={}", server.addr).unwrap(); write!(stdin, "\r\n{target}={}\r\n", server.addr).unwrap();
stdin.flush().unwrap(); stdin.flush().unwrap();
let deadline = Instant::now() + Duration::from_secs(5); let deadline = Instant::now() + Duration::from_secs(5);
loop { loop {
@@ -485,3 +485,46 @@ fn targets_stdin_supersedes_the_active_set_and_stops_on_eof() {
.any(|row| row.contains("\"target\":\"second\"") .any(|row| row.contains("\"target\":\"second\"")
&& row.contains("\"event\":\"session_closed\""))); && 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}");
}
}