Skip to content

Commit bbee944

Browse files
fix(itmux): address PR #252 review (timeout validation + validator soundness)
Fix 1 (codex must-fix): --timeout took an unvalidated f64, so --timeout=-1 / =0 / =NaN / =inf reached Duration::from_secs_f64 and panicked before docker. Add a clap value_parser (parse_positive_timeout) rejecting non-finite and non-positive values with a clean CLI error (exit 2, no panic). Unit tests cover -1/0/NaN/inf/garbage -> error and 2.5 -> Some(2.5). Fix 2 (codex must-fix): the event-contract validator dropped blank stdout lines at collection, so a blank line in --json mode slipped past the purity check. parse_lines no longer filters blank lines; a blank/whitespace-only line surfaces as NonJson and the purity rule rejects it. Negative test added. Fix 3 (codex must-fix): the validator counted exactly one session_end but did not enforce ordering, so session_end -> tool_start -> result (OnStream) and lifecycle events after session_end (ResultFile) wrongly passed. Enforce session_end as the terminal lifecycle event: only the OnStream result line may follow it, nothing else, in both modes. Negative tests added for both modes. Nits: removed the dead StreamedRun.exit_ok field (+ its allow(dead_code)); documented the E3 lexicographic-ts assumption (emitter always uses fixed-width second-precision UTC Z timestamps) rather than adding a datetime dependency. Claude-Session: https://claude.ai/code/session_019TtekhJft4s2qBGRGgxToG
1 parent 51df883 commit bbee944

2 files changed

Lines changed: 123 additions & 16 deletions

File tree

providers/workspaces/interactive-tmux/driver-rs/src/main.rs

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,12 +127,32 @@ enum Cmd {
127127
/// Wall-clock timeout, in seconds, for the whole run. Maps to
128128
/// `AgentRunSpec.limits.timeout_s`. When omitted, the orchestrator's
129129
/// default await bound applies (behaviour unchanged from before this
130-
/// flag existed).
131-
#[arg(long)]
130+
/// flag existed). Must be a finite, strictly-positive number - a
131+
/// non-finite or non-positive value is rejected with a clean CLI error
132+
/// (never a downstream `Duration::from_secs_f64` panic).
133+
#[arg(long, value_parser = parse_positive_timeout)]
132134
timeout: Option<f64>,
133135
},
134136
}
135137

138+
/// Clap value parser for `--timeout`: accept only a finite, strictly-positive
139+
/// number of seconds. Rejects `<= 0.0` (an instant/zero timeout is meaningless)
140+
/// and non-finite values (`NaN`, `inf`) with a message clap renders as a clean
141+
/// CLI error - this is what keeps `Duration::from_secs_f64` from ever seeing a
142+
/// value that would panic.
143+
fn parse_positive_timeout(raw: &str) -> Result<f64, String> {
144+
let value: f64 = raw
145+
.parse()
146+
.map_err(|_| format!("'{raw}' is not a number"))?;
147+
if !value.is_finite() {
148+
return Err(format!("timeout must be a finite number, got '{raw}'"));
149+
}
150+
if value <= 0.0 {
151+
return Err(format!("timeout must be greater than 0, got '{raw}'"));
152+
}
153+
Ok(value)
154+
}
155+
136156
/// Build the `AgentRunSpec` for `itmux run` from the CLI inputs. Pure so the
137157
/// `--timeout -> limits.timeout_s` mapping is unit-testable without spawning a
138158
/// run. When `timeout` is `None`, `limits` stays `None` so the default await
@@ -647,6 +667,23 @@ mod tests {
647667
assert_eq!(limits.token_budget, None);
648668
}
649669

670+
#[test]
671+
fn parse_positive_timeout_accepts_a_finite_positive_value() {
672+
assert_eq!(parse_positive_timeout("2.5"), Ok(2.5));
673+
}
674+
675+
#[test]
676+
fn parse_positive_timeout_rejects_non_positive_and_non_finite() {
677+
// Each of these would reach Duration::from_secs_f64 and panic if it
678+
// slipped through - the parser must reject them cleanly instead.
679+
for bad in ["-1", "0", "NaN", "inf", "-inf", "not-a-number"] {
680+
assert!(
681+
parse_positive_timeout(bad).is_err(),
682+
"expected {bad:?} to be rejected"
683+
);
684+
}
685+
}
686+
650687
#[test]
651688
fn build_run_spec_without_timeout_leaves_limits_none() {
652689
// Omitting --timeout must not change existing behaviour: no limits, so

providers/workspaces/interactive-tmux/driver-rs/tests/live_eval.rs

Lines changed: 84 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,18 @@ enum ParsedLine {
9696
NonJson(String),
9797
}
9898

99-
/// Parse a stdout blob into per-line results. Blank lines are ignored (they are
100-
/// not stream content); every other line must parse as an [`AgentRunEvent`].
99+
/// Parse a stdout blob into per-line results, ONE per line, dropping nothing.
100+
///
101+
/// In `--json` mode stdout is pure event JSONL: every line must parse as an
102+
/// [`AgentRunEvent`], and a blank/whitespace-only line is itself a purity
103+
/// violation (it is not valid event JSONL), so it is surfaced as a
104+
/// [`ParsedLine::NonJson`] for [`validate_event_contract`] to reject rather than
105+
/// silently swallowed. (`str::lines()` already omits a single trailing newline,
106+
/// so a normal `println!`-terminated stream does not produce a spurious blank
107+
/// final line.)
101108
fn parse_lines(stdout: &str) -> Vec<ParsedLine> {
102109
stdout
103110
.lines()
104-
.filter(|line| !line.trim().is_empty())
105111
.map(|line| match serde_json::from_str::<AgentRunEvent>(line) {
106112
Ok(event) => ParsedLine::Event(Box::new(event)),
107113
Err(_) => ParsedLine::NonJson(line.to_string()),
@@ -152,7 +158,11 @@ enum ResultDelivery {
152158
/// * zero non-JSON lines on stdout (`--json` mode is pure event JSONL);
153159
/// * `seq` monotonic from 0 with no gaps;
154160
/// * `run_id` identical across every line;
155-
/// * exactly one `session_end`;
161+
/// * exactly one `session_end`, and it is the LAST lifecycle event - no
162+
/// `tool_start`/`tool_end`/`token_usage`/further `session_end` may follow it
163+
/// (in BOTH modes). In `OnStream` mode the terminal `type:"result"` line is
164+
/// the only event allowed after `session_end`; in `ResultFile` mode nothing
165+
/// may follow it;
156166
/// * the final result is delivered via the expected channel: exactly one
157167
/// `type:"result"` line for [`ResultDelivery::OnStream`], and ZERO for
158168
/// [`ResultDelivery::ResultFile`] (stdout stays pure lifecycle events).
@@ -203,6 +213,25 @@ fn validate_event_contract(parsed: &[ParsedLine], delivery: ResultDelivery) -> R
203213
));
204214
}
205215

216+
// session_end is the TERMINAL lifecycle event: the orchestrator emits it
217+
// last, immediately before the (optional) terminal result. So nothing but a
218+
// `result` line may appear after it - any lifecycle event
219+
// (tool_start/tool_end/token_usage/another session_end) following
220+
// session_end is a contract violation in BOTH modes. (`ResultFile` also
221+
// forbids result lines entirely, below, so there it means NOTHING follows.)
222+
let session_end_index = events
223+
.iter()
224+
.position(|event| matches!(event.payload, AgentRunEventPayload::SessionEnd { .. }))
225+
.expect("exactly one session_end confirmed above");
226+
for (offset, event) in events.iter().enumerate().skip(session_end_index + 1) {
227+
if !matches!(event.payload, AgentRunEventPayload::Result { .. }) {
228+
return Err(format!(
229+
"session_end is not terminal: a non-result event (seq {}, line {offset}) follows it",
230+
event.seq
231+
));
232+
}
233+
}
234+
206235
// Final-result delivery channel.
207236
let result_lines = events
208237
.iter()
@@ -329,13 +358,12 @@ impl Signaler {
329358
}
330359
}
331360

332-
/// A streamed run: every stdout line tagged with its arrival [`Instant`], plus
333-
/// the terminal exit status. Used by E3 (arrival spread) and E4 (signal at a
334-
/// stream boundary).
361+
/// A streamed run: every stdout line tagged with its arrival [`Instant`]. Used
362+
/// by E3 (arrival spread) and E4 (signal at a stream boundary). The terminal
363+
/// verdict is read from the parsed result, not the process exit code, so no
364+
/// exit status is retained here.
335365
struct StreamedRun {
336366
lines: Vec<(Instant, String)>,
337-
#[allow(dead_code)]
338-
exit_ok: bool,
339367
}
340368

341369
impl StreamedRun {
@@ -387,11 +415,10 @@ fn stream_run(
387415
lines.push((at, line));
388416
}
389417
let _ = reader.join();
390-
let status = child.wait()?;
391-
Ok(StreamedRun {
392-
lines,
393-
exit_ok: status.success(),
394-
})
418+
// Reap the child so it does not linger as a zombie; the terminal verdict is
419+
// taken from the parsed result stream, not this exit status.
420+
let _ = child.wait()?;
421+
Ok(StreamedRun { lines })
395422
}
396423

397424
/// Is a line a `tool_end submit` or `tool_start await` event? That is the
@@ -578,6 +605,43 @@ mod plumbing_tests {
578605
assert!(err.contains("non-JSON"), "err: {err}");
579606
}
580607

608+
#[test]
609+
fn validator_rejects_an_embedded_blank_line() {
610+
// A blank line on stdout in --json mode is a purity violation: it is
611+
// not valid event JSONL and must NOT be silently dropped. Inject one
612+
// between two otherwise-valid events.
613+
let sample = good_sample();
614+
let (head, tail) = sample.split_once('\n').expect("multi-line sample");
615+
let corrupted = format!("{head}\n\n{tail}");
616+
let parsed = parse_lines(&corrupted);
617+
let err = validate_event_contract(&parsed, ResultDelivery::OnStream).unwrap_err();
618+
assert!(err.contains("non-JSON"), "err: {err}");
619+
}
620+
621+
#[test]
622+
fn validator_rejects_a_lifecycle_event_after_session_end_on_stream() {
623+
// session_end must be terminal: a tool_start AFTER it (before the
624+
// result line) is rejected even though counts/seq are otherwise valid.
625+
let stream = "\
626+
{\"run_id\":\"r\",\"seq\":0,\"ts\":\"t\",\"type\":\"session_end\",\"outcome\":{\"success\":true,\"summary\":\"ok\"}}
627+
{\"run_id\":\"r\",\"seq\":1,\"ts\":\"t\",\"type\":\"tool_start\",\"tool_name\":\"provision\",\"tool_input\":null}
628+
{\"run_id\":\"r\",\"seq\":2,\"ts\":\"t\",\"type\":\"result\",\"result\":{\"result\":{\"success\":true,\"summary\":\"ok\"},\"output_artifacts\":[],\"session_log\":\"\",\"observability\":null}}";
629+
let parsed = parse_lines(stream);
630+
let err = validate_event_contract(&parsed, ResultDelivery::OnStream).unwrap_err();
631+
assert!(err.contains("session_end is not terminal"), "err: {err}");
632+
}
633+
634+
#[test]
635+
fn validator_rejects_a_lifecycle_event_after_session_end_with_result_file() {
636+
// Same rule in --result-file mode: nothing may follow session_end.
637+
let stream = "\
638+
{\"run_id\":\"r\",\"seq\":0,\"ts\":\"t\",\"type\":\"session_end\",\"outcome\":{\"success\":true,\"summary\":\"ok\"}}
639+
{\"run_id\":\"r\",\"seq\":1,\"ts\":\"t\",\"type\":\"tool_end\",\"tool_name\":\"capture\",\"success\":true,\"output_summary\":null}";
640+
let parsed = parse_lines(stream);
641+
let err = validate_event_contract(&parsed, ResultDelivery::ResultFile).unwrap_err();
642+
assert!(err.contains("session_end is not terminal"), "err: {err}");
643+
}
644+
581645
#[test]
582646
fn validator_rejects_a_seq_gap() {
583647
// Drop the seq=1 line so the sequence jumps 0 -> 2.
@@ -831,6 +895,12 @@ fn e3_live_events_stream_incrementally() {
831895
first_tool_start_ts.expect("a tool_start"),
832896
session_end_ts.expect("a session_end"),
833897
);
898+
// Lexicographic compare is sound here because the emitter
899+
// (`workspace_executor::now_rfc3339`) always produces fixed-width,
900+
// second-precision UTC timestamps with a literal `Z` offset
901+
// (`YYYY-MM-DDThh:mm:ssZ`), so string order equals chronological order. The
902+
// crate carries no datetime parser (it hand-rolls RFC3339), and adding one
903+
// solely for this assertion is not worth the dependency weight.
834904
assert!(
835905
ts_start <= ts_end,
836906
"tool_start {ts_start} must be <= session_end {ts_end}"

0 commit comments

Comments
 (0)