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}