Skip to main content

lean_ctx/core/
process_capture.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Captured execution with bounded child waiting and process-tree cleanup.
4//!
5//! Uses private temporary files instead of pipes, so inherited output handles
6//! cannot keep reader threads alive. Unix cleanup covers the dedicated process
7//! group; deliberately detached descendants are outside that cleanup boundary.
8//! Windows children are assigned to a kill-on-close Job Object before resuming.
9
10#[cfg(windows)]
11mod secret_windows;
12#[cfg(windows)]
13pub(crate) use secret_windows::read_secret_stdout;
14
15pub struct CapturedOutput {
16    pub output: std::process::Output,
17    pub timed_out: bool,
18    pub(crate) cancelled: bool,
19}
20
21/// Bound captured bytes without allowing inherited handles to extend waiting.
22/// Length checks stop oversized writers; temporary-file growth between polls
23/// is not an operating-system disk quota. `None` preserves unlimited waiting.
24pub fn run_with_output_limits(
25    command: &mut std::process::Command,
26    timeout: Option<std::time::Duration>,
27    stdout_limit: usize,
28    stderr_limit: usize,
29) -> Result<CapturedOutput, String> {
30    wait_for_capture_with_limits(
31        spawn_capture(command)?,
32        timeout,
33        Some((stdout_limit as u64, stderr_limit as u64)),
34        &mut || false,
35    )
36}
37
38/// Use the same bounded capture and process-tree cleanup for cancellable work.
39pub(crate) fn run_with_output_limits_cancellable(
40    command: &mut std::process::Command,
41    timeout: std::time::Duration,
42    stdout_limit: usize,
43    stderr_limit: usize,
44    mut cancellation_requested: impl FnMut() -> bool,
45) -> Result<CapturedOutput, String> {
46    wait_for_capture_with_limits(
47        spawn_capture(command)?,
48        Some(timeout),
49        Some((stdout_limit as u64, stderr_limit as u64)),
50        &mut cancellation_requested,
51    )
52}
53
54pub(crate) struct CapturedChild {
55    child: std::process::Child,
56    stdout: std::fs::File,
57    stderr: std::fs::File,
58    bin: String,
59    cleanup: ProcessCleanup,
60    /// Q13: ends the child's process group when this capture is over or this
61    /// process is gone. `None`: this process is not the `lean-ctx` binary.
62    #[cfg(unix)]
63    guard: Option<crate::shell::containment::CallGuard>,
64}
65
66impl CapturedChild {
67    pub(crate) fn program(&self) -> &str {
68        &self.bin
69    }
70}
71
72#[derive(Default)]
73struct ProcessCleanup {
74    #[cfg(windows)]
75    job: Option<ProcessJob>,
76}
77
78#[cfg(windows)]
79struct ProcessJob(windows_sys::Win32::Foundation::HANDLE);
80
81#[cfg(windows)]
82impl Drop for ProcessJob {
83    fn drop(&mut self) {
84        // SAFETY: this instance exclusively owns the CreateJobObjectW handle.
85        unsafe {
86            windows_sys::Win32::Foundation::CloseHandle(self.0);
87        }
88    }
89}
90
91pub(crate) fn spawn_capture(command: &mut std::process::Command) -> Result<CapturedChild, String> {
92    use std::process::Stdio;
93
94    let program = command.get_program().to_string_lossy().into_owned();
95    let bin = program.as_str();
96    let stdout = tempfile::tempfile()
97        .map_err(|error| format!("failed to create command '{bin}' stdout capture: {error}"))?;
98    let stderr = tempfile::tempfile()
99        .map_err(|error| format!("failed to create command '{bin}' stderr capture: {error}"))?;
100    command
101        .stdout(Stdio::from(stdout.try_clone().map_err(|error| {
102            format!("failed to clone command '{bin}' stdout capture: {error}")
103        })?))
104        .stderr(Stdio::from(stderr.try_clone().map_err(|error| {
105            format!("failed to clone command '{bin}' stderr capture: {error}")
106        })?));
107    #[cfg(unix)]
108    {
109        use std::os::unix::process::CommandExt;
110        command.process_group(0);
111    }
112    #[cfg(windows)]
113    {
114        use std::os::windows::process::CommandExt;
115        command.creation_flags(windows_sys::Win32::System::Threading::CREATE_SUSPENDED);
116    }
117    let spawn_error = |e: std::io::Error| {
118        if e.kind() == std::io::ErrorKind::NotFound {
119            format!("command '{bin}' not found in PATH")
120        } else {
121            format!("failed to run '{bin}': {e}")
122        }
123    };
124    // Q13: no child runs without its guard. The program is executed only
125    // once the guard is ready, so a caller that dies in between leaves
126    // nothing running; if no guard can be attached the program was never
127    // executed and the command is refused. `None` for the guard: this
128    // process is not the `lean-ctx` binary.
129    #[cfg(unix)]
130    let (mut child, guard) = {
131        use crate::shell::containment::Refusal;
132        match crate::shell::containment::start_guarded_child(command) {
133            Ok(started) => started,
134            Err(Refusal::NotStarted(error)) => return Err(spawn_error(error)),
135            Err(Refusal::NotLeader) => {
136                return Err(format!(
137                    "command '{bin}' could not be contained: the process does not lead a group of its own"
138                ));
139            }
140            Err(Refusal::NoGuard(reason)) => {
141                return Err(format!("command '{bin}' could not be contained: {reason}"));
142            }
143        }
144    };
145    #[cfg(not(unix))]
146    let mut child = command.spawn().map_err(spawn_error)?;
147
148    // A guard that goes out of scope on this path ends the child's group.
149    let cleanup = match process_cleanup(&child, bin) {
150        Ok(cleanup) => cleanup,
151        Err(error) => {
152            let _ = child.kill();
153            let _ = child.wait();
154            return Err(error);
155        }
156    };
157
158    Ok(CapturedChild {
159        child,
160        stdout,
161        stderr,
162        bin: bin.to_owned(),
163        cleanup,
164        #[cfg(unix)]
165        guard,
166    })
167}
168
169/// Whether the child has exited. On Unix it is NOT reaped by this: as a
170/// zombie it keeps its pid — the id of its own process group — from being
171/// reused while that group is still to be signalled.
172#[cfg(unix)]
173fn child_exited(child: &mut std::process::Child) -> std::io::Result<bool> {
174    crate::shell::exec::exited_unreaped(child)
175}
176
177#[cfg(not(unix))]
178fn child_exited(child: &mut std::process::Child) -> std::io::Result<bool> {
179    child.try_wait().map(|status| status.is_some())
180}
181
182#[cfg(windows)]
183fn process_cleanup(child: &std::process::Child, bin: &str) -> Result<ProcessCleanup, String> {
184    use std::os::windows::io::AsRawHandle;
185    use windows_sys::Win32::System::JobObjects::{
186        AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
187        JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
188        SetInformationJobObject,
189    };
190
191    // SAFETY: null attributes/name create a private job owned by ProcessJob.
192    let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
193    if handle.is_null() {
194        return Err(format!(
195            "failed to create child job for '{bin}': {}",
196            std::io::Error::last_os_error()
197        ));
198    }
199    let job = ProcessJob(handle);
200    let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
201    limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
202    // SAFETY: limits has the exact structure and size required by this info class.
203    let configured = unsafe {
204        SetInformationJobObject(
205            job.0,
206            JobObjectExtendedLimitInformation,
207            std::ptr::from_ref(&limits).cast(),
208            std::mem::size_of_val(&limits) as u32,
209        )
210    };
211    if configured == 0 {
212        return Err(format!(
213            "failed to configure child job for '{bin}': {}",
214            std::io::Error::last_os_error()
215        ));
216    }
217    // SAFETY: Child owns a live process handle for the child process.
218    let assigned = unsafe { AssignProcessToJobObject(job.0, child.as_raw_handle().cast()) };
219    if assigned == 0 {
220        return Err(format!(
221            "failed to assign command '{bin}' to its job: {}",
222            std::io::Error::last_os_error()
223        ));
224    }
225    resume_process_threads(child.id(), bin)?;
226    Ok(ProcessCleanup { job: Some(job) })
227}
228
229#[cfg(windows)]
230fn resume_process_threads(process_id: u32, bin: &str) -> Result<(), String> {
231    use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
232    use windows_sys::Win32::System::Diagnostics::ToolHelp::{
233        CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next,
234    };
235    use windows_sys::Win32::System::Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME};
236
237    // SAFETY: snapshot handle is closed on every return path below.
238    let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
239    if snapshot == INVALID_HANDLE_VALUE {
240        return Err(format!(
241            "failed to inspect suspended command '{bin}' threads: {}",
242            std::io::Error::last_os_error()
243        ));
244    }
245    let mut entry = THREADENTRY32 {
246        dwSize: std::mem::size_of::<THREADENTRY32>() as u32,
247        ..THREADENTRY32::default()
248    };
249    // SAFETY: snapshot and entry are valid for ToolHelp thread enumeration.
250    let mut has_entry = unsafe { Thread32First(snapshot, &raw mut entry) } != 0;
251    let mut resumed = false;
252    let result = loop {
253        if !has_entry {
254            break if resumed {
255                Ok(())
256            } else {
257                Err(format!(
258                    "suspended command '{bin}' exposed no resumable thread"
259                ))
260            };
261        }
262        if entry.th32OwnerProcessID == process_id {
263            // SAFETY: entry names a thread owned by the suspended child process.
264            let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
265            if thread.is_null() {
266                break Err(format!(
267                    "failed to open suspended command '{bin}' thread: {}",
268                    std::io::Error::last_os_error()
269                ));
270            }
271            // SAFETY: thread is a live handle with THREAD_SUSPEND_RESUME access.
272            let resume_result = unsafe { ResumeThread(thread) };
273            // SAFETY: this scope owns the OpenThread handle.
274            unsafe { CloseHandle(thread) };
275            if resume_result == u32::MAX {
276                break Err(format!(
277                    "failed to resume command '{bin}' thread: {}",
278                    std::io::Error::last_os_error()
279                ));
280            }
281            resumed = true;
282        }
283        // SAFETY: snapshot and entry remain valid until CloseHandle below.
284        has_entry = unsafe { Thread32Next(snapshot, &raw mut entry) } != 0;
285    };
286    // SAFETY: this scope owns the ToolHelp snapshot handle.
287    unsafe { CloseHandle(snapshot) };
288    result
289}
290
291#[cfg(not(windows))]
292fn process_cleanup(_: &std::process::Child, _: &str) -> Result<ProcessCleanup, String> {
293    Ok(ProcessCleanup::default())
294}
295
296pub(crate) fn wait_for_capture(
297    child: CapturedChild,
298    timeout: std::time::Duration,
299) -> Result<CapturedOutput, String> {
300    wait_for_capture_with_limits(child, Some(timeout), None, &mut || false)
301}
302
303fn wait_for_capture_with_limits(
304    CapturedChild {
305        mut child,
306        mut stdout,
307        mut stderr,
308        bin,
309        mut cleanup,
310        #[cfg(unix)]
311        guard,
312    }: CapturedChild,
313    timeout: Option<std::time::Duration>,
314    limits: Option<(u64, u64)>,
315    cancellation_requested: &mut dyn FnMut() -> bool,
316) -> Result<CapturedOutput, String> {
317    const CLEANUP_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
318
319    let started = std::time::Instant::now();
320    let (status, timed_out, cancelled, cleanup_error) = loop {
321        // Q13: the exit is observed without reaping. Every group signal below
322        // is sent while the child is unreaped; it is reaped right afterwards.
323        let state = child_exited(&mut child)
324            .map_err(|error| format!("failed to wait for '{bin}': {error}"))
325            .and_then(|state| {
326                if let Some((stdout_limit, stderr_limit)) = limits {
327                    capture_length(&stdout, &bin, "stdout", Some(stdout_limit))?;
328                    capture_length(&stderr, &bin, "stderr", Some(stderr_limit))?;
329                }
330                Ok(state)
331            });
332        match state {
333            Err(wait_error) => {
334                let cleanup_error = terminate_process_tree(&mut child, &bin, &mut cleanup)
335                    .err()
336                    .map(|error| format!("cleanup failed: {error}"));
337                let reap_error = reap_child(&mut child, &bin, CLEANUP_GRACE)
338                    .err()
339                    .map(|error| format!("reap failed: {error}"));
340                let details = [cleanup_error, reap_error]
341                    .into_iter()
342                    .flatten()
343                    .collect::<Vec<_>>()
344                    .join("; ");
345                return Err(if details.is_empty() {
346                    wait_error
347                } else {
348                    format!("{wait_error}; {details}")
349                });
350            }
351            Ok(true) => {
352                // Exited, not reaped: the leader still pins its group id.
353                let cleanup_error = stop_remaining_process_group(&child, &bin, &mut cleanup).err();
354                break (
355                    reap_child(&mut child, &bin, CLEANUP_GRACE)?,
356                    false,
357                    false,
358                    cleanup_error,
359                );
360            }
361            Ok(false) if cancellation_requested() => {
362                let cleanup_error = terminate_process_tree(&mut child, &bin, &mut cleanup).err();
363                break (
364                    reap_child(&mut child, &bin, CLEANUP_GRACE)?,
365                    false,
366                    true,
367                    cleanup_error,
368                );
369            }
370            Ok(false) if timeout.is_some_and(|timeout| started.elapsed() >= timeout) => {
371                let cleanup_error = terminate_process_tree(&mut child, &bin, &mut cleanup).err();
372                break (
373                    reap_child(&mut child, &bin, CLEANUP_GRACE)?,
374                    true,
375                    false,
376                    cleanup_error,
377                );
378            }
379            Ok(false) => std::thread::sleep(std::time::Duration::from_millis(10)),
380        }
381    };
382    // The capture is over: the guard ends what is left of the group and is
383    // reaped. (Early returns above drop the guard, which does the same.)
384    #[cfg(unix)]
385    if let Some(guard) = guard {
386        let ended = guard.finish();
387        if ended != crate::shell::containment::GuardOutcome::Ended {
388            tracing::warn!("command '{bin}': the end of its process group is {ended:?}");
389        }
390    }
391    let stdout = read_capture(&mut stdout, &bin, "stdout", limits.map(|limits| limits.0))?;
392    let stderr = read_capture(&mut stderr, &bin, "stderr", limits.map(|limits| limits.1))?;
393
394    if let Some(error) = cleanup_error {
395        return Err(error);
396    }
397
398    Ok(CapturedOutput {
399        output: std::process::Output {
400            status,
401            stdout,
402            stderr,
403        },
404        timed_out,
405        cancelled,
406    })
407}
408
409fn read_capture(
410    capture: &mut std::fs::File,
411    bin: &str,
412    stream: &str,
413    limit: Option<u64>,
414) -> Result<Vec<u8>, String> {
415    use std::io::{Read, Seek};
416
417    capture
418        .rewind()
419        .map_err(|error| format!("failed to rewind command '{bin}' {stream}: {error}"))?;
420    // Snapshot the length: a detached descendant cannot extend this read forever.
421    let length = capture_length(capture, bin, stream, limit)?;
422    let mut bytes = Vec::new();
423    capture
424        .take(length)
425        .read_to_end(&mut bytes)
426        .map_err(|error| format!("failed to read command '{bin}' {stream}: {error}"))?;
427    Ok(bytes)
428}
429
430fn capture_length(
431    capture: &std::fs::File,
432    bin: &str,
433    stream: &str,
434    limit: Option<u64>,
435) -> Result<u64, String> {
436    let length = capture
437        .metadata()
438        .map_err(|error| format!("failed to inspect command '{bin}' {stream}: {error}"))?
439        .len();
440    if let Some(limit) = limit
441        && length > limit
442    {
443        return Err(format!(
444            "command '{bin}' {stream} output exceeded the capture bound ({limit} bytes)"
445        ));
446    }
447    Ok(length)
448}
449
450fn reap_child(
451    child: &mut std::process::Child,
452    bin: &str,
453    timeout: std::time::Duration,
454) -> Result<std::process::ExitStatus, String> {
455    let deadline = std::time::Instant::now() + timeout;
456    loop {
457        match child
458            .try_wait()
459            .map_err(|error| format!("failed to reap command '{bin}': {error}"))?
460        {
461            Some(status) => return Ok(status),
462            None if std::time::Instant::now() >= deadline => {
463                return Err(format!("command '{bin}' did not exit after termination"));
464            }
465            None => std::thread::sleep(std::time::Duration::from_millis(10)),
466        }
467    }
468}
469
470// Test recorder: for every group signal of this thread, whether the leader
471// was still unreaped at that moment. A signal after the reap would be a
472// signal to a number that may already belong to another group.
473#[cfg(all(any(test, feature = "test-support"), unix))]
474thread_local! {
475    static GROUP_SIGNAL_LOG: std::cell::RefCell<Vec<bool>> =
476        const { std::cell::RefCell::new(Vec::new()) };
477}
478
479#[cfg(all(any(test, feature = "test-support"), unix))]
480pub(crate) fn take_group_signal_log() -> Vec<bool> {
481    GROUP_SIGNAL_LOG.with(|log| std::mem::take(&mut *log.borrow_mut()))
482}
483
484#[cfg(all(any(test, feature = "test-support"), unix))]
485fn leader_unreaped(pid: libc::pid_t) -> bool {
486    // SAFETY: an all-zero siginfo_t is a valid out-parameter; WNOWAIT leaves
487    // the child waitable. The call fails once the child has been reaped.
488    unsafe {
489        let mut info: libc::siginfo_t = std::mem::zeroed();
490        libc::waitid(
491            libc::P_PID,
492            pid as libc::id_t,
493            &raw mut info,
494            libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
495        ) == 0
496    }
497}
498
499#[cfg(unix)]
500fn stop_remaining_process_group(
501    child: &std::process::Child,
502    bin: &str,
503    _: &mut ProcessCleanup,
504) -> Result<(), String> {
505    let pgid = crate::ipc::process::group_target(child.id())
506        .map_err(|refusal| format!("failed to stop child process group '{bin}': {refusal}"))?;
507    // Ownership rule (Q13 steps 1 and 5): the group is addressed by the
508    // child's pid, which is ours only while the child is unreaped. Every
509    // caller observes the exit with `child_exited` (no reap) and reaps after
510    // this returns.
511    #[cfg(any(test, feature = "test-support"))]
512    GROUP_SIGNAL_LOG.with(|log| log.borrow_mut().push(leader_unreaped(pgid)));
513    // SAFETY: the child was spawned as leader of its dedicated process group
514    // and is unreaped, so its pid cannot name another group.
515    if unsafe { libc::killpg(pgid, libc::SIGKILL) } == 0 {
516        return Ok(());
517    }
518    let error = std::io::Error::last_os_error();
519    if error.raw_os_error() == Some(libc::ESRCH)
520        // Darwin answers EPERM for a group whose only member is the exited,
521        // not yet reaped leader. That is settled by looking at the process
522        // table, never by signalling again.
523        || (error.raw_os_error() == Some(libc::EPERM)
524            && crate::shell::containment::live_members(pgid) == Some(0))
525    {
526        Ok(())
527    } else {
528        Err(format!(
529            "failed to stop child process group '{bin}': {error}"
530        ))
531    }
532}
533
534#[cfg(windows)]
535fn stop_remaining_process_group(
536    _: &std::process::Child,
537    _: &str,
538    cleanup: &mut ProcessCleanup,
539) -> Result<(), String> {
540    drop(cleanup.job.take());
541    Ok(())
542}
543
544fn terminate_process_tree(
545    child: &mut std::process::Child,
546    bin: &str,
547    cleanup: &mut ProcessCleanup,
548) -> Result<(), String> {
549    #[cfg(unix)]
550    {
551        match stop_remaining_process_group(child, bin, cleanup) {
552            Ok(()) => return Ok(()),
553            Err(group_error) => {
554                child.kill().map_err(|error| {
555                    format!("{group_error}; failed to stop direct formatter child '{bin}': {error}")
556                })?;
557                return Err(group_error);
558            }
559        }
560    }
561    #[cfg(windows)]
562    {
563        stop_remaining_process_group(child, bin, cleanup)?;
564        return Ok(());
565    }
566
567    #[allow(unreachable_code)]
568    child
569        .kill()
570        .map_err(|e| format!("failed to stop timed-out command '{bin}': {e}"))
571}
572
573#[cfg(test)]
574mod bounded_capture_tests {
575    use std::io::Write;
576
577    #[test]
578    fn capture_checks_length_before_reading_and_keeps_exact_boundary() {
579        let mut file = tempfile::tempfile().expect("capture file");
580        file.write_all(b"four").expect("capture bytes");
581        let error = super::read_capture(&mut file, "test", "stdout", Some(3))
582            .expect_err("oversized capture must be rejected");
583        assert!(error.contains("output exceeded"));
584        assert_eq!(
585            super::read_capture(&mut file, "test", "stdout", Some(4)).unwrap(),
586            b"four"
587        );
588        assert_eq!(
589            super::read_capture(&mut file, "test", "stdout", None).unwrap(),
590            b"four"
591        );
592    }
593
594    #[test]
595    fn capture_read_errors_are_not_silently_accepted_as_eof() {
596        let directory = tempfile::tempdir().expect("temporary directory");
597        let mut file =
598            std::fs::File::create(directory.path().join("write-only")).expect("write-only capture");
599        file.write_all(b"four").expect("capture bytes");
600        for limit in [None, Some(4)] {
601            let error = super::read_capture(&mut file, "test", "stderr", limit)
602                .expect_err("write-only file cannot supply readable output");
603            assert!(error.contains("failed to read"), "{error}");
604        }
605    }
606}