Skip to content

Commit 04118f6

Browse files
fix: handle complex shell commands (pipes) correctly
- Fix UTF-8 byte boundary panic when reading incremental output (use String::drain instead of byte-position slice) - Fix terminate not killing pipeline child processes (use process_group(0) and kill -PGID to signal entire group) - Add regression tests for both issues Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent e0f48d4 commit 04118f6

1 file changed

Lines changed: 84 additions & 21 deletions

File tree

src/process.rs

Lines changed: 84 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ pub struct ProcessInfo {
1616
child_pid: Option<u32>,
1717
stdout_buffer: Arc<Mutex<String>>,
1818
stderr_buffer: Arc<Mutex<String>>,
19-
stdout_position: usize,
20-
stderr_position: usize,
2119
accessed: bool,
2220
finished: Arc<AtomicBool>,
2321
exit_code: Arc<Mutex<Option<i32>>>,
@@ -89,6 +87,9 @@ impl ProcessManager {
8987
.stdin(Stdio::null())
9088
.stdout(Stdio::piped())
9189
.stderr(Stdio::piped())
90+
// Spawn the shell into its own process group so that terminate
91+
// can send signals to the entire group (shell + all pipeline children).
92+
.process_group(0)
9293
.kill_on_drop(true);
9394

9495
if let Some(ref cwd_path) = resolved_cwd {
@@ -200,8 +201,6 @@ impl ProcessManager {
200201
child_pid,
201202
stdout_buffer,
202203
stderr_buffer,
203-
stdout_position: 0,
204-
stderr_position: 0,
205204
accessed: false,
206205
finished,
207206
exit_code,
@@ -236,10 +235,11 @@ impl ProcessManager {
236235

237236
if terminate && !finished.load(Ordering::SeqCst) {
238237
if let Some(pid) = self.processes[&process_id].child_pid {
239-
let _ = nix::sys::signal::kill(
240-
nix::unistd::Pid::from_raw(pid as i32),
241-
nix::sys::signal::Signal::SIGTERM,
242-
);
238+
// Negate the PID to address the entire process group (shell + all pipeline
239+
// children). The shell was spawned with process_group(0), so its PGID equals
240+
// its own PID.
241+
let pgid = nix::unistd::Pid::from_raw(-(pid as i32));
242+
let _ = nix::sys::signal::kill(pgid, nix::sys::signal::Signal::SIGTERM);
243243
}
244244
{
245245
let guard = logger_for_signal.lock().await;
@@ -254,10 +254,8 @@ impl ProcessManager {
254254

255255
if timed_out && !finished.load(Ordering::SeqCst) {
256256
if let Some(pid) = self.processes[&process_id].child_pid {
257-
let _ = nix::sys::signal::kill(
258-
nix::unistd::Pid::from_raw(pid as i32),
259-
nix::sys::signal::Signal::SIGKILL,
260-
);
257+
let pgid = nix::unistd::Pid::from_raw(-(pid as i32));
258+
let _ = nix::sys::signal::kill(pgid, nix::sys::signal::Signal::SIGKILL);
261259
}
262260
{
263261
let guard = logger_for_signal.lock().await;
@@ -334,20 +332,19 @@ impl ProcessManager {
334332
}
335333
}
336334

337-
// Extract incremental output
335+
// Extract incremental output by draining the buffer — this is safe for
336+
// any UTF-8 content because we drain whole Strings (never mid-char slices).
338337
let proc = self.processes.get_mut(&process_id).unwrap();
339338

340339
let new_stdout = {
341-
let guard = proc.stdout_buffer.lock().await;
342-
guard[proc.stdout_position..].to_string()
340+
let mut guard = proc.stdout_buffer.lock().await;
341+
guard.drain(..).collect::<String>()
343342
};
344-
proc.stdout_position += new_stdout.len();
345343

346344
let new_stderr = {
347-
let guard = proc.stderr_buffer.lock().await;
348-
guard[proc.stderr_position..].to_string()
345+
let mut guard = proc.stderr_buffer.lock().await;
346+
guard.drain(..).collect::<String>()
349347
};
350-
proc.stderr_position += new_stderr.len();
351348

352349
let elapsed_time = proc.start_time.elapsed().as_secs_f64() * 1000.0;
353350
let is_finished = proc.finished.load(Ordering::SeqCst);
@@ -359,8 +356,6 @@ impl ProcessManager {
359356

360357
if is_finished {
361358
proc.accessed = true;
362-
proc.stdout_buffer.lock().await.clear();
363-
proc.stderr_buffer.lock().await.clear();
364359
}
365360

366361
Ok(PollResult {
@@ -844,4 +839,72 @@ mod tests {
844839
log_files
845840
);
846841
}
842+
843+
// --- Bug regression tests ---
844+
845+
/// UTF-8 multi-byte characters should not cause a panic when output is read
846+
/// incrementally (stdout_position was a byte offset that could land mid-char).
847+
#[tokio::test]
848+
async fn test_multibyte_utf8_incremental_output() {
849+
let mut pm = ProcessManager::new(false);
850+
// Output contains multi-byte UTF-8 (Chinese characters, emoji).
851+
// Two polls exercise the incremental slice logic.
852+
let id = pm
853+
.spawn_process("printf '你好世界\n' && sleep 0.1 && printf '🎉done\n'", None)
854+
.await
855+
.unwrap();
856+
let r1 = pm.poll_process(id, 200, false, None).await.unwrap();
857+
let r2 = pm.poll_process(id, 3000, false, None).await.unwrap();
858+
let combined = format!("{}{}", r1.stdout, r2.stdout);
859+
assert!(
860+
combined.contains('你'),
861+
"should contain Chinese chars, got: {:?}",
862+
combined
863+
);
864+
assert!(
865+
combined.contains("🎉done"),
866+
"should contain emoji output, got: {:?}",
867+
combined
868+
);
869+
}
870+
871+
/// When terminate=true on a pipeline command, ALL child processes in the
872+
/// pipeline must be killed, not just the shell process.
873+
#[tokio::test]
874+
async fn test_terminate_kills_process_group() {
875+
let mut pm = ProcessManager::new(false);
876+
// `sleep 30 | cat` — the shell forks both `sleep` and `cat`.
877+
// After terminate, neither should survive.
878+
// Use a unique marker to avoid matching other test processes.
879+
let unique = "async_bash_mcp_terminate_test_marker_31415926";
880+
let cmd = format!("sleep 30 | cat # {unique}");
881+
let id = pm.spawn_process(&cmd, None).await.unwrap();
882+
// Brief wait so bash has time to fork the pipeline children.
883+
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
884+
let start = Instant::now();
885+
let result = pm.poll_process(id, 8000, true, None).await.unwrap();
886+
let elapsed = start.elapsed().as_millis();
887+
assert!(
888+
result.finished,
889+
"pipeline should be finished after terminate"
890+
);
891+
assert!(
892+
elapsed < 5000,
893+
"terminate should complete quickly (all procs killed), took {}ms",
894+
elapsed
895+
);
896+
// Give the OS a moment to reap processes.
897+
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
898+
// Confirm the `sleep 30` pipeline child is actually dead (not a lingering orphan).
899+
// If only the shell was killed, `sleep` would still be running.
900+
let orphan_check = std::process::Command::new("pgrep")
901+
.args(["-f", unique])
902+
.output()
903+
.expect("pgrep");
904+
assert!(
905+
orphan_check.stdout.is_empty(),
906+
"pipeline child should not be running after terminate (orphan leak), pgrep found: {:?}",
907+
String::from_utf8_lossy(&orphan_check.stdout)
908+
);
909+
}
847910
}

0 commit comments

Comments
 (0)