Skip to main content

xtask/
watchdog.rs

1//! Runs a command that is expected to keep talking, and diagnoses it if it stops.
2//!
3//! A coverage run hung for over an hour: two web integration tests stopped
4//! producing output while the job's heartbeat kept pulsing, so nothing looked
5//! wrong until the deadline killed it — with no indication of which tests were
6//! responsible. Diagnosing it meant catching the pod live and hand-running
7//! `pg_stat_activity` and `/proc` inspection before it disappeared. The next
8//! occurrence should arrive already diagnosed.
9//!
10//! Only for commands whose silence is itself a symptom. The musl link is quiet
11//! for twelve minutes and is perfectly healthy; wrapping that would be a way to
12//! kill good builds.
13
14use std::io::{BufRead, BufReader};
15use std::process::{Child, Command, Stdio};
16use std::sync::Arc;
17use std::sync::atomic::{AtomicU64, Ordering};
18use std::time::{Duration, Instant};
19
20use anyhow::{Context, Result, bail};
21
22/// How long a watched command may produce nothing before it is declared stuck.
23///
24/// Generous on purpose: with several test threads, other tests keep completing
25/// even while one is slow, so real silence this long means nothing is
26/// progressing. The hang this exists for was silent for eleven minutes.
27pub const STALL: Duration = Duration::from_secs(300);
28
29/// Runs a command, forwarding its output, and fails if it goes quiet.
30pub fn run_watched(program: &str, args: &[&str], env: &[(&str, &str)]) -> Result<()> {
31    run_watched_for(program, args, env, STALL)
32}
33
34/// As [`run_watched`], with the silence budget supplied.
35///
36/// Exists so the stall path itself is testable: a test cannot wait out the real
37/// five minutes, and a guard that has never fired is not known to work.
38pub fn run_watched_for(
39    program: &str,
40    args: &[&str],
41    env: &[(&str, &str)],
42    stall: Duration,
43) -> Result<()> {
44    let mut command = Command::new(program);
45    command
46        .args(args)
47        .stdout(Stdio::piped())
48        .stderr(Stdio::piped());
49    for (key, value) in env {
50        command.env(key, value);
51    }
52    let mut child = command
53        .spawn()
54        .with_context(|| format!("failed to run {program}"))?;
55
56    let last_output = Arc::new(AtomicU64::new(0));
57    let started = Instant::now();
58
59    let mut pumps = Vec::new();
60    if let Some(stdout) = child.stdout.take() {
61        pumps.push(pump(stdout, Arc::clone(&last_output), started, false));
62    }
63    if let Some(stderr) = child.stderr.take() {
64        pumps.push(pump(stderr, Arc::clone(&last_output), started, true));
65    }
66
67    let status = loop {
68        if let Some(status) = child.try_wait().context("waiting on the child")? {
69            break status;
70        }
71        let quiet = started.elapsed().as_secs() - last_output.load(Ordering::Relaxed);
72        if quiet > stall.as_secs() {
73            diagnose(&child, quiet);
74            let _ = child.kill();
75            let _ = child.wait();
76            bail!(
77                "{program} produced no output for {quiet}s — see the diagnostics above. \
78                 This is the silent-hang failure mode, not a slow build."
79            );
80        }
81        std::thread::sleep(Duration::from_millis(500));
82    };
83
84    for pump in pumps {
85        let _ = pump.join();
86    }
87    if !status.success() {
88        bail!("{program} {} exited with {status}", args.join(" "));
89    }
90    Ok(())
91}
92
93/// Forwards a stream line by line, recording when each line arrived.
94fn pump<R: std::io::Read + Send + 'static>(
95    stream: R,
96    last_output: Arc<AtomicU64>,
97    started: Instant,
98    is_stderr: bool,
99) -> std::thread::JoinHandle<()> {
100    std::thread::spawn(move || {
101        for line in BufReader::new(stream).lines().map_while(Result::ok) {
102            last_output.store(started.elapsed().as_secs(), Ordering::Relaxed);
103            if is_stderr {
104                eprintln!("{line}");
105            } else {
106                println!("{line}");
107            }
108        }
109    })
110}
111
112/// Everything worth knowing about a stuck run, captured before it is killed.
113///
114/// Each of these had to be gathered by hand from a live pod last time, racing
115/// the job's deadline. The database view is the one that mattered: connections
116/// sitting `idle / ClientRead` while the client waited on the socket is what
117/// identified a desynchronised connection rather than a slow query.
118fn diagnose(child: &Child, quiet: u64) {
119    eprintln!("\n>> STALL: no output for {quiet}s — collecting diagnostics\n");
120
121    if let Ok(url) = std::env::var("DATABASE_URL") {
122        eprintln!(">> database activity:");
123        report(
124            "psql",
125            &[
126                &url,
127                "-tAc",
128                "select pid, state, wait_event_type, wait_event, \
129                 left(replace(query, chr(10), ' '), 80) from pg_stat_activity \
130                 where backend_type = 'client backend'",
131            ],
132        );
133        eprintln!(">> locks not granted:");
134        report(
135            "psql",
136            &[
137                &url,
138                "-tAc",
139                "select pid, relation::regclass, mode from pg_locks where not granted",
140            ],
141        );
142    }
143
144    // Thread states of the stuck process tree: futex_wait means a lock,
145    // epoll_wait means it is waiting on a socket that will never answer.
146    let pid = child.id();
147    eprintln!(">> thread states under pid {pid}:");
148    for entry in std::fs::read_dir(format!("/proc/{pid}/task"))
149        .into_iter()
150        .flatten()
151        .flatten()
152    {
153        let wchan = std::fs::read_to_string(entry.path().join("wchan")).unwrap_or_default();
154        if !wchan.is_empty() {
155            eprintln!("   tid {:?}: {wchan}", entry.file_name());
156        }
157    }
158    eprintln!(">> processes:");
159    report("ps", &["-eo", "pid,ppid,etime,stat,pcpu,args"]);
160    eprintln!();
161}
162
163/// Best effort: a diagnostic that cannot run must not mask the stall itself.
164fn report(program: &str, args: &[&str]) {
165    match Command::new(program).args(args).output() {
166        Ok(out) => {
167            for line in String::from_utf8_lossy(&out.stdout).lines().take(40) {
168                eprintln!("   {line}");
169            }
170        }
171        Err(err) => eprintln!("   ({program} unavailable: {err})"),
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    /// A command that keeps talking must not be killed, however long it runs.
180    #[test]
181    fn a_chatty_command_is_left_alone() {
182        // Prints for longer than a stall check cycle, in small gaps.
183        let out = run_watched(
184            "bash",
185            &["-c", "for i in 1 2 3 4 5; do echo line $i; sleep 0.3; done"],
186            &[],
187        );
188        assert!(
189            out.is_ok(),
190            "a command producing output must not be stalled: {out:?}"
191        );
192    }
193
194    /// The guard must actually fire, kill the child, and say so.
195    #[test]
196    fn a_silent_command_is_stalled_and_killed() {
197        let started = Instant::now();
198        let err = run_watched_for(
199            "bash",
200            &["-c", "echo starting; sleep 120"],
201            &[],
202            Duration::from_secs(2),
203        )
204        .expect_err("a command silent past its budget must fail");
205
206        let text = format!("{err}");
207        assert!(text.contains("no output for"), "{text}");
208        assert!(
209            started.elapsed() < Duration::from_secs(30),
210            "the child was not killed promptly — it ran for {:?}",
211            started.elapsed()
212        );
213    }
214
215    /// A command that fails must still report its failure, not a stall.
216    #[test]
217    fn a_failing_command_reports_its_exit_status() {
218        let err = run_watched("bash", &["-c", "echo working; exit 3"], &[])
219            .expect_err("a non-zero exit must be an error");
220        let text = format!("{err}");
221        assert!(text.contains("exited with"), "{text}");
222        assert!(
223            !text.contains("no output for"),
224            "a failing command must not be misreported as a stall: {text}"
225        );
226    }
227}