1use 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
22pub const STALL: Duration = Duration::from_secs(300);
28
29pub fn run_watched(program: &str, args: &[&str], env: &[(&str, &str)]) -> Result<()> {
31 run_watched_for(program, args, env, STALL)
32}
33
34pub 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
93fn 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
112fn 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 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
163fn 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 #[test]
181 fn a_chatty_command_is_left_alone() {
182 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 #[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 #[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}