chimera_core/transport/
subprocess.rs1use std::collections::{HashMap, HashSet};
2use std::path::PathBuf;
3use std::process::ExitStatus;
4
5use serde::Serialize;
6use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader, BufWriter};
7use tokio::process::{Child, ChildStdin, ChildStdout, Command};
8use tokio::task::JoinHandle;
9
10use crate::AgentError;
11
12const MAX_STDERR_SIZE: usize = 64 * 1024; #[derive(Debug, Clone, bon::Builder)]
15pub struct SubprocessCommand {
16 pub binary: PathBuf,
17 #[builder(default)]
18 pub args: Vec<String>,
19 #[builder(default)]
20 pub env: HashMap<String, String>,
21 #[builder(default)]
24 pub env_remove: HashSet<String>,
25 #[builder(default = true)]
28 pub inherit_env: bool,
29 pub cwd: Option<PathBuf>,
30}
31
32pub struct Subprocess {
33 child: Child,
34 stdin: Option<BufWriter<ChildStdin>>,
35 stdout: Option<ChildStdout>,
36 stderr_task: JoinHandle<String>,
37}
38
39impl Subprocess {
40 pub fn spawn(cmd: SubprocessCommand) -> Result<Self, AgentError> {
41 let mut command = Command::new(&cmd.binary);
42 command.args(&cmd.args);
43
44 if cmd.inherit_env {
45 command.envs(&cmd.env);
46 for key in &cmd.env_remove {
47 command.env_remove(key);
48 }
49 } else {
50 command.env_clear().envs(&cmd.env);
51 }
52
53 if let Some(cwd) = &cmd.cwd {
54 command.current_dir(cwd);
55 }
56
57 command
58 .stdin(std::process::Stdio::piped())
59 .stdout(std::process::Stdio::piped())
60 .stderr(std::process::Stdio::piped());
61 command.kill_on_drop(true);
62
63 let mut child = command
64 .spawn()
65 .map_err(|source| AgentError::SpawnFailed { source })?;
66
67 let stdin = child.stdin.take().map(BufWriter::new);
68 let stdout = child.stdout.take();
69
70 let stderr = child.stderr.take().expect("stderr was piped");
71 let stderr_task = tokio::spawn(async move {
72 let mut buf = vec![0u8; MAX_STDERR_SIZE];
73 let mut reader = BufReader::new(stderr);
74 let mut total = 0;
75
76 loop {
77 match reader.read(&mut buf[total..]).await {
78 Ok(0) => break,
79 Ok(n) => {
80 total += n;
81 if total >= MAX_STDERR_SIZE {
82 break;
83 }
84 }
85 Err(_) => break,
86 }
87 }
88
89 String::from_utf8_lossy(&buf[..total]).into_owned()
90 });
91
92 Ok(Self {
93 child,
94 stdin,
95 stdout,
96 stderr_task,
97 })
98 }
99
100 pub fn take_stdin(&mut self) -> Option<BufWriter<ChildStdin>> {
101 self.stdin.take()
102 }
103
104 pub fn take_stdout(&mut self) -> Option<ChildStdout> {
105 self.stdout.take()
106 }
107
108 pub async fn write(&mut self, data: &str) -> Result<(), AgentError> {
109 let stdin = self.stdin.as_mut().ok_or_else(|| AgentError::Other {
110 message: "stdin already closed".into(),
111 source: None,
112 })?;
113 stdin
114 .write_all(data.as_bytes())
115 .await
116 .map_err(|e| AgentError::StdinWrite { source: e })?;
117 stdin
118 .flush()
119 .await
120 .map_err(|e| AgentError::StdinWrite { source: e })
121 }
122
123 pub async fn write_json(&mut self, value: &impl Serialize) -> Result<(), AgentError> {
124 let json = serde_json::to_string(value).map_err(|e| AgentError::Other {
125 message: "failed to serialize JSON".into(),
126 source: Some(Box::new(e)),
127 })?;
128 self.write(&json).await?;
129 self.write("\n").await
130 }
131
132 pub async fn close_stdin(&mut self) {
133 self.stdin.take();
134 }
135
136 pub async fn kill(&mut self) -> Result<(), AgentError> {
137 self.child.kill().await.map_err(|e| AgentError::Other {
138 message: "failed to kill process".into(),
139 source: Some(Box::new(e)),
140 })
141 }
142
143 pub async fn wait(mut self) -> Result<SubprocessOutput, AgentError> {
144 let status = self.child.wait().await.map_err(|e| AgentError::Other {
145 message: "failed to wait for process".into(),
146 source: Some(Box::new(e)),
147 })?;
148 let stderr = self.stderr_task.await.unwrap_or_default();
149 Ok(SubprocessOutput { status, stderr })
150 }
151}
152
153#[derive(Debug)]
154pub struct SubprocessOutput {
155 pub status: ExitStatus,
156 pub stderr: String,
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn stderr_cap_threshold() {
165 assert_eq!(MAX_STDERR_SIZE, 64 * 1024);
166 }
167}