Skip to main content

arcbox_computer_runtime/sandbox/
execution.rs

1//! Addressable, resumable workload executions (CORE-55).
2//!
3//! An execution is a workload started inside a sandbox that outlives any
4//! particular client connection. The manager retains offset-addressed
5//! stdout/stderr per execution so a client can disconnect, reconnect, and
6//! resume both streams without loss, and gives the process an identity that
7//! can be written to, signalled, resized, and awaited independently of any
8//! stream. Stdin writes are offset-idempotent: a retried write of already
9//! accepted bytes is deduplicated instead of double-fed to the process.
10
11use std::collections::VecDeque;
12
13use tokio::sync::{mpsc, watch};
14
15use super::workload::{WorkloadClaim, WorkloadSlot};
16use super::*;
17use crate::lifecycle::actor::WorkloadOutcome;
18
19/// Retained bytes per stdio channel. Older bytes are dropped and the buffer's
20/// base offset advances; an attach below the base resumes from the earliest
21/// retained byte (chunk offsets expose the gap to the client).
22const CHANNEL_RETENTION: usize = 8 * 1024 * 1024;
23
24/// How long an exited execution stays attachable/awaitable before it is
25/// dropped from the registry.
26const EXITED_RETENTION: Duration = Duration::from_secs(5 * 60);
27
28/// Largest single chunk forwarded on an attach stream.
29const ATTACH_CHUNK: usize = 64 * 1024;
30
31/// Buffered chunks between an execution's buffers and one attach consumer.
32const ATTACH_QUEUE: usize = 64;
33
34/// How many times a start re-checks the registry after awaiting an identical
35/// in-flight start. One round is the normal case.
36const MAX_START_RESERVE_ROUNDS: usize = 8;
37
38/// Parameters for starting an execution.
39#[derive(Debug, Clone, Default)]
40pub struct ExecutionSpec {
41    /// Caller-supplied id for idempotent retry (auto-generated when `None`).
42    pub id: Option<String>,
43    /// Command and arguments.
44    pub cmd: Vec<String>,
45    /// Environment variable overrides.
46    pub env: HashMap<String, String>,
47    /// Working directory (empty = rootfs default).
48    pub working_dir: String,
49    /// User to run as (empty = root).
50    pub user: String,
51    /// Allocate a pseudo-TTY (stdout carries the merged PTY stream).
52    pub tty: bool,
53    /// Initial terminal size when `tty` is set.
54    pub tty_size: Option<(u16, u16)>,
55    /// Kill the process after this many seconds (0 = no timeout).
56    pub timeout_seconds: u32,
57    /// Keep stdin open for [`SandboxManager::write_stdin`]. When false the
58    /// process starts with stdin already at EOF (run semantics).
59    pub stdin: bool,
60}
61
62/// Point-in-time view of an execution.
63#[derive(Debug, Clone)]
64pub struct ExecutionSnapshot {
65    /// Execution id, unique within its sandbox.
66    pub id: String,
67    /// Owning sandbox.
68    pub sandbox_id: SandboxId,
69    /// Whether the workload runs on a pseudo-TTY.
70    pub tty: bool,
71    /// When the workload was dispatched.
72    pub started_at: DateTime<Utc>,
73    /// When the workload terminated (`None` while running).
74    pub exited_at: Option<DateTime<Utc>>,
75    /// How the process terminated. `None` while running, and also when the
76    /// session broke before an exit was observed (see `error`).
77    pub exit_status: Option<ExitStatus>,
78    /// Set when the session broke before the process reported an exit.
79    pub error: Option<String>,
80    /// Total bytes ever produced on stdout (monotonic, beyond retention).
81    pub stdout_len: u64,
82    /// Total bytes ever produced on stderr.
83    pub stderr_len: u64,
84    /// Stdin acceptance state.
85    pub stdin: StdinState,
86}
87
88impl ExecutionSnapshot {
89    /// True until the workload exits (or its session breaks).
90    #[must_use]
91    pub const fn is_running(&self) -> bool {
92        self.exited_at.is_none()
93    }
94}
95
96/// Which stdio channel an attach chunk belongs to. For TTY executions the
97/// merged PTY stream arrives on `Stdout`.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum ExecutionChannel {
100    /// Standard output (or the PTY stream for `tty` executions).
101    Stdout,
102    /// Standard error (never produced by `tty` executions).
103    Stderr,
104}
105
106/// One offset-addressed chunk delivered on an attach stream.
107#[derive(Debug, Clone)]
108pub struct ExecutionOutput {
109    /// Source channel.
110    pub channel: ExecutionChannel,
111    /// Absolute offset of `data[0]` within the channel's byte stream. May be
112    /// greater than the requested resume offset when retention dropped bytes.
113    pub offset: u64,
114    /// Raw output bytes.
115    pub data: Vec<u8>,
116}
117
118/// Stdin acceptance state, the anchor for offset-resumable writes.
119#[derive(Debug, Clone, Copy, Default)]
120pub struct StdinState {
121    /// Bytes accepted and forwarded so far — the offset at which the next
122    /// write must start.
123    pub bytes_written: u64,
124    /// Whether stdin has been closed.
125    pub closed: bool,
126}
127
128/// Retained suffix of one stdio channel, addressed by absolute offsets.
129#[derive(Default)]
130struct ChannelBuffer {
131    /// Absolute offset of `data[0]`.
132    base: u64,
133    data: VecDeque<u8>,
134}
135
136impl ChannelBuffer {
137    /// One past the last retained byte — the channel's total length so far.
138    fn end(&self) -> u64 {
139        self.base + self.data.len() as u64
140    }
141
142    fn append(&mut self, bytes: &[u8]) {
143        self.data.extend(bytes);
144        if self.data.len() > CHANNEL_RETENTION {
145            let drop = self.data.len() - CHANNEL_RETENTION;
146            self.data.drain(..drop);
147            self.base += drop as u64;
148        }
149    }
150
151    /// Read up to `max` bytes starting at `offset`, clamped to the retained
152    /// range. Returns the actual start offset alongside the bytes.
153    fn read_from(&self, offset: u64, max: usize) -> (u64, Vec<u8>) {
154        let start = offset.max(self.base);
155        if start >= self.end() {
156            return (start, Vec::new());
157        }
158        // The buffer is capped at CHANNEL_RETENTION, so the skip fits usize.
159        let skip = usize::try_from(start - self.base).expect("offset within retained buffer");
160        let take = (self.data.len() - skip).min(max);
161        let (front, back) = self.data.as_slices();
162        let mut out = Vec::with_capacity(take);
163        if skip < front.len() {
164            let n = (front.len() - skip).min(take);
165            out.extend_from_slice(&front[skip..skip + n]);
166            out.extend_from_slice(&back[..take - n]);
167        } else {
168            let off = skip - front.len();
169            out.extend_from_slice(&back[off..off + take]);
170        }
171        (start, out)
172    }
173}
174
175/// Mutable state of one execution. Guarded by a std mutex; never held across
176/// an await point.
177#[derive(Default)]
178struct ExecState {
179    stdout: ChannelBuffer,
180    stderr: ChannelBuffer,
181    stdin_written: u64,
182    stdin_closed: bool,
183    exited_at: Option<DateTime<Utc>>,
184    exit_status: Option<ExitStatus>,
185    error: Option<String>,
186}
187
188/// A live or recently-exited execution.
189pub(super) struct Execution {
190    id: String,
191    sandbox_id: SandboxId,
192    tty: bool,
193    started_at: DateTime<Utc>,
194    /// The started command, kept to distinguish an idempotent start retry
195    /// (same id, same command) from an id collision.
196    cmd: Vec<String>,
197    state: Mutex<ExecState>,
198    /// Bumped after every state mutation; attach and wait subscribers park on
199    /// it instead of polling.
200    version: watch::Sender<u64>,
201    /// Session input queue (stdin bytes / resize / signal / EOF).
202    input_tx: mpsc::Sender<ExecInputMsg>,
203    /// Serialises `write_stdin` offset accounting across concurrent callers.
204    stdin_gate: tokio::sync::Mutex<()>,
205}
206
207impl Execution {
208    fn new(
209        id: String,
210        sandbox_id: SandboxId,
211        spec: &ExecutionSpec,
212        input_tx: mpsc::Sender<ExecInputMsg>,
213    ) -> Self {
214        Self {
215            id,
216            sandbox_id,
217            tty: spec.tty,
218            started_at: Utc::now(),
219            cmd: spec.cmd.clone(),
220            state: Mutex::new(ExecState {
221                stdin_closed: !spec.stdin,
222                ..ExecState::default()
223            }),
224            version: watch::channel(0).0,
225            input_tx,
226            stdin_gate: tokio::sync::Mutex::new(()),
227        }
228    }
229
230    fn snapshot(&self) -> ExecutionSnapshot {
231        let st = self.state.lock().unwrap();
232        ExecutionSnapshot {
233            id: self.id.clone(),
234            sandbox_id: self.sandbox_id.clone(),
235            tty: self.tty,
236            started_at: self.started_at,
237            exited_at: st.exited_at,
238            exit_status: st.exit_status,
239            error: st.error.clone(),
240            stdout_len: st.stdout.end(),
241            stderr_len: st.stderr.end(),
242            stdin: StdinState {
243                bytes_written: st.stdin_written,
244                closed: st.stdin_closed,
245            },
246        }
247    }
248
249    fn bump(&self) {
250        self.version.send_modify(|v| *v += 1);
251    }
252
253    /// Record the session's end and wake all subscribers. Idempotent: the
254    /// first outcome wins (a teardown purge and the session's own exit path
255    /// can race).
256    fn mark_exited(&self, outcome: &std::result::Result<ExitStatus, String>) {
257        let mut st = self.state.lock().unwrap();
258        if st.exited_at.is_some() {
259            return;
260        }
261        st.exited_at = Some(Utc::now());
262        match outcome {
263            Ok(status) => st.exit_status = Some(*status),
264            Err(e) => st.error = Some(e.clone()),
265        }
266        drop(st);
267        self.bump();
268    }
269
270    fn has_exited(&self) -> bool {
271        self.state.lock().unwrap().exited_at.is_some()
272    }
273
274    /// Error for operations that need the workload alive.
275    fn exited_error(&self) -> VmmError {
276        VmmError::WrongState {
277            id: format!("execution '{}'", self.id),
278            expected: "running".into(),
279            actual: "exited".into(),
280        }
281    }
282
283    /// Spawn a forwarder that replays buffered output from the given offsets
284    /// and follows live output until exit.
285    fn attach(
286        self: &Arc<Self>,
287        stdout_offset: u64,
288        stderr_offset: u64,
289    ) -> (ExecutionSnapshot, mpsc::Receiver<ExecutionOutput>) {
290        let snapshot = self.snapshot();
291        let (tx, rx) = mpsc::channel(ATTACH_QUEUE);
292        tokio::spawn(pump_attach(
293            Arc::clone(self),
294            stdout_offset,
295            stderr_offset,
296            tx,
297        ));
298        (snapshot, rx)
299    }
300
301    /// Offset-idempotent stdin write; see [`SandboxManager::write_stdin`].
302    async fn write_stdin(&self, offset: u64, data: &[u8], eof: bool) -> Result<StdinState> {
303        if eof && self.tty {
304            return Err(VmmError::Config(
305                "cannot close stdin of a TTY execution; send Ctrl-D (0x04) instead".into(),
306            ));
307        }
308        // One writer at a time: the accept-forward-account sequence below
309        // must not interleave between concurrent callers.
310        let _gate = self.stdin_gate.lock().await;
311
312        let fresh = {
313            let st = self.state.lock().unwrap();
314            if st.exited_at.is_some() {
315                return Err(self.exited_error());
316            }
317            if offset > st.stdin_written {
318                return Err(VmmError::StdinGap {
319                    accepted: st.stdin_written,
320                    offset,
321                });
322            }
323            let overlap = st.stdin_written - offset;
324            if overlap >= data.len() as u64 {
325                // Pure retry of already-accepted bytes.
326                Vec::new()
327            } else {
328                if st.stdin_closed {
329                    return Err(VmmError::Config("stdin is already closed".into()));
330                }
331                #[allow(
332                    clippy::cast_possible_truncation,
333                    reason = "overlap < data.len() which is usize"
334                )]
335                let skip = overlap as usize;
336                data[skip..].to_vec()
337            }
338        };
339
340        if !fresh.is_empty() {
341            let accepted = fresh.len() as u64;
342            self.input_tx
343                .send(ExecInputMsg::Stdin(fresh))
344                .await
345                .map_err(|_| self.exited_error())?;
346            self.state.lock().unwrap().stdin_written += accepted;
347            self.bump();
348        }
349        if eof && !self.state.lock().unwrap().stdin_closed {
350            // Best-effort: the process may exit between the check and the
351            // send; the closed flag still records what the client asked for.
352            let _ = self.input_tx.send(ExecInputMsg::Eof).await;
353            self.state.lock().unwrap().stdin_closed = true;
354            self.bump();
355        }
356
357        Ok(self.snapshot().stdin)
358    }
359
360    /// Deliver a POSIX signal to the running workload's process group.
361    async fn signal(&self, signal: i32) -> Result<()> {
362        if !(1..=64).contains(&signal) {
363            return Err(VmmError::Config(format!("invalid signal {signal}")));
364        }
365        if self.has_exited() {
366            return Err(self.exited_error());
367        }
368        self.input_tx
369            .send(ExecInputMsg::Signal(signal))
370            .await
371            .map_err(|_| self.exited_error())
372    }
373
374    /// Resize the workload's pseudo-TTY.
375    async fn resize(&self, width: u16, height: u16) -> Result<()> {
376        if !self.tty {
377            return Err(VmmError::Config("execution has no TTY to resize".into()));
378        }
379        if self.has_exited() {
380            return Err(self.exited_error());
381        }
382        self.input_tx
383            .send(ExecInputMsg::Resize { width, height })
384            .await
385            .map_err(|_| self.exited_error())
386    }
387
388    /// Wait until the workload exits or `timeout` elapses; zero polls.
389    async fn wait(&self, timeout: Duration) -> ExecutionSnapshot {
390        let mut version = self.version.subscribe();
391        let deadline = tokio::time::Instant::now() + timeout;
392        loop {
393            // Mark the version seen before inspecting state, so a mutation in
394            // between leaves a pending change notification.
395            version.borrow_and_update();
396            if self.has_exited() || timeout.is_zero() {
397                return self.snapshot();
398            }
399            if tokio::time::timeout_at(deadline, version.changed())
400                .await
401                .is_err()
402            {
403                return self.snapshot();
404            }
405        }
406    }
407}
408
409type ExecKey = (SandboxId, String);
410
411/// Registry of executions, keyed by `(sandbox_id, execution_id)`.
412#[derive(Default)]
413pub(super) struct ExecutionRegistry {
414    inner: Mutex<RegistryInner>,
415}
416
417#[derive(Default)]
418struct RegistryInner {
419    live: HashMap<ExecKey, Arc<Execution>>,
420    /// Keys reserved by an in-flight start, so a duplicate id can never
421    /// dispatch a second process (mirrors `reserve_id` for sandboxes).
422    pending: HashMap<ExecKey, PendingStart>,
423}
424
425/// A start that has reserved its key but not finished dispatching.
426struct PendingStart {
427    /// The command being started, so a retry can tell an idempotent replay
428    /// from an id collision before the execution exists.
429    cmd: Vec<String>,
430    /// Fires once the start commits or unwinds, waking matching retries.
431    done: watch::Sender<bool>,
432}
433
434/// Outcome of reserving an execution slot.
435enum Reserve {
436    /// The id already ran this exact command — an idempotent start retry.
437    Existing(ExecutionSnapshot),
438    /// The slot is reserved; commit or drop to release.
439    Slot(SlotGuard),
440    /// An identical start is still in flight; await it, then re-reserve.
441    AwaitPending(watch::Receiver<bool>),
442}
443
444/// RAII reservation for an execution key; removed on drop unless committed.
445struct SlotGuard {
446    registry: Arc<ExecutionRegistry>,
447    key: ExecKey,
448    committed: bool,
449}
450
451impl SlotGuard {
452    fn commit(mut self, exec: &Arc<Execution>) {
453        let mut inner = self.registry.inner.lock().unwrap();
454        let pending = inner.pending.remove(&self.key);
455        inner.live.insert(self.key.clone(), Arc::clone(exec));
456        drop(inner);
457        // Wake matching retries only after the execution is visible, so the
458        // one that wakes finds it live rather than racing back to pending.
459        if let Some(pending) = pending {
460            let _ = pending.done.send(true);
461        }
462        self.committed = true;
463    }
464}
465
466impl Drop for SlotGuard {
467    fn drop(&mut self) {
468        if !self.committed {
469            let pending = self
470                .registry
471                .inner
472                .lock()
473                .unwrap()
474                .pending
475                .remove(&self.key);
476            if let Some(pending) = pending {
477                let _ = pending.done.send(true);
478            }
479        }
480    }
481}
482
483impl ExecutionRegistry {
484    fn reserve(self: &Arc<Self>, key: ExecKey, cmd: &[String]) -> Result<Reserve> {
485        let mut inner = self.inner.lock().unwrap();
486        if let Some(existing) = inner.live.get(&key) {
487            if existing.cmd == cmd {
488                return Ok(Reserve::Existing(existing.snapshot()));
489            }
490            return Err(VmmError::AlreadyExists(format!(
491                "execution '{}' (id reused for a different command)",
492                key.1
493            )));
494        }
495        if let Some(pending) = inner.pending.get(&key) {
496            if pending.cmd == cmd {
497                // A retry inside the start window is still an idempotent
498                // retry: wait for the original rather than failing, or the
499                // contract only holds once the process is already running.
500                return Ok(Reserve::AwaitPending(pending.done.subscribe()));
501            }
502            return Err(VmmError::AlreadyExists(format!(
503                "execution '{}' (id reused for a different command)",
504                key.1
505            )));
506        }
507        inner.pending.insert(
508            key.clone(),
509            PendingStart {
510                cmd: cmd.to_vec(),
511                done: watch::channel(false).0,
512            },
513        );
514        Ok(Reserve::Slot(SlotGuard {
515            registry: Arc::clone(self),
516            key,
517            committed: false,
518        }))
519    }
520
521    fn get(&self, sandbox_id: &str, execution_id: &str) -> Result<Arc<Execution>> {
522        self.inner
523            .lock()
524            .unwrap()
525            .live
526            .get(&(sandbox_id.to_owned(), execution_id.to_owned()))
527            .cloned()
528            .ok_or_else(|| {
529                VmmError::NotFound(format!(
530                    "execution '{execution_id}' in sandbox '{sandbox_id}'"
531                ))
532            })
533    }
534
535    /// Snapshots of every retained execution of one sandbox — running and
536    /// exited, until the retention GC drops them.
537    fn list(&self, sandbox_id: &str) -> Vec<ExecutionSnapshot> {
538        self.inner
539            .lock()
540            .unwrap()
541            .live
542            .iter()
543            .filter(|((sid, _), _)| sid == sandbox_id)
544            .map(|(_, exec)| exec.snapshot())
545            .collect()
546    }
547
548    /// Remove `exec`'s entry if it is still the registered generation.
549    fn remove_generation(&self, exec: &Arc<Execution>) {
550        let key = (exec.sandbox_id.clone(), exec.id.clone());
551        let mut inner = self.inner.lock().unwrap();
552        if inner.live.get(&key).is_some_and(|e| Arc::ptr_eq(e, exec)) {
553            inner.live.remove(&key);
554        }
555    }
556
557    /// Mark every still-running execution of a sandbox as torn down so
558    /// parked attach/wait subscribers resolve. Entries stay registered —
559    /// their buffered output remains readable until the per-execution
560    /// retention GC drops them.
561    fn interrupt_sandbox(&self, sandbox_id: &str) {
562        let executions: Vec<Arc<Execution>> = {
563            let inner = self.inner.lock().unwrap();
564            inner
565                .live
566                .iter()
567                .filter(|((sid, _), _)| sid == sandbox_id)
568                .map(|(_, exec)| Arc::clone(exec))
569                .collect()
570        };
571        for exec in executions {
572            exec.mark_exited(&Err("sandbox stopped".to_owned()));
573        }
574    }
575}
576
577/// Interrupt executions when their sandbox reaches a terminal state. Covers
578/// every teardown path (stop, remove, TTL expiry, boot failure) without
579/// threading the registry through each of them.
580pub(super) fn spawn_teardown_purge(
581    registry: Arc<ExecutionRegistry>,
582    mut events: broadcast::Receiver<SandboxEvent>,
583) {
584    tokio::spawn(async move {
585        loop {
586            match events.recv().await {
587                Ok(ev) if ev.is_terminal() => {
588                    registry.interrupt_sandbox(&ev.sandbox_id);
589                }
590                Ok(_) | Err(broadcast::error::RecvError::Lagged(_)) => {}
591                Err(broadcast::error::RecvError::Closed) => break,
592            }
593        }
594    });
595}
596
597/// Drain a session's output into the execution's buffers until it exits,
598/// then keep the exited record around for late attach/wait before GC.
599async fn run_session(
600    exec: Arc<Execution>,
601    mut output_rx: mpsc::Receiver<Result<OutputChunk>>,
602    slot: Arc<dyn WorkloadSlot>,
603    registry: Arc<ExecutionRegistry>,
604) {
605    let outcome: std::result::Result<ExitStatus, String> = loop {
606        match output_rx.recv().await {
607            Some(Ok(OutputChunk::Stdout(data))) => {
608                exec.state.lock().unwrap().stdout.append(&data);
609                exec.bump();
610            }
611            Some(Ok(OutputChunk::Stderr(data))) => {
612                exec.state.lock().unwrap().stderr.append(&data);
613                exec.bump();
614            }
615            Some(Ok(OutputChunk::Exit(status))) => break Ok(status),
616            Some(Err(e)) => break Err(e.to_string()),
617            None => break Err("execution session closed before exit".to_owned()),
618        }
619    };
620
621    match &outcome {
622        Ok(status) => slot.exited(WorkloadOutcome::Exited(*status)),
623        // The guest vm-agent kills the workload when its host connection
624        // drops, so the computer is idle either way — there is just no status
625        // to report, only why.
626        Err(e) => {
627            warn!(sandbox_id = %exec.sandbox_id, execution_id = %exec.id, error = %e,
628                  "execution session broke before exit");
629            slot.exited(WorkloadOutcome::Broke(e.clone()));
630        }
631    }
632    exec.mark_exited(&outcome);
633
634    tokio::time::sleep(EXITED_RETENTION).await;
635    registry.remove_generation(&exec);
636}
637
638impl SandboxManager {
639    /// Start an execution inside a `Ready` sandbox.
640    ///
641    /// The execution id (caller-supplied or generated) addresses the process
642    /// afterwards: attach, stdin writes, signals, resize, and wait all go
643    /// through it. Retrying a start with the same id and command returns the
644    /// existing execution instead of dispatching a second process.
645    pub async fn start_execution(
646        &self,
647        sandbox_id: &SandboxId,
648        spec: ExecutionSpec,
649    ) -> Result<ExecutionSnapshot> {
650        let id = match &spec.id {
651            Some(id) if !id.is_empty() => {
652                super::validate_id("execution id", id)?;
653                id.clone()
654            }
655            _ => Uuid::new_v4().to_string(),
656        };
657        if spec.cmd.is_empty() {
658            return Err(VmmError::Config(
659                "execution command must not be empty".into(),
660            ));
661        }
662
663        // Resolve the reservation, awaiting an identical in-flight start
664        // rather than failing it. One await is the normal case (the original
665        // commits or unwinds); the bound only stops a pathological interleave
666        // of repeated failing starts from spinning here forever.
667        let mut reservation = None;
668        for _ in 0..MAX_START_RESERVE_ROUNDS {
669            match self
670                .executions
671                .reserve((sandbox_id.clone(), id.clone()), &spec.cmd)?
672            {
673                Reserve::Existing(snapshot) => return Ok(snapshot),
674                Reserve::Slot(guard) => {
675                    reservation = Some(guard);
676                    break;
677                }
678                Reserve::AwaitPending(mut done) => {
679                    let _ = done.changed().await;
680                }
681            }
682        }
683        let Some(reservation) = reservation else {
684            return Err(VmmError::AlreadyExists(format!(
685                "execution '{id}' (concurrent starts did not settle)"
686            )));
687        };
688
689        let agent = self.require_ready_agent(sandbox_id)?;
690        let start = StartCommand {
691            cmd: spec.cmd.clone(),
692            env: spec.env.clone(),
693            working_dir: spec.working_dir.clone(),
694            user: spec.user.clone(),
695            tty: spec.tty,
696            tty_width: spec.tty_size.map_or(80, |(w, _)| w),
697            tty_height: spec.tty_size.map_or(24, |(_, h)| h),
698            timeout_seconds: spec.timeout_seconds,
699        };
700
701        // Claim the computer (Ready → Running) before dispatching, so a
702        // losing racer never launches a process (same discipline as
703        // workload.rs).
704        let slot = self.workload_slot(sandbox_id)?;
705        slot.claim(WorkloadClaim::Api).await?;
706
707        let (input_tx, output_rx) = match agent.exec(start).await {
708            Ok(pair) => pair,
709            Err(e) => {
710                slot.release();
711                return Err(e);
712            }
713        };
714        if !spec.stdin && !spec.tty {
715            // Run semantics: the process starts with stdin at EOF. TTY
716            // executions are exempt — a PTY has no out-of-band EOF, and the
717            // vm-agent's TTY session stops reading input frames (signals,
718            // resizes) once it sees one.
719            let _ = input_tx.send(ExecInputMsg::Eof).await;
720        }
721
722        let exec = Arc::new(Execution::new(id, sandbox_id.clone(), &spec, input_tx));
723        reservation.commit(&exec);
724
725        tokio::spawn(run_session(
726            Arc::clone(&exec),
727            output_rx,
728            slot,
729            Arc::clone(&self.executions),
730        ));
731
732        Ok(exec.snapshot())
733    }
734
735    /// Attach to an execution's output from the given per-channel offsets.
736    ///
737    /// The receiver yields buffered output first (starting at the earliest
738    /// retained byte if retention already dropped the requested offset), then
739    /// live output, and closes once the execution has exited and both
740    /// channels are drained. Read the final state with
741    /// [`SandboxManager::wait_execution`] afterwards.
742    pub fn attach_execution(
743        &self,
744        sandbox_id: &str,
745        execution_id: &str,
746        stdout_offset: u64,
747        stderr_offset: u64,
748    ) -> Result<(ExecutionSnapshot, mpsc::Receiver<ExecutionOutput>)> {
749        let exec = self.executions.get(sandbox_id, execution_id)?;
750        Ok(exec.attach(stdout_offset, stderr_offset))
751    }
752
753    /// Write stdin bytes at an absolute offset (offset-idempotent).
754    ///
755    /// Bytes below the accepted count are deduplicated so a retried write
756    /// never double-feeds the process; an offset beyond the accepted count is
757    /// a gap and is rejected with the resume point. `eof` closes stdin after
758    /// the write — rejected for TTY executions, where EOF is a byte (Ctrl-D)
759    /// the client sends through the stream itself.
760    pub async fn write_stdin(
761        &self,
762        sandbox_id: &str,
763        execution_id: &str,
764        offset: u64,
765        data: &[u8],
766        eof: bool,
767    ) -> Result<StdinState> {
768        self.executions
769            .get(sandbox_id, execution_id)?
770            .write_stdin(offset, data, eof)
771            .await
772    }
773
774    /// Current stdin acceptance state — the resume point after a lost write.
775    pub fn stdin_status(&self, sandbox_id: &str, execution_id: &str) -> Result<StdinState> {
776        Ok(self
777            .executions
778            .get(sandbox_id, execution_id)?
779            .snapshot()
780            .stdin)
781    }
782
783    /// Deliver a POSIX signal to a running execution's process group.
784    pub async fn signal_execution(
785        &self,
786        sandbox_id: &str,
787        execution_id: &str,
788        signal: i32,
789    ) -> Result<()> {
790        self.executions
791            .get(sandbox_id, execution_id)?
792            .signal(signal)
793            .await
794    }
795
796    /// Resize a running TTY execution's terminal.
797    pub async fn resize_execution(
798        &self,
799        sandbox_id: &str,
800        execution_id: &str,
801        width: u16,
802        height: u16,
803    ) -> Result<()> {
804        self.executions
805            .get(sandbox_id, execution_id)?
806            .resize(width, height)
807            .await
808    }
809
810    /// Wait until an execution exits or `timeout` elapses, then return its
811    /// state. A zero timeout polls the current state.
812    pub async fn wait_execution(
813        &self,
814        sandbox_id: &str,
815        execution_id: &str,
816        timeout: Duration,
817    ) -> Result<ExecutionSnapshot> {
818        Ok(self
819            .executions
820            .get(sandbox_id, execution_id)?
821            .wait(timeout)
822            .await)
823    }
824
825    /// List a sandbox's retained executions, running and exited, ordered by
826    /// start time (then id, for a stable order under equal timestamps).
827    ///
828    /// The sandbox must exist; an unknown id is `NotFound` rather than an
829    /// empty list, so a caller can tell "no executions" from a typo.
830    pub fn list_executions(&self, sandbox_id: &SandboxId) -> Result<Vec<ExecutionSnapshot>> {
831        self.computer(sandbox_id)?;
832        let mut snapshots = self.executions.list(sandbox_id);
833        snapshots.sort_by(|a, b| {
834            a.started_at
835                .cmp(&b.started_at)
836                .then_with(|| a.id.cmp(&b.id))
837        });
838        Ok(snapshots)
839    }
840
841    /// Wait until something inside an alive sandbox listens on TCP `port`,
842    /// or fail with [`VmmError::DeadlineExceeded`] once `timeout` elapses.
843    ///
844    /// The vm-agent watches the guest's own listen table in-process — no
845    /// connect probes that would perturb the workload with spurious
846    /// accepted connections.
847    pub async fn wait_sandbox_port(
848        &self,
849        id: &SandboxId,
850        port: u16,
851        timeout: Duration,
852    ) -> Result<()> {
853        let agent = self.require_alive_agent(id)?;
854        match agent.wait_for_port(port, timeout).await? {
855            crate::agent::PortWait::Listening => Ok(()),
856            crate::agent::PortWait::Deadline => Err(VmmError::DeadlineExceeded(format!(
857                "no listener on port {port} in sandbox '{id}' within {}s",
858                timeout.as_secs()
859            ))),
860        }
861    }
862}
863
864/// Forward buffered + live output to one attach consumer.
865async fn pump_attach(
866    exec: Arc<Execution>,
867    mut stdout_cursor: u64,
868    mut stderr_cursor: u64,
869    tx: mpsc::Sender<ExecutionOutput>,
870) {
871    let mut version = exec.version.subscribe();
872    loop {
873        // Mark the version seen before reading, so an append between the read
874        // and the wait below leaves a pending change notification.
875        version.borrow_and_update();
876        loop {
877            let (chunk, exited) = {
878                let st = exec.state.lock().unwrap();
879                let (start, data) = st.stdout.read_from(stdout_cursor, ATTACH_CHUNK);
880                if data.is_empty() {
881                    let (start, data) = st.stderr.read_from(stderr_cursor, ATTACH_CHUNK);
882                    if data.is_empty() {
883                        (None, st.exited_at.is_some())
884                    } else {
885                        stderr_cursor = start + data.len() as u64;
886                        (
887                            Some(ExecutionOutput {
888                                channel: ExecutionChannel::Stderr,
889                                offset: start,
890                                data,
891                            }),
892                            false,
893                        )
894                    }
895                } else {
896                    stdout_cursor = start + data.len() as u64;
897                    (
898                        Some(ExecutionOutput {
899                            channel: ExecutionChannel::Stdout,
900                            offset: start,
901                            data,
902                        }),
903                        false,
904                    )
905                }
906            };
907            match chunk {
908                Some(chunk) => {
909                    if tx.send(chunk).await.is_err() {
910                        return; // consumer gone
911                    }
912                }
913                None if exited => return, // drained past the exit
914                None => break,            // caught up; wait for more
915            }
916        }
917        // The sender lives inside `exec`, which we hold — never closed.
918        let _ = version.changed().await;
919    }
920}
921
922#[cfg(test)]
923mod tests {
924    use super::*;
925
926    fn stdin_spec() -> ExecutionSpec {
927        ExecutionSpec {
928            cmd: vec!["cat".into()],
929            stdin: true,
930            ..ExecutionSpec::default()
931        }
932    }
933
934    fn test_execution(spec: &ExecutionSpec) -> (Arc<Execution>, mpsc::Receiver<ExecInputMsg>) {
935        let (tx, rx) = mpsc::channel(8);
936        let exec = Arc::new(Execution::new(
937            "exec-1".into(),
938            "sandbox-1".into(),
939            spec,
940            tx,
941        ));
942        (exec, rx)
943    }
944
945    #[test]
946    fn channel_buffer_addresses_bytes_absolutely() {
947        let mut buf = ChannelBuffer::default();
948        buf.append(b"hello ");
949        buf.append(b"world");
950        assert_eq!(buf.end(), 11);
951
952        let (start, data) = buf.read_from(0, 1024);
953        assert_eq!((start, data.as_slice()), (0, b"hello world".as_slice()));
954
955        let (start, data) = buf.read_from(6, 3);
956        assert_eq!((start, data.as_slice()), (6, b"wor".as_slice()));
957
958        // Reads at or past the end are empty at the requested offset.
959        let (start, data) = buf.read_from(11, 1024);
960        assert_eq!((start, data.len()), (11, 0));
961    }
962
963    #[test]
964    fn channel_buffer_trims_to_retention_and_advances_base() {
965        let mut buf = ChannelBuffer::default();
966        let chunk = vec![7u8; CHANNEL_RETENTION];
967        buf.append(&chunk);
968        buf.append(b"tail");
969
970        assert_eq!(buf.base, 4);
971        assert_eq!(buf.end(), CHANNEL_RETENTION as u64 + 4);
972
973        // A read below the base is clamped to the earliest retained byte —
974        // the returned offset exposes the gap.
975        let (start, data) = buf.read_from(0, 8);
976        assert_eq!(start, 4);
977        assert_eq!(data.len(), 8);
978    }
979
980    #[tokio::test]
981    async fn write_stdin_dedupes_retries_and_rejects_gaps() {
982        let (exec, mut rx) = test_execution(&stdin_spec());
983
984        let st = exec.write_stdin(0, b"hello", false).await.unwrap();
985        assert_eq!(st.bytes_written, 5);
986
987        // Full retry of the same write: accepted, nothing re-forwarded.
988        let st = exec.write_stdin(0, b"hello", false).await.unwrap();
989        assert_eq!(st.bytes_written, 5);
990
991        // Overlapping retry: only the unseen suffix is forwarded.
992        let st = exec.write_stdin(3, b"lo world", false).await.unwrap();
993        assert_eq!(st.bytes_written, 11);
994
995        // A gap is rejected with the resume point.
996        let err = exec.write_stdin(99, b"x", false).await.unwrap_err();
997        assert!(matches!(err, VmmError::StdinGap { accepted: 11, .. }));
998
999        // Exactly the deduplicated byte stream reached the process.
1000        let mut forwarded = Vec::new();
1001        while let Ok(msg) = rx.try_recv() {
1002            match msg {
1003                ExecInputMsg::Stdin(data) => forwarded.extend_from_slice(&data),
1004                other => panic!("unexpected input message: {other:?}"),
1005            }
1006        }
1007        assert_eq!(forwarded, b"hello world");
1008    }
1009
1010    #[tokio::test]
1011    async fn write_stdin_eof_closes_and_tty_rejects_eof() {
1012        let (exec, mut rx) = test_execution(&stdin_spec());
1013
1014        let st = exec.write_stdin(0, b"in", true).await.unwrap();
1015        assert!(st.closed);
1016        assert!(matches!(rx.recv().await, Some(ExecInputMsg::Stdin(_))));
1017        assert!(matches!(rx.recv().await, Some(ExecInputMsg::Eof)));
1018
1019        // Further data after EOF is rejected; a pure duplicate is not.
1020        let err = exec.write_stdin(2, b"more", false).await.unwrap_err();
1021        assert!(matches!(err, VmmError::Config(_)));
1022        let st = exec.write_stdin(0, b"in", false).await.unwrap();
1023        assert_eq!(st.bytes_written, 2);
1024
1025        // TTY executions cannot close stdin via eof.
1026        let tty_spec = ExecutionSpec {
1027            tty: true,
1028            ..stdin_spec()
1029        };
1030        let (tty_exec, _tty_rx) = test_execution(&tty_spec);
1031        let err = tty_exec.write_stdin(0, b"", true).await.unwrap_err();
1032        assert!(matches!(err, VmmError::Config(_)));
1033    }
1034
1035    #[tokio::test]
1036    async fn attach_replays_from_offsets_and_closes_after_exit() {
1037        let (exec, _rx) = test_execution(&stdin_spec());
1038
1039        {
1040            let mut st = exec.state.lock().unwrap();
1041            st.stdout.append(b"out-data");
1042            st.stderr.append(b"err");
1043        }
1044        exec.bump();
1045
1046        let (snapshot, mut out) = exec.attach(2, 0);
1047        assert!(snapshot.is_running());
1048
1049        let first = out.recv().await.unwrap();
1050        assert_eq!(first.channel, ExecutionChannel::Stdout);
1051        assert_eq!(first.offset, 2);
1052        assert_eq!(first.data, b"t-data");
1053        let second = out.recv().await.unwrap();
1054        assert_eq!(second.channel, ExecutionChannel::Stderr);
1055        assert_eq!(second.offset, 0);
1056        assert_eq!(second.data, b"err");
1057
1058        // Live append after the replay is delivered too.
1059        exec.state.lock().unwrap().stdout.append(b"+live");
1060        exec.bump();
1061        let live = out.recv().await.unwrap();
1062        assert_eq!(live.offset, 8);
1063        assert_eq!(live.data, b"+live");
1064
1065        // Exit closes the stream once drained.
1066        exec.mark_exited(&Ok(ExitStatus::Code(0)));
1067        assert!(out.recv().await.is_none());
1068
1069        let done = exec.wait(Duration::ZERO).await;
1070        assert_eq!(done.exit_status, Some(ExitStatus::Code(0)));
1071    }
1072
1073    #[tokio::test]
1074    async fn wait_times_out_then_resolves_on_exit() {
1075        let (exec, _rx) = test_execution(&stdin_spec());
1076
1077        // Zero timeout polls the running state.
1078        assert!(exec.wait(Duration::ZERO).await.is_running());
1079        // A short timeout returns the still-running state.
1080        assert!(exec.wait(Duration::from_millis(20)).await.is_running());
1081
1082        // A concurrent exit resolves a parked wait.
1083        let exiter = {
1084            let exec = Arc::clone(&exec);
1085            tokio::spawn(async move {
1086                tokio::time::sleep(Duration::from_millis(20)).await;
1087                exec.mark_exited(&Ok(ExitStatus::Signaled(9)));
1088            })
1089        };
1090        let snap = exec.wait(Duration::from_secs(5)).await;
1091        assert_eq!(snap.exit_status, Some(ExitStatus::Signaled(9)));
1092        exiter.await.unwrap();
1093    }
1094
1095    #[tokio::test]
1096    async fn signal_and_resize_validate_state() {
1097        let (exec, mut rx) = test_execution(&stdin_spec());
1098
1099        exec.signal(15).await.unwrap();
1100        assert!(matches!(rx.recv().await, Some(ExecInputMsg::Signal(15))));
1101        assert!(matches!(
1102            exec.signal(0).await.unwrap_err(),
1103            VmmError::Config(_)
1104        ));
1105        // Non-TTY executions have nothing to resize.
1106        assert!(matches!(
1107            exec.resize(80, 24).await.unwrap_err(),
1108            VmmError::Config(_)
1109        ));
1110
1111        exec.mark_exited(&Ok(ExitStatus::Code(0)));
1112        assert!(matches!(
1113            exec.signal(9).await.unwrap_err(),
1114            VmmError::WrongState { .. }
1115        ));
1116    }
1117
1118    #[tokio::test]
1119    async fn interrupt_sandbox_marks_running_executions_but_keeps_them_readable() {
1120        let registry = Arc::new(ExecutionRegistry::default());
1121        let (exec, _rx) = test_execution(&stdin_spec());
1122        registry
1123            .inner
1124            .lock()
1125            .unwrap()
1126            .live
1127            .insert(("sandbox-1".into(), "exec-1".into()), Arc::clone(&exec));
1128
1129        registry.interrupt_sandbox("sandbox-1");
1130        // Still registered: buffered output stays readable until the
1131        // retention GC, but the execution is resolved as torn down.
1132        assert!(registry.get("sandbox-1", "exec-1").is_ok());
1133        let snap = exec.snapshot();
1134        assert!(!snap.is_running());
1135        assert_eq!(snap.error.as_deref(), Some("sandbox stopped"));
1136
1137        // An already-exited execution keeps its original outcome.
1138        let (done, _rx2) = test_execution(&stdin_spec());
1139        done.mark_exited(&Ok(ExitStatus::Code(3)));
1140        registry
1141            .inner
1142            .lock()
1143            .unwrap()
1144            .live
1145            .insert(("sandbox-1".into(), "exec-2".into()), Arc::clone(&done));
1146        registry.interrupt_sandbox("sandbox-1");
1147        assert_eq!(done.snapshot().exit_status, Some(ExitStatus::Code(3)));
1148    }
1149
1150    #[tokio::test]
1151    async fn start_reservation_is_idempotent_per_command() {
1152        let registry = Arc::new(ExecutionRegistry::default());
1153        let key = ("s".to_owned(), "e".to_owned());
1154        let cmd = vec!["echo".to_owned()];
1155
1156        // Reserve → commit.
1157        let Reserve::Slot(slot) = registry.reserve(key.clone(), &cmd).unwrap() else {
1158            panic!("expected a fresh slot");
1159        };
1160        let (tx, _rx) = mpsc::channel(1);
1161        let exec = Arc::new(Execution::new(
1162            "e".into(),
1163            "s".into(),
1164            &ExecutionSpec {
1165                cmd: cmd.clone(),
1166                ..ExecutionSpec::default()
1167            },
1168            tx,
1169        ));
1170        slot.commit(&exec);
1171
1172        // Same command → idempotent retry hands back the existing execution.
1173        assert!(matches!(
1174            registry.reserve(key.clone(), &cmd).unwrap(),
1175            Reserve::Existing(_)
1176        ));
1177        // Different command under the same id → collision.
1178        assert!(matches!(
1179            registry.reserve(key, &["other".to_owned()]),
1180            Err(VmmError::AlreadyExists(_))
1181        ));
1182
1183        // While a start is in flight, a matching retry waits for it instead
1184        // of failing — the idempotency contract holds inside the start
1185        // window, not only once the process is already running. A differing
1186        // command is still a collision.
1187        let key2 = ("s".to_owned(), "e2".to_owned());
1188        {
1189            let Reserve::Slot(_slot) = registry.reserve(key2.clone(), &cmd).unwrap() else {
1190                panic!("expected a fresh slot");
1191            };
1192            assert!(matches!(
1193                registry.reserve(key2.clone(), &cmd).unwrap(),
1194                Reserve::AwaitPending(_)
1195            ));
1196            assert!(matches!(
1197                registry.reserve(key2.clone(), &["other".to_owned()]),
1198                Err(VmmError::AlreadyExists(_))
1199            ));
1200        }
1201        // A dropped (uncommitted) reservation frees the slot.
1202        assert!(matches!(
1203            registry.reserve(key2, &cmd).unwrap(),
1204            Reserve::Slot(_)
1205        ));
1206    }
1207
1208    #[tokio::test]
1209    async fn a_pending_start_wakes_matching_retries_with_the_committed_execution() {
1210        let registry = Arc::new(ExecutionRegistry::default());
1211        let key = ("s".to_owned(), "e".to_owned());
1212        let cmd = vec!["sleep".to_owned()];
1213
1214        let Reserve::Slot(slot) = registry.reserve(key.clone(), &cmd).unwrap() else {
1215            panic!("expected a fresh slot");
1216        };
1217        let Reserve::AwaitPending(mut waiter) = registry.reserve(key.clone(), &cmd).unwrap() else {
1218            panic!("a matching retry must await the in-flight start");
1219        };
1220
1221        // Commit the original; the waiter must then see the live execution.
1222        let (tx, _rx) = mpsc::channel(1);
1223        let exec = Arc::new(Execution::new(
1224            "e".into(),
1225            "s".into(),
1226            &ExecutionSpec {
1227                cmd: cmd.clone(),
1228                ..ExecutionSpec::default()
1229            },
1230            tx,
1231        ));
1232        slot.commit(&exec);
1233
1234        waiter.changed().await.expect("pending start signals");
1235        assert!(matches!(
1236            registry.reserve(key.clone(), &cmd).unwrap(),
1237            Reserve::Existing(_)
1238        ));
1239
1240        // A start that unwinds instead wakes waiters too, and the key is
1241        // free for the retry to claim.
1242        let key2 = ("s".to_owned(), "e2".to_owned());
1243        let Reserve::Slot(failed) = registry.reserve(key2.clone(), &cmd).unwrap() else {
1244            panic!("expected a fresh slot");
1245        };
1246        let Reserve::AwaitPending(mut waiter) = registry.reserve(key2.clone(), &cmd).unwrap()
1247        else {
1248            panic!("a matching retry must await the in-flight start");
1249        };
1250        drop(failed);
1251        waiter.changed().await.expect("unwound start signals");
1252        assert!(matches!(
1253            registry.reserve(key2, &cmd).unwrap(),
1254            Reserve::Slot(_)
1255        ));
1256    }
1257}