diff --git a/crates/glass-android/src/doctor.rs b/crates/glass-android/src/doctor.rs index eca374a1..811b6fda 100644 --- a/crates/glass-android/src/doctor.rs +++ b/crates/glass-android/src/doctor.rs @@ -5,7 +5,7 @@ //! Reports `Check` statuses; never errors. use glass_core::Deadline; -use glass_core::{Check, CheckStatus}; +use glass_core::{BoundedRun, Check, CheckStatus}; use crate::a11y::{Attempt, adb_runner, attempt_deadline, dump_once}; use crate::adb::Adb; @@ -254,14 +254,14 @@ fn first_line(s: &str) -> String { fn list_avds(bin: &str, budget: std::time::Duration) -> AvdList { let mut cmd = std::process::Command::new(bin); cmd.arg("-list-avds"); - match glass_core::run_bounded(&mut cmd, budget, "emulator:-list-avds") { - Ok(o) => AvdList::Listed(parse_list_avds(&String::from_utf8_lossy(&o.stdout))), - Err(e) => { - // A timeout is reported, not logged (the detail carries the budget); every other - // failure is logged the way the other doctor probes are. - if e.bound() == Some(glass_core::BoundKind::TimedOut) { - return AvdList::TimedOut(e.to_string()); - } + match glass_core::run_bounded_classified(&mut cmd, budget, "emulator:-list-avds") { + BoundedRun::Answered(o) => { + AvdList::Listed(parse_list_avds(&String::from_utf8_lossy(&o.stdout))) + } + // A timeout is reported, not logged (the detail carries the budget); every other + // failure is logged the way the other doctor probes are. + BoundedRun::TimedOut(e) => AvdList::TimedOut(e.to_string()), + BoundedRun::NotStarted(e) | BoundedRun::Failed(e) => { eprintln!("glass-android doctor: {e}"); AvdList::Unreadable(e.to_string()) } diff --git a/crates/glass-core/src/bounded.rs b/crates/glass-core/src/bounded.rs index ea017ca2..f341f05b 100644 --- a/crates/glass-core/src/bounded.rs +++ b/crates/glass-core/src/bounded.rs @@ -50,6 +50,31 @@ pub const KILL_REAP: Duration = Duration::from_millis(500); /// AVD, a `uiautomator dump` is ~12KB and the largest, `exec-out screencap`, is ~10MB. const MAX_CAPTURE: usize = 64 * 1024 * 1024; +/// The execution facts from one bounded command, before a caller assigns domain meaning. +#[derive(Debug)] +pub enum BoundedRun { + /// The command ran to completion. A non-zero status is still an answer. + Answered(Output), + /// The command ran until its effective bound elapsed. + TimedOut(GlassError), + /// The command was not spawned because no time remained. + NotStarted(GlassError), + /// The command did not produce a complete answer for another reason. + Failed(GlassError), +} + +/// Run one command under `budget` and retain its execution facts as structured values. +pub fn run_bounded_classified(cmd: &mut Command, budget: Duration, op: &str) -> BoundedRun { + match run_bounded(cmd, budget, op) { + Ok(output) => BoundedRun::Answered(output), + Err(error) => match error.bound() { + Some(BoundKind::TimedOut) => BoundedRun::TimedOut(error), + Some(BoundKind::NotStarted) => BoundedRun::NotStarted(error), + None => BoundedRun::Failed(error), + }, + } +} + /// Run `cmd` to completion, or kill it and fail once `budget` elapses. /// /// `op` names the operation in the error (`"adb:uiautomator dump"`), so a timeout says which call @@ -487,6 +512,65 @@ mod tests { assert_eq!(out.status.code(), Some(3)); } + #[test] + #[cfg(unix)] + fn a_classified_run_preserves_a_nonzero_answer() { + let run = run_bounded_classified( + Command::new("/bin/sh").args(["-c", "printf answered; exit 3"]), + Duration::from_secs(10), + "test:classified-answer", + ); + let BoundedRun::Answered(out) = run else { + panic!("a completed command is an answer"); + }; + assert_eq!(out.status.code(), Some(3)); + assert_eq!(String::from_utf8_lossy(&out.stdout), "answered"); + } + + #[test] + #[cfg(unix)] + fn a_classified_run_names_a_timeout_without_message_matching() { + let run = run_bounded_classified( + Command::new("/bin/sh").args(["-c", "sleep 30"]), + Duration::from_millis(100), + "test:classified-timeout", + ); + let BoundedRun::TimedOut(err) = run else { + panic!("a command killed at its bound is timed out"); + }; + assert_eq!(err.bound(), Some(BoundKind::TimedOut)); + assert!(err.to_string().contains("test:classified-timeout"), "{err}"); + } + + #[test] + #[cfg(unix)] + fn a_classified_run_says_when_it_never_started() { + let run = run_bounded_classified( + &mut Command::new("/bin/true"), + Duration::ZERO, + "test:classified-not-started", + ); + let BoundedRun::NotStarted(err) = run else { + panic!("a zero-budget command is not started"); + }; + assert_eq!(err.bound(), Some(BoundKind::NotStarted)); + } + + #[test] + #[cfg(unix)] + fn a_classified_run_keeps_an_ordinary_failure_distinct() { + let run = run_bounded_classified( + &mut Command::new("/path/glass-test-command-does-not-exist"), + Duration::from_secs(10), + "test:classified-failure", + ); + let BoundedRun::Failed(err) = run else { + panic!("a spawn refusal is an ordinary execution failure"); + }; + assert_eq!(err.bound(), None); + assert!(err.to_string().contains("failed to start"), "{err}"); + } + #[test] #[cfg(unix)] fn stderr_is_captured_alongside_stdout() { diff --git a/crates/glass-core/src/lib.rs b/crates/glass-core/src/lib.rs index 9eee48b9..058ccfa4 100644 --- a/crates/glass-core/src/lib.rs +++ b/crates/glass-core/src/lib.rs @@ -13,7 +13,10 @@ pub mod toolpath; pub use toolpath::tool_path; pub mod bounded; -pub use bounded::{note_if_skipped, run_bounded, run_bounded_until, run_bounded_with_stdin}; +pub use bounded::{ + BoundedRun, note_if_skipped, run_bounded, run_bounded_classified, run_bounded_until, + run_bounded_with_stdin, +}; pub mod a11y_thread; pub use a11y_thread::A11yThread; diff --git a/crates/glass-ios/src/doctor.rs b/crates/glass-ios/src/doctor.rs index 516ff904..b4058eb5 100644 --- a/crates/glass-ios/src/doctor.rs +++ b/crates/glass-ios/src/doctor.rs @@ -10,7 +10,7 @@ use std::path::Path; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; -use glass_core::{BoundKind, Check, CheckStatus, GlassError}; +use glass_core::{BoundedRun, Check, CheckStatus, GlassError}; use glass_exec_unix::Resolved; use crate::device::{Resolve, SimDevice, parse_devices, resolve}; @@ -280,20 +280,21 @@ enum Run { /// One bounded one-shot probe; the cause is kept on the result and logged to stderr. fn run_probe(cmd: &mut Command, op: &str) -> Run { - match glass_core::run_bounded(cmd, PROBE_BUDGET, op) { - Ok(o) => Run::Answered { + match glass_core::run_bounded_classified(cmd, PROBE_BUDGET, op) { + BoundedRun::Answered(o) => Run::Answered { status_ok: o.status.success(), stdout: String::from_utf8_lossy(&o.stdout).into_owned(), stderr: String::from_utf8_lossy(&o.stderr).into_owned(), }, - Err(e) => { + BoundedRun::TimedOut(e) => { let cause = e.to_string(); eprintln!("glass-ios doctor: {cause}"); - if e.bound() == Some(BoundKind::TimedOut) { - Run::TimedOut(cause) - } else { - Run::Failed(cause) - } + Run::TimedOut(cause) + } + BoundedRun::NotStarted(e) | BoundedRun::Failed(e) => { + let cause = e.to_string(); + eprintln!("glass-ios doctor: {cause}"); + Run::Failed(cause) } } }