//! Machinerie de process **générique et paramétrable par la commande** pour les //! adapters structurés (ARCHITECTURE §17.2). Strictement **séparée** du parsing du //! format de chaque CLI (cf. `claude::parse_event` / `codex::parse_event`). //! //! # Rôle //! //! Lancer un process CLI dont la sortie est **orientée lignes** (un objet/événement //! par ligne, typiquement du JSONL), lire ces lignes jusqu'à épuisement (ou jusqu'à //! un timeout), et les remettre au parser propre à l'adapter. Aucune connaissance //! de Claude/Codex/JSON ici : *seulement* spawn, pipes, drain ligne-à-ligne, kill. //! //! # Pourquoi paramétrable par la commande //! //! Le binaire et ses arguments sont fournis par l'appelant ([`SpawnLine`]). En prod //! l'adapter passe `claude … --output-format stream-json` ; en test, un **fake CLI //! scriptable** (cf. `conformance`) est substitué pour rejouer un script de lignes //! canned — déterministe, hors-réseau. La machinerie est identique dans les deux cas. //! //! # Incarnation « un run par tour » //! //! `run_turn` lance le process, lui passe (optionnellement) le `prompt` sur stdin, //! draine **toutes** les lignes de stdout jusqu'à EOF, attend la fin du process, et //! retourne les lignes brutes. C'est l'incarnation (b) de §17.2 (« un `exec` par //! `send()` », réattaché via l'id de conversation) — la plus simple et la plus //! déterministe ; elle suffit au contrat de port et au fake CLI. Une éventuelle //! incarnation « process persistant piloté en flux » resterait derrière le **même** //! port sans changer le parsing. use std::io; use std::process::Stdio; use std::sync::mpsc::Sender; use std::sync::{Arc, Mutex as StdMutex}; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; use tokio::sync::Mutex as AsyncMutex; use domain::ports::{AgentSessionError, CancelCurrentTurnOutcome}; use domain::sandbox::{SandboxEnforcer, SandboxPlan}; /// Une invocation orientée lignes : binaire + arguments + cwd + env + prompt à /// pousser sur stdin (le cas échéant). **Paramétrable par la commande** : c'est ce /// que le test substitue pour brancher le fake CLI. #[derive(Debug, Clone)] pub struct SpawnLine { /// Exécutable à lancer (le vrai `claude`/`codex`, ou le fake CLI en test). pub command: String, /// Arguments (déjà composés par l'adapter : `--output-format stream-json`, …). pub args: Vec, /// Répertoire de travail (run dir isolé §14.1). pub cwd: String, /// Variables d'environnement additionnelles. pub env: Vec<(String, String)>, /// Contenu à écrire sur stdin du process (`None` ⇒ stdin fermé immédiatement). /// Sert au mode `--input-format stream-json` / au passage du prompt. pub stdin: Option, /// Plan de sandbox OS à appliquer sur l'enfant (lot LP4-4). `None` ⇒ aucun /// sandboxing : `run_turn` emprunte le drain async tokio **inchangé** (invariant /// produit : rien posé ⇒ comportement natif). `Some` **et** un enforcer fourni à /// [`run_turn`] ⇒ le plan est appliqué sur l'enfant via [`drain_sandboxed`]. pub sandbox: Option, } /// Shared handle used by structured adapters to cancel the process backing the /// current turn without shutting down the reusable session. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum LastTurnState { Completed, Cancelled, } #[derive(Default)] struct TurnCancelState { next_generation: u64, active: Option<(u64, bool)>, last: Option, } type AsyncTurnChild = Arc>; type StdTurnChild = Arc>; type TrackedAsyncTurnChild = Option<(u64, AsyncTurnChild)>; type TrackedStdTurnChild = Option<(u64, StdTurnChild)>; /// Generation-aware handle for cancelling the process backing one current turn. #[derive(Clone, Default)] pub struct TurnCancel { state: Arc>, async_child: Arc>, std_child: Arc>, } #[derive(Clone)] struct TurnToken { cancel: TurnCancel, generation: u64, } pub(super) struct TurnLease { token: TurnToken, } impl TurnLease { pub(super) async fn track_async(&self, child: AsyncTurnChild) -> Result<(), AgentSessionError> { if !self .token .cancel .set_async(self.token.generation, Arc::clone(&child)) { child .lock() .await .kill() .await .map_err(|e| AgentSessionError::Io(format!("annulation du tour: {e}")))?; } Ok(()) } pub(super) fn clear_async(&self, child: &AsyncTurnChild) { self.token.cancel.clear_async(self.token.generation, child); } } impl Drop for TurnLease { fn drop(&mut self) { self.token.cancel.finish(self.token.generation); } } impl TurnCancel { /// Creates an empty cancel handle with no active child. #[must_use] pub fn new() -> Self { Self::default() } pub(super) fn begin(&self) -> TurnLease { let generation = { let mut state = self .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); state.next_generation = state.next_generation.wrapping_add(1).max(1); let generation = state.next_generation; state.active = Some((generation, false)); generation }; TurnLease { token: TurnToken { cancel: self.clone(), generation, }, } } fn finish(&self, generation: u64) { let mut state = self .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if let Some((active_generation, cancel_requested)) = state.active { if active_generation == generation { state.active = None; state.last = Some(if cancel_requested { LastTurnState::Cancelled } else { LastTurnState::Completed }); } } } fn restore_after_cancel_failure(&self, generation: u64) { let mut state = self .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if state.active == Some((generation, true)) { state.active = Some((generation, false)); } } fn set_async(&self, generation: u64, child: AsyncTurnChild) -> bool { let state = self .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if !matches!(state.active, Some((active, false)) if active == generation) { return false; } *self .async_child .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = Some((generation, child)); true } fn clear_async(&self, generation: u64, child: &AsyncTurnChild) { let mut current = self .async_child .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if current.as_ref().is_some_and(|(stored_generation, stored)| { *stored_generation == generation && Arc::ptr_eq(stored, child) }) { *current = None; } } fn set_std(&self, generation: u64, child: StdTurnChild) -> bool { let state = self .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if !matches!(state.active, Some((active, false)) if active == generation) { return false; } *self .std_child .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = Some((generation, child)); true } fn clear_std(&self, generation: u64, child: &StdTurnChild) { let mut current = self .std_child .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if current.as_ref().is_some_and(|(stored_generation, stored)| { *stored_generation == generation && Arc::ptr_eq(stored, child) }) { *current = None; } } /// Kills the current turn process, if any, without shutting down the session. pub async fn cancel(&self) -> Result { let generation = { let mut state = self .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); match state.active { Some((_, true)) => return Ok(CancelCurrentTurnOutcome::AlreadyCancelled), Some((generation, false)) => { state.active = Some((generation, true)); generation } None => { return Ok(match state.last { Some(LastTurnState::Cancelled) => { CancelCurrentTurnOutcome::AlreadyCancelled } Some(LastTurnState::Completed) | None => { CancelCurrentTurnOutcome::AlreadyCompleted } }); } } }; let async_child = self .async_child .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .as_ref() .filter(|(child_generation, _)| *child_generation == generation) .map(|(_, child)| Arc::clone(child)); if let Some(child) = async_child { if let Err(error) = child.lock().await.kill().await { self.restore_after_cancel_failure(generation); return Err(AgentSessionError::Io(format!( "annulation du tour: {error}" ))); } } let std_child = self .std_child .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .as_ref() .filter(|(child_generation, _)| *child_generation == generation) .map(|(_, child)| Arc::clone(child)); if let Some(child) = std_child { let mut child = child .lock() .map_err(|_| AgentSessionError::Io("child lock empoisonné".to_owned()))?; if let Err(error) = child.kill() { self.restore_after_cancel_failure(generation); return Err(AgentSessionError::Io(format!( "annulation du tour: {error}" ))); } } Ok(CancelCurrentTurnOutcome::Cancelled) } } /// Lance `spec`, pousse `spec.stdin` sur l'entrée standard, **draine toutes les /// lignes de stdout jusqu'à EOF**, puis attend la fin du process. Retourne les /// lignes brutes (sans `\n`) dans l'ordre d'émission. /// /// `timeout` borne l'ensemble run+drain : à expiration le process est tué et /// [`AgentSessionError::Timeout`] est retourné (la machinerie ne suppose jamais que /// l'appelant veut attendre indéfiniment). `None` ⇒ pas de borne. /// /// Quand `spec.sandbox` porte un plan **et** qu'un `enforcer` est fourni (chemin /// Linux uniquement, lot LP4-4), le tour passe par [`run_turn_sandboxed`] : l'enfant /// est lancé sous le domaine Landlock. Sinon — et **partout** hors Linux — c'est le /// drain async tokio historique, strictement inchangé (zéro régression). /// /// # Errors /// - [`AgentSessionError::Start`] si le process ne démarre pas (binaire introuvable, /// ou enforcement de sandbox impossible : fail-closed, **aucun** enfant ne tourne) ; /// - [`AgentSessionError::Io`] sur échec de lecture/écriture des pipes ; /// - [`AgentSessionError::Timeout`] si `timeout` expire. pub async fn run_turn( spec: &SpawnLine, timeout: Option, enforcer: Option<&Arc>, line_tap: Option>, ) -> Result, AgentSessionError> { run_turn_impl(spec, timeout, enforcer, line_tap, None).await } /// Same as [`run_turn`], but tracks the live child in `cancel` while the turn runs. pub async fn run_turn_cancellable( spec: &SpawnLine, timeout: Option, enforcer: Option<&Arc>, line_tap: Option>, cancel: &TurnCancel, ) -> Result, AgentSessionError> { let lease = cancel.begin(); run_turn_impl(spec, timeout, enforcer, line_tap, Some(&lease.token)).await } async fn run_turn_impl( spec: &SpawnLine, timeout: Option, enforcer: Option<&Arc>, line_tap: Option>, cancel: Option<&TurnToken>, ) -> Result, AgentSessionError> { // Chemin SANDBOXÉ (Linux + plan posé + enforcer câblé) : transpose la technique // du PTY (`spawn_command_sandboxed`) — enforce sur un thread jetable AVANT le fork, // l'enfant hérite le domaine via fork+exec. #[cfg(target_os = "linux")] if let (Some(plan), Some(enforcer)) = (spec.sandbox.as_ref(), enforcer) { return run_turn_sandboxed( spec, plan.clone(), Arc::clone(enforcer), timeout, line_tap, cancel.cloned(), ) .await; } // Hors Linux : aucun sandboxing OS ⇒ on ignore l'enforcer (Noop de toute façon) et // on garde le drain async historique. `let _` évite l'avertissement « unused ». #[cfg(not(target_os = "linux"))] let _ = enforcer; match timeout { Some(dur) => match tokio::time::timeout(dur, drain(spec, line_tap, cancel)).await { Ok(result) => result, Err(_elapsed) => { if let Some(cancel) = cancel { let _ = cancel.cancel.cancel().await; } Err(AgentSessionError::Timeout) } }, None => drain(spec, line_tap, cancel).await, } } /// Variante **sandboxée** du tour (lot LP4-4, Linux seulement). Le drain bloquant /// `std::process` est exécuté sur un **thread jetable** : on y restreint le thread /// (`enforcer.enforce(plan)`) AVANT le `spawn`, puis on lance l'enfant. La technique /// reproduit celle du PTY ([`crate::pty`]) : `pre_exec(enforce)` est INTERDIT (Landlock /// alloue ⇒ risque de deadlock `malloc` post-`fork` en process multithreadé), on /// s'appuie donc sur l'**héritage du domaine Landlock**. /// /// ## Pourquoi pas de `pre_exec` (divergence assumée du cadrage) /// /// Le cadrage proposait un `pre_exec` **vide** pour forcer `std` sur le chemin /// déterministe `fork`+`exec` (jamais `posix_spawn`). Or `pre_exec` est `unsafe`, et /// cette crate est `#![forbid(unsafe_code)]` (invariant non contournable localement). /// On s'en passe sans perte de garantie : `landlock_restrict_self` restreint le /// **thread courant et toute sa descendance**, héritage assuré par le noyau à travers /// `fork`/`clone`/`vfork` **et** préservé par `execve` — donc aussi via `posix_spawn` /// (qui est `clone`+`execve` sous le capot), car l'enforcement vit au niveau des /// *credentials* de la tâche, hors d'atteinte de l'espace utilisateur. Le PTY n'obtenait /// le `fork`+`exec` que comme **effet de bord** du `pre_exec` interne de `portable-pty` ; /// la garantie de sécurité, elle, ne repose que sur cet héritage. Le thread meurt avec /// sa restriction, les autres threads d'IdeA ne sont jamais touchés. /// /// `enforce` fail-closed : un `Err` ⇒ [`AgentSessionError::Start`] et **aucun** enfant. /// /// ## Timeout sous sandbox /// /// Le thread bloquant n'est pas annulable « de l'extérieur ». On le réconcilie avec /// l'async par deux canaux oneshot : le thread renvoie un **killer** /// (`Arc>`) juste après le spawn, puis son résultat à la fin. On pose /// `tokio::time::timeout` sur la réception du résultat ; à expiration on **tue /// l'enfant** via le killer ⇒ EOF côté stdout ⇒ le thread sort de sa boucle de drain, /// `wait()` (reap, pas de zombie) et se termine. On renvoie alors [`AgentSessionError::Timeout`]. #[cfg(target_os = "linux")] async fn run_turn_sandboxed( spec: &SpawnLine, plan: SandboxPlan, enforcer: Arc, timeout: Option, line_tap: Option>, cancel: Option, ) -> Result, AgentSessionError> { // Données possédées : rien n'emprunte le thread jetable. let command = spec.command.clone(); let args = spec.args.clone(); let cwd = spec.cwd.clone(); let env = spec.env.clone(); let stdin = spec.stdin.clone(); let (killer_tx, killer_rx) = tokio::sync::oneshot::channel::>>(); let (done_tx, done_rx) = tokio::sync::oneshot::channel::, AgentSessionError>>(); // Thread JETABLE : sa restriction Landlock meurt avec lui. std::thread::spawn(move || { let result = drain_sandboxed( command, args, cwd, env, stdin, &enforcer, &plan, killer_tx, line_tap, cancel, ); // Le récepteur peut avoir abandonné (timeout) : on ignore l'erreur d'envoi. let _ = done_tx.send(result); }); match timeout { Some(dur) => match tokio::time::timeout(dur, done_rx).await { // Le thread a fini dans les temps (succès ou erreur métier). Ok(Ok(result)) => result, // Sender lâché sans valeur (panique du thread) ⇒ I/O. Ok(Err(_canceled)) => Err(AgentSessionError::Io( "thread sandbox terminé sans résultat".to_owned(), )), // Expiration : tue l'enfant (⇒ EOF ⇒ le thread se termine et reap). Err(_elapsed) => { if let Ok(child) = killer_rx.await { if let Ok(mut c) = child.lock() { let _ = c.kill(); } } Err(AgentSessionError::Timeout) } }, None => match done_rx.await { Ok(result) => result, Err(_canceled) => Err(AgentSessionError::Io( "thread sandbox terminé sans résultat".to_owned(), )), }, } } /// Drain **bloquant et sandboxé** exécuté sur le thread jetable (lot LP4-4, Linux). /// /// 1. `enforce(plan)` restreint CE thread (fail-closed) — la descendance hérite le /// domaine Landlock (cf. doc de [`run_turn_sandboxed`]) ; /// 2. `std::process::Command::spawn` (aucun `pre_exec` : `unsafe` interdit ici) ; /// 3. pousse le prompt sur stdin puis EOF ; /// 4. sort `stdout` du child **avant** de le partager : le drain lit sans tenir le /// `Mutex`, donc le killer (timeout) peut verrouiller et tuer à tout moment ; /// 5. draine ligne-à-ligne jusqu'à EOF ; /// 6. `wait()` (reap) — pas de zombie. #[cfg(target_os = "linux")] #[allow(clippy::too_many_arguments)] fn drain_sandboxed( command: String, args: Vec, cwd: String, env: Vec<(String, String)>, stdin_content: Option, enforcer: &Arc, plan: &SandboxPlan, killer_tx: tokio::sync::oneshot::Sender>>, line_tap: Option>, cancel: Option, ) -> Result, AgentSessionError> { use std::io::{BufRead, BufReader as StdBufReader, Read as _, Write as _}; use std::process::{Command as StdCommand, Stdio}; use std::sync::Mutex as StdMutex; // 1. Restreint CE thread AVANT tout fork (fail-closed : Err ⇒ aucun child ne tourne). enforcer .enforce(plan) .map_err(|e| AgentSessionError::Start(format!("sandbox enforcement failed: {e}")))?; // 2. Commande std (≡ `drain` async, mais synchrone). let mut cmd = StdCommand::new(&command); cmd.args(&args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); if cwd != "/" && !cwd.is_empty() { cmd.current_dir(&cwd); } for (key, value) in &env { cmd.env(key, value); } // Pas de `pre_exec` : il serait `unsafe` (interdit dans cette crate). L'enfant // hérite de toute façon le domaine Landlock posé sur ce thread (cf. doc de // `run_turn_sandboxed`), que `std` emprunte `posix_spawn` ou `fork`+`exec`. let mut child = cmd .spawn() .map_err(|e| AgentSessionError::Start(format!("{command}: {e}")))?; // 3. Pousse le prompt sur stdin puis le ferme (EOF). Erreur d'I/O ⇒ `Io`. if let Some(input) = &stdin_content { let mut si = child .stdin .take() .ok_or_else(|| AgentSessionError::Io("stdin pipe indisponible".to_owned()))?; si.write_all(input.as_bytes()) .map_err(|e| AgentSessionError::Io(e.to_string()))?; drop(si); } else { drop(child.stdin.take()); } // 4. Sort stdout AVANT de partager le child (drain sans lock ⇒ killer libre). let stdout = child .stdout .take() .ok_or_else(|| AgentSessionError::Io("stdout pipe indisponible".to_owned()))?; let stderr = child .stderr .take() .ok_or_else(|| AgentSessionError::Io("stderr pipe indisponible".to_owned()))?; let stderr_handle = std::thread::spawn(move || { let mut bytes = Vec::new(); StdBufReader::new(stderr) .read_to_end(&mut bytes) .map(|_| bytes) .map_err(|e| AgentSessionError::Io(e.to_string())) }); let child = Arc::new(StdMutex::new(child)); if let Some(cancel) = &cancel { if !cancel.cancel.set_std(cancel.generation, Arc::clone(&child)) { child .lock() .map_err(|_| AgentSessionError::Io("child lock empoisonné".to_owned()))? .kill() .map_err(|e| AgentSessionError::Io(format!("annulation du tour: {e}")))?; } } // Donne au côté async de quoi tuer l'enfant en cas de timeout. let _ = killer_tx.send(Arc::clone(&child)); // 5. Drain ligne-à-ligne jusqu'à EOF. Un kill côté async ferme stdout ⇒ EOF. let mut collected = Vec::new(); for line in StdBufReader::new(stdout).lines() { match line { Ok(l) => { if let Some(tap) = &line_tap { let _ = tap.send(l.clone()); } collected.push(l); } Err(e) => return Err(AgentSessionError::Io(e.to_string())), } } // 6. Reap (pas de zombie). Lock tenu brièvement. let status = child .lock() .map_err(|_| AgentSessionError::Io("child lock empoisonné".to_owned()))? .wait() .map_err(|e| AgentSessionError::Io(e.to_string()))?; if let Some(cancel) = &cancel { cancel.cancel.clear_std(cancel.generation, &child); } let stderr_bytes = stderr_handle .join() .map_err(|_| AgentSessionError::Io("stderr thread terminé sans résultat".to_owned()))??; if !status.success() && collected.is_empty() { return Err(AgentSessionError::Io(process_exit_message( &command, status.code(), &stderr_bytes, ))); } Ok(collected) } /// Cœur du drain : spawn → écriture stdin → lecture ligne-à-ligne → wait. async fn drain( spec: &SpawnLine, line_tap: Option>, cancel: Option<&TurnToken>, ) -> Result, AgentSessionError> { let mut cmd = Command::new(&spec.command); cmd.args(&spec.args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); if spec.cwd != "/" && !spec.cwd.is_empty() { cmd.current_dir(&spec.cwd); } for (key, value) in &spec.env { cmd.env(key, value); } let child = cmd .spawn() .map_err(|e| AgentSessionError::Start(format!("{}: {e}", spec.command)))?; let child = Arc::new(AsyncMutex::new(child)); if let Some(cancel) = cancel { if !cancel .cancel .set_async(cancel.generation, Arc::clone(&child)) { child .lock() .await .kill() .await .map_err(|e| AgentSessionError::Io(format!("annulation du tour: {e}")))?; } } // Pousse le prompt sur stdin puis le ferme (EOF) — sans quoi un process en mode // `stream-json` attend indéfiniment. Erreur d'I/O ⇒ `Io` (pas `Start`). if let Some(input) = &spec.stdin { let mut stdin = child .lock() .await .stdin .take() .ok_or_else(|| AgentSessionError::Io("stdin pipe indisponible".to_owned()))?; stdin .write_all(input.as_bytes()) .await .map_err(|e| AgentSessionError::Io(e.to_string()))?; // Drop explicite ⇒ EOF côté process. drop(stdin); } else { drop(child.lock().await.stdin.take()); } let stdout = child .lock() .await .stdout .take() .ok_or_else(|| AgentSessionError::Io("stdout pipe indisponible".to_owned()))?; let mut stderr = child .lock() .await .stderr .take() .ok_or_else(|| AgentSessionError::Io("stderr pipe indisponible".to_owned()))?; let stderr_task = tokio::spawn(async move { let mut bytes = Vec::new(); stderr.read_to_end(&mut bytes).await.map(|_| bytes) }); let mut lines = BufReader::new(stdout).lines(); let mut collected = Vec::new(); loop { match lines.next_line().await { Ok(Some(line)) => { if let Some(tap) = &line_tap { let _ = tap.send(line.clone()); } collected.push(line); } Ok(None) => break, Err(e) => return Err(map_io(e)), } } // Réifie le statut du process pour ne pas laisser de zombie ; un statut non nul // n'est pas en soi une erreur de session si la CLI a imprimé une sortie // structurée, captée ensuite par le parser. Sans stdout, il n'y a rien à parser : // on remonte le stderr réel au lieu de laisser l'appelant conclure à un no-Final. let status = child.lock().await.wait().await.map_err(map_io)?; if let Some(cancel) = cancel { cancel.cancel.clear_async(cancel.generation, &child); } let stderr_bytes = stderr_task .await .map_err(|e| AgentSessionError::Io(format!("lecture stderr: {e}")))? .map_err(map_io)?; if !status.success() && collected.is_empty() { return Err(AgentSessionError::Io(process_exit_message( &spec.command, status.code(), &stderr_bytes, ))); } Ok(collected) } /// Mappe une erreur d'I/O système sur l'erreur de port appropriée. fn map_io(e: io::Error) -> AgentSessionError { AgentSessionError::Io(e.to_string()) } fn process_exit_message(command: &str, code: Option, stderr: &[u8]) -> String { let status = code.map_or_else(|| "signal".to_owned(), |code| format!("code {code}")); let stderr = String::from_utf8_lossy(stderr); let stderr = stderr.trim(); if stderr.is_empty() { format!("{command} exited with {status} without structured output") } else { format!("{command} exited with {status}: {stderr}") } } #[cfg(test)] mod tests { use super::*; use std::time::Instant; #[tokio::test] async fn run_turn_cancellable_kills_current_child_without_waiting_for_natural_exit() { let spec = SpawnLine { command: "/bin/sh".to_owned(), args: vec![ "-c".to_owned(), "printf 'started\\n'; exec sleep 10".to_owned(), ], cwd: "/".to_owned(), env: Vec::new(), stdin: None, sandbox: None, }; let cancel = TurnCancel::new(); let started = Instant::now(); let worker = tokio::spawn({ let cancel = cancel.clone(); async move { run_turn_cancellable(&spec, None, None, None, &cancel).await } }); tokio::time::sleep(Duration::from_millis(100)).await; assert_eq!( cancel.cancel().await.expect("cancel current child"), CancelCurrentTurnOutcome::Cancelled ); assert_eq!( cancel.cancel().await.expect("repeat cancel"), CancelCurrentTurnOutcome::AlreadyCancelled ); let lines = worker.await.expect("worker joins").expect("drain result"); assert_eq!(lines, vec!["started"]); assert!( started.elapsed() < Duration::from_secs(3), "cancel should not wait for the child process natural exit" ); assert_eq!( cancel.cancel().await.expect("cancel after cancelled turn"), CancelCurrentTurnOutcome::AlreadyCancelled ); } #[tokio::test] async fn cancel_after_natural_completion_is_distinct_from_a_missing_session() { let spec = SpawnLine { command: "/bin/sh".to_owned(), args: vec!["-c".to_owned(), "printf 'done\\n'".to_owned()], cwd: "/".to_owned(), env: Vec::new(), stdin: None, sandbox: None, }; let cancel = TurnCancel::new(); let lines = run_turn_cancellable(&spec, None, None, None, &cancel) .await .expect("turn completes"); assert_eq!(lines, ["done"]); assert_eq!( cancel.cancel().await.expect("terminal cancel is harmless"), CancelCurrentTurnOutcome::AlreadyCompleted ); } #[tokio::test] async fn cancel_before_child_registration_kills_that_same_generation_on_registration() { let cancel = TurnCancel::new(); let turn = cancel.begin(); assert_eq!( cancel.cancel().await.expect("pre-spawn cancel"), CancelCurrentTurnOutcome::Cancelled ); let child = Command::new("/bin/sh") .args(["-c", "sleep 10"]) .spawn() .expect("spawn delayed child"); let child = Arc::new(AsyncMutex::new(child)); turn.track_async(Arc::clone(&child)) .await .expect("cancelled generation kills child at registration"); let status = tokio::time::timeout(Duration::from_secs(2), child.lock().await.wait()) .await .expect("child exits promptly") .expect("child wait succeeds"); assert!(!status.success()); } }