1use std::collections::BTreeMap;
11use std::sync::atomic::Ordering;
12
13use anyhow::{Context, Result};
14use scc::HashMap as SccHashMap;
15use serde::{Deserialize, Serialize};
16use tokio::sync::{broadcast, watch};
17
18use secure_exec_client::wire::{self, EventPayload, ProcessSnapshotStatus, StreamChannel};
19
20use crate::agent_os::{AgentOs, ProcessEntry};
21use crate::command_line::resolve_exec_command;
22use crate::error::ClientError;
23use crate::stream::{ByteStream, Subscription};
24
25const PROCESS_STREAM_CAPACITY: usize = 1024;
27
28const PROCESS_REGISTRY_LIMIT: usize = 1024;
30
31const OBSERVED_PROCESS_TIME_LIMIT: usize = 4096;
33
34const EXEC_OUTPUT_CAPTURE_LIMIT_BYTES: usize = 16 * 1024 * 1024;
36
37pub(crate) const DEFAULT_EXEC_CWD: &str = "/workspace";
39
40pub(crate) const SYNTHETIC_PID_BASE: u64 = 1_000_000;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
50#[serde(rename_all = "lowercase")]
51pub enum TimingMitigation {
52 #[default]
53 Off,
54 Freeze,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum StdinInput {
60 Text(String),
61 Bytes(Vec<u8>),
62}
63
64pub type OutputCallback = Box<dyn FnMut(&[u8]) + Send>;
67
68pub struct ExecOptions {
74 pub env: BTreeMap<String, String>,
75 pub cwd: Option<String>,
76 pub stdin: Option<StdinInput>,
77 pub timeout: Option<f64>,
78 pub on_stdout: Option<OutputCallback>,
79 pub on_stderr: Option<OutputCallback>,
80 pub capture_stdio: Option<bool>,
81 pub file_path: Option<String>,
82 pub cpu_time_limit_ms: Option<f64>,
83 pub timing_mitigation: Option<TimingMitigation>,
84}
85
86impl Default for ExecOptions {
87 fn default() -> Self {
88 Self {
89 env: BTreeMap::new(),
90 cwd: Some(DEFAULT_EXEC_CWD.to_string()),
91 stdin: None,
92 timeout: None,
93 on_stdout: None,
94 on_stderr: None,
95 capture_stdio: None,
96 file_path: None,
97 cpu_time_limit_ms: None,
98 timing_mitigation: None,
99 }
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct ExecResult {
106 pub exit_code: i32,
107 pub stdout: String,
108 pub stderr: String,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
113#[serde(rename_all = "lowercase")]
114pub enum SpawnStdio {
115 #[default]
116 Pipe,
117 Inherit,
118}
119
120#[derive(Default)]
122pub struct SpawnOptions {
123 pub base: ExecOptions,
124 pub stdio: Option<SpawnStdio>,
125 pub stdin_fd: Option<i32>,
126 pub stdout_fd: Option<i32>,
127 pub stderr_fd: Option<i32>,
128 pub stream_stdin: Option<bool>,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct SpawnedProcessInfo {
134 pub pid: u32,
135 pub command: String,
136 pub args: Vec<String>,
137 pub running: bool,
138 #[serde(rename = "exitCode")]
139 pub exit_code: Option<i32>,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144pub struct SpawnHandle {
145 pub pid: u32,
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "lowercase")]
151pub enum ProcessStatus {
152 Running,
153 Exited,
154}
155
156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158pub struct ProcessInfo {
159 pub pid: u32,
160 pub ppid: u32,
161 pub pgid: u32,
162 pub sid: u32,
163 pub driver: String,
164 pub command: String,
165 pub args: Vec<String>,
166 pub cwd: String,
167 pub status: ProcessStatus,
168 #[serde(rename = "exitCode")]
169 pub exit_code: Option<i32>,
170 #[serde(rename = "startTime")]
171 pub start_time: f64,
172 #[serde(rename = "exitTime")]
173 pub exit_time: Option<f64>,
174}
175
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
178pub struct ProcessTreeNode {
179 #[serde(flatten)]
180 pub info: ProcessInfo,
181 pub children: Vec<ProcessTreeNode>,
182}
183
184impl AgentOs {
189 pub async fn exec(&self, command: &str, options: ExecOptions) -> Result<ExecResult> {
194 let (resolved_command, resolved_args) = resolve_exec_command(command)?;
198 self.exec_argv(&resolved_command, &resolved_args, options)
199 .await
200 }
201
202 pub async fn exec_argv(
208 &self,
209 command: &str,
210 args: &[String],
211 mut options: ExecOptions,
212 ) -> Result<ExecResult> {
213 let process_id = self.next_process_id();
214
215 let mut events = self.transport().subscribe_wire_events();
218
219 let resolved_command = command.to_owned();
220 let resolved_args = args.to_vec();
221 let started = self
222 .send_execute(
223 &process_id,
224 Some(resolved_command),
225 resolved_args,
226 options.env.clone(),
227 options.cwd.clone(),
228 )
229 .await
230 .context("exec: Execute request failed")?;
231 debug_assert_eq!(started.process_id, process_id);
232
233 if let Some(stdin) = options.stdin.take() {
236 let chunk = stdin_to_bytes(stdin);
237 let ownership = self.vm_scope();
238 let _ = self
239 .transport()
240 .request_wire(
241 ownership,
242 wire::RequestPayload::WriteStdinRequest(wire::WriteStdinRequest {
243 process_id: process_id.clone(),
244 chunk,
245 }),
246 )
247 .await;
248 }
249 {
250 let ownership = self.vm_scope();
251 let _ = self
252 .transport()
253 .request_wire(
254 ownership,
255 wire::RequestPayload::CloseStdinRequest(wire::CloseStdinRequest {
256 process_id: process_id.clone(),
257 }),
258 )
259 .await;
260 }
261
262 let mut on_stdout = options.on_stdout.take();
263 let mut on_stderr = options.on_stderr.take();
264
265 let timeout_deadline = options
269 .timeout
270 .filter(|ms| ms.is_finite() && *ms >= 0.0)
271 .map(|ms| {
272 tokio::time::Instant::now() + std::time::Duration::from_secs_f64(ms / 1000.0)
273 });
274 let mut killed_for_timeout = false;
275
276 let capture_stdio = options.capture_stdio.unwrap_or(true);
277 let mut stdout = Vec::<u8>::new();
278 let mut stderr = Vec::<u8>::new();
279 let mut captured_output_bytes = 0usize;
280 let mut capture_error: Option<ClientError> = None;
281 let exit_code = loop {
282 let recv = events.recv();
283 let frame = match timeout_deadline {
284 Some(deadline) => {
285 tokio::select! {
286 result = recv => result,
287 _ = tokio::time::sleep_until(deadline), if !killed_for_timeout => {
288 killed_for_timeout = true;
289 self.kill_wire_process(&process_id, "SIGKILL");
290 continue;
291 }
292 }
293 }
294 None => recv.await,
295 };
296 let (_, payload) = match frame {
297 Ok(frame) => frame,
298 Err(broadcast::error::RecvError::Lagged(_)) => continue,
299 Err(broadcast::error::RecvError::Closed) => {
300 return Err(ClientError::Sidecar(
301 "exec: event stream closed before process exit".to_owned(),
302 )
303 .into());
304 }
305 };
306 match payload {
307 EventPayload::ProcessOutputEvent(output) if output.process_id == process_id => {
308 match output.channel {
309 StreamChannel::Stdout => {
310 if let Some(cb) = on_stdout.as_mut() {
311 cb(&output.chunk);
312 }
313 if capture_stdio && capture_error.is_none() {
314 match append_exec_output(
315 &mut stdout,
316 &output.chunk,
317 &mut captured_output_bytes,
318 "stdout",
319 ) {
320 Ok(()) => {}
321 Err(error) => {
322 self.kill_wire_process(&process_id, "SIGKILL");
323 capture_error = Some(error);
324 }
325 }
326 }
327 }
328 StreamChannel::Stderr => {
329 if let Some(cb) = on_stderr.as_mut() {
330 cb(&output.chunk);
331 }
332 if capture_stdio && capture_error.is_none() {
333 match append_exec_output(
334 &mut stderr,
335 &output.chunk,
336 &mut captured_output_bytes,
337 "stderr",
338 ) {
339 Ok(()) => {}
340 Err(error) => {
341 self.kill_wire_process(&process_id, "SIGKILL");
342 capture_error = Some(error);
343 }
344 }
345 }
346 }
347 }
348 }
349 EventPayload::ProcessExitedEvent(exited) if exited.process_id == process_id => {
350 break exited.exit_code;
351 }
352 EventPayload::ProcessOutputEvent(_)
353 | EventPayload::ProcessExitedEvent(_)
354 | EventPayload::VmLifecycleEvent(_)
355 | EventPayload::StructuredEvent(_)
356 | EventPayload::ExtEnvelope(_) => {}
357 }
358 };
359
360 if let Some(error) = capture_error {
361 return Err(error.into());
362 }
363
364 Ok(ExecResult {
365 exit_code,
366 stdout: String::from_utf8_lossy(&stdout).into_owned(),
367 stderr: String::from_utf8_lossy(&stderr).into_owned(),
368 })
369 }
370
371 pub fn spawn(
375 &self,
376 command: &str,
377 args: Vec<String>,
378 mut options: SpawnOptions,
379 ) -> Result<SpawnHandle> {
380 let registry_guard = self.inner().process_registry_lock.lock();
381 self.prune_exited_processes_locked(1);
382 if self.process_registry_len_locked() >= PROCESS_REGISTRY_LIMIT {
383 return Err(ClientError::Sidecar(format!(
384 "process registry limit exceeded: at most {PROCESS_REGISTRY_LIMIT} processes can be tracked per VM"
385 ))
386 .into());
387 }
388
389 let pid = self
392 .inner()
393 .synthetic_pid_counter
394 .fetch_add(1, Ordering::SeqCst) as u32;
395 let process_id = format!("proc-{pid}-{}", uuid::Uuid::new_v4());
396
397 let (stdout_tx, _) = broadcast::channel::<Vec<u8>>(PROCESS_STREAM_CAPACITY);
398 let (stderr_tx, _) = broadcast::channel::<Vec<u8>>(PROCESS_STREAM_CAPACITY);
399 let (exit_tx, _) = watch::channel::<Option<i32>>(None);
402 let (kernel_pid_tx, _) = watch::channel::<Option<u32>>(None);
405
406 if let Some(cb) = options.base.on_stdout.take() {
409 install_output_callback(stdout_tx.clone(), cb);
410 }
411 if let Some(cb) = options.base.on_stderr.take() {
412 install_output_callback(stderr_tx.clone(), cb);
413 }
414
415 let entry = ProcessEntry {
416 command: command.to_owned(),
417 args: args.clone(),
418 stdout_tx: stdout_tx.clone(),
419 stderr_tx: stderr_tx.clone(),
420 exit_tx: exit_tx.clone(),
421 process_id: process_id.clone(),
422 kernel_pid: kernel_pid_tx.clone(),
423 };
424 let _ = self.inner().processes.insert(pid, entry);
427 drop(registry_guard);
428
429 let events = self.transport().subscribe_wire_events();
431
432 let this = self.clone();
433 let command = command.to_owned();
434 tokio::spawn(async move {
435 this.run_spawn(
436 pid,
437 process_id,
438 command,
439 args,
440 options,
441 events,
442 stdout_tx,
443 stderr_tx,
444 exit_tx,
445 kernel_pid_tx,
446 )
447 .await;
448 });
449
450 Ok(SpawnHandle { pid })
451 }
452
453 pub fn write_process_stdin(
455 &self,
456 pid: u32,
457 data: StdinInput,
458 ) -> std::result::Result<(), ClientError> {
459 let process_id = self.lookup_process_id(pid)?;
460 let chunk: Vec<u8> = stdin_to_bytes(data);
461 let this = self.clone();
462 tokio::spawn(async move {
464 let ownership = this.vm_scope();
465 let _ = this
466 .transport()
467 .request_wire(
468 ownership,
469 wire::RequestPayload::WriteStdinRequest(wire::WriteStdinRequest {
470 process_id,
471 chunk,
472 }),
473 )
474 .await;
475 });
476 Ok(())
477 }
478
479 pub fn close_process_stdin(&self, pid: u32) -> std::result::Result<(), ClientError> {
481 let process_id = self.lookup_process_id(pid)?;
482 let this = self.clone();
483 tokio::spawn(async move {
484 let ownership = this.vm_scope();
485 let _ = this
486 .transport()
487 .request_wire(
488 ownership,
489 wire::RequestPayload::CloseStdinRequest(wire::CloseStdinRequest { process_id }),
490 )
491 .await;
492 });
493 Ok(())
494 }
495
496 pub fn on_process_stdout(&self, pid: u32) -> std::result::Result<ByteStream, ClientError> {
498 let rx = self
499 .inner()
500 .processes
501 .read(&pid, |_, entry| entry.stdout_tx.subscribe())
502 .ok_or(ClientError::ProcessNotFound(pid))?;
503 Ok(ByteStream::new(rx))
504 }
505
506 pub fn on_process_stderr(&self, pid: u32) -> std::result::Result<ByteStream, ClientError> {
508 let rx = self
509 .inner()
510 .processes
511 .read(&pid, |_, entry| entry.stderr_tx.subscribe())
512 .ok_or(ClientError::ProcessNotFound(pid))?;
513 Ok(ByteStream::new(rx))
514 }
515
516 pub fn on_process_exit(
521 &self,
522 pid: u32,
523 handler: impl FnOnce(i32) + Send + 'static,
524 ) -> std::result::Result<Subscription, ClientError> {
525 let mut rx = self
526 .inner()
527 .processes
528 .read(&pid, |_, entry| entry.exit_tx.subscribe())
529 .ok_or(ClientError::ProcessNotFound(pid))?;
530
531 if let Some(code) = *rx.borrow() {
533 handler(code);
534 return Ok(Subscription::noop());
535 }
536
537 let task = tokio::spawn(async move {
540 while rx.changed().await.is_ok() {
541 if let Some(code) = *rx.borrow() {
542 handler(code);
543 return;
544 }
545 }
546 });
547 Ok(Subscription::new(move || task.abort()))
548 }
549
550 pub async fn wait_process(&self, pid: u32) -> std::result::Result<i32, ClientError> {
553 let mut rx = self
554 .inner()
555 .processes
556 .read(&pid, |_, entry| entry.exit_tx.subscribe())
557 .ok_or(ClientError::ProcessNotFound(pid))?;
558
559 if let Some(code) = *rx.borrow() {
560 return Ok(code);
561 }
562 while rx.changed().await.is_ok() {
563 if let Some(code) = *rx.borrow() {
564 return Ok(code);
565 }
566 }
567 Err(ClientError::Sidecar(format!(
568 "wait_process: exit channel closed before process {pid} reported an exit code"
569 )))
570 }
571
572 pub fn list_processes(&self) -> Vec<SpawnedProcessInfo> {
574 let mut out = Vec::new();
575 self.inner().processes.scan(|pid, entry| {
576 let exit_code = *entry.exit_tx.borrow();
577 out.push(SpawnedProcessInfo {
578 pid: *pid,
579 command: entry.command.clone(),
580 args: entry.args.clone(),
581 running: exit_code.is_none(),
582 exit_code,
583 });
584 });
585 out
586 }
587
588 pub async fn all_processes(&self) -> Result<Vec<ProcessInfo>> {
596 let ownership = self.vm_scope();
597 let response = self
598 .transport()
599 .request_wire(ownership, wire::RequestPayload::GetProcessSnapshotRequest)
600 .await
601 .context("all_processes: GetProcessSnapshot request failed")?;
602 let snapshot = match response {
603 wire::ResponsePayload::ProcessSnapshotResponse(snapshot) => snapshot,
604 wire::ResponsePayload::RejectedResponse(wire::RejectedResponse { code, message }) => {
605 return Err(ClientError::Kernel { code, message }.into());
606 }
607 other => {
608 return Err(ClientError::Sidecar(format!(
609 "all_processes: unexpected response {other:?}"
610 ))
611 .into());
612 }
613 };
614
615 struct Tracked {
619 exit_code: Option<i32>,
620 command: String,
621 args: Vec<String>,
622 }
623 let mut tracked_by_process_id: BTreeMap<String, Tracked> = BTreeMap::new();
624 let mut display_pid_by_kernel_pid: BTreeMap<u32, u32> = BTreeMap::new();
625 self.inner().processes.scan(|display_pid, entry| {
626 let exit_code = *entry.exit_tx.borrow();
627 if let Some(kernel_pid) = *entry.kernel_pid.borrow() {
628 display_pid_by_kernel_pid.insert(kernel_pid, *display_pid);
629 }
630 tracked_by_process_id.insert(
631 entry.process_id.clone(),
632 Tracked {
633 exit_code,
634 command: entry.command.clone(),
635 args: entry.args.clone(),
636 },
637 );
638 });
639
640 let now_ms = epoch_ms_now();
641 let mut seen_display_pids: std::collections::BTreeSet<u32> =
642 std::collections::BTreeSet::new();
643 let mut out: Vec<ProcessInfo> = Vec::new();
644
645 for entry in snapshot.processes {
646 let tracked = tracked_by_process_id.get(&entry.process_id);
647 let display_pid = display_pid_by_kernel_pid
648 .get(&entry.pid)
649 .copied()
650 .unwrap_or(entry.pid);
651 let display_ppid = display_pid_by_kernel_pid
652 .get(&entry.ppid)
653 .copied()
654 .unwrap_or(entry.ppid);
655 let display_pgid = display_pid_by_kernel_pid
656 .get(&entry.pgid)
657 .copied()
658 .unwrap_or(entry.pgid);
659 let display_sid = display_pid_by_kernel_pid
660 .get(&entry.sid)
661 .copied()
662 .unwrap_or(entry.sid);
663
664 let process_key = format!("{}:{}", entry.process_id, entry.pid);
666 let start_time = self.observed_start_time(&process_key, now_ms);
667
668 let (status, exit_code) = match tracked {
671 Some(t) => match t.exit_code {
672 Some(code) => (ProcessStatus::Exited, Some(code)),
673 None => (ProcessStatus::Running, entry.exit_code),
674 },
675 None => {
676 let status = match entry.status {
677 ProcessSnapshotStatus::Running | ProcessSnapshotStatus::Stopped => {
678 ProcessStatus::Running
679 }
680 ProcessSnapshotStatus::Exited => ProcessStatus::Exited,
681 };
682 (status, entry.exit_code)
683 }
684 };
685
686 let exit_time = match (tracked, status) {
688 (Some(_), ProcessStatus::Exited) => {
689 Some(self.observed_exit_time(&entry.process_id, now_ms))
690 }
691 _ => None,
692 };
693
694 let (command, args) = match tracked {
695 Some(t) => (t.command.clone(), t.args.clone()),
696 None => (entry.command, entry.args),
697 };
698
699 seen_display_pids.insert(display_pid);
700 out.push(ProcessInfo {
701 pid: display_pid,
702 ppid: display_ppid,
703 pgid: display_pgid,
704 sid: display_sid,
705 driver: entry.driver,
706 command,
707 args,
708 cwd: entry.cwd,
709 status,
710 exit_code,
711 start_time,
712 exit_time,
713 });
714 }
715
716 self.inner().processes.scan(|display_pid, entry| {
719 if seen_display_pids.contains(display_pid) {
720 return;
721 }
722 let exit_code = *entry.exit_tx.borrow();
723 let process_key = format!("{}:{}", entry.process_id, display_pid);
724 let start_time = self.observed_start_time(&process_key, now_ms);
725 let (status, exit_time) = match exit_code {
726 Some(_) => (
727 ProcessStatus::Exited,
728 Some(self.observed_exit_time(&entry.process_id, now_ms)),
729 ),
730 None => (ProcessStatus::Running, None),
731 };
732 out.push(ProcessInfo {
733 pid: *display_pid,
734 ppid: 0,
735 pgid: *display_pid,
736 sid: *display_pid,
737 driver: String::new(),
738 command: entry.command.clone(),
739 args: entry.args.clone(),
740 cwd: String::new(),
741 status,
742 exit_code,
743 start_time,
744 exit_time,
745 });
746 });
747
748 out.sort_by_key(|info| info.pid);
749 Ok(out)
750 }
751
752 fn observed_start_time(&self, process_key: &str, now_ms: f64) -> f64 {
755 let _guard = self.inner().observed_process_time_lock.lock();
756 if let Some(existing) = self
757 .inner()
758 .observed_process_start_times
759 .read(process_key, |_, value| *value)
760 {
761 return existing;
762 }
763 let _ = self
764 .inner()
765 .observed_process_start_times
766 .insert(process_key.to_owned(), now_ms);
767 prune_string_f64_map(
768 &self.inner().observed_process_start_times,
769 OBSERVED_PROCESS_TIME_LIMIT,
770 );
771 self.inner()
774 .observed_process_start_times
775 .read(process_key, |_, value| *value)
776 .unwrap_or(now_ms)
777 }
778
779 fn observed_exit_time(&self, process_id: &str, now_ms: f64) -> f64 {
781 let _guard = self.inner().observed_process_time_lock.lock();
782 if let Some(existing) = self
783 .inner()
784 .observed_process_exit_times
785 .read(process_id, |_, value| *value)
786 {
787 return existing;
788 }
789 let _ = self
790 .inner()
791 .observed_process_exit_times
792 .insert(process_id.to_owned(), now_ms);
793 prune_string_f64_map(
794 &self.inner().observed_process_exit_times,
795 OBSERVED_PROCESS_TIME_LIMIT,
796 );
797 self.inner()
798 .observed_process_exit_times
799 .read(process_id, |_, value| *value)
800 .unwrap_or(now_ms)
801 }
802
803 pub async fn process_tree(&self) -> Result<Vec<ProcessTreeNode>> {
805 let processes = self.all_processes().await?;
806 Ok(build_process_forest(processes))
807 }
808
809 pub fn get_process(&self, pid: u32) -> std::result::Result<SpawnedProcessInfo, ClientError> {
811 self.inner()
812 .processes
813 .read(&pid, |pid, entry| {
814 let exit_code = *entry.exit_tx.borrow();
815 SpawnedProcessInfo {
816 pid: *pid,
817 command: entry.command.clone(),
818 args: entry.args.clone(),
819 running: exit_code.is_none(),
820 exit_code,
821 }
822 })
823 .ok_or(ClientError::ProcessNotFound(pid))
824 }
825
826 pub fn stop_process(&self, pid: u32) -> std::result::Result<(), ClientError> {
828 self.signal_process(pid, "SIGTERM")
829 }
830
831 pub fn kill_process(&self, pid: u32) -> std::result::Result<(), ClientError> {
833 self.signal_process(pid, "SIGKILL")
834 }
835
836 fn vm_scope(&self) -> wire::OwnershipScope {
842 wire::OwnershipScope::VmOwnership(wire::VmOwnership {
843 connection_id: self.connection_id().to_string(),
844 session_id: self.wire_session_id().to_string(),
845 vm_id: self.vm_id().to_string(),
846 })
847 }
848
849 fn next_process_id(&self) -> String {
851 let n = self.inner().process_counter.fetch_add(1, Ordering::SeqCst);
852 format!("proc-{n}-{}", uuid::Uuid::new_v4())
853 }
854
855 fn lookup_process_id(&self, pid: u32) -> std::result::Result<String, ClientError> {
857 self.inner()
858 .processes
859 .read(&pid, |_, entry| entry.process_id.clone())
860 .ok_or(ClientError::ProcessNotFound(pid))
861 }
862
863 async fn send_execute(
865 &self,
866 process_id: &str,
867 command: Option<String>,
868 args: Vec<String>,
869 env: BTreeMap<String, String>,
870 cwd: Option<String>,
871 ) -> std::result::Result<wire::ProcessStartedResponse, ClientError> {
872 let ownership = self.vm_scope();
873 let response = self
874 .transport()
875 .request_wire(
876 ownership,
877 wire::RequestPayload::ExecuteRequest(wire::ExecuteRequest {
878 process_id: process_id.to_owned(),
879 command,
880 runtime: None,
881 entrypoint: None,
882 args,
883 env: env.into_iter().collect(),
884 cwd,
885 wasm_permission_tier: None,
886 }),
887 )
888 .await?;
889 match response {
890 wire::ResponsePayload::ProcessStartedResponse(started) => Ok(started),
891 wire::ResponsePayload::RejectedResponse(wire::RejectedResponse { code, message }) => {
892 Err(ClientError::Kernel { code, message })
893 }
894 other => Err(ClientError::Sidecar(format!(
895 "Execute: unexpected response {other:?}"
896 ))),
897 }
898 }
899
900 fn kill_wire_process(&self, process_id: &str, signal: &str) {
903 let process_id = process_id.to_owned();
904 let signal = signal.to_owned();
905 let this = self.clone();
906 tokio::spawn(async move {
907 let ownership = this.vm_scope();
908 let _ = this
909 .transport()
910 .request_wire(
911 ownership,
912 wire::RequestPayload::KillProcessRequest(wire::KillProcessRequest {
913 process_id,
914 signal,
915 }),
916 )
917 .await;
918 });
919 }
920
921 fn signal_process(&self, pid: u32, signal: &str) -> std::result::Result<(), ClientError> {
924 let (process_id, already_exited) = self
925 .inner()
926 .processes
927 .read(&pid, |_, entry| {
928 (entry.process_id.clone(), entry.exit_tx.borrow().is_some())
929 })
930 .ok_or(ClientError::ProcessNotFound(pid))?;
931 if already_exited {
932 return Ok(());
933 }
934 let signal = signal.to_owned();
935 let this = self.clone();
936 tokio::spawn(async move {
937 let ownership = this.vm_scope();
938 let _ = this
939 .transport()
940 .request_wire(
941 ownership,
942 wire::RequestPayload::KillProcessRequest(wire::KillProcessRequest {
943 process_id,
944 signal,
945 }),
946 )
947 .await;
948 });
949 Ok(())
950 }
951
952 fn process_registry_len_locked(&self) -> usize {
953 let mut count = 0usize;
954 self.inner().processes.scan(|_, _| {
955 count += 1;
956 });
957 count
958 }
959
960 fn prune_exited_processes_locked(&self, reserve_slots: usize) {
961 let mut entries = Vec::new();
962 self.inner().processes.scan(|pid, entry| {
963 entries.push((*pid, entry.exit_tx.borrow().is_some()));
964 });
965 let target_len = PROCESS_REGISTRY_LIMIT.saturating_sub(reserve_slots);
966 if entries.len() <= target_len {
967 return;
968 }
969
970 for pid in exited_pids_to_prune(entries, target_len) {
971 self.remove_process_tracking_locked(pid);
972 }
973 }
974
975 fn remove_process_tracking_locked(&self, pid: u32) {
976 if let Some((_, entry)) = self.inner().processes.remove(&pid) {
977 let _time_guard = self.inner().observed_process_time_lock.lock();
978 let _ = self
979 .inner()
980 .observed_process_exit_times
981 .remove(&entry.process_id);
982 let fallback_start_key = format!("{}:{pid}", entry.process_id);
983 let _ = self
984 .inner()
985 .observed_process_start_times
986 .remove(&fallback_start_key);
987 if let Some(kernel_pid) = *entry.kernel_pid.borrow() {
988 let start_key = format!("{}:{kernel_pid}", entry.process_id);
989 let _ = self.inner().observed_process_start_times.remove(&start_key);
990 }
991 }
992 }
993
994 #[allow(clippy::too_many_arguments)]
999 async fn run_spawn(
1000 self,
1001 pid: u32,
1002 process_id: String,
1003 command: String,
1004 args: Vec<String>,
1005 options: SpawnOptions,
1006 mut events: broadcast::Receiver<(wire::OwnershipScope, EventPayload)>,
1007 stdout_tx: broadcast::Sender<Vec<u8>>,
1008 stderr_tx: broadcast::Sender<Vec<u8>>,
1009 exit_tx: watch::Sender<Option<i32>>,
1010 kernel_pid_tx: watch::Sender<Option<u32>>,
1011 ) {
1012 match self
1013 .send_execute(
1014 &process_id,
1015 Some(command),
1016 args,
1017 options.base.env.clone(),
1018 options.base.cwd.clone(),
1019 )
1020 .await
1021 {
1022 Ok(started) => {
1023 if let Some(kernel_pid) = started.pid {
1026 let _ = kernel_pid_tx.send(Some(kernel_pid));
1027 }
1028 }
1029 Err(error) => {
1030 let message = format!("{error}\n");
1034 let _ = stderr_tx.send(message.into_bytes());
1035 tracing::error!(?error, pid, %process_id, "spawn: Execute request failed");
1036 let _ = exit_tx.send(Some(1));
1037 let _guard = self.inner().process_registry_lock.lock();
1038 self.prune_exited_processes_locked(0);
1039 return;
1040 }
1041 }
1042
1043 loop {
1044 let (_, payload) = match events.recv().await {
1045 Ok(frame) => frame,
1046 Err(broadcast::error::RecvError::Lagged(_)) => continue,
1047 Err(broadcast::error::RecvError::Closed) => {
1048 let _ = exit_tx.send(Some(0));
1052 break;
1053 }
1054 };
1055 match payload {
1056 EventPayload::ProcessOutputEvent(output) if output.process_id == process_id => {
1057 let bytes = output.chunk;
1058 match output.channel {
1059 StreamChannel::Stdout => {
1060 let _ = stdout_tx.send(bytes);
1061 }
1062 StreamChannel::Stderr => {
1063 let _ = stderr_tx.send(bytes);
1064 }
1065 }
1066 }
1067 EventPayload::ProcessExitedEvent(exited) if exited.process_id == process_id => {
1068 let _ = exit_tx.send(Some(exited.exit_code));
1069 break;
1070 }
1071 EventPayload::ProcessOutputEvent(_)
1072 | EventPayload::ProcessExitedEvent(_)
1073 | EventPayload::VmLifecycleEvent(_)
1074 | EventPayload::StructuredEvent(_)
1075 | EventPayload::ExtEnvelope(_) => {}
1076 }
1077 }
1078 let _guard = self.inner().process_registry_lock.lock();
1079 self.prune_exited_processes_locked(0);
1080 }
1081}
1082
1083fn build_process_forest(processes: Vec<ProcessInfo>) -> Vec<ProcessTreeNode> {
1090 use std::collections::BTreeMap as Map;
1091
1092 let pids: std::collections::BTreeSet<u32> = processes.iter().map(|p| p.pid).collect();
1093 let mut children_of: Map<u32, Vec<usize>> = Map::new();
1095 let mut roots: Vec<usize> = Vec::new();
1096 for (index, proc) in processes.iter().enumerate() {
1097 if pids.contains(&proc.ppid) {
1098 children_of.entry(proc.ppid).or_default().push(index);
1099 } else {
1100 roots.push(index);
1101 }
1102 }
1103
1104 fn build_node(
1105 index: usize,
1106 processes: &[ProcessInfo],
1107 children_of: &Map<u32, Vec<usize>>,
1108 seen: &mut std::collections::BTreeSet<usize>,
1109 ) -> ProcessTreeNode {
1110 let info = processes[index].clone();
1111 seen.insert(index);
1112 let child_indices: Vec<usize> = children_of
1113 .get(&info.pid)
1114 .map(|indices| {
1115 indices
1116 .iter()
1117 .copied()
1118 .filter(|child_index| !seen.contains(child_index))
1119 .collect()
1120 })
1121 .unwrap_or_default();
1122 let children = child_indices
1123 .into_iter()
1124 .map(|child_index| build_node(child_index, processes, children_of, seen))
1125 .collect();
1126 ProcessTreeNode { info, children }
1127 }
1128
1129 let mut seen = std::collections::BTreeSet::new();
1130 roots
1131 .into_iter()
1132 .map(|index| build_node(index, &processes, &children_of, &mut seen))
1133 .collect()
1134}
1135
1136fn stdin_to_bytes(input: StdinInput) -> Vec<u8> {
1139 match input {
1140 StdinInput::Text(text) => text.into_bytes(),
1141 StdinInput::Bytes(bytes) => bytes,
1142 }
1143}
1144
1145fn append_exec_output(
1146 buffer: &mut Vec<u8>,
1147 chunk: &[u8],
1148 captured_output_bytes: &mut usize,
1149 channel: &str,
1150) -> std::result::Result<(), ClientError> {
1151 let next_total = captured_output_bytes
1152 .checked_add(chunk.len())
1153 .ok_or_else(|| exec_output_limit_error(channel, usize::MAX))?;
1154 if next_total > EXEC_OUTPUT_CAPTURE_LIMIT_BYTES {
1155 return Err(exec_output_limit_error(channel, next_total));
1156 }
1157 buffer.extend_from_slice(chunk);
1158 *captured_output_bytes = next_total;
1159 Ok(())
1160}
1161
1162fn exec_output_limit_error(channel: &str, size: usize) -> ClientError {
1163 ClientError::Sidecar(format!(
1164 "exec {channel} capture is {size} bytes, limit is {EXEC_OUTPUT_CAPTURE_LIMIT_BYTES}"
1165 ))
1166}
1167
1168fn exited_pids_to_prune(mut entries: Vec<(u32, bool)>, target_len: usize) -> Vec<u32> {
1169 if entries.len() <= target_len {
1170 return Vec::new();
1171 }
1172 let mut remove_count = entries.len() - target_len;
1173 entries.sort_by_key(|(pid, _)| *pid);
1174 let mut out = Vec::new();
1175 for (pid, exited) in entries {
1176 if remove_count == 0 {
1177 break;
1178 }
1179 if !exited {
1180 continue;
1181 }
1182 out.push(pid);
1183 remove_count -= 1;
1184 }
1185 out
1186}
1187
1188fn prune_string_f64_map(map: &SccHashMap<String, f64>, limit: usize) {
1189 let mut keys = Vec::new();
1190 map.scan(|key, _| {
1191 keys.push(key.clone());
1192 });
1193 if keys.len() <= limit {
1194 return;
1195 }
1196 let remove_count = keys.len() - limit;
1197 keys.sort();
1198 for key in keys.into_iter().take(remove_count) {
1199 let _ = map.remove(&key);
1200 }
1201}
1202
1203pub(crate) fn install_output_callback(
1207 tx: broadcast::Sender<Vec<u8>>,
1208 mut callback: OutputCallback,
1209) {
1210 let mut rx = tx.subscribe();
1211 tokio::spawn(async move {
1212 loop {
1213 match rx.recv().await {
1214 Ok(chunk) => callback(&chunk),
1215 Err(broadcast::error::RecvError::Lagged(_)) => continue,
1216 Err(broadcast::error::RecvError::Closed) => break,
1217 }
1218 }
1219 });
1220}
1221
1222fn epoch_ms_now() -> f64 {
1224 use std::time::{SystemTime, UNIX_EPOCH};
1225 SystemTime::now()
1226 .duration_since(UNIX_EPOCH)
1227 .map(|d| d.as_secs_f64() * 1000.0)
1228 .unwrap_or(0.0)
1229}
1230
1231#[cfg(test)]
1232mod tests {
1233 use super::{
1234 append_exec_output, exited_pids_to_prune, prune_string_f64_map, ExecOptions,
1235 DEFAULT_EXEC_CWD, EXEC_OUTPUT_CAPTURE_LIMIT_BYTES,
1236 };
1237 use scc::HashMap as SccHashMap;
1238
1239 #[test]
1240 fn exec_options_default_uses_workspace_cwd() {
1241 assert_eq!(
1242 ExecOptions::default().cwd.as_deref(),
1243 Some(DEFAULT_EXEC_CWD)
1244 );
1245 }
1246
1247 #[test]
1248 fn append_exec_output_rejects_capture_over_limit() {
1249 let mut buffer = vec![0u8; EXEC_OUTPUT_CAPTURE_LIMIT_BYTES - 1];
1250 let mut captured = buffer.len();
1251
1252 append_exec_output(&mut buffer, &[1], &mut captured, "stdout")
1253 .expect("chunk at limit should fit");
1254 assert_eq!(captured, EXEC_OUTPUT_CAPTURE_LIMIT_BYTES);
1255
1256 let error = append_exec_output(&mut buffer, &[2], &mut captured, "stdout")
1257 .expect_err("chunk over limit should fail");
1258 assert!(
1259 error.to_string().contains("exec stdout capture is"),
1260 "unexpected error: {error}"
1261 );
1262 assert_eq!(captured, EXEC_OUTPUT_CAPTURE_LIMIT_BYTES);
1263 assert_eq!(buffer.len(), EXEC_OUTPUT_CAPTURE_LIMIT_BYTES);
1264 }
1265
1266 #[test]
1267 fn exited_pid_pruning_keeps_live_entries_and_removes_oldest_exited() {
1268 let pids = exited_pids_to_prune(vec![(3, true), (1, false), (2, true), (4, true)], 2);
1269 assert_eq!(pids, vec![2, 3]);
1270 }
1271
1272 #[test]
1273 fn observed_time_pruning_enforces_limit() {
1274 let map = SccHashMap::new();
1275 let _ = map.insert("b".to_string(), 2.0);
1276 let _ = map.insert("a".to_string(), 1.0);
1277 let _ = map.insert("c".to_string(), 3.0);
1278
1279 prune_string_f64_map(&map, 2);
1280
1281 assert!(map.read("a", |_, _| ()).is_none());
1282 assert!(map.read("b", |_, _| ()).is_some());
1283 assert!(map.read("c", |_, _| ()).is_some());
1284 }
1285}