Skip to main content

monoloop_loop/transaction/
process_tool.rs

1//! Process-isolated tool execution (V2 §14.3 / D-043).
2//!
3//! A Tokio task is **not** an isolation boundary. [`ProcessIsolatedToolHandler`]
4//! owns an OS child process. Hard stop uses OS `kill` + `try_wait` (mutex never
5//! held across an `.await`). Cooperative cancel is best-effort only until
6//! escalate-to-kill; it does not claim to stop the child by itself.
7//!
8//! M5.4 / D-048: the wait/poll loop is returned as
9//! [`LinkedToolExecutionHandle::drive`] and polled on the dispatcher /
10//! ToolWorker task. Stdin is delivered with `tokio::process` async write on
11//! that same owned drive — no ambient `spawn_blocking`.
12
13use super::tool_handler::{
14    LinkedToolExecutionHandle, ToolExecutionCompletion, ToolExecutionControl, ToolHandler,
15    ToolKillHandle,
16};
17use monoloop_contracts::{
18    CanonicalToolOutput, ToolCall, ToolCallContext, ToolCompletion, ToolExecutionId,
19    ToolRuntimeError, ToolStartError,
20};
21use std::future::Future;
22use std::pin::Pin;
23use std::process::Stdio;
24use std::sync::{Arc, Mutex};
25use std::time::{Duration, Instant};
26use tokio::io::AsyncWriteExt;
27use tokio::process::{Child, Command};
28use tokio::sync::oneshot;
29
30/// How the child process is launched for ProcessIsolated tools.
31///
32/// Only direct program exec is supported — never `sh -c` (grandchild would not
33/// die with the parent shell; V2 §14.3 / D-043).
34#[derive(Clone, Debug)]
35pub enum ProcessToolCommand {
36    /// Direct program + args; kill reaps this PID. Payload JSON is written to stdin.
37    Program {
38        /// Executable path or name on PATH.
39        program: String,
40        /// Arguments (no shell).
41        args: Vec<String>,
42    },
43    /// Sleep until killed (qualification: child is `sleep` itself).
44    SleepUntilKilled {
45        /// Sleep duration if never killed.
46        seconds: u64,
47    },
48}
49
50/// Host tool that runs in a real OS child process (V2 §14.3).
51#[derive(Clone, Debug)]
52pub struct ProcessIsolatedToolHandler {
53    command: ProcessToolCommand,
54    /// Optional slot written with the child PID immediately after spawn (D-048 proofs).
55    pid_slot: Option<Arc<std::sync::atomic::AtomicU32>>,
56}
57
58impl ProcessIsolatedToolHandler {
59    /// Construct from a command recipe.
60    pub fn new(command: ProcessToolCommand) -> Self {
61        Self {
62            command,
63            pid_slot: None,
64        }
65    }
66
67    /// Qualification helper: child sleeps until OS kill.
68    pub fn sleep_until_killed(seconds: u64) -> Self {
69        Self::new(ProcessToolCommand::SleepUntilKilled { seconds })
70    }
71
72    /// Record the OS child PID into `slot` as soon as spawn succeeds (tests / sacrificial).
73    pub fn with_pid_slot(mut self, slot: Arc<std::sync::atomic::AtomicU32>) -> Self {
74        self.pid_slot = Some(slot);
75        self
76    }
77}
78
79impl ToolHandler for ProcessIsolatedToolHandler {
80    fn start(
81        &self,
82        call: ToolCall,
83        context: ToolCallContext,
84    ) -> Result<LinkedToolExecutionHandle, ToolStartError> {
85        let control = ToolExecutionControl::new();
86        // Absolute deadline from call context bounds the wait poll loop.
87        let kill_deadline = context.deadline;
88        let mut child = match &self.command {
89            ProcessToolCommand::Program { program, args } => Command::new(program)
90                .args(args)
91                .stdin(Stdio::piped())
92                .stdout(Stdio::null())
93                .stderr(Stdio::null())
94                .spawn()
95                .map_err(|_| ToolStartError::Rejected("process spawn failed"))?,
96            // Direct `sleep` — killing this PID reaps the sleeper.
97            ProcessToolCommand::SleepUntilKilled { seconds } => Command::new("sleep")
98                .arg(seconds.to_string())
99                .stdin(Stdio::null())
100                .stdout(Stdio::null())
101                .stderr(Stdio::null())
102                .spawn()
103                .map_err(|_| ToolStartError::Rejected("process spawn failed"))?,
104        };
105
106        if let Some(slot) = &self.pid_slot {
107            if let Some(id) = child.id() {
108                slot.store(id, std::sync::atomic::Ordering::SeqCst);
109            }
110        }
111
112        // D-048: take stdin before ownership; never block `start` on write_all.
113        let stdin = if matches!(self.command, ProcessToolCommand::Program { .. }) {
114            child.stdin.take()
115        } else {
116            None
117        };
118        let payload = if stdin.is_some() {
119            serde_json::to_vec(&call.arguments).unwrap_or_default()
120        } else {
121            Vec::new()
122        };
123
124        let (tx, rx) = oneshot::channel();
125        // Kill handle + Child ownership exist before any stdin delivery (D-048).
126        let (kill, drive) =
127            ToolKillHandle::from_child_driven_with_stdin(child, stdin, payload, tx, kill_deadline);
128
129        Ok(LinkedToolExecutionHandle {
130            execution_id: ToolExecutionId::generate(),
131            control,
132            completion: ToolExecutionCompletion::new(rx),
133            kill: Some(kill),
134            drive: Some(drive),
135        })
136    }
137
138    fn supports_abort(&self) -> bool {
139        false
140    }
141
142    fn supports_isolated_kill(&self) -> bool {
143        true
144    }
145
146    fn os_process_isolated(&self) -> bool {
147        true
148    }
149}
150
151impl ToolKillHandle {
152    /// Own a [`Child`]: kill uses OS signals; wait/poll is an inline drive future (M5.4).
153    ///
154    /// Mutex is never held across `.await` — kill can interleave with the poll loop.
155    pub fn from_child_driven(
156        child: Child,
157        completion_tx: oneshot::Sender<ToolCompletion>,
158        wait_deadline: Instant,
159    ) -> (Self, Pin<Box<dyn Future<Output = ()> + Send>>) {
160        Self::from_child_driven_with_stdin(child, None, Vec::new(), completion_tx, wait_deadline)
161    }
162
163    /// Own a [`Child`] immediately; optional stdin is written on the owned drive
164    /// via `tokio::process` async I/O (D-048 — never block `ToolHandler::start`,
165    /// never ambient `spawn_blocking`).
166    pub fn from_child_driven_with_stdin(
167        child: Child,
168        stdin: Option<tokio::process::ChildStdin>,
169        payload: Vec<u8>,
170        completion_tx: oneshot::Sender<ToolCompletion>,
171        wait_deadline: Instant,
172    ) -> (Self, Pin<Box<dyn Future<Output = ()> + Send>>) {
173        let child_arc = Arc::new(Mutex::new(Some(child)));
174        let child_for_wait = Arc::clone(&child_arc);
175        let kill = Self::from_process(Arc::clone(&child_arc));
176        let kill_for_drive = kill.clone();
177        let drive = Box::pin(async move {
178            // Stdin on the owned drive. Failures kill the child, then the wait
179            // loop below must observe exit before any reap accounting.
180            let mut fail_deadline = false;
181            if let Some(mut stdin) = stdin {
182                let remaining = wait_deadline.saturating_duration_since(Instant::now());
183                let write_ok = matches!(
184                    tokio::time::timeout(remaining, stdin.write_all(&payload)).await,
185                    Ok(Ok(()))
186                );
187                // Always drop stdin so the child can see EOF when the write finished.
188                drop(stdin);
189                if !write_ok {
190                    fail_deadline = true;
191                    if let Some(c) = child_for_wait
192                        .lock()
193                        .unwrap_or_else(|e| e.into_inner())
194                        .as_mut()
195                    {
196                        let _ = c.start_kill();
197                    }
198                }
199            }
200
201            // After kill, keep polling until try_wait observes exit (or a bounded
202            // post-kill grace elapses). Never treat start_kill as reap.
203            let mut killed_at: Option<Instant> = if fail_deadline {
204                Some(Instant::now())
205            } else {
206                None
207            };
208            let post_kill_grace = Duration::from_secs(2);
209            let status = loop {
210                if killed_at.is_none() && Instant::now() >= wait_deadline {
211                    if let Some(c) = child_for_wait
212                        .lock()
213                        .unwrap_or_else(|e| e.into_inner())
214                        .as_mut()
215                    {
216                        let _ = c.start_kill();
217                    }
218                    killed_at = Some(Instant::now());
219                }
220                let polled = {
221                    let mut guard = child_for_wait.lock().unwrap_or_else(|e| e.into_inner());
222                    match guard.as_mut() {
223                        Some(c) => match c.try_wait() {
224                            Ok(Some(s)) => {
225                                let _ = guard.take();
226                                Some(s)
227                            }
228                            Ok(None) => None,
229                            Err(_) => break None,
230                        },
231                        None => break None,
232                    }
233                };
234                if let Some(st) = polled {
235                    break Some(st);
236                }
237                if killed_at.is_some_and(|t| Instant::now() >= t + post_kill_grace) {
238                    break None;
239                }
240                tokio::time::sleep(Duration::from_millis(5)).await;
241            };
242
243            // Reap accounting only after observed exit (or Child already taken).
244            let observed = status.is_some()
245                || child_for_wait
246                    .lock()
247                    .unwrap_or_else(|e| e.into_inner())
248                    .is_none();
249            if observed {
250                kill_for_drive.note_process_reaped();
251            }
252
253            let completion = if fail_deadline {
254                ToolCompletion::RuntimeFailed(ToolRuntimeError::DeadlineExceeded)
255            } else {
256                match status {
257                    Some(st) if st.success() => ToolCompletion::Succeeded(
258                        CanonicalToolOutput::Json(serde_json::json!({"ok": true})),
259                    ),
260                    Some(_) => ToolCompletion::RuntimeFailed(ToolRuntimeError::TerminationFailed),
261                    None => ToolCompletion::RuntimeFailed(ToolRuntimeError::CompletionLost),
262                }
263            };
264            let _ = completion_tx.send(completion);
265        });
266        (kill, drive)
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crate::transaction::host_tools::RegisteredTool;
274    use crate::transaction::tool_handler::IsolatedKillableToolHandler;
275    use monoloop_contracts::{
276        ChannelId, JsonSchema, SessionId, SessionKey, ToolActionId, ToolCall, ToolCallContext,
277        ToolExecutionClass, ToolId, ToolLimits, ToolName, ToolOutputContract, ToolSpec,
278        ToolSuccessContract, TransactionId,
279    };
280    use std::sync::Arc;
281
282    fn ctx() -> ToolCallContext {
283        ToolCallContext {
284            transaction_id: TransactionId::generate(),
285            session_key: SessionKey::new(
286                ChannelId::try_new("c").unwrap(),
287                SessionId::try_new("s").unwrap(),
288            ),
289            exchange_id: Some(monoloop_contracts::ExchangeId::generate()),
290            tool_action_id: ToolActionId::new("a"),
291            tool_id: ToolId::try_new("p").unwrap(),
292            deadline: Instant::now() + Duration::from_secs(5),
293        }
294    }
295
296    fn call() -> ToolCall {
297        ToolCall {
298            tool_name: ToolName::try_new("p").unwrap(),
299            tool_id: ToolId::try_new("p").unwrap(),
300            provider_tool_call_id: "p".into(),
301            arguments: serde_json::json!({}),
302            request_ordinal: 0,
303        }
304    }
305
306    fn process_spec() -> ToolSpec {
307        let schema = JsonSchema::try_new(serde_json::json!({"type": "object"})).unwrap();
308        ToolSpec::try_new(
309            ToolId::try_new("p").unwrap(),
310            ToolName::try_new("p").unwrap(),
311            "process tool",
312            schema.clone(),
313            ToolOutputContract {
314                success: ToolSuccessContract::json(schema),
315                error_data_schema: None,
316            },
317            ToolLimits::default(),
318            ToolExecutionClass::ProcessIsolated {
319                grace: Duration::from_millis(50),
320                kill_deadline: Duration::from_secs(2),
321            },
322        )
323        .unwrap()
324    }
325
326    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
327    async fn process_isolated_owned_processes_counter_tracks_live_child() {
328        use std::sync::atomic::{AtomicU32, Ordering};
329        let counter = Arc::new(AtomicU32::new(0));
330        let handler = ProcessIsolatedToolHandler::sleep_until_killed(3600);
331        let mut handle = handler.start(call(), ctx()).expect("start");
332        let kill = handle.kill.as_ref().expect("kill");
333        kill.register_owned_process(Arc::clone(&counter));
334        assert_eq!(counter.load(Ordering::SeqCst), 1, "live child must count");
335        let drive = handle.drive.take().unwrap();
336        let wait = handle.completion.wait();
337        tokio::pin!(drive);
338        tokio::pin!(wait);
339        kill.kill();
340        tokio::time::timeout(Duration::from_secs(2), async {
341            tokio::select! {
342                _ = &mut wait => {}
343                _ = &mut drive => { let _ = wait.await; }
344            }
345        })
346        .await
347        .expect("reaped");
348        assert_eq!(
349            counter.load(Ordering::SeqCst),
350            0,
351            "reaped child must release owned_processes"
352        );
353    }
354
355    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
356    async fn process_isolated_kill_stops_sleeping_child() {
357        let handler = ProcessIsolatedToolHandler::sleep_until_killed(3600);
358        let mut handle = handler.start(call(), ctx()).expect("start");
359        assert!(handle.kill.as_ref().unwrap().is_process_isolated());
360        assert!(
361            handle.drive.is_some(),
362            "ProcessIsolated wait must be an inline drive (no spawn_blocking)"
363        );
364        let kill = handle.kill.expect("process kill handle");
365        handle.control.cancel();
366        // Drive the wait loop while we escalate to OS kill.
367        let drive = handle.drive.take().unwrap();
368        let wait = handle.completion.wait();
369        tokio::pin!(drive);
370        tokio::pin!(wait);
371        tokio::time::sleep(Duration::from_millis(20)).await;
372        kill.kill();
373        tokio::time::timeout(Duration::from_secs(2), async {
374            tokio::select! {
375                _ = &mut wait => {}
376                _ = &mut drive => { let _ = wait.await; }
377            }
378        })
379        .await
380        .expect("child joined after kill");
381        kill.join_timeout(Duration::from_secs(1))
382            .await
383            .expect("process reaped");
384    }
385
386    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
387    async fn process_isolated_claims_structural_factory() {
388        let handler = ProcessIsolatedToolHandler::sleep_until_killed(1);
389        assert!(handler.os_process_isolated());
390        assert!(handler.supports_isolated_kill());
391        assert!(!handler.supports_abort());
392    }
393
394    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
395    async fn process_isolated_program_owns_before_stdin_and_is_killable() {
396        // Child that never reads stdin — previously blocked start() on write_all.
397        let handler = ProcessIsolatedToolHandler::new(ProcessToolCommand::Program {
398            program: "sleep".into(),
399            args: vec!["30".into()],
400        });
401        let mut big = call();
402        big.arguments = serde_json::json!({"pad": "x".repeat(64 * 1024)});
403        let mut handle = handler
404            .start(big, ctx())
405            .expect("start must return before stdin completes");
406        assert!(handle.kill.as_ref().unwrap().is_process_isolated());
407        let kill = handle.kill.clone().expect("kill");
408        let drive = handle.drive.take().unwrap();
409        // Kill while stdin may still be draining — ownership must already exist.
410        kill.kill();
411        let _ = tokio::time::timeout(Duration::from_secs(2), drive).await;
412        kill.join_timeout(Duration::from_secs(1))
413            .await
414            .expect("child reaped after kill");
415    }
416
417    /// D-048: stdin write lives on the owned drive (async), and reap accounting
418    /// requires an observed `try_wait` exit — not merely `start_kill`.
419    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
420    async fn process_isolated_stdin_timeout_reaps_only_after_observed_exit() {
421        use std::sync::atomic::{AtomicU32, Ordering};
422        let counter = Arc::new(AtomicU32::new(0));
423        // cat reads stdin forever if we never close... but we close after write.
424        // Use sleep so stdin write to a non-reader can block until deadline/kill.
425        let handler = ProcessIsolatedToolHandler::new(ProcessToolCommand::Program {
426            program: "sleep".into(),
427            args: vec!["30".into()],
428        });
429        let mut short = ctx();
430        short.deadline = Instant::now() + Duration::from_millis(80);
431        let mut big = call();
432        big.arguments = serde_json::json!({"pad": "x".repeat(256 * 1024)});
433        let mut handle = handler.start(big, short).expect("start");
434        let kill = handle.kill.as_ref().expect("kill");
435        kill.register_owned_process(Arc::clone(&counter));
436        assert_eq!(counter.load(Ordering::SeqCst), 1);
437        let drive = handle.drive.take().unwrap();
438        let wait = handle.completion.wait();
439        tokio::pin!(drive);
440        tokio::pin!(wait);
441        tokio::time::timeout(Duration::from_secs(3), async {
442            tokio::select! {
443                _ = &mut wait => {}
444                _ = &mut drive => { let _ = wait.await; }
445            }
446        })
447        .await
448        .expect("drive must conclude");
449        // After observed exit, owned_processes must be released.
450        assert_eq!(
451            counter.load(Ordering::SeqCst),
452            0,
453            "note_process_reaped only after observed exit"
454        );
455        assert!(
456            !kill.has_join(),
457            "kill handle must report reaped after observed exit"
458        );
459    }
460
461    #[test]
462    fn process_isolated_rejects_dyn_handler_path() {
463        let spec = process_spec();
464        let tokio_handler = Arc::new(IsolatedKillableToolHandler::new(|_c, _x| {
465            Box::pin(async {
466                ToolCompletion::Succeeded(CanonicalToolOutput::Json(serde_json::json!({})))
467            })
468        })) as Arc<dyn ToolHandler>;
469        let err = RegisteredTool::try_new(spec, tokio_handler).unwrap_err();
470        let msg = format!("{err}");
471        assert!(
472            msg.contains("try_new_process_isolated") || msg.contains("ProcessIsolated"),
473            "got {msg}"
474        );
475    }
476
477    #[test]
478    fn process_isolated_accepts_structural_handler() {
479        let spec = process_spec();
480        RegisteredTool::try_new_process_isolated(
481            spec,
482            ProcessIsolatedToolHandler::sleep_until_killed(1),
483        )
484        .expect("structural ProcessIsolated ok");
485    }
486
487    #[test]
488    fn process_isolated_typed_api_rejects_wrong_class() {
489        let schema = JsonSchema::try_new(serde_json::json!({"type": "object"})).unwrap();
490        let spec = ToolSpec::try_new(
491            ToolId::try_new("p").unwrap(),
492            ToolName::try_new("p").unwrap(),
493            "abortable",
494            schema.clone(),
495            ToolOutputContract {
496                success: ToolSuccessContract::json(schema),
497                error_data_schema: None,
498            },
499            ToolLimits::default(),
500            ToolExecutionClass::AbortableAtYield {
501                grace: Duration::from_secs(1),
502            },
503        )
504        .unwrap();
505        let err = RegisteredTool::try_new_process_isolated(
506            spec,
507            ProcessIsolatedToolHandler::sleep_until_killed(1),
508        )
509        .unwrap_err();
510        assert!(format!("{err}").contains("ProcessIsolated"));
511    }
512}