1use std::io::Read;
2use std::process::{Child, Output};
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5
6pub struct LimitedProcessOutput {
19 pub output: Output,
20 pub completeness: crate::core::context_admission::OutputCompleteness,
21}
22
23#[cfg(test)]
26pub(crate) fn wait_with_limits(
27 child: Child,
28 max_bytes: usize,
29 timeout: std::time::Duration,
30 kill_group: bool,
31) -> Output {
32 wait_with_limits_captured(child, max_bytes, timeout, kill_group).output
33}
34
35pub fn wait_with_limits_captured(
36 mut child: Child,
37 max_bytes: usize,
38 timeout: std::time::Duration,
39 kill_group: bool,
40) -> LimitedProcessOutput {
41 const STDERR_LIMIT: usize = 512 * 1024;
42 const DRAIN_GRACE: std::time::Duration = std::time::Duration::from_millis(250);
45 const READER_BUDGET: std::time::Duration = std::time::Duration::from_secs(2);
47 const REAP_BUDGET: std::time::Duration = std::time::Duration::from_secs(2);
49
50 let own_group = owned_group(&mut child, kill_group);
54
55 let stdout_pipe = child.stdout.take();
56 let stderr_pipe = child.stderr.take();
57 let start = std::time::Instant::now();
58 let truncated = Arc::new(AtomicBool::new(false));
59 let read_failed = Arc::new(AtomicBool::new(false));
60
61 let stdout_capture = Arc::new(StreamCapture::default());
65 let stderr_capture = Arc::new(StreamCapture::default());
66 let stdout_handle = std::thread::spawn({
67 let (capture, truncated, read_failed) = (
68 Arc::clone(&stdout_capture),
69 Arc::clone(&truncated),
70 Arc::clone(&read_failed),
71 );
72 move || read_stream(stdout_pipe, max_bytes, &capture, &truncated, &read_failed)
73 });
74 let stderr_handle = std::thread::spawn({
75 let (capture, truncated, read_failed) = (
76 Arc::clone(&stderr_capture),
77 Arc::clone(&truncated),
78 Arc::clone(&read_failed),
79 );
80 move || {
81 read_stream(
82 stderr_pipe,
83 STDERR_LIMIT,
84 &capture,
85 &truncated,
86 &read_failed,
87 );
88 }
89 });
90
91 let mut timed_out = false;
92 let mut wait_failed = false;
93 let mut kill_failed = false;
94 let mut forced_pipe_cleanup = false;
95 loop {
96 let hit_timeout = start.elapsed() > timeout;
97 if hit_timeout || truncated.load(Ordering::Relaxed) {
98 kill_failed |= !kill_child(&mut child, own_group);
100 timed_out = hit_timeout;
101 break;
102 }
103 match exited_unreaped(&mut child) {
104 Ok(true) => {
105 if own_group.is_some()
110 && !readers_finish_within(&stdout_handle, &stderr_handle, DRAIN_GRACE)
111 {
112 kill_failed |= !kill_child(&mut child, own_group);
113 forced_pipe_cleanup = true;
114 }
115 break;
116 }
117 Err(_) => {
118 wait_failed = true;
119 kill_failed |= !kill_child(&mut child, own_group);
120 break;
121 }
122 Ok(false) => std::thread::sleep(std::time::Duration::from_millis(50)),
123 }
124 }
125
126 let reaped = reap_within(&mut child, REAP_BUDGET);
131
132 let readers_finished = readers_finish_within(&stdout_handle, &stderr_handle, READER_BUDGET);
139 let reader_join_failed =
140 !readers_finished || stdout_handle.join().is_err() || stderr_handle.join().is_err();
141 let (mut stdout_buf, stdout_truncated) = stdout_capture.take();
142 let (mut stderr_buf, stderr_truncated) = stderr_capture.take();
143 let decode_lossy =
144 std::str::from_utf8(&stdout_buf).is_err() || std::str::from_utf8(&stderr_buf).is_err();
145 let reader_incomplete = wait_failed
146 || kill_failed
147 || reaped.is_none()
148 || forced_pipe_cleanup
149 || read_failed.load(Ordering::Relaxed)
150 || reader_join_failed;
151
152 if timed_out || stdout_truncated {
153 let notice = format!(
154 "\n[lean-ctx: output truncated at {} MB / {}s limit]\n",
155 max_bytes / (1024 * 1024),
156 timeout.as_secs()
157 );
158 stdout_buf.extend_from_slice(notice.as_bytes());
159 }
160 if stderr_truncated {
161 let notice = format!(
162 "\n[lean-ctx: stderr truncated at {} KB limit]\n",
163 STDERR_LIMIT / 1024
164 );
165 stderr_buf.extend_from_slice(notice.as_bytes());
166 }
167 if !readers_finished {
168 stdout_buf.extend_from_slice(
169 b"\n[lean-ctx: output not captured: a process outside this call kept it open]\n",
170 );
171 }
172
173 if reaped.is_none() {
174 stdout_buf.extend_from_slice(
175 b"\n[lean-ctx: the process could not be ended within the cleanup budget]\n",
176 );
177 }
178 let status = reaped.unwrap_or_else(synthetic_failure_status);
179
180 LimitedProcessOutput {
181 output: Output {
182 status,
183 stdout: stdout_buf,
184 stderr: stderr_buf,
185 },
186 completeness: crate::core::context_admission::OutputCompleteness {
187 truncated: stdout_truncated || stderr_truncated || truncated.load(Ordering::Relaxed),
188 timed_out,
189 reader_incomplete,
190 cancelled: false,
191 decode_lossy,
192 },
193 }
194}
195
196#[derive(Default)]
200struct StreamCapture {
201 bytes: std::sync::Mutex<Vec<u8>>,
202 truncated: AtomicBool,
203}
204
205impl StreamCapture {
206 fn take(&self) -> (Vec<u8>, bool) {
210 let bytes = std::mem::take(
211 &mut *self
212 .bytes
213 .lock()
214 .unwrap_or_else(std::sync::PoisonError::into_inner),
215 );
216 (bytes, self.truncated.load(Ordering::Relaxed))
217 }
218}
219
220fn read_stream(
224 pipe: Option<impl Read>,
225 limit: usize,
226 capture: &StreamCapture,
227 any_truncated: &AtomicBool,
228 read_failed: &AtomicBool,
229) {
230 let Some(mut pipe) = pipe else {
231 read_failed.store(true, Ordering::Relaxed);
232 return;
233 };
234 let mut chunk = [0u8; 8192];
235 loop {
236 match pipe.read(&mut chunk) {
237 Ok(0) => return,
238 Ok(n) => {
239 let mut bytes = capture
240 .bytes
241 .lock()
242 .unwrap_or_else(std::sync::PoisonError::into_inner);
243 if bytes.len() + n > limit {
244 let remaining = limit.saturating_sub(bytes.len());
245 bytes.extend_from_slice(&chunk[..remaining]);
246 capture.truncated.store(true, Ordering::Relaxed);
247 any_truncated.store(true, Ordering::Relaxed);
248 return;
249 }
250 bytes.extend_from_slice(&chunk[..n]);
251 }
252 Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
253 Err(_) => {
254 read_failed.store(true, Ordering::Relaxed);
255 return;
256 }
257 }
258 }
259}
260
261#[cfg(unix)]
272fn owned_group(child: &mut Child, kill_group: bool) -> Option<i32> {
273 if !kill_group {
274 return None;
275 }
276 let pid = child.id() as libc::pid_t;
277 let group = unsafe { libc::getpgid(pid) };
280 if group == pid {
281 return Some(pid);
282 }
283 (group == -1 && matches!(exited_unreaped(child), Ok(true))).then_some(pid)
284}
285
286#[cfg(not(unix))]
287fn owned_group(_: &mut Child, _: bool) -> Option<i32> {
288 None
289}
290
291#[cfg(unix)]
293pub(crate) fn exited_unreaped(child: &mut Child) -> std::io::Result<bool> {
294 let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
296 let result = unsafe {
298 libc::waitid(
299 libc::P_PID,
300 child.id() as libc::id_t,
301 &raw mut info,
302 libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
303 )
304 };
305 if result == -1 {
306 let error = std::io::Error::last_os_error();
307 return if error.kind() == std::io::ErrorKind::Interrupted {
308 Ok(false)
309 } else {
310 Err(error)
311 };
312 }
313 Ok(unsafe { info.si_pid() } != 0)
315}
316
317#[cfg(not(unix))]
318fn exited_unreaped(child: &mut Child) -> std::io::Result<bool> {
319 child.try_wait().map(|status| status.is_some())
320}
321
322fn readers_finish_within<A, B>(
323 first: &std::thread::JoinHandle<A>,
324 second: &std::thread::JoinHandle<B>,
325 budget: std::time::Duration,
326) -> bool {
327 let deadline = std::time::Instant::now() + budget;
328 loop {
329 if first.is_finished() && second.is_finished() {
330 return true;
331 }
332 if std::time::Instant::now() >= deadline {
333 return false;
334 }
335 std::thread::sleep(std::time::Duration::from_millis(5));
336 }
337}
338
339fn kill_child(child: &mut Child, own_group: Option<i32>) -> bool {
349 #[cfg(unix)]
350 if let Some(group) = own_group {
351 unsafe { libc::killpg(group, libc::SIGKILL) };
353 }
354 #[cfg(not(unix))]
355 let _ = own_group;
356 #[cfg(windows)]
357 let tree_killed = {
358 const TREE_KILL_BUDGET: std::time::Duration = std::time::Duration::from_secs(5);
359 match std::process::Command::new("taskkill")
360 .args(["/F", "/T", "/PID", &child.id().to_string()])
361 .stdin(std::process::Stdio::null())
362 .stdout(std::process::Stdio::null())
363 .stderr(std::process::Stdio::null())
364 .spawn()
365 {
366 Ok(mut tree_kill) => {
367 if let Some(status) = reap_within(&mut tree_kill, TREE_KILL_BUDGET) {
368 status.success()
369 } else {
370 let _ = tree_kill.kill();
371 let _ = reap_within(&mut tree_kill, TREE_KILL_BUDGET);
372 false
373 }
374 }
375 Err(_) => false,
376 }
377 };
378 #[cfg(not(windows))]
379 let tree_killed = true;
380 let _ = child.kill();
381 tree_killed
382}
383
384pub fn reap_within(
386 child: &mut Child,
387 budget: std::time::Duration,
388) -> Option<std::process::ExitStatus> {
389 let deadline = std::time::Instant::now() + budget;
390 loop {
391 match child.try_wait() {
392 Ok(Some(status)) => return Some(status),
393 Ok(None) if std::time::Instant::now() < deadline => {
394 std::thread::sleep(std::time::Duration::from_millis(5));
395 }
396 Ok(None) | Err(_) => return None,
397 }
398 }
399}
400
401#[cfg(unix)]
409fn synthetic_failure_status() -> std::process::ExitStatus {
410 use std::os::unix::process::ExitStatusExt;
411 std::process::ExitStatus::from_raw(1 << 8)
415}
416
417#[cfg(not(unix))]
418fn synthetic_failure_status() -> std::process::ExitStatus {
419 use std::os::windows::process::ExitStatusExt;
420 std::process::ExitStatus::from_raw(1)
421}
422
423#[cfg(test)]
424mod tests {
425 const OUTPUT_HELPER_ENV: &str = "LEAN_CTX_TEST_OUTPUT_HELPER";
426 const OUTPUT_HELPER_TEST: &str = "shell::exec::process::tests::wait_with_limits_output_helper";
427
428 #[test]
429 #[ignore = "subprocess helper for wait_with_limits tests"]
430 fn wait_with_limits_output_helper() {
431 use std::io::Write as _;
432
433 match std::env::var(OUTPUT_HELPER_ENV).as_deref() {
434 Ok("stdout-small") => {
435 std::io::stdout().write_all(b"hello\n").unwrap();
436 }
437 Ok("stdout-forever") => {
443 let chunk = [b'a'; 8192];
444 let mut stream = std::io::stdout().lock();
445 while stream.write_all(&chunk).is_ok() {}
446 std::thread::sleep(std::time::Duration::from_mins(1));
447 }
448 Ok("stderr-forever") => {
449 let chunk = [b'a'; 8192];
450 let mut stream = std::io::stderr().lock();
451 while stream.write_all(&chunk).is_ok() {}
452 std::thread::sleep(std::time::Duration::from_mins(1));
453 }
454 Ok("sleep") => {
455 std::thread::sleep(std::time::Duration::from_mins(1));
456 }
457 #[cfg(unix)]
458 Ok("guardian") => guardian(),
459 Ok(other) => panic!("unknown output helper mode: {other}"),
460 Err(error) => panic!("missing {OUTPUT_HELPER_ENV}: {error}"),
461 }
462 }
463
464 fn spawn_output_helper(mode: &str) -> std::process::Child {
465 std::process::Command::new(std::env::current_exe().unwrap())
466 .args([OUTPUT_HELPER_TEST, "--exact", "--ignored", "--nocapture"])
467 .env(OUTPUT_HELPER_ENV, mode)
468 .stdout(std::process::Stdio::piped())
469 .stderr(std::process::Stdio::piped())
470 .spawn()
471 .unwrap()
472 }
473
474 #[test]
475 fn wait_with_limits_captures_output() {
476 let child = spawn_output_helper("stdout-small");
477
478 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(5), false);
479 let stdout = String::from_utf8_lossy(&output.stdout);
480 assert!(
481 stdout.contains("hello"),
482 "expected 'hello' in output: {stdout}"
483 );
484 assert!(output.status.success());
485 }
486
487 #[test]
488 fn wait_with_limits_truncates_large_output() {
489 let child = spawn_output_helper("stdout-forever");
490
491 let output =
492 super::wait_with_limits(child, 1024, std::time::Duration::from_secs(10), false);
493 let stdout = String::from_utf8_lossy(&output.stdout);
494 assert!(
495 stdout.contains("[lean-ctx: output truncated"),
496 "expected truncation notice, got len={}: ...{}",
497 stdout.len(),
498 &stdout[stdout.len().saturating_sub(80)..]
499 );
500 }
501
502 #[test]
503 fn synthetic_failure_status_is_a_failure_without_spawning_anything() {
504 let status = super::synthetic_failure_status();
505 assert!(!status.success());
506 #[cfg(unix)]
507 {
508 use std::os::unix::process::ExitStatusExt;
509 assert_eq!(status.code(), Some(1));
510 assert_eq!(status.signal(), None);
511 }
512 }
513
514 #[test]
515 fn wait_with_limits_truncates_large_stderr() {
516 let child = spawn_output_helper("stderr-forever");
517
518 let output = super::wait_with_limits(
519 child,
520 1024 * 1024,
521 std::time::Duration::from_secs(10),
522 false,
523 );
524 let stderr = String::from_utf8_lossy(&output.stderr);
525 assert!(
526 stderr.contains("[lean-ctx: stderr truncated"),
527 "expected stderr truncation notice, got len={}: ...{}",
528 stderr.len(),
529 &stderr[stderr.len().saturating_sub(80)..]
530 );
531 }
532
533 #[test]
534 fn wait_with_limits_kills_promptly_on_truncation() {
535 let child = spawn_output_helper("stdout-forever");
536
537 let start = std::time::Instant::now();
538 let output =
539 super::wait_with_limits(child, 4096, std::time::Duration::from_secs(20), false);
540 let elapsed = start.elapsed();
541
542 assert!(
543 elapsed < std::time::Duration::from_secs(3),
544 "truncation should kill promptly, took {elapsed:?} (timeout was 20s)"
545 );
546 let stdout = String::from_utf8_lossy(&output.stdout);
547 assert!(stdout.contains("[lean-ctx: output truncated"));
548 }
549
550 #[test]
551 fn wait_with_limits_timeout_kills_process() {
552 let child = spawn_output_helper("sleep");
553
554 let start = std::time::Instant::now();
555 let output =
556 super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200), false);
557 let elapsed = start.elapsed();
558
559 assert!(
560 elapsed < std::time::Duration::from_secs(3),
561 "timeout should kill quickly, took {elapsed:?}"
562 );
563 let stdout = String::from_utf8_lossy(&output.stdout);
564 assert!(stdout.contains("[lean-ctx: output truncated"));
565 }
566
567 #[cfg(unix)]
573 #[test]
574 fn wait_with_limits_group_kill_reaps_grandchildren() {
575 use std::os::unix::process::CommandExt as _;
576 let mut cmd = std::process::Command::new("sh");
580 cmd.args(["-c", "sleep 30 & sleep 30"])
581 .stdin(std::process::Stdio::null())
582 .stdout(std::process::Stdio::piped())
583 .stderr(std::process::Stdio::piped());
584 cmd.process_group(0);
585 let child = cmd.spawn().unwrap();
586 let pgid = child.id() as libc::pid_t;
587
588 let start = std::time::Instant::now();
589 let _ = super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200), true);
590 let elapsed = start.elapsed();
591
592 assert!(
593 elapsed < std::time::Duration::from_secs(5),
594 "group kill must unblock the reader threads, took {elapsed:?}"
595 );
596 let mut group_gone = false;
599 for _ in 0..50 {
600 if unsafe { libc::killpg(pgid, 0) } == -1 {
602 group_gone = true;
603 break;
604 }
605 std::thread::sleep(std::time::Duration::from_millis(20));
606 }
607 assert!(group_gone, "process group {pgid} must be fully reaped");
608 }
609
610 #[cfg(unix)]
611 fn alive(pid: u32) -> bool {
612 unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
614 }
615
616 #[cfg(unix)]
620 fn guardian() {
621 use std::os::unix::process::CommandExt as _;
622 use std::process::{Command, Stdio};
623 let piped = |script: &str| {
624 let mut command = Command::new("sh");
625 command
626 .args(["-c", script])
627 .stdin(Stdio::null())
628 .stdout(Stdio::piped())
629 .stderr(Stdio::piped());
630 command
631 };
632 let mut sentinel = Command::new("sleep")
633 .arg("60")
634 .stdin(Stdio::null())
635 .stdout(Stdio::null())
636 .stderr(Stdio::null())
637 .spawn()
638 .unwrap();
639 let own = unsafe { libc::getpgrp() };
641 assert_eq!(own, std::process::id() as libc::pid_t, "guardian leads");
642 struct EndOwnGroup(libc::pid_t);
646 impl Drop for EndOwnGroup {
647 fn drop(&mut self) {
648 use std::io::Write as _;
649 let _ = std::io::stdout().flush();
650 unsafe { libc::killpg(self.0, libc::SIGKILL) };
652 }
653 }
654 let _end = EndOwnGroup(own);
655 assert_eq!(unsafe { libc::getpgid(sentinel.id() as libc::pid_t) }, own);
657
658 let child = piped("sleep 60 & sleep 60").spawn().unwrap();
662 let start = std::time::Instant::now();
663 let captured = super::wait_with_limits_captured(
664 child,
665 1024,
666 std::time::Duration::from_millis(200),
667 true,
668 );
669 assert!(start.elapsed() < std::time::Duration::from_secs(8));
670 assert!(captured.completeness.timed_out);
671 assert!(captured.completeness.reader_incomplete);
672 assert!(
673 alive(sentinel.id()),
674 "inherited group must not be signalled"
675 );
676
677 let child = piped("sleep 60 & yes").spawn().unwrap();
679 let captured =
680 super::wait_with_limits_captured(child, 1024, std::time::Duration::from_secs(30), true);
681 assert!(captured.completeness.truncated);
682 assert!(
683 alive(sentinel.id()),
684 "inherited group must not be signalled"
685 );
686
687 let mut command = piped("sleep 60 & echo started");
691 command.process_group(0);
692 let child = command.spawn().unwrap();
693 let group = child.id() as libc::pid_t;
694 let start = std::time::Instant::now();
695 let captured =
696 super::wait_with_limits_captured(child, 1024, std::time::Duration::from_secs(30), true);
697 assert!(
698 start.elapsed() < std::time::Duration::from_secs(5),
699 "leader gone, pipe holder alive: must not wait for it"
700 );
701 assert!(!captured.completeness.timed_out);
702 assert!(captured.completeness.reader_incomplete);
703 assert!(String::from_utf8_lossy(&captured.output.stdout).contains("started"));
704 let mut group_gone = false;
705 for _ in 0..100 {
706 if unsafe { libc::killpg(group, 0) } == -1 {
708 group_gone = true;
709 break;
710 }
711 std::thread::sleep(std::time::Duration::from_millis(20));
712 }
713 assert!(group_gone, "the call's own group must be gone");
714 assert!(
715 alive(sentinel.id()),
716 "a foreign group must not be signalled"
717 );
718
719 let child = piped("echo written; echo problem >&2; sleep 60 &")
725 .spawn()
726 .unwrap();
727 let start = std::time::Instant::now();
728 let captured =
729 super::wait_with_limits_captured(child, 1024, std::time::Duration::from_secs(30), true);
730 assert!(start.elapsed() < std::time::Duration::from_secs(8));
731 assert!(captured.output.status.success());
732 assert!(captured.completeness.reader_incomplete);
733 assert!(!captured.completeness.timed_out && !captured.completeness.truncated);
734 assert_eq!(
735 String::from_utf8_lossy(&captured.output.stdout),
736 "written\n\n[lean-ctx: output not captured: a process outside this call kept it open]\n"
737 );
738 assert_eq!(captured.output.stderr, b"problem\n");
739 assert!(
740 alive(sentinel.id()),
741 "inherited group must not be signalled"
742 );
743
744 let child = piped("echo written; echo problem >&2").spawn().unwrap();
747 let captured =
748 super::wait_with_limits_captured(child, 1024, std::time::Duration::from_secs(30), true);
749 assert_eq!(captured.output.stdout, b"written\n");
750 assert_eq!(captured.output.stderr, b"problem\n");
751 assert!(captured.completeness.is_complete());
752
753 sentinel.kill().unwrap();
755 sentinel.wait().unwrap();
756 println!("GUARDIAN-COMPLETE");
757 }
758
759 #[cfg(unix)]
764 #[test]
765 fn limits_signal_only_a_group_the_call_owns() {
766 use std::os::unix::process::CommandExt as _;
767 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
768 command
769 .args([OUTPUT_HELPER_TEST, "--exact", "--ignored", "--nocapture"])
770 .env(OUTPUT_HELPER_ENV, "guardian")
771 .stdin(std::process::Stdio::null())
772 .stdout(std::process::Stdio::piped())
773 .stderr(std::process::Stdio::piped());
774 command.process_group(0);
775 let child = command.spawn().unwrap();
776 let captured = super::wait_with_limits_captured(
779 child,
780 64 * 1024,
781 std::time::Duration::from_mins(1),
782 true,
783 );
784 let stdout = String::from_utf8_lossy(&captured.output.stdout);
785 let stderr = String::from_utf8_lossy(&captured.output.stderr);
786 assert!(
787 stdout.contains("GUARDIAN-COMPLETE"),
788 "guardian status {:?}\n{stdout}\n{stderr}",
789 captured.output.status
790 );
791 }
792
793 #[cfg(unix)]
797 #[test]
798 fn stdin_relay_forwards_piped_data() {
799 use std::io::Write;
800 use std::os::unix::process::CommandExt as _;
801
802 let mut cmd = std::process::Command::new("cat");
803 cmd.stdin(std::process::Stdio::piped())
804 .stdout(std::process::Stdio::piped())
805 .stderr(std::process::Stdio::piped());
806 cmd.process_group(0);
807 let mut child = cmd.spawn().expect("failed to spawn cat");
808
809 let mut child_stdin = child.stdin.take().unwrap();
810 std::thread::spawn(move || {
811 child_stdin.write_all(b"hello from pipe\n").unwrap();
812 drop(child_stdin);
813 });
814
815 let output = child.wait_with_output().expect("wait failed");
816 let stdout = String::from_utf8_lossy(&output.stdout);
817 assert!(
818 stdout.contains("hello from pipe"),
819 "#806: piped stdin must reach the child, got: {stdout}"
820 );
821 }
822
823 #[cfg(unix)]
826 #[test]
827 fn stdin_relay_no_data_does_not_hang() {
828 let start = std::time::Instant::now();
829 let mut cmd = std::process::Command::new("sh");
830 cmd.args(["-c", "echo ok"])
831 .stdin(std::process::Stdio::piped())
832 .stdout(std::process::Stdio::piped())
833 .stderr(std::process::Stdio::piped());
834 use std::os::unix::process::CommandExt as _;
835 cmd.process_group(0);
836 let mut child = cmd.spawn().unwrap();
837 drop(child.stdin.take());
839 let output = child.wait_with_output().unwrap();
840 let elapsed = start.elapsed();
841 assert!(
842 elapsed < std::time::Duration::from_secs(5),
843 "must not hang when stdin is closed immediately, took {elapsed:?}"
844 );
845 let stdout = String::from_utf8_lossy(&output.stdout);
846 assert!(stdout.contains("ok"), "command output missing: {stdout}");
847 }
848}