use super::*;
use crate::lifecycle::actor::WorkloadOutcome;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkloadClaim {
Api,
Initial,
}
#[async_trait::async_trait]
pub trait WorkloadSlot: Send + Sync {
async fn claim(&self, claim: WorkloadClaim) -> Result<()>;
fn release(&self);
fn exited(&self, outcome: WorkloadOutcome);
}
fn spawn_exit_watcher(
inner_rx: tokio::sync::mpsc::Receiver<Result<OutputChunk>>,
slot: Arc<dyn WorkloadSlot>,
) -> tokio::sync::mpsc::Receiver<Result<OutputChunk>> {
let (wrapped_tx, wrapped_rx) = tokio::sync::mpsc::channel(64);
tokio::spawn(async move {
let mut inner_rx = inner_rx;
let mut consumer_gone = false;
while let Some(result) = inner_rx.recv().await {
if let Ok(OutputChunk::Exit(status)) = &result {
slot.exited(WorkloadOutcome::Exited(*status));
}
if !consumer_gone && wrapped_tx.send(result).await.is_err() {
consumer_gone = true;
}
}
});
wrapped_rx
}
pub async fn start_run_workload(
agent: &dyn GuestAgent,
start: StartCommand,
slot: Arc<dyn WorkloadSlot>,
claim: WorkloadClaim,
) -> Result<tokio::sync::mpsc::Receiver<Result<OutputChunk>>> {
slot.claim(claim).await?;
let inner_rx = match agent.run(start).await {
Ok(rx) => rx,
Err(error) => {
slot.release();
return Err(error);
}
};
Ok(spawn_exit_watcher(inner_rx, slot))
}
impl ComputerManager {
pub(super) fn workload_slot(&self, id: &ComputerId) -> Result<Arc<dyn WorkloadSlot>> {
Ok(Arc::new(crate::lifecycle::flows::ActorSlot {
id: id.clone(),
mailbox: self.mailbox(id)?,
}))
}
#[allow(
clippy::too_many_arguments,
reason = "public API mirrors workload request"
)]
pub async fn run_in_computer(
&self,
id: &ComputerId,
cmd: Vec<String>,
env: HashMap<String, String>,
working_dir: String,
user: String,
tty: bool,
tty_size: Option<(u16, u16)>,
timeout_seconds: u32,
) -> Result<tokio::sync::mpsc::Receiver<Result<OutputChunk>>> {
let agent = self.require_ready_agent(id)?;
let start = StartCommand {
cmd,
env,
working_dir,
user,
tty,
tty_width: tty_size.map_or(80, |(w, _)| w),
tty_height: tty_size.map_or(24, |(_, h)| h),
timeout_seconds,
};
start_run_workload(
agent.as_ref(),
start,
self.workload_slot(id)?,
WorkloadClaim::Api,
)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
struct RecordingSlot {
reports: Mutex<Vec<String>>,
}
#[async_trait::async_trait]
impl WorkloadSlot for RecordingSlot {
async fn claim(&self, _claim: WorkloadClaim) -> Result<()> {
self.reports.lock().unwrap().push("claim".to_owned());
Ok(())
}
fn release(&self) {
self.reports.lock().unwrap().push("release".to_owned());
}
fn exited(&self, outcome: WorkloadOutcome) {
self.reports.lock().unwrap().push(format!("{outcome:?}"));
}
}
#[tokio::test]
async fn the_watcher_reports_the_exit_even_when_the_consumer_disconnects() {
let slot = Arc::new(RecordingSlot::default());
let (inner_tx, inner_rx) = tokio::sync::mpsc::channel(8);
let wrapped_rx = spawn_exit_watcher(inner_rx, Arc::clone(&slot) as Arc<dyn WorkloadSlot>);
drop(wrapped_rx);
inner_tx
.send(Ok(OutputChunk::Stdout(b"noise".to_vec())))
.await
.unwrap();
inner_tx
.send(Ok(OutputChunk::Exit(ExitStatus::Code(7))))
.await
.unwrap();
drop(inner_tx);
for _ in 0..50 {
if !slot.reports.lock().unwrap().is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(slot.reports.lock().unwrap().as_slice(), ["Exited(Code(7))"]);
}
#[tokio::test]
async fn the_watcher_forwards_then_reports_for_a_live_consumer() {
let slot = Arc::new(RecordingSlot::default());
let (inner_tx, inner_rx) = tokio::sync::mpsc::channel(8);
let mut wrapped_rx =
spawn_exit_watcher(inner_rx, Arc::clone(&slot) as Arc<dyn WorkloadSlot>);
inner_tx
.send(Ok(OutputChunk::Stdout(b"hello".to_vec())))
.await
.unwrap();
let first = wrapped_rx.recv().await.unwrap().unwrap();
assert!(matches!(first, OutputChunk::Stdout(data) if data == b"hello"));
inner_tx
.send(Ok(OutputChunk::Exit(ExitStatus::Code(0))))
.await
.unwrap();
drop(inner_tx);
let exit = wrapped_rx.recv().await.unwrap().unwrap();
assert!(matches!(exit, OutputChunk::Exit(ExitStatus::Code(0))));
assert!(wrapped_rx.recv().await.is_none());
assert_eq!(slot.reports.lock().unwrap().as_slice(), ["Exited(Code(0))"]);
}
#[tokio::test]
async fn a_refused_dispatch_gives_the_claim_back() {
let slot = Arc::new(RecordingSlot::default());
let agents = crate::testkit::agent::FakeAgentFactory::new();
agents.on(
&["/bin/nope"],
crate::testkit::agent::Reply::Fails("no such file".into()),
);
start_run_workload(
agents.agent().as_ref(),
StartCommand {
cmd: vec!["/bin/nope".into()],
env: HashMap::new(),
working_dir: String::new(),
user: String::new(),
tty: false,
tty_width: 80,
tty_height: 24,
timeout_seconds: 0,
},
Arc::clone(&slot) as Arc<dyn WorkloadSlot>,
WorkloadClaim::Api,
)
.await
.expect_err("the guest refused the command");
assert_eq!(
slot.reports.lock().unwrap().as_slice(),
["claim", "release"]
);
}
}