Skip to main content

entracte_lib/
proc.rs

1//! Run external commands with a hard timeout.
2//!
3//! `std::process::Command::output()` blocks until the child exits with no
4//! upper bound. The OS-integration probes (`pmset`, `powercfg`, `xprop`,
5//! `gsettings`, `gdbus`, `systemd-inhibit`) run inside ~10s detection poll
6//! loops and on the break-overlay open path, so a wedged tool — an
7//! unresponsive X server, a dead session bus — would stall the calling
8//! thread forever and freeze the guard flag (DND / camera / video) at its
9//! last value. [`CommandTimeoutExt::output_timeout`] bounds the wait and
10//! kills the child if it overruns, degrading to a probe error the callers
11//! already treat as "signal absent".
12
13use std::io::{self, Read};
14use std::process::{Child, Command, ExitStatus, Output, Stdio};
15use std::thread;
16use std::time::{Duration, Instant};
17
18/// Upper bound for a single detection probe. The tools normally answer in
19/// well under 100 ms; 2 s is comfortably above any healthy run while still
20/// far below the poll cadence it guards, so a hung probe costs one slow
21/// tick rather than a permanently stuck signal.
22pub const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
23
24/// How often to check whether the child has exited while waiting. 10 ms is
25/// fine-grained next to the timeouts and poll loops it guards, and keeps the
26/// wait off a busy spin.
27const POLL_INTERVAL: Duration = Duration::from_millis(10);
28
29/// Poll `child` until it exits or `deadline` passes. On overrun the child is
30/// killed and reaped (so it can't linger as a zombie) and `Ok(None)` is
31/// returned; otherwise `Ok(Some(status))`.
32fn wait_until(child: &mut Child, deadline: Instant) -> io::Result<Option<ExitStatus>> {
33    loop {
34        if let Some(status) = child.try_wait()? {
35            return Ok(Some(status));
36        }
37        if Instant::now() >= deadline {
38            let _ = child.kill();
39            let _ = child.wait();
40            return Ok(None);
41        }
42        thread::sleep(POLL_INTERVAL);
43    }
44}
45
46/// Wait for an already-spawned `child` up to `timeout`, killing and reaping
47/// it if it overruns. Returns `Ok(Some(status))` if it exited on its own, or
48/// `Ok(None)` if it was killed for exceeding the timeout. Either way the
49/// child is reaped, so a fire-and-forget caller can't leak a zombie or a
50/// runaway process.
51pub fn reap_or_kill(child: &mut Child, timeout: Duration) -> io::Result<Option<ExitStatus>> {
52    wait_until(child, Instant::now() + timeout)
53}
54
55/// Read `reader` to EOF, retaining at most `cap` bytes when `Some`.
56///
57/// With `None` this is a plain `read_to_end`. With `Some(cap)` it still
58/// drains the pipe to EOF — so the child never blocks on a full pipe, which
59/// would turn a chatty-but-brief command into a spurious timeout — but
60/// discards everything past `cap` so a command that floods its output can't
61/// balloon memory before the caller truncates it (#213).
62fn read_capped(mut reader: impl Read, cap: Option<usize>) -> Vec<u8> {
63    let Some(cap) = cap else {
64        let mut buf = Vec::new();
65        let _ = reader.read_to_end(&mut buf);
66        return buf;
67    };
68    let mut buf = Vec::with_capacity(cap.min(READ_CHUNK));
69    let mut chunk = [0u8; READ_CHUNK];
70    loop {
71        match reader.read(&mut chunk) {
72            Ok(0) => break,
73            Ok(n) => {
74                if buf.len() < cap {
75                    let room = cap - buf.len();
76                    buf.extend_from_slice(&chunk[..n.min(room)]);
77                }
78            }
79            // Retry on EINTR like `read_to_end` does, so a stray signal
80            // mid-read doesn't look like EOF and stop the drain early.
81            Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
82            Err(_) => break,
83        }
84    }
85    buf
86}
87
88/// Scratch buffer size for the draining read loop.
89const READ_CHUNK: usize = 8 * 1024;
90
91/// Stop Windows from allocating a console window for a console-subsystem
92/// child.
93///
94/// Redirecting stdio is not enough: `powercfg.exe` and `cmd.exe` are console
95/// subsystem binaries, so Windows gives each spawn its own console, which
96/// flashes on screen and steals focus for a few frames. Entracte's detection
97/// probes run on a 10 s loop, so that surfaced as a `cmd` window blinking
98/// every ten seconds for the whole session (#303).
99///
100/// `CREATE_NO_WINDOW` suppresses the console while leaving the child attached
101/// to the pipes we hand it — unlike `DETACHED_PROCESS`, which would also
102/// suppress the window but break output capture. Non-Windows targets have no
103/// console to suppress, so this is a no-op there and callers stay
104/// platform-agnostic.
105pub fn suppress_console(cmd: &mut Command) -> &mut Command {
106    #[cfg(windows)]
107    {
108        use std::os::windows::process::CommandExt;
109        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
110        cmd.creation_flags(CREATE_NO_WINDOW);
111    }
112    cmd
113}
114
115/// Spawn `cmd`, capture its output bounded by the wait `timeout` and an
116/// optional per-stream byte `cap`, killing and reaping the child on overrun.
117fn spawn_and_capture(
118    cmd: &mut Command,
119    timeout: Duration,
120    cap: Option<usize>,
121) -> io::Result<Output> {
122    cmd.stdin(Stdio::null())
123        .stdout(Stdio::piped())
124        .stderr(Stdio::piped());
125    suppress_console(cmd);
126    let mut child = cmd.spawn()?;
127
128    let child_stdout = child.stdout.take().expect("stdout piped above");
129    let child_stderr = child.stderr.take().expect("stderr piped above");
130    let stdout_reader = thread::spawn(move || read_capped(child_stdout, cap));
131    let stderr_reader = thread::spawn(move || read_capped(child_stderr, cap));
132
133    let Some(status) = wait_until(&mut child, Instant::now() + timeout)? else {
134        // Timed out: the child was killed and reaped. Deliberately don't
135        // join the reader threads — a killed process whose child inherited
136        // the pipe (e.g. a shell that spawned the real tool) can keep them
137        // blocked on `read_to_end`, and the caller must not wait on that.
138        // Dropping the handles detaches them; a reader then outlives this
139        // call until its pipe write-end finally closes — for a wedged
140        // pipe-holding grandchild, only when that process dies or the app
141        // exits. An accepted, bounded cost on the probe path (the probed
142        // tools don't fork such children), not a guarantee of prompt
143        // cleanup.
144        return Err(io::Error::new(
145            io::ErrorKind::TimedOut,
146            "command exceeded timeout",
147        ));
148    };
149
150    let stdout = stdout_reader.join().unwrap_or_default();
151    let stderr = stderr_reader.join().unwrap_or_default();
152    Ok(Output {
153        status,
154        stdout,
155        stderr,
156    })
157}
158
159/// Drop-in replacement for [`Command::output`] that bounds the wait.
160pub trait CommandTimeoutExt {
161    /// Run the command to completion, capturing its output, but kill it and
162    /// return a `TimedOut` error if it runs longer than `timeout`.
163    fn output_timeout(&mut self, timeout: Duration) -> io::Result<Output>;
164
165    /// Like [`output_timeout`](Self::output_timeout) but retains at most
166    /// `cap` bytes of each of stdout/stderr, draining and discarding the
167    /// rest. Bounds memory on the capture path (the hook Test button) so a
168    /// flooding command can't balloon the buffer before truncation (#213).
169    fn output_timeout_capped(&mut self, timeout: Duration, cap: usize) -> io::Result<Output>;
170}
171
172impl CommandTimeoutExt for Command {
173    fn output_timeout(&mut self, timeout: Duration) -> io::Result<Output> {
174        spawn_and_capture(self, timeout, None)
175    }
176
177    fn output_timeout_capped(&mut self, timeout: Duration, cap: usize) -> io::Result<Output> {
178        spawn_and_capture(self, timeout, Some(cap))
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn read_capped_unbounded_returns_everything() {
188        let data = vec![b'x'; 100_000];
189        let out = read_capped(io::Cursor::new(data.clone()), None);
190        assert_eq!(out.len(), data.len());
191    }
192
193    #[test]
194    fn read_capped_retains_at_most_cap_bytes() {
195        let out = read_capped(io::Cursor::new(vec![b'x'; 100_000]), Some(8193));
196        assert_eq!(out.len(), 8193);
197    }
198
199    #[test]
200    fn read_capped_passes_short_input_through() {
201        let out = read_capped(io::Cursor::new(b"hello".to_vec()), Some(8193));
202        assert_eq!(out, b"hello");
203    }
204
205    #[test]
206    fn read_capped_retries_on_interrupted() {
207        // A reader that returns EINTR once before its data. The retry branch
208        // must keep reading rather than treating EINTR as EOF (which would
209        // return an empty buffer).
210        struct Flaky {
211            step: u8,
212        }
213        impl Read for Flaky {
214            fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
215                self.step += 1;
216                match self.step {
217                    1 => Err(io::Error::from(io::ErrorKind::Interrupted)),
218                    2 => {
219                        buf[..3].copy_from_slice(b"abc");
220                        Ok(3)
221                    }
222                    _ => Ok(0),
223                }
224            }
225        }
226        assert_eq!(read_capped(Flaky { step: 0 }, Some(8)), b"abc");
227    }
228
229    #[test]
230    fn read_capped_stops_on_a_hard_error() {
231        // A non-Interrupted error ends the drain (returning what was read so
232        // far) rather than looping — distinct from the EINTR retry above.
233        struct Boom;
234        impl Read for Boom {
235            fn read(&mut self, _: &mut [u8]) -> io::Result<usize> {
236                Err(io::Error::from(io::ErrorKind::BrokenPipe))
237            }
238        }
239        assert!(read_capped(Boom, Some(8)).is_empty());
240    }
241
242    // Directly exercises the `output_timeout_capped` (`Some(cap)`) wiring
243    // through a real child, not just the `read_capped` core. Unix-only: needs
244    // a shell that floods then exits when the reader stops (yes | head).
245    #[cfg(unix)]
246    #[test]
247    fn output_timeout_capped_bounds_each_stream() {
248        let mut cmd = Command::new("/bin/sh");
249        cmd.args(["-c", "yes entracte | head -c 100000"]);
250        let out = cmd
251            .output_timeout_capped(Duration::from_secs(5), 4096)
252            .expect("command completes within the timeout");
253        let len = out.stdout.len();
254        assert!(
255            len <= 4096,
256            "stdout should be bounded by the cap, was {len}"
257        );
258        assert!(out.stderr.is_empty());
259    }
260
261    #[test]
262    fn read_capped_drains_the_reader_past_the_cap() {
263        // Even once the retained buffer fills at `cap`, the reader is consumed
264        // to EOF — so a real child pipe never blocks on a full buffer. The
265        // cursor ending at its length proves every byte was read.
266        let mut cursor = io::Cursor::new(vec![b'x'; 100_000]);
267        let out = read_capped(&mut cursor, Some(16));
268        assert_eq!(out.len(), 16);
269        assert_eq!(cursor.position(), 100_000);
270    }
271
272    fn echo_hello() -> Command {
273        #[cfg(unix)]
274        {
275            let mut cmd = Command::new("/bin/echo");
276            cmd.arg("hello");
277            cmd
278        }
279        #[cfg(windows)]
280        {
281            let mut cmd = Command::new("cmd");
282            cmd.args(["/C", "echo hello"]);
283            cmd
284        }
285    }
286
287    fn sleep_five_seconds() -> Command {
288        #[cfg(unix)]
289        {
290            let mut cmd = Command::new("/bin/sleep");
291            cmd.arg("5");
292            cmd
293        }
294        #[cfg(windows)]
295        {
296            // ping spaces its probes ~1s apart, so -n 6 sleeps ~5s without
297            // needing a console the way `timeout` does.
298            let mut cmd = Command::new("cmd");
299            cmd.args(["/C", "ping", "-n", "6", "127.0.0.1"]);
300            cmd
301        }
302    }
303
304    #[test]
305    fn suppress_console_keeps_output_capture_working() {
306        // The probe path applies `suppress_console` before spawning, so the
307        // flag must hide the console *without* detaching the child from our
308        // pipes. `DETACHED_PROCESS` would also hide the window but silently
309        // break capture, so asserting stdout still arrives is what
310        // distinguishes the correct flag from the plausible wrong one.
311        let mut cmd = echo_hello();
312        suppress_console(&mut cmd);
313        let out = cmd.output_timeout(Duration::from_secs(5)).unwrap();
314        assert!(out.status.success());
315        assert!(
316            String::from_utf8_lossy(&out.stdout).contains("hello"),
317            "stdout was {:?}",
318            out.stdout
319        );
320    }
321
322    #[test]
323    fn suppress_console_returns_the_same_command_for_chaining() {
324        let mut cmd = echo_hello();
325        let ptr = &raw const cmd;
326        let returned = suppress_console(&mut cmd);
327        assert_eq!(
328            ptr, &raw const *returned,
329            "suppress_console must borrow through, not replace the command"
330        );
331    }
332
333    #[test]
334    fn returns_captured_output_for_a_fast_command() {
335        let out = echo_hello().output_timeout(Duration::from_secs(5)).unwrap();
336        assert!(out.status.success());
337        assert!(
338            String::from_utf8_lossy(&out.stdout).contains("hello"),
339            "stdout was {:?}",
340            out.stdout
341        );
342    }
343
344    #[test]
345    fn kills_and_errors_when_the_command_overruns() {
346        let started = Instant::now();
347        let err = sleep_five_seconds()
348            .output_timeout(Duration::from_millis(150))
349            .unwrap_err();
350        let elapsed = started.elapsed();
351        assert_eq!(err.kind(), io::ErrorKind::TimedOut);
352        assert!(
353            elapsed < Duration::from_secs(3),
354            "should return shortly after the timeout, took {elapsed:?}"
355        );
356    }
357
358    #[test]
359    fn propagates_a_spawn_failure() {
360        let err = Command::new("/nonexistent/entracte-probe-binary")
361            .output_timeout(Duration::from_secs(1))
362            .unwrap_err();
363        assert_ne!(err.kind(), io::ErrorKind::TimedOut);
364    }
365
366    #[test]
367    fn reap_or_kill_reaps_a_fast_child() {
368        let mut child = echo_hello().stdout(Stdio::null()).spawn().unwrap();
369        let status = reap_or_kill(&mut child, Duration::from_secs(5)).unwrap();
370        assert!(status.expect("child exited on its own").success());
371    }
372
373    #[test]
374    fn reap_or_kill_kills_a_child_that_overruns() {
375        let started = Instant::now();
376        let mut child = sleep_five_seconds().stdout(Stdio::null()).spawn().unwrap();
377        let status = reap_or_kill(&mut child, Duration::from_millis(150)).unwrap();
378        let elapsed = started.elapsed();
379        assert!(status.is_none(), "an overrunning child should be killed");
380        assert!(
381            elapsed < Duration::from_secs(3),
382            "should return shortly after the timeout, took {elapsed:?}"
383        );
384    }
385}
386
387/// Guards the invariant behind #303: no child process is spawned without
388/// suppressing the Windows console window.
389///
390/// [`tests::suppress_console_keeps_output_capture_working`] proves the *flag*
391/// is the right one, but nothing behavioural proves it is still *applied* on
392/// the paths that matter. Deleting the single call inside
393/// [`spawn_and_capture`], or adding a new probe that calls `Command::spawn`
394/// directly, would bring the flashing console back across the whole app — and
395/// would go unnoticed on macOS and Linux, where all of this is a no-op, which
396/// is exactly where the fix was written. Detecting an absent window needs a
397/// real Windows desktop, so this checks the source instead, in the same spirit
398/// as the settings parity tests.
399#[cfg(test)]
400mod console_suppression_drift {
401    use std::path::{Path, PathBuf};
402
403    /// Files allowed to call `Command::spawn` without applying
404    /// `suppress_console`, each with the reason it is safe. Adding an entry
405    /// should be a deliberate decision about console behaviour, not a way to
406    /// quiet this test.
407    const EXEMPT: &[(&str, &str)] = &[
408        (
409            "proc.rs",
410            "defines suppress_console and applies it inside spawn_and_capture",
411        ),
412        (
413            "camera.rs",
414            "macOS-only `log stream` probe — the spawn sits behind \
415             #[cfg(target_os = \"macos\")] and never compiles on Windows",
416        ),
417    ];
418
419    fn collect_rs(dir: &Path, out: &mut Vec<PathBuf>) {
420        let entries = std::fs::read_dir(dir)
421            .unwrap_or_else(|e| panic!("could not read {}: {e}", dir.display()));
422        for entry in entries {
423            let path = entry.expect("readable directory entry").path();
424            if path.is_dir() {
425                collect_rs(&path, out);
426            } else if path.extension().is_some_and(|ext| ext == "rs") {
427                out.push(path);
428            }
429        }
430    }
431
432    fn source_files() -> Vec<PathBuf> {
433        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
434        let mut files = Vec::new();
435        collect_rs(&root, &mut files);
436        let root = root.display().to_string();
437        assert!(
438            !files.is_empty(),
439            "found no .rs files under {root} — has the layout moved? If so, update this test's root."
440        );
441        files
442    }
443
444    /// True when `source` spawns a child process without suppressing the
445    /// console. Kept as a pure predicate so the decision itself is testable:
446    /// the directory scan below can only ever exercise the "clean" answer
447    /// while the codebase is clean, which would leave the interesting half
448    /// unverified.
449    fn spawns_without_suppression(source: &str) -> bool {
450        // `thread::spawn` has no leading dot, so this only matches a spawn
451        // invoked on a value — `some_command.spawn()`.
452        source.contains(".spawn()") && !source.contains("suppress_console")
453    }
454
455    #[test]
456    fn spawn_and_capture_still_suppresses_the_console() {
457        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/proc.rs");
458        let source = std::fs::read_to_string(&path)
459            .unwrap_or_else(|e| panic!("could not read {}: {e}", path.display()));
460        let start = source
461            .find("fn spawn_and_capture(")
462            .expect("spawn_and_capture is defined in proc.rs");
463        let body = &source[start..];
464        let end = body
465            .find("\n}")
466            .expect("spawn_and_capture has a closing brace");
467        let body = &body[..end];
468        assert!(
469            body.contains("suppress_console("),
470            "spawn_and_capture no longer calls suppress_console — every OS probe \
471             and the hook Test button spawn through it, so dropping that call \
472             brings back the console window flashing every ~10s on Windows (#303)."
473        );
474    }
475
476    #[test]
477    fn spawns_without_suppression_flags_only_an_unguarded_spawn() {
478        assert!(spawns_without_suppression("let c = cmd.spawn();"));
479        assert!(!spawns_without_suppression(
480            "suppress_console(&mut cmd); let c = cmd.spawn();"
481        ));
482        assert!(!spawns_without_suppression("let x = compute();"));
483        // The scan leans on `thread::spawn` having no leading dot; pin it, or
484        // every threaded module would look like an offender.
485        assert!(!spawns_without_suppression(
486            "thread::spawn(move || loop { check(); });"
487        ));
488    }
489
490    #[test]
491    fn direct_spawn_sites_suppress_the_console() {
492        let checked: Vec<(String, bool)> = source_files()
493            .iter()
494            .map(|path| {
495                let name = path
496                    .file_name()
497                    .expect("file has a name")
498                    .to_string_lossy()
499                    .into_owned();
500                let source = std::fs::read_to_string(path)
501                    .unwrap_or_else(|e| panic!("could not read {}: {e}", path.display()));
502                let exempt = EXEMPT.iter().any(|(exempt, _)| *exempt == name);
503                let unguarded = !exempt && spawns_without_suppression(&source);
504                (name, unguarded)
505            })
506            .collect();
507        let offenders: Vec<&String> = checked
508            .iter()
509            .filter(|(_, unguarded)| *unguarded)
510            .map(|(name, _)| name)
511            .collect();
512        let offenders = format!("{offenders:?}");
513        assert!(
514            offenders == "[]",
515            "these files spawn a child process without suppressing the Windows console \
516             window: {offenders}. On Windows a console-subsystem child gets its own \
517             console window even with stdio redirected, which is what made a cmd window \
518             flash every ~10s (#303). Either route the spawn through proc::output_timeout, \
519             call proc::suppress_console on the Command first, or add the file to EXEMPT \
520             with the reason it is safe."
521        );
522    }
523}