arcbox_computer_runtime/sandbox/
workload.rs1use super::*;
2use crate::lifecycle::actor::WorkloadOutcome;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum WorkloadClaim {
7 Api,
9 Initial,
16}
17
18#[async_trait::async_trait]
26pub trait WorkloadSlot: Send + Sync {
27 async fn claim(&self, claim: WorkloadClaim) -> Result<()>;
31 fn release(&self);
34 fn exited(&self, outcome: WorkloadOutcome);
36}
37
38fn spawn_exit_watcher(
48 inner_rx: tokio::sync::mpsc::Receiver<Result<OutputChunk>>,
49 slot: Arc<dyn WorkloadSlot>,
50) -> tokio::sync::mpsc::Receiver<Result<OutputChunk>> {
51 let (wrapped_tx, wrapped_rx) = tokio::sync::mpsc::channel(64);
52 tokio::spawn(async move {
53 let mut inner_rx = inner_rx;
54 let mut consumer_gone = false;
55 while let Some(result) = inner_rx.recv().await {
56 if let Ok(OutputChunk::Exit(status)) = &result {
58 slot.exited(WorkloadOutcome::Exited(*status));
59 }
60 if !consumer_gone && wrapped_tx.send(result).await.is_err() {
63 consumer_gone = true;
64 }
65 }
66 });
67 wrapped_rx
68}
69
70pub async fn start_run_workload(
79 agent: &dyn GuestAgent,
80 start: StartCommand,
81 slot: Arc<dyn WorkloadSlot>,
82 claim: WorkloadClaim,
83) -> Result<tokio::sync::mpsc::Receiver<Result<OutputChunk>>> {
84 slot.claim(claim).await?;
85 let inner_rx = match agent.run(start).await {
86 Ok(rx) => rx,
87 Err(error) => {
88 slot.release();
89 return Err(error);
90 }
91 };
92 Ok(spawn_exit_watcher(inner_rx, slot))
93}
94
95impl ComputerManager {
96 pub(super) fn workload_slot(&self, id: &ComputerId) -> Result<Arc<dyn WorkloadSlot>> {
98 Ok(Arc::new(crate::lifecycle::flows::ActorSlot {
99 id: id.clone(),
100 mailbox: self.mailbox(id)?,
101 }))
102 }
103
104 #[allow(
105 clippy::too_many_arguments,
106 reason = "public API mirrors workload request"
107 )]
108 pub async fn run_in_computer(
109 &self,
110 id: &ComputerId,
111 cmd: Vec<String>,
112 env: HashMap<String, String>,
113 working_dir: String,
114 user: String,
115 tty: bool,
116 tty_size: Option<(u16, u16)>,
117 timeout_seconds: u32,
118 ) -> Result<tokio::sync::mpsc::Receiver<Result<OutputChunk>>> {
119 let agent = self.require_ready_agent(id)?;
120
121 let start = StartCommand {
122 cmd,
123 env,
124 working_dir,
125 user,
126 tty,
127 tty_width: tty_size.map_or(80, |(w, _)| w),
128 tty_height: tty_size.map_or(24, |(_, h)| h),
129 timeout_seconds,
130 };
131
132 start_run_workload(
133 agent.as_ref(),
134 start,
135 self.workload_slot(id)?,
136 WorkloadClaim::Api,
137 )
138 .await
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[derive(Default)]
150 struct RecordingSlot {
151 reports: Mutex<Vec<String>>,
152 }
153
154 #[async_trait::async_trait]
155 impl WorkloadSlot for RecordingSlot {
156 async fn claim(&self, _claim: WorkloadClaim) -> Result<()> {
157 self.reports.lock().unwrap().push("claim".to_owned());
158 Ok(())
159 }
160
161 fn release(&self) {
162 self.reports.lock().unwrap().push("release".to_owned());
163 }
164
165 fn exited(&self, outcome: WorkloadOutcome) {
166 self.reports.lock().unwrap().push(format!("{outcome:?}"));
167 }
168 }
169
170 #[tokio::test]
171 async fn the_watcher_reports_the_exit_even_when_the_consumer_disconnects() {
172 let slot = Arc::new(RecordingSlot::default());
176 let (inner_tx, inner_rx) = tokio::sync::mpsc::channel(8);
177
178 let wrapped_rx = spawn_exit_watcher(inner_rx, Arc::clone(&slot) as Arc<dyn WorkloadSlot>);
179 drop(wrapped_rx); inner_tx
182 .send(Ok(OutputChunk::Stdout(b"noise".to_vec())))
183 .await
184 .unwrap();
185 inner_tx
186 .send(Ok(OutputChunk::Exit(ExitStatus::Code(7))))
187 .await
188 .unwrap();
189 drop(inner_tx); for _ in 0..50 {
192 if !slot.reports.lock().unwrap().is_empty() {
193 break;
194 }
195 tokio::time::sleep(Duration::from_millis(10)).await;
196 }
197 assert_eq!(slot.reports.lock().unwrap().as_slice(), ["Exited(Code(7))"]);
198 }
199
200 #[tokio::test]
201 async fn the_watcher_forwards_then_reports_for_a_live_consumer() {
202 let slot = Arc::new(RecordingSlot::default());
203 let (inner_tx, inner_rx) = tokio::sync::mpsc::channel(8);
204
205 let mut wrapped_rx =
206 spawn_exit_watcher(inner_rx, Arc::clone(&slot) as Arc<dyn WorkloadSlot>);
207
208 inner_tx
209 .send(Ok(OutputChunk::Stdout(b"hello".to_vec())))
210 .await
211 .unwrap();
212 let first = wrapped_rx.recv().await.unwrap().unwrap();
213 assert!(matches!(first, OutputChunk::Stdout(data) if data == b"hello"));
214
215 inner_tx
216 .send(Ok(OutputChunk::Exit(ExitStatus::Code(0))))
217 .await
218 .unwrap();
219 drop(inner_tx);
220
221 let exit = wrapped_rx.recv().await.unwrap().unwrap();
223 assert!(matches!(exit, OutputChunk::Exit(ExitStatus::Code(0))));
224 assert!(wrapped_rx.recv().await.is_none());
225 assert_eq!(slot.reports.lock().unwrap().as_slice(), ["Exited(Code(0))"]);
226 }
227
228 #[tokio::test]
231 async fn a_refused_dispatch_gives_the_claim_back() {
232 let slot = Arc::new(RecordingSlot::default());
233 let agents = crate::testkit::agent::FakeAgentFactory::new();
234 agents.on(
235 &["/bin/nope"],
236 crate::testkit::agent::Reply::Fails("no such file".into()),
237 );
238
239 start_run_workload(
240 agents.agent().as_ref(),
241 StartCommand {
242 cmd: vec!["/bin/nope".into()],
243 env: HashMap::new(),
244 working_dir: String::new(),
245 user: String::new(),
246 tty: false,
247 tty_width: 80,
248 tty_height: 24,
249 timeout_seconds: 0,
250 },
251 Arc::clone(&slot) as Arc<dyn WorkloadSlot>,
252 WorkloadClaim::Api,
253 )
254 .await
255 .expect_err("the guest refused the command");
256 assert_eq!(
257 slot.reports.lock().unwrap().as_slice(),
258 ["claim", "release"]
259 );
260 }
261}