1use 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
17pub type OutputCallback = Arc<dyn Fn(&str) + Send + Sync>;
19
20#[derive(Default, Clone)]
22pub struct RunOptions {
23 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 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
53pub 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 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 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 pub async fn wait(self) -> Result<CommandResult, SolariError> {
84 self.wait_deadline(None).await
85 }
86
87 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 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 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 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#[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 self.pending.drain(..valid);
190 break;
191 }
192 }
193 }
194 }
195 }
196 out
197 }
198
199 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
238fn 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
258pub(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
284pub(crate) async fn run_command(
286 sb: &Sandbox,
287 cmd: &str,
288 opts: &RunOptions,
289) -> Result<CommandResult, SolariError> {
290 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 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 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
329async 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
352pub struct Commands<'a> {
354 pub(crate) sb: &'a Sandbox,
355}
356
357impl<'a> Commands<'a> {
358 pub async fn run(&self, cmd: &str, opts: RunOptions) -> Result<CommandResult, SolariError> {
360 run_command(self.sb, cmd, &opts).await
361 }
362
363 pub async fn start(&self, cmd: &str, opts: RunOptions) -> Result<CommandHandle, SolariError> {
365 start_command(self.sb, cmd, &opts).await
366 }
367}