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