1use super::path::resolve_in_project;
2use crate::{
3 error::{ErrorCode, ExeoraError},
4 protocol::{
5 DEFAULT_COMMAND_TIMEOUT_MS, MAX_COMMAND_OUTPUT_BYTES, MAX_PROCESS_BUFFER_BYTES,
6 MAX_PROCESS_CHUNK_BYTES, MAX_PROCESSES_PER_PROJECT,
7 },
8};
9#[cfg(windows)]
10use process_wrap::tokio::JobObject;
11#[cfg(unix)]
12use process_wrap::tokio::ProcessGroup;
13use process_wrap::tokio::{ChildWrapper, CommandWrap, KillOnDrop};
14use serde::Deserialize;
15use serde_json::{Value, json};
16use std::{
17 collections::{HashMap, VecDeque},
18 path::{Path, PathBuf},
19 process::Stdio,
20 sync::Arc,
21 time::Duration,
22};
23use tokio::{
24 io::{AsyncRead, AsyncReadExt, AsyncWriteExt},
25 sync::Mutex,
26};
27use tokio_util::sync::CancellationToken;
28use uuid::Uuid;
29
30type SharedChild = Arc<Mutex<Box<dyn ChildWrapper>>>;
31
32struct Running {
33 root: PathBuf,
34 child: SharedChild,
35 stdin: Arc<Mutex<Option<tokio::process::ChildStdin>>>,
36 ring: Arc<Mutex<Ring>>,
37 exit_code: Option<i32>,
38 running: bool,
39}
40
41struct Chunk {
43 text: String,
44 bytes: usize,
45}
46
47#[derive(Default)]
56struct Ring {
57 chunks: VecDeque<Chunk>,
58 bytes: usize,
59 dropped: usize,
60}
61
62impl Ring {
63 fn append(&mut self, text: String) {
64 let bytes = text.len();
65 self.bytes += bytes;
66 self.chunks.push_back(Chunk { text, bytes });
67 while self.bytes > MAX_PROCESS_BUFFER_BYTES {
68 let overflow = self.bytes - MAX_PROCESS_BUFFER_BYTES;
69 let Some(oldest) = self.chunks.front_mut() else {
70 break;
71 };
72 if oldest.bytes <= overflow {
73 let oldest = self.chunks.pop_front().expect("front exists");
74 self.bytes -= oldest.bytes;
75 self.dropped += oldest.bytes;
76 continue;
77 }
78
79 let mut cut = overflow;
80 while !oldest.text.is_char_boundary(cut) {
81 cut += 1;
82 }
83 oldest.text = oldest.text.split_off(cut);
84 oldest.bytes -= cut;
85 self.bytes -= cut;
86 self.dropped += cut;
87 }
88 }
89
90 fn slice(&self, offset: usize, max: usize) -> (String, usize) {
92 let mut skipped = offset;
93 let mut output = String::with_capacity(max);
94 let mut consumed = 0;
95
96 for chunk in &self.chunks {
97 if skipped >= chunk.bytes {
98 skipped -= chunk.bytes;
99 continue;
100 }
101 let mut start = skipped;
102 while !chunk.text.is_char_boundary(start) {
103 start += 1;
104 }
105 consumed += start - skipped;
106 let budget = max.saturating_sub(output.len());
107 let mut end = (start + budget).min(chunk.bytes);
108 while end > start && !chunk.text.is_char_boundary(end) {
109 end -= 1;
110 }
111 output.push_str(&chunk.text[start..end]);
112 consumed += end - start;
113 skipped = 0;
114 if end < chunk.bytes || output.len() >= max {
115 break;
116 }
117 }
118 (output, consumed)
119 }
120}
121
122pub struct ProcessRegistry {
123 entries: Mutex<HashMap<String, Running>>,
124}
125
126impl Default for ProcessRegistry {
127 fn default() -> Self {
128 Self::new()
129 }
130}
131
132impl ProcessRegistry {
133 pub fn new() -> Self {
134 Self {
135 entries: Mutex::new(HashMap::new()),
136 }
137 }
138
139 pub async fn run_command(
140 &self,
141 root: &Path,
142 value: Value,
143 cancel: CancellationToken,
144 ) -> Result<Value, ExeoraError> {
145 let args: RunArgs = parse(value)?;
146 let (real_root, cwd) = resolve_in_project(root, args.cwd.as_deref().unwrap_or("."))?;
147 let timeout_ms = args.timeout_ms.unwrap_or(DEFAULT_COMMAND_TIMEOUT_MS);
148 let mut child = spawn_wrapped(&args.command, &real_root.join(cwd), false)?;
149 let stdout = child.stdout().take();
150 let stderr = child.stderr().take();
151 let captured = Arc::new(Mutex::new(CapturedOutput::default()));
152 let stdout_task = tokio::spawn(capture(stdout, OutputStream::Stdout, captured.clone()));
153 let stderr_task = tokio::spawn(capture(stderr, OutputStream::Stderr, captured.clone()));
154
155 let mut timed_out = false;
156 let mut cancelled = false;
157 let status = {
158 let wait = child.wait();
159 tokio::pin!(wait);
160 tokio::select! {
161 status = &mut wait => Some(status.map_err(|error| ExeoraError::tool(error.to_string()))?),
162 _ = tokio::time::sleep(Duration::from_millis(timeout_ms)) => { timed_out = true; None },
163 _ = cancel.cancelled() => { cancelled = true; None },
164 }
165 };
166 if status.is_none() {
167 let _ = kill_child(child.as_mut()).await;
168 }
169 stdout_task.await.map_err(join_error)??;
170 stderr_task.await.map_err(join_error)??;
171 let captured = std::mem::take(&mut *captured.lock().await);
172 let truncated = captured.truncated;
173 let (stdout, stderr) = captured.into_strings();
174 if cancelled {
175 return Err(ExeoraError::new(
176 ErrorCode::Cancelled,
177 "The call was cancelled while the command was running.",
178 ));
179 }
180 Ok(json!({
181 "command": args.command,
182 "exitCode": status.and_then(|status| status.code()),
183 "stdout": stdout,
184 "stderr": stderr,
185 "truncated": truncated,
186 "timedOut": timed_out,
187 }))
188 }
189
190 pub async fn start_command(&self, root: &Path, value: Value) -> Result<Value, ExeoraError> {
191 let args: StartArgs = parse(value)?;
192 let (real_root, cwd) = resolve_in_project(root, args.cwd.as_deref().unwrap_or("."))?;
193 let root_key = real_root.clone();
194 let mut entries = self.entries.lock().await;
195 for entry in entries.values_mut() {
196 refresh(entry).await;
197 }
198 if entries
199 .values()
200 .filter(|entry| entry.root == root_key && entry.running)
201 .count()
202 >= MAX_PROCESSES_PER_PROJECT
203 {
204 return Err(ExeoraError::tool(format!(
205 "This project already has {MAX_PROCESSES_PER_PROJECT} processes running. Stop one with kill_command before starting another."
206 )));
207 }
208 let mut child = spawn_wrapped(&args.command, &real_root.join(cwd), true)?;
209 let pid = child.id();
210 let stdin = Arc::new(Mutex::new(child.stdin().take()));
211 let stdout = child.stdout().take();
212 let stderr = child.stderr().take();
213 let ring = Arc::new(Mutex::new(Ring::default()));
214 spawn_reader(stdout, ring.clone());
215 spawn_reader(stderr, ring.clone());
216 let id = format!("proc_{}", Uuid::new_v4().simple());
217 entries.insert(
218 id.clone(),
219 Running {
220 root: real_root,
221 child: Arc::new(Mutex::new(child)),
222 stdin,
223 ring,
224 exit_code: None,
225 running: true,
226 },
227 );
228 Ok(json!({ "processId": id, "command": args.command, "pid": pid }))
229 }
230
231 pub async fn get_output(&self, root: &Path, value: Value) -> Result<Value, ExeoraError> {
232 let args: OutputArgs = parse(value)?;
233 let mut entries = self.entries.lock().await;
234 let entry = find_entry(&mut entries, root, &args.process_id)?;
235 refresh(entry).await;
236 let ring = entry.ring.lock().await;
237 let total = ring.dropped + ring.bytes;
238 let from = args.cursor.unwrap_or(0);
239 let start = from.max(ring.dropped).min(total);
240 let (chunk, read) = ring.slice(start - ring.dropped, MAX_PROCESS_CHUNK_BYTES);
241 Ok(json!({
242 "processId": args.process_id,
243 "chunk": chunk,
244 "nextCursor": start + read,
245 "skipped": from < ring.dropped,
246 "running": entry.running,
247 "exitCode": entry.exit_code,
248 }))
249 }
250
251 pub async fn send_input(&self, root: &Path, value: Value) -> Result<Value, ExeoraError> {
252 let args: InputArgs = parse(value)?;
253 let mut entries = self.entries.lock().await;
254 let entry = find_entry(&mut entries, root, &args.process_id)?;
255 if !entry.running {
256 return Err(ExeoraError::tool("That process is not accepting input."));
257 }
258 let payload = if args.newline.unwrap_or(true) {
259 format!("{}\n", args.data)
260 } else {
261 args.data
262 };
263
264 let mut stdin = entry.stdin.lock().await;
269 let written = match stdin.as_mut() {
270 None => Err(std::io::ErrorKind::BrokenPipe.into()),
271 Some(stdin) => match stdin.write_all(payload.as_bytes()).await {
272 Ok(()) => stdin.flush().await,
273 Err(error) => Err(error),
274 },
275 };
276 drop(stdin);
277
278 if let Err(error) = written {
279 refresh(entry).await;
280 return Err(if entry.running {
281 ExeoraError::tool(error.to_string())
282 } else {
283 ExeoraError::tool("That process is not accepting input.")
284 });
285 }
286 Ok(json!({ "processId": args.process_id, "bytesWritten": payload.len() }))
287 }
288
289 pub async fn kill_command(&self, root: &Path, value: Value) -> Result<Value, ExeoraError> {
290 let args: ProcessArgs = parse(value)?;
291 let mut entries = self.entries.lock().await;
292 let entry = find_entry(&mut entries, root, &args.process_id)?;
293 refresh(entry).await;
294 if !entry.running {
295 return Ok(
296 json!({ "processId": args.process_id, "killed": false, "exitCode": entry.exit_code }),
297 );
298 }
299 let mut child = entry.child.lock().await;
304 let _ = child.start_kill();
305 drop(child);
306 entry.running = false;
307
308 let child = entry.child.clone();
313 tokio::spawn(async move {
314 let mut child = child.lock().await;
315 let _ = child.wait().await;
316 });
317 Ok(json!({ "processId": args.process_id, "killed": true, "exitCode": entry.exit_code }))
318 }
319
320 pub async fn kill_all(&self) {
321 let mut entries = self.entries.lock().await;
322 for entry in entries.values_mut() {
323 if entry.running {
324 let mut child = entry.child.lock().await;
325 let _ = kill_child(child.as_mut()).await;
326 }
327 }
328 entries.clear();
329 }
330}
331
332#[derive(Deserialize)]
333#[serde(rename_all = "camelCase")]
334struct RunArgs {
335 command: String,
336 cwd: Option<String>,
337 timeout_ms: Option<u64>,
338}
339#[derive(Deserialize)]
340struct StartArgs {
341 command: String,
342 cwd: Option<String>,
343}
344#[derive(Deserialize)]
345#[serde(rename_all = "camelCase")]
346struct OutputArgs {
347 process_id: String,
348 cursor: Option<usize>,
349}
350#[derive(Deserialize)]
351#[serde(rename_all = "camelCase")]
352struct InputArgs {
353 process_id: String,
354 data: String,
355 newline: Option<bool>,
356}
357#[derive(Deserialize)]
358#[serde(rename_all = "camelCase")]
359struct ProcessArgs {
360 process_id: String,
361}
362
363fn spawn_wrapped(
364 command: &str,
365 cwd: &Path,
366 input: bool,
367) -> Result<Box<dyn ChildWrapper>, ExeoraError> {
368 let (program, shell_args) = shell(command);
369 let mut wrapped = CommandWrap::with_new(program, |cmd| {
370 cmd.args(shell_args)
371 .current_dir(cwd)
372 .stdout(Stdio::piped())
373 .stderr(Stdio::piped())
374 .stdin(if input { Stdio::piped() } else { Stdio::null() });
375 });
376 #[cfg(unix)]
377 wrapped.wrap(ProcessGroup::leader());
378 #[cfg(windows)]
379 wrapped.wrap(JobObject);
380 wrapped.wrap(KillOnDrop);
381 wrapped
382 .spawn()
383 .map_err(|error| ExeoraError::tool(error.to_string()))
384}
385
386#[cfg(unix)]
387fn shell(command: &str) -> (&'static str, Vec<&str>) {
388 ("/bin/sh", vec!["-c", command])
389}
390#[cfg(windows)]
391fn shell(command: &str) -> (&'static str, Vec<&str>) {
392 ("cmd.exe", vec!["/d", "/s", "/c", command])
393}
394
395fn spawn_reader<R: AsyncRead + Unpin + Send + 'static>(reader: Option<R>, ring: Arc<Mutex<Ring>>) {
396 let Some(mut reader) = reader else {
397 return;
398 };
399 tokio::spawn(async move {
400 let mut buffer = vec![0; 8192];
401 while let Ok(count) = reader.read(&mut buffer).await {
402 if count == 0 {
403 break;
404 }
405 let mut guard = ring.lock().await;
406 guard.append(String::from_utf8_lossy(&buffer[..count]).into_owned());
407 }
408 });
409}
410
411#[derive(Clone, Copy)]
412enum OutputStream {
413 Stdout,
414 Stderr,
415}
416
417struct OutputChunk {
418 stream: OutputStream,
419 bytes: Vec<u8>,
420}
421
422#[derive(Default)]
423struct CapturedOutput {
424 chunks: VecDeque<OutputChunk>,
425 bytes: usize,
426 truncated: bool,
427}
428
429impl CapturedOutput {
430 fn append(&mut self, stream: OutputStream, mut bytes: Vec<u8>) {
431 if bytes.len() > MAX_COMMAND_OUTPUT_BYTES {
432 self.truncated = true;
433 bytes.drain(..bytes.len() - MAX_COMMAND_OUTPUT_BYTES);
434 }
435 self.bytes += bytes.len();
436 self.chunks.push_back(OutputChunk { stream, bytes });
437 while self.bytes > MAX_COMMAND_OUTPUT_BYTES {
438 self.truncated = true;
439 let overflow = self.bytes - MAX_COMMAND_OUTPUT_BYTES;
440 let Some(oldest) = self.chunks.front_mut() else {
441 break;
442 };
443 if oldest.bytes.len() <= overflow {
444 let oldest = self.chunks.pop_front().expect("front exists");
445 self.bytes -= oldest.bytes.len();
446 } else {
447 oldest.bytes.drain(..overflow);
448 self.bytes -= overflow;
449 }
450 }
451 }
452
453 fn into_strings(self) -> (String, String) {
454 let mut stdout = Vec::new();
455 let mut stderr = Vec::new();
456 for chunk in self.chunks {
457 match chunk.stream {
458 OutputStream::Stdout => stdout.extend(chunk.bytes),
459 OutputStream::Stderr => stderr.extend(chunk.bytes),
460 }
461 }
462 (
463 String::from_utf8_lossy(&stdout).into_owned(),
464 String::from_utf8_lossy(&stderr).into_owned(),
465 )
466 }
467}
468
469async fn capture<R: AsyncRead + Unpin>(
470 reader: Option<R>,
471 stream: OutputStream,
472 captured: Arc<Mutex<CapturedOutput>>,
473) -> Result<(), ExeoraError> {
474 let Some(mut reader) = reader else {
475 return Ok(());
476 };
477 let mut buffer = vec![0; 8192];
478 loop {
479 let count = reader
480 .read(&mut buffer)
481 .await
482 .map_err(|error| ExeoraError::tool(error.to_string()))?;
483 if count == 0 {
484 break;
485 }
486 captured
487 .lock()
488 .await
489 .append(stream, buffer[..count].to_vec());
490 }
491 Ok(())
492}
493
494async fn refresh(entry: &mut Running) {
495 if !entry.running {
496 return;
497 }
498 if let Ok(Some(status)) = entry.child.lock().await.try_wait() {
499 entry.running = false;
500 entry.exit_code = status.code();
501 }
502}
503
504fn find_entry<'a>(
505 entries: &'a mut HashMap<String, Running>,
506 root: &Path,
507 id: &str,
508) -> Result<&'a mut Running, ExeoraError> {
509 let real_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_owned());
510 entries
511 .get_mut(id)
512 .filter(|entry| entry.root == real_root)
513 .ok_or_else(|| {
514 ExeoraError::tool(
515 "No such process. It may have been stopped, or it belongs to another project.",
516 )
517 })
518}
519
520fn parse<T: for<'de> Deserialize<'de>>(value: Value) -> Result<T, ExeoraError> {
521 serde_json::from_value(value)
522 .map_err(|error| ExeoraError::new(ErrorCode::InvalidArguments, error.to_string()))
523}
524fn join_error(error: tokio::task::JoinError) -> ExeoraError {
525 ExeoraError::tool(error.to_string())
526}
527
528async fn kill_child(child: &mut dyn ChildWrapper) -> std::io::Result<()> {
529 Box::into_pin(child.kill()).await
530}
531
532#[cfg(test)]
533mod tests {
534 use super::{CapturedOutput, OutputStream, Ring};
535 use crate::protocol::{MAX_COMMAND_OUTPUT_BYTES, MAX_PROCESS_BUFFER_BYTES};
536
537 #[test]
538 fn a_multibyte_character_at_the_byte_limit_waits_for_the_next_read() {
539 let mut ring = Ring::default();
540 ring.append("a".repeat(9));
541 ring.append("\u{1f600}tail".to_owned());
542 ring.append("later".to_owned());
543
544 let (head, read) = ring.slice(0, 10);
545 assert_eq!(head, "a".repeat(9));
546 assert_eq!(read, 9, "the character is left for the next read");
547
548 let (tail, read) = ring.slice(read, 8);
549 assert_eq!(tail, "\u{1f600}tail");
550 assert_eq!(read, 8);
551 }
552
553 #[test]
554 fn a_cursor_inside_a_character_advances_past_it() {
555 let mut ring = Ring::default();
556 ring.append("\u{1f600}tail".to_owned());
557
558 let (chunk, read) = ring.slice(1, 10);
559 assert_eq!(chunk, "tail");
560 assert_eq!(read, 7, "three skipped bytes and four bytes of tail");
561 }
562
563 #[test]
564 fn one_large_chunk_is_trimmed_to_the_process_byte_limit() {
565 let mut ring = Ring::default();
566 let input = "\u{00e9}".repeat(MAX_PROCESS_BUFFER_BYTES);
567 let input_bytes = input.len();
568 ring.append(input);
569
570 assert!(ring.bytes <= MAX_PROCESS_BUFFER_BYTES);
571 assert_eq!(ring.dropped + ring.bytes, input_bytes);
572 }
573
574 #[test]
575 fn stdout_and_stderr_share_one_command_output_budget() {
576 let mut output = CapturedOutput::default();
577 output.append(OutputStream::Stdout, vec![b'o'; 150_000]);
578 output.append(OutputStream::Stderr, vec![b'e'; 100_000]);
579 assert!(output.truncated);
580 assert_eq!(output.bytes, MAX_COMMAND_OUTPUT_BYTES);
581
582 let (stdout, stderr) = output.into_strings();
583 assert_eq!(stdout.len() + stderr.len(), MAX_COMMAND_OUTPUT_BYTES);
584 }
585}