Skip to main content

lean_ctx/shell/exec/
process.rs

1use std::io::Read;
2use std::process::{Child, Output};
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5
6/// Wait for a child process with output-size and time limits.
7/// Kills the process if either limit is exceeded, returning what was
8/// captured so far. Prevents unbounded memory growth on commands that
9/// produce massive output (e.g. `rg -i "pattern"` over a large tree).
10///
11/// `kill_group` (Unix): the child was spawned into its own process group
12/// (`process_group(0)`), so a timeout kill signals the whole group. Killing
13/// only the direct child (a shell) leaves orphaned grandchildren holding the
14/// stdout/stderr pipe write ends — the reader threads then never see EOF and
15/// the join below blocks forever, wedging the caller *despite* the timeout
16/// having fired (GH #720: an orphaned `rg` kept a Cursor shell session dead
17/// for hours).
18pub struct LimitedProcessOutput {
19    pub output: Output,
20    pub completeness: crate::core::context_admission::OutputCompleteness,
21}
22
23/// The output alone, for tests. Product callers take the completeness with
24/// it (`wait_with_limits_captured`): an incomplete capture is a decision input.
25#[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    /// How long the readers may take to see end-of-file after the child
43    /// exited before its own group is signalled.
44    const DRAIN_GRACE: std::time::Duration = std::time::Duration::from_millis(250);
45    /// How long the readers may take to finish once the child is gone.
46    const READER_BUDGET: std::time::Duration = std::time::Duration::from_secs(2);
47    /// How long an exited or killed child may take to become reapable.
48    const REAP_BUDGET: std::time::Duration = std::time::Duration::from_secs(2);
49
50    // Decided once, before anything is reaped: only a group the child leads
51    // itself is ever signalled. A child that inherited its group (the
52    // caller's, the host's) is killed alone.
53    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    // Each reader appends what it reads to a buffer this thread can read at
62    // any time: a reader that is abandoned below still hands over everything
63    // it had read by then.
64    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            // Not reaped yet: the leader still pins its group id.
99            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                // The leader has exited but is deliberately not reaped here:
106                // as a zombie it keeps its pid, and with it the id of its own
107                // group, from being reused while descendants that still hold
108                // the pipes are signalled.
109                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    // The child has exited or was killed above; reaping it is bounded all the
127    // same. A child that cannot be reaped (a kill that did not take effect)
128    // must not hold this call: it is reported as an incomplete capture with a
129    // failure status instead.
130    let reaped = reap_within(&mut child, REAP_BUDGET);
131
132    // A descendant outside any group this call owns may keep a pipe open for
133    // as long as it likes. The readers are given a budget; a reader that does
134    // not finish is left behind — never joined, never waited for again — and
135    // the capture is reported as incomplete. What it had read up to this
136    // moment is still returned: on each stream a prefix of what was written
137    // to it, within that stream's limit.
138    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/// What one reader has read of its stream so far, shared with the waiting
197/// thread: the bytes (never more than the stream's limit) and whether the
198/// limit cut them short.
199#[derive(Default)]
200struct StreamCapture {
201    bytes: std::sync::Mutex<Vec<u8>>,
202    truncated: AtomicBool,
203}
204
205impl StreamCapture {
206    /// The bytes read up to now and whether the limit was reached. A reader
207    /// that is still running keeps its lock only for one append, so this
208    /// never waits on a pipe.
209    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
220/// Reads `pipe` to its end or to `limit` bytes into `capture`, chunk by
221/// chunk, so that the capture holds at every moment a prefix of the stream.
222/// `any_truncated` tells the waiting thread to end the child.
223fn 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/// The process group this call may signal: the child's own, and only when the
262/// child leads it (it was spawned with `process_group(0)`). Read while the
263/// child is certainly not reaped, so the answer is about this child.
264///
265/// A child that has already exited cannot be asked on every system (macOS
266/// reports no group for a zombie). Its unreaped pid still names it alone, and
267/// a group with that number is either the one it led or does not exist: no
268/// other process can have created a group under a pid that is still taken.
269/// So the number is safe to signal, and a call whose leader finished before
270/// the wait began still ends what it left behind.
271#[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    // SAFETY: getpgid only reads kernel process metadata; the pid is ours and
278    // unreaped (alive or zombie), so it cannot name another process.
279    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/// Whether the child has exited, WITHOUT reaping it on Unix (`WNOWAIT`).
292#[cfg(unix)]
293pub(crate) fn exited_unreaped(child: &mut Child) -> std::io::Result<bool> {
294    // SAFETY: an all-zero siginfo_t is a valid out-parameter for waitid.
295    let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
296    // SAFETY: waitid on our own unreaped child; WNOWAIT leaves it waitable.
297    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    // SAFETY: waitid succeeded; with WNOHANG si_pid stays 0 while running.
314    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
339/// Kill the child — and, when it leads its own process group, every
340/// descendant in that group (GH #720). SIGKILL to the group ends a shell's
341/// grandchildren so the captured pipes actually close. A group the child
342/// merely inherited is never signalled: that would end the caller and its
343/// unrelated siblings.
344///
345/// Returns `false` when the platform's tree kill did not confirm (Windows:
346/// `taskkill` failed, or did not finish within its own budget). The direct
347/// child is killed in every case.
348fn kill_child(child: &mut Child, own_group: Option<i32>) -> bool {
349    #[cfg(unix)]
350    if let Some(group) = own_group {
351        // SAFETY: plain syscall on a group id pinned by our unreaped child.
352        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
384/// Reap a child that has exited or was just killed, within `budget`.
385pub 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/// A synthetic failed `ExitStatus`, used only when `Child::wait()` itself
402/// errors (e.g. the process was already reaped by another waiter) and there
403/// is no real status to report. The previous fallback shelled out to
404/// `Command::new("false").status()` to manufacture one, which panicked via
405/// `.expect()` wherever no `false` binary exists on `PATH` — Windows, and
406/// minimal/scratch containers. `ExitStatusExt::from_raw` builds the status
407/// value directly, with no subprocess involved, so it can't fail.
408#[cfg(unix)]
409fn synthetic_failure_status() -> std::process::ExitStatus {
410    use std::os::unix::process::ExitStatusExt;
411    // Raw wait(2) status encoding: low 7 bits 0 signals a normal exit
412    // (`WIFEXITED`), the next byte up is the exit code (`WEXITSTATUS`) — so
413    // `1 << 8` decodes as "exited normally with code 1".
414    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            // A producer that never ends on its own. Once the reader drops the
438            // pipe, keep the process alive until the parent kills it: exiting
439            // here would race that kill against the instrumented binary's
440            // at-exit coverage profile write and leave a truncated .profraw
441            // that breaks the Coverage job's report.
442            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    /// GH #720: killing only the direct child (a shell) on timeout leaves its
568    /// grandchildren alive holding the stdout pipe — the reader threads never
569    /// see EOF and `wait_with_limits` blocks forever even though the timeout
570    /// fired. With the child in its own process group and a group kill, the
571    /// whole tree dies and the call returns promptly.
572    #[cfg(unix)]
573    #[test]
574    fn wait_with_limits_group_kill_reaps_grandchildren() {
575        use std::os::unix::process::CommandExt as _;
576        // The shell spawns a grandchild that inherits stdout and sleeps far
577        // beyond the timeout; the shell itself also sleeps so the timeout path
578        // (not natural exit) is exercised.
579        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        // The whole group must be gone (ESRCH), not just the direct child.
597        // A brief grace period lets the kernel finish reaping.
598        let mut group_gone = false;
599        for _ in 0..50 {
600            // SAFETY: signal 0 only probes for existence.
601            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        // SAFETY: signal 0 only probes for existence.
613        unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
614    }
615
616    /// Body of the guardian process (its own process group, created by the
617    /// test below). Everything that could be signalled by mistake lives in
618    /// this group: the guardian itself and a sentinel that belongs to no call.
619    #[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        // SAFETY: getpgrp has no preconditions.
640        let own = unsafe { libc::getpgrp() };
641        assert_eq!(own, std::process::id() as libc::pid_t, "guardian leads");
642        // Whatever happens below, the fixture ends its own group — itself
643        // included — while it still leads it, so nothing outlives the test
644        // and no group id is signalled after it could have been reused.
645        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                // SAFETY: this process leads the group and is alive.
651                unsafe { libc::killpg(self.0, libc::SIGKILL) };
652            }
653        }
654        let _end = EndOwnGroup(own);
655        // SAFETY: reads metadata of our live child.
656        assert_eq!(unsafe { libc::getpgid(sentinel.id() as libc::pid_t) }, own);
657
658        // 1. A call that INHERITED this group runs into its timeout while a
659        // grandchild holds its pipes. Only the call's direct child is killed;
660        // sentinel and guardian live; the capture is bounded and incomplete.
661        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        // 2. The same with the output limit instead of the timeout.
678        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        // 3. A call with its OWN group whose leader exits at once while its
688        // child keeps the pipes: the group is found although the leader is
689        // gone, the whole call tree ends, nothing else does.
690        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            // SAFETY: signal 0 only probes for existence.
707            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        // 4. A command writes a line and exits, and leaves a process outside
720        // any group this call owns holding its pipes (the call inherited
721        // this group, so nothing is signalled). The readers are abandoned
722        // after their budget; what they had read is returned, in front of
723        // the notice, and the capture is flagged incomplete.
724        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        // 5. Control: a command that leaves nothing is returned as it always
745        // was — its bytes and nothing else, complete.
746        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        // The sentinel survived every case; end and reap it ourselves.
754        sentinel.kill().unwrap();
755        sentinel.wait().unwrap();
756        println!("GUARDIAN-COMPLETE");
757    }
758
759    /// Q13: a timeout or output limit never signals a process group the call
760    /// did not create, and a leader that exited before its pipe-holding child
761    /// does not hide the call's own group. Runs under a guardian in its own
762    /// group, so a regression can only kill the fixture, never the test run.
763    #[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        // The guardian ends its own group as its last act, so its exit status
777        // is a kill signal by design; the marker line is the verdict.
778        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    /// #806: piped stdin must be forwarded to the child via Stdio::piped()
794    /// and relayed, not nulled. Tests the relay pattern: write data to child
795    /// stdin, close it (EOF), child reads and exits.
796    #[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    /// #806: commands that don't read stdin must still work normally
824    /// when no data is piped (relay thread sees immediate EOF from parent).
825    #[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        // Close stdin immediately (simulates relay with empty parent pipe)
838        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}