Skip to main content

solari/
commands.rs

1//! `commands` namespace: run / start / stdin / kill over the control WS, plus
2//! the one-shot REST `/exec` fast path. Mirrors the `commands` surface + the
3//! `runCommand`/`startCommand` internals of `handle.ts`.
4
5use std::sync::Arc;
6
7use base64::Engine;
8use serde_json::{Map, Value};
9use tokio::sync::mpsc;
10
11use crate::channel::{AsyncFrame, ControlChannel};
12use crate::client::Sandbox;
13use crate::error::{is_route_unavailable, SolariError};
14use crate::http::encode_uri_component;
15use crate::types::CommandResult;
16
17/// A streamed-output callback (`stdout`/`stderr`). `Fn(&str)`.
18pub type OutputCallback = Arc<dyn Fn(&str) + Send + Sync>;
19
20/// Options for `commands.run` / `commands.start`.
21#[derive(Default, Clone)]
22pub struct RunOptions {
23    /// argv tail (the guest runs `cmd` with these, NOT via a shell).
24    pub args: Option<Vec<String>>,
25    pub cwd: Option<String>,
26    pub env: Option<std::collections::HashMap<String, String>>,
27    pub user: Option<String>,
28    pub timeout_ms: Option<u64>,
29    /// Run detached: returns immediately, output still streams via callbacks.
30    pub background: bool,
31    pub on_stdout: Option<OutputCallback>,
32    pub on_stderr: Option<OutputCallback>,
33}
34
35impl RunOptions {
36    pub fn new() -> Self {
37        Self::default()
38    }
39    pub fn args<I, S>(mut self, args: I) -> Self
40    where
41        I: IntoIterator<Item = S>,
42        S: Into<String>,
43    {
44        self.args = Some(args.into_iter().map(Into::into).collect());
45        self
46    }
47    pub fn cwd(mut self, cwd: impl Into<String>) -> Self {
48        self.cwd = Some(cwd.into());
49        self
50    }
51}
52
53/// A started command handle (`commands.start`).
54pub struct CommandHandle {
55    pub cmd_id: String,
56    channel: Arc<ControlChannel>,
57    rx: mpsc::UnboundedReceiver<AsyncFrame>,
58    on_stdout: Option<OutputCallback>,
59    on_stderr: Option<OutputCallback>,
60}
61
62impl CommandHandle {
63    /// Write bytes to the command's stdin.
64    pub async fn stdin(&self, data: impl AsRef<[u8]>) -> Result<(), SolariError> {
65        let b64 = base64::engine::general_purpose::STANDARD.encode(data.as_ref());
66        let mut p = Map::new();
67        p.insert("cmdId".into(), Value::String(self.cmd_id.clone()));
68        p.insert("base64".into(), Value::String(b64));
69        self.channel.call("cmd.stdin", Value::Object(p)).await.map(|_| ())
70    }
71
72    /// Send a signal (default SIGTERM guest-side).
73    pub async fn kill(&self, signal: Option<i64>) -> Result<(), SolariError> {
74        let mut p = Map::new();
75        p.insert("cmdId".into(), Value::String(self.cmd_id.clone()));
76        if let Some(s) = signal {
77            p.insert("signal".into(), Value::from(s));
78        }
79        self.channel.call("cmd.kill", Value::Object(p)).await.map(|_| ())
80    }
81
82    /// Wait for the command to exit, accumulating stdout/stderr.
83    pub async fn wait(self) -> Result<CommandResult, SolariError> {
84        self.wait_deadline(None).await
85    }
86
87    /// `wait` with an optional deadline. When it elapses the command is killed (SIGKILL) and the
88    /// result is exit -1 with the output so far, the same result the guest's own `/exec` timeout gives.
89    pub(crate) async fn wait_deadline(
90        mut self,
91        deadline: Option<tokio::time::Instant>,
92    ) -> Result<CommandResult, SolariError> {
93        let mut stdout = String::new();
94        let mut stderr = String::new();
95        let mut out_dec = Utf8Stream::default();
96        let mut err_dec = Utf8Stream::default();
97        loop {
98            let next = match deadline {
99                Some(d) => match tokio::time::timeout_at(d, self.rx.recv()).await {
100                    Ok(frame) => frame,
101                    Err(_) => {
102                        let _ = self.kill(Some(9)).await;
103                        self.channel.unregister_command(&self.cmd_id);
104                        return Ok(CommandResult { exit_code: -1, stdout, stderr, timed_out: true });
105                    }
106                },
107                None => self.rx.recv().await,
108            };
109            let Some(frame) = next else { break };
110            match frame.typ.as_str() {
111                "cmd.data" => {
112                    let is_err = frame.stream.as_deref() == Some("stderr");
113                    // One streaming decoder per stream: a multi-byte character split across two frames must come out
114                    // whole, not as U+FFFD at each end.
115                    let bytes = frame.base64.as_ref().map(|b| decode_bytes(b)).unwrap_or_default();
116                    let text = if is_err { err_dec.push(&bytes) } else { out_dec.push(&bytes) };
117                    if is_err {
118                        stderr.push_str(&text);
119                        if let Some(cb) = &self.on_stderr {
120                            cb(&text);
121                        }
122                    } else {
123                        stdout.push_str(&text);
124                        if let Some(cb) = &self.on_stdout {
125                            cb(&text);
126                        }
127                    }
128                }
129                "cmd.exit" => {
130                    let exit_code = frame.exit_code.unwrap_or(0);
131                    self.channel.unregister_command(&self.cmd_id);
132                    // Bytes held back at the end of a stream (a truncated character) are flushed, not dropped.
133                    let rest = out_dec.finish();
134                    if !rest.is_empty() {
135                        stdout.push_str(&rest);
136                        if let Some(cb) = &self.on_stdout {
137                            cb(&rest);
138                        }
139                    }
140                    let rest = err_dec.finish();
141                    if !rest.is_empty() {
142                        stderr.push_str(&rest);
143                        if let Some(cb) = &self.on_stderr {
144                            cb(&rest);
145                        }
146                    }
147                    return Ok(CommandResult { exit_code, stdout, stderr, timed_out: false });
148                }
149                _ => {}
150            }
151        }
152        // Channel dropped before `cmd.exit`.
153        Err(SolariError::connection("Control channel closed"))
154    }
155}
156
157fn decode_bytes(b64: &str) -> Vec<u8> {
158    base64::engine::general_purpose::STANDARD.decode(b64).unwrap_or_default()
159}
160
161/// Streaming UTF-8 decoder: holds back an incomplete character at the end of a chunk until the next chunk completes it.
162/// Invalid bytes become U+FFFD, as `String::from_utf8_lossy` does.
163#[derive(Default)]
164pub(crate) struct Utf8Stream {
165    pending: Vec<u8>,
166}
167
168impl Utf8Stream {
169    pub(crate) fn push(&mut self, bytes: &[u8]) -> String {
170        self.pending.extend_from_slice(bytes);
171        let mut out = String::new();
172        loop {
173            match std::str::from_utf8(&self.pending) {
174                Ok(s) => {
175                    out.push_str(s);
176                    self.pending.clear();
177                    break;
178                }
179                Err(e) => {
180                    let valid = e.valid_up_to();
181                    out.push_str(std::str::from_utf8(&self.pending[..valid]).unwrap_or(""));
182                    match e.error_len() {
183                        Some(n) => {
184                            out.push('\u{FFFD}');
185                            self.pending.drain(..valid + n);
186                        }
187                        None => {
188                            // An incomplete character at the end: wait for the next chunk.
189                            self.pending.drain(..valid);
190                            break;
191                        }
192                    }
193                }
194            }
195        }
196        out
197    }
198
199    /// End of stream: a character still incomplete is reported once as U+FFFD.
200    pub(crate) fn finish(&mut self) -> String {
201        if self.pending.is_empty() {
202            String::new()
203        } else {
204            self.pending.clear();
205            "\u{FFFD}".to_string()
206        }
207    }
208}
209
210#[cfg(test)]
211mod utf8_tests {
212    use super::Utf8Stream;
213
214    #[test]
215    fn characters_split_across_chunks_come_out_whole() {
216        let text = "漢字🙂é".repeat(50);
217        let mut dec = Utf8Stream::default();
218        let mut out = String::new();
219        for b in text.as_bytes() {
220            out.push_str(&dec.push(&[*b]));
221        }
222        out.push_str(&dec.finish());
223        assert_eq!(out, text);
224        assert!(!out.contains('\u{FFFD}'));
225    }
226
227    #[test]
228    fn a_truncated_character_at_the_end_is_reported_once_and_invalid_bytes_are_replaced() {
229        let mut dec = Utf8Stream::default();
230        assert_eq!(dec.push(b"ok\xe6"), "ok");
231        assert_eq!(dec.finish(), "\u{FFFD}");
232        let mut dec = Utf8Stream::default();
233        assert_eq!(dec.push(b"a\xffb"), "a\u{FFFD}b");
234        assert_eq!(dec.finish(), "");
235    }
236}
237
238/// Build a JSON params object, inserting only present (`Some`) fields — mirrors
239/// how JSON.stringify omits `undefined`.
240fn cmd_start_params(cmd: &str, opts: &RunOptions) -> Value {
241    let mut p = Map::new();
242    p.insert("cmd".into(), Value::String(cmd.to_string()));
243    if let Some(args) = &opts.args {
244        p.insert("args".into(), Value::from(args.clone()));
245    }
246    if let Some(cwd) = &opts.cwd {
247        p.insert("cwd".into(), Value::String(cwd.clone()));
248    }
249    if let Some(env) = &opts.env {
250        p.insert("env".into(), serde_json::to_value(env).unwrap());
251    }
252    if let Some(user) = &opts.user {
253        p.insert("user".into(), Value::String(user.clone()));
254    }
255    Value::Object(p)
256}
257
258/// `commands.start`: start a process and return a handle immediately.
259pub(crate) async fn start_command(
260    sb: &Sandbox,
261    cmd: &str,
262    opts: &RunOptions,
263) -> Result<CommandHandle, SolariError> {
264    sb.channel().connect().await?;
265    let result = sb
266        .channel()
267        .call("cmd.start", cmd_start_params(cmd, opts))
268        .await?;
269    let cmd_id = result
270        .get("cmdId")
271        .and_then(Value::as_str)
272        .ok_or_else(|| SolariError::Other("cmd.start reply missing cmdId".into()))?
273        .to_string();
274    let rx = sb.channel().register_command(&cmd_id);
275    Ok(CommandHandle {
276        cmd_id,
277        channel: sb.channel_arc(),
278        rx,
279        on_stdout: opts.on_stdout.clone(),
280        on_stderr: opts.on_stderr.clone(),
281    })
282}
283
284/// `commands.run`: run a command to completion.
285pub(crate) async fn run_command(
286    sb: &Sandbox,
287    cmd: &str,
288    opts: &RunOptions,
289) -> Result<CommandResult, SolariError> {
290    // One-shot HTTP fast path: a plain run-to-completion (no streaming
291    // callbacks, not backgrounded, no per-command env/user) over the warm REST
292    // connection, ONLY when the control channel isn't already open.
293    if !opts.background
294        && opts.on_stdout.is_none()
295        && opts.on_stderr.is_none()
296        && opts.env.is_none()
297        && opts.user.is_none()
298        && !sb.channel().connected()
299    {
300        match exec_oneshot(sb, cmd, opts).await {
301            Ok(r) => return Ok(r),
302            // Fall back to the WS path ONLY when `/exec` is unavailable. Any
303            // other failure propagates (never silently double-execute).
304            Err(err) => {
305                if !is_route_unavailable(&err) {
306                    return Err(err);
307                }
308            }
309        }
310    }
311
312    let handle = start_command(sb, cmd, opts).await?;
313    if opts.background {
314        return Ok(CommandResult {
315            exit_code: 0,
316            stdout: String::new(),
317            stderr: String::new(),
318            timed_out: false,
319        });
320    }
321    // `timeout_ms` is a deadline for the command here too (it used to be honoured only by the REST path).
322    let deadline = opts
323        .timeout_ms
324        .filter(|t| *t > 0)
325        .map(|t| tokio::time::Instant::now() + std::time::Duration::from_millis(t));
326    handle.wait_deadline(deadline).await
327}
328
329/// POST `/sandboxes/:id/exec` — the one-shot fast path.
330async fn exec_oneshot(
331    sb: &Sandbox,
332    cmd: &str,
333    opts: &RunOptions,
334) -> Result<CommandResult, SolariError> {
335    let mut body = Map::new();
336    body.insert("cmd".into(), Value::String(cmd.to_string()));
337    if let Some(args) = &opts.args {
338        body.insert("args".into(), Value::from(args.clone()));
339    }
340    if let Some(cwd) = &opts.cwd {
341        body.insert("cwd".into(), Value::String(cwd.clone()));
342    }
343    if let Some(t) = opts.timeout_ms {
344        body.insert("timeoutMs".into(), Value::from(t));
345    }
346    let path = format!("/sandboxes/{}/exec", encode_uri_component(sb.id()));
347    sb.http()
348        .request::<CommandResult>("POST", &path, Some(Value::Object(body)), None)
349        .await
350}
351
352/// The ergonomic `commands` accessor returned by `Sandbox::commands()`.
353pub struct Commands<'a> {
354    pub(crate) sb: &'a Sandbox,
355}
356
357impl<'a> Commands<'a> {
358    /// Run a command to completion.
359    pub async fn run(&self, cmd: &str, opts: RunOptions) -> Result<CommandResult, SolariError> {
360        run_command(self.sb, cmd, &opts).await
361    }
362
363    /// Start a command and return a handle immediately.
364    pub async fn start(&self, cmd: &str, opts: RunOptions) -> Result<CommandHandle, SolariError> {
365        start_command(self.sb, cmd, &opts).await
366    }
367}