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