Skip to main content

a3s_box_runtime/grpc/
exec.rs

1//! Command execution and streaming clients.
2
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use a3s_box_core::error::{BoxError, Result};
7use tokio::io::AsyncWriteExt;
8use tokio::net::UnixStream;
9use tokio::sync::Mutex;
10
11const EXEC_CONTROL_CANCEL: &[u8] = b"cancel";
12const EXEC_CONTROL_STDIN_CLOSE: &[u8] = b"stdin-close";
13/// Host→guest control: flush all buffered output and reply with a flush-ack.
14const EXEC_CONTROL_FLUSH: &[u8] = b"flush";
15/// Host→guest control: stream a tar archive of the guest-visible rootfs.
16const EXEC_CONTROL_ARCHIVE_ROOTFS: &[u8] = b"archive-rootfs-v1";
17const EXEC_CONTROL_ARCHIVE_ROOTFS_PAUSE: &[u8] = b"archive-rootfs-v1:pause";
18/// Guest→host marker after every archive data frame has been sent.
19const EXEC_ARCHIVE_ROOTFS_DONE: &[u8] = b"archive-rootfs-v1-done";
20/// Guest→host marker (carried in a Control frame) acknowledging a flush. Kept
21/// distinct from an `ExecExit` JSON payload so `next_event` can tell them apart.
22/// Must match the guest's `EXEC_FLUSH_ACK` in `guest/init/src/exec_server.rs`.
23const EXEC_FLUSH_ACK: &[u8] = b"flush-ack";
24/// Guest→host acknowledgement that a `signal-main:<N>` graceful-stop control was
25/// received and the signal delivered. Must match the guest's
26/// `EXEC_SIGNAL_MAIN_ACK` in `guest/init/src/exec_server.rs`.
27const EXEC_SIGNAL_MAIN_ACK: &[u8] = b"signal-main-ack";
28/// Guest→host acknowledgement that a `spawn-main` deferred-main control was
29/// received and the container main spawned. Matches the guest's
30/// `EXEC_SPAWN_MAIN_ACK` in `guest/init/src/exec_server.rs`.
31const EXEC_SPAWN_MAIN_ACK: &[u8] = b"spawn-main-ack";
32/// Guest→host negative acknowledgement for `spawn-main`, followed by a UTF-8-ish
33/// diagnostic string from guest-init.
34const EXEC_SPAWN_MAIN_NACK: &[u8] = b"spawn-main-nack:";
35
36/// Host-side slack added to a one-shot exec's in-guest `timeout_ns` before the
37/// host gives up reading the reply. The in-guest timeout cannot fire if the
38/// guest is wedged, so the host needs its own ceiling.
39const EXEC_HOST_SLACK_SECS: u64 = 10;
40/// Host-side deadline for a `signal-main` ACK. Signal delivery + the ACK are
41/// fast; a wedged guest that never replies must not block the caller's
42/// force-kill fallback.
43const SIGNAL_MAIN_ACK_TIMEOUT_SECS: u64 = 10;
44
45type ExecFrameReader = a3s_transport::FrameReader<tokio::io::ReadHalf<tokio::net::UnixStream>>;
46type ExecFrameWriter = a3s_transport::FrameWriter<tokio::io::WriteHalf<tokio::net::UnixStream>>;
47
48/// Client for executing commands in the guest over Unix socket.
49///
50/// Uses the Frame wire protocol: sends a Data frame with JSON ExecRequest,
51/// receives a Data frame with JSON ExecOutput.
52#[derive(Debug)]
53pub struct ExecClient {
54    socket_path: PathBuf,
55}
56
57impl ExecClient {
58    pub(crate) fn for_socket(socket_path: &Path) -> Self {
59        Self {
60            socket_path: socket_path.to_path_buf(),
61        }
62    }
63
64    /// Connect to the exec server via Unix socket.
65    ///
66    /// Verifies the socket is connectable.
67    pub async fn connect(socket_path: &Path) -> Result<Self> {
68        let client = Self::for_socket(socket_path);
69        let _stream = client.open_stream().await?;
70        Ok(client)
71    }
72
73    /// Get the socket path this client is connected to.
74    pub fn socket_path(&self) -> &Path {
75        &self.socket_path
76    }
77
78    pub(crate) async fn open_stream(&self) -> Result<UnixStream> {
79        UnixStream::connect(&self.socket_path).await.map_err(|e| {
80            BoxError::ExecError(format!(
81                "Exec connection failed to {}: {}",
82                self.socket_path.display(),
83                e,
84            ))
85        })
86    }
87
88    /// Execute a command in the guest.
89    ///
90    /// Sends a Data frame with JSON ExecRequest, reads a Data frame with JSON ExecOutput.
91    pub async fn exec_command(
92        &self,
93        request: &a3s_box_core::exec::ExecRequest,
94    ) -> Result<a3s_box_core::exec::ExecOutput> {
95        let stream = self.open_stream().await?;
96        self.exec_command_on_stream(stream, request).await
97    }
98
99    pub(crate) async fn exec_command_on_stream(
100        &self,
101        mut stream: UnixStream,
102        request: &a3s_box_core::exec::ExecRequest,
103    ) -> Result<a3s_box_core::exec::ExecOutput> {
104        let payload = serde_json::to_vec(request)
105            .map_err(|e| BoxError::ExecError(format!("Failed to serialize exec request: {}", e)))?;
106
107        // Send request as Data frame
108        let request_frame = a3s_transport::Frame::data(payload);
109        let encoded = request_frame.encode().map_err(|e| {
110            BoxError::ExecError(format!("Failed to encode exec request frame: {}", e))
111        })?;
112        stream
113            .write_all(&encoded)
114            .await
115            .map_err(|e| BoxError::ExecError(format!("Exec request write failed: {}", e)))?;
116
117        // Read response frame, bounded by a HOST-side deadline of the request's
118        // timeout plus slack. The request's timeout_ns is only enforced INSIDE
119        // the guest; a wedged guest (kernel hang, OOM thrash, frozen VM) can
120        // still complete the host connect handshake but never reply, which would
121        // block this read forever and stall every caller (health probes, the
122        // monitor poll loop, CLI exec).
123        let (r, _w) = tokio::io::split(stream);
124        let mut reader = a3s_transport::FrameReader::new(r);
125        let host_deadline = std::time::Duration::from_nanos(request.timeout_ns)
126            .saturating_add(std::time::Duration::from_secs(EXEC_HOST_SLACK_SECS));
127        let frame = tokio::time::timeout(host_deadline, reader.read_frame())
128            .await
129            .map_err(|_| {
130                BoxError::ExecError(format!(
131                    "Exec response timed out after {host_deadline:?} (guest may be wedged)"
132                ))
133            })?
134            .map_err(|e| BoxError::ExecError(format!("Exec response read failed: {}", e)))?
135            .ok_or_else(|| {
136                BoxError::ExecError("Exec server closed without response".to_string())
137            })?;
138
139        match frame.frame_type {
140            a3s_transport::FrameType::Data => {
141                let output: a3s_box_core::exec::ExecOutput = serde_json::from_slice(&frame.payload)
142                    .map_err(|e| {
143                        BoxError::ExecError(format!("Failed to parse exec response: {}", e))
144                    })?;
145                Ok(output)
146            }
147            a3s_transport::FrameType::Error => {
148                let msg = String::from_utf8_lossy(&frame.payload);
149                Err(BoxError::ExecError(format!("Exec server error: {}", msg)))
150            }
151            _ => Err(BoxError::ExecError(format!(
152                "Unexpected frame type: {:?}",
153                frame.frame_type
154            ))),
155        }
156    }
157
158    /// Execute a command in streaming mode.
159    ///
160    /// Sends a Data frame with JSON ExecRequest (streaming=true), then reads
161    /// multiple frames: ExecChunk frames for stdout/stderr data, and a final
162    /// ExecExit frame with the exit code.
163    ///
164    /// Returns a `StreamingExec` handle for reading events.
165    pub async fn exec_stream(
166        &self,
167        request: &a3s_box_core::exec::ExecRequest,
168    ) -> Result<StreamingExec> {
169        let stream = self.open_stream().await?;
170        self.exec_stream_on_stream(stream, request).await
171    }
172
173    pub(crate) async fn exec_stream_on_stream(
174        &self,
175        stream: UnixStream,
176        request: &a3s_box_core::exec::ExecRequest,
177    ) -> Result<StreamingExec> {
178        let mut req = request.clone();
179        req.streaming = true;
180
181        let payload = serde_json::to_vec(&req)
182            .map_err(|e| BoxError::ExecError(format!("Failed to serialize exec request: {}", e)))?;
183
184        let (r, w) = tokio::io::split(stream);
185        let mut writer = a3s_transport::FrameWriter::new(w);
186        writer
187            .write_data(&payload)
188            .await
189            .map_err(|e| BoxError::ExecError(format!("Exec request write failed: {}", e)))?;
190
191        let reader = a3s_transport::FrameReader::new(r);
192        let started = std::time::Instant::now();
193
194        Ok(StreamingExec {
195            reader,
196            writer: Arc::new(Mutex::new(writer)),
197            started,
198            stdout_bytes: 0,
199            stderr_bytes: 0,
200            done: false,
201        })
202    }
203
204    /// Stream a guest-created rootfs tar archive into `output`.
205    ///
206    /// The guest performs `stat` and tar-header creation, preserving Linux
207    /// uid/gid/mode even when the host virtio-fs backing directory exposes
208    /// different macOS metadata. Mounted subtrees are excluded by guest-init.
209    pub async fn archive_rootfs<W>(&self, output: &mut W, pause: bool) -> Result<u64>
210    where
211        W: tokio::io::AsyncWrite + Unpin,
212    {
213        let mut stream = UnixStream::connect(&self.socket_path)
214            .await
215            .map_err(|error| {
216                BoxError::ExecError(format!(
217                    "Rootfs archive connection failed to {}: {error}",
218                    self.socket_path.display()
219                ))
220            })?;
221
222        let control = if pause {
223            EXEC_CONTROL_ARCHIVE_ROOTFS_PAUSE
224        } else {
225            EXEC_CONTROL_ARCHIVE_ROOTFS
226        };
227        let request = a3s_transport::Frame::control(control.to_vec());
228        stream
229            .write_all(&request.encode().map_err(|error| {
230                BoxError::ExecError(format!("Rootfs archive request encode failed: {error}"))
231            })?)
232            .await
233            .map_err(|error| {
234                BoxError::ExecError(format!("Rootfs archive request write failed: {error}"))
235            })?;
236
237        let (reader, _writer) = tokio::io::split(stream);
238        let mut reader = a3s_transport::FrameReader::new(reader);
239        let mut written = 0u64;
240        loop {
241            let frame = reader
242                .read_frame()
243                .await
244                .map_err(|error| {
245                    BoxError::ExecError(format!("Rootfs archive read failed: {error}"))
246                })?
247                .ok_or_else(|| {
248                    BoxError::ExecError(
249                        "Rootfs archive stream closed before completion".to_string(),
250                    )
251                })?;
252
253            match frame.frame_type {
254                a3s_transport::FrameType::Data => {
255                    output.write_all(&frame.payload).await.map_err(|error| {
256                        BoxError::ExecError(format!("Rootfs archive output write failed: {error}"))
257                    })?;
258                    written = written.saturating_add(frame.payload.len() as u64);
259                }
260                a3s_transport::FrameType::Control if frame.payload == EXEC_ARCHIVE_ROOTFS_DONE => {
261                    output.flush().await.map_err(|error| {
262                        BoxError::ExecError(format!("Rootfs archive output flush failed: {error}"))
263                    })?;
264                    return Ok(written);
265                }
266                a3s_transport::FrameType::Error => {
267                    return Err(BoxError::ExecError(format!(
268                        "Guest rootfs archive failed: {}",
269                        String::from_utf8_lossy(&frame.payload)
270                    )));
271                }
272                other => {
273                    return Err(BoxError::ExecError(format!(
274                        "Unexpected rootfs archive frame: {other:?}"
275                    )));
276                }
277            }
278        }
279    }
280
281    /// Transfer a file to/from the guest.
282    ///
283    /// Sends a Data frame with JSON FileRequest, reads a Data frame with JSON FileResponse.
284    pub async fn file_transfer(
285        &self,
286        request: &a3s_box_core::exec::FileRequest,
287    ) -> Result<a3s_box_core::exec::FileResponse> {
288        let stream = self.open_stream().await?;
289        self.file_transfer_on_stream(stream, request).await
290    }
291
292    pub(crate) async fn file_transfer_on_stream(
293        &self,
294        mut stream: UnixStream,
295        request: &a3s_box_core::exec::FileRequest,
296    ) -> Result<a3s_box_core::exec::FileResponse> {
297        let payload = serde_json::to_vec(request)
298            .map_err(|e| BoxError::ExecError(format!("Failed to serialize file request: {}", e)))?;
299
300        let request_frame = a3s_transport::Frame::data(payload);
301        let encoded = request_frame.encode().map_err(|e| {
302            BoxError::ExecError(format!("Failed to encode file request frame: {}", e))
303        })?;
304        stream
305            .write_all(&encoded)
306            .await
307            .map_err(|e| BoxError::ExecError(format!("File request write failed: {}", e)))?;
308
309        let (r, _w) = tokio::io::split(stream);
310        let mut reader = a3s_transport::FrameReader::new(r);
311        let frame = reader
312            .read_frame()
313            .await
314            .map_err(|e| BoxError::ExecError(format!("File response read failed: {}", e)))?
315            .ok_or_else(|| {
316                BoxError::ExecError("Exec server closed without response".to_string())
317            })?;
318
319        match frame.frame_type {
320            a3s_transport::FrameType::Data => {
321                let response: a3s_box_core::exec::FileResponse =
322                    serde_json::from_slice(&frame.payload).map_err(|e| {
323                        BoxError::ExecError(format!("Failed to parse file response: {}", e))
324                    })?;
325                Ok(response)
326            }
327            a3s_transport::FrameType::Error => {
328                let msg = String::from_utf8_lossy(&frame.payload);
329                Err(BoxError::ExecError(format!("File transfer error: {}", msg)))
330            }
331            _ => Err(BoxError::ExecError(format!(
332                "Unexpected frame type: {:?}",
333                frame.frame_type
334            ))),
335        }
336    }
337
338    /// Send a Heartbeat frame and wait for a Heartbeat response.
339    ///
340    /// Returns `true` if the exec server responds, `false` otherwise.
341    pub async fn heartbeat(&self) -> Result<bool> {
342        let mut stream = match UnixStream::connect(&self.socket_path).await {
343            Ok(s) => s,
344            Err(_) => return Ok(false),
345        };
346
347        let frame = a3s_transport::Frame::heartbeat();
348        let encoded = match frame.encode() {
349            Ok(e) => e,
350            Err(_) => return Ok(false),
351        };
352
353        if stream.write_all(&encoded).await.is_err() {
354            return Ok(false);
355        }
356
357        let (r, _w) = tokio::io::split(stream);
358        let mut reader = a3s_transport::FrameReader::new(r);
359        match reader.read_frame().await {
360            Ok(Some(f)) if f.frame_type == a3s_transport::FrameType::Heartbeat => Ok(true),
361            _ => Ok(false),
362        }
363    }
364
365    /// Ask the guest to deliver `signal` (a signal number, e.g. 15 for SIGTERM)
366    /// to the main container process for graceful shutdown. The guest runs the
367    /// container's own stop handler; when it exits, guest init exits and the VM
368    /// stops cleanly. Returns `Ok(true)` if the guest acknowledged, `Ok(false)`
369    /// if it did not respond (caller should fall back to a hard stop).
370    pub async fn signal_main(&self, signal: i32) -> Result<bool> {
371        let mut stream = match UnixStream::connect(&self.socket_path).await {
372            Ok(s) => s,
373            Err(_) => return Ok(false),
374        };
375
376        let payload = format!("signal-main:{}", signal).into_bytes();
377        let frame = a3s_transport::Frame::control(payload);
378        let encoded = frame
379            .encode()
380            .map_err(|e| BoxError::ExecError(format!("signal-main frame encode failed: {}", e)))?;
381
382        if stream.write_all(&encoded).await.is_err() {
383            return Ok(false);
384        }
385
386        // Host-side deadline: a wedged guest can complete the connect handshake
387        // (listen backlog) but never write the ACK, which would hang this read
388        // forever — and stop/restart deliver the signal through here BEFORE their
389        // force-kill fallback, so the fallback would never run. On timeout report
390        // not-acknowledged so the caller force-kills.
391        let (r, _w) = tokio::io::split(stream);
392        let mut reader = a3s_transport::FrameReader::new(r);
393        let read = tokio::time::timeout(
394            std::time::Duration::from_secs(SIGNAL_MAIN_ACK_TIMEOUT_SECS),
395            reader.read_frame(),
396        )
397        .await;
398        match read {
399            Ok(Ok(Some(f)))
400                if f.frame_type == a3s_transport::FrameType::Control
401                    && f.payload == EXEC_SIGNAL_MAIN_ACK =>
402            {
403                Ok(true)
404            }
405            _ => Ok(false),
406        }
407    }
408
409    /// Ask a guest that booted IDLE (`BOX_DEFERRED_MAIN=1`) to spawn its container
410    /// command — already known to the guest via BOX_EXEC_* — as the MAIN process.
411    /// The spawned main inherits the console (so its output reaches the json-file
412    /// logs) and drives the VM lifecycle. Returns `Ok(true)` if acknowledged.
413    pub async fn spawn_main(&self, spec_json: Option<&[u8]>) -> Result<bool> {
414        let mut stream = match UnixStream::connect(&self.socket_path).await {
415            Ok(s) => s,
416            Err(_) => return Ok(false),
417        };
418
419        let mut payload = b"spawn-main:".to_vec();
420        if let Some(json) = spec_json {
421            payload.extend_from_slice(json);
422        }
423        let frame = a3s_transport::Frame::control(payload);
424        let encoded = frame
425            .encode()
426            .map_err(|e| BoxError::ExecError(format!("spawn-main frame encode failed: {}", e)))?;
427
428        if stream.write_all(&encoded).await.is_err() {
429            return Ok(false);
430        }
431
432        let (r, _w) = tokio::io::split(stream);
433        let mut reader = a3s_transport::FrameReader::new(r);
434        match reader.read_frame().await {
435            Ok(Some(f))
436                if f.frame_type == a3s_transport::FrameType::Control
437                    && f.payload == EXEC_SPAWN_MAIN_ACK =>
438            {
439                Ok(true)
440            }
441            Ok(Some(f))
442                if f.frame_type == a3s_transport::FrameType::Control
443                    && f.payload.starts_with(EXEC_SPAWN_MAIN_NACK) =>
444            {
445                let reason = String::from_utf8_lossy(&f.payload[EXEC_SPAWN_MAIN_NACK.len()..]);
446                Err(BoxError::ExecError(format!(
447                    "spawn-main rejected by guest: {reason}"
448                )))
449            }
450            _ => Ok(false),
451        }
452    }
453}
454
455/// Handle for reading streaming exec events.
456///
457/// Reads frames from the exec server: Data frames contain `ExecChunk` (stdout/stderr),
458/// Control frames contain `ExecExit` (final exit code).
459pub struct StreamingExec {
460    reader: ExecFrameReader,
461    writer: Arc<Mutex<ExecFrameWriter>>,
462    started: std::time::Instant,
463    stdout_bytes: u64,
464    stderr_bytes: u64,
465    done: bool,
466}
467
468/// Cloneable input side for a running streaming exec workload.
469#[derive(Clone, Debug)]
470pub struct StreamingExecInput {
471    writer: Arc<Mutex<ExecFrameWriter>>,
472}
473
474impl StreamingExecInput {
475    /// Write bytes to the running command's stdin.
476    pub async fn write_stdin(&self, data: &[u8]) -> Result<()> {
477        self.writer
478            .lock()
479            .await
480            .write_data(data)
481            .await
482            .map_err(|e| BoxError::ExecError(format!("Streaming exec stdin write failed: {}", e)))
483    }
484
485    /// Close the running command's stdin without stopping the process.
486    pub async fn close_stdin(&self) -> Result<()> {
487        self.writer
488            .lock()
489            .await
490            .write_control(EXEC_CONTROL_STDIN_CLOSE)
491            .await
492            .map_err(|e| {
493                BoxError::ExecError(format!("Streaming exec stdin close write failed: {}", e))
494            })
495    }
496
497    /// Request cancellation of the running command.
498    pub async fn cancel(&self) -> Result<()> {
499        self.writer
500            .lock()
501            .await
502            .write_control(EXEC_CONTROL_CANCEL)
503            .await
504            .map_err(|e| BoxError::ExecError(format!("Streaming exec cancel write failed: {}", e)))
505    }
506
507    /// Request a flush of the guest's buffered output. The guest replies with a
508    /// flush-ack (`ExecEvent::FlushAck`) once every chunk it had buffered at
509    /// flush time has been sent, establishing a clean log-rotation boundary.
510    pub async fn flush(&self) -> Result<()> {
511        self.writer
512            .lock()
513            .await
514            .write_control(EXEC_CONTROL_FLUSH)
515            .await
516            .map_err(|e| BoxError::ExecError(format!("Streaming exec flush write failed: {}", e)))
517    }
518}
519
520impl StreamingExec {
521    /// Return a cloneable input handle for this running stream.
522    pub fn input(&self) -> StreamingExecInput {
523        StreamingExecInput {
524            writer: self.writer.clone(),
525        }
526    }
527
528    /// Write bytes to the running command's stdin.
529    pub async fn write_stdin(&self, data: &[u8]) -> Result<()> {
530        self.input().write_stdin(data).await
531    }
532
533    /// Close the running command's stdin without stopping the process.
534    pub async fn close_stdin(&self) -> Result<()> {
535        self.input().close_stdin().await
536    }
537
538    /// Request a flush of the guest's buffered output (see
539    /// [`StreamingExecInput::flush`]).
540    pub async fn flush(&self) -> Result<()> {
541        self.input().flush().await
542    }
543
544    /// Read the next event from the stream.
545    ///
546    /// Returns `None` when the command has exited and all output has been read.
547    pub async fn next_event(&mut self) -> Result<Option<a3s_box_core::exec::ExecEvent>> {
548        use a3s_box_core::exec::{ExecChunk, ExecEvent, ExecExit};
549
550        if self.done {
551            return Ok(None);
552        }
553
554        let frame = match self.reader.read_frame().await {
555            Ok(Some(f)) => f,
556            Ok(None) => {
557                self.done = true;
558                return Ok(None);
559            }
560            Err(e) => {
561                self.done = true;
562                return Err(BoxError::ExecError(format!(
563                    "Streaming exec read failed: {}",
564                    e
565                )));
566            }
567        };
568
569        match frame.frame_type {
570            a3s_transport::FrameType::Data => {
571                // Data frame = ExecChunk (stdout/stderr)
572                let chunk: ExecChunk = serde_json::from_slice(&frame.payload).map_err(|e| {
573                    BoxError::ExecError(format!("Failed to parse exec chunk: {}", e))
574                })?;
575                match chunk.stream {
576                    a3s_box_core::exec::StreamType::Stdout => {
577                        self.stdout_bytes += chunk.data.len() as u64;
578                    }
579                    a3s_box_core::exec::StreamType::Stderr => {
580                        self.stderr_bytes += chunk.data.len() as u64;
581                    }
582                }
583                Ok(Some(ExecEvent::Chunk(chunk)))
584            }
585            a3s_transport::FrameType::Control => {
586                // A Control frame is either a flush-ack marker or an ExecExit.
587                if frame.payload == EXEC_FLUSH_ACK {
588                    // Boundary marker for log rotation — the stream continues.
589                    return Ok(Some(ExecEvent::FlushAck));
590                }
591                let exit: ExecExit = serde_json::from_slice(&frame.payload).map_err(|e| {
592                    BoxError::ExecError(format!("Failed to parse exec exit: {}", e))
593                })?;
594                self.done = true;
595                Ok(Some(ExecEvent::Exit(exit)))
596            }
597            a3s_transport::FrameType::Error => {
598                let msg = String::from_utf8_lossy(&frame.payload);
599                self.done = true;
600                Err(BoxError::ExecError(format!(
601                    "Streaming exec error: {}",
602                    msg
603                )))
604            }
605            _ => Err(BoxError::ExecError(format!(
606                "Unexpected frame type in stream: {:?}",
607                frame.frame_type
608            ))),
609        }
610    }
611
612    /// Request cancellation of the running streaming exec workload.
613    ///
614    /// The guest exec server treats this as a best-effort container stop signal
615    /// and should emit a final exit frame after terminating the child process.
616    pub async fn cancel(&mut self) -> Result<()> {
617        self.input().cancel().await
618    }
619
620    /// Collect all remaining output and return the final result with metrics.
621    ///
622    /// Consumes the stream, buffering all stdout/stderr until the command exits.
623    pub async fn collect(
624        mut self,
625    ) -> Result<(
626        a3s_box_core::exec::ExecOutput,
627        a3s_box_core::exec::ExecMetrics,
628    )> {
629        use a3s_box_core::exec::{ExecEvent, ExecMetrics, ExecOutput};
630
631        let mut stdout = Vec::new();
632        let mut stderr = Vec::new();
633        let mut exit_code = -1;
634
635        while let Some(event) = self.next_event().await? {
636            match event {
637                ExecEvent::Chunk(chunk) => match chunk.stream {
638                    a3s_box_core::exec::StreamType::Stdout => stdout.extend_from_slice(&chunk.data),
639                    a3s_box_core::exec::StreamType::Stderr => stderr.extend_from_slice(&chunk.data),
640                },
641                ExecEvent::FlushAck => {}
642                ExecEvent::Exit(exit) => {
643                    exit_code = exit.exit_code;
644                }
645            }
646        }
647
648        let metrics = ExecMetrics {
649            duration_ms: self.started.elapsed().as_millis() as u64,
650            peak_memory_bytes: None,
651            stdout_bytes: self.stdout_bytes,
652            stderr_bytes: self.stderr_bytes,
653        };
654
655        let output = ExecOutput {
656            stdout,
657            stderr,
658            exit_code,
659        };
660
661        Ok((output, metrics))
662    }
663
664    /// Whether the stream has finished (command exited or connection closed).
665    pub fn is_done(&self) -> bool {
666        self.done
667    }
668
669    /// Get execution metrics so far.
670    pub fn metrics(&self) -> a3s_box_core::exec::ExecMetrics {
671        a3s_box_core::exec::ExecMetrics {
672            duration_ms: self.started.elapsed().as_millis() as u64,
673            peak_memory_bytes: None,
674            stdout_bytes: self.stdout_bytes,
675            stderr_bytes: self.stderr_bytes,
676        }
677    }
678}
679
680impl std::fmt::Debug for StreamingExec {
681    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
682        f.debug_struct("StreamingExec")
683            .field("done", &self.done)
684            .field("stdout_bytes", &self.stdout_bytes)
685            .field("stderr_bytes", &self.stderr_bytes)
686            .finish()
687    }
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693    use tokio::io::AsyncReadExt;
694    use tokio::net::UnixListener;
695
696    fn bind_test_listener(path: &Path) -> Option<UnixListener> {
697        match UnixListener::bind(path) {
698            Ok(listener) => Some(listener),
699            Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
700                eprintln!(
701                    "skipping Unix socket test; sandbox denied bind at {}: {}",
702                    path.display(),
703                    e
704                );
705                None
706            }
707            Err(e) => panic!("failed to bind test socket {}: {}", path.display(), e),
708        }
709    }
710
711    #[tokio::test]
712    async fn test_exec_connect_nonexistent_socket() {
713        let result = ExecClient::connect(Path::new("/tmp/nonexistent-a3s-exec-test.sock")).await;
714        assert!(result.is_err());
715        let err = result.unwrap_err();
716        assert!(matches!(err, BoxError::ExecError(_)));
717    }
718
719    #[tokio::test]
720    async fn test_exec_connect_and_socket_path() {
721        let tmp = tempfile::TempDir::new().unwrap();
722        let sock_path = tmp.path().join("exec.sock");
723        let Some(_listener) = bind_test_listener(&sock_path) else {
724            return;
725        };
726
727        let client = ExecClient::connect(&sock_path).await.unwrap();
728        assert_eq!(client.socket_path(), sock_path);
729    }
730
731    #[tokio::test]
732    async fn test_exec_heartbeat_with_echo_server() {
733        let tmp = tempfile::TempDir::new().unwrap();
734        let sock_path = tmp.path().join("hb_echo.sock");
735        let Some(listener) = bind_test_listener(&sock_path) else {
736            return;
737        };
738
739        tokio::spawn(async move {
740            // Accept connect verification
741            let (stream, _) = listener.accept().await.unwrap();
742            drop(stream);
743            // Accept heartbeat connection and echo back
744            let (mut stream, _) = listener.accept().await.unwrap();
745            // Read frame header
746            let mut header = [0u8; 5];
747            stream.read_exact(&mut header).await.unwrap();
748            let len = u32::from_be_bytes([header[1], header[2], header[3], header[4]]) as usize;
749            let mut payload = vec![0u8; len];
750            if len > 0 {
751                stream.read_exact(&mut payload).await.unwrap();
752            }
753            // Respond with Heartbeat frame
754            let response = a3s_transport::Frame::heartbeat();
755            let encoded = response.encode().unwrap();
756            stream.write_all(&encoded).await.unwrap();
757        });
758
759        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
760
761        let client = ExecClient::connect(&sock_path).await.unwrap();
762        let result = client.heartbeat().await.unwrap();
763        assert!(result);
764    }
765
766    #[tokio::test]
767    async fn test_exec_heartbeat_no_response() {
768        let tmp = tempfile::TempDir::new().unwrap();
769        let sock_path = tmp.path().join("hb_close.sock");
770        let Some(listener) = bind_test_listener(&sock_path) else {
771            return;
772        };
773
774        tokio::spawn(async move {
775            // Accept connect verification
776            let (stream, _) = listener.accept().await.unwrap();
777            drop(stream);
778            // Accept heartbeat connection, read request, then close
779            let (mut stream, _) = listener.accept().await.unwrap();
780            let mut buf = vec![0u8; 1024];
781            let _ = stream.read(&mut buf).await;
782            drop(stream);
783        });
784
785        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
786
787        let client = ExecClient::connect(&sock_path).await.unwrap();
788        let result = client.heartbeat().await.unwrap();
789        assert!(!result);
790    }
791
792    #[tokio::test]
793    async fn test_exec_heartbeat_nonexistent_socket() {
794        // heartbeat() on a non-connectable socket should return false, not error
795        let client = ExecClient {
796            socket_path: PathBuf::from("/tmp/nonexistent-hb-test.sock"),
797        };
798        let result = client.heartbeat().await.unwrap();
799        assert!(!result);
800    }
801
802    #[tokio::test]
803    async fn test_archive_rootfs_streams_data_until_done_marker() {
804        let tmp = tempfile::TempDir::new().unwrap();
805        let sock_path = tmp.path().join("archive.sock");
806        let Some(listener) = bind_test_listener(&sock_path) else {
807            return;
808        };
809
810        tokio::spawn(async move {
811            // ExecClient::connect performs one reachability connection first.
812            let (stream, _) = listener.accept().await.unwrap();
813            drop(stream);
814            let (mut stream, _) = listener.accept().await.unwrap();
815            let mut header = [0u8; 5];
816            stream.read_exact(&mut header).await.unwrap();
817            assert_eq!(header[0], a3s_transport::FrameType::Control as u8);
818            let length = u32::from_be_bytes(header[1..5].try_into().unwrap()) as usize;
819            let mut payload = vec![0u8; length];
820            stream.read_exact(&mut payload).await.unwrap();
821            assert_eq!(payload, EXEC_CONTROL_ARCHIVE_ROOTFS);
822
823            for payload in [b"first".as_slice(), b"-second".as_slice()] {
824                let frame = a3s_transport::Frame::data(payload.to_vec());
825                stream.write_all(&frame.encode().unwrap()).await.unwrap();
826            }
827            let done = a3s_transport::Frame::control(EXEC_ARCHIVE_ROOTFS_DONE.to_vec());
828            stream.write_all(&done.encode().unwrap()).await.unwrap();
829        });
830
831        let client = ExecClient::connect(&sock_path).await.unwrap();
832        let output_path = tmp.path().join("rootfs.tar");
833        let mut output = tokio::fs::File::create(&output_path).await.unwrap();
834        let written = client.archive_rootfs(&mut output, false).await.unwrap();
835        drop(output);
836
837        assert_eq!(written, 12);
838        assert_eq!(std::fs::read(output_path).unwrap(), b"first-second");
839    }
840
841    #[tokio::test]
842    async fn test_exec_signal_main_round_trip() {
843        let tmp = tempfile::TempDir::new().unwrap();
844        let sock_path = tmp.path().join("signal_main.sock");
845        let Some(listener) = bind_test_listener(&sock_path) else {
846            return;
847        };
848
849        tokio::spawn(async move {
850            // Accept connect verification
851            let (stream, _) = listener.accept().await.unwrap();
852            drop(stream);
853            // Accept signal-main connection: read the Control frame, ack it.
854            let (stream, _) = listener.accept().await.unwrap();
855            let (r, w) = tokio::io::split(stream);
856            let mut reader = a3s_transport::FrameReader::new(r);
857            let mut writer = a3s_transport::FrameWriter::new(w);
858
859            let frame = reader.read_frame().await.unwrap().unwrap();
860            assert_eq!(frame.frame_type, a3s_transport::FrameType::Control);
861            assert_eq!(frame.payload, b"signal-main:2");
862
863            writer.write_control(EXEC_SIGNAL_MAIN_ACK).await.unwrap();
864        });
865
866        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
867
868        let client = ExecClient::connect(&sock_path).await.unwrap();
869        // SIGINT = 2 (image STOPSIGNAL example)
870        let acked = client.signal_main(2).await.unwrap();
871        assert!(acked);
872    }
873
874    #[tokio::test]
875    async fn test_exec_signal_main_nonexistent_socket() {
876        // signal_main on a non-connectable socket returns false, not an error,
877        // so the caller can fall back to a hard stop.
878        let client = ExecClient {
879            socket_path: PathBuf::from("/tmp/nonexistent-signal-main-test.sock"),
880        };
881        let acked = client.signal_main(15).await.unwrap();
882        assert!(!acked);
883    }
884
885    #[tokio::test]
886    async fn test_exec_client_exec_command() {
887        let tmp = tempfile::TempDir::new().unwrap();
888        let sock_path = tmp.path().join("exec_cmd.sock");
889        let Some(listener) = bind_test_listener(&sock_path) else {
890            return;
891        };
892
893        tokio::spawn(async move {
894            // Accept connect verification
895            let (stream, _) = listener.accept().await.unwrap();
896            drop(stream);
897            // Accept exec request — read Frame, respond with Frame
898            let (stream, _) = listener.accept().await.unwrap();
899            let (r, w) = tokio::io::split(stream);
900            let mut reader = a3s_transport::FrameReader::new(r);
901            let mut writer = a3s_transport::FrameWriter::new(w);
902
903            // Read request frame
904            let _frame = reader.read_frame().await.unwrap().unwrap();
905
906            // Send response as Data frame
907            let output = a3s_box_core::exec::ExecOutput {
908                stdout: b"hello\n".to_vec(),
909                stderr: vec![],
910                exit_code: 0,
911            };
912            let payload = serde_json::to_vec(&output).unwrap();
913            writer.write_data(&payload).await.unwrap();
914        });
915
916        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
917
918        let client = ExecClient::connect(&sock_path).await.unwrap();
919        let req = a3s_box_core::exec::ExecRequest {
920            cmd: vec!["echo".to_string(), "hello".to_string()],
921            env: vec![],
922            working_dir: None,
923            rootfs: None,
924            user: None,
925            stdin: None,
926            stdin_streaming: false,
927            timeout_ns: 0,
928            streaming: false,
929        };
930        let output = client.exec_command(&req).await.unwrap();
931        assert_eq!(output.exit_code, 0);
932        assert_eq!(&output.stdout[..], b"hello\n");
933        assert!(output.stderr.is_empty());
934    }
935
936    #[tokio::test]
937    async fn test_exec_client_exec_stream_collect() {
938        let tmp = tempfile::TempDir::new().unwrap();
939        let sock_path = tmp.path().join("exec_stream.sock");
940        let Some(listener) = bind_test_listener(&sock_path) else {
941            return;
942        };
943
944        tokio::spawn(async move {
945            let (stream, _) = listener.accept().await.unwrap();
946            drop(stream);
947
948            let (stream, _) = listener.accept().await.unwrap();
949            let (r, w) = tokio::io::split(stream);
950            let mut reader = a3s_transport::FrameReader::new(r);
951            let mut writer = a3s_transport::FrameWriter::new(w);
952
953            let frame = reader.read_frame().await.unwrap().unwrap();
954            let request: a3s_box_core::exec::ExecRequest =
955                serde_json::from_slice(&frame.payload).unwrap();
956            assert!(request.streaming);
957
958            let stdout = a3s_box_core::exec::ExecChunk {
959                stream: a3s_box_core::exec::StreamType::Stdout,
960                data: b"hello ".to_vec(),
961            };
962            writer
963                .write_data(&serde_json::to_vec(&stdout).unwrap())
964                .await
965                .unwrap();
966
967            let stderr = a3s_box_core::exec::ExecChunk {
968                stream: a3s_box_core::exec::StreamType::Stderr,
969                data: b"warn".to_vec(),
970            };
971            writer
972                .write_data(&serde_json::to_vec(&stderr).unwrap())
973                .await
974                .unwrap();
975
976            let exit = a3s_box_core::exec::ExecExit {
977                exit_code: 17,
978                oom_killed: false,
979            };
980            writer
981                .write_control(&serde_json::to_vec(&exit).unwrap())
982                .await
983                .unwrap();
984        });
985
986        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
987
988        let client = ExecClient::connect(&sock_path).await.unwrap();
989        let req = a3s_box_core::exec::ExecRequest {
990            cmd: vec!["echo".to_string(), "hello".to_string()],
991            env: vec![],
992            working_dir: None,
993            rootfs: None,
994            user: None,
995            stdin: None,
996            stdin_streaming: false,
997            timeout_ns: 0,
998            streaming: false,
999        };
1000
1001        let stream = client.exec_stream(&req).await.unwrap();
1002        let (output, metrics) = stream.collect().await.unwrap();
1003        assert_eq!(output.stdout, b"hello ");
1004        assert_eq!(output.stderr, b"warn");
1005        assert_eq!(output.exit_code, 17);
1006        assert_eq!(metrics.stdout_bytes, 6);
1007        assert_eq!(metrics.stderr_bytes, 4);
1008    }
1009
1010    #[tokio::test]
1011    async fn test_exec_client_exec_stream_cancel_writes_control_frame() {
1012        let tmp = tempfile::TempDir::new().unwrap();
1013        let sock_path = tmp.path().join("exec_stream_cancel.sock");
1014        let Some(listener) = bind_test_listener(&sock_path) else {
1015            return;
1016        };
1017
1018        tokio::spawn(async move {
1019            let (stream, _) = listener.accept().await.unwrap();
1020            drop(stream);
1021
1022            let (stream, _) = listener.accept().await.unwrap();
1023            let (r, w) = tokio::io::split(stream);
1024            let mut reader = a3s_transport::FrameReader::new(r);
1025            let mut writer = a3s_transport::FrameWriter::new(w);
1026
1027            let frame = reader.read_frame().await.unwrap().unwrap();
1028            let request: a3s_box_core::exec::ExecRequest =
1029                serde_json::from_slice(&frame.payload).unwrap();
1030            assert!(request.streaming);
1031
1032            let cancel = reader.read_frame().await.unwrap().unwrap();
1033            assert_eq!(cancel.frame_type, a3s_transport::FrameType::Control);
1034            assert_eq!(cancel.payload, b"cancel");
1035
1036            let exit = a3s_box_core::exec::ExecExit {
1037                exit_code: 137,
1038                oom_killed: false,
1039            };
1040            writer
1041                .write_control(&serde_json::to_vec(&exit).unwrap())
1042                .await
1043                .unwrap();
1044        });
1045
1046        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
1047
1048        let client = ExecClient::connect(&sock_path).await.unwrap();
1049        let req = a3s_box_core::exec::ExecRequest {
1050            cmd: vec!["sleep".to_string(), "60".to_string()],
1051            env: vec![],
1052            working_dir: None,
1053            rootfs: None,
1054            user: None,
1055            stdin: None,
1056            stdin_streaming: false,
1057            timeout_ns: 0,
1058            streaming: false,
1059        };
1060
1061        let mut stream = client.exec_stream(&req).await.unwrap();
1062        stream.cancel().await.unwrap();
1063        let event = stream.next_event().await.unwrap().unwrap();
1064        match event {
1065            a3s_box_core::exec::ExecEvent::Exit(exit) => assert_eq!(exit.exit_code, 137),
1066            other => panic!("unexpected event: {other:?}"),
1067        }
1068    }
1069
1070    #[tokio::test]
1071    async fn test_exec_client_exec_stream_input_writes_stdin_and_close() {
1072        let tmp = tempfile::TempDir::new().unwrap();
1073        let sock_path = tmp.path().join("exec_stream_stdin.sock");
1074        let Some(listener) = bind_test_listener(&sock_path) else {
1075            return;
1076        };
1077
1078        tokio::spawn(async move {
1079            let (stream, _) = listener.accept().await.unwrap();
1080            drop(stream);
1081
1082            let (stream, _) = listener.accept().await.unwrap();
1083            let (r, w) = tokio::io::split(stream);
1084            let mut reader = a3s_transport::FrameReader::new(r);
1085            let mut writer = a3s_transport::FrameWriter::new(w);
1086
1087            let frame = reader.read_frame().await.unwrap().unwrap();
1088            let request: a3s_box_core::exec::ExecRequest =
1089                serde_json::from_slice(&frame.payload).unwrap();
1090            assert!(request.streaming);
1091
1092            let stdin = reader.read_frame().await.unwrap().unwrap();
1093            assert_eq!(stdin.frame_type, a3s_transport::FrameType::Data);
1094            assert_eq!(stdin.payload, b"hello stdin\n");
1095
1096            let close = reader.read_frame().await.unwrap().unwrap();
1097            assert_eq!(close.frame_type, a3s_transport::FrameType::Control);
1098            assert_eq!(close.payload, EXEC_CONTROL_STDIN_CLOSE);
1099
1100            let exit = a3s_box_core::exec::ExecExit {
1101                exit_code: 0,
1102                oom_killed: false,
1103            };
1104            writer
1105                .write_control(&serde_json::to_vec(&exit).unwrap())
1106                .await
1107                .unwrap();
1108        });
1109
1110        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
1111
1112        let client = ExecClient::connect(&sock_path).await.unwrap();
1113        let req = a3s_box_core::exec::ExecRequest {
1114            cmd: vec!["cat".to_string()],
1115            env: vec![],
1116            working_dir: None,
1117            rootfs: None,
1118            user: None,
1119            stdin: None,
1120            stdin_streaming: true,
1121            timeout_ns: 0,
1122            streaming: false,
1123        };
1124
1125        let mut stream = client.exec_stream(&req).await.unwrap();
1126        let input = stream.input();
1127        input.write_stdin(b"hello stdin\n").await.unwrap();
1128        input.close_stdin().await.unwrap();
1129        let event = stream.next_event().await.unwrap().unwrap();
1130        match event {
1131            a3s_box_core::exec::ExecEvent::Exit(exit) => assert_eq!(exit.exit_code, 0),
1132            other => panic!("unexpected event: {other:?}"),
1133        }
1134    }
1135
1136    #[tokio::test]
1137    async fn test_exec_client_flush_sends_control_and_parses_ack_then_exit() {
1138        let tmp = tempfile::TempDir::new().unwrap();
1139        let sock_path = tmp.path().join("exec_stream_flush.sock");
1140        let Some(listener) = bind_test_listener(&sock_path) else {
1141            return;
1142        };
1143
1144        tokio::spawn(async move {
1145            let (stream, _) = listener.accept().await.unwrap();
1146            drop(stream);
1147
1148            let (stream, _) = listener.accept().await.unwrap();
1149            let (r, w) = tokio::io::split(stream);
1150            let mut reader = a3s_transport::FrameReader::new(r);
1151            let mut writer = a3s_transport::FrameWriter::new(w);
1152
1153            // Consume the streaming request, then the flush control frame.
1154            let _req = reader.read_frame().await.unwrap().unwrap();
1155            let flush = reader.read_frame().await.unwrap().unwrap();
1156            assert_eq!(flush.frame_type, a3s_transport::FrameType::Control);
1157            assert_eq!(flush.payload, EXEC_CONTROL_FLUSH);
1158
1159            // Reply: a buffered chunk, the flush-ack marker, then exit.
1160            let chunk = a3s_box_core::exec::ExecChunk {
1161                stream: a3s_box_core::exec::StreamType::Stdout,
1162                data: b"pre-rotation\n".to_vec(),
1163            };
1164            writer
1165                .write_data(&serde_json::to_vec(&chunk).unwrap())
1166                .await
1167                .unwrap();
1168            writer.write_control(EXEC_FLUSH_ACK).await.unwrap();
1169            let exit = a3s_box_core::exec::ExecExit {
1170                exit_code: 0,
1171                oom_killed: false,
1172            };
1173            writer
1174                .write_control(&serde_json::to_vec(&exit).unwrap())
1175                .await
1176                .unwrap();
1177        });
1178
1179        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
1180
1181        let client = ExecClient::connect(&sock_path).await.unwrap();
1182        let req = a3s_box_core::exec::ExecRequest {
1183            cmd: vec!["sh".to_string()],
1184            env: vec![],
1185            working_dir: None,
1186            rootfs: None,
1187            user: None,
1188            stdin: None,
1189            stdin_streaming: false,
1190            timeout_ns: 0,
1191            streaming: false,
1192        };
1193
1194        let mut stream = client.exec_stream(&req).await.unwrap();
1195        stream.flush().await.unwrap();
1196
1197        use a3s_box_core::exec::ExecEvent;
1198        match stream.next_event().await.unwrap().unwrap() {
1199            ExecEvent::Chunk(c) => assert_eq!(c.data, b"pre-rotation\n"),
1200            other => panic!("expected chunk, got {other:?}"),
1201        }
1202        // The flush-ack must parse as FlushAck, NOT as an exit (which would
1203        // wrongly end the stream).
1204        match stream.next_event().await.unwrap().unwrap() {
1205            ExecEvent::FlushAck => {}
1206            other => panic!("expected flush-ack, got {other:?}"),
1207        }
1208        match stream.next_event().await.unwrap().unwrap() {
1209            ExecEvent::Exit(exit) => assert_eq!(exit.exit_code, 0),
1210            other => panic!("expected exit, got {other:?}"),
1211        }
1212    }
1213
1214    #[tokio::test]
1215    async fn test_exec_client_malformed_response() {
1216        let tmp = tempfile::TempDir::new().unwrap();
1217        let sock_path = tmp.path().join("exec_bad.sock");
1218        let Some(listener) = bind_test_listener(&sock_path) else {
1219            return;
1220        };
1221
1222        tokio::spawn(async move {
1223            let (stream, _) = listener.accept().await.unwrap();
1224            drop(stream);
1225            let (mut stream, _) = listener.accept().await.unwrap();
1226            let mut buf = vec![0u8; 4096];
1227            let _ = stream.read(&mut buf).await;
1228            // Send garbage — not a valid frame
1229            stream.write_all(b"garbage").await.unwrap();
1230            drop(stream);
1231        });
1232
1233        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
1234
1235        let client = ExecClient::connect(&sock_path).await.unwrap();
1236        let req = a3s_box_core::exec::ExecRequest {
1237            cmd: vec!["test".to_string()],
1238            env: vec![],
1239            working_dir: None,
1240            rootfs: None,
1241            user: None,
1242            stdin: None,
1243            stdin_streaming: false,
1244            timeout_ns: 0,
1245            streaming: false,
1246        };
1247        let result = client.exec_command(&req).await;
1248        assert!(result.is_err());
1249    }
1250}