fix(managed): publish terminal outcomes for failed targets

Extend the managed group stream with authoritative per-target terminal outcomes and structured failure classifications. Final client events are published first, followed by exactly one TargetFinished outcome for success, peer close, no-test, opening/runtime failure, removal, or cancellation.

Scheduler-owned cleanup now stops the receiver and seals subscriptions on natural completion, eliminating the event-consumer/join circular wait while preserving queued drain. TargetEventSubscription now yields ManagedGroupEvent, so consumers must handle Client and TargetFinished variants.

Strict-timeout regressions cover mixed success/failure, all-open failure, runtime failure, no-test ordering, removal, cancellation, and disconnection before join.
This commit is contained in:
Nils Andreas Svee committed 2026-07-28 00:07:18 +07:00
1 parent 3e0047e505
commit 97aac829ec
5 files changed
+548 -132

No files matched your search

+14 -15
View File
@@ -7,8 +7,8 @@ use std::{
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use irtt_client::{
ClientEvent, EventSubscriptionError, ManagedClientGroup, ManagedClientGroupConfig,
ManagedGroupEndReason, SubscriberConfig, SubscriberOverflow,
EventSubscriptionError, ManagedClientGroup, ManagedClientGroupConfig, ManagedGroupEndReason,
ManagedGroupEvent, SubscriberConfig, SubscriberOverflow,
};
use crate::{
@@ -184,13 +184,17 @@ fn run_group_tui(
}
match events.try_recv() {
Ok(Some(target_event)) => {
Ok(Some(group_event)) => {
saw_target_event = true;
last_event_at = Instant::now();
if is_terminal_target_event(&target_event.event) {
terminal_targets.insert(target_event.target.as_str().to_owned());
match group_event {
ManagedGroupEvent::Client(target_event) => {
state.process_target_event(&target_event);
}
ManagedGroupEvent::TargetFinished(target) => {
terminal_targets.insert(target.id.as_str().to_owned());
}
}
state.process_target_event(&target_event);
render_if_due(terminal, state, next_render, false)?;
}
Ok(None) => {
@@ -225,8 +229,10 @@ fn run_group_tui(
render_if_due(terminal, state, next_render, true)?;
let outcome = session.join()?;
while let Ok(Some(target_event)) = events.try_recv() {
state.process_target_event(&target_event);
while let Ok(Some(group_event)) = events.try_recv() {
if let ManagedGroupEvent::Client(target_event) = group_event {
state.process_target_event(&target_event);
}
}
if outcome.end_reason == ManagedGroupEndReason::Cancelled && !interrupted {
@@ -265,13 +271,6 @@ impl GroupLoopExit {
}
}
fn is_terminal_target_event(event: &ClientEvent) -> bool {
matches!(
event,
ClientEvent::SessionClosed { .. } | ClientEvent::NoTestCompleted { .. }
)
}
fn estimated_group_completion_grace(args: &TuiArgs) -> Duration {
let open_timeout: Duration = args.to_client_config().open_timeouts.iter().sum();
open_timeout