oxicode_agent/runtime/
shell.rs1use super::ShellOutput;
22use async_trait::async_trait;
23use parking_lot::Mutex;
24use std::path::PathBuf;
25use std::sync::atomic::{AtomicU64, Ordering};
26use std::time::{Duration, Instant};
27use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
28use tokio::process::{Child, ChildStdin, ChildStdout};
29
30const MARKER: &str = "__OXI_SH_DONE__";
31const DEFAULT_MAX_OUTPUT: usize = 512 * 1024;
32
33struct ShellProc {
34 child: Child,
35 stdin: ChildStdin,
36 stdout: BufReader<ChildStdout>,
37 initialized: bool,
39}
40
41pub struct PersistentShellSession {
44 workspace_root: PathBuf,
45 max_output: usize,
46 proc: Mutex<Option<ShellProc>>,
47 active_pgid: AtomicU64,
51}
52
53impl std::fmt::Debug for PersistentShellSession {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 f.debug_struct("PersistentShellSession")
56 .field("workspace_root", &self.workspace_root)
57 .field("alive", &self.proc.lock().is_some())
58 .finish()
59 }
60}
61
62impl PersistentShellSession {
63 pub fn new(workspace_root: PathBuf) -> Self {
65 Self {
66 workspace_root,
67 max_output: DEFAULT_MAX_OUTPUT,
68 proc: Mutex::new(None),
69 active_pgid: AtomicU64::new(0),
70 }
71 }
72
73 pub fn with_max_output(mut self, max: usize) -> Self {
75 self.max_output = max;
76 self
77 }
78
79 fn spawn(&self) -> std::io::Result<ShellProc> {
80 use tokio::process::Command;
81 let mut cmd = Command::new("bash");
82 cmd.args(["--noprofile", "--norc"])
83 .current_dir(&self.workspace_root)
84 .stdin(std::process::Stdio::piped())
85 .stdout(std::process::Stdio::piped())
86 .stderr(std::process::Stdio::piped())
87 .kill_on_drop(true);
88 #[cfg(unix)]
89 {
90 cmd.process_group(0);
92 }
93 let mut child = cmd.spawn()?;
94 let stdin = child
95 .stdin
96 .take()
97 .ok_or_else(|| std::io::Error::other("no stdin"))?;
98 let stdout = child
99 .stdout
100 .take()
101 .ok_or_else(|| std::io::Error::other("no stdout"))?;
102 if let Some(stderr) = child.stderr.take() {
105 tokio::spawn(async move {
106 let mut reader = BufReader::new(stderr);
107 let mut line = String::new();
108 let mut kept: usize = 0;
109 loop {
110 match reader.read_line(&mut line).await {
111 Ok(0) | Err(_) => break,
112 Ok(_) => {
113 kept = kept.saturating_add(line.len());
114 line.clear();
115 if kept >= DEFAULT_MAX_OUTPUT {
116 break; }
118 }
119 }
120 }
121 });
122 }
123 Ok(ShellProc {
124 child,
125 stdin,
126 stdout: BufReader::new(stdout),
127 initialized: false,
128 })
129 }
130
131 fn take_proc(&self) -> std::io::Result<ShellProc> {
133 let existing = self.proc.lock().take();
134 match existing {
135 Some(mut p) => {
136 let alive = p.child.try_wait().map_or(true, |s| s.is_none());
137 if alive { Ok(p) } else { self.spawn() }
138 }
139 None => self.spawn(),
140 }
141 }
142
143 fn put_proc(&self, proc: ShellProc) {
144 self.active_pgid.store(0, Ordering::SeqCst);
145 *self.proc.lock() = Some(proc);
146 }
147}
148
149fn interrupt_active(pgid: u64) {
153 #[cfg(unix)]
154 if pgid != 0 {
155 unsafe {
159 libc::kill(-(pgid as i32), libc::SIGINT);
160 }
161 }
162}
163
164#[async_trait]
165impl super::ShellSession for PersistentShellSession {
166 async fn execute(&self, command: &str, timeout: Duration) -> Result<ShellOutput, String> {
167 let deadline = Instant::now() + timeout;
168 let mut proc = self.take_proc().map_err(|e| format!("spawn bash: {e}"))?;
169 if !proc.initialized {
170 proc.stdin
176 .write_all(b"exec 2>&1\ntrap : INT\n")
177 .await
178 .map_err(|e| format!("bash init write: {e}"))?;
179 proc.stdin
180 .flush()
181 .await
182 .map_err(|e| format!("bash init flush: {e}"))?;
183 proc.initialized = true;
184 }
185 self.active_pgid
186 .store(proc.child.id().unwrap_or(0) as u64, Ordering::SeqCst);
187
188 let payload = format!("{command}\nprintf '%s\\n' \"{MARKER}$?\"\n");
189 if let Err(e) = proc.stdin.write_all(payload.as_bytes()).await {
190 self.active_pgid.store(0, Ordering::SeqCst);
191 let msg = format!("bash stdin write: {e}");
192 let _ = proc.child.kill().await;
193 return Err(msg);
194 }
195 if let Err(e) = proc.stdin.flush().await {
196 self.active_pgid.store(0, Ordering::SeqCst);
197 let msg = format!("bash stdin flush: {e}");
198 let _ = proc.child.kill().await;
199 return Err(msg);
200 }
201
202 let mut stdout = String::new();
203 let mut truncated = false;
204 let mut exit_code: Option<i32> = None;
205 loop {
206 if Instant::now() >= deadline {
207 interrupt_active(self.active_pgid.load(Ordering::SeqCst));
208 break;
209 }
210 let mut line = String::new();
211 let read = tokio::time::timeout_at(
212 tokio::time::Instant::from(deadline),
213 proc.stdout.read_line(&mut line),
214 )
215 .await;
216 match read {
217 Err(_elapsed) => {
218 interrupt_active(self.active_pgid.load(Ordering::SeqCst));
219 break;
220 }
221 Ok(Err(e)) => {
222 let msg = format!("bash stdout read: {e}");
223 let _ = proc.child.kill().await;
224 return Err(msg);
225 }
226 Ok(Ok(0)) => {
227 let msg = "bash exited before the command completed".to_string();
228 let _ = proc.child.kill().await;
229 return Err(msg);
230 }
231 Ok(Ok(_)) => {
232 if let Some(rest) = line.trim_end().strip_prefix(MARKER) {
233 exit_code = rest.trim().parse::<i32>().ok();
234 break;
235 }
236 if stdout.len() + line.len() > self.max_output {
237 truncated = true;
238 } else {
239 stdout.push_str(&line);
240 }
241 }
242 }
243 }
244 self.put_proc(proc);
245 Ok(ShellOutput {
246 stdout,
247 stderr: String::new(),
248 exit_code: exit_code.unwrap_or(124),
249 truncated: truncated || exit_code.is_none(),
250 })
251 }
252
253 fn cancel(&self) {
254 interrupt_active(self.active_pgid.load(Ordering::SeqCst));
255 }
256
257 async fn reset(&self) -> Result<(), String> {
258 let taken = self.proc.lock().take();
259 if let Some(mut p) = taken {
260 let _ = p.child.kill().await;
261 }
262 Ok(())
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use crate::ShellSession as _;
270 use std::sync::Arc;
271
272 #[tokio::test]
273 async fn cwd_and_env_persist() {
274 let dir = tempfile::tempdir().unwrap();
275 let sub = dir.path().join("sub");
276 std::fs::create_dir(&sub).unwrap();
277 let session = PersistentShellSession::new(dir.path().to_path_buf());
278 let out = session
279 .execute("cd sub && export OXI_FIXTURE=1", Duration::from_secs(5))
280 .await
281 .unwrap();
282 assert_eq!(out.exit_code, 0);
283 let out = session
284 .execute("echo \"$PWD $OXI_FIXTURE\"", Duration::from_secs(5))
285 .await
286 .unwrap();
287 assert!(out.stdout.contains("sub"), "cwd must persist: {out:?}");
288 assert!(
289 out.stdout.trim_end().ends_with(" 1"),
290 "env must persist: {out:?}"
291 );
292 }
293
294 #[tokio::test]
295 async fn output_bound_reports_truncated() {
296 let dir = tempfile::tempdir().unwrap();
297 let session = PersistentShellSession::new(dir.path().to_path_buf()).with_max_output(4_096);
298 let out = session
301 .execute("seq 1 200000", Duration::from_secs(10))
302 .await
303 .unwrap();
304 assert!(out.truncated);
305 assert_eq!(out.exit_code, 0);
306 assert!(out.stdout.len() <= 4_096 + 8);
307 }
308
309 #[tokio::test]
310 async fn reset_returns_to_workspace_root() {
311 let dir = tempfile::tempdir().unwrap();
312 let sub = dir.path().join("sub");
313 std::fs::create_dir(&sub).unwrap();
314 let session = PersistentShellSession::new(dir.path().to_path_buf());
315 session
316 .execute("cd sub", Duration::from_secs(5))
317 .await
318 .unwrap();
319 session.reset().await.unwrap();
320 let out = session
321 .execute("pwd", Duration::from_secs(5))
322 .await
323 .unwrap();
324 assert!(
325 !out.stdout.contains("sub"),
326 "reset must restore root: {out:?}"
327 );
328 }
329
330 #[tokio::test]
331 async fn cancel_after_multiple_commands() {
332 let dir = tempfile::tempdir().unwrap();
333 let session = Arc::new(PersistentShellSession::new(dir.path().to_path_buf()));
334 let _ = session
335 .execute("echo one", Duration::from_secs(5))
336 .await
337 .unwrap();
338 let _ = session
339 .execute("echo two", Duration::from_secs(5))
340 .await
341 .unwrap();
342 let worker_session = session.clone();
343 let worker = tokio::spawn(async move {
344 worker_session
345 .execute("sleep 30", Duration::from_secs(60))
346 .await
347 });
348 tokio::time::sleep(Duration::from_millis(300)).await;
349 session.cancel();
350 let out = tokio::time::timeout(Duration::from_secs(10), worker)
351 .await
352 .expect("execute must return")
353 .expect("join")
354 .expect("execute ok");
355 assert_eq!(out.exit_code, 130, "{out:?}");
356 }
357
358 #[tokio::test]
359 async fn cancel_aborts_long_command() {
360 let dir = tempfile::tempdir().unwrap();
361 let session = Arc::new(PersistentShellSession::new(dir.path().to_path_buf()));
362 let worker = {
363 let session = session.clone();
364 tokio::spawn(async move { session.execute("sleep 30", Duration::from_secs(60)).await })
365 };
366 tokio::time::sleep(Duration::from_millis(300)).await;
367 session.cancel();
368 let started = Instant::now();
369 let out = tokio::time::timeout(Duration::from_secs(10), worker)
372 .await
373 .expect("execute must return after cancel")
374 .expect("join")
375 .expect("execute ok");
376 assert!(
377 started.elapsed() < Duration::from_secs(20),
378 "cancel must be prompt"
379 );
380 assert_eq!(out.exit_code, 130, "SIGINT must surface as 130: {out:?}");
381 }
382}