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