1use std::io::{self, Read};
14use std::process::{Child, Command, ExitStatus, Output, Stdio};
15use std::thread;
16use std::time::{Duration, Instant};
17
18pub const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
23
24const POLL_INTERVAL: Duration = Duration::from_millis(10);
28
29fn 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
46pub fn reap_or_kill(child: &mut Child, timeout: Duration) -> io::Result<Option<ExitStatus>> {
52 wait_until(child, Instant::now() + timeout)
53}
54
55fn 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 Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
82 Err(_) => break,
83 }
84 }
85 buf
86}
87
88const READ_CHUNK: usize = 8 * 1024;
90
91pub 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
115fn 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 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
159pub trait CommandTimeoutExt {
161 fn output_timeout(&mut self, timeout: Duration) -> io::Result<Output>;
164
165 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 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 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 #[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 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 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 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#[cfg(test)]
400mod console_suppression_drift {
401 use std::path::{Path, PathBuf};
402
403 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 fn spawns_without_suppression(source: &str) -> bool {
450 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 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}