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:
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user