Files
IdeA/crates/infrastructure/tests/background_task_runner.rs
Blomios b734c237d3 feat(background-task): rendu live des tâches de fond — subscriber UI + canal IPC (#58)
Câble un canal attachable au flux de sortie d'une tâche de fond (runner
infrastructure + commande app-tauri + port/adaptateurs frontend) et le
panneau ProjectWorkStatePanel s'y abonne pour un rendu live au lieu d'un
état figé au dernier snapshot.

Validations obtenues avant commit :
- cargo test -p infrastructure --test background_task_runner : vert
- cargo check -p backend -p app-tauri : vert
- npx vitest run src/features/workstate/workstate.test.tsx : vert
- npm run typecheck : vert
- verdict QA #58 : vert

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 16:48:29 +02:00

347 lines
10 KiB
Rust

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use domain::ports::{
BackgroundTaskRunner, Clock, ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort, SpawnSpec,
};
use domain::{
AgentId, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy, ProjectId,
ProjectPath, PtySize, SessionId, TaskId,
};
use infrastructure::CommandBackgroundRunner;
use uuid::Uuid;
#[derive(Default)]
struct FakeClock(Mutex<i64>);
impl FakeClock {
fn set(&self, now: i64) {
*self.0.lock().unwrap() = now;
}
}
impl Clock for FakeClock {
fn now_millis(&self) -> i64 {
*self.0.lock().unwrap()
}
}
#[derive(Default)]
struct FakePtyState {
handle: Option<PtyHandle>,
status: Option<ExitStatus>,
kills: usize,
scrollback: Vec<u8>,
}
#[derive(Default)]
struct FakePty {
state: Mutex<FakePtyState>,
wait_changed: tokio::sync::Notify,
subscribe_count: AtomicUsize,
}
impl FakePty {
fn complete(&self, code: Option<i32>) {
self.state.lock().unwrap().status = Some(ExitStatus { code });
self.wait_changed.notify_waiters();
}
fn set_scrollback(&self, bytes: &[u8]) {
self.state.lock().unwrap().scrollback = bytes.to_vec();
}
fn kill_count(&self) -> usize {
self.state.lock().unwrap().kills
}
fn subscribe_count(&self) -> usize {
self.subscribe_count.load(Ordering::SeqCst)
}
}
#[async_trait]
impl PtyPort for FakePty {
async fn spawn(&self, _spec: SpawnSpec, _size: PtySize) -> Result<PtyHandle, PtyError> {
let handle = PtyHandle {
session_id: SessionId::from_uuid(Uuid::from_u128(0x777)),
};
self.state.lock().unwrap().handle = Some(handle.clone());
Ok(handle)
}
fn write(&self, _handle: &PtyHandle, _data: &[u8]) -> Result<(), PtyError> {
Ok(())
}
fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> {
Ok(())
}
fn subscribe_output(&self, _handle: &PtyHandle) -> Result<OutputStream, PtyError> {
self.subscribe_count.fetch_add(1, Ordering::SeqCst);
Ok(Box::new(std::iter::empty()))
}
fn scrollback(&self, _handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
Ok(self.state.lock().unwrap().scrollback.clone())
}
async fn wait(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
loop {
if let Some(status) = self.state.lock().unwrap().status {
return Ok(status);
}
self.wait_changed.notified().await;
}
}
fn try_wait(&self, _handle: &PtyHandle) -> Result<Option<ExitStatus>, PtyError> {
Ok(self.state.lock().unwrap().status)
}
async fn kill(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
let mut state = self.state.lock().unwrap();
state.kills += 1;
let status = state.status.get_or_insert(ExitStatus { code: Some(130) });
Ok(*status)
}
}
fn id(n: u128) -> Uuid {
Uuid::from_u128(n)
}
fn spawn_spec(task_id: TaskId, deadline_ms: Option<u64>) -> domain::ports::BackgroundTaskSpec {
domain::ports::BackgroundTaskSpec {
task_id,
project_id: ProjectId::from_uuid(id(1)),
owner_agent_id: AgentId::from_uuid(id(2)),
kind: BackgroundTaskKind::Command {
label: "test command".to_owned(),
},
wake_policy: BackgroundTaskWakePolicy::RecordOnly,
command: Some(SpawnSpec {
command: "/bin/sh".to_owned(),
args: vec!["-c".to_owned(), "echo test".to_owned()],
cwd: ProjectPath::new("/tmp").unwrap(),
env: Vec::new(),
context_plan: None,
sandbox: None,
}),
deadline_ms,
}
}
fn runner_with(
pty: Arc<FakePty>,
clock: Arc<FakeClock>,
) -> (
CommandBackgroundRunner,
domain::ports::BackgroundCompletionStream,
) {
let runner = CommandBackgroundRunner::new(pty, clock);
let completions = runner.subscribe_completions();
(runner, completions)
}
#[tokio::test]
async fn completes_from_wait_without_draining_output() {
let pty = Arc::new(FakePty::default());
pty.set_scrollback(b"hello from scrollback");
let clock = Arc::new(FakeClock::default());
let (runner, mut completions) = runner_with(Arc::clone(&pty), clock);
let task_id = TaskId::from_uuid(id(10));
runner
.spawn(spawn_spec(task_id, None))
.await
.expect("spawn succeeds");
pty.complete(Some(0));
let completion = tokio::time::timeout(
Duration::from_secs(1),
tokio::task::spawn_blocking(move || completions.next().expect("completion")),
)
.await
.expect("completion arrives")
.expect("completion thread joins");
assert_eq!(completion.task_id, task_id);
match completion.result {
BackgroundTaskResult::Success {
exit_code,
stdout_tail,
..
} => {
assert_eq!(exit_code, Some(0));
assert_eq!(stdout_tail.as_deref(), Some("hello from scrollback"));
}
other => panic!("expected success, got {other:?}"),
}
assert_eq!(pty.subscribe_count(), 0, "runner must not drain output");
assert_eq!(pty.kill_count(), 1, "natural exit is cleaned up once");
}
#[tokio::test]
async fn ui_subscriber_does_not_interfere_with_runner_completion() {
let pty = Arc::new(FakePty::default());
let clock = Arc::new(FakeClock::default());
let (runner, mut completions) = runner_with(Arc::clone(&pty), clock);
let task_id = TaskId::from_uuid(id(11));
runner
.spawn(spawn_spec(task_id, None))
.await
.expect("spawn succeeds");
let handle = runner
.pty_handle_for(task_id)
.expect("runner exposes live PTY handle");
let _ui_stream = pty.subscribe_output(&handle).expect("ui subscribes");
pty.complete(Some(0));
let completion = tokio::time::timeout(
Duration::from_secs(1),
tokio::task::spawn_blocking(move || completions.next().expect("completion")),
)
.await
.expect("completion arrives")
.expect("completion thread joins");
assert_eq!(completion.task_id, task_id);
assert!(matches!(
completion.result,
BackgroundTaskResult::Success { .. }
));
assert_eq!(
pty.subscribe_count(),
1,
"only the explicit UI subscription should exist"
);
}
#[tokio::test]
async fn double_ui_subscriber_uses_independent_pty_streams() {
let pty = Arc::new(FakePty::default());
let clock = Arc::new(FakeClock::default());
let (runner, _completions) = runner_with(Arc::clone(&pty), clock);
let task_id = TaskId::from_uuid(id(14));
runner
.spawn(spawn_spec(task_id, None))
.await
.expect("spawn succeeds");
let handle = runner
.pty_handle_for(task_id)
.expect("runner exposes live PTY handle");
let _first = pty.subscribe_output(&handle).expect("first ui subscribes");
let _second = pty.subscribe_output(&handle).expect("second ui subscribes");
assert_eq!(
pty.subscribe_count(),
2,
"each UI attach must get its own PTY subscription"
);
assert_eq!(
runner.pty_handle_for(task_id),
Some(handle),
"UI subscribers must not remove the runner's live handle"
);
}
#[tokio::test]
async fn completed_task_no_longer_exposes_live_pty_handle_for_attach() {
let pty = Arc::new(FakePty::default());
pty.set_scrollback(b"final tail");
let clock = Arc::new(FakeClock::default());
let (runner, mut completions) = runner_with(Arc::clone(&pty), clock);
let task_id = TaskId::from_uuid(id(15));
runner
.spawn(spawn_spec(task_id, None))
.await
.expect("spawn succeeds");
assert!(runner.pty_handle_for(task_id).is_some());
pty.complete(Some(0));
let completion = tokio::time::timeout(
Duration::from_secs(1),
tokio::task::spawn_blocking(move || completions.next().expect("completion")),
)
.await
.expect("completion arrives")
.expect("completion thread joins");
assert_eq!(completion.task_id, task_id);
assert_eq!(runner.pty_handle_for(task_id), None);
assert_eq!(
pty.subscribe_count(),
0,
"attach after completion must not subscribe to a dead PTY handle"
);
}
#[tokio::test]
async fn deadline_expires_and_kills_when_wait_never_resolves() {
let pty = Arc::new(FakePty::default());
let clock = Arc::new(FakeClock::default());
clock.set(100);
let (runner, mut completions) = runner_with(Arc::clone(&pty), Arc::clone(&clock));
let task_id = TaskId::from_uuid(id(12));
runner
.spawn(spawn_spec(task_id, Some(101)))
.await
.expect("spawn succeeds");
let completion = tokio::time::timeout(
Duration::from_secs(1),
tokio::task::spawn_blocking(move || completions.next().expect("completion")),
)
.await
.expect("completion arrives")
.expect("completion thread joins");
assert_eq!(completion.task_id, task_id);
assert!(matches!(
completion.result,
BackgroundTaskResult::Expired { .. }
));
assert_eq!(pty.kill_count(), 1);
assert_eq!(pty.subscribe_count(), 0);
}
#[tokio::test]
async fn cancel_wins_even_if_output_is_still_available() {
let pty = Arc::new(FakePty::default());
pty.set_scrollback(b"still streaming");
let clock = Arc::new(FakeClock::default());
let (runner, mut completions) = runner_with(Arc::clone(&pty), clock);
let task_id = TaskId::from_uuid(id(13));
runner
.spawn(spawn_spec(task_id, None))
.await
.expect("spawn succeeds");
runner.cancel(task_id).await.expect("cancel succeeds");
let completion = tokio::time::timeout(
Duration::from_secs(1),
tokio::task::spawn_blocking(move || completions.next().expect("completion")),
)
.await
.expect("completion arrives")
.expect("completion thread joins");
assert_eq!(completion.task_id, task_id);
assert!(matches!(
completion.result,
BackgroundTaskResult::Cancelled { .. }
));
assert_eq!(pty.kill_count(), 1);
assert_eq!(pty.subscribe_count(), 0);
}