Skip to main content

supermachine/
exec.rs

1//! Host-side framing for the in-guest exec agent.
2//!
3//! Mirrors the wire protocol implemented in
4//! `crates/supermachine-guest-agent`. See
5//! `docs/design/exec-2026-05-03.md` for the protocol overview.
6//!
7//! Public surface (re-exported from the crate root):
8//!
9//! - [`crate::Vm::exec`] / [`crate::Vm::exec_builder`] (in `api.rs`)
10//! - [`ExecBuilder`] — chainable spawn config
11//! - [`ExecChild`] — handle to a running guest process
12//! - [`ExecStdin`] / [`ExecStdout`] / [`ExecStderr`] — std-style stdio
13//!
14//! Internals:
15//!
16//! - `spawn` dials the host-side `<vsock_mux>-exec.sock`, sends
17//!   the REQUEST frame, then spawns a demux thread that reads
18//!   frames and pumps STDOUT/STDERR bytes into pipe pairs (so the
19//!   caller's [`std::io::Read`] just works), and posts the EXIT
20//!   status onto a channel.
21
22use std::collections::BTreeMap;
23use std::io::{self, Read, Write};
24use std::net::Shutdown;
25#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
26use std::os::unix::ffi::OsStrExt;
27#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
28use std::os::unix::fs::PermissionsExt;
29use std::os::unix::net::UnixStream;
30#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
31use std::os::unix::process::CommandExt;
32use std::path::Path;
33use std::path::PathBuf;
34use std::process::ExitStatus;
35#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
36use std::process::{ChildStdin, Command, Stdio};
37use std::sync::atomic::AtomicBool;
38use std::sync::mpsc::{channel, Receiver, RecvTimeoutError, Sender, TryRecvError};
39use std::sync::{Arc, Mutex};
40use std::thread::{self, JoinHandle};
41use std::time::{Duration, Instant};
42
43#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
44use serde::Deserialize;
45use serde::Serialize;
46
47// Wire frame types — must match the agent's constants.
48const FRAME_REQUEST: u8 = 0xff;
49const FRAME_CONTROL: u8 = 0xfe;
50const FRAME_STDIN: u8 = 0;
51const FRAME_STDOUT: u8 = 1;
52const FRAME_STDERR: u8 = 2;
53const FRAME_RESIZE: u8 = 3;
54const FRAME_SIGNAL: u8 = 4;
55const FRAME_EXIT: u8 = 5;
56const FRAME_ERROR: u8 = 6;
57
58/// Configurable spawn for [`crate::Vm::exec`]. Built via
59/// [`crate::Vm::exec_builder`] or constructed inline by `Vm::exec`.
60///
61/// ```no_run
62/// # use supermachine::{Image, Vm, VmConfig};
63/// let image = Image::from_snapshot("path/to/snapshot")?;
64/// let vm = Vm::start(&image, &VmConfig::new())?;
65/// let mut child = vm.exec_builder()
66///     .argv(["sh", "-c", "echo hi && exit 7"])
67///     .env("FOO", "bar")
68///     .spawn()?;
69/// let status = child.wait()?;
70/// assert_eq!(status.code(), Some(7));
71/// # Ok::<(), Box<dyn std::error::Error>>(())
72/// ```
73#[derive(Clone)]
74pub struct ExecBuilder {
75    exec_path: PathBuf,
76    argv: Vec<String>,
77    env: BTreeMap<String, String>,
78    cwd: Option<String>,
79    tty: bool,
80    cols: Option<u16>,
81    rows: Option<u16>,
82    timeout: Option<Duration>,
83    /// Files to atomically write inside the guest before exec.
84    /// Eliminates a separate `write_file` round-trip.
85    stage_files: Vec<StageFile>,
86    /// Additional argvs to run sequentially after `argv` succeeds
87    /// (`&&` semantics). Stops on first non-zero exit. Removes the
88    /// need for a `sh -c "X && Y"` wrapper and its extra fork.
89    chain: Vec<Vec<String>>,
90    /// No-virt sentry WARM-DAEMON route: when set (by [`crate::Vm::exec_builder`] on
91    /// a warm-daemon-pool-backed `Vm`), [`ExecBuilder::output`] runs `argv` as a
92    /// fresh CLIENT cell via `crate::sentry::Pool::exec_capture` — which reaches the
93    /// detached warm daemon over the supervisor-global owned loopback — instead of
94    /// dialing `exec_path` (a warm-daemon pooled `Vm` has no exec socket). `None` for
95    /// every other backend / path.
96    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
97    warm_pool: Option<std::sync::Arc<crate::sentry::Pool>>,
98    /// External no-KVM runsc backend route. `Vm::exec_builder` sets this for
99    /// runsc-backed VMs; `output()` executes via `runsc exec`, while `spawn()`
100    /// bridges the same framed `ExecChild` API onto a live `runsc exec` child.
101    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
102    runsc: Option<RunscExecTarget>,
103    /// Native kboxlike backend route. `output()` and streaming `spawn()` execute
104    /// by launching a fresh process under the backend rootfs.
105    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
106    kboxlike: Option<KboxlikeExecTarget>,
107}
108
109#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
110#[derive(Clone, Debug, Serialize, Deserialize)]
111#[doc(hidden)]
112pub struct RunscExecTarget {
113    pub runsc_bin: PathBuf,
114    pub runsc_root: PathBuf,
115    #[serde(default)]
116    pub network: Option<String>,
117    pub container_id: String,
118    pub image_env: Vec<(String, String)>,
119    #[serde(default)]
120    pub agent_path: Option<String>,
121    pub file_agent_rootfs: Option<PathBuf>,
122}
123
124#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
125#[derive(Clone, Debug, Serialize, Deserialize)]
126#[doc(hidden)]
127pub struct KboxlikeExecTarget {
128    pub rootfs: PathBuf,
129    pub image_env: Vec<(String, String)>,
130    #[serde(default)]
131    pub user: Option<(u32, u32)>,
132    #[serde(default)]
133    pub mounts: Vec<(PathBuf, String)>,
134}
135
136#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
137pub(crate) const RUNSC_EXEC_TOKEN_PREFIX: &str = "supermachine-runsc-exec-v1:";
138#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
139pub(crate) const KBOXLIKE_EXEC_TOKEN_PREFIX: &str = "supermachine-kboxlike-exec-v1:";
140#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
141pub(crate) const RUNSC_NETWORK_NONE: &str = "none";
142#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
143pub(crate) const RUNSC_NETWORK_SANDBOX: &str = "sandbox";
144
145#[derive(Clone)]
146struct StageFile {
147    path: String,
148    data: Vec<u8>,
149    mode: Option<u32>,
150}
151
152impl ExecBuilder {
153    /// Construct a builder that dials a specific exec-socket
154    /// path. Most embedders should use [`crate::Vm::exec_builder`]
155    /// instead; this lower-level constructor is for tooling that
156    /// already has a router daemon running and just wants to
157    /// dial a known socket (e.g. the `supermachine exec` CLI).
158    pub fn new(exec_path: PathBuf) -> Self {
159        Self {
160            exec_path,
161            argv: Vec::new(),
162            env: BTreeMap::new(),
163            cwd: None,
164            tty: false,
165            cols: None,
166            rows: None,
167            timeout: None,
168            stage_files: Vec::new(),
169            chain: Vec::new(),
170            #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
171            warm_pool: None,
172            #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
173            runsc: None,
174            #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
175            kboxlike: None,
176        }
177    }
178
179    /// Rebuild an exec builder from the opaque token returned by
180    /// `Vm::exec_binding_token`. Plain socket-path tokens remain accepted for
181    /// existing KVM/HVF/sentry callers; runsc tokens carry the host-control-plane
182    /// target needed to execute via `runsc exec`.
183    #[doc(hidden)]
184    pub fn from_binding_token(token: impl AsRef<str>) -> io::Result<Self> {
185        let token = token.as_ref();
186        #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
187        if let Some(raw) = token.strip_prefix(RUNSC_EXEC_TOKEN_PREFIX) {
188            let bytes = crate::api::b64_decode(raw)
189                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
190            let target: RunscExecTarget = serde_json::from_slice(&bytes)
191                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
192            return Ok(Self::new(PathBuf::from("runsc-exec-token")).with_runsc_exec(target));
193        }
194        #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
195        if let Some(raw) = token.strip_prefix(KBOXLIKE_EXEC_TOKEN_PREFIX) {
196            let bytes = crate::api::b64_decode(raw)
197                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
198            let target: KboxlikeExecTarget = serde_json::from_slice(&bytes)
199                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
200            return Ok(Self::new(PathBuf::from("kboxlike-exec-token")).with_kboxlike_exec(target));
201        }
202        Ok(Self::new(PathBuf::from(token)))
203    }
204
205    /// Route this builder's [`output`](Self::output) through a no-virt sentry
206    /// warm-daemon pool (`Pool::exec_capture`) instead of an exec socket. Set by
207    /// [`crate::Vm::exec_builder`] for a warm-daemon-pool-backed `Vm`.
208    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
209    pub(crate) fn with_warm_pool(mut self, pool: std::sync::Arc<crate::sentry::Pool>) -> Self {
210        self.warm_pool = Some(pool);
211        self
212    }
213
214    /// Route this builder's [`output`](Self::output) through `runsc exec` for the
215    /// experimental external no-KVM backend.
216    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
217    #[doc(hidden)]
218    pub fn with_runsc_exec(mut self, target: RunscExecTarget) -> Self {
219        self.runsc = Some(target);
220        self
221    }
222
223    /// Route this builder's [`output`](Self::output) through the experimental
224    /// native kboxlike backend.
225    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
226    #[doc(hidden)]
227    pub fn with_kboxlike_exec(mut self, target: KboxlikeExecTarget) -> Self {
228        self.kboxlike = Some(target);
229        self
230    }
231
232    /// The argv to execute in the guest. First element is the
233    /// program (resolved via PATH inside the guest).
234    pub fn argv<I, S>(mut self, argv: I) -> Self
235    where
236        I: IntoIterator<Item = S>,
237        S: Into<String>,
238    {
239        self.argv = argv.into_iter().map(Into::into).collect();
240        self
241    }
242
243    /// Set an environment variable for the spawned process.
244    /// Repeatable. The agent merges these on top of its own
245    /// environment, so `PATH`, `HOME`, etc. are inherited unless
246    /// you explicitly override them.
247    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
248        self.env.insert(key.into(), value.into());
249        self
250    }
251
252    /// Working directory for the spawned process inside the guest.
253    pub fn cwd(mut self, path: impl Into<String>) -> Self {
254        self.cwd = Some(path.into());
255        self
256    }
257
258    /// Allocate a pseudo-terminal. stdin/stdout pass through the
259    /// pty master; stderr is empty (the pty merges fd 1 + fd 2).
260    /// Use this for interactive shells.
261    pub fn tty(mut self, on: bool) -> Self {
262        self.tty = on;
263        self
264    }
265
266    /// Initial terminal size for tty mode. Has no effect without
267    /// `.tty(true)`. Use [`ExecChild::resize`] to change later.
268    pub fn winsize(mut self, cols: u16, rows: u16) -> Self {
269        self.cols = Some(cols);
270        self.rows = Some(rows);
271        self
272    }
273
274    /// Hard wall-clock timeout. If the process is still running
275    /// when the deadline fires, the watchdog sends SIGKILL and
276    /// the resulting [`ExecOutcome`] has `timed_out = true`.
277    /// Only honored by [`ExecBuilder::output`] (the streaming
278    /// [`ExecBuilder::spawn`] path leaves cancellation to the
279    /// caller, who already has [`ExecChild::signal`]).
280    ///
281    /// Reliable: `timed_out = true` is always set when the
282    /// deadline fires.
283    ///
284    /// Process group: the agent runs `setpgid(0, 0)` in the
285    /// child after fork, then forwards SIGNAL to the entire
286    /// process group via `kill(-pid, sig)`. Forking children
287    /// (e.g. `sh -c "rustc x.rs && /tmp/x"`) get killed
288    /// transitively — no orphaned processes blocking the EXIT
289    /// frame. Verified to take ~500 ms end-to-end on a 500 ms
290    /// timeout across `sleep`, `sh -c sleep`, busy loops, and
291    /// shell pipelines.
292    pub fn timeout(mut self, d: Duration) -> Self {
293        self.timeout = Some(d);
294        self
295    }
296
297    /// Stage `bytes` at `path` inside the guest before exec runs.
298    /// Atomic (write+rename). Folded into the same vsock RPC as
299    /// the exec request — eliminates the separate `Vm::write_file`
300    /// round-trip. Repeatable for multiple files.
301    ///
302    /// Use when your workload starts with a known-small input file
303    /// (source code, a test config) — replace
304    /// `vm.write_file(p, b)?; vm.exec_builder().argv([…]).output()?`
305    /// with `vm.exec_builder().stage_file(p, b).argv([…]).output()?`.
306    pub fn stage_file(mut self, path: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Self {
307        self.stage_files.push(StageFile {
308            path: path.into(),
309            data: bytes.into(),
310            mode: None,
311        });
312        self
313    }
314
315    /// Like [`Self::stage_file`] but also sets the file mode.
316    pub fn stage_file_mode(
317        mut self,
318        path: impl Into<String>,
319        bytes: impl Into<Vec<u8>>,
320        mode: u32,
321    ) -> Self {
322        self.stage_files.push(StageFile {
323            path: path.into(),
324            data: bytes.into(),
325            mode: Some(mode),
326        });
327        self
328    }
329
330    /// Append a second (third, …) argv to run after the previous
331    /// one succeeds (`&&` semantics). Stops on first non-zero
332    /// exit. Removes the need for a `sh -c "X && Y"` wrapper —
333    /// the agent forks each command directly in the guest.
334    pub fn chain<I, S>(mut self, argv: I) -> Self
335    where
336        I: IntoIterator<Item = S>,
337        S: Into<String>,
338    {
339        self.chain.push(argv.into_iter().map(Into::into).collect());
340        self
341    }
342
343    /// Dial the agent, send the REQUEST, and return the
344    /// [`ExecChild`] handle. Use this when you want to stream
345    /// stdin/stdout/stderr yourself; for a one-call "run, drain,
346    /// collect" pattern use [`ExecBuilder::output`].
347    pub fn spawn(self) -> io::Result<ExecChild> {
348        if self.argv.is_empty() {
349            return Err(io::Error::new(
350                io::ErrorKind::InvalidInput,
351                "exec: argv is empty",
352            ));
353        }
354        #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
355        if self.runsc.is_some() {
356            return runsc_spawn(self);
357        }
358        #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
359        if self.kboxlike.is_some() {
360            return kboxlike_spawn(self);
361        }
362        spawn(self)
363    }
364
365    /// Run to completion, collecting stdout + stderr + exit
366    /// status into one [`ExecOutcome`]. Blocks until the process
367    /// exits (or the configured [`ExecBuilder::timeout`] fires
368    /// and the process is killed).
369    ///
370    /// Mirrors [`std::process::Command::output`]. Use this for
371    /// the common "exec a command, look at the result" case;
372    /// reach for [`ExecBuilder::spawn`] only when you need to
373    /// stream stdio.
374    ///
375    /// ```no_run
376    /// # use std::time::Duration;
377    /// # use supermachine::{Image, Vm, VmConfig};
378    /// let image = Image::from_snapshot("path/to/snapshot")?;
379    /// let vm = Vm::start(&image, &VmConfig::new())?;
380    /// let out = vm.exec_builder()
381    ///     .argv(["sh", "-c", "echo hi; echo err >&2; exit 7"])
382    ///     .timeout(Duration::from_secs(30))
383    ///     .output()?;
384    /// assert_eq!(out.status.code(), Some(7));
385    /// assert_eq!(out.stdout, b"hi\n");
386    /// assert_eq!(out.stderr, b"err\n");
387    /// assert!(!out.timed_out);
388    /// # Ok::<(), Box<dyn std::error::Error>>(())
389    /// ```
390    pub fn output(self) -> io::Result<ExecOutcome> {
391        // No-virt sentry warm-daemon route: run `argv` as a fresh CLIENT cell in the
392        // daemon's supervisor (it reaches the warm daemon over the owned loopback).
393        // `Pool::exec_capture` is blocking and returns (exit_code, combined_output);
394        // bridge it onto an ExecOutcome (no exec socket, no streaming — the
395        // daemon-client model is request/response, so TTY/stdin/timeout are N/A here).
396        #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
397        if let Some(pool) = &self.warm_pool {
398            let started = std::time::Instant::now();
399            // Send the FULL exec request (argv + env + cwd + stage_files + chain),
400            // same JSON shape as the exec-socket path, so the daemon supervisor stages
401            // files + applies per-exec env/cwd + chains — full feature parity (vs the
402            // old argv-only route). Combined stdout+stderr; tty/streaming/stdin N/A on
403            // the request/response daemon path.
404            let stage_payload: Vec<StageFilePayload> = self
405                .stage_files
406                .iter()
407                .map(|s| StageFilePayload {
408                    path: &s.path,
409                    data_b64: crate::api::b64_encode(&s.data),
410                    mode: s.mode,
411                })
412                .collect();
413            let payload = RequestPayload {
414                argv: &self.argv,
415                env: &self.env,
416                cwd: self.cwd.as_deref(),
417                tty: false,
418                cols: None,
419                rows: None,
420                stage_files: stage_payload,
421                chain: &self.chain,
422            };
423            let body = serde_json::to_vec(&payload)
424                .map_err(|e| io::Error::other(format!("sentry exec request encode: {e}")))?;
425            let (code, out) = pool
426                .exec_capture_full(&body)
427                .map_err(|e| io::Error::other(format!("sentry warm-daemon exec: {e}")))?;
428            return Ok(ExecOutcome {
429                status: synthesize_exit(code as u32),
430                stdout: out,
431                stderr: Vec::new(),
432                duration: started.elapsed(),
433                peak_rss_kib: None,
434                timed_out: false,
435            });
436        }
437        #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
438        if self.runsc.is_some() {
439            return runsc_output(self);
440        }
441        #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
442        if self.kboxlike.is_some() {
443            return kboxlike_output(self);
444        }
445        // `output()` and `output_with_cancel()` share the same body;
446        // the only difference is that `output()` passes a Receiver
447        // that never fires. Constructing one is essentially free
448        // (a single mpsc allocation that immediately gets dropped
449        // when this function returns).
450        let (never_tx, never_rx) = channel::<()>();
451        // Keep the sender alive for the lifetime of this call so
452        // the channel doesn't "fire" via Disconnected — that would
453        // be ambiguous with "user cancelled."
454        let result = self.output_with_cancel(never_rx);
455        drop(never_tx);
456        result
457    }
458
459    /// Like [`output`] but transparently retries a connection-SETUP
460    /// failure — the agent closed before the workload produced ANY output
461    /// or EXIT (see [`ExecEof::is_setup_failure`]) — up to `retries` times
462    /// with a short linear backoff, re-dialing a fresh connection each
463    /// time. ONLY that case is retried: a mid-stream drop (output already
464    /// delivered) and a timeout/cancel are returned unchanged, because a
465    /// retry there could double-execute a side-effecting command.
466    ///
467    /// Semantics are at-least-once — a workload whose EXIT frame was lost
468    /// *after* it already ran (rare) could run twice — so use this only for
469    /// idempotent workloads. `retries == 0` is exactly [`output`] (no
470    /// clone, no retry, the default).
471    ///
472    /// [`output`]: Self::output
473    pub fn output_resilient(self, retries: u8) -> io::Result<ExecOutcome> {
474        if retries == 0 {
475            return self.output();
476        }
477        let mut attempt: u8 = 0;
478        loop {
479            // Clone so each attempt dials a fresh connection; `self` seeds
480            // the clones and drops when the loop returns.
481            let result = self.clone().output();
482            let setup_failure = result
483                .as_ref()
484                .err()
485                .and_then(|e| e.get_ref())
486                .and_then(|e| e.downcast_ref::<ExecEof>())
487                .map(ExecEof::is_setup_failure)
488                .unwrap_or(false);
489            if setup_failure && attempt < retries {
490                attempt += 1;
491                eprintln!(
492                    "[exec] connection-setup EOF (agent not ready / worker gone) — retry \
493                     {attempt}/{retries}"
494                );
495                thread::sleep(Duration::from_millis(50 * attempt as u64));
496                continue;
497            }
498            return result;
499        }
500    }
501
502    /// Like [`Self::output`] but additionally interrupts the running
503    /// process if `cancel` receives a value (or is dropped). The
504    /// interrupt path is best-effort `SIGKILL` via the agent
505    /// followed by a host-side [`ExecChild::abort`] — so cancel
506    /// works even if the worker / agent has already died.
507    ///
508    /// `timeout` (set via [`Self::timeout`]) and `cancel` are
509    /// orthogonal: BOTH fire SIGKILL when their trigger arrives;
510    /// whichever wins kills the child. `ExecOutcome::timed_out`
511    /// reports whether the timeout specifically fired; the cancel
512    /// path doesn't surface a distinguishing flag (the caller
513    /// already knows they cancelled).
514    ///
515    /// Typical use:
516    ///
517    /// ```ignore
518    /// let (cancel_tx, cancel_rx) = std::sync::mpsc::channel();
519    /// std::thread::spawn(move || {
520    ///     wait_for_user_ctrl_c();
521    ///     let _ = cancel_tx.send(());
522    /// });
523    /// let outcome = vm.exec_builder(["./run.sh"])
524    ///     .timeout(Duration::from_secs(60))
525    ///     .output_with_cancel(cancel_rx)?;
526    /// ```
527    pub fn output_with_cancel(self, cancel: Receiver<()>) -> io::Result<ExecOutcome> {
528        let timeout = self.timeout;
529        let t0 = Instant::now();
530        let mut child = self.spawn()?;
531        // Close stdin immediately so workloads that block on
532        // `read(0)` (cat, anything reading stdin) see EOF and
533        // exit. Drop sends an empty STDIN frame to the agent;
534        // the agent's input thread observes that as "EOF for
535        // current child" without exiting, so SIGNAL frames from
536        // our watchdog still reach the agent later.
537        drop(child.stdin());
538        let mut stdout = child.stdout();
539        let mut stderr = child.stderr();
540        // Drain both pipes in parallel so a chatty stderr never
541        // backpressures stdout (or vice versa) and deadlocks.
542        let stdout_handle = stdout.take().map(|mut s| {
543            thread::spawn(move || {
544                let mut buf = Vec::new();
545                let _ = s.read_to_end(&mut buf);
546                buf
547            })
548        });
549        let stderr_handle = stderr.take().map(|mut s| {
550            thread::spawn(move || {
551                let mut buf = Vec::new();
552                let _ = s.read_to_end(&mut buf);
553                buf
554            })
555        });
556        // Unified watchdog: waits for whichever fires first —
557        //   * a stop-tx send (cooperative cleanup once wait returns)
558        //   * a timeout (if configured)
559        //   * a value on the user's cancel channel
560        // …and on timeout or cancel sends SIGKILL via the agent,
561        // then aborts the host-side socket so even a dead agent
562        // can't keep us blocked.
563        let (stop_tx, stop_rx) = channel::<()>();
564        let timed_out = Arc::new(std::sync::atomic::AtomicBool::new(false));
565        let signaler = child.signaler();
566        let watchdog = {
567            let timed_out = Arc::clone(&timed_out);
568            Some(thread::spawn(move || {
569                // Two-phase escalation on cancel / timeout:
570                //   1. Send SIGKILL via the agent (polite — gives
571                //      the agent a chance to reap the child
572                //      cleanly and post an EXIT frame so the caller
573                //      gets a proper exit status, e.g. 128+9=137).
574                //   2. Wait up to ABORT_GRACE for the wait-side to
575                //      finish (stop_rx); if not, force-close the
576                //      host-side socket (signaler.abort()) so the
577                //      demux thread unblocks regardless of agent
578                //      liveness.
579                //
580                // Phase 1 alone covers the happy path (test:
581                // timeoutMs kills a sleep, expects exitCode=137).
582                // Phase 2 covers the integrator's "agent/worker
583                // died, host wait won't return" case.
584                const TICK: Duration = Duration::from_millis(10);
585                const ABORT_GRACE: Duration = Duration::from_secs(2);
586                let deadline = timeout.map(|d| Instant::now() + d);
587
588                // Trigger-and-grace: try the polite kill, then
589                // wait up to ABORT_GRACE for the wait-side to
590                // complete. If it doesn't, force-close.
591                let escalate = |stop_rx: &Receiver<()>| {
592                    let _ = signaler.kill();
593                    match stop_rx.recv_timeout(ABORT_GRACE) {
594                        Ok(()) | Err(RecvTimeoutError::Disconnected) => {
595                            // Agent reaped child + wait returned —
596                            // clean path. Skip the force-close.
597                        }
598                        Err(RecvTimeoutError::Timeout) => {
599                            // Agent didn't reap within grace
600                            // (likely dead / unresponsive). Force-
601                            // close so demux unblocks and the
602                            // caller doesn't hang.
603                            let _ = signaler.abort();
604                        }
605                    }
606                };
607
608                loop {
609                    // Stop signal (wait completed) — clean exit.
610                    match stop_rx.try_recv() {
611                        Ok(()) | Err(TryRecvError::Disconnected) => return,
612                        Err(TryRecvError::Empty) => {}
613                    }
614                    // User cancel — escalate.
615                    match cancel.try_recv() {
616                        Ok(()) => {
617                            escalate(&stop_rx);
618                            return;
619                        }
620                        Err(TryRecvError::Disconnected) => {
621                            // Cancel-tx dropped without firing —
622                            // user gave up on cancellation. Keep
623                            // polling for the stop signal.
624                        }
625                        Err(TryRecvError::Empty) => {}
626                    }
627                    // Timeout — escalate + mark.
628                    if let Some(dl) = deadline {
629                        if Instant::now() >= dl {
630                            timed_out.store(true, std::sync::atomic::Ordering::SeqCst);
631                            if crate::trace::enabled("timeout") {
632                                eprintln!("[exec.timeout] fired at +{:?}", timeout.unwrap());
633                            }
634                            escalate(&stop_rx);
635                            return;
636                        }
637                    }
638                    // Sleep for either TICK or "until deadline,
639                    // whichever is sooner" — keeps timeout latency
640                    // tight without busy-looping.
641                    let sleep_for = deadline
642                        .map(|dl| dl.saturating_duration_since(Instant::now()).min(TICK))
643                        .unwrap_or(TICK);
644                    if sleep_for.is_zero() {
645                        continue;
646                    }
647                    // Use recv_timeout so a stop_tx send wakes us
648                    // immediately on the happy path.
649                    match stop_rx.recv_timeout(sleep_for) {
650                        Ok(()) => return,
651                        Err(RecvTimeoutError::Timeout) => continue,
652                        Err(RecvTimeoutError::Disconnected) => return,
653                    }
654                }
655            }))
656        };
657        let exit = child.wait_with_rss();
658        // Stop the watchdog (idempotent if it already exited).
659        let _ = stop_tx.send(());
660        if let Some(h) = watchdog {
661            let _ = h.join();
662        }
663        let stdout_bytes = stdout_handle
664            .map(|h| h.join().unwrap_or_default())
665            .unwrap_or_default();
666        let stderr_bytes = stderr_handle
667            .map(|h| h.join().unwrap_or_default())
668            .unwrap_or_default();
669        let (status, peak_rss_kib) = exit?;
670        Ok(ExecOutcome {
671            status,
672            stdout: stdout_bytes,
673            stderr: stderr_bytes,
674            duration: t0.elapsed(),
675            peak_rss_kib,
676            timed_out: timed_out.load(std::sync::atomic::Ordering::SeqCst),
677        })
678    }
679}
680
681/// Result of [`ExecBuilder::output`]. Mirrors the shape of
682/// [`std::process::Output`] with two additions: `duration` (host-
683/// side wall-clock) and `timed_out` (true iff the wall-clock
684/// timeout fired and the process was SIGKILL'd).
685///
686/// `peak_rss_kib` is `None` today; a future supermachine-agent
687/// rev will populate it via `getrusage(RUSAGE_CHILDREN)`.
688#[derive(Debug)]
689pub struct ExecOutcome {
690    /// Exit status, exactly as [`std::process::Output::status`].
691    pub status: ExitStatus,
692    /// Captured stdout bytes (verbatim — caller decodes if needed).
693    pub stdout: Vec<u8>,
694    /// Captured stderr bytes (verbatim).
695    pub stderr: Vec<u8>,
696    /// Host-side wall-clock duration from [`ExecBuilder::output`]
697    /// being called to the process exiting (or being killed).
698    pub duration: Duration,
699    /// Peak resident set size of the process and its children, in
700    /// KiB. `None` until the guest agent gains rusage support.
701    pub peak_rss_kib: Option<u64>,
702    /// `true` iff the wall-clock timeout from
703    /// [`ExecBuilder::timeout`] fired and the process was killed
704    /// with SIGKILL.
705    pub timed_out: bool,
706}
707
708impl ExecOutcome {
709    /// `true` when the exit status is success AND the process
710    /// wasn't forcibly killed by the timeout.
711    pub fn success(&self) -> bool {
712        self.status.success() && !self.timed_out
713    }
714}
715
716#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
717fn runsc_output(builder: ExecBuilder) -> io::Result<ExecOutcome> {
718    let target = builder.runsc.clone().ok_or_else(|| {
719        io::Error::new(
720            io::ErrorKind::InvalidInput,
721            "runsc_output called without a runsc target",
722        )
723    })?;
724    if builder.argv.is_empty() {
725        return Err(io::Error::new(
726            io::ErrorKind::InvalidInput,
727            "exec: argv is empty",
728        ));
729    }
730    if builder.tty {
731        if target.agent_path.is_some() {
732            return runsc_agent_output(builder, target);
733        }
734        return Err(io::Error::new(
735            io::ErrorKind::Unsupported,
736            "runsc backend does not support TTY exec without a bundled agent",
737        ));
738    }
739    if builder.tty || builder.cols.is_some() || builder.rows.is_some() {
740        return Err(io::Error::new(
741            io::ErrorKind::Unsupported,
742            "runsc backend does not support winsize without TTY",
743        ));
744    }
745    runsc_stage_files(&target, &builder.stage_files)?;
746
747    let started = Instant::now();
748    let mut stdout = Vec::new();
749    let mut stderr = Vec::new();
750    let mut status = synthesize_exit(0);
751    let mut timed_out = false;
752    let mut argvs = Vec::with_capacity(1 + builder.chain.len());
753    argvs.push(builder.argv.clone());
754    argvs.extend(builder.chain.iter().cloned());
755
756    for argv in argvs {
757        let out = if target.file_agent_rootfs.is_some() {
758            runsc_file_agent_output_one(&target, &builder, &argv, started)?
759        } else {
760            runsc_output_one(&target, &builder, &argv, started)?
761        };
762        timed_out |= out.timed_out;
763        stdout.extend(out.stdout);
764        stderr.extend(out.stderr);
765        status = out.status;
766        if timed_out || !status.success() {
767            break;
768        }
769    }
770
771    Ok(ExecOutcome {
772        status,
773        stdout,
774        stderr,
775        duration: started.elapsed(),
776        peak_rss_kib: None,
777        timed_out,
778    })
779}
780
781#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
782fn runsc_agent_output(builder: ExecBuilder, target: RunscExecTarget) -> io::Result<ExecOutcome> {
783    let started = Instant::now();
784    runsc_stage_files(&target, &builder.stage_files)?;
785    let agent_path = target.agent_path.as_deref().ok_or_else(|| {
786        io::Error::new(
787            io::ErrorKind::InvalidInput,
788            "runsc agent exec target missing agent path",
789        )
790    })?;
791    let mut cmd = Command::new(&target.runsc_bin);
792    add_runsc_global_flags_with_network(&mut cmd, &target.runsc_root, runsc_network(&target));
793    cmd.arg("exec")
794        .arg(&target.container_id)
795        .arg(agent_path)
796        .arg("--exec-stdio")
797        .stdin(Stdio::piped())
798        .stdout(Stdio::piped())
799        .stderr(Stdio::piped());
800    let mut child = cmd.spawn().map_err(|e| {
801        io::Error::new(
802            e.kind(),
803            format!(
804                "runsc exec {} {} --exec-stdio: {e}",
805                target.container_id, agent_path
806            ),
807        )
808    })?;
809    let stage_payload: Vec<StageFilePayload> = builder
810        .stage_files
811        .iter()
812        .map(|s| StageFilePayload {
813            path: &s.path,
814            data_b64: crate::api::b64_encode(&s.data),
815            mode: s.mode,
816        })
817        .collect();
818    let payload = RequestPayload {
819        argv: &builder.argv,
820        env: &builder.env,
821        cwd: builder.cwd.as_deref(),
822        tty: builder.tty,
823        cols: builder.cols,
824        rows: builder.rows,
825        stage_files: stage_payload,
826        chain: &builder.chain,
827    };
828    let json = serde_json::to_vec(&payload).map_err(|e| {
829        io::Error::new(
830            io::ErrorKind::InvalidInput,
831            format!("runsc agent exec: encode REQUEST: {e}"),
832        )
833    })?;
834    {
835        let stdin = child.stdin.as_mut().ok_or_else(|| {
836            io::Error::new(io::ErrorKind::Other, "runsc agent exec stdin pipe missing")
837        })?;
838        write_raw_frame(stdin, FRAME_REQUEST, &json)?;
839        write_raw_frame(stdin, FRAME_STDIN, &[])?;
840    }
841
842    let mut child_stdout = child.stdout.take().ok_or_else(|| {
843        io::Error::new(io::ErrorKind::Other, "runsc agent exec stdout pipe missing")
844    })?;
845    let mut child_stderr = child.stderr.take().ok_or_else(|| {
846        io::Error::new(io::ErrorKind::Other, "runsc agent exec stderr pipe missing")
847    })?;
848    let stderr_handle = thread::Builder::new()
849        .name("supermachine-runsc-agent-output-stderr".into())
850        .spawn(move || {
851            let mut buf = Vec::new();
852            let _ = child_stderr.read_to_end(&mut buf);
853            buf
854        })
855        .map_err(|e| {
856            io::Error::new(
857                io::ErrorKind::Other,
858                format!("runsc agent output stderr thread: {e}"),
859            )
860        })?;
861
862    let mut stdout = Vec::new();
863    let mut stderr = Vec::new();
864    let mut status = synthesize_exit(0);
865    let done = Arc::new(AtomicBool::new(false));
866    let timed_out = Arc::new(AtomicBool::new(false));
867    let watchdog = builder.timeout.map(|timeout| {
868        let done = Arc::clone(&done);
869        let timed_out = Arc::clone(&timed_out);
870        let child_pid = child.id() as i32;
871        thread::spawn(move || {
872            let elapsed = started.elapsed();
873            if elapsed < timeout {
874                thread::sleep(timeout - elapsed);
875            }
876            if !done.load(std::sync::atomic::Ordering::SeqCst) {
877                timed_out.store(true, std::sync::atomic::Ordering::SeqCst);
878                let _ = unsafe { libc::kill(child_pid, libc::SIGKILL) };
879            }
880        })
881    });
882
883    loop {
884        let (kind, payload) = match read_frame(&mut child_stdout) {
885            Ok(frame) => frame,
886            Err(e) => {
887                if timed_out.load(std::sync::atomic::Ordering::SeqCst) {
888                    break;
889                }
890                let _ = child.kill();
891                let _ = child.wait();
892                done.store(true, std::sync::atomic::Ordering::SeqCst);
893                if let Some(h) = watchdog {
894                    let _ = h.join();
895                }
896                let stderr = stderr_handle.join().unwrap_or_default();
897                if !stderr.is_empty() {
898                    return Err(io::Error::new(
899                        e.kind(),
900                        format!(
901                            "runsc agent exec frame read failed: {e}; runsc stderr={}",
902                            String::from_utf8_lossy(&stderr)
903                        ),
904                    ));
905                }
906                return Err(e);
907            }
908        };
909        match kind {
910            FRAME_STDOUT => stdout.extend_from_slice(&payload),
911            FRAME_STDERR => stderr.extend_from_slice(&payload),
912            FRAME_EXIT => {
913                match decode_exit_payload(&payload) {
914                    DemuxExit::Status { code, .. } => status = synthesize_exit(code),
915                    DemuxExit::Error(msg) => {
916                        let _ = child.kill();
917                        let _ = child.wait();
918                        done.store(true, std::sync::atomic::Ordering::SeqCst);
919                        if let Some(h) = watchdog {
920                            let _ = h.join();
921                        }
922                        let mut runsc_stderr = stderr_handle.join().unwrap_or_default();
923                        stderr.append(&mut runsc_stderr);
924                        return Err(io::Error::new(io::ErrorKind::InvalidData, msg));
925                    }
926                    _ => {}
927                }
928                break;
929            }
930            FRAME_ERROR => {
931                let msg = String::from_utf8_lossy(&payload).into_owned();
932                let _ = child.kill();
933                let _ = child.wait();
934                done.store(true, std::sync::atomic::Ordering::SeqCst);
935                if let Some(h) = watchdog {
936                    let _ = h.join();
937                }
938                let mut runsc_stderr = stderr_handle.join().unwrap_or_default();
939                stderr.append(&mut runsc_stderr);
940                return Err(io::Error::new(io::ErrorKind::Other, msg));
941            }
942            _ => {}
943        }
944    }
945    drop(child.stdin.take());
946    let host_status = child.wait()?;
947    done.store(true, std::sync::atomic::Ordering::SeqCst);
948    if let Some(h) = watchdog {
949        let _ = h.join();
950    }
951    let mut runsc_stderr = stderr_handle.join().unwrap_or_default();
952    stderr.append(&mut runsc_stderr);
953    if timed_out.load(std::sync::atomic::Ordering::SeqCst) {
954        status = synthesize_exit(runsc_status_code(host_status));
955    }
956    Ok(ExecOutcome {
957        status,
958        stdout,
959        stderr,
960        duration: started.elapsed(),
961        peak_rss_kib: None,
962        timed_out: timed_out.load(std::sync::atomic::Ordering::SeqCst),
963    })
964}
965
966#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
967fn runsc_file_agent_output_one(
968    target: &RunscExecTarget,
969    builder: &ExecBuilder,
970    argv: &[String],
971    started: Instant,
972) -> io::Result<ExecOutcome> {
973    let rootfs = target.file_agent_rootfs.as_ref().ok_or_else(|| {
974        io::Error::new(
975            io::ErrorKind::InvalidInput,
976            "runsc file-agent exec target missing rootfs",
977        )
978    })?;
979    let req_dir = rootfs
980        .join(".supermachine")
981        .join("runsc-exec")
982        .join("requests")
983        .join(format!(
984            "req-{}-{}",
985            std::process::id(),
986            unique_suffix_nanos()
987        ));
988    std::fs::create_dir_all(&req_dir)?;
989
990    let mut script = String::from("set -e\n");
991    if let Some(cwd) = builder.cwd.as_deref() {
992        script.push_str("cd ");
993        script.push_str(&sh_quote(cwd));
994        script.push('\n');
995    }
996    let mut env = BTreeMap::new();
997    for (k, v) in &target.image_env {
998        env.insert(k.clone(), v.clone());
999    }
1000    for (k, v) in &builder.env {
1001        env.insert(k.clone(), v.clone());
1002    }
1003    for (k, v) in env {
1004        script.push_str("export ");
1005        script.push_str(&k);
1006        script.push('=');
1007        script.push_str(&sh_quote(&v));
1008        script.push('\n');
1009    }
1010    script.push_str("exec");
1011    for arg in argv {
1012        script.push(' ');
1013        script.push_str(&sh_quote(arg));
1014    }
1015    script.push('\n');
1016
1017    std::fs::write(req_dir.join("script"), script)?;
1018    std::fs::write(req_dir.join("ready"), b"1\n")?;
1019
1020    let mut timed_out = false;
1021    loop {
1022        if req_dir.join("done").is_file() {
1023            break;
1024        }
1025        if let Some(timeout) = builder.timeout {
1026            if started.elapsed() >= timeout {
1027                timed_out = true;
1028                break;
1029            }
1030        }
1031        thread::sleep(Duration::from_millis(20));
1032    }
1033
1034    let stdout = std::fs::read(req_dir.join("stdout")).unwrap_or_default();
1035    let stderr = std::fs::read(req_dir.join("stderr")).unwrap_or_default();
1036    let code = std::fs::read_to_string(req_dir.join("status"))
1037        .ok()
1038        .and_then(|s| s.trim().parse::<u32>().ok())
1039        .unwrap_or(if timed_out { 124 } else { 1 });
1040    let _ = std::fs::remove_dir_all(&req_dir);
1041    Ok(ExecOutcome {
1042        status: synthesize_exit(code),
1043        stdout,
1044        stderr,
1045        duration: started.elapsed(),
1046        peak_rss_kib: None,
1047        timed_out,
1048    })
1049}
1050
1051#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
1052fn kboxlike_output(builder: ExecBuilder) -> io::Result<ExecOutcome> {
1053    let target = builder.kboxlike.clone().ok_or_else(|| {
1054        io::Error::new(
1055            io::ErrorKind::InvalidInput,
1056            "kboxlike_output called without a kboxlike target",
1057        )
1058    })?;
1059    if builder.argv.is_empty() {
1060        return Err(io::Error::new(
1061            io::ErrorKind::InvalidInput,
1062            "exec: argv is empty",
1063        ));
1064    }
1065    if builder.tty || builder.cols.is_some() || builder.rows.is_some() {
1066        return Err(io::Error::new(
1067            io::ErrorKind::Unsupported,
1068            "kboxlike backend does not support TTY exec yet",
1069        ));
1070    }
1071    kboxlike_stage_files(&target, &builder.stage_files)?;
1072
1073    let started = Instant::now();
1074    let mut stdout = Vec::new();
1075    let mut stderr = Vec::new();
1076    let mut status = synthesize_exit(0);
1077    let mut timed_out = false;
1078    let mut argvs = Vec::with_capacity(1 + builder.chain.len());
1079    argvs.push(builder.argv.clone());
1080    argvs.extend(builder.chain.iter().cloned());
1081
1082    for argv in argvs {
1083        let out = kboxlike_output_one(&target, &builder, &argv, started)?;
1084        timed_out |= out.timed_out;
1085        stdout.extend(out.stdout);
1086        stderr.extend(out.stderr);
1087        status = out.status;
1088        if timed_out || !status.success() {
1089            break;
1090        }
1091    }
1092
1093    Ok(ExecOutcome {
1094        status,
1095        stdout,
1096        stderr,
1097        duration: started.elapsed(),
1098        peak_rss_kib: None,
1099        timed_out,
1100    })
1101}
1102
1103#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
1104fn kboxlike_output_one(
1105    target: &KboxlikeExecTarget,
1106    builder: &ExecBuilder,
1107    argv: &[String],
1108    started: Instant,
1109) -> io::Result<ExecOutcome> {
1110    let mut cmd = kboxlike_exec_command(target, builder, argv)?;
1111    cmd.stdin(Stdio::null());
1112    cmd.stdout(Stdio::piped());
1113    cmd.stderr(Stdio::piped());
1114    let mut child = cmd
1115        .spawn()
1116        .map_err(|e| io::Error::new(e.kind(), format!("kboxlike exec {:?}: {e}", argv)))?;
1117    let mut timed_out = false;
1118    loop {
1119        if child.try_wait()?.is_some() {
1120            break;
1121        }
1122        if let Some(timeout) = builder.timeout {
1123            if started.elapsed() >= timeout {
1124                timed_out = true;
1125                let _ = child.kill();
1126                break;
1127            }
1128        }
1129        thread::sleep(Duration::from_millis(10));
1130    }
1131    let out = child.wait_with_output()?;
1132    Ok(ExecOutcome {
1133        status: out.status,
1134        stdout: out.stdout,
1135        stderr: out.stderr,
1136        duration: started.elapsed(),
1137        peak_rss_kib: None,
1138        timed_out,
1139    })
1140}
1141
1142#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
1143fn kboxlike_spawn(builder: ExecBuilder) -> io::Result<ExecChild> {
1144    let target = builder.kboxlike.clone().ok_or_else(|| {
1145        io::Error::new(
1146            io::ErrorKind::InvalidInput,
1147            "kboxlike_spawn called without a kboxlike target",
1148        )
1149    })?;
1150    if builder.argv.is_empty() {
1151        return Err(io::Error::new(
1152            io::ErrorKind::InvalidInput,
1153            "exec: argv is empty",
1154        ));
1155    }
1156    if builder.tty || builder.cols.is_some() || builder.rows.is_some() {
1157        return Err(io::Error::new(
1158            io::ErrorKind::Unsupported,
1159            "kboxlike backend does not support TTY exec yet",
1160        ));
1161    }
1162    kboxlike_stage_files(&target, &builder.stage_files)?;
1163
1164    let argv = if builder.chain.is_empty() {
1165        builder.argv.clone()
1166    } else {
1167        runsc_spawn_argv(&builder)
1168    };
1169    let mut cmd = kboxlike_exec_command(&target, &builder, &argv)?;
1170    cmd.stdin(Stdio::piped());
1171    cmd.stdout(Stdio::piped());
1172    cmd.stderr(Stdio::piped());
1173    let mut child = cmd
1174        .spawn()
1175        .map_err(|e| io::Error::new(e.kind(), format!("kboxlike exec {:?}: {e}", argv)))?;
1176    let child_pid = child.id() as i32;
1177    let child_stdin = Arc::new(Mutex::new(child.stdin.take()));
1178    let child_stdout = child
1179        .stdout
1180        .take()
1181        .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "kboxlike exec stdout pipe missing"))?;
1182    let child_stderr = child
1183        .stderr
1184        .take()
1185        .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "kboxlike exec stderr pipe missing"))?;
1186
1187    let (host_sock, bridge_sock) = UnixStream::pair()?;
1188    let sock_r = host_sock.try_clone()?;
1189    let interrupt_fd = Arc::new(host_sock.try_clone()?);
1190    let sock_w = Arc::new(Mutex::new(host_sock));
1191
1192    let (stdout_tx, stdout_r) = channel::<Vec<u8>>();
1193    let (stderr_tx, stderr_r) = channel::<Vec<u8>>();
1194    let (exit_tx, exit_rx) = channel::<DemuxExit>();
1195    let demux = thread::Builder::new()
1196        .name("supermachine-kboxlike-exec-demux".into())
1197        .spawn(move || {
1198            demux_loop(sock_r, stdout_tx, stderr_tx, exit_tx);
1199        })
1200        .map_err(|e| {
1201            io::Error::new(
1202                io::ErrorKind::Other,
1203                format!("kboxlike exec: demux thread: {e}"),
1204            )
1205        })?;
1206
1207    let bridge_w = Arc::new(Mutex::new(bridge_sock.try_clone()?));
1208    let control_stdin = Arc::clone(&child_stdin);
1209    let process_done = Arc::new(AtomicBool::new(false));
1210    let control_done = Arc::clone(&process_done);
1211    let mut control_r = bridge_sock.try_clone()?;
1212    thread::Builder::new()
1213        .name("supermachine-kboxlike-exec-control".into())
1214        .spawn(move || {
1215            runsc_spawn_control_loop(&mut control_r, child_pid, control_stdin, control_done);
1216        })
1217        .map_err(|e| {
1218            io::Error::new(
1219                io::ErrorKind::Other,
1220                format!("kboxlike exec: control thread: {e}"),
1221            )
1222        })?;
1223
1224    let stdout_pump = runsc_spawn_pump(
1225        "supermachine-kboxlike-exec-stdout",
1226        child_stdout,
1227        FRAME_STDOUT,
1228        Arc::clone(&bridge_w),
1229    )?;
1230    let stderr_pump = runsc_spawn_pump(
1231        "supermachine-kboxlike-exec-stderr",
1232        child_stderr,
1233        FRAME_STDERR,
1234        Arc::clone(&bridge_w),
1235    )?;
1236    thread::Builder::new()
1237        .name("supermachine-kboxlike-exec-wait".into())
1238        .spawn(move || {
1239            drop(child_stdin);
1240            let status = child.wait();
1241            let payload = match status {
1242                Ok(status) => runsc_status_code(status).to_be_bytes(),
1243                Err(_) => 1u32.to_be_bytes(),
1244            };
1245            process_done.store(true, std::sync::atomic::Ordering::SeqCst);
1246            let _ = send_frame(&bridge_w, FRAME_EXIT, &payload);
1247            if let Ok(sock) = bridge_w.lock() {
1248                let _ = sock.shutdown(Shutdown::Both);
1249            }
1250            let _ = stdout_pump.join();
1251            let _ = stderr_pump.join();
1252        })
1253        .map_err(|e| {
1254            io::Error::new(
1255                io::ErrorKind::Other,
1256                format!("kboxlike exec: wait thread: {e}"),
1257            )
1258        })?;
1259
1260    let stdin = ExecStdin::new(sock_w.clone());
1261
1262    Ok(ExecChild {
1263        sock_w,
1264        we_signaled: Arc::new(AtomicBool::new(false)),
1265        aborted: Arc::new(AtomicBool::new(false)),
1266        interrupt_fd,
1267        stdout_r: Some(stdout_r),
1268        stderr_r: Some(stderr_r),
1269        stdin: Some(stdin),
1270        demux: Some(demux),
1271        exit_rx,
1272    })
1273}
1274
1275#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
1276fn kboxlike_exec_command(
1277    target: &KboxlikeExecTarget,
1278    builder: &ExecBuilder,
1279    argv: &[String],
1280) -> io::Result<Command> {
1281    if argv.is_empty() {
1282        return Err(io::Error::new(
1283            io::ErrorKind::InvalidInput,
1284            "exec: argv is empty",
1285        ));
1286    }
1287    let rootfs = std::ffi::CString::new(target.rootfs.as_os_str().as_bytes()).map_err(|_| {
1288        io::Error::new(
1289            io::ErrorKind::InvalidInput,
1290            "kboxlike rootfs path contains NUL byte",
1291        )
1292    })?;
1293    let cwd = std::ffi::CString::new(builder.cwd.as_deref().unwrap_or("/")).map_err(|_| {
1294        io::Error::new(
1295            io::ErrorKind::InvalidInput,
1296            "kboxlike exec cwd contains NUL byte",
1297        )
1298    })?;
1299    let mounts = kboxlike_prepare_runtime_mounts(&target.rootfs, &target.mounts)?;
1300    let mut cmd = Command::new(&argv[0]);
1301    cmd.args(&argv[1..]);
1302    cmd.env_clear();
1303    let mut env = BTreeMap::new();
1304    for (key, value) in &target.image_env {
1305        env.insert(key.clone(), value.clone());
1306    }
1307    for (key, value) in &builder.env {
1308        env.insert(key.clone(), value.clone());
1309    }
1310    for (key, value) in env {
1311        cmd.env(key, value);
1312    }
1313    let user = target.user;
1314    unsafe {
1315        cmd.pre_exec(move || {
1316            kboxlike_setup_runtime_mounts(&mounts)?;
1317            if libc::chroot(rootfs.as_ptr()) < 0 {
1318                return Err(io::Error::last_os_error());
1319            }
1320            if libc::chdir(cwd.as_ptr()) < 0 {
1321                return Err(io::Error::last_os_error());
1322            }
1323            if let Some((uid, gid)) = user {
1324                kboxlike_drop_uid_gid(uid, gid)?;
1325            }
1326            Ok(())
1327        });
1328    }
1329    Ok(cmd)
1330}
1331
1332#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
1333struct KboxlikePreparedRuntimeMounts {
1334    binds: Vec<(std::ffi::CString, std::ffi::CString)>,
1335    proc_target: std::ffi::CString,
1336    shm_target: std::ffi::CString,
1337}
1338
1339#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
1340fn kboxlike_prepare_runtime_mounts(
1341    rootfs: &Path,
1342    mounts: &[(PathBuf, String)],
1343) -> io::Result<KboxlikePreparedRuntimeMounts> {
1344    kboxlike_create_nested_mount_targets(mounts)?;
1345    let mut prepared = Vec::with_capacity(mounts.len());
1346    for (host_path, guest_path) in mounts {
1347        let target = kboxlike_rootfs_join(rootfs, guest_path)?;
1348        std::fs::create_dir_all(&target)?;
1349        let host = std::ffi::CString::new(host_path.as_os_str().as_bytes()).map_err(|_| {
1350            io::Error::new(
1351                io::ErrorKind::InvalidInput,
1352                format!(
1353                    "kboxlike mount host path contains NUL byte: {}",
1354                    host_path.display()
1355                ),
1356            )
1357        })?;
1358        let target = std::ffi::CString::new(target.as_os_str().as_bytes()).map_err(|_| {
1359            io::Error::new(
1360                io::ErrorKind::InvalidInput,
1361                format!("kboxlike mount target contains NUL byte: {guest_path}"),
1362            )
1363        })?;
1364        prepared.push((host, target));
1365    }
1366    kboxlike_append_standard_dev_bind_mounts(rootfs, &mut prepared)?;
1367    let proc_target = rootfs.join("proc");
1368    std::fs::create_dir_all(&proc_target)?;
1369    let shm_target = rootfs.join("dev").join("shm");
1370    std::fs::create_dir_all(&shm_target)?;
1371    let proc_target = std::ffi::CString::new(proc_target.as_os_str().as_bytes()).map_err(|_| {
1372        io::Error::new(
1373            io::ErrorKind::InvalidInput,
1374            format!(
1375                "kboxlike proc target path contains NUL byte: {}",
1376                proc_target.display()
1377            ),
1378        )
1379    })?;
1380    let shm_target = std::ffi::CString::new(shm_target.as_os_str().as_bytes()).map_err(|_| {
1381        io::Error::new(
1382            io::ErrorKind::InvalidInput,
1383            format!(
1384                "kboxlike shm target path contains NUL byte: {}",
1385                shm_target.display()
1386            ),
1387        )
1388    })?;
1389    Ok(KboxlikePreparedRuntimeMounts {
1390        binds: prepared,
1391        proc_target,
1392        shm_target,
1393    })
1394}
1395
1396#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
1397fn kboxlike_create_nested_mount_targets(mounts: &[(PathBuf, String)]) -> io::Result<()> {
1398    for (parent_host, parent_guest) in mounts {
1399        for (_, child_guest) in mounts {
1400            let Some(relative_child) = kboxlike_nested_mount_suffix(parent_guest, child_guest)
1401            else {
1402                continue;
1403            };
1404            std::fs::create_dir_all(parent_host.join(relative_child))?;
1405        }
1406    }
1407    Ok(())
1408}
1409
1410#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
1411fn kboxlike_nested_mount_suffix(parent_guest: &str, child_guest: &str) -> Option<PathBuf> {
1412    let parent = Path::new(parent_guest.strip_prefix('/').unwrap_or(parent_guest));
1413    let child = Path::new(child_guest.strip_prefix('/').unwrap_or(child_guest));
1414    if parent == child {
1415        return None;
1416    }
1417    child.strip_prefix(parent).ok().map(Path::to_path_buf)
1418}
1419
1420#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
1421fn kboxlike_append_standard_dev_bind_mounts(
1422    rootfs: &Path,
1423    prepared: &mut Vec<(std::ffi::CString, std::ffi::CString)>,
1424) -> io::Result<()> {
1425    let dev_dir = rootfs.join("dev");
1426    std::fs::create_dir_all(&dev_dir)?;
1427    for name in ["null", "zero", "random", "urandom"] {
1428        let host_path = PathBuf::from("/dev").join(name);
1429        if !host_path.exists() {
1430            continue;
1431        }
1432        let target = dev_dir.join(name);
1433        if !target.exists() {
1434            std::fs::File::create(&target)?;
1435        }
1436        let host = std::ffi::CString::new(host_path.as_os_str().as_bytes()).map_err(|_| {
1437            io::Error::new(
1438                io::ErrorKind::InvalidInput,
1439                format!(
1440                    "kboxlike device host path contains NUL byte: {}",
1441                    host_path.display()
1442                ),
1443            )
1444        })?;
1445        let target = std::ffi::CString::new(target.as_os_str().as_bytes()).map_err(|_| {
1446            io::Error::new(
1447                io::ErrorKind::InvalidInput,
1448                format!(
1449                    "kboxlike device target path contains NUL byte: {}",
1450                    target.display()
1451                ),
1452            )
1453        })?;
1454        prepared.push((host, target));
1455    }
1456    Ok(())
1457}
1458
1459#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
1460fn kboxlike_setup_runtime_mounts(mounts: &KboxlikePreparedRuntimeMounts) -> io::Result<()> {
1461    unsafe {
1462        if libc::unshare(libc::CLONE_NEWNS) < 0 {
1463            return Err(io::Error::last_os_error());
1464        }
1465        if libc::mount(
1466            std::ptr::null(),
1467            c"/".as_ptr(),
1468            std::ptr::null(),
1469            (libc::MS_REC | libc::MS_PRIVATE) as libc::c_ulong,
1470            std::ptr::null(),
1471        ) < 0
1472        {
1473            return Err(io::Error::last_os_error());
1474        }
1475        for (host, target) in &mounts.binds {
1476            if libc::mount(
1477                host.as_ptr(),
1478                target.as_ptr(),
1479                std::ptr::null(),
1480                (libc::MS_BIND | libc::MS_REC) as libc::c_ulong,
1481                std::ptr::null(),
1482            ) < 0
1483            {
1484                return Err(io::Error::last_os_error());
1485            }
1486        }
1487        if libc::mount(
1488            c"proc".as_ptr(),
1489            mounts.proc_target.as_ptr(),
1490            c"proc".as_ptr(),
1491            (libc::MS_NOSUID | libc::MS_NOEXEC | libc::MS_NODEV) as libc::c_ulong,
1492            std::ptr::null(),
1493        ) < 0
1494        {
1495            return Err(io::Error::last_os_error());
1496        }
1497        if libc::mount(
1498            c"tmpfs".as_ptr(),
1499            mounts.shm_target.as_ptr(),
1500            c"tmpfs".as_ptr(),
1501            (libc::MS_NOSUID | libc::MS_NODEV) as libc::c_ulong,
1502            c"mode=1777,size=64m".as_ptr().cast(),
1503        ) < 0
1504        {
1505            return Err(io::Error::last_os_error());
1506        }
1507    }
1508    Ok(())
1509}
1510
1511#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
1512fn kboxlike_drop_uid_gid(uid: u32, gid: u32) -> io::Result<()> {
1513    unsafe {
1514        if libc::setgroups(0, std::ptr::null()) < 0 {
1515            return Err(io::Error::last_os_error());
1516        }
1517        if libc::setgid(gid as libc::gid_t) < 0 {
1518            return Err(io::Error::last_os_error());
1519        }
1520        if libc::setuid(uid as libc::uid_t) < 0 {
1521            return Err(io::Error::last_os_error());
1522        }
1523    }
1524    Ok(())
1525}
1526
1527#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
1528fn kboxlike_stage_files(target: &KboxlikeExecTarget, files: &[StageFile]) -> io::Result<()> {
1529    for file in files {
1530        let target_path = kboxlike_rootfs_join(&target.rootfs, &file.path)?;
1531        let parent = target_path.parent().ok_or_else(|| {
1532            io::Error::new(
1533                io::ErrorKind::InvalidInput,
1534                format!("stage file has no parent: {}", file.path),
1535            )
1536        })?;
1537        std::fs::create_dir_all(parent)?;
1538        let name = target_path
1539            .file_name()
1540            .and_then(|name| name.to_str())
1541            .unwrap_or("stage");
1542        let tmp = parent.join(format!(".supermachine-stage-{}-{name}", std::process::id()));
1543        std::fs::write(&tmp, &file.data)?;
1544        let mode = file.mode.unwrap_or(0o644);
1545        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode))?;
1546        std::fs::rename(&tmp, &target_path)?;
1547    }
1548    Ok(())
1549}
1550
1551#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
1552fn kboxlike_rootfs_join(rootfs: &Path, guest_path: &str) -> io::Result<PathBuf> {
1553    let rel = guest_path.strip_prefix('/').unwrap_or(guest_path);
1554    let p = Path::new(rel);
1555    if rel.is_empty()
1556        || p.components().any(|component| {
1557            matches!(
1558                component,
1559                std::path::Component::ParentDir | std::path::Component::RootDir
1560            )
1561        })
1562    {
1563        return Err(io::Error::new(
1564            io::ErrorKind::InvalidInput,
1565            format!("guest path escapes rootfs: {guest_path}"),
1566        ));
1567    }
1568    Ok(rootfs.join(p))
1569}
1570
1571#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
1572fn runsc_output_one(
1573    target: &RunscExecTarget,
1574    builder: &ExecBuilder,
1575    argv: &[String],
1576    started: Instant,
1577) -> io::Result<ExecOutcome> {
1578    let mut cmd = Command::new(&target.runsc_bin);
1579    add_runsc_global_flags_with_network(&mut cmd, &target.runsc_root, runsc_network(target));
1580    cmd.arg("exec");
1581    if let Some(cwd) = builder.cwd.as_deref() {
1582        cmd.arg("-cwd").arg(cwd);
1583    }
1584    let mut env = BTreeMap::new();
1585    for (k, v) in &target.image_env {
1586        env.insert(k.clone(), v.clone());
1587    }
1588    for (k, v) in &builder.env {
1589        env.insert(k.clone(), v.clone());
1590    }
1591    for (k, v) in env {
1592        cmd.arg("-env").arg(format!("{k}={v}"));
1593    }
1594    cmd.arg(&target.container_id);
1595    cmd.args(argv);
1596    cmd.stdin(Stdio::null());
1597    cmd.stdout(Stdio::piped());
1598    cmd.stderr(Stdio::piped());
1599
1600    let mut child = cmd.spawn().map_err(|e| {
1601        io::Error::new(e.kind(), format!("runsc exec {}: {e}", target.container_id))
1602    })?;
1603    let mut timed_out = false;
1604    loop {
1605        if child.try_wait()?.is_some() {
1606            break;
1607        }
1608        if let Some(timeout) = builder.timeout {
1609            if started.elapsed() >= timeout {
1610                timed_out = true;
1611                let _ = child.kill();
1612                break;
1613            }
1614        }
1615        thread::sleep(Duration::from_millis(10));
1616    }
1617    let out = child.wait_with_output()?;
1618    Ok(ExecOutcome {
1619        status: out.status,
1620        stdout: out.stdout,
1621        stderr: out.stderr,
1622        duration: started.elapsed(),
1623        peak_rss_kib: None,
1624        timed_out,
1625    })
1626}
1627
1628#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
1629fn runsc_spawn(builder: ExecBuilder) -> io::Result<ExecChild> {
1630    let target = builder.runsc.clone().ok_or_else(|| {
1631        io::Error::new(
1632            io::ErrorKind::InvalidInput,
1633            "runsc_spawn called without a runsc target",
1634        )
1635    })?;
1636    if builder.argv.is_empty() {
1637        return Err(io::Error::new(
1638            io::ErrorKind::InvalidInput,
1639            "exec: argv is empty",
1640        ));
1641    }
1642    if target.agent_path.is_some() {
1643        return runsc_agent_spawn(builder, target);
1644    }
1645    if builder.tty || builder.cols.is_some() || builder.rows.is_some() {
1646        return Err(io::Error::new(
1647            io::ErrorKind::Unsupported,
1648            "runsc backend does not support TTY exec yet",
1649        ));
1650    }
1651    runsc_stage_files(&target, &builder.stage_files)?;
1652
1653    let argv = runsc_spawn_argv(&builder);
1654    let mut cmd = runsc_exec_command(&target, &builder, &argv);
1655    cmd.stdin(Stdio::piped());
1656    cmd.stdout(Stdio::piped());
1657    cmd.stderr(Stdio::piped());
1658    let mut child = cmd.spawn().map_err(|e| {
1659        io::Error::new(e.kind(), format!("runsc exec {}: {e}", target.container_id))
1660    })?;
1661    let child_pid = child.id() as i32;
1662    let child_stdin = Arc::new(Mutex::new(child.stdin.take()));
1663    let child_stdout = child
1664        .stdout
1665        .take()
1666        .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "runsc exec stdout pipe missing"))?;
1667    let child_stderr = child
1668        .stderr
1669        .take()
1670        .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "runsc exec stderr pipe missing"))?;
1671
1672    let (host_sock, bridge_sock) = UnixStream::pair()?;
1673    let sock_r = host_sock.try_clone()?;
1674    let interrupt_fd = Arc::new(host_sock.try_clone()?);
1675    let sock_w = Arc::new(Mutex::new(host_sock));
1676
1677    let (stdout_tx, stdout_r) = channel::<Vec<u8>>();
1678    let (stderr_tx, stderr_r) = channel::<Vec<u8>>();
1679    let (exit_tx, exit_rx) = channel::<DemuxExit>();
1680    let demux = thread::Builder::new()
1681        .name("supermachine-runsc-exec-demux".into())
1682        .spawn(move || {
1683            demux_loop(sock_r, stdout_tx, stderr_tx, exit_tx);
1684        })
1685        .map_err(|e| {
1686            io::Error::new(
1687                io::ErrorKind::Other,
1688                format!("runsc exec: demux thread: {e}"),
1689            )
1690        })?;
1691
1692    let bridge_w = Arc::new(Mutex::new(bridge_sock.try_clone()?));
1693    let control_stdin = Arc::clone(&child_stdin);
1694    let process_done = Arc::new(AtomicBool::new(false));
1695    let control_done = Arc::clone(&process_done);
1696    let mut control_r = bridge_sock.try_clone()?;
1697    thread::Builder::new()
1698        .name("supermachine-runsc-exec-control".into())
1699        .spawn(move || {
1700            runsc_spawn_control_loop(&mut control_r, child_pid, control_stdin, control_done);
1701        })
1702        .map_err(|e| {
1703            io::Error::new(
1704                io::ErrorKind::Other,
1705                format!("runsc exec: control thread: {e}"),
1706            )
1707        })?;
1708
1709    let stdout_pump = runsc_spawn_pump(
1710        "supermachine-runsc-exec-stdout",
1711        child_stdout,
1712        FRAME_STDOUT,
1713        Arc::clone(&bridge_w),
1714    )?;
1715    let stderr_pump = runsc_spawn_pump(
1716        "supermachine-runsc-exec-stderr",
1717        child_stderr,
1718        FRAME_STDERR,
1719        Arc::clone(&bridge_w),
1720    )?;
1721    thread::Builder::new()
1722        .name("supermachine-runsc-exec-wait".into())
1723        .spawn(move || {
1724            drop(child_stdin);
1725            let status = child.wait();
1726            let payload = match status {
1727                Ok(status) => runsc_status_code(status).to_be_bytes(),
1728                Err(_) => 1u32.to_be_bytes(),
1729            };
1730            process_done.store(true, std::sync::atomic::Ordering::SeqCst);
1731            let _ = send_frame(&bridge_w, FRAME_EXIT, &payload);
1732            if let Ok(sock) = bridge_w.lock() {
1733                let _ = sock.shutdown(Shutdown::Both);
1734            }
1735            let _ = stdout_pump.join();
1736            let _ = stderr_pump.join();
1737        })
1738        .map_err(|e| {
1739            io::Error::new(
1740                io::ErrorKind::Other,
1741                format!("runsc exec: wait thread: {e}"),
1742            )
1743        })?;
1744
1745    let stdin = ExecStdin::new(sock_w.clone());
1746
1747    Ok(ExecChild {
1748        sock_w,
1749        we_signaled: Arc::new(AtomicBool::new(false)),
1750        aborted: Arc::new(AtomicBool::new(false)),
1751        interrupt_fd,
1752        stdout_r: Some(stdout_r),
1753        stderr_r: Some(stderr_r),
1754        stdin: Some(stdin),
1755        demux: Some(demux),
1756        exit_rx,
1757    })
1758}
1759
1760#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
1761fn runsc_agent_spawn(builder: ExecBuilder, target: RunscExecTarget) -> io::Result<ExecChild> {
1762    let debug = std::env::var_os("SUPERMACHINE_RUNSC_EXECDBG").is_some();
1763    let agent_path = target.agent_path.as_deref().ok_or_else(|| {
1764        io::Error::new(
1765            io::ErrorKind::InvalidInput,
1766            "runsc agent exec target missing agent path",
1767        )
1768    })?;
1769    let mut cmd = Command::new(&target.runsc_bin);
1770    add_runsc_global_flags_with_network(&mut cmd, &target.runsc_root, runsc_network(&target));
1771    cmd.arg("exec")
1772        .arg(&target.container_id)
1773        .arg(agent_path)
1774        .arg("--exec-stdio");
1775    cmd.stdin(Stdio::piped());
1776    cmd.stdout(Stdio::piped());
1777    cmd.stderr(Stdio::piped());
1778    if debug {
1779        eprintln!(
1780            "[runsc.execdbg] spawn agent container={} path={} argv0={}",
1781            target.container_id,
1782            agent_path,
1783            builder.argv.first().map(String::as_str).unwrap_or("")
1784        );
1785    }
1786    let mut child = cmd.spawn().map_err(|e| {
1787        io::Error::new(
1788            e.kind(),
1789            format!(
1790                "runsc exec {} {} --exec-stdio: {e}",
1791                target.container_id, agent_path
1792            ),
1793        )
1794    })?;
1795    let child_pid = child.id() as i32;
1796    let mut child_stdin = child.stdin.take().ok_or_else(|| {
1797        io::Error::new(io::ErrorKind::Other, "runsc agent exec stdin pipe missing")
1798    })?;
1799    let mut child_stdout = child.stdout.take().ok_or_else(|| {
1800        io::Error::new(io::ErrorKind::Other, "runsc agent exec stdout pipe missing")
1801    })?;
1802    let child_stderr = child.stderr.take().ok_or_else(|| {
1803        io::Error::new(io::ErrorKind::Other, "runsc agent exec stderr pipe missing")
1804    })?;
1805
1806    let (host_sock, bridge_sock) = UnixStream::pair()?;
1807    let sock_r = host_sock.try_clone()?;
1808    let interrupt_fd = Arc::new(host_sock.try_clone()?);
1809    let sock_w = Arc::new(Mutex::new(host_sock));
1810
1811    let (stdout_tx, stdout_r) = channel::<Vec<u8>>();
1812    let (stderr_tx, stderr_r) = channel::<Vec<u8>>();
1813    let (exit_tx, exit_rx) = channel::<DemuxExit>();
1814    let demux = thread::Builder::new()
1815        .name("supermachine-runsc-agent-demux".into())
1816        .spawn(move || {
1817            demux_loop(sock_r, stdout_tx, stderr_tx, exit_tx);
1818        })
1819        .map_err(|e| {
1820            io::Error::new(
1821                io::ErrorKind::Other,
1822                format!("runsc agent exec: demux thread: {e}"),
1823            )
1824        })?;
1825
1826    let bridge_w = Arc::new(Mutex::new(bridge_sock.try_clone()?));
1827    let mut bridge_to_agent = bridge_sock.try_clone()?;
1828    let process_done = Arc::new(AtomicBool::new(false));
1829    let pump_done = Arc::clone(&process_done);
1830    let debug_c2g = debug;
1831    thread::Builder::new()
1832        .name("supermachine-runsc-agent-c2g".into())
1833        .spawn(move || {
1834            let mut copied: u64 = 0;
1835            let mut buf = [0u8; 8192];
1836            let copied_result: io::Result<u64> = loop {
1837                match bridge_to_agent.read(&mut buf) {
1838                    Ok(0) => break Ok(copied),
1839                    Ok(n) => {
1840                        copied += n as u64;
1841                        if debug_c2g {
1842                            eprintln!("[runsc.execdbg] c2g read n={n} total={copied}");
1843                        }
1844                        if let Err(e) = child_stdin.write_all(&buf[..n]) {
1845                            break Err(e);
1846                        }
1847                        if let Err(e) = child_stdin.flush() {
1848                            break Err(e);
1849                        }
1850                    }
1851                    Err(e) => break Err(e),
1852                }
1853            };
1854            if debug_c2g {
1855                eprintln!("[runsc.execdbg] c2g done copied={copied_result:?}");
1856            }
1857            let _ = child_stdin.flush();
1858            drop(child_stdin);
1859            if !pump_done.load(std::sync::atomic::Ordering::SeqCst) {
1860                if debug_c2g {
1861                    eprintln!("[runsc.execdbg] c2g killing host runsc exec pid={child_pid}");
1862                }
1863                let _ = unsafe { libc::kill(child_pid, libc::SIGKILL) };
1864            }
1865        })
1866        .map_err(|e| {
1867            io::Error::new(
1868                io::ErrorKind::Other,
1869                format!("runsc agent exec: stdin bridge thread: {e}"),
1870            )
1871        })?;
1872
1873    let stdout_bridge_w = Arc::clone(&bridge_w);
1874    let debug_g2c = debug;
1875    let stdout_pump = thread::Builder::new()
1876        .name("supermachine-runsc-agent-g2c".into())
1877        .spawn(move || {
1878            let mut buf = [0u8; 8192];
1879            let mut total: u64 = 0;
1880            loop {
1881                match child_stdout.read(&mut buf) {
1882                    Ok(0) => {
1883                        if debug_g2c {
1884                            eprintln!("[runsc.execdbg] g2c eof total={total}");
1885                        }
1886                        return;
1887                    }
1888                    Ok(n) => {
1889                        total += n as u64;
1890                        if debug_g2c {
1891                            eprintln!("[runsc.execdbg] g2c read n={n} total={total}");
1892                        }
1893                        let mut guard = match stdout_bridge_w.lock() {
1894                            Ok(g) => g,
1895                            Err(_) => return,
1896                        };
1897                        if guard.write_all(&buf[..n]).is_err() {
1898                            return;
1899                        }
1900                    }
1901                    Err(e) => {
1902                        if debug_g2c {
1903                            eprintln!("[runsc.execdbg] g2c read error {e}");
1904                        }
1905                        return;
1906                    }
1907                }
1908            }
1909        })
1910        .map_err(|e| {
1911            io::Error::new(
1912                io::ErrorKind::Other,
1913                format!("runsc agent exec: stdout bridge thread: {e}"),
1914            )
1915        })?;
1916    let stderr_pump = runsc_spawn_pump(
1917        "supermachine-runsc-agent-stderr",
1918        child_stderr,
1919        FRAME_STDERR,
1920        Arc::clone(&bridge_w),
1921    )?;
1922
1923    thread::Builder::new()
1924        .name("supermachine-runsc-agent-wait".into())
1925        .spawn(move || {
1926            let status = child.wait();
1927            if debug {
1928                eprintln!("[runsc.execdbg] wait status={status:?}");
1929            }
1930            process_done.store(true, std::sync::atomic::Ordering::SeqCst);
1931            if let Ok(sock) = bridge_w.lock() {
1932                let _ = sock.shutdown(Shutdown::Both);
1933            }
1934            let _ = stdout_pump.join();
1935            let _ = stderr_pump.join();
1936        })
1937        .map_err(|e| {
1938            io::Error::new(
1939                io::ErrorKind::Other,
1940                format!("runsc agent exec: wait thread: {e}"),
1941            )
1942        })?;
1943
1944    let stage_payload: Vec<StageFilePayload> = builder
1945        .stage_files
1946        .iter()
1947        .map(|s| StageFilePayload {
1948            path: &s.path,
1949            data_b64: crate::api::b64_encode(&s.data),
1950            mode: s.mode,
1951        })
1952        .collect();
1953    let payload = RequestPayload {
1954        argv: &builder.argv,
1955        env: &builder.env,
1956        cwd: builder.cwd.as_deref(),
1957        tty: builder.tty,
1958        cols: builder.cols,
1959        rows: builder.rows,
1960        stage_files: stage_payload,
1961        chain: &builder.chain,
1962    };
1963    let json = serde_json::to_vec(&payload).map_err(|e| {
1964        io::Error::new(
1965            io::ErrorKind::InvalidInput,
1966            format!("runsc agent exec: encode REQUEST: {e}"),
1967        )
1968    })?;
1969    send_frame(&sock_w, FRAME_REQUEST, &json)?;
1970
1971    let stdin = ExecStdin::new(sock_w.clone());
1972
1973    Ok(ExecChild {
1974        sock_w,
1975        we_signaled: Arc::new(AtomicBool::new(false)),
1976        aborted: Arc::new(AtomicBool::new(false)),
1977        interrupt_fd,
1978        stdout_r: Some(stdout_r),
1979        stderr_r: Some(stderr_r),
1980        stdin: Some(stdin),
1981        demux: Some(demux),
1982        exit_rx,
1983    })
1984}
1985
1986#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
1987fn runsc_exec_command(target: &RunscExecTarget, builder: &ExecBuilder, argv: &[String]) -> Command {
1988    let mut cmd = Command::new(&target.runsc_bin);
1989    add_runsc_global_flags_with_network(&mut cmd, &target.runsc_root, runsc_network(target));
1990    cmd.arg("exec");
1991    if let Some(cwd) = builder.cwd.as_deref() {
1992        cmd.arg("-cwd").arg(cwd);
1993    }
1994    let mut env = BTreeMap::new();
1995    for (k, v) in &target.image_env {
1996        env.insert(k.clone(), v.clone());
1997    }
1998    for (k, v) in &builder.env {
1999        env.insert(k.clone(), v.clone());
2000    }
2001    for (k, v) in env {
2002        cmd.arg("-env").arg(format!("{k}={v}"));
2003    }
2004    cmd.arg(&target.container_id);
2005    cmd.args(argv);
2006    cmd
2007}
2008
2009#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
2010fn runsc_spawn_argv(builder: &ExecBuilder) -> Vec<String> {
2011    if builder.chain.is_empty() {
2012        return builder.argv.clone();
2013    }
2014    let mut commands = Vec::with_capacity(1 + builder.chain.len());
2015    commands.push(shell_join_argv(&builder.argv));
2016    for argv in &builder.chain {
2017        commands.push(shell_join_argv(argv));
2018    }
2019    vec!["/bin/sh".into(), "-c".into(), commands.join(" && ")]
2020}
2021
2022#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
2023fn shell_join_argv(argv: &[String]) -> String {
2024    argv.iter()
2025        .map(|arg| sh_quote(arg))
2026        .collect::<Vec<_>>()
2027        .join(" ")
2028}
2029
2030#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
2031fn runsc_stage_files(target: &RunscExecTarget, files: &[StageFile]) -> io::Result<()> {
2032    for file in files {
2033        let q_path = sh_quote(&file.path);
2034        let mode = file.mode.unwrap_or(0o644);
2035        let script = format!(
2036            "set -e\n\
2037             p={q_path}\n\
2038             d=$(dirname -- \"$p\")\n\
2039             mkdir -p -- \"$d\"\n\
2040             t=\"$d/.supermachine-stage-$$-$(basename -- \"$p\")\"\n\
2041             cat > \"$t\"\n\
2042             chmod {mode:o} \"$t\"\n\
2043             mv -f -- \"$t\" \"$p\"\n"
2044        );
2045        let mut cmd = Command::new(&target.runsc_bin);
2046        add_runsc_global_flags_with_network(&mut cmd, &target.runsc_root, runsc_network(target));
2047        cmd.arg("exec")
2048            .arg(&target.container_id)
2049            .arg("/bin/sh")
2050            .arg("-c")
2051            .arg(script);
2052        cmd.stdin(Stdio::piped());
2053        cmd.stdout(Stdio::piped());
2054        cmd.stderr(Stdio::piped());
2055        let mut child = cmd.spawn().map_err(|e| {
2056            io::Error::new(
2057                e.kind(),
2058                format!(
2059                    "runsc stage_file {} in {}: {e}",
2060                    file.path, target.container_id
2061                ),
2062            )
2063        })?;
2064        if let Some(mut stdin) = child.stdin.take() {
2065            stdin.write_all(&file.data)?;
2066        }
2067        let out = child.wait_with_output()?;
2068        if !out.status.success() {
2069            return Err(io::Error::other(format!(
2070                "runsc stage_file {} failed: status={} stdout={} stderr={}",
2071                file.path,
2072                out.status,
2073                String::from_utf8_lossy(&out.stdout),
2074                String::from_utf8_lossy(&out.stderr)
2075            )));
2076        }
2077    }
2078    Ok(())
2079}
2080
2081#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
2082fn runsc_spawn_control_loop(
2083    sock: &mut UnixStream,
2084    child_pid: i32,
2085    child_stdin: Arc<Mutex<Option<ChildStdin>>>,
2086    process_done: Arc<AtomicBool>,
2087) {
2088    loop {
2089        let (kind, payload) = match read_frame(sock) {
2090            Ok(frame) => frame,
2091            Err(_) => {
2092                if !process_done.load(std::sync::atomic::Ordering::SeqCst) {
2093                    let _ = unsafe { libc::kill(child_pid, libc::SIGKILL) };
2094                }
2095                return;
2096            }
2097        };
2098        match kind {
2099            FRAME_STDIN => {
2100                let mut guard = match child_stdin.lock() {
2101                    Ok(g) => g,
2102                    Err(_) => return,
2103                };
2104                if payload.is_empty() {
2105                    guard.take();
2106                } else if let Some(stdin) = guard.as_mut() {
2107                    let _ = stdin.write_all(&payload);
2108                    let _ = stdin.flush();
2109                }
2110            }
2111            FRAME_SIGNAL => {
2112                if let Some(signum) = payload.first().copied() {
2113                    let _ = unsafe { libc::kill(child_pid, signum as i32) };
2114                }
2115            }
2116            FRAME_RESIZE => {}
2117            _ => {}
2118        }
2119    }
2120}
2121
2122#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
2123fn runsc_spawn_pump<R>(
2124    name: &'static str,
2125    mut reader: R,
2126    kind: u8,
2127    bridge_w: Arc<Mutex<UnixStream>>,
2128) -> io::Result<JoinHandle<()>>
2129where
2130    R: Read + Send + 'static,
2131{
2132    thread::Builder::new()
2133        .name(name.into())
2134        .spawn(move || {
2135            let mut buf = [0u8; 8192];
2136            loop {
2137                match reader.read(&mut buf) {
2138                    Ok(0) => return,
2139                    Ok(n) => {
2140                        if send_frame(&bridge_w, kind, &buf[..n]).is_err() {
2141                            return;
2142                        }
2143                    }
2144                    Err(_) => return,
2145                }
2146            }
2147        })
2148        .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("runsc exec: {name}: {e}")))
2149}
2150
2151#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
2152fn write_raw_frame<W: Write>(writer: &mut W, kind: u8, payload: &[u8]) -> io::Result<()> {
2153    let mut header = [0u8; 5];
2154    header[0] = kind;
2155    header[1..5].copy_from_slice(&(payload.len() as u32).to_be_bytes());
2156    writer.write_all(&header)?;
2157    if !payload.is_empty() {
2158        writer.write_all(payload)?;
2159    }
2160    writer.flush()
2161}
2162
2163#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
2164fn runsc_status_code(status: ExitStatus) -> u32 {
2165    #[cfg(unix)]
2166    {
2167        use std::os::unix::process::ExitStatusExt;
2168        if let Some(code) = status.code() {
2169            return code as u32;
2170        }
2171        if let Some(signal) = status.signal() {
2172            return 128 + signal as u32;
2173        }
2174    }
2175    1
2176}
2177
2178#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
2179fn sh_quote(s: &str) -> String {
2180    let mut out = String::from("'");
2181    for ch in s.chars() {
2182        if ch == '\'' {
2183            out.push_str("'\\''");
2184        } else {
2185            out.push(ch);
2186        }
2187    }
2188    out.push('\'');
2189    out
2190}
2191
2192#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
2193fn unique_suffix_nanos() -> u128 {
2194    std::time::SystemTime::now()
2195        .duration_since(std::time::UNIX_EPOCH)
2196        .map(|d| d.as_nanos())
2197        .unwrap_or(0)
2198}
2199
2200#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
2201pub(crate) fn add_runsc_global_flags(cmd: &mut Command, root: &Path) {
2202    add_runsc_global_flags_with_network(cmd, root, RUNSC_NETWORK_NONE);
2203}
2204
2205#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
2206pub(crate) fn add_runsc_global_flags_with_network(cmd: &mut Command, root: &Path, network: &str) {
2207    cmd.arg("--root")
2208        .arg(root)
2209        .arg("--platform=systrap")
2210        .arg(format!("--network={network}"))
2211        .arg("--overlay2=none")
2212        .arg("--file-access=shared");
2213    if network == RUNSC_NETWORK_SANDBOX {
2214        cmd.arg("--reproduce-nat").arg("--reproduce-nftables");
2215    }
2216}
2217
2218#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
2219fn runsc_network(target: &RunscExecTarget) -> &str {
2220    target.network.as_deref().unwrap_or(RUNSC_NETWORK_NONE)
2221}
2222
2223/// JSON shape for the REQUEST frame. Fields match the agent's
2224/// `ExecRequest` struct.
2225#[derive(Serialize)]
2226struct RequestPayload<'a> {
2227    argv: &'a [String],
2228    env: &'a BTreeMap<String, String>,
2229    #[serde(skip_serializing_if = "Option::is_none")]
2230    cwd: Option<&'a str>,
2231    tty: bool,
2232    #[serde(skip_serializing_if = "Option::is_none")]
2233    cols: Option<u16>,
2234    #[serde(skip_serializing_if = "Option::is_none")]
2235    rows: Option<u16>,
2236    #[serde(skip_serializing_if = "Vec::is_empty")]
2237    stage_files: Vec<StageFilePayload<'a>>,
2238    #[serde(skip_serializing_if = "<[_]>::is_empty")]
2239    chain: &'a [Vec<String>],
2240}
2241
2242#[derive(Serialize)]
2243struct StageFilePayload<'a> {
2244    path: &'a str,
2245    data_b64: String,
2246    #[serde(skip_serializing_if = "Option::is_none")]
2247    mode: Option<u32>,
2248}
2249
2250/// A running guest process. Hold this until you call [`wait`] or
2251/// drop it; dropping closes the agent connection, which kills the
2252/// child via the agent's `SIGHUP` propagation.
2253///
2254/// [`wait`]: ExecChild::wait
2255pub struct ExecChild {
2256    /// Writer half of the unix socket to the agent. Wrapped in a
2257    /// mutex so STDIN frames don't interleave with RESIZE/SIGNAL
2258    /// frames coming from other places.
2259    sock_w: Arc<Mutex<UnixStream>>,
2260    /// 0.7.44+ flips to true as soon as we send the first SIGNAL
2261    /// frame (from `signal()`/`signaler.signal()`/`signaler.kill()`).
2262    /// Used by `wait_with_rss` to synthesize a kill-by-signal
2263    /// ExitStatus when the agent closes the connection before an
2264    /// EXIT frame arrives — which happens reliably under heavy
2265    /// stdout flood when the single exec vsock channel is so
2266    /// congested with STDOUT bytes that the EXIT frame can't be
2267    /// queued in time before the watchdog's ABORT_GRACE elapses
2268    /// and force-closes the socket. The host KNOWS the workload
2269    /// was killed (we sent the kill); the missing EXIT is a wire
2270    /// artifact, not a semantic loss. See `synthesize_killed_by_signal`.
2271    we_signaled: Arc<AtomicBool>,
2272    /// Set to `true` the moment the host tears the connection down
2273    /// locally — via [`Self::abort`], [`ExecSignaler::abort`], or Drop.
2274    /// Lets the `EofBeforeExit` path distinguish "we closed it" (a clean
2275    /// local abort) from "the agent/VM went away unexpectedly", so the
2276    /// surfaced error is self-diagnosing instead of a generic EOF.
2277    aborted: Arc<AtomicBool>,
2278    /// A third `try_clone`d handle to the same underlying socket,
2279    /// retained ONLY for the host-side shutdown path. Calling
2280    /// `shutdown(Both)` on this fd wakes the demux thread's blocking
2281    /// read on its own fd (both fds share the kernel-side socket
2282    /// state). Used by [`Self::abort`] and Drop so an embedder that
2283    /// needs to interrupt a blocked [`wait`] / [`output`] from another
2284    /// thread can do so WITHOUT relying on the worker / agent being
2285    /// alive — a missing capability before 0.7.10 that forced
2286    /// embedders into thread-spawn-and-poll workarounds.
2287    ///
2288    /// [`wait`]: ExecChild::wait
2289    /// [`output`]: ExecBuilder::output
2290    interrupt_fd: Arc<UnixStream>,
2291    /// stdout chunk receiver (caller side). Pre-0.7.12 this was an
2292    /// `Option<OwnedFd>` wrapping a Unix pipe read end; we replaced
2293    /// that with an in-memory channel because the kernel pipe
2294    /// buffer (16 KiB on macOS) capped streaming throughput — any
2295    /// workload that produced >~48 KiB before the caller drained
2296    /// deadlocked the agent's writer mutex. The channel has no such
2297    /// ceiling; bytes are queued in memory until `ExecStdout::read`
2298    /// drains them.
2299    stdout_r: Option<Receiver<Vec<u8>>>,
2300    /// stderr chunk receiver. Same channel-based plumbing as stdout.
2301    stderr_r: Option<Receiver<Vec<u8>>>,
2302    /// stdin pipe write end (caller side). `None` if already closed
2303    /// via [`ExecStdin::close`].
2304    stdin: Option<ExecStdin>,
2305    /// Demux thread join handle.
2306    demux: Option<JoinHandle<()>>,
2307    /// EXIT status receiver, posted by the demux thread.
2308    exit_rx: Receiver<DemuxExit>,
2309}
2310
2311/// Send + Sync + Clone handle to an [`ExecChild`]'s control channel.
2312/// Lets a thread that doesn't own the child send signals AND abort
2313/// the connection — the canonical Rust answer to "I want to cancel
2314/// an in-flight `output()` / `wait()` from a registry / watchdog /
2315/// shutdown handler."
2316///
2317/// Why this exists: `ExecChild` itself is `!Sync` (its `exit_rx` is
2318/// `mpsc::Receiver`, which is `Send` but `!Sync`), so you can't share
2319/// `&ExecChild` across threads. `ExecSignaler` is a `!exit_rx`
2320/// slice of the child that IS thread-safe — built on the same
2321/// `Arc<Mutex<UnixStream>>` the writer-side already uses internally.
2322///
2323/// Cost: each clone is one atomic refcount increment.
2324///
2325/// Idiomatic shape:
2326///
2327/// ```ignore
2328/// let child = vm.exec_builder(["sleep", "30"]).spawn()?;
2329/// let signaler = child.signaler();
2330/// std::thread::spawn(move || {
2331///     // user pressed Ctrl+C — interrupt the running process.
2332///     let _ = signaler.kill();
2333/// });
2334/// let exit = child.wait()?;   // unblocks when kill arrives
2335/// ```
2336///
2337/// For "interrupt even if the worker died" semantics, use
2338/// [`Self::abort`] — it closes the host-side socket which causes
2339/// the demux thread to surface EOF immediately, regardless of
2340/// whether the guest agent is still alive.
2341#[derive(Clone)]
2342pub struct ExecSignaler {
2343    sock_w: Arc<Mutex<UnixStream>>,
2344    interrupt_fd: Arc<UnixStream>,
2345    /// Shared with the parent [`ExecChild`] so a successful
2346    /// `signal()`/`kill()` lets `wait_with_rss` synthesize a
2347    /// SIGKILL exit when the agent closes the channel without
2348    /// sending EXIT (vsock congestion → EXIT can't queue). See
2349    /// `ExecChild::we_signaled` for the full rationale.
2350    we_signaled: Arc<AtomicBool>,
2351    /// Shared with the parent [`ExecChild`]; set when this signaler's
2352    /// [`Self::abort`] tears the connection down, so the child's
2353    /// `EofBeforeExit` path reports a local abort rather than an
2354    /// unexplained agent disconnect. See [`ExecChild::aborted`].
2355    aborted: Arc<AtomicBool>,
2356}
2357
2358impl std::fmt::Debug for ExecSignaler {
2359    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2360        f.debug_struct("ExecSignaler").finish_non_exhaustive()
2361    }
2362}
2363
2364impl ExecSignaler {
2365    /// Send a signal to the in-guest process. The agent forwards it
2366    /// via `kill(child_pid, signum)`. Returns `Ok(())` if the frame
2367    /// reached the agent; the result of the actual kill is async
2368    /// (the agent sends an EXIT frame when the process dies).
2369    ///
2370    /// Common signals: `libc::SIGTERM` (graceful), `libc::SIGINT`
2371    /// (Ctrl+C-style), `libc::SIGKILL` (forced). See [`Self::kill`]
2372    /// for the SIGKILL shortcut.
2373    pub fn signal(&self, signum: i32) -> io::Result<()> {
2374        // Mark "we signalled" BEFORE the send. Even if the send
2375        // fails (vsock congested, agent dead, etc.), the host has
2376        // committed to wanting the child dead — a subsequent EOF
2377        // should be interpreted as kill-success, not "agent
2378        // disconnected unexpectedly".
2379        self.we_signaled
2380            .store(true, std::sync::atomic::Ordering::SeqCst);
2381        let payload = [signum as u8];
2382        send_frame(&self.sock_w, FRAME_SIGNAL, &payload)
2383    }
2384
2385    /// Shortcut for `signal(libc::SIGKILL)`. Mirrors
2386    /// [`std::process::Child::kill`].
2387    pub fn kill(&self) -> io::Result<()> {
2388        self.signal(libc::SIGKILL)
2389    }
2390
2391    /// Tear down the host-side connection IMMEDIATELY — does NOT
2392    /// require the agent to be alive. Closes the underlying Unix
2393    /// socket (via `shutdown(Both)`), which wakes the demux thread
2394    /// from its blocking read and surfaces `UnexpectedEof` to the
2395    /// caller's [`ExecChild::wait`].
2396    ///
2397    /// Use this when the SIGNAL path won't work — e.g. the worker
2398    /// process died (pool shutdown) but the host-side `output()` /
2399    /// `wait()` is still blocked because EOF hasn't propagated.
2400    ///
2401    /// Safe to call from multiple threads / multiple times — the
2402    /// shutdown(2) syscall is idempotent on an already-shutdown
2403    /// socket.
2404    pub fn abort(&self) -> io::Result<()> {
2405        // Mark the teardown as local BEFORE the shutdown so the demux
2406        // thread's resulting EOF is attributed to us, not the agent.
2407        self.aborted
2408            .store(true, std::sync::atomic::Ordering::SeqCst);
2409        // Ignore "not connected" if we've already shut down.
2410        match self.interrupt_fd.shutdown(Shutdown::Both) {
2411            Ok(()) => Ok(()),
2412            Err(e) if e.kind() == io::ErrorKind::NotConnected => Ok(()),
2413            Err(e) => Err(e),
2414        }
2415    }
2416}
2417
2418/// Outcome reported by the demux thread.
2419enum DemuxExit {
2420    /// Agent sent EXIT frame. `peak_rss_kib` is `None` for older
2421    /// agents that only send the 4-byte exit_code payload, and
2422    /// `Some(kib)` for v0.2.x+ agents that include rusage.
2423    Status {
2424        code: u32,
2425        peak_rss_kib: Option<u64>,
2426    },
2427    /// Agent closed the socket without sending EXIT. Carries how much
2428    /// the agent had streamed before the close (STDOUT/STDERR frame
2429    /// count + byte count) so the caller can self-diagnose the cause:
2430    /// zero output usually means the agent never started / the VM went
2431    /// away, while output-then-EOF means a mid-stream drop (congestion).
2432    EofBeforeExit { frames: u64, bytes: u64 },
2433    /// Agent sent ERROR frame.
2434    Error(String),
2435    /// Demux I/O failed.
2436    Io(io::Error),
2437}
2438
2439impl ExecChild {
2440    /// Take ownership of the stdin handle. Calling this twice
2441    /// returns `None` the second time.
2442    pub fn stdin(&mut self) -> Option<ExecStdin> {
2443        self.stdin.take()
2444    }
2445
2446    /// Take ownership of stdout. Returns a `Read`er. Useful when
2447    /// you want to spawn your own pump thread.
2448    pub fn stdout(&mut self) -> Option<ExecStdout> {
2449        self.stdout_r.take().map(ExecStdout::from_rx)
2450    }
2451
2452    /// Take ownership of stderr. Empty in tty mode.
2453    pub fn stderr(&mut self) -> Option<ExecStderr> {
2454        self.stderr_r.take().map(ExecStderr::from_rx)
2455    }
2456
2457    /// Send a signal (SIGTERM/SIGINT/...) to the guest process.
2458    pub fn signal(&self, signum: i32) -> io::Result<()> {
2459        // Same we_signaled marking as ExecSignaler::signal — see
2460        // there for rationale.
2461        self.we_signaled
2462            .store(true, std::sync::atomic::Ordering::SeqCst);
2463        let payload = [signum as u8];
2464        send_frame(&self.sock_w, FRAME_SIGNAL, &payload)
2465    }
2466
2467    /// Hand back a [`Send + Sync + Clone`](ExecSignaler) handle for
2468    /// signaling THIS child from another thread. The returned handle
2469    /// can outlive the `ExecChild` — after the child exits, calls
2470    /// on the signaler become no-ops on the closed socket (no panic,
2471    /// no deadlock).
2472    ///
2473    /// Typical use: register the signaler in a cancellation registry,
2474    /// move the child into a wait thread, fire the registry slot
2475    /// (e.g. on user-cancel or pool-shutdown) to interrupt.
2476    ///
2477    /// Cost: one atomic refcount increment.
2478    ///
2479    /// [`Send + Sync + Clone`]: ExecSignaler
2480    pub fn signaler(&self) -> ExecSignaler {
2481        ExecSignaler {
2482            sock_w: Arc::clone(&self.sock_w),
2483            interrupt_fd: Arc::clone(&self.interrupt_fd),
2484            we_signaled: Arc::clone(&self.we_signaled),
2485            aborted: Arc::clone(&self.aborted),
2486        }
2487    }
2488
2489    /// Close the host-side socket (`shutdown(Both)`) WITHOUT waiting
2490    /// for a clean exit. The demux thread observes EOF and exits;
2491    /// a concurrent [`wait`] / [`output`] returns
2492    /// `Err(UnexpectedEof)`. Use this when you've already decided
2493    /// the child should stop right now — e.g. a pool shutdown
2494    /// killed the worker and the in-flight wait would otherwise
2495    /// hang.
2496    ///
2497    /// Equivalent to `self.signaler().abort()`. Provided here for
2498    /// the common case where you have the child handle directly.
2499    ///
2500    /// [`wait`]: Self::wait
2501    /// [`output`]: ExecBuilder::output
2502    pub fn abort(&self) -> io::Result<()> {
2503        self.aborted
2504            .store(true, std::sync::atomic::Ordering::SeqCst);
2505        match self.interrupt_fd.shutdown(Shutdown::Both) {
2506            Ok(()) => Ok(()),
2507            Err(e) if e.kind() == io::ErrorKind::NotConnected => Ok(()),
2508            Err(e) => Err(e),
2509        }
2510    }
2511
2512    /// Non-consuming poll. Returns `Ok(Some(_))` if the child has
2513    /// exited (and reaps the exit status), `Ok(None)` if it's still
2514    /// running, `Err(_)` on demux failure. Mirrors
2515    /// [`std::process::Child::try_wait`] — useful when you want to
2516    /// register the signaler with a registry AND periodically check
2517    /// completion without committing to a blocking [`wait`].
2518    ///
2519    /// [`wait`]: Self::wait
2520    pub fn try_wait(&self) -> io::Result<Option<(ExitStatus, Option<u64>)>> {
2521        match self.exit_rx.try_recv() {
2522            Ok(DemuxExit::Status { code, peak_rss_kib }) => {
2523                Ok(Some((synthesize_exit(code), peak_rss_kib)))
2524            }
2525            Ok(DemuxExit::EofBeforeExit { frames, bytes }) => {
2526                // 0.7.44+ if we sent SIGNAL before EOF, the host
2527                // explicitly asked the child to die. The missing
2528                // EXIT is a vsock-congestion wire artifact, not a
2529                // semantic loss — synthesize SIGKILL exit so the
2530                // caller observes a clean "killed by signal 9".
2531                if self.we_signaled.load(std::sync::atomic::Ordering::SeqCst) {
2532                    Ok(Some((synthesize_killed_by_signal(libc::SIGKILL), None)))
2533                } else {
2534                    Err(eof_before_exit_error(
2535                        self.aborted.load(std::sync::atomic::Ordering::SeqCst),
2536                        frames,
2537                        bytes,
2538                    ))
2539                }
2540            }
2541            Ok(DemuxExit::Error(msg)) => Err(io::Error::new(io::ErrorKind::Other, msg)),
2542            Ok(DemuxExit::Io(e)) => Err(e),
2543            Err(TryRecvError::Empty) => Ok(None),
2544            Err(TryRecvError::Disconnected) => Err(io::Error::new(
2545                io::ErrorKind::Other,
2546                "exec: demux thread died",
2547            )),
2548        }
2549    }
2550
2551    /// Resize the pty (tty mode only). No-op in pipe mode (the
2552    /// agent ignores RESIZE frames if no pty is attached).
2553    pub fn resize(&self, cols: u16, rows: u16) -> io::Result<()> {
2554        let mut payload = [0u8; 4];
2555        payload[0..2].copy_from_slice(&cols.to_be_bytes());
2556        payload[2..4].copy_from_slice(&rows.to_be_bytes());
2557        send_frame(&self.sock_w, FRAME_RESIZE, &payload)
2558    }
2559
2560    /// Block until the guest process exits. Returns the exit
2561    /// status (or a synthesized one if the agent disconnected).
2562    pub fn wait(self) -> io::Result<ExitStatus> {
2563        Ok(self.wait_with_rss()?.0)
2564    }
2565
2566    /// Like [`ExecChild::wait`] but also surfaces the peak RSS
2567    /// the agent reports (`Some(kib)` for v0.2.x+ agents,
2568    /// `None` for older ones). Used internally by
2569    /// [`ExecBuilder::output`] to populate
2570    /// [`ExecOutcome::peak_rss_kib`].
2571    pub fn wait_with_rss(mut self) -> io::Result<(ExitStatus, Option<u64>)> {
2572        // Drop stdin if the caller didn't already; the child needs
2573        // EOF to exit cleanly for any program that reads stdin.
2574        self.stdin.take();
2575
2576        let exit = self
2577            .exit_rx
2578            .recv()
2579            .map_err(|_| io::Error::new(io::ErrorKind::Other, "exec: demux thread died"))?;
2580        if let Some(h) = self.demux.take() {
2581            let _ = h.join();
2582        }
2583        match exit {
2584            DemuxExit::Status { code, peak_rss_kib } => Ok((synthesize_exit(code), peak_rss_kib)),
2585            DemuxExit::EofBeforeExit { frames, bytes } => {
2586                // See `try_wait` for rationale: signal-then-EOF
2587                // is a successful kill from the caller's POV; the
2588                // missing EXIT frame is a wire-level congestion
2589                // artifact.
2590                if self.we_signaled.load(std::sync::atomic::Ordering::SeqCst) {
2591                    Ok((synthesize_killed_by_signal(libc::SIGKILL), None))
2592                } else {
2593                    Err(eof_before_exit_error(
2594                        self.aborted.load(std::sync::atomic::Ordering::SeqCst),
2595                        frames,
2596                        bytes,
2597                    ))
2598                }
2599            }
2600            DemuxExit::Error(msg) => Err(io::Error::new(io::ErrorKind::Other, msg)),
2601            DemuxExit::Io(e) => Err(e),
2602        }
2603    }
2604}
2605
2606impl Drop for ExecChild {
2607    fn drop(&mut self) {
2608        // Two-step teardown:
2609        //   1. Send EOF on the child's stdin so well-behaved programs
2610        //      that read(0) (sh, cat, …) exit naturally.
2611        //   2. Shutdown the host-side socket. This wakes the demux
2612        //      thread from any in-flight blocking read AND signals
2613        //      to the agent that the controller is gone — the agent
2614        //      will SIGHUP the child if it hasn't exited yet.
2615        //
2616        // Step 2 is the fix for the 0.7.10 "drop doesn't propagate
2617        // to in-flight wait" bug an integrator reported: pre-0.7.10
2618        // Drop only did step 1, leaving the demux thread blocked in
2619        // a read that the agent never closed (e.g. because the
2620        // worker died via pool.shutdown() and the close-detection
2621        // didn't propagate). Step 2 makes Drop the canonical "I
2622        // give up, clean up everything" path.
2623        //
2624        // We do NOT join the demux thread here — Drop must not
2625        // block on a foreign thread. After shutdown the demux read
2626        // returns within microseconds; the thread exits on its own
2627        // and the OS reclaims its stack.
2628        self.stdin.take();
2629        // Mark the teardown local before the shutdown: if a concurrent
2630        // wait/try_wait is still racing, its resulting EofBeforeExit is
2631        // attributed to our Drop, not to an unexplained agent close.
2632        self.aborted
2633            .store(true, std::sync::atomic::Ordering::SeqCst);
2634        let _ = self.interrupt_fd.shutdown(Shutdown::Both);
2635    }
2636}
2637
2638/// Stdin handle. Bytes you write here get framed into STDIN
2639/// frames and sent to the agent, which writes them to the child's
2640/// stdin. Drop or call [`close`] for EOF.
2641///
2642/// [`close`]: ExecStdin::close
2643pub struct ExecStdin {
2644    sock_w: Arc<Mutex<UnixStream>>,
2645    closed: bool,
2646}
2647
2648impl ExecStdin {
2649    fn new(sock_w: Arc<Mutex<UnixStream>>) -> Self {
2650        Self {
2651            sock_w,
2652            closed: false,
2653        }
2654    }
2655
2656    /// Send EOF on stdin. The agent half-closes the child's stdin
2657    /// pipe; programs that read until EOF (cat, sh, ...) will
2658    /// then exit.
2659    pub fn close(mut self) -> io::Result<()> {
2660        if self.closed {
2661            return Ok(());
2662        }
2663        self.closed = true;
2664        send_frame(&self.sock_w, FRAME_STDIN, &[])
2665    }
2666}
2667
2668impl Write for ExecStdin {
2669    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
2670        if buf.is_empty() {
2671            return Ok(0);
2672        }
2673        send_frame(&self.sock_w, FRAME_STDIN, buf)?;
2674        Ok(buf.len())
2675    }
2676    fn flush(&mut self) -> io::Result<()> {
2677        Ok(())
2678    }
2679}
2680
2681impl Drop for ExecStdin {
2682    fn drop(&mut self) {
2683        if !self.closed {
2684            // Best-effort EOF; ignore errors — the socket may be
2685            // gone if the child already exited.
2686            let _ = send_frame(&self.sock_w, FRAME_STDIN, &[]);
2687        }
2688    }
2689}
2690
2691/// Stdout handle. Wraps an in-memory chunk channel; the demux
2692/// thread sends STDOUT-frame payloads into it. `Read::read` drains
2693/// whole chunks at a time, holding any tail bytes that didn't fit
2694/// in the caller's `buf` in `leftover` for the next call.
2695///
2696/// 0.7.12: moved from a Unix pipe to an mpsc channel. The pipe
2697/// version had a 16 KiB ceiling on macOS that deadlocked the agent
2698/// writer mutex once a workload produced >~48 KiB before the
2699/// caller drained.
2700pub struct ExecStdout {
2701    rx: Receiver<Vec<u8>>,
2702    /// Tail of a previously-received chunk that didn't fit in the
2703    /// caller's last `buf`. Drained first on the next `read`.
2704    leftover: Vec<u8>,
2705}
2706
2707impl ExecStdout {
2708    fn from_rx(rx: Receiver<Vec<u8>>) -> Self {
2709        Self {
2710            rx,
2711            leftover: Vec::new(),
2712        }
2713    }
2714
2715    /// Nonblocking variant for poll-style bindings. Returns:
2716    /// - `Ok(Some(n))` when bytes were copied into `buf`;
2717    /// - `Ok(None)` when no chunk is currently queued;
2718    /// - `Ok(Some(0))` on EOF.
2719    pub fn try_read(&mut self, buf: &mut [u8]) -> io::Result<Option<usize>> {
2720        chunk_channel_try_read(&self.rx, &mut self.leftover, buf)
2721    }
2722}
2723
2724impl Read for ExecStdout {
2725    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
2726        chunk_channel_read(&self.rx, &mut self.leftover, buf)
2727    }
2728}
2729
2730/// Stderr handle. Same shape as [`ExecStdout`]; empty in tty mode.
2731pub struct ExecStderr {
2732    rx: Receiver<Vec<u8>>,
2733    leftover: Vec<u8>,
2734}
2735
2736impl ExecStderr {
2737    fn from_rx(rx: Receiver<Vec<u8>>) -> Self {
2738        Self {
2739            rx,
2740            leftover: Vec::new(),
2741        }
2742    }
2743
2744    /// Nonblocking variant for poll-style bindings. See [`ExecStdout::try_read`].
2745    pub fn try_read(&mut self, buf: &mut [u8]) -> io::Result<Option<usize>> {
2746        chunk_channel_try_read(&self.rx, &mut self.leftover, buf)
2747    }
2748}
2749
2750impl Read for ExecStderr {
2751    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
2752        chunk_channel_read(&self.rx, &mut self.leftover, buf)
2753    }
2754}
2755
2756/// Shared body for ExecStdout/ExecStderr's `Read::read`. Returns
2757/// `Ok(0)` when the sender has dropped AND no leftover remains —
2758/// that's the canonical EOF signal that lets caller loops like
2759/// `while (chunk = read(); chunk.len() > 0)` terminate.
2760fn chunk_channel_read(
2761    rx: &Receiver<Vec<u8>>,
2762    leftover: &mut Vec<u8>,
2763    buf: &mut [u8],
2764) -> io::Result<usize> {
2765    if buf.is_empty() {
2766        return Ok(0);
2767    }
2768    if leftover.is_empty() {
2769        match rx.recv() {
2770            Ok(chunk) => {
2771                if chunk.is_empty() {
2772                    // Treat an empty payload as a no-op — recurse to
2773                    // wait for the next real chunk.
2774                    return chunk_channel_read(rx, leftover, buf);
2775                }
2776                *leftover = chunk;
2777            }
2778            Err(_) => return Ok(0), // sender dropped → EOF
2779        }
2780    }
2781    let n = buf.len().min(leftover.len());
2782    buf[..n].copy_from_slice(&leftover[..n]);
2783    leftover.drain(..n);
2784    Ok(n)
2785}
2786
2787fn chunk_channel_try_read(
2788    rx: &Receiver<Vec<u8>>,
2789    leftover: &mut Vec<u8>,
2790    buf: &mut [u8],
2791) -> io::Result<Option<usize>> {
2792    if buf.is_empty() {
2793        return Ok(Some(0));
2794    }
2795    while leftover.is_empty() {
2796        match rx.try_recv() {
2797            Ok(chunk) => {
2798                if chunk.is_empty() {
2799                    continue;
2800                }
2801                *leftover = chunk;
2802            }
2803            Err(TryRecvError::Empty) => return Ok(None),
2804            Err(TryRecvError::Disconnected) => return Ok(Some(0)),
2805        }
2806    }
2807    let n = buf.len().min(leftover.len());
2808    buf[..n].copy_from_slice(&leftover[..n]);
2809    leftover.drain(..n);
2810    Ok(Some(n))
2811}
2812
2813/// Send a CONTROL frame to the in-guest exec agent and wait for
2814/// its single-frame ack. Used for `signal` (forward TERM/KILL to
2815/// the workload) and any future control actions.
2816///
2817/// Returns Ok if the agent ack'd success; Err with the agent's
2818/// reported reason otherwise. Network/socket errors surface as
2819/// `io::Error`; remote-side failures (e.g. "no workload pid yet"
2820/// during the bake-time window) surface as
2821/// `io::ErrorKind::Other` with the agent's message.
2822pub fn send_control(exec_path: &Path, action_json: &serde_json::Value) -> io::Result<()> {
2823    let _ = send_control_with_ack(exec_path, action_json, None)?;
2824    Ok(())
2825}
2826
2827/// Like [`send_control`] but returns the parsed ack JSON for
2828/// callers that need ack fields (e.g. `read_file` reads the
2829/// `data_b64` field). `read_timeout` overrides the default 5s
2830/// receive timeout — pass a generous value when the agent might
2831/// take a while (large file reads).
2832///
2833/// Public so that `@supermachine/core` (the napi binding crate
2834/// in `npm/supermachine-core/`) can use the same wire-format
2835/// helper its Rust sibling does, without duplicating JSON-action
2836/// shapes. Embedders outside this workspace should prefer the
2837/// stable wrappers on [`crate::Vm`] (e.g. [`crate::Vm::read_file`])
2838/// — the action JSON shape may evolve.
2839pub fn send_control_with_ack(
2840    exec_path: &Path,
2841    action_json: &serde_json::Value,
2842    read_timeout: Option<std::time::Duration>,
2843) -> io::Result<serde_json::Value> {
2844    use std::io::Read;
2845    let mut sock = UnixStream::connect(exec_path)?;
2846    sock.set_read_timeout(read_timeout.or(Some(std::time::Duration::from_secs(5))))?;
2847    sock.set_write_timeout(Some(std::time::Duration::from_secs(5)))?;
2848    // Serialize the JSON body once.
2849    let body = serde_json::to_vec(action_json)
2850        .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, format!("encode CONTROL: {e}")))?;
2851    let mut header = [0u8; 5];
2852    header[0] = FRAME_CONTROL;
2853    header[1..5].copy_from_slice(&(body.len() as u32).to_be_bytes());
2854    sock.write_all(&header)?;
2855    if !body.is_empty() {
2856        sock.write_all(&body)?;
2857    }
2858
2859    // Read the ack frame. Bumped to 16 MiB to accommodate
2860    // read_file responses (base64-encoded ≈ 12 MiB raw bytes).
2861    let mut ack_hdr = [0u8; 5];
2862    sock.read_exact(&mut ack_hdr)?;
2863    let kind = ack_hdr[0];
2864    let len = u32::from_be_bytes([ack_hdr[1], ack_hdr[2], ack_hdr[3], ack_hdr[4]]) as usize;
2865    if len > 16 * 1024 * 1024 {
2866        return Err(io::Error::new(
2867            io::ErrorKind::InvalidData,
2868            format!("CONTROL ack frame too large: {len}"),
2869        ));
2870    }
2871    let mut ack_body = vec![0u8; len];
2872    if !ack_body.is_empty() {
2873        sock.read_exact(&mut ack_body)?;
2874    }
2875
2876    if kind != FRAME_CONTROL {
2877        return Err(io::Error::new(
2878            io::ErrorKind::InvalidData,
2879            format!("expected CONTROL ack frame, got {kind:#x}"),
2880        ));
2881    }
2882    let ack: serde_json::Value = serde_json::from_slice(&ack_body).map_err(|e| {
2883        io::Error::new(io::ErrorKind::InvalidData, format!("CONTROL ack JSON: {e}"))
2884    })?;
2885    if ack.get("ok").and_then(|v| v.as_bool()) == Some(true) {
2886        return Ok(ack);
2887    }
2888    let msg = ack
2889        .get("error")
2890        .and_then(|v| v.as_str())
2891        .unwrap_or("CONTROL: agent reported failure")
2892        .to_owned();
2893    Err(io::Error::new(io::ErrorKind::Other, msg))
2894}
2895
2896// ---------- spawn ----------
2897
2898fn spawn(builder: ExecBuilder) -> io::Result<ExecChild> {
2899    let sock = UnixStream::connect(&builder.exec_path)?;
2900    let sock_r = sock.try_clone()?;
2901    // Third fd referencing the same socket, retained for the
2902    // host-side abort path (see ExecChild::interrupt_fd). Lives in
2903    // an Arc so it can be cheaply cloned into ExecSignaler handles
2904    // — Arc<UnixStream> is fine because UnixStream is Send + Sync.
2905    let interrupt_fd = Arc::new(sock.try_clone()?);
2906    let sock_w = Arc::new(Mutex::new(sock));
2907
2908    // Send REQUEST.
2909    let stage_payload: Vec<StageFilePayload> = builder
2910        .stage_files
2911        .iter()
2912        .map(|s| StageFilePayload {
2913            path: &s.path,
2914            data_b64: crate::api::b64_encode(&s.data),
2915            mode: s.mode,
2916        })
2917        .collect();
2918    let payload = RequestPayload {
2919        argv: &builder.argv,
2920        env: &builder.env,
2921        cwd: builder.cwd.as_deref(),
2922        tty: builder.tty,
2923        cols: builder.cols,
2924        rows: builder.rows,
2925        stage_files: stage_payload,
2926        chain: &builder.chain,
2927    };
2928    let json = serde_json::to_vec(&payload).map_err(|e| {
2929        io::Error::new(
2930            io::ErrorKind::InvalidInput,
2931            format!("exec: encode REQUEST: {e}"),
2932        )
2933    })?;
2934    send_frame(&sock_w, FRAME_REQUEST, &json)?;
2935
2936    // In-memory chunk channels for stdout/stderr. Replaces the
2937    // pre-0.7.12 Unix pipes — those had a 16 KiB macOS buffer
2938    // ceiling that capped streaming throughput. The channel is
2939    // unbounded: workloads that produce data faster than the
2940    // caller drains will accumulate bytes in memory until either
2941    // (a) the caller catches up or (b) the agent finishes and
2942    // demux exits, draining the queue on the receiver side. Same
2943    // memory profile as `output()`'s read_to_end accumulator.
2944    let (stdout_tx, stdout_r) = channel::<Vec<u8>>();
2945    let (stderr_tx, stderr_r) = channel::<Vec<u8>>();
2946
2947    let (exit_tx, exit_rx) = channel::<DemuxExit>();
2948    let demux = thread::Builder::new()
2949        .name("supermachine-exec-demux".into())
2950        .spawn(move || {
2951            demux_loop(sock_r, stdout_tx, stderr_tx, exit_tx);
2952        })
2953        .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("exec: demux thread: {e}")))?;
2954
2955    let stdin = ExecStdin::new(sock_w.clone());
2956
2957    Ok(ExecChild {
2958        sock_w,
2959        we_signaled: Arc::new(AtomicBool::new(false)),
2960        aborted: Arc::new(AtomicBool::new(false)),
2961        interrupt_fd,
2962        stdout_r: Some(stdout_r),
2963        stderr_r: Some(stderr_r),
2964        stdin: Some(stdin),
2965        demux: Some(demux),
2966        exit_rx,
2967    })
2968}
2969
2970fn demux_loop(
2971    mut sock: UnixStream,
2972    stdout_tx: Sender<Vec<u8>>,
2973    stderr_tx: Sender<Vec<u8>>,
2974    exit_tx: Sender<DemuxExit>,
2975) {
2976    // Push STDOUT/STDERR payloads into in-memory mpsc channels.
2977    // When this function returns the senders drop, signalling EOF
2978    // to ExecStdout/ExecStderr readers (their `recv()` returns
2979    // Err → `read` returns Ok(0)).
2980    //
2981    // Track how much the agent streamed before any premature EOF so
2982    // `EofBeforeExit` can self-diagnose (no output → never started /
2983    // VM gone; output-then-EOF → mid-stream drop).
2984    let mut frames: u64 = 0;
2985    let mut bytes: u64 = 0;
2986    loop {
2987        let (kind, payload) = match read_frame(&mut sock) {
2988            Ok(f) => f,
2989            Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
2990                let _ = exit_tx.send(DemuxExit::EofBeforeExit { frames, bytes });
2991                return;
2992            }
2993            Err(e) => {
2994                let _ = exit_tx.send(DemuxExit::Io(e));
2995                return;
2996            }
2997        };
2998        match kind {
2999            FRAME_STDOUT => {
3000                frames += 1;
3001                bytes += payload.len() as u64;
3002                let _ = stdout_tx.send(payload);
3003            }
3004            FRAME_STDERR => {
3005                frames += 1;
3006                bytes += payload.len() as u64;
3007                let _ = stderr_tx.send(payload);
3008            }
3009            FRAME_EXIT => {
3010                let _ = exit_tx.send(decode_exit_payload(&payload));
3011                return;
3012            }
3013            FRAME_ERROR => {
3014                let msg = String::from_utf8_lossy(&payload).into_owned();
3015                let _ = exit_tx.send(DemuxExit::Error(msg));
3016                return;
3017            }
3018            _ => {
3019                // STDIN / RESIZE / SIGNAL / REQUEST shouldn't come
3020                // from the agent. Ignore unknown frames so the
3021                // protocol can grow additive types without
3022                // breaking older hosts.
3023            }
3024        }
3025    }
3026}
3027
3028// ---------- frame I/O ----------
3029
3030fn send_frame(sock: &Arc<Mutex<UnixStream>>, kind: u8, payload: &[u8]) -> io::Result<()> {
3031    let mut header = [0u8; 5];
3032    header[0] = kind;
3033    header[1..5].copy_from_slice(&(payload.len() as u32).to_be_bytes());
3034    let mut g = sock
3035        .lock()
3036        .map_err(|_| io::Error::new(io::ErrorKind::Other, "exec: socket mutex poisoned"))?;
3037    g.write_all(&header)?;
3038    if !payload.is_empty() {
3039        g.write_all(payload)?;
3040    }
3041    Ok(())
3042}
3043
3044fn read_frame<R: Read>(sock: &mut R) -> io::Result<(u8, Vec<u8>)> {
3045    let mut header = [0u8; 5];
3046    sock.read_exact(&mut header)?;
3047    let kind = header[0];
3048    let len = u32::from_be_bytes([header[1], header[2], header[3], header[4]]) as usize;
3049    // Ceiling generous enough that the agent can stream large
3050    // outputs without us tripping a guard, but tight enough that
3051    // a malformed length doesn't allocate gigabytes.
3052    if len > 16 * 1024 * 1024 {
3053        return Err(io::Error::new(
3054            io::ErrorKind::InvalidData,
3055            format!("exec: frame len {len} > 16 MiB"),
3056        ));
3057    }
3058    let mut payload = vec![0u8; len];
3059    if !payload.is_empty() {
3060        sock.read_exact(&mut payload)?;
3061    }
3062    Ok((kind, payload))
3063}
3064
3065// ---------- exit status synthesis ----------
3066
3067/// Structured detail for an EOF that arrived before the agent sent EXIT
3068/// (and the host did not signal the child). Wrapped inside the
3069/// `io::Error` returned by [`ExecChild::wait`] / [`ExecBuilder::output`]
3070/// (kind `UnexpectedEof`), so callers can `downcast_ref::<ExecEof>()` to
3071/// branch reliably instead of string-matching the message. The `Display`
3072/// text is the same self-diagnosing message as before, so existing
3073/// `to_string()` / log output is unchanged.
3074#[derive(Debug, Clone, Copy)]
3075pub struct ExecEof {
3076    /// The host tore the connection down locally (`abort()` / `Drop`) —
3077    /// not an agent-side failure.
3078    pub aborted: bool,
3079    /// STDOUT/STDERR frames the agent streamed before the close.
3080    pub frames: u64,
3081    /// Bytes the agent streamed before the close.
3082    pub bytes: u64,
3083}
3084
3085impl ExecEof {
3086    /// True when this is a connection-SETUP failure: the agent closed
3087    /// before producing ANY output or EXIT, and we didn't abort locally.
3088    /// The workload had not yet streamed anything — re-establishing the
3089    /// connection and re-sending the request is the *only* class of EOF
3090    /// that may be safely retried (mid-stream drops, where output was
3091    /// already delivered, must not be — a retry could double-execute a
3092    /// side-effecting command). Even here, retry is at-least-once: see
3093    /// [`ExecBuilder::output_resilient`].
3094    pub fn is_setup_failure(&self) -> bool {
3095        !self.aborted && self.frames == 0 && self.bytes == 0
3096    }
3097}
3098
3099impl std::fmt::Display for ExecEof {
3100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3101        if self.aborted {
3102            f.write_str(
3103                "exec: connection closed before EXIT because the host aborted it locally \
3104                 (abort()/Drop) — not an agent-side failure",
3105            )
3106        } else if self.frames == 0 && self.bytes == 0 {
3107            f.write_str(
3108                "exec: agent closed the connection before producing any output or EXIT — the \
3109                 in-guest agent likely failed to start the process, or the VM went away \
3110                 (guest panic / reboot / OOM-kill)",
3111            )
3112        } else {
3113            write!(
3114                f,
3115                "exec: agent closed the connection after {} output frame(s) / {} byte(s) \
3116                 without sending EXIT — the channel dropped mid-stream (the host may be \
3117                 oversubscribed; set SUPERMACHINE_WORKER_LOG_FILE to capture worker logs \
3118                 for a post-mortem)",
3119                self.frames, self.bytes
3120            )
3121        }
3122    }
3123}
3124
3125impl std::error::Error for ExecEof {}
3126
3127/// Build the self-diagnosing error for a premature EOF (see [`ExecEof`]).
3128/// Returns an `io::Error` of kind `UnexpectedEof` wrapping the structured
3129/// detail, so callers can both read the message and `downcast_ref` it.
3130fn eof_before_exit_error(aborted: bool, frames: u64, bytes: u64) -> io::Error {
3131    io::Error::new(
3132        io::ErrorKind::UnexpectedEof,
3133        ExecEof {
3134            aborted,
3135            frames,
3136            bytes,
3137        },
3138    )
3139}
3140
3141/// Decode an EXIT frame payload: a 4-byte BE exit code, optionally
3142/// followed by an 8-byte BE peak-RSS (KiB). A payload SHORTER than the
3143/// 4-byte code is a truncated/corrupt frame and is reported as an
3144/// `Error` — NOT silently as a clean exit-0, which would hide a failed
3145/// workload from a caller that relies on the exit code. The agent fully
3146/// controls this frame, so a buggy/hostile/congestion-truncated EXIT
3147/// must fail loud rather than masquerade as success.
3148fn decode_exit_payload(payload: &[u8]) -> DemuxExit {
3149    if payload.len() < 4 {
3150        return DemuxExit::Error(format!(
3151            "truncated EXIT frame: {} byte(s), need >= 4 for the exit code",
3152            payload.len()
3153        ));
3154    }
3155    let code = u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]);
3156    let peak_rss_kib = if payload.len() >= 12 {
3157        Some(u64::from_be_bytes([
3158            payload[4],
3159            payload[5],
3160            payload[6],
3161            payload[7],
3162            payload[8],
3163            payload[9],
3164            payload[10],
3165            payload[11],
3166        ]))
3167    } else {
3168        None
3169    };
3170    DemuxExit::Status { code, peak_rss_kib }
3171}
3172
3173#[cfg(unix)]
3174fn synthesize_exit(code: u32) -> ExitStatus {
3175    // ExitStatus on unix wraps a wait status int. The agent
3176    // already collapsed signal/exit into a u32 (signal -> 128+sig).
3177    // We re-encode as if WIFEXITED with that exit code: high byte
3178    // = exit code, low byte = 0. Calling `.code()` then returns
3179    // `Some(code)` which matches what callers expect.
3180    use std::os::unix::process::ExitStatusExt;
3181    ExitStatus::from_raw(((code as i32) & 0xff) << 8)
3182}
3183
3184/// 0.7.44+ synthesize a "killed by signal N" ExitStatus when the
3185/// agent disconnects post-SIGNAL without sending an EXIT frame
3186/// (vsock data-channel congestion → EXIT can't queue before host
3187/// watchdog's ABORT_GRACE force-closes the socket).
3188///
3189/// Encoding: WIFSIGNALED uses the LOW 7 bits of the wait status
3190/// for the signal number, with bit 7 cleared (no core dump bit
3191/// set). `.code()` returns `None`, `.signal()` returns `Some(N)`,
3192/// and `process::Output::code()`'s legacy mapping returns
3193/// `Some(128 + N)` — matching the convention every test in the
3194/// suite uses (`expect(exitCode).toBe(137)` for SIGKILL).
3195#[cfg(unix)]
3196fn synthesize_killed_by_signal(sig: i32) -> ExitStatus {
3197    use std::os::unix::process::ExitStatusExt;
3198    // WIFSIGNALED wstatus: low 7 bits = signal, bit 7 = core dump.
3199    // Bit 8 (the WIFEXITED marker) must be CLEAR; bits 9-15 unused.
3200    ExitStatus::from_raw(sig & 0x7f)
3201}
3202
3203// =====================================================================
3204// Tests — focused on the 0.7.10 API additions (ExecSignaler, abort,
3205// try_wait, output_with_cancel). Tests that need a real VM live in
3206// the integration suite under npm/supermachine-core/__test__/.
3207// =====================================================================
3208#[cfg(test)]
3209mod tests {
3210    use super::*;
3211    use std::os::unix::net::UnixListener;
3212    use std::os::unix::process::ExitStatusExt;
3213    use std::path::PathBuf;
3214
3215    #[test]
3216    fn decode_exit_payload_reads_code_and_optional_rss() {
3217        // 4-byte code only.
3218        match decode_exit_payload(&[0, 0, 0, 42]) {
3219            DemuxExit::Status { code, peak_rss_kib } => {
3220                assert_eq!(code, 42);
3221                assert_eq!(peak_rss_kib, None);
3222            }
3223            _ => panic!("expected Status"),
3224        }
3225        // 12-byte: code + peak RSS.
3226        let mut p = vec![0, 0, 0, 7];
3227        p.extend_from_slice(&1234u64.to_be_bytes());
3228        match decode_exit_payload(&p) {
3229            DemuxExit::Status { code, peak_rss_kib } => {
3230                assert_eq!(code, 7);
3231                assert_eq!(peak_rss_kib, Some(1234));
3232            }
3233            _ => panic!("expected Status"),
3234        }
3235        // 4..11 bytes: code present, partial RSS tolerated as None.
3236        match decode_exit_payload(&[0, 0, 1, 0, 0xaa, 0xbb]) {
3237            DemuxExit::Status { code, peak_rss_kib } => {
3238                assert_eq!(code, 256);
3239                assert_eq!(peak_rss_kib, None);
3240            }
3241            _ => panic!("expected Status"),
3242        }
3243    }
3244
3245    #[test]
3246    fn decode_exit_payload_truncated_is_error_not_exit_zero() {
3247        // The fix: a sub-4-byte EXIT frame must surface as an error, NOT
3248        // silently report a clean exit-0 that would mask a failed command.
3249        for short in [&[][..], &[0][..], &[0, 0][..], &[0, 0, 0][..]] {
3250            match decode_exit_payload(short) {
3251                DemuxExit::Error(msg) => assert!(msg.contains("truncated EXIT"), "{msg}"),
3252                _ => panic!("{}-byte EXIT frame must be an Error", short.len()),
3253            }
3254        }
3255    }
3256
3257    #[test]
3258    fn synthesize_exit_round_trips_exit_code() {
3259        for c in [0u32, 1, 42, 255] {
3260            assert_eq!(synthesize_exit(c).code(), Some(c as i32), "code {c}");
3261        }
3262        assert!(synthesize_exit(0).signal().is_none());
3263    }
3264
3265    #[test]
3266    fn synthesize_killed_by_signal_reports_signal_not_code() {
3267        // SIGKILL (9) / SIGTERM (15): `.signal()` set, `.code()` None.
3268        assert_eq!(synthesize_killed_by_signal(9).signal(), Some(9));
3269        assert_eq!(synthesize_killed_by_signal(9).code(), None);
3270        assert_eq!(synthesize_killed_by_signal(15).signal(), Some(15));
3271    }
3272
3273    #[test]
3274    fn eof_before_exit_error_is_self_diagnosing() {
3275        // Every branch is an UnexpectedEof (callers match on .kind()),
3276        // but the message must point at the right cause.
3277        let aborted = eof_before_exit_error(true, 0, 0);
3278        assert_eq!(aborted.kind(), io::ErrorKind::UnexpectedEof);
3279        assert!(aborted.to_string().contains("aborted it locally"));
3280
3281        let never_started = eof_before_exit_error(false, 0, 0);
3282        assert_eq!(never_started.kind(), io::ErrorKind::UnexpectedEof);
3283        assert!(never_started
3284            .to_string()
3285            .contains("before producing any output"));
3286
3287        let mid_stream = eof_before_exit_error(false, 7, 4096);
3288        assert_eq!(mid_stream.kind(), io::ErrorKind::UnexpectedEof);
3289        let msg = mid_stream.to_string();
3290        assert!(
3291            msg.contains("7 output frame"),
3292            "should report frame count: {msg}"
3293        );
3294        assert!(msg.contains("4096 byte"), "should report byte count: {msg}");
3295        assert!(
3296            msg.contains("mid-stream"),
3297            "should flag a mid-stream drop: {msg}"
3298        );
3299
3300        // `aborted` takes priority even if some output was seen — a local
3301        // teardown is a local teardown regardless of prior bytes.
3302        let aborted_after_output = eof_before_exit_error(true, 7, 4096);
3303        assert!(aborted_after_output
3304            .to_string()
3305            .contains("aborted it locally"));
3306    }
3307
3308    #[test]
3309    fn exec_eof_is_structured_and_downcastable() {
3310        // The retry predicate must be reliable structured data, not a
3311        // brittle string match. Only the "never produced output, not
3312        // aborted" case is a retryable setup failure.
3313        let setup = eof_before_exit_error(false, 0, 0);
3314        let eof = setup
3315            .get_ref()
3316            .and_then(|e| e.downcast_ref::<ExecEof>())
3317            .expect("UnexpectedEof must wrap a downcastable ExecEof");
3318        assert!(
3319            eof.is_setup_failure(),
3320            "no output + not aborted ⇒ retryable setup failure"
3321        );
3322
3323        // Mid-stream drop: output already delivered ⇒ NOT retryable.
3324        let mid = eof_before_exit_error(false, 3, 100);
3325        let mid_eof = mid.get_ref().unwrap().downcast_ref::<ExecEof>().unwrap();
3326        assert!(
3327            !mid_eof.is_setup_failure(),
3328            "mid-stream drop must never be retried"
3329        );
3330
3331        // Local abort with zero output: NOT a setup failure (intentional).
3332        let aborted = eof_before_exit_error(true, 0, 0);
3333        let ab_eof = aborted
3334            .get_ref()
3335            .unwrap()
3336            .downcast_ref::<ExecEof>()
3337            .unwrap();
3338        assert!(
3339            !ab_eof.is_setup_failure(),
3340            "local abort is not an agent setup failure"
3341        );
3342
3343        // `output_resilient(0)` is exactly `output()` — no retry behaviour
3344        // to reason about for the default.
3345        // (Behavioural retry needs a live agent; covered by the napi suite.)
3346    }
3347
3348    /// Compile-time bound check: ExecSignaler must be Send + Sync +
3349    /// Clone. If a future refactor accidentally adds a !Sync field
3350    /// to ExecSignaler this test fails to build, surfacing the
3351    /// regression as a compile error rather than a runtime
3352    /// "your watchdog can't see the signaler" puzzle.
3353    #[test]
3354    fn exec_signaler_is_send_sync_clone() {
3355        fn assert_send<T: Send>() {}
3356        fn assert_sync<T: Sync>() {}
3357        fn assert_clone<T: Clone>() {}
3358        assert_send::<ExecSignaler>();
3359        assert_sync::<ExecSignaler>();
3360        assert_clone::<ExecSignaler>();
3361    }
3362
3363    /// Compile-time bound check: ExecChild stays Send (the existing
3364    /// guarantee). Sync was never offered (exit_rx is mpsc::Receiver,
3365    /// which is !Sync); embedders who need cross-thread control go
3366    /// through ExecSignaler.
3367    #[test]
3368    fn exec_child_is_send() {
3369        fn assert_send<T: Send>() {}
3370        assert_send::<ExecChild>();
3371    }
3372
3373    /// Spin up a UnixListener that accepts a connection, swallows the
3374    /// REQUEST frame, then HANGS — never sends EXIT. Without
3375    /// `abort()`, a `wait()` on the resulting child blocks forever
3376    /// (or until the test harness kills the process). With
3377    /// `abort()`, the host-side shutdown wakes the demux thread,
3378    /// `wait()` returns Err(UnexpectedEof) within milliseconds.
3379    ///
3380    /// This is the unit-test analogue of the integrator's "drop the
3381    /// Vm to interrupt an exec doesn't work" bug — pre-fix the
3382    /// blocking wait wouldn't unblock; post-fix it does.
3383    /// A short, unique Unix-socket directory under `/tmp`. macOS caps
3384    /// `sockaddr_un.sun_path` at 104 bytes; building the socket path
3385    /// from `std::env::temp_dir()` (a long `$TMPDIR` like
3386    /// `/var/folders/…`) plus a descriptive subdir name plus
3387    /// `exec.sock` overflows that → `bind()` fails with ENAMETOOLONG.
3388    /// Anchoring under `/tmp` with a compact name keeps the path well
3389    /// under the limit regardless of `$TMPDIR`. The atomic seq gives
3390    /// per-process uniqueness without a long nanosecond suffix.
3391    fn short_sock_dir() -> PathBuf {
3392        use std::sync::atomic::{AtomicU64, Ordering};
3393        static SEQ: AtomicU64 = AtomicU64::new(0);
3394        let dir = PathBuf::from("/tmp").join(format!(
3395            "sm-exec-{}-{}",
3396            std::process::id(),
3397            SEQ.fetch_add(1, Ordering::Relaxed)
3398        ));
3399        std::fs::create_dir_all(&dir).unwrap();
3400        dir
3401    }
3402
3403    #[test]
3404    fn signaler_abort_unblocks_blocked_wait() {
3405        let dir = short_sock_dir();
3406        let sock_path: PathBuf = dir.join("exec.sock");
3407        let listener = UnixListener::bind(&sock_path).unwrap();
3408        // Accept-and-park thread: the moment a client connects, we
3409        // sleep "forever" (= 60s, the test's outer timeout) without
3410        // ever sending bytes back. The post-fix abort path is what
3411        // unblocks the client's wait.
3412        let listener_handle = thread::spawn(move || {
3413            if let Ok((mut conn, _addr)) = listener.accept() {
3414                // Consume the REQUEST frame so the client's send
3415                // doesn't block. Then hang.
3416                let mut hdr = [0u8; 5];
3417                if conn.read_exact(&mut hdr).is_ok() {
3418                    let len = u32::from_be_bytes([hdr[1], hdr[2], hdr[3], hdr[4]]) as usize;
3419                    let mut payload = vec![0u8; len];
3420                    let _ = conn.read_exact(&mut payload);
3421                }
3422                // Park forever (or until the connection drops).
3423                thread::sleep(Duration::from_secs(60));
3424            }
3425        });
3426
3427        // Spawn a child against the hung listener.
3428        let builder = ExecBuilder::new(sock_path.clone()).argv(["true"]);
3429        let child = builder.spawn().expect("spawn against listener");
3430        let signaler = child.signaler();
3431
3432        // From another thread, abort after a small delay.
3433        let abort_handle = thread::spawn(move || {
3434            thread::sleep(Duration::from_millis(50));
3435            signaler.abort().expect("abort must succeed");
3436        });
3437
3438        // Wait. Pre-fix this hangs (until the listener's 60s sleep
3439        // ends or the test runner times out). Post-fix it returns
3440        // Err(UnexpectedEof) within ~50ms + RTT.
3441        let t0 = Instant::now();
3442        let res = child.wait();
3443        let elapsed = t0.elapsed();
3444        let _ = abort_handle.join();
3445        // Don't join the listener thread — it's parked.
3446
3447        assert!(
3448            elapsed < Duration::from_secs(5),
3449            "wait must unblock quickly after abort; took {:?}",
3450            elapsed
3451        );
3452        assert!(
3453            res.is_err(),
3454            "wait after abort must return Err (the agent didn't send EXIT); got {:?}",
3455            res.as_ref().ok()
3456        );
3457        let err = res.unwrap_err();
3458        assert_eq!(
3459            err.kind(),
3460            io::ErrorKind::UnexpectedEof,
3461            "expected UnexpectedEof, got {:?}: {err}",
3462            err.kind()
3463        );
3464
3465        // Cleanup.
3466        let _ = std::fs::remove_file(&sock_path);
3467        let _ = std::fs::remove_dir(&dir);
3468        // listener thread will be reaped when its sleep ends; in
3469        // test environment it gets killed on process exit.
3470        std::mem::forget(listener_handle);
3471    }
3472
3473    /// `try_wait()` returns None while the child is running, Some
3474    /// after it exits (or the connection is aborted). Mirrors
3475    /// std::process::Child::try_wait's contract.
3476    #[test]
3477    fn try_wait_returns_none_then_some_after_abort() {
3478        let dir = short_sock_dir();
3479        let sock_path: PathBuf = dir.join("exec.sock");
3480        let listener = UnixListener::bind(&sock_path).unwrap();
3481        let listener_handle = thread::spawn(move || {
3482            if let Ok((mut conn, _)) = listener.accept() {
3483                let mut hdr = [0u8; 5];
3484                if conn.read_exact(&mut hdr).is_ok() {
3485                    let len = u32::from_be_bytes([hdr[1], hdr[2], hdr[3], hdr[4]]) as usize;
3486                    let mut payload = vec![0u8; len];
3487                    let _ = conn.read_exact(&mut payload);
3488                }
3489                thread::sleep(Duration::from_secs(60));
3490            }
3491        });
3492
3493        let builder = ExecBuilder::new(sock_path.clone()).argv(["true"]);
3494        let child = builder.spawn().expect("spawn");
3495
3496        // While the listener parks (no EXIT sent), try_wait sees
3497        // nothing.
3498        assert!(
3499            child.try_wait().expect("try_wait pre-exit").is_none(),
3500            "try_wait must return None while child is running"
3501        );
3502
3503        // Abort the connection. The demux thread observes EOF and
3504        // posts DemuxExit::EofBeforeExit on exit_rx. try_wait then
3505        // surfaces that as an Err.
3506        child.abort().expect("abort");
3507
3508        // Give the demux thread a moment to observe shutdown and
3509        // post to exit_rx. Tight bound — actual latency is <1ms in
3510        // practice.
3511        let t0 = Instant::now();
3512        let result = loop {
3513            match child.try_wait() {
3514                Ok(Some(_)) => break Ok(()), // unexpected but tolerable
3515                Ok(None) => {
3516                    if t0.elapsed() > Duration::from_secs(2) {
3517                        panic!("try_wait never observed post-abort completion");
3518                    }
3519                    thread::sleep(Duration::from_millis(5));
3520                }
3521                Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
3522                    break Err(e);
3523                }
3524                Err(e) => panic!("unexpected try_wait error: {e}"),
3525            }
3526        };
3527        // We expect the Err path (no EXIT was sent).
3528        assert!(result.is_err(), "try_wait must surface UnexpectedEof");
3529
3530        let _ = std::fs::remove_file(&sock_path);
3531        let _ = std::fs::remove_dir(&dir);
3532        std::mem::forget(listener_handle);
3533    }
3534
3535    /// Cloning a signaler then calling abort on the clone affects
3536    /// the original ExecChild. Verifies the Arc-sharing is
3537    /// connected end-to-end.
3538    #[test]
3539    fn signaler_clone_shares_underlying_socket() {
3540        let dir = short_sock_dir();
3541        let sock_path: PathBuf = dir.join("exec.sock");
3542        let listener = UnixListener::bind(&sock_path).unwrap();
3543        let listener_handle = thread::spawn(move || {
3544            if let Ok((mut conn, _)) = listener.accept() {
3545                let mut hdr = [0u8; 5];
3546                if conn.read_exact(&mut hdr).is_ok() {
3547                    let len = u32::from_be_bytes([hdr[1], hdr[2], hdr[3], hdr[4]]) as usize;
3548                    let mut payload = vec![0u8; len];
3549                    let _ = conn.read_exact(&mut payload);
3550                }
3551                thread::sleep(Duration::from_secs(60));
3552            }
3553        });
3554
3555        let builder = ExecBuilder::new(sock_path.clone()).argv(["true"]);
3556        let child = builder.spawn().expect("spawn");
3557        let s1 = child.signaler();
3558        let s2 = s1.clone(); // Arc clone — same underlying socket.
3559        drop(s1); // Original goes away; abort from the clone still works.
3560
3561        thread::spawn(move || {
3562            thread::sleep(Duration::from_millis(50));
3563            let _ = s2.abort();
3564        });
3565
3566        let res = child.wait();
3567        assert!(res.is_err(), "cloned signaler's abort must unblock wait");
3568
3569        let _ = std::fs::remove_file(&sock_path);
3570        let _ = std::fs::remove_dir(&dir);
3571        std::mem::forget(listener_handle);
3572    }
3573}
3574
3575// Property-based hardening of the exec wire framing (`send_frame` /
3576// `read_frame`, the 1-byte-type + 4-byte-BE-len + payload protocol the
3577// host and the guest agent both speak). Two properties:
3578//
3579//   1. Roundtrip: anything send_frame writes, read_frame reads back
3580//      byte-for-byte (kind + payload), for any kind and any payload up
3581//      to the frame size cap.
3582//   2. Garbage tolerance: read_frame fed ARBITRARY bytes followed by a
3583//      peer close never panics and never tries to allocate past the
3584//      16 MiB ceiling — it returns Ok or a clean Err. This is the
3585//      hostile-input contract; a missing length cap here would be a
3586//      remote OOM from a compromised/buggy guest agent.
3587#[cfg(test)]
3588mod frame_proptests {
3589    use super::{read_frame, send_frame};
3590    use proptest::prelude::*;
3591    use std::io::Write;
3592    use std::os::unix::net::UnixStream;
3593    use std::sync::{Arc, Mutex};
3594
3595    proptest! {
3596        #![proptest_config(ProptestConfig::with_cases(512))]
3597
3598        /// send_frame → read_frame is a lossless roundtrip for any
3599        /// (kind, payload). Payload kept under the socket buffer so the
3600        /// single-threaded write can't deadlock against the read.
3601        #[test]
3602        fn frame_roundtrips(kind in any::<u8>(), payload in prop::collection::vec(any::<u8>(), 0..8192)) {
3603            let (tx, mut rx) = UnixStream::pair().unwrap();
3604            let tx = Arc::new(Mutex::new(tx));
3605            send_frame(&tx, kind, &payload).unwrap();
3606            let (got_kind, got_payload) = read_frame(&mut rx).unwrap();
3607            prop_assert_eq!(got_kind, kind);
3608            prop_assert_eq!(got_payload, payload);
3609        }
3610
3611        /// read_frame must survive arbitrary bytes + peer close: no
3612        /// panic, no over-cap allocation, no hang. The writer closes
3613        /// after dumping the bytes, so a length field that over-promises
3614        /// resolves to a clean UnexpectedEof rather than blocking.
3615        #[test]
3616        fn read_frame_tolerates_garbage(raw in prop::collection::vec(any::<u8>(), 0..512)) {
3617            let (mut tx, mut rx) = UnixStream::pair().unwrap();
3618            tx.write_all(&raw).ok();
3619            drop(tx); // EOF — unblocks a read that out-runs the bytes.
3620            // The contract is "doesn't panic / doesn't hang / doesn't
3621            // honor a >16 MiB length". Result may be Ok or Err.
3622            if let Ok((_kind, payload)) = read_frame(&mut rx) {
3623                prop_assert!(payload.len() <= 16 * 1024 * 1024);
3624            }
3625        }
3626    }
3627
3628    const CAP: usize = 16 * 1024 * 1024;
3629
3630    /// A length field of exactly the cap is accepted; one byte over is
3631    /// rejected with InvalidData BEFORE any payload allocation. This
3632    /// pins the precise boundary of the remote-OOM guard.
3633    #[test]
3634    fn read_frame_length_cap_boundary() {
3635        use std::io::ErrorKind;
3636
3637        // cap + 1 → rejected immediately on the header (no payload sent).
3638        let (mut tx, mut rx) = UnixStream::pair().unwrap();
3639        let mut hdr = [0u8; 5];
3640        hdr[0] = 1; // kind
3641        hdr[1..5].copy_from_slice(&((CAP + 1) as u32).to_be_bytes());
3642        tx.write_all(&hdr).unwrap();
3643        drop(tx);
3644        let err = read_frame(&mut rx).unwrap_err();
3645        assert_eq!(
3646            err.kind(),
3647            ErrorKind::InvalidData,
3648            "over-cap must be InvalidData"
3649        );
3650        assert!(err.to_string().contains("16 MiB"), "got: {err}");
3651
3652        // Exactly cap → accepted. Write from a thread since a 16 MiB
3653        // payload exceeds the socket buffer and would deadlock a
3654        // single-threaded write against the read.
3655        let (tx, mut rx) = UnixStream::pair().unwrap();
3656        let tx = Arc::new(Mutex::new(tx));
3657        let writer = std::thread::spawn(move || {
3658            send_frame(&tx, 7, &vec![0xabu8; CAP]).unwrap();
3659        });
3660        let (kind, payload) = read_frame(&mut rx).unwrap();
3661        writer.join().unwrap();
3662        assert_eq!(kind, 7);
3663        assert_eq!(payload.len(), CAP);
3664        assert!(payload.iter().all(|&b| b == 0xab));
3665    }
3666}