Skip to main content

arcbox_computer_runtime/sandbox/
workload.rs

1use super::*;
2use crate::lifecycle::actor::WorkloadOutcome;
3
4/// Which caller is taking the single-workload slot.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum WorkloadClaim {
7    /// The public Run/Exec surface: claims from `Ready` only.
8    Api,
9    /// The boot/restore pipeline's own initial cmd: additionally claims
10    /// from the readiness gate. A boot with an initial cmd holds the gate
11    /// until this claim, so an Inspect-polling client cannot slip an exec
12    /// into the boot tail (the warm-snapshot publish pause is a wide
13    /// window) and silently steal the slot the template's default cmd was
14    /// owed — the CORE-107 e2e caught exactly that.
15    Initial,
16}
17
18/// A computer's single-workload slot.
19///
20/// Taking it, giving it back and reporting the exit are all lifecycle
21/// transitions, so all three belong to the computer's actor rather than to
22/// the code that dispatches the command. The seam keeps the dispatch itself
23/// off the mailbox: [`start_run_workload`] talks to the guest directly, and
24/// only the three edges are verbs.
25#[async_trait::async_trait]
26pub trait WorkloadSlot: Send + Sync {
27    /// Take the slot. `WrongState` when a workload is already running, the
28    /// computer is on its way out, or an `Api` claim reached one that has
29    /// not announced READY.
30    async fn claim(&self, claim: WorkloadClaim) -> Result<()>;
31    /// Give the slot back after a dispatch the guest refused. Nothing ran,
32    /// so nothing is announced.
33    fn release(&self);
34    /// The workload ended.
35    fn exited(&self, outcome: WorkloadOutcome);
36}
37
38/// Spawn the watcher that mirrors a workload's output to the caller and drives
39/// the exit-side state machine.
40///
41/// The watcher **always drains `inner_rx` to completion** so the `exit` chunk
42/// is processed even after the caller drops its receiver. Coupling the state
43/// update to a successful forward was the original defect: a consumer that
44/// disconnected mid-workload broke the loop before the exit frame, stranding
45/// the computer in `Running` forever. Now a gone consumer only stops the
46/// forwarding; the drain (and the exit report) continues.
47fn 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            // Handle the exit chunk regardless of the consumer's presence.
57            if let Ok(OutputChunk::Exit(status)) = &result {
58                slot.exited(WorkloadOutcome::Exited(*status));
59            }
60            // Forward until the consumer goes away, then keep draining so the
61            // exit chunk above is still reached.
62            if !consumer_gone && wrapped_tx.send(result).await.is_err() {
63                consumer_gone = true;
64            }
65        }
66    });
67    wrapped_rx
68}
69
70/// Start a non-interactive workload through the computer's agent and wire up
71/// the workload slot.
72///
73/// Shared by `Run` (`WorkloadClaim::Api`) and the initial `cmd` launched by
74/// the boot/restore gates (`WorkloadClaim::Initial`): claims the slot
75/// **before** connecting so a losing racer never dispatches a command,
76/// launches the session, then spawns the exit watcher. A launch failure gives
77/// the claim back.
78pub 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    /// This computer's workload slot: its actor, behind the seam.
97    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    /// Records what the watcher reported, standing in for the computer's
147    /// actor: the state machine those reports drive is table-tested in
148    /// `crate::lifecycle::tests`.
149    #[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        // Regression for the strand-in-Running bug: drop the consumer before
173        // the exit chunk; the watcher must still process the exit, or the
174        // computer never leaves `Running`.
175        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); // consumer gone immediately
180
181        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); // guest side closes after exit
190
191        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        // The forwarded exit chunk arrives, then the channel closes.
222        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    /// A guest that refuses the dispatch must give the slot back — and only
229    /// the slot: nothing ran, so there is no exit to report.
230    #[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}