Skip to main content

fno_agents/
subprocess_ask.rs

1//! Shared one-shot-subprocess primitives for the client-side `ask` ports
2//! (codex + gemini). Extracted from `codex_ask.rs` (ab-73da4ac2) so the SIGINT
3//! forwarding, process-group kill, grace reap, watchdog, and output tee live in
4//! ONE place and the PR #371/#372 hardening carveouts apply to every provider:
5//!
6//! - cv-cfdb7a56 (SIGINT forwarding, ab-e7fdbcb6): forward operator Ctrl-C to
7//!   the child's process group so codex/gemini + their sandbox descendants are
8//!   not orphaned. Both providers' one-shot subprocess is `setpgid(0,0)` into
9//!   its own group, so terminal SIGINT never reaches it without forwarding.
10//! - cv-16eb2200 (canonicalize warn): [`resolve_ask_cwd`] warns at the failure
11//!   point instead of silently joining cwd/current_dir.
12//!
13//! The two providers diverge ONLY in how they read stdout (codex parses a
14//! per-line JSONL stream; gemini reads a single JSON blob) and how they treat
15//! stderr (codex merges it, gemini drains it on a separate thread to keep the
16//! JSON parse pure). Everything below is identical for both.
17
18use std::path::{Path, PathBuf};
19use std::process::Child;
20use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
21use std::sync::mpsc::{channel, Sender};
22use std::sync::{Mutex, MutexGuard};
23use std::thread::JoinHandle;
24use std::time::{Duration, Instant};
25
26// ===========================================================================
27// SIGINT forwarding (ab-e7fdbcb6 / cv-cfdb7a56) — shared across providers
28// ===========================================================================
29//
30// The one-shot ask subprocess (codex or gemini) runs in its OWN process group
31// (`setpgid(0, 0)` in the spawner's pre_exec), so a terminal Ctrl-C — delivered
32// by the tty to the fno foreground process group — never reaches the child or
33// the sandbox subshells it spawns. Rust installs no SIGINT handler by default,
34// so the parent fno process dies on the first Ctrl-C and ORPHANS the child.
35//
36// Python's providers catch `KeyboardInterrupt` mid-read and `os.killpg(pgid,
37// SIGINT)`, then wait/escalate. We reproduce it with a process-global SIGINT
38// handler installed only for the lifetime of one subprocess (the RAII guard
39// below). The handler forwards SIGINT to the child group and records the
40// interrupt; the read loop then ends naturally when the child tears down and
41// closes its pipe, and the caller returns its `Interrupted` error (exit 130,
42// matching CPython's KeyboardInterrupt exit).
43//
44// Only `libc::killpg` and atomic stores run inside the handler — both are
45// async-signal-safe.
46
47/// Process group of the in-flight ask child (0 when none). Read by the signal
48/// handler; set/cleared by [`SigintForwarder`].
49static ASK_CHILD_PGID: AtomicI32 = AtomicI32::new(0);
50/// Set by the signal handler when a SIGINT was forwarded. Polled post-loop via
51/// [`ask_interrupted`].
52static ASK_INTERRUPTED: AtomicBool = AtomicBool::new(false);
53/// Serializes installation + lifetime of the process-global SIGINT handler.
54/// The fno ask client is one-shot, but tests run dispatch on parallel threads
55/// in one binary; holding this lock for the guard's whole lifetime makes the
56/// "one ask child in flight at a time" invariant *enforced* rather than merely
57/// assumed, and prevents concurrent installs from clobbering the statics.
58static SIGINT_MUTEX: Mutex<()> = Mutex::new(());
59
60/// True iff the in-flight (or just-finished) child received a forwarded SIGINT.
61/// Cleared on the next [`SigintForwarder::install`].
62pub fn ask_interrupted() -> bool {
63    ASK_INTERRUPTED.load(Ordering::SeqCst)
64}
65
66/// SIGINT handler: forward the signal to the ask child's process group and flag
67/// the interrupt. Async-signal-safe (killpg + atomic store only).
68extern "C" fn forward_sigint_to_child(_sig: libc::c_int) {
69    let pgid = ASK_CHILD_PGID.load(Ordering::SeqCst);
70    if pgid > 0 {
71        // SAFETY: killpg is async-signal-safe; pgid is the child's group id.
72        unsafe {
73            libc::killpg(pgid, libc::SIGINT);
74        }
75    }
76    ASK_INTERRUPTED.store(true, Ordering::SeqCst);
77}
78
79/// RAII guard installing the SIGINT-forwarding handler for the duration of one
80/// ask subprocess, restoring the previous disposition (and clearing the pgid)
81/// on drop. The fno ask client is one-shot, so only one ask child is ever in
82/// flight per process.
83#[must_use = "dropping the guard immediately uninstalls the SIGINT handler"]
84pub struct SigintForwarder {
85    prev: libc::sighandler_t,
86    /// Held for the guard's whole lifetime so installs serialize (see
87    /// `SIGINT_MUTEX`). Dropped after `Drop::drop` runs, i.e. after the prior
88    /// disposition is restored and `ASK_CHILD_PGID` is cleared.
89    _guard: MutexGuard<'static, ()>,
90}
91
92impl SigintForwarder {
93    /// Install the handler, pointing it at `pgid` (== child pid, since the
94    /// child is `setpgid(0, 0)` into its own group).
95    pub fn install(pgid: u32) -> Self {
96        // Serialize install + lifetime. Poisoning is irrelevant for a unit
97        // lock; recover the guard either way.
98        let guard = SIGINT_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
99        // With the lock held, no other forwarder can be live; Drop clears
100        // ASK_CHILD_PGID before releasing the lock, so this always holds.
101        debug_assert_eq!(
102            ASK_CHILD_PGID.load(Ordering::SeqCst),
103            0,
104            "SIGINT_MUTEX held but ASK_CHILD_PGID != 0; guard lifetime invariant violated"
105        );
106        ASK_INTERRUPTED.store(false, Ordering::SeqCst);
107        ASK_CHILD_PGID.store(pgid as i32, Ordering::SeqCst);
108        // SAFETY: forward_sigint_to_child is async-signal-safe; we save the
109        // prior disposition to restore on drop.
110        let prev = unsafe {
111            libc::signal(
112                libc::SIGINT,
113                forward_sigint_to_child as *const () as libc::sighandler_t,
114            )
115        };
116        if prev == libc::SIG_IGN {
117            // The parent explicitly ignored SIGINT (backgrounded / under a
118            // supervisor). Standard Unix convention is to inherit that: undo
119            // our handler immediately and do NOT forward. ASK_CHILD_PGID is
120            // cleared so the handler (now SIG_IGN again) can never fire, and
121            // Drop's generic `restore = self.prev` re-applies SIG_IGN.
122            // SAFETY: restoring the just-saved SIG_IGN disposition.
123            unsafe {
124                libc::signal(libc::SIGINT, libc::SIG_IGN);
125            }
126            ASK_CHILD_PGID.store(0, Ordering::SeqCst);
127        } else if prev == libc::SIG_ERR {
128            // Installing a SIGINT handler effectively never fails, but if it
129            // did we must NOT later restore SIG_ERR (itself an error). Warn and
130            // let Drop fall back to SIG_DFL.
131            eprintln!(
132                "fno-agents: failed to install SIGINT handler; Ctrl-C will not forward to the child"
133            );
134        }
135        Self {
136            prev,
137            _guard: guard,
138        }
139    }
140}
141
142impl Drop for SigintForwarder {
143    fn drop(&mut self) {
144        // Restore the prior disposition. For SIG_IGN we already re-applied it in
145        // install (and never installed our handler); for SIG_ERR fall back to
146        // SIG_DFL (restoring SIG_ERR is itself an error). Otherwise restore the
147        // saved handler.
148        let restore = if self.prev == libc::SIG_ERR {
149            libc::SIG_DFL
150        } else {
151            self.prev
152        };
153        // SAFETY: restore the prior SIGINT disposition and clear the pgid so a
154        // late signal can't target an exited (possibly recycled) pid.
155        unsafe {
156            libc::signal(libc::SIGINT, restore);
157        }
158        ASK_CHILD_PGID.store(0, Ordering::SeqCst);
159    }
160}
161
162// ===========================================================================
163// Process-group kill + grace reap
164// ===========================================================================
165
166/// Send `sig` to the process group of `pid`.
167pub fn kill_pgrp(pid: u32, sig: libc::c_int) {
168    unsafe {
169        let pgid = libc::getpgid(pid as libc::pid_t);
170        if pgid > 0 {
171            libc::killpg(pgid, sig);
172        }
173    }
174}
175
176/// Reap `child`: wait up to `grace_sec`, then SIGTERM, then SIGKILL after 5s.
177/// Returns `(exit_code, sigkill_escalated)`. (std has no `wait_timeout`, so we
178/// spin-poll `try_wait` at 25ms.)
179pub fn wait_with_grace(pid: u32, child: &mut Child, grace_sec: f64) -> (i32, bool) {
180    let deadline = Instant::now() + Duration::from_secs_f64(grace_sec);
181    loop {
182        match child.try_wait() {
183            Ok(Some(status)) => return (status.code().unwrap_or(-1), false),
184            Ok(None) => {
185                if Instant::now() >= deadline {
186                    break;
187                }
188                std::thread::sleep(Duration::from_millis(25));
189            }
190            Err(_) => break,
191        }
192    }
193    // Grace expired: SIGTERM to pgrp.
194    kill_pgrp(pid, libc::SIGTERM);
195    let sigterm_deadline = Instant::now() + Duration::from_secs(5);
196    loop {
197        match child.try_wait() {
198            Ok(Some(status)) => return (status.code().unwrap_or(-1), false),
199            Ok(None) => {
200                if Instant::now() >= sigterm_deadline {
201                    break;
202                }
203                std::thread::sleep(Duration::from_millis(25));
204            }
205            Err(_) => break,
206        }
207    }
208    // SIGKILL escalation.
209    kill_pgrp(pid, libc::SIGKILL);
210    let sigkill_deadline = Instant::now() + Duration::from_secs(2);
211    loop {
212        match child.try_wait() {
213            Ok(Some(status)) => return (status.code().unwrap_or(-1), true),
214            Ok(None) => {
215                if Instant::now() >= sigkill_deadline {
216                    break;
217                }
218                std::thread::sleep(Duration::from_millis(25));
219            }
220            Err(_) => break,
221        }
222    }
223    // Last resort: child not reaped after SIGKILL+2s.
224    (-9, true)
225}
226
227// ===========================================================================
228// output.jsonl tee
229// ===========================================================================
230
231/// Open the JSONL tee in append mode, creating parent dirs.
232///
233/// `Path::parent()` returns `Some("")` for a bare-filename relative path (e.g.
234/// `"output.jsonl"`), and `create_dir_all("")` fails — skip the mkdir when the
235/// parent is empty (the file lives in cwd and the dir already exists). Returns
236/// the raw `io::Error` so each provider wraps it in its own `TeeOpen` variant
237/// with a provider-tagged message.
238pub fn open_tee(log_path: &Path) -> std::io::Result<std::fs::File> {
239    if let Some(parent) = log_path.parent() {
240        if !parent.as_os_str().is_empty() {
241            std::fs::create_dir_all(parent)?;
242        }
243    }
244    std::fs::OpenOptions::new()
245        .create(true)
246        .append(true)
247        .open(log_path)
248}
249
250// ===========================================================================
251// Wall-clock watchdog
252// ===========================================================================
253
254/// Cancelable wall-clock watchdog: on timeout, SIGTERM the child's process
255/// group; escalate to SIGKILL after a 2s grace. Cancelable via an internal
256/// channel so a happy-path completion (the caller calls [`AskWatchdog::cancel`]
257/// before reaping) makes `recv_timeout` return `Disconnected` and the kill
258/// cascade is skipped — mirrors Python's `for t in timers: t.cancel()` in the
259/// `finally` block.
260///
261/// Python parity: the providers only arm the watchdog when `timeout > 0`. A
262/// zero-duration timeout means "disabled" (caller opted out), NOT "immediate
263/// expiry"; `Some(Duration::ZERO)` is treated as `None`.
264pub struct AskWatchdog {
265    timed_out: std::sync::Arc<AtomicBool>,
266    done_tx: Option<Sender<()>>,
267    handle: Option<JoinHandle<()>>,
268}
269
270impl AskWatchdog {
271    /// Arm a watchdog for `pid` (its process group). `timeout == None` or
272    /// `Some(ZERO)` arms nothing.
273    pub fn spawn(pid: u32, timeout: Option<Duration>) -> Self {
274        let timed_out = std::sync::Arc::new(AtomicBool::new(false));
275        let watchdog_timeout = timeout.filter(|d| !d.is_zero());
276        let (done_tx, done_rx) = channel::<()>();
277        let handle = watchdog_timeout.map(|d| {
278            let pid_for_wd = pid;
279            let timed_out_for_wd = timed_out.clone();
280            std::thread::spawn(move || {
281                use std::sync::mpsc::RecvTimeoutError;
282                match done_rx.recv_timeout(d) {
283                    Ok(()) | Err(RecvTimeoutError::Disconnected) => {
284                        // Main thread completed (or dropped its sender). Skip
285                        // the kill cascade; the child either exited or is being
286                        // reaped by `wait_with_grace` momentarily.
287                        return;
288                    }
289                    Err(RecvTimeoutError::Timeout) => {
290                        timed_out_for_wd.store(true, Ordering::SeqCst);
291                        kill_pgrp(pid_for_wd, libc::SIGTERM);
292                    }
293                }
294                // Second-stage escalation: also cancelable. If the SIGTERM was
295                // honored and main signals done within 2s, skip SIGKILL.
296                match done_rx.recv_timeout(Duration::from_secs(2)) {
297                    Ok(()) | Err(RecvTimeoutError::Disconnected) => {}
298                    Err(RecvTimeoutError::Timeout) => {
299                        kill_pgrp(pid_for_wd, libc::SIGKILL);
300                    }
301                }
302            })
303        });
304        Self {
305            timed_out,
306            done_tx: Some(done_tx),
307            handle,
308        }
309    }
310
311    /// Cancel the kill cascade (drop the sender). Call BEFORE reaping so a slow
312    /// reap doesn't run out the watchdog's `recv_timeout` window.
313    pub fn cancel(&mut self) {
314        self.done_tx.take();
315    }
316
317    /// Join the watchdog thread so its forensic state (the `timed_out` store)
318    /// is committed before [`AskWatchdog::timed_out`] is read. Call AFTER
319    /// reaping.
320    pub fn join(&mut self) {
321        if let Some(h) = self.handle.take() {
322            let _ = h.join();
323        }
324    }
325
326    /// Whether the watchdog fired (the child exceeded its wall-clock budget).
327    ///
328    /// Call [`AskWatchdog::join`] FIRST: the watchdog thread stores the flag
329    /// just after `recv_timeout` returns `Timeout`, so a read before the thread
330    /// is joined can race and observe a stale `false`. Both `run_codex` and
331    /// `run_gemini` reap → `join()` → `timed_out()` in that order.
332    pub fn timed_out(&self) -> bool {
333        self.timed_out.load(Ordering::SeqCst)
334    }
335}
336
337// ===========================================================================
338// cwd resolution for the client `maybe_run_*_ask` hooks (cv-16eb2200)
339// ===========================================================================
340
341/// Resolve the `--cwd` param to an absolute path before it reaches the registry
342/// row, mirroring Python's `Path(cwd).resolve()`.
343///
344/// cv-16eb2200: the prior code silently joined cwd/current_dir on a
345/// `canonicalize` failure (a not-yet-existing path), producing a confusing
346/// downstream error with no breadcrumb. Now the fallback warns at the failure
347/// point so the operator can see why the recorded cwd diverged from `--cwd`.
348pub fn resolve_ask_cwd(cwd_param: Option<&str>) -> PathBuf {
349    match cwd_param {
350        Some(c) => match std::fs::canonicalize(c) {
351            Ok(p) => p,
352            Err(e) => {
353                let p = PathBuf::from(c);
354                let resolved = if p.is_absolute() {
355                    p
356                } else {
357                    std::env::current_dir().map(|d| d.join(&p)).unwrap_or(p)
358                };
359                eprintln!(
360                    "fno-agents: could not canonicalize --cwd {:?} ({}); recording {:?}",
361                    c, e, resolved
362                );
363                resolved
364            }
365        },
366        None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
367    }
368}