fix(background-task): découpler la détection de fin de tâche du drain output PTY (#2)

Le runner de tâches de fond détectait la fin d'un process via l'EOF du drain de
sortie PTY, couplant le cycle de vie du process au flux d'output. Un output
encore ouvert (ou drainé ailleurs) pouvait masquer ou retarder la détection de
fin.

Ajoute wait/try_wait au port figé PtyPort :
- wait : bloquant, rend un ExitStatus idempotent ;
- try_wait : non bloquant, rend Option<ExitStatus> ;
- exit status mémorisé pour garder wait/try_wait/kill cohérents.
Implémentation dans PortablePtyAdapter (état d'exit mémorisé). Le
CommandBackgroundRunner détecte désormais la fin via pty.wait (select sur
cancel/deadline) au lieu de l'EOF du drain. Tous les fakes PtyPort du workspace
sont complétés en conséquence.

Le tee live UI reste hors périmètre (traité en #58). Aucun breaking IPC/front.

Tests : infrastructure/tests/background_task_runner.rs (nouveau) + pty_adapter.rs
verts, non-régression application/app-tauri OK (hors échecs réseau du sandbox).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 23:42:40 +02:00
parent c6e34483d3
commit 62b71776f5
15 changed files with 550 additions and 37 deletions

View File

@ -17,21 +17,19 @@
//!
//! # Lifecycle of one task
//!
//! `spawn` opens the PTY and starts a detached worker. The worker watches the
//! PTY's output stream for EOF (natural process exit), a cancel notification, or
//! the deadline, whichever comes first. It then snapshots the bounded output tail
//! from the PTY scrollback, reaps the real exit status via
//! `spawn` opens the PTY and starts a detached worker. The worker waits on the
//! PTY process status via [`PtyPort::wait`](domain::ports::PtyPort::wait), a
//! cancel notification, or the deadline, whichever comes first. It then snapshots
//! the bounded output tail from the PTY scrollback, cleans up via
//! [`PtyPort::kill`](domain::ports::PtyPort::kill), maps that to a terminal
//! [`BackgroundTaskResult`], and sends the completion.
//!
//! # Output tail & the live tee
//!
//! A PTY **merges** stdout and stderr onto one stream, so the completion carries
//! the merged output in `stdout_tail` and leaves `stderr_tail` empty. Exit
//! detection reads the PTY's single-consumer output subscription; a *live tee*
//! that re-subscribes the same PTY (to render it in an xterm cell) would supersede
//! the runner's subscription and break EOF detection — teeing a running command
//! therefore requires a multi-consumer PTY broadcast, which is out of scope here.
//! the merged output in `stdout_tail` and leaves `stderr_tail` empty. Completion
//! detection is deliberately decoupled from output subscriptions; the tail is read
//! from PTY scrollback, while process lifecycle comes from `wait`.
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
@ -56,7 +54,7 @@ const DEFAULT_SIZE: PtySize = PtySize { rows: 24, cols: 80 };
/// Why the worker's terminal wait resolved.
enum WaitOutcome {
/// The process exited on its own (stream hit EOF).
/// The process exited on its own.
Exited,
/// A [`cancel`](BackgroundTaskRunner::cancel) was requested.
Cancelled,
@ -184,8 +182,8 @@ impl CommandBackgroundRunner {
deadline_ms: Option<u64>,
clock: &Arc<dyn Clock>,
) -> WaitOutcome {
let eof = Self::wait_for_eof(Arc::clone(pty), control.pty_handle.clone());
tokio::pin!(eof);
let process_exit = pty.wait(&control.pty_handle);
tokio::pin!(process_exit);
let sleep_ms = deadline_ms.map(|deadline| {
let now = u64::try_from(clock.now_millis().max(0)).unwrap_or(0);
@ -194,30 +192,22 @@ impl CommandBackgroundRunner {
match sleep_ms {
Some(ms) => tokio::select! {
() = &mut eof => WaitOutcome::Exited,
result = &mut process_exit => {
let _ = result;
WaitOutcome::Exited
},
() = control.cancel.notified() => WaitOutcome::Cancelled,
() = tokio::time::sleep(std::time::Duration::from_millis(ms)) => WaitOutcome::Expired,
},
None => tokio::select! {
() = &mut eof => WaitOutcome::Exited,
result = &mut process_exit => {
let _ = result;
WaitOutcome::Exited
},
() = control.cancel.notified() => WaitOutcome::Cancelled,
},
}
}
/// Resolves when the PTY output stream ends (the process has exited).
///
/// The domain `OutputStream` is a blocking iterator, so it is drained on a
/// blocking task and its bytes discarded — the persisted tail is read from the
/// PTY scrollback instead, which the reader thread fills regardless of
/// subscribers. If the handle is already unknown, resolves immediately so the
/// worker proceeds to reap.
async fn wait_for_eof(pty: Arc<dyn PtyPort>, handle: PtyHandle) {
let Ok(stream) = pty.subscribe_output(&handle) else {
return;
};
let _ = tokio::task::spawn_blocking(move || for _chunk in stream {}).await;
}
}
#[async_trait]

View File

@ -1674,6 +1674,12 @@ mod tests {
fn scrollback(&self, _handle: &Handle) -> Result<Vec<u8>, PtyError> {
Ok(Vec::new())
}
async fn wait(&self, _handle: &Handle) -> Result<ExitStatus, PtyError> {
Ok(ExitStatus { code: Some(0) })
}
fn try_wait(&self, _handle: &Handle) -> Result<Option<ExitStatus>, PtyError> {
Ok(Some(ExitStatus { code: Some(0) }))
}
async fn kill(&self, _handle: &Handle) -> Result<ExitStatus, PtyError> {
Ok(ExitStatus { code: Some(0) })
}

View File

@ -40,6 +40,7 @@ use std::io::{Read, Write};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;
use async_trait::async_trait;
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySystem, SlavePty};
@ -57,6 +58,9 @@ const READ_BUF: usize = 8 * 1024;
/// oldest bytes are dropped so a re-attaching view repaints recent history.
const SCROLLBACK_CAP: usize = 100 * 1024;
type SharedChild = Arc<Mutex<Option<Box<dyn Child + Send + Sync>>>>;
type SharedExitStatus = Arc<Mutex<Option<ExitStatus>>>;
/// The shared output hub of one PTY: a bounded scrollback ring buffer plus the
/// **single** currently-subscribed receiver. The reader thread feeds both; a
/// view subscribes on (re-)attach, and each new subscription supersedes the
@ -129,8 +133,12 @@ struct LivePty {
master: Box<dyn MasterPty + Send>,
/// Writer into the PTY (child stdin).
writer: Box<dyn Write + Send>,
/// The spawned child process.
child: Box<dyn Child + Send + Sync>,
/// The spawned child process, retained until wait/try_wait/kill observes its
/// exit status.
child: SharedChild,
/// Memorized exit status. Kept while the PTY session is registered so
/// wait/try_wait/kill are idempotent relative to each other.
exit_status: SharedExitStatus,
/// Shared scrollback + subscriber hub, fed by the reader thread.
output: Arc<Mutex<Broadcast>>,
/// Handle of the reader thread, joined on kill.
@ -241,6 +249,88 @@ fn to_command(spec: &SpawnSpec) -> CommandBuilder {
cmd
}
fn portable_status(status: &portable_pty::ExitStatus) -> ExitStatus {
ExitStatus {
code: exit_code(status),
}
}
fn try_wait_shared(
child: &SharedChild,
exit_status: &SharedExitStatus,
) -> Result<Option<ExitStatus>, PtyError> {
if let Some(status) = *exit_status
.lock()
.map_err(|_| PtyError::Io("pty exit status poisoned".to_owned()))?
{
return Ok(Some(status));
}
let observed = {
let mut child_guard = child
.lock()
.map_err(|_| PtyError::Io("pty child lock poisoned".to_owned()))?;
let Some(child) = child_guard.as_mut() else {
return Ok(*exit_status
.lock()
.map_err(|_| PtyError::Io("pty exit status poisoned".to_owned()))?);
};
match child.try_wait().map_err(|e| PtyError::Io(e.to_string()))? {
Some(status) => {
let status = portable_status(&status);
*child_guard = None;
Some(status)
}
None => None,
}
};
if let Some(status) = observed {
*exit_status
.lock()
.map_err(|_| PtyError::Io("pty exit status poisoned".to_owned()))? = Some(status);
Ok(Some(status))
} else {
Ok(None)
}
}
fn kill_shared(
child: &SharedChild,
exit_status: &SharedExitStatus,
) -> Result<ExitStatus, PtyError> {
if let Some(status) = *exit_status
.lock()
.map_err(|_| PtyError::Io("pty exit status poisoned".to_owned()))?
{
return Ok(status);
}
let status = {
let mut child_guard = child
.lock()
.map_err(|_| PtyError::Io("pty child lock poisoned".to_owned()))?;
let Some(child) = child_guard.as_mut() else {
return exit_status
.lock()
.map_err(|_| PtyError::Io("pty exit status poisoned".to_owned()))?
.ok_or_else(|| PtyError::Io("pty child missing exit status".to_owned()));
};
let _ = child.kill();
let raw = child.wait().map_err(|e| PtyError::Io(e.to_string()))?;
let status = portable_status(&raw);
*child_guard = None;
status
};
*exit_status
.lock()
.map_err(|_| PtyError::Io("pty exit status poisoned".to_owned()))? = Some(status);
Ok(status)
}
#[async_trait]
impl PtyPort for PortablePtyAdapter {
async fn spawn(&self, spec: SpawnSpec, size: PtySize) -> Result<PtyHandle, PtyError> {
@ -311,7 +401,8 @@ impl PtyPort for PortablePtyAdapter {
let live = LivePty {
master: pair.master,
writer,
child,
child: Arc::new(Mutex::new(Some(child))),
exit_status: Arc::new(Mutex::new(None)),
output,
reader: Some(reader_handle),
};
@ -373,6 +464,27 @@ impl PtyPort for PortablePtyAdapter {
Ok(snapshot)
}
async fn wait(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
loop {
if let Some(status) = self.try_wait(handle)? {
return Ok(status);
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
fn try_wait(&self, handle: &PtyHandle) -> Result<Option<ExitStatus>, PtyError> {
let (child, exit_status) = {
let map = self
.sessions
.lock()
.map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?;
let live = map.get(&handle.session_id).ok_or(PtyError::NotFound)?;
(Arc::clone(&live.child), Arc::clone(&live.exit_status))
};
try_wait_shared(&child, &exit_status)
}
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
// Remove from the registry so the writer/master drop and the child is
// fully owned here while we tear it down.
@ -384,9 +496,9 @@ impl PtyPort for PortablePtyAdapter {
map.remove(&handle.session_id).ok_or(PtyError::NotFound)?
};
// Ask the child to terminate, then wait for its real status.
let _ = live.child.kill();
let status = live.child.wait().map_err(|e| PtyError::Io(e.to_string()))?;
// Ask the child to terminate, unless wait/try_wait already memorized its
// natural exit status.
let status = kill_shared(&live.child, &live.exit_status)?;
// Dropping master/writer closes the PTY; the reader thread then sees EOF.
// Dropping the broadcast hub drops every subscriber's sender, so any
@ -395,9 +507,7 @@ impl PtyPort for PortablePtyAdapter {
let _ = reader.join();
}
Ok(ExitStatus {
code: exit_code(&status),
})
Ok(status)
}
}