fix(cli): drive single-target runs with ManagedClient
Replace the manual single-target ClientSession loops in the stream and TUI adapters with ManagedClient::start_with_subscription so the existing worker owns opening, deadline-aware pacing, receive handling, timeout polling, draining, and close. Use bounded subscription waits for interruption and input polling, drain queued events before and after join, and report per-subscription drops without changing output, statistics, labels, summaries, warning, or peer-close policy. Add process and unit regressions for 10 ms cadence, interruption, final-event draining, TUI terminal state, and overflow visibility.
This commit is contained in:
1 parent
080d45ca67
commit
6af4663b6e
9 files changed
+530
-477
No files matched your search
@@ -9,9 +9,10 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use irtt_client::{
|
use irtt_client::{
|
||||||
ClientEvent, EventSubscriptionError, ManagedClientGroup, ManagedClientGroupConfig,
|
ClientEvent, EventSubscriptionError, ManagedClient, ManagedClientGroup,
|
||||||
ManagedGroupCompletionPolicy, ManagedGroupEndReason, ManagedGroupEvent, ManagedTargetOutcome,
|
ManagedClientGroupConfig, ManagedGroupCompletionPolicy, ManagedGroupEndReason,
|
||||||
SubscriberConfig, SubscriberOverflow, TargetEvent,
|
ManagedGroupEvent, ManagedTargetOutcome, SessionEndReason, SubscriberConfig,
|
||||||
|
SubscriberOverflow, TargetEvent,
|
||||||
};
|
};
|
||||||
#[cfg(all(test, feature = "stats"))]
|
#[cfg(all(test, feature = "stats"))]
|
||||||
use irtt_client::{ClientTimestamp, PacketMeta, RttSample, SignedDuration};
|
use irtt_client::{ClientTimestamp, PacketMeta, RttSample, SignedDuration};
|
||||||
@@ -28,7 +29,6 @@ use crate::shared::client::{
|
|||||||
peer_close_run_error, request_group_stop_once, should_print_final_summary,
|
peer_close_run_error, request_group_stop_once, should_print_final_summary,
|
||||||
should_stop_group_on_peer_close,
|
should_stop_group_on_peer_close,
|
||||||
},
|
},
|
||||||
ClientSession,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(feature = "stats")]
|
#[cfg(feature = "stats")]
|
||||||
@@ -49,6 +49,8 @@ const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
|
|||||||
|
|
||||||
const GROUP_IDLE_SLEEP: Duration = Duration::from_millis(5);
|
const GROUP_IDLE_SLEEP: Duration = Duration::from_millis(5);
|
||||||
const GROUP_COMPLETION_GRACE: Duration = Duration::from_secs(1);
|
const GROUP_COMPLETION_GRACE: Duration = Duration::from_secs(1);
|
||||||
|
const MANAGED_EVENT_WAIT_SLICE: Duration = Duration::from_millis(20);
|
||||||
|
const MANAGED_EVENT_CAPACITY: usize = 16_384;
|
||||||
|
|
||||||
pub fn run_stream(
|
pub fn run_stream(
|
||||||
args: ClientArgs,
|
args: ClientArgs,
|
||||||
@@ -103,66 +105,71 @@ pub fn run_stream(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut session = ClientSession::connect(args.to_client_config(), continuous)?;
|
let (session, events) = ManagedClient::start_with_subscription(
|
||||||
|
args.to_client_config(),
|
||||||
if is_shutdown_requested(shutdown_requested) {
|
managed_event_subscriber_config(),
|
||||||
return Ok(());
|
)?;
|
||||||
}
|
|
||||||
|
|
||||||
let events = session.open()?;
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
print_events_with_stats(&mut stream_output, &events, Some(target_label), &mut stats)?;
|
|
||||||
#[cfg(not(feature = "stats"))]
|
|
||||||
print_events_with_stats(&mut stream_output, &events, Some(target_label))?;
|
|
||||||
|
|
||||||
let mut interrupted = false;
|
let mut interrupted = false;
|
||||||
while session.should_continue(shutdown_requested) {
|
loop {
|
||||||
let events = session.step(shutdown_requested)?;
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
print_events_with_stats(&mut stream_output, &events, Some(target_label), &mut stats)?;
|
|
||||||
#[cfg(not(feature = "stats"))]
|
|
||||||
print_events_with_stats(&mut stream_output, &events, Some(target_label))?;
|
|
||||||
|
|
||||||
if is_shutdown_requested(shutdown_requested) {
|
if is_shutdown_requested(shutdown_requested) {
|
||||||
interrupted = true;
|
interrupted = true;
|
||||||
|
session.stop();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
session.sleep_until_next_send();
|
match events.recv_timeout(MANAGED_EVENT_WAIT_SLICE) {
|
||||||
|
Ok(Some(event)) => {
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
print_events_with_stats(
|
||||||
|
&mut stream_output,
|
||||||
|
std::slice::from_ref(&event),
|
||||||
|
Some(target_label),
|
||||||
|
&mut stats,
|
||||||
|
)?;
|
||||||
|
#[cfg(not(feature = "stats"))]
|
||||||
|
print_events_with_stats(
|
||||||
|
&mut stream_output,
|
||||||
|
std::slice::from_ref(&event),
|
||||||
|
Some(target_label),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(EventSubscriptionError::Disconnected) => break,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
interrupted |= is_shutdown_requested(shutdown_requested);
|
interrupted |= is_shutdown_requested(shutdown_requested);
|
||||||
|
|
||||||
if interrupted {
|
if interrupted {
|
||||||
eprintln!("interrupted, closing session...");
|
eprintln!("interrupted, closing session...");
|
||||||
}
|
drain_single_client_events(
|
||||||
|
&events,
|
||||||
if session.should_drain_final(interrupted) {
|
&mut stream_output,
|
||||||
session.drain_final(|events| {
|
target_label,
|
||||||
#[cfg(feature = "stats")]
|
#[cfg(feature = "stats")]
|
||||||
let _ =
|
&mut stats,
|
||||||
print_events_with_stats(&mut stream_output, events, Some(target_label), &mut stats);
|
)?;
|
||||||
#[cfg(not(feature = "stats"))]
|
|
||||||
let _ = print_events_with_stats(&mut stream_output, events, Some(target_label));
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let events = session.poll_timeouts()?;
|
let outcome = session.join();
|
||||||
#[cfg(feature = "stats")]
|
drain_single_client_events(
|
||||||
print_events_with_stats(&mut stream_output, &events, Some(target_label), &mut stats)?;
|
&events,
|
||||||
#[cfg(not(feature = "stats"))]
|
&mut stream_output,
|
||||||
print_events_with_stats(&mut stream_output, &events, Some(target_label))?;
|
target_label,
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
&mut stats,
|
||||||
|
)?;
|
||||||
|
if let Some(warning) = dropped_event_warning(events.dropped_events(), "managed client") {
|
||||||
|
eprintln!("{warning}");
|
||||||
|
}
|
||||||
|
|
||||||
let events = session.close()?;
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
print_events_with_stats(&mut stream_output, &events, Some(target_label), &mut stats)?;
|
|
||||||
#[cfg(not(feature = "stats"))]
|
|
||||||
print_events_with_stats(&mut stream_output, &events, Some(target_label))?;
|
|
||||||
interrupted |= is_shutdown_requested(shutdown_requested);
|
interrupted |= is_shutdown_requested(shutdown_requested);
|
||||||
let peer_close_error = peer_close_run_error(
|
let peer_close_error = outcome.as_ref().ok().and_then(|outcome| {
|
||||||
continuous,
|
peer_close_run_error(
|
||||||
interrupted,
|
continuous,
|
||||||
if session.is_peer_closed() { 1 } else { 0 },
|
interrupted,
|
||||||
);
|
u64::from(outcome.end_reason == SessionEndReason::PeerClosed),
|
||||||
|
)
|
||||||
|
});
|
||||||
let print_final_summary = should_print_final_summary(continuous, interrupted);
|
let print_final_summary = should_print_final_summary(continuous, interrupted);
|
||||||
stream_output.print_final_summary = print_final_summary;
|
stream_output.print_final_summary = print_final_summary;
|
||||||
stream_output.show_running_only_summary_note = continuous && interrupted && print_final_summary;
|
stream_output.show_running_only_summary_note = continuous && interrupted && print_final_summary;
|
||||||
@@ -171,12 +178,59 @@ pub fn run_stream(
|
|||||||
#[cfg(not(feature = "stats"))]
|
#[cfg(not(feature = "stats"))]
|
||||||
stream_output.print_summary()?;
|
stream_output.print_summary()?;
|
||||||
stream_output.out.flush()?;
|
stream_output.out.flush()?;
|
||||||
|
if let Err(error) = outcome {
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
if let Some(error) = peer_close_error {
|
if let Some(error) = peer_close_error {
|
||||||
return Err(error.into());
|
return Err(error.into());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn managed_event_subscriber_config() -> SubscriberConfig {
|
||||||
|
SubscriberConfig {
|
||||||
|
capacity: MANAGED_EVENT_CAPACITY,
|
||||||
|
// CLI output is best-effort under sustained backpressure. Dropping
|
||||||
|
// stale rows keeps the managed worker independent from output speed,
|
||||||
|
// while dropped_events() makes incomplete output visible.
|
||||||
|
overflow: SubscriberOverflow::DropOldest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
fn drain_single_client_events<W: Write>(
|
||||||
|
events: &irtt_client::EventSubscription,
|
||||||
|
stream_output: &mut StreamOutput<'_, W>,
|
||||||
|
target_label: &str,
|
||||||
|
stats: &mut StatsCollector,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
while let Ok(Some(event)) = events.try_recv() {
|
||||||
|
print_events_with_stats(
|
||||||
|
stream_output,
|
||||||
|
std::slice::from_ref(&event),
|
||||||
|
Some(target_label),
|
||||||
|
stats,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "stats"))]
|
||||||
|
fn drain_single_client_events<W: Write>(
|
||||||
|
events: &irtt_client::EventSubscription,
|
||||||
|
stream_output: &mut StreamOutput<'_, W>,
|
||||||
|
target_label: &str,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
while let Ok(Some(event)) = events.try_recv() {
|
||||||
|
print_events_with_stats(
|
||||||
|
stream_output,
|
||||||
|
std::slice::from_ref(&event),
|
||||||
|
Some(target_label),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
struct StreamOutput<'a, W: Write> {
|
struct StreamOutput<'a, W: Write> {
|
||||||
config: OutputConfig,
|
config: OutputConfig,
|
||||||
header_printed: bool,
|
header_printed: bool,
|
||||||
@@ -300,7 +354,7 @@ fn run_group_stream(
|
|||||||
group_config,
|
group_config,
|
||||||
managed_targets,
|
managed_targets,
|
||||||
SubscriberConfig {
|
SubscriberConfig {
|
||||||
capacity: 16_384,
|
capacity: MANAGED_EVENT_CAPACITY,
|
||||||
// CLI output is best-effort under sustained backpressure. Dropping
|
// CLI output is best-effort under sustained backpressure. Dropping
|
||||||
// stale rows keeps continuous runs attached to the running group
|
// stale rows keeps continuous runs attached to the running group
|
||||||
// instead of turning a full output queue into a disconnected
|
// instead of turning a full output queue into a disconnected
|
||||||
@@ -427,7 +481,7 @@ fn run_group_stream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(warning) = dropped_event_warning(events.dropped_events()) {
|
if let Some(warning) = dropped_event_warning(events.dropped_events(), "managed group") {
|
||||||
eprintln!("{warning}");
|
eprintln!("{warning}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,10 +546,15 @@ fn report_target_failure(target: &ManagedTargetOutcome) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dropped_event_warning(dropped_events: u64) -> Option<String> {
|
fn dropped_event_warning(dropped_events: u64, source: &str) -> Option<String> {
|
||||||
(dropped_events > 0).then(|| {
|
(dropped_events > 0).then(|| {
|
||||||
|
let event_word = if dropped_events == 1 {
|
||||||
|
"event"
|
||||||
|
} else {
|
||||||
|
"events"
|
||||||
|
};
|
||||||
format!(
|
format!(
|
||||||
"irtt-rs: warning: dropped {dropped_events} managed group events; output and statistics may be incomplete"
|
"irtt-rs: warning: dropped {dropped_events} {source} {event_word}; output and statistics may be incomplete"
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -654,12 +713,57 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dropped_event_warning_marks_statistics_incomplete() {
|
fn dropped_event_warning_marks_statistics_incomplete() {
|
||||||
assert_eq!(dropped_event_warning(0), None);
|
assert_eq!(dropped_event_warning(0, "managed client"), None);
|
||||||
let warning = dropped_event_warning(7).unwrap();
|
let warning = dropped_event_warning(7, "managed group").unwrap();
|
||||||
assert!(warning.contains("dropped 7 managed group events"));
|
assert!(warning.contains("dropped 7 managed group events"));
|
||||||
assert!(warning.contains("statistics may be incomplete"));
|
assert!(warning.contains("statistics may be incomplete"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_client_drain_preserves_final_queue_and_surfaces_overflow() {
|
||||||
|
let hub = irtt_client::EventHub::new();
|
||||||
|
let events = hub
|
||||||
|
.subscribe(SubscriberConfig {
|
||||||
|
capacity: 1,
|
||||||
|
overflow: SubscriberOverflow::DropOldest,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
hub.publish(reply_event(1, 1_000));
|
||||||
|
hub.publish(reply_event(2, 2_000));
|
||||||
|
hub.disconnect_all();
|
||||||
|
|
||||||
|
let mut stats = StatsCollector::new(StatsConfig::finite());
|
||||||
|
let mut out = Vec::new();
|
||||||
|
{
|
||||||
|
let mut stream_output = StreamOutput {
|
||||||
|
config: output_config(
|
||||||
|
crate::cmd::client::OutputFormat::Csv,
|
||||||
|
Some("event,seq"),
|
||||||
|
crate::cmd::client::HeaderMode::Never,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
header_printed: false,
|
||||||
|
print_final_summary: false,
|
||||||
|
show_running_only_summary_note: false,
|
||||||
|
out: &mut out,
|
||||||
|
};
|
||||||
|
drain_single_client_events(&events, &mut stream_output, "target", &mut stats).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let rendered = String::from_utf8(out).unwrap();
|
||||||
|
assert_eq!(rendered.trim(), "echo_reply,2");
|
||||||
|
assert_eq!(events.dropped_events(), 1);
|
||||||
|
assert!(
|
||||||
|
dropped_event_warning(events.dropped_events(), "managed client")
|
||||||
|
.unwrap()
|
||||||
|
.contains("dropped 1 managed client event")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
events.try_recv().unwrap_err(),
|
||||||
|
EventSubscriptionError::Disconnected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn output_helper_streams_and_collects_events() {
|
fn output_helper_streams_and_collects_events() {
|
||||||
let sent_at = test_timestamp(Duration::from_secs(1));
|
let sent_at = test_timestamp(Duration::from_secs(1));
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ use std::{
|
|||||||
|
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||||
use irtt_client::{
|
use irtt_client::{
|
||||||
EventSubscriptionError, ManagedClientGroup, ManagedClientGroupConfig,
|
EventSubscription, EventSubscriptionError, ManagedClient, ManagedClientGroup,
|
||||||
ManagedGroupCompletionPolicy, ManagedGroupEndReason, ManagedGroupEvent, SubscriberConfig,
|
ManagedClientGroupConfig, ManagedGroupCompletionPolicy, ManagedGroupEndReason,
|
||||||
SubscriberOverflow,
|
ManagedGroupEvent, SessionEndReason, SubscriberConfig, SubscriberOverflow,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -17,7 +17,6 @@ use crate::{
|
|||||||
shared::client::{
|
shared::client::{
|
||||||
is_shutdown_requested,
|
is_shutdown_requested,
|
||||||
session::{peer_close_run_error, request_group_stop_once, should_stop_group_on_peer_close},
|
session::{peer_close_run_error, request_group_stop_once, should_stop_group_on_peer_close},
|
||||||
ClientSession,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,6 +26,7 @@ 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 IDLE_SLEEP: Duration = Duration::from_millis(5);
|
||||||
const GROUP_COMPLETION_GRACE: Duration = Duration::from_secs(1);
|
const GROUP_COMPLETION_GRACE: Duration = Duration::from_secs(1);
|
||||||
|
const MANAGED_EVENT_CAPACITY: usize = 16_384;
|
||||||
|
|
||||||
pub fn run_tui(
|
pub fn run_tui(
|
||||||
args: TuiArgs,
|
args: TuiArgs,
|
||||||
@@ -61,7 +61,10 @@ pub fn run_tui(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut session = match ClientSession::connect(args.to_client_config(), continuous) {
|
let (session, events) = match ManagedClient::start_with_subscription(
|
||||||
|
args.to_client_config(),
|
||||||
|
managed_event_subscriber_config(),
|
||||||
|
) {
|
||||||
Ok(session) => session,
|
Ok(session) => session,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
state.set_error(err.to_string());
|
state.set_error(err.to_string());
|
||||||
@@ -70,43 +73,33 @@ pub fn run_tui(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if is_shutdown_requested(shutdown_requested) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let events = session.open()?;
|
|
||||||
state.process_events(&events);
|
|
||||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
|
||||||
|
|
||||||
let mut interrupted = false;
|
let mut interrupted = false;
|
||||||
// Keep this in lockstep with run_stream: send due probes, drain available
|
loop {
|
||||||
// replies, poll timeouts, sleep toward the next absolute send deadline,
|
if is_shutdown_requested(shutdown_requested) {
|
||||||
// then perform the same final drain, timeout poll, and close sequence.
|
interrupted = true;
|
||||||
while session.should_continue(shutdown_requested) {
|
session.stop();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let events = session.step(shutdown_requested)?;
|
match events.recv_timeout(managed_tui_wait_duration(&next_render, state.paused)) {
|
||||||
state.process_events(&events);
|
Ok(Some(event)) => {
|
||||||
|
state.process_event(&event);
|
||||||
if is_shutdown_requested(shutdown_requested) {
|
render_if_due(&mut terminal, &state, &mut next_render, false)?;
|
||||||
interrupted = true;
|
}
|
||||||
break;
|
Ok(None) => {
|
||||||
|
render_if_due(&mut terminal, &state, &mut next_render, false)?;
|
||||||
|
}
|
||||||
|
Err(EventSubscriptionError::Disconnected) => break,
|
||||||
}
|
}
|
||||||
|
|
||||||
render_if_due(&mut terminal, &state, &mut next_render, false)?;
|
|
||||||
wait_for_tui_activity(
|
|
||||||
session.next_send_deadline(),
|
|
||||||
&mut next_render,
|
|
||||||
&mut state,
|
|
||||||
&mut terminal,
|
|
||||||
shutdown_requested,
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
interrupted |= is_shutdown_requested(shutdown_requested);
|
interrupted |= is_shutdown_requested(shutdown_requested);
|
||||||
|
|
||||||
@@ -115,29 +108,32 @@ pub fn run_tui(
|
|||||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if session.should_drain_final(interrupted) {
|
if interrupted {
|
||||||
state.set_status(TuiStatus::Draining);
|
state.set_status(TuiStatus::Draining);
|
||||||
let mut drain_render = Instant::now();
|
drain_single_tui_events(&events, &mut state);
|
||||||
session.drain_final(|events| {
|
|
||||||
state.process_events(events);
|
|
||||||
let _ = render_if_due(&mut terminal, &state, &mut drain_render, false);
|
|
||||||
})?;
|
|
||||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let events = session.poll_timeouts()?;
|
|
||||||
state.process_events(&events);
|
|
||||||
|
|
||||||
state.set_status(TuiStatus::Closing);
|
state.set_status(TuiStatus::Closing);
|
||||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
||||||
|
|
||||||
let events = session.close()?;
|
let outcome = session.join();
|
||||||
state.process_events(&events);
|
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);
|
interrupted |= is_shutdown_requested(shutdown_requested);
|
||||||
if let Some(error) = peer_close_run_error(
|
if let Some(error) = peer_close_run_error(
|
||||||
continuous,
|
continuous,
|
||||||
interrupted,
|
interrupted,
|
||||||
if session.is_peer_closed() { 1 } else { 0 },
|
u64::from(outcome.end_reason == SessionEndReason::PeerClosed),
|
||||||
) {
|
) {
|
||||||
state.set_run_error(error.clone());
|
state.set_run_error(error.clone());
|
||||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
||||||
@@ -175,7 +171,7 @@ fn run_group_tui(
|
|||||||
group_config,
|
group_config,
|
||||||
managed_targets,
|
managed_targets,
|
||||||
SubscriberConfig {
|
SubscriberConfig {
|
||||||
capacity: 16_384,
|
capacity: MANAGED_EVENT_CAPACITY,
|
||||||
overflow: SubscriberOverflow::DropOldest,
|
overflow: SubscriberOverflow::DropOldest,
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
@@ -272,7 +268,7 @@ fn run_group_tui(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state.mark_dropped_events(events.dropped_events());
|
state.mark_dropped_group_events(events.dropped_events());
|
||||||
for target in &outcome.targets {
|
for target in &outcome.targets {
|
||||||
if terminal_targets.insert(target.id.as_str().to_owned()) {
|
if terminal_targets.insert(target.id.as_str().to_owned()) {
|
||||||
state.process_target_outcome(target);
|
state.process_target_outcome(target);
|
||||||
@@ -451,6 +447,28 @@ fn render_if_due(
|
|||||||
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 {
|
||||||
|
let render_wait = if paused {
|
||||||
|
TUI_WAIT_SLICE
|
||||||
|
} else {
|
||||||
|
next_render.saturating_duration_since(Instant::now())
|
||||||
|
};
|
||||||
|
render_wait.min(TUI_WAIT_SLICE)
|
||||||
|
}
|
||||||
|
|
||||||
fn wait_for_tui_activity(
|
fn wait_for_tui_activity(
|
||||||
next_send_deadline: Option<Instant>,
|
next_send_deadline: Option<Instant>,
|
||||||
next_render: &mut Instant,
|
next_render: &mut Instant,
|
||||||
@@ -552,4 +570,30 @@ mod tests {
|
|||||||
&irtt_client::ManagedTargetEndReason::PeerClosed
|
&irtt_client::ManagedTargetEndReason::PeerClosed
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -140,12 +140,6 @@ impl TuiState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn process_events(&mut self, events: &[ClientEvent]) {
|
|
||||||
for event in events {
|
|
||||||
self.process_event(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn process_event(&mut self, event: &ClientEvent) {
|
pub(super) fn process_event(&mut self, event: &ClientEvent) {
|
||||||
self.process_event_for_target(0, event);
|
self.process_event_for_target(0, event);
|
||||||
}
|
}
|
||||||
@@ -388,13 +382,31 @@ impl TuiState {
|
|||||||
self.status = status;
|
self.status = status;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn mark_dropped_events(&mut self, dropped_events: u64) {
|
#[cfg(test)]
|
||||||
|
pub(super) fn status(&self) -> TuiStatus {
|
||||||
|
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) {
|
||||||
if dropped_events == 0 {
|
if dropped_events == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.dropped_events = dropped_events;
|
self.dropped_events = dropped_events;
|
||||||
|
let event_word = if dropped_events == 1 {
|
||||||
|
"event"
|
||||||
|
} else {
|
||||||
|
"events"
|
||||||
|
};
|
||||||
let warning =
|
let warning =
|
||||||
format!("dropped {dropped_events} managed group events; statistics may be incomplete");
|
format!("dropped {dropped_events} {source} {event_word}; statistics may be incomplete");
|
||||||
self.last_warning = Some(warning.clone());
|
self.last_warning = Some(warning.clone());
|
||||||
self.push_event(format!("warning {warning}"));
|
self.push_event(format!("warning {warning}"));
|
||||||
}
|
}
|
||||||
@@ -2497,7 +2509,7 @@ mod tests {
|
|||||||
fn dropped_events_mark_tui_statistics_incomplete() {
|
fn dropped_events_mark_tui_statistics_incomplete() {
|
||||||
let mut state = TuiState::default();
|
let mut state = TuiState::default();
|
||||||
|
|
||||||
state.mark_dropped_events(9);
|
state.mark_dropped_group_events(9);
|
||||||
|
|
||||||
assert_eq!(state.dropped_events, 9);
|
assert_eq!(state.dropped_events, 9);
|
||||||
assert!(state
|
assert!(state
|
||||||
@@ -2510,6 +2522,19 @@ mod tests {
|
|||||||
.is_some_and(|event| event.contains("dropped 9 managed group events")));
|
.is_some_and(|event| event.contains("dropped 9 managed group events")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dropped_single_client_events_are_visible() {
|
||||||
|
let mut state = TuiState::default();
|
||||||
|
|
||||||
|
state.mark_dropped_client_events(3);
|
||||||
|
|
||||||
|
assert_eq!(state.dropped_events, 3);
|
||||||
|
assert!(state
|
||||||
|
.recent_events
|
||||||
|
.back()
|
||||||
|
.is_some_and(|event| event.contains("dropped 3 managed client events")));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recent_event_buffer_stays_bounded() {
|
fn recent_event_buffer_stays_bounded() {
|
||||||
let mut state = TuiState::default();
|
let mut state = TuiState::default();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ pub use args::{
|
|||||||
ClockArg, CommonClientArgs, ReceivedStatsArg, TimestampArg,
|
ClockArg, CommonClientArgs, ReceivedStatsArg, TimestampArg,
|
||||||
};
|
};
|
||||||
pub use config::{expected_probe_count, DEFAULT_RECV_TIMEOUT};
|
pub use config::{expected_probe_count, DEFAULT_RECV_TIMEOUT};
|
||||||
pub use session::{final_drain_duration, is_shutdown_requested, ClientSession, RECV_BUDGET};
|
pub use session::is_shutdown_requested;
|
||||||
pub use targets::{
|
pub use targets::{
|
||||||
parse_labelled_target, resolved_managed_targets, target_specs, GroupPacingArg,
|
parse_labelled_target, resolved_managed_targets, target_specs, GroupPacingArg,
|
||||||
LabelledTargetArg, ResolvedTarget, TargetSpec,
|
LabelledTargetArg, ResolvedTarget, TargetSpec,
|
||||||
|
|||||||
@@ -1,146 +1,11 @@
|
|||||||
use std::{
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
sync::atomic::{AtomicBool, Ordering},
|
|
||||||
thread,
|
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
|
||||||
|
|
||||||
use irtt_client::{
|
use irtt_client::ManagedTargetEndReason;
|
||||||
Client, ClientConfig, ClientEvent, ManagedTargetEndReason, OpenOutcome, RecvBudget,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub const RECV_BUDGET: RecvBudget = RecvBudget { max_packets: 16 };
|
|
||||||
const MAX_FINAL_DRAIN: Duration = Duration::from_secs(30);
|
|
||||||
const IDLE_SLEEP: Duration = Duration::from_millis(5);
|
|
||||||
const MAX_SLEEP: Duration = Duration::from_millis(20);
|
|
||||||
|
|
||||||
pub struct ClientSession {
|
|
||||||
client: Client,
|
|
||||||
continuous: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ClientSession {
|
|
||||||
pub fn connect(
|
|
||||||
config: ClientConfig,
|
|
||||||
continuous: bool,
|
|
||||||
) -> Result<Self, irtt_client::ClientError> {
|
|
||||||
Ok(Self {
|
|
||||||
client: Client::connect(config)?,
|
|
||||||
continuous,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn open(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
|
|
||||||
Ok(vec![open_event(&self.client.open()?).clone()])
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn step(
|
|
||||||
&mut self,
|
|
||||||
shutdown_requested: &AtomicBool,
|
|
||||||
) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
|
|
||||||
let mut events = Vec::new();
|
|
||||||
if should_send_probe(self.client.next_send_deadline(), shutdown_requested) {
|
|
||||||
events.extend(self.client.send_probe()?);
|
|
||||||
}
|
|
||||||
events.extend(self.client.recv_available(RECV_BUDGET)?);
|
|
||||||
if self.client.is_peer_closed() {
|
|
||||||
return Ok(events);
|
|
||||||
}
|
|
||||||
events.extend(self.client.poll_timeouts()?);
|
|
||||||
Ok(events)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn should_continue(&self, shutdown_requested: &AtomicBool) -> bool {
|
|
||||||
!is_shutdown_requested(shutdown_requested)
|
|
||||||
&& !self.client.is_peer_closed()
|
|
||||||
&& (self.continuous || self.client.next_send_deadline().is_some())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn should_drain_final(&self, interrupted: bool) -> bool {
|
|
||||||
should_drain_final(self.continuous, interrupted)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn drain_final<F>(&mut self, mut on_events: F) -> Result<(), irtt_client::ClientError>
|
|
||||||
where
|
|
||||||
F: FnMut(&[ClientEvent]),
|
|
||||||
{
|
|
||||||
let deadline = Instant::now() + final_drain_duration(self.client.probe_timeout());
|
|
||||||
loop {
|
|
||||||
if self.client.is_peer_closed() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut received = false;
|
|
||||||
|
|
||||||
let events = self.client.recv_available(RECV_BUDGET)?;
|
|
||||||
received |= !events.is_empty();
|
|
||||||
on_events(&events);
|
|
||||||
|
|
||||||
if self.client.is_peer_closed() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let events = self.client.poll_timeouts()?;
|
|
||||||
received |= !events.is_empty();
|
|
||||||
on_events(&events);
|
|
||||||
|
|
||||||
if self.client.is_run_complete() || Instant::now() >= deadline {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !received {
|
|
||||||
thread::sleep(IDLE_SLEEP);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn poll_timeouts(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
|
|
||||||
if self.client.is_peer_closed() {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
self.client.poll_timeouts()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn close(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
|
|
||||||
if self.client.is_peer_closed() {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
self.client.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_peer_closed(&self) -> bool {
|
|
||||||
self.client.is_peer_closed()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn next_send_deadline(&self) -> Option<Instant> {
|
|
||||||
self.client.next_send_deadline()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn probe_timeout(&self) -> Duration {
|
|
||||||
self.client.probe_timeout()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sleep_until_next_send(&self) {
|
|
||||||
let sleep_for = match self.next_send_deadline() {
|
|
||||||
Some(deadline) => deadline
|
|
||||||
.saturating_duration_since(Instant::now())
|
|
||||||
.min(MAX_SLEEP),
|
|
||||||
None => Duration::from_millis(1),
|
|
||||||
};
|
|
||||||
if !sleep_for.is_zero() {
|
|
||||||
thread::sleep(sleep_for);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_shutdown_requested(shutdown_requested: &AtomicBool) -> bool {
|
pub fn is_shutdown_requested(shutdown_requested: &AtomicBool) -> bool {
|
||||||
shutdown_requested.load(Ordering::Relaxed)
|
shutdown_requested.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn should_drain_final(continuous: bool, interrupted: bool) -> bool {
|
|
||||||
!continuous || interrupted
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn should_print_final_summary(continuous: bool, interrupted: bool) -> bool {
|
pub fn should_print_final_summary(continuous: bool, interrupted: bool) -> bool {
|
||||||
!continuous || interrupted
|
!continuous || interrupted
|
||||||
}
|
}
|
||||||
@@ -180,67 +45,9 @@ pub fn request_group_stop_once(stop_requested: &mut bool) -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn final_drain_duration(probe_timeout: Duration) -> Duration {
|
|
||||||
probe_timeout.min(MAX_FINAL_DRAIN)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn should_send_probe(next_send_deadline: Option<Instant>, shutdown_requested: &AtomicBool) -> bool {
|
|
||||||
!is_shutdown_requested(shutdown_requested)
|
|
||||||
&& next_send_deadline.is_some_and(|deadline| deadline <= Instant::now())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open_event(outcome: &OpenOutcome) -> &ClientEvent {
|
|
||||||
match outcome {
|
|
||||||
OpenOutcome::Started { event, .. } | OpenOutcome::NoTestCompleted { event, .. } => event,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use irtt_proto::{
|
|
||||||
echo_packet_len,
|
|
||||||
flags::{self, FLAG_OPEN, FLAG_REPLY},
|
|
||||||
layout::PacketLayout,
|
|
||||||
Clock, Params, ReceivedStats, StampAt, TimestampFields, MAGIC, PROTOCOL_VERSION,
|
|
||||||
};
|
|
||||||
use std::{
|
|
||||||
net::{SocketAddr, UdpSocket},
|
|
||||||
thread::JoinHandle,
|
|
||||||
};
|
|
||||||
|
|
||||||
const TOKEN: u64 = 0x1234_5678_90ab_cdef;
|
|
||||||
|
|
||||||
struct FakeServer {
|
|
||||||
addr: SocketAddr,
|
|
||||||
done: JoinHandle<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FakeServer {
|
|
||||||
fn join(self) {
|
|
||||||
self.done.join().unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn final_drain_uses_capped_probe_timeout() {
|
|
||||||
assert_eq!(
|
|
||||||
final_drain_duration(Duration::from_secs(4)),
|
|
||||||
Duration::from_secs(4)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
final_drain_duration(Duration::from_secs(60)),
|
|
||||||
Duration::from_secs(30)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn should_drain_final_for_finite_or_interrupted_runs() {
|
|
||||||
assert!(should_drain_final(false, false));
|
|
||||||
assert!(should_drain_final(false, true));
|
|
||||||
assert!(should_drain_final(true, true));
|
|
||||||
assert!(!should_drain_final(true, false));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn peer_close_run_error_is_run_mode_and_interruption_aware() {
|
fn peer_close_run_error_is_run_mode_and_interruption_aware() {
|
||||||
@@ -284,172 +91,4 @@ mod tests {
|
|||||||
assert!(request_group_stop_once(&mut stop_requested));
|
assert!(request_group_stop_once(&mut stop_requested));
|
||||||
assert!(!request_group_stop_once(&mut stop_requested));
|
assert!(!request_group_stop_once(&mut stop_requested));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn peer_close_followed_by_session_cleanup_is_successful() {
|
|
||||||
let params = test_params(None, Duration::from_millis(10));
|
|
||||||
let server = start_peer_close_server(params);
|
|
||||||
let mut session = ClientSession::connect(config(server.addr, None), true).unwrap();
|
|
||||||
let shutdown_requested = AtomicBool::new(false);
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
session.open().unwrap().as_slice(),
|
|
||||||
[ClientEvent::SessionStarted { .. }]
|
|
||||||
));
|
|
||||||
|
|
||||||
let events = session.step(&shutdown_requested).unwrap();
|
|
||||||
assert!(events
|
|
||||||
.iter()
|
|
||||||
.any(|event| matches!(event, ClientEvent::EchoReply { .. })));
|
|
||||||
assert!(events
|
|
||||||
.iter()
|
|
||||||
.any(|event| matches!(event, ClientEvent::SessionClosed { .. })));
|
|
||||||
assert!(!session.should_continue(&shutdown_requested));
|
|
||||||
assert!(session.is_peer_closed());
|
|
||||||
|
|
||||||
session.drain_final(|_| {}).unwrap();
|
|
||||||
assert!(session.poll_timeouts().unwrap().is_empty());
|
|
||||||
assert!(session.close().unwrap().is_empty());
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn config(addr: SocketAddr, duration: Option<Duration>) -> ClientConfig {
|
|
||||||
ClientConfig {
|
|
||||||
server_addr: addr.to_string(),
|
|
||||||
duration,
|
|
||||||
interval: Duration::from_millis(10),
|
|
||||||
open_timeouts: vec![Duration::from_millis(200)],
|
|
||||||
probe_timeout: Duration::from_millis(20),
|
|
||||||
socket_config: irtt_client::SocketConfig {
|
|
||||||
recv_timeout: Some(Duration::from_millis(200)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..ClientConfig::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn test_params(duration: Option<Duration>, interval: Duration) -> Params {
|
|
||||||
Params {
|
|
||||||
protocol_version: PROTOCOL_VERSION,
|
|
||||||
duration_ns: duration.map_or(0, test_duration_ns_i64),
|
|
||||||
interval_ns: test_duration_ns_i64(interval),
|
|
||||||
length: 0,
|
|
||||||
received_stats: ReceivedStats::Both,
|
|
||||||
stamp_at: StampAt::Both,
|
|
||||||
clock: Clock::Both,
|
|
||||||
dscp: 0,
|
|
||||||
server_fill: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn test_duration_ns_i64(duration: Duration) -> i64 {
|
|
||||||
i64::try_from(duration.as_nanos()).expect("test duration fits i64 nanoseconds")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn start_peer_close_server(params: Params) -> FakeServer {
|
|
||||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
|
||||||
let addr = socket.local_addr().unwrap();
|
|
||||||
let done = thread::spawn(move || {
|
|
||||||
let (_, peer) = recv_request(&socket);
|
|
||||||
socket
|
|
||||||
.send_to(&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms), peer)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let (packet, peer) = recv_request(&socket);
|
|
||||||
let seq = u32::from_le_bytes(packet[12..16].try_into().unwrap());
|
|
||||||
socket
|
|
||||||
.send_to(
|
|
||||||
&echo_reply_packet(
|
|
||||||
TOKEN,
|
|
||||||
seq,
|
|
||||||
¶ms,
|
|
||||||
&TimestampFields::default(),
|
|
||||||
FLAG_REPLY | flags::FLAG_CLOSE,
|
|
||||||
),
|
|
||||||
peer,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
socket
|
|
||||||
.set_read_timeout(Some(Duration::from_millis(250)))
|
|
||||||
.unwrap();
|
|
||||||
if let Some((packet, _)) = recv_request_timeout(&socket) {
|
|
||||||
panic!(
|
|
||||||
"session cleanup must not send any packet after peer close; flags={:?} len={}",
|
|
||||||
packet.get(3).copied(),
|
|
||||||
packet.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
FakeServer { addr, done }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn recv_request(socket: &UdpSocket) -> (Vec<u8>, SocketAddr) {
|
|
||||||
let mut buf = [0_u8; 2048];
|
|
||||||
let (size, peer) = socket.recv_from(&mut buf).unwrap();
|
|
||||||
(buf[..size].to_vec(), peer)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn recv_request_timeout(socket: &UdpSocket) -> Option<(Vec<u8>, SocketAddr)> {
|
|
||||||
let mut buf = [0_u8; 2048];
|
|
||||||
socket
|
|
||||||
.recv_from(&mut buf)
|
|
||||||
.ok()
|
|
||||||
.map(|(size, peer)| (buf[..size].to_vec(), peer))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open_reply(flags: u8, token: u64, params: &Params) -> Vec<u8> {
|
|
||||||
let mut packet = Vec::new();
|
|
||||||
packet.extend_from_slice(&MAGIC);
|
|
||||||
packet.push(flags);
|
|
||||||
packet.extend_from_slice(&token.to_le_bytes());
|
|
||||||
packet.extend_from_slice(¶ms.encode());
|
|
||||||
packet
|
|
||||||
}
|
|
||||||
|
|
||||||
fn echo_reply_packet(
|
|
||||||
token: u64,
|
|
||||||
seq: u32,
|
|
||||||
params: &Params,
|
|
||||||
timestamps: &TimestampFields,
|
|
||||||
flags: u8,
|
|
||||||
) -> Vec<u8> {
|
|
||||||
let layout = PacketLayout::echo(false, params);
|
|
||||||
let packet_len = echo_packet_len(false, params)
|
|
||||||
.expect("session test params must have a non-negative packet length");
|
|
||||||
let mut packet = Vec::with_capacity(packet_len);
|
|
||||||
|
|
||||||
packet.extend_from_slice(&MAGIC);
|
|
||||||
packet.push(flags);
|
|
||||||
packet.extend_from_slice(&token.to_le_bytes());
|
|
||||||
packet.extend_from_slice(&seq.to_le_bytes());
|
|
||||||
|
|
||||||
if layout.recv_count {
|
|
||||||
packet.extend_from_slice(&42_u32.to_le_bytes());
|
|
||||||
}
|
|
||||||
if layout.recv_window {
|
|
||||||
packet.extend_from_slice(&0_u64.to_le_bytes());
|
|
||||||
}
|
|
||||||
if layout.recv_wall {
|
|
||||||
packet.extend_from_slice(×tamps.recv_wall.unwrap_or(0).to_le_bytes());
|
|
||||||
}
|
|
||||||
if layout.recv_mono {
|
|
||||||
packet.extend_from_slice(×tamps.recv_mono.unwrap_or(0).to_le_bytes());
|
|
||||||
}
|
|
||||||
if layout.midpoint_wall {
|
|
||||||
packet.extend_from_slice(×tamps.midpoint_wall.unwrap_or(0).to_le_bytes());
|
|
||||||
}
|
|
||||||
if layout.midpoint_mono {
|
|
||||||
packet.extend_from_slice(×tamps.midpoint_mono.unwrap_or(0).to_le_bytes());
|
|
||||||
}
|
|
||||||
if layout.send_wall {
|
|
||||||
packet.extend_from_slice(×tamps.send_wall.unwrap_or(0).to_le_bytes());
|
|
||||||
}
|
|
||||||
if layout.send_mono {
|
|
||||||
packet.extend_from_slice(×tamps.send_mono.unwrap_or(0).to_le_bytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
packet.resize(packet_len, 0);
|
|
||||||
packet
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -48,6 +48,19 @@ impl InterruptibleFakeServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ObservedFakeServer {
|
||||||
|
addr: SocketAddr,
|
||||||
|
probes: mpsc::Receiver<Instant>,
|
||||||
|
done: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObservedFakeServer {
|
||||||
|
fn join(self) -> Vec<Instant> {
|
||||||
|
self.done.join().unwrap();
|
||||||
|
self.probes.try_iter().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn list_columns_succeeds_without_target() {
|
fn list_columns_succeeds_without_target() {
|
||||||
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
|
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
|
||||||
@@ -143,6 +156,56 @@ fn single_target_custom_target_column_renders_positional_label() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_target_ten_millisecond_cadence_uses_managed_deadlines() {
|
||||||
|
let duration = Duration::from_millis(90);
|
||||||
|
let interval = Duration::from_millis(10);
|
||||||
|
let server = start_observed_echo_server(test_params(Some(duration), interval));
|
||||||
|
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-cli"));
|
||||||
|
command.args([
|
||||||
|
"--duration",
|
||||||
|
"90ms",
|
||||||
|
"--interval",
|
||||||
|
"10ms",
|
||||||
|
"--format",
|
||||||
|
"csv",
|
||||||
|
"--columns",
|
||||||
|
"event,seq",
|
||||||
|
"--header",
|
||||||
|
"never",
|
||||||
|
&server.addr.to_string(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let output = run_with_timeout(command, Duration::from_secs(3));
|
||||||
|
let probe_times = server.join();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"status={:?}\nstderr={}",
|
||||||
|
output.status.code(),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||||
|
let sent_rows = stdout
|
||||||
|
.lines()
|
||||||
|
.filter(|line| line.starts_with("echo_sent,"))
|
||||||
|
.count();
|
||||||
|
assert!(
|
||||||
|
(7..=11).contains(&sent_rows),
|
||||||
|
"expected about 9 probes at 10 ms cadence, got {sent_rows}\n{stdout}"
|
||||||
|
);
|
||||||
|
assert_eq!(sent_rows, probe_times.len(), "{stdout}");
|
||||||
|
assert!(
|
||||||
|
!probe_times
|
||||||
|
.windows(3)
|
||||||
|
.any(|window| window[2].duration_since(window[0]) < Duration::from_millis(5)),
|
||||||
|
"managed pacing emitted a catch-up burst: {probe_times:?}"
|
||||||
|
);
|
||||||
|
assert!(stdout
|
||||||
|
.lines()
|
||||||
|
.any(|line| line.starts_with("session_closed,")));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn finite_single_target_peer_close_exits_successfully() {
|
fn finite_single_target_peer_close_exits_successfully() {
|
||||||
let server = start_peer_close_server(test_params(
|
let server = start_peer_close_server(test_params(
|
||||||
@@ -213,6 +276,57 @@ fn continuous_single_target_peer_close_exits_nonzero() {
|
|||||||
assert!(stderr.contains("peer closure"), "{stderr}");
|
assert!(stderr.contains("peer closure"), "{stderr}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn continuous_single_target_interruption_drains_final_events_and_succeeds() {
|
||||||
|
let params = test_params(None, Duration::from_millis(10));
|
||||||
|
let server = start_interruptible_echo_server(params);
|
||||||
|
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-cli"));
|
||||||
|
command.args([
|
||||||
|
"--duration",
|
||||||
|
"0s",
|
||||||
|
"--interval",
|
||||||
|
"10ms",
|
||||||
|
"--format",
|
||||||
|
"csv",
|
||||||
|
"--columns",
|
||||||
|
"event,seq",
|
||||||
|
"--header",
|
||||||
|
"never",
|
||||||
|
&server.addr.to_string(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let child = command
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
server.wait_until_opened();
|
||||||
|
thread::sleep(Duration::from_millis(50));
|
||||||
|
let signal_status = Command::new("kill")
|
||||||
|
.args(["-INT", &child.id().to_string()])
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
assert!(signal_status.success());
|
||||||
|
let output = wait_with_timeout(child, Duration::from_secs(2));
|
||||||
|
server.join();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"status={:?}\nstderr={}",
|
||||||
|
output.status.code(),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||||
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||||
|
assert!(stdout.lines().any(|line| line.starts_with("echo_reply,")));
|
||||||
|
assert!(stdout
|
||||||
|
.lines()
|
||||||
|
.any(|line| line.starts_with("session_closed,")));
|
||||||
|
assert!(stderr.contains("interrupted, closing session"), "{stderr}");
|
||||||
|
assert!(!stderr.contains("peer closure"), "{stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multi_target_csv_emits_rows_for_both_labels() {
|
fn multi_target_csv_emits_rows_for_both_labels() {
|
||||||
let params = test_params(Some(Duration::from_millis(40)), Duration::from_millis(10));
|
let params = test_params(Some(Duration::from_millis(40)), Duration::from_millis(10));
|
||||||
@@ -658,6 +772,44 @@ fn start_echo_server_inner(
|
|||||||
FakeServer { addr, done }
|
FakeServer { addr, done }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn start_observed_echo_server(params: Params) -> ObservedFakeServer {
|
||||||
|
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||||
|
socket
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||||
|
.unwrap();
|
||||||
|
let addr = socket.local_addr().unwrap();
|
||||||
|
let (probe_tx, probes) = mpsc::channel();
|
||||||
|
let done = thread::spawn(move || {
|
||||||
|
let (_, peer) = recv_request(&socket);
|
||||||
|
socket
|
||||||
|
.send_to(&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms), peer)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (packet, peer) = recv_request(&socket);
|
||||||
|
let flags = packet[3];
|
||||||
|
if flags & FLAG_CLOSE != 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
probe_tx.send(Instant::now()).unwrap();
|
||||||
|
let seq = u32::from_le_bytes(packet[12..16].try_into().unwrap());
|
||||||
|
socket
|
||||||
|
.send_to(
|
||||||
|
&echo_reply_packet(
|
||||||
|
TOKEN,
|
||||||
|
seq,
|
||||||
|
¶ms,
|
||||||
|
&TimestampFields::default(),
|
||||||
|
FLAG_REPLY,
|
||||||
|
),
|
||||||
|
peer,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ObservedFakeServer { addr, probes, done }
|
||||||
|
}
|
||||||
|
|
||||||
fn start_interruptible_echo_server(params: Params) -> InterruptibleFakeServer {
|
fn start_interruptible_echo_server(params: Params) -> InterruptibleFakeServer {
|
||||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||||
socket
|
socket
|
||||||
|
|||||||
@@ -169,9 +169,9 @@ pub enum EventSubscriptionError {
|
|||||||
/// The subscription can no longer receive new events.
|
/// The subscription can no longer receive new events.
|
||||||
///
|
///
|
||||||
/// Already queued events are delivered first. After the queue is drained,
|
/// Already queued events are delivered first. After the queue is drained,
|
||||||
/// `recv` and `try_recv` return this error when the hub disconnects the
|
/// `recv`, `recv_timeout`, and `try_recv` return this error when the hub
|
||||||
/// subscriber, the hub is shut down, or the subscription is disconnected by
|
/// disconnects the subscriber, the hub is shut down, or the subscription is
|
||||||
/// its overflow policy.
|
/// disconnected by its overflow policy.
|
||||||
#[error("event subscription is disconnected")]
|
#[error("event subscription is disconnected")]
|
||||||
Disconnected,
|
Disconnected,
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ use std::{
|
|||||||
atomic::{AtomicU64, Ordering},
|
atomic::{AtomicU64, Ordering},
|
||||||
Arc, Condvar, Mutex, Weak,
|
Arc, Condvar, Mutex, Weak,
|
||||||
},
|
},
|
||||||
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{error::EventSubscriptionError, event::ClientEvent};
|
use crate::{error::EventSubscriptionError, event::ClientEvent};
|
||||||
@@ -254,6 +255,39 @@ impl<T> EventSubscription<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wait up to `timeout` for the next queued event.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(None)` when the timeout elapses while the subscription
|
||||||
|
/// remains connected. If the subscription is disconnected, this returns
|
||||||
|
/// [`EventSubscriptionError::Disconnected`] after any already queued events
|
||||||
|
/// have been drained.
|
||||||
|
pub fn recv_timeout(&self, timeout: Duration) -> Result<Option<T>, EventSubscriptionError> {
|
||||||
|
let started_at = Instant::now();
|
||||||
|
let mut state = self.inner.state.lock().expect("subscriber mutex poisoned");
|
||||||
|
loop {
|
||||||
|
if let Some(event) = state.queue.pop_front() {
|
||||||
|
return Ok(Some(event));
|
||||||
|
}
|
||||||
|
if !state.connected {
|
||||||
|
return Err(EventSubscriptionError::Disconnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(wait_for) = timeout.checked_sub(started_at.elapsed()) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if wait_for.is_zero() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (next_state, _) = self
|
||||||
|
.inner
|
||||||
|
.available
|
||||||
|
.wait_timeout(state, wait_for)
|
||||||
|
.expect("subscriber mutex poisoned");
|
||||||
|
state = next_state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Try to receive one queued event without blocking.
|
/// Try to receive one queued event without blocking.
|
||||||
///
|
///
|
||||||
/// Returns `Ok(None)` when the subscription is still connected but no event
|
/// Returns `Ok(None)` when the subscription is still connected but no event
|
||||||
@@ -507,6 +541,25 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timed_receive_drains_queued_events_and_observes_disconnect() {
|
||||||
|
let hub = EventHub::new();
|
||||||
|
let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(sub.recv_timeout(Duration::ZERO).unwrap(), None);
|
||||||
|
hub.publish(event(1));
|
||||||
|
hub.disconnect_all();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
event_message(sub.recv_timeout(Duration::from_secs(1)).unwrap().unwrap()),
|
||||||
|
"event-1"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sub.recv_timeout(Duration::from_secs(1)).unwrap_err(),
|
||||||
|
EventSubscriptionError::Disconnected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sealed_hub_drains_queued_events_then_disconnects() {
|
fn sealed_hub_drains_queued_events_then_disconnects() {
|
||||||
let hub = EventHub::new();
|
let hub = EventHub::new();
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ impl OutcomeCounters {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{config::NegotiationPolicy, SubscriberOverflow};
|
use crate::{config::NegotiationPolicy, EventSubscriptionError, SubscriberOverflow};
|
||||||
use irtt_proto::{
|
use irtt_proto::{
|
||||||
echo_packet_len,
|
echo_packet_len,
|
||||||
flags::{self, FLAG_OPEN, FLAG_REPLY},
|
flags::{self, FLAG_OPEN, FLAG_REPLY},
|
||||||
@@ -687,6 +687,42 @@ mod tests {
|
|||||||
.any(|event| matches!(event, ClientEvent::SessionClosed { .. })));
|
.any(|event| matches!(event, ClientEvent::SessionClosed { .. })));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn joining_before_consuming_preserves_queued_final_events() {
|
||||||
|
let duration = Duration::from_millis(25);
|
||||||
|
let server = start_echo_server(test_params(Some(duration), Duration::from_millis(10)));
|
||||||
|
let (session, sub) = ManagedClient::start_with_subscription(
|
||||||
|
config(server.addr, Some(duration)),
|
||||||
|
SubscriberConfig {
|
||||||
|
capacity: 32,
|
||||||
|
overflow: SubscriberOverflow::DropOldest,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let outcome = session.join().unwrap();
|
||||||
|
let mut events = Vec::new();
|
||||||
|
loop {
|
||||||
|
match sub.try_recv() {
|
||||||
|
Ok(Some(event)) => events.push(event),
|
||||||
|
Ok(None) => panic!("joined managed subscription remained connected"),
|
||||||
|
Err(EventSubscriptionError::Disconnected) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.join();
|
||||||
|
|
||||||
|
assert_eq!(outcome.end_reason, SessionEndReason::TestComplete);
|
||||||
|
assert_eq!(sub.dropped_events(), 0);
|
||||||
|
assert!(matches!(
|
||||||
|
events.first(),
|
||||||
|
Some(ClientEvent::SessionStarted { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
events.last(),
|
||||||
|
Some(ClientEvent::SessionClosed { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn finite_managed_run_drains_late_reply_after_timeout_before_close() {
|
fn finite_managed_run_drains_late_reply_after_timeout_before_close() {
|
||||||
let duration = Duration::from_millis(1);
|
let duration = Duration::from_millis(1);
|
||||||
|
|||||||
Reference in new issue
Block a user