Files
IdeA/crates/infrastructure/tests/pty_adapter.rs
Blomios 62b71776f5 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>
2026-07-14 23:42:40 +02:00

280 lines
9.9 KiB
Rust

//! L3 integration tests for [`PortablePtyAdapter`] — exercising a **real** OS
//! PTY on Linux. We spawn tiny `/bin/sh` programs whose output is deterministic,
//! drain the blocking output stream on a dedicated thread, and assert on the
//! bytes / exit code.
//!
//! Robustness: every blocking drain runs on its own thread joined with a bounded
//! timeout so a misbehaving PTY can never hang the test suite/CI.
#![cfg(unix)]
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use domain::ports::{PtyPort, SpawnSpec};
use domain::{ProjectPath, PtySize};
use infrastructure::PortablePtyAdapter;
/// Hard ceiling for any single PTY interaction in these tests.
const TIMEOUT: Duration = Duration::from_secs(10);
fn sh_spec(script: &str) -> SpawnSpec {
SpawnSpec {
command: "/bin/sh".to_owned(),
args: vec!["-c".to_owned(), script.to_owned()],
cwd: ProjectPath::new("/").unwrap(),
env: Vec::new(),
context_plan: None,
sandbox: None,
}
}
fn size() -> PtySize {
PtySize::new(24, 80).unwrap()
}
/// Drains an output stream to a single `Vec<u8>` on a worker thread, returning
/// the collected bytes or panicking if it does not finish within `TIMEOUT`.
fn drain_with_timeout(stream: domain::ports::OutputStream, timeout: Duration) -> Vec<u8> {
let (tx, rx) = mpsc::channel();
let worker = thread::spawn(move || {
let mut all = Vec::new();
for chunk in stream {
all.extend_from_slice(&chunk);
}
let _ = tx.send(all);
});
let bytes = rx
.recv_timeout(timeout)
.expect("output stream drained within timeout");
worker.join().expect("drain thread joined");
bytes
}
#[tokio::test]
async fn spawn_printf_streams_expected_bytes_and_exits_zero() {
let pty = PortablePtyAdapter::new();
let handle = pty
.spawn(sh_spec("printf hello-pty"), size())
.await
.expect("spawn succeeds");
let stream = pty.subscribe_output(&handle).expect("subscribe once");
let bytes = drain_with_timeout(stream, TIMEOUT);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("hello-pty"),
"expected output to contain 'hello-pty', got {text:?}"
);
// Process already exited; kill collects the status. `sh` exiting cleanly → 0.
let status = pty.kill(&handle).await.expect("kill succeeds");
assert_eq!(status.code, Some(0), "clean exit reports code 0");
}
#[tokio::test]
async fn write_is_echoed_back_through_output_stream() {
// `cat` echoes its stdin back to stdout; we feed it a line then close stdin
// by killing it, and assert we saw the echoed bytes.
let pty = PortablePtyAdapter::new();
let handle = pty.spawn(sh_spec("cat"), size()).await.expect("spawn cat");
let stream = pty.subscribe_output(&handle).expect("subscribe once");
// Look for the marker on a worker thread, with a timeout, so we don't block
// forever if `cat` never echoes.
let (found_tx, found_rx) = mpsc::channel();
let worker = thread::spawn(move || {
let mut all = Vec::new();
for chunk in stream {
all.extend_from_slice(&chunk);
if String::from_utf8_lossy(&all).contains("marker-123") {
let _ = found_tx.send(true);
// Keep draining until EOF so the thread can exit on kill.
}
}
});
pty.write(&handle, b"marker-123\n").expect("write to cat");
let found = found_rx
.recv_timeout(TIMEOUT)
.expect("echoed marker observed within timeout");
assert!(found, "cat echoed the written bytes back");
pty.kill(&handle).await.expect("kill cat");
worker.join().expect("drain thread joined after kill");
}
#[tokio::test]
async fn subscribe_output_is_re_subscribable_for_reattach() {
// A live PTY can be subscribed to more than once over its lifetime: the
// first view detaches (drops its stream), a second view re-attaches and
// still receives subsequent output — the core of the no-kill navigation fix.
let pty = PortablePtyAdapter::new();
let handle = pty.spawn(sh_spec("cat"), size()).await.expect("spawn cat");
// First attachment: subscribe, observe an echo, then drop the stream
// (simulating a view tearing down on navigation — NOT a kill).
{
let first = pty.subscribe_output(&handle).expect("first subscribe");
let (tx, rx) = mpsc::channel();
let worker = thread::spawn(move || {
let mut all = Vec::new();
for chunk in first {
all.extend_from_slice(&chunk);
if String::from_utf8_lossy(&all).contains("first-marker") {
let _ = tx.send(());
return; // drop the stream → detach
}
}
});
pty.write(&handle, b"first-marker\n").expect("write 1");
rx.recv_timeout(TIMEOUT).expect("first view saw its marker");
worker.join().expect("first worker joined");
}
// Second attachment to the SAME live PTY (no re-spawn): must still receive
// new output produced after re-subscription.
let second = pty.subscribe_output(&handle).expect("re-subscribe");
let (tx, rx) = mpsc::channel();
let worker = thread::spawn(move || {
let mut all = Vec::new();
for chunk in second {
all.extend_from_slice(&chunk);
if String::from_utf8_lossy(&all).contains("second-marker") {
let _ = tx.send(());
}
}
});
pty.write(&handle, b"second-marker\n").expect("write 2");
rx.recv_timeout(TIMEOUT)
.expect("re-attached view saw new output");
pty.kill(&handle).await.expect("kill cat");
worker.join().expect("second worker joined after kill");
}
#[tokio::test]
async fn scrollback_retains_recent_output_for_repaint() {
// After output is produced and the process exits, the scrollback still holds
// the recent bytes so a re-attaching view can repaint them.
let pty = PortablePtyAdapter::new();
let handle = pty
.spawn(sh_spec("printf scrollback-content"), size())
.await
.expect("spawn");
// Drain to EOF so all output has been pushed into the ring buffer.
let stream = pty.subscribe_output(&handle).expect("subscribe");
drain_with_timeout(stream, TIMEOUT);
let sb = pty.scrollback(&handle).expect("scrollback readable");
let text = String::from_utf8_lossy(&sb);
assert!(
text.contains("scrollback-content"),
"scrollback should retain recent output, got {text:?}"
);
let _ = pty.kill(&handle).await;
}
#[tokio::test]
async fn wait_reports_short_command_exit_without_output_drain() {
let pty = PortablePtyAdapter::new();
let handle = pty
.spawn(sh_spec("printf wait-decoupled"), size())
.await
.expect("spawn");
let status = pty.wait(&handle).await.expect("wait succeeds");
assert_eq!(status.code, Some(0));
let sb = pty
.scrollback(&handle)
.expect("scrollback readable after wait");
let text = String::from_utf8_lossy(&sb);
assert!(
text.contains("wait-decoupled"),
"reader thread still collects output independently, got {text:?}"
);
let cleanup = pty.kill(&handle).await.expect("cleanup kill succeeds");
assert_eq!(cleanup, status, "cleanup returns memorized exit status");
assert!(
pty.scrollback(&handle).is_err(),
"cleanup purges the session"
);
}
#[tokio::test]
async fn scrollback_is_bounded_to_cap_and_keeps_most_recent_bytes() {
// Emit clearly more than 100 KB of deterministic output, then assert the
// retained scrollback is bounded and ends with the most recent bytes.
let pty = PortablePtyAdapter::new();
// 5000 lines of "....END<n>" → well over 100 KB; the tail is the freshest.
let script = "i=0; while [ $i -lt 5000 ]; do \
printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-%d\\n' $i; \
i=$((i+1)); done; printf 'FINAL-LINE-MARKER'";
let handle = pty.spawn(sh_spec(script), size()).await.expect("spawn");
let stream = pty.subscribe_output(&handle).expect("subscribe");
drain_with_timeout(stream, TIMEOUT);
let sb = pty.scrollback(&handle).expect("scrollback readable");
assert!(
sb.len() <= 100 * 1024,
"scrollback must be bounded to ~100 KB, was {} bytes",
sb.len()
);
// The newest output is retained even though the oldest was dropped.
let text = String::from_utf8_lossy(&sb);
assert!(
text.contains("FINAL-LINE-MARKER"),
"the most recent bytes must be kept in the ring buffer"
);
// And the very first lines must have been evicted.
assert!(
!text.contains("-0\n") || sb.len() < 100 * 1024,
"oldest bytes should be dropped once the cap is exceeded"
);
let _ = pty.kill(&handle).await;
}
#[tokio::test]
async fn write_resize_kill_on_unknown_handle_are_not_found() {
use domain::ports::{PtyError, PtyHandle};
use domain::SessionId;
let pty = PortablePtyAdapter::new();
let ghost = PtyHandle {
session_id: SessionId::new_random(),
};
assert_eq!(pty.write(&ghost, b"x"), Err(PtyError::NotFound));
assert_eq!(pty.resize(&ghost, size()), Err(PtyError::NotFound));
assert!(pty.subscribe_output(&ghost).is_err());
assert_eq!(pty.try_wait(&ghost), Err(PtyError::NotFound));
assert_eq!(pty.wait(&ghost).await, Err(PtyError::NotFound));
assert_eq!(pty.kill(&ghost).await, Err(PtyError::NotFound));
}
#[tokio::test]
async fn resize_on_live_pty_succeeds() {
let pty = PortablePtyAdapter::new();
let handle = pty
.spawn(sh_spec("sleep 0.2"), size())
.await
.expect("spawn");
pty.resize(&handle, PtySize::new(40, 120).unwrap())
.expect("resize a live pty succeeds");
// Drain + reap so the test leaves no live process/thread behind.
let stream = pty.subscribe_output(&handle).expect("subscribe");
let _ = thread::spawn(move || stream.count());
let _ = pty.kill(&handle).await;
}