Files
IdeA/crates/infrastructure/src/session/process.rs

820 lines
30 KiB
Rust

//! 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<String>,
/// 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<String>,
/// 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<SandboxPlan>,
}
/// 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<LastTurnState>,
}
type AsyncTurnChild = Arc<AsyncMutex<tokio::process::Child>>;
type StdTurnChild = Arc<StdMutex<std::process::Child>>;
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<StdMutex<TurnCancelState>>,
async_child: Arc<StdMutex<TrackedAsyncTurnChild>>,
std_child: Arc<StdMutex<TrackedStdTurnChild>>,
}
#[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<CancelCurrentTurnOutcome, AgentSessionError> {
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<Duration>,
enforcer: Option<&Arc<dyn SandboxEnforcer>>,
line_tap: Option<Sender<String>>,
) -> Result<Vec<String>, 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<Duration>,
enforcer: Option<&Arc<dyn SandboxEnforcer>>,
line_tap: Option<Sender<String>>,
cancel: &TurnCancel,
) -> Result<Vec<String>, 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<Duration>,
enforcer: Option<&Arc<dyn SandboxEnforcer>>,
line_tap: Option<Sender<String>>,
cancel: Option<&TurnToken>,
) -> Result<Vec<String>, 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<Mutex<Child>>`) 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<dyn SandboxEnforcer>,
timeout: Option<Duration>,
line_tap: Option<Sender<String>>,
cancel: Option<TurnToken>,
) -> Result<Vec<String>, 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::<Arc<StdMutex<std::process::Child>>>();
let (done_tx, done_rx) =
tokio::sync::oneshot::channel::<Result<Vec<String>, 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<String>,
cwd: String,
env: Vec<(String, String)>,
stdin_content: Option<String>,
enforcer: &Arc<dyn SandboxEnforcer>,
plan: &SandboxPlan,
killer_tx: tokio::sync::oneshot::Sender<Arc<std::sync::Mutex<std::process::Child>>>,
line_tap: Option<Sender<String>>,
cancel: Option<TurnToken>,
) -> Result<Vec<String>, 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<Sender<String>>,
cancel: Option<&TurnToken>,
) -> Result<Vec<String>, 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<i32>, 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());
}
}