Skip to main content

agentos_client/
process.rs

1//! Process execution & management methods + supporting types.
2//!
3//! Ported from `packages/core/src/agent-os.ts` (process methods) and `runtime-compat.ts`
4//! (`ExecOptions`, `ExecResult`, `ProcessInfo`, etc.).
5//!
6//! Two distinct process views: SDK-spawned processes (`processes` map, keyed by user-facing pid)
7//! back `spawn` + the stdin/stdout/stderr/exit subscriptions + `wait/list/get/stop/kill`; the kernel
8//! process table backs `exec`, `all_processes`, `process_tree`.
9
10use 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
25/// Broadcast channel capacity for a spawned process's stdout/stderr fan-out.
26const PROCESS_STREAM_CAPACITY: usize = 1024;
27
28/// Maximum SDK-spawned process entries retained per VM.
29const PROCESS_REGISTRY_LIMIT: usize = 1024;
30
31/// Maximum first-observed process timestamp entries retained per VM.
32const OBSERVED_PROCESS_TIME_LIMIT: usize = 4096;
33
34/// Maximum bytes captured by `exec` across stdout and stderr.
35const EXEC_OUTPUT_CAPTURE_LIMIT_BYTES: usize = 16 * 1024 * 1024;
36
37/// Default guest working directory for `exec`/`spawn`, matching the TS sidecar client.
38pub(crate) const DEFAULT_EXEC_CWD: &str = "/workspace";
39
40/// Base value for the synthetic display-pid sequence used by `spawn` (TS `SYNTHETIC_PID_BASE`). The
41/// first spawned process is assigned exactly this value.
42pub(crate) const SYNTHETIC_PID_BASE: u64 = 1_000_000;
43
44// ---------------------------------------------------------------------------
45// Supporting types
46// ---------------------------------------------------------------------------
47
48/// Timing-mitigation mode for an execution.
49#[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/// `stdin` value: a string or raw bytes.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum StdinInput {
60    Text(String),
61    Bytes(Vec<u8>),
62}
63
64/// A raw-byte streaming callback for stdout/stderr (TS `(data: Uint8Array) => void`). Invoked once
65/// per output chunk as it arrives. Never assume UTF-8: chunks are delivered as raw bytes.
66pub type OutputCallback = Box<dyn FnMut(&[u8]) + Send>;
67
68/// Base options shared by `exec` and `spawn`.
69///
70/// `on_stdout`/`on_stderr` mirror the TS `ExecOptions.onStdout`/`onStderr` raw-byte streaming
71/// callbacks. For `exec` they fire for the duration of the call; for `spawn` they are seeded into the
72/// stdout/stderr fan-out at spawn time (matching the TS initial-handler-set behavior).
73pub 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/// Result of `exec`.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct ExecResult {
106    pub exit_code: i32,
107    pub stdout: String,
108    pub stderr: String,
109}
110
111/// `stdio` mode for a spawn.
112#[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/// Options for `spawn` (extends [`ExecOptions`]).
121#[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/// Public JSON info for SDK-spawned processes.
132#[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/// The pid returned by `spawn`.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144pub struct SpawnHandle {
145    pub pid: u32,
146}
147
148/// Process status from the kernel process table.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "lowercase")]
151pub enum ProcessStatus {
152    Running,
153    Exited,
154}
155
156/// Full kernel process info (TS `KernelProcessInfo`).
157#[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/// A node in the process forest (`ProcessInfo` + children).
177#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
178pub struct ProcessTreeNode {
179    #[serde(flatten)]
180    pub info: ProcessInfo,
181    pub children: Vec<ProcessTreeNode>,
182}
183
184// ---------------------------------------------------------------------------
185// Methods
186// ---------------------------------------------------------------------------
187
188impl AgentOs {
189    /// Run a command to completion. The wire `Execute` request starts the process and returns a
190    /// process id immediately; stdout/stderr are accumulated and the call resolves once the matching
191    /// `ProcessExited` event arrives. This mirrors the TS pass-through to `kernel.exec` semantically:
192    /// the result is the full captured stdout/stderr plus exit code.
193    pub async fn exec(&self, command: &str, options: ExecOptions) -> Result<ExecResult> {
194        // Parse the command line into a `(command, args)` pair the same way the sidecar's
195        // child_process path does: shell-free argv lists spawn directly (preserving the command's
196        // real exit code), while shell syntax or a builtin head runs under `sh -c <line>`.
197        let (resolved_command, resolved_args) = resolve_exec_command(command)?;
198        self.exec_argv(&resolved_command, &resolved_args, options)
199            .await
200    }
201
202    /// Run a command to completion from an already-structured `(command, args)` argv, bypassing the
203    /// `exec` command-line parser. Each `args` element is sent verbatim as a distinct argv element —
204    /// no whitespace re-splitting, no shell metacharacter detection, and no routing through
205    /// `sh -c`. Callers that already hold a structured argv (for example the cron `Exec` action)
206    /// must use this so the structured-argv contract is preserved end to end.
207    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        // Subscribe to events BEFORE issuing the request so no output/exit is missed between the
216        // request landing and the subscription being installed.
217        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        // Deliver any provided stdin, then close stdin so a non-interactive run observes EOF. This
234        // mirrors the TS `runAndCapture` path (`proc.writeStdin(options.stdin); proc.closeStdin()`).
235        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        // A `timeout` (ms) bounds the run: when it elapses, SIGKILL the process and keep draining
266        // until the exit event lands. This mirrors the TS `runAndCapture` timeout race that kills the
267        // process and then awaits its exit code.
268        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    /// Spawn a process. SYNC; returns `{ pid }` only. Installs stdout/stderr fan-out over broadcast
372    /// channels and wires exit via a background event-pump task. The user-facing `pid` is the
373    /// SDK-allocated map key (the wire `process_id` is held inside the [`ProcessEntry`]).
374    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        // Draw the public pid from the dedicated synthetic-pid space (TS `nextSyntheticPid`), seeded
390        // at `SYNTHETIC_PID_BASE`. `exec` uses a separate counter so it never perturbs this sequence.
391        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        // Seeded `None`; the already-exited branch of `on_process_exit` fires immediately once this
400        // watch holds `Some(code)`.
401        let (exit_tx, _) = watch::channel::<Option<i32>>(None);
402        // Seeded `None`; filled with the kernel pid once the `Execute` response lands so
403        // `all_processes`/`process_tree` can remap the kernel snapshot back to this display pid.
404        let (kernel_pid_tx, _) = watch::channel::<Option<u32>>(None);
405
406        // Seed any caller-provided initial stdout/stderr callbacks into the fan-out, matching the TS
407        // initial-handler-set behavior (`stdoutHandlers.add(options.onStdout)`).
408        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        // `spawn` is documented as overwriting any prior entry for a freshly allocated pid; the pid
425        // is monotonic so a collision is not expected.
426        let _ = self.inner().processes.insert(pid, entry);
427        drop(registry_guard);
428
429        // Subscribe to events before issuing the request so the pump sees everything.
430        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    /// Write to a spawned process's stdin. SYNC. Errors with `ProcessNotFound`.
454    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        // Fire-and-forget: the TS API is synchronous and does not surface a write error.
463        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    /// Close a spawned process's stdin. SYNC. Errors with `ProcessNotFound`.
480    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    /// Subscribe to a spawned process's stdout. No replay; multi-subscriber. Errors if unknown.
497    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    /// Subscribe to a spawned process's stderr. No replay; multi-subscriber. Errors if unknown.
507    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    /// Register a once-only exit handler. If the process has already exited, the handler fires
517    /// immediately and synchronously and a no-op unsubscribe is returned (the `watch` already holds
518    /// `Some(code)`). Otherwise the handler fires once when the exit code lands. The exit code is
519    /// `i32`, never null.
520    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        // Already-exited branch: fire immediately + synchronously, return a no-op unsubscribe.
532        if let Some(code) = *rx.borrow() {
533            handler(code);
534            return Ok(Subscription::noop());
535        }
536
537        // Otherwise wait for the watch to transition to `Some(code)` and fire exactly once. The
538        // returned `Subscription` cancels the waiting task on drop (= unsubscribe).
539        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    /// Await a spawned process's exit code. Unknown-pid lookup errors (synchronously in TS; here the
551    /// lookup error is returned before any awaiting begins).
552    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    /// List SDK-spawned processes only. `running = exit_code.is_none()`.
573    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    /// List ALL kernel processes (native sidecar process snapshot).
589    ///
590    /// The kernel snapshot keys processes by their raw kernel pid. SDK-spawned root processes carry a
591    /// synthetic display pid (the `spawn` return value); this remaps each snapshot entry's
592    /// pid/ppid/pgid/sid back to that display pid via the per-process `kernel_pid` watch, so a caller
593    /// can correlate `spawn()` with `all_processes()`/`process_tree()`. Results are sorted ascending
594    /// by display pid (TS `snapshotProcesses` `.sort((l,r) => l.pid - r.pid)`).
595    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        // Snapshot the SDK process registry, keyed by wire `process_id`, capturing exit code,
616        // command, and args. This mirrors the TS `trackedProcessesById` lookup used to build
617        // `displayPidByKernelPid` and override fields.
618        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            // First-observed start time, keyed by `"<process_id>:<kernel_pid>"` (TS `processKey`).
665            let process_key = format!("{}:{}", entry.process_id, entry.pid);
666            let start_time = self.observed_start_time(&process_key, now_ms);
667
668            // Status/exit code: a tracked process whose SDK exit code is known is `exited`; otherwise
669            // a tracked process is `running`; an untracked process uses the snapshot status.
670            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            // Exit time: only tracked-and-exited processes carry one (TS `tracked?.exitTime`).
687            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        // Tracked processes not yet present in the snapshot (the spawn `Execute` has not surfaced in
717        // the kernel table yet). TS fills these with `ppid:0, pgid/sid = pid`.
718        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    /// Return the first-observed start time for a process key, recording `now` the first time it is
753    /// seen so later snapshots report a stable timestamp (TS `observedProcessStartTimes`).
754    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        // Re-read to honor a racing insert that may have won; either value is a valid first-observed
772        // timestamp.
773        self.inner()
774            .observed_process_start_times
775            .read(process_key, |_, value| *value)
776            .unwrap_or(now_ms)
777    }
778
779    /// Return the first-observed exit time for an SDK process id, recording `now` on first sight.
780    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    /// Build the process forest from `all_processes`, linked by `ppid`.
804    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    /// Get a single SDK-spawned process's info. Errors (not None) when not found.
810    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    /// SIGTERM a spawned process. No-op if already exited; errors if unknown.
827    pub fn stop_process(&self, pid: u32) -> std::result::Result<(), ClientError> {
828        self.signal_process(pid, "SIGTERM")
829    }
830
831    /// SIGKILL a spawned process. No-op if already exited; errors if unknown.
832    pub fn kill_process(&self, pid: u32) -> std::result::Result<(), ClientError> {
833        self.signal_process(pid, "SIGKILL")
834    }
835
836    // -----------------------------------------------------------------------
837    // Internal helpers
838    // -----------------------------------------------------------------------
839
840    /// Build the VM-scoped ownership for a wire request.
841    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    /// Allocate a fresh wire `process_id` (used by `exec`, which does not register in the SDK map).
850    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    /// Resolve the wire `process_id` for an SDK pid, erroring with `ProcessNotFound` if unknown.
856    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    /// Send the `Execute` wire request, mapping a rejection into [`ClientError::Kernel`].
864    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    /// Fire-and-forget kill of a wire process by its `process_id` (used by `exec` timeout). The TS
901    /// timeout path calls `proc.kill(9)`, which maps to a `SIGKILL` kill request.
902    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    /// Send a kill signal for an SDK pid. No-op if already exited; errors with `ProcessNotFound` if
922    /// the pid is unknown.
923    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    /// Background pump for a spawned process: issue the `Execute` request, then fan kernel
995    /// `ProcessOutput`/`ProcessExited` events for this process id into the per-process broadcast and
996    /// watch channels. Exited entries are retained for post-exit inspection, then pruned oldest-first
997    /// under registry pressure.
998    #[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                // Seed the kernel pid so `all_processes`/`process_tree` can remap this process's
1024                // kernel-snapshot entry back to its display pid.
1025                if let Some(kernel_pid) = started.pid {
1026                    let _ = kernel_pid_tx.send(Some(kernel_pid));
1027                }
1028            }
1029            Err(error) => {
1030                // The native TS launch-failure path emits the error message (plus a trailing
1031                // newline) on stderr and resolves the wait with exit code 1 (`startTrackedProcess`
1032                // catch -> stderr handlers + `finishProcess(entry, 1)`).
1033                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                    // The event stream closed before an exit event landed. The TS fallback treats a
1049                    // process that has fully disappeared from the VM snapshot as reaped with exit
1050                    // code 0; mirror that terminal value so waiters resolve instead of hanging.
1051                    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
1083/// Assemble a process forest from a flat process list, linking children by `ppid`.
1084///
1085/// Mirrors the TS `processTree` `nodeMap` algorithm exactly: a process is a root iff its `ppid` is
1086/// NOT present among the listed pids. A self-parented process (`ppid == pid`) finds itself as its
1087/// parent, so it is attached as its own child and is excluded from the roots (effectively dropped
1088/// from the output tree). A `seen` guard prevents the self-cycle from recursing forever.
1089fn 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    // Children adjacency keyed by parent pid, preserving input (sorted) order.
1094    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
1136/// Convert a [`StdinInput`] to raw bytes. A string is delivered as its UTF-8 bytes; raw bytes are
1137/// delivered verbatim (binary-safe, never lossy).
1138fn 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
1203/// Drive a caller-supplied output callback from a fresh subscription on the given broadcast channel.
1204/// Each chunk delivered to the channel is forwarded to `callback` as raw bytes. The task ends when
1205/// the channel closes (process exit), matching the TS handler-set lifetime.
1206pub(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
1222/// Current wall-clock time as epoch milliseconds (TS `Date.now()`).
1223fn 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}