1use std::collections::{BTreeMap, VecDeque};
8use std::path::{Path, PathBuf};
9use std::str::FromStr;
10use std::sync::{Arc, Mutex};
11use std::time::Duration;
12
13use async_process::Child;
14use serde::{Deserialize, Serialize};
15use std::pin::pin;
16
17use crate::{Client, Conductor, LineDirection, Role};
18
19type DebugCallback = Arc<dyn Fn(&str, LineDirection) + Send + Sync + 'static>;
20
21const STDERR_CAPTURE_LIMIT: usize = 64 * 1024;
22const STDERR_READ_BUFFER_SIZE: usize = 8 * 1024;
23const STDERR_LINE_TRUNCATION_MARKER: &str = "… [stderr line truncated]";
24const SHUTDOWN_GRACE_PERIOD: Duration = Duration::from_secs(1);
27#[cfg(unix)]
36const TERMINATE_GRACE_PERIOD: Duration = Duration::from_secs(2);
37
38#[derive(Clone, Copy, Debug)]
43struct ShutdownTimeouts {
44 exit: Duration,
46 #[cfg(unix)]
48 terminate: Duration,
49}
50
51impl Default for ShutdownTimeouts {
52 fn default() -> Self {
53 Self {
54 exit: SHUTDOWN_GRACE_PERIOD,
55 #[cfg(unix)]
56 terminate: TERMINATE_GRACE_PERIOD,
57 }
58 }
59}
60
61#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
80#[serde(rename_all = "camelCase", deny_unknown_fields)]
81#[non_exhaustive]
82pub struct AcpAgentConfig {
83 command: PathBuf,
84 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 args: Vec<String>,
86 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
87 env: BTreeMap<String, String>,
88}
89
90impl AcpAgentConfig {
91 #[must_use]
93 pub fn new(command: impl Into<PathBuf>) -> Self {
94 Self {
95 command: command.into(),
96 args: Vec::new(),
97 env: BTreeMap::new(),
98 }
99 }
100
101 #[must_use]
103 pub fn arg(mut self, arg: impl Into<String>) -> Self {
104 self.args.push(arg.into());
105 self
106 }
107
108 #[must_use]
110 pub fn args<I, S>(mut self, args: I) -> Self
111 where
112 I: IntoIterator<Item = S>,
113 S: Into<String>,
114 {
115 self.args.extend(args.into_iter().map(Into::into));
116 self
117 }
118
119 #[must_use]
121 pub fn env(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
122 self.env.insert(name.into(), value.into());
123 self
124 }
125
126 #[must_use]
128 pub fn envs<I, K, V>(mut self, env: I) -> Self
129 where
130 I: IntoIterator<Item = (K, V)>,
131 K: Into<String>,
132 V: Into<String>,
133 {
134 self.env.extend(
135 env.into_iter()
136 .map(|(name, value)| (name.into(), value.into())),
137 );
138 self
139 }
140
141 #[must_use]
143 pub fn command(&self) -> &Path {
144 &self.command
145 }
146
147 #[must_use]
149 pub fn arguments(&self) -> &[String] {
150 &self.args
151 }
152
153 #[must_use]
155 pub fn environment(&self) -> &BTreeMap<String, String> {
156 &self.env
157 }
158}
159
160pub struct AcpAgent {
213 config: AcpAgentConfig,
214 debug_callback: Option<DebugCallback>,
215 shutdown_timeouts: ShutdownTimeouts,
216}
217
218impl std::fmt::Debug for AcpAgent {
219 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220 f.debug_struct("AcpAgent")
221 .field("config", &self.config)
222 .field(
223 "debug_callback",
224 &self.debug_callback.as_ref().map(|_| "..."),
225 )
226 .field("shutdown_timeouts", &self.shutdown_timeouts)
227 .finish()
228 }
229}
230
231impl AcpAgent {
232 #[must_use]
234 pub fn new(config: AcpAgentConfig) -> Self {
235 Self {
236 config,
237 debug_callback: None,
238 shutdown_timeouts: ShutdownTimeouts::default(),
239 }
240 }
241
242 #[must_use]
244 pub fn config(&self) -> &AcpAgentConfig {
245 &self.config
246 }
247
248 #[must_use]
250 pub fn into_config(self) -> AcpAgentConfig {
251 self.config
252 }
253
254 #[must_use]
272 pub fn with_debug<F>(mut self, callback: F) -> Self
273 where
274 F: Fn(&str, LineDirection) + Send + Sync + 'static,
275 {
276 self.debug_callback = Some(Arc::new(callback));
277 self
278 }
279
280 pub(crate) fn spawn_process(
285 &self,
286 ) -> Result<
287 (
288 async_process::ChildStdin,
289 async_process::ChildStdout,
290 async_process::ChildStderr,
291 Child,
292 ),
293 crate::Error,
294 > {
295 let mut std_cmd = std::process::Command::new(&self.config.command);
296 std_cmd.args(&self.config.args);
297 std_cmd.envs(&self.config.env);
298 #[cfg(unix)]
299 {
300 use std::os::unix::process::CommandExt as _;
301
302 std_cmd.process_group(0);
309 }
310 let mut cmd = async_process::Command::from(std_cmd);
311 #[cfg(windows)]
312 {
313 use async_process::windows::CommandExt as _;
314
315 cmd.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
316 }
317 cmd.stdin(std::process::Stdio::piped())
318 .stdout(std::process::Stdio::piped())
319 .stderr(std::process::Stdio::piped());
320
321 let mut child = cmd.spawn().map_err(crate::Error::into_internal_error)?;
322
323 let child_stdin = child
324 .stdin
325 .take()
326 .ok_or_else(|| crate::util::internal_error("Failed to open stdin"))?;
327 let child_stdout = child
328 .stdout
329 .take()
330 .ok_or_else(|| crate::util::internal_error("Failed to open stdout"))?;
331 let child_stderr = child
332 .stderr
333 .take()
334 .ok_or_else(|| crate::util::internal_error("Failed to open stderr"))?;
335
336 Ok((child_stdin, child_stdout, child_stderr, child))
337 }
338}
339
340#[cfg(unix)]
347fn signal_process_group(pid: u32, signal: rustix::process::Signal) {
348 if let Some(pid) = rustix::process::Pid::from_raw(pid.cast_signed()) {
349 let _result = rustix::process::kill_process_group(pid, signal);
350 }
351}
352
353struct ChildGuard(Child);
362
363impl ChildGuard {
364 async fn wait(&mut self) -> std::io::Result<std::process::ExitStatus> {
365 self.0.status().await
366 }
367
368 fn terminate(&mut self) {
369 #[cfg(unix)]
373 signal_process_group(self.0.id(), rustix::process::Signal::KILL);
374 drop(self.0.kill());
377 }
378}
379
380impl Drop for ChildGuard {
381 fn drop(&mut self) {
382 self.terminate();
383 }
384}
385
386#[derive(Default)]
387struct StderrTail {
388 bytes: VecDeque<u8>,
389 truncated: bool,
390}
391
392impl StderrTail {
393 fn push(&mut self, bytes: &[u8]) {
394 if bytes.len() >= STDERR_CAPTURE_LIMIT {
395 self.truncated |= !self.bytes.is_empty() || bytes.len() > STDERR_CAPTURE_LIMIT;
396 self.bytes.clear();
397 self.bytes
398 .extend(bytes[bytes.len() - STDERR_CAPTURE_LIMIT..].iter().copied());
399 return;
400 }
401
402 let overflow = self
403 .bytes
404 .len()
405 .saturating_add(bytes.len())
406 .saturating_sub(STDERR_CAPTURE_LIMIT);
407 if overflow > 0 {
408 self.truncated = true;
409 drop(self.bytes.drain(..overflow));
410 }
411 self.bytes.extend(bytes.iter().copied());
412 }
413
414 fn into_string(mut self) -> String {
415 let truncated = self.truncated;
416 let stderr = String::from_utf8_lossy(self.bytes.make_contiguous());
417 if truncated {
418 format!("[stderr truncated; showing last {STDERR_CAPTURE_LIMIT} bytes]\n{stderr}")
419 } else {
420 stderr.into_owned()
421 }
422 }
423}
424
425#[derive(Clone, Default)]
427struct StderrCapture(Arc<Mutex<StderrTail>>);
428
429impl StderrCapture {
430 fn push(&self, bytes: &[u8]) {
431 self.0
432 .lock()
433 .expect("stderr capture lock poisoned")
434 .push(bytes);
435 }
436
437 fn take(&self) -> String {
438 let tail = std::mem::take(&mut *self.0.lock().expect("stderr capture lock poisoned"));
439 tail.into_string()
440 }
441}
442
443#[derive(Default)]
444struct StderrDebugLines {
445 current: Vec<u8>,
446 truncated: bool,
447 pending_carriage_return: bool,
448}
449
450impl StderrDebugLines {
451 fn push(&mut self, bytes: &[u8], callback: &DebugCallback) {
452 for &byte in bytes {
453 if self.pending_carriage_return {
454 if byte == b'\n' {
455 self.pending_carriage_return = false;
456 self.emit(callback);
457 continue;
458 }
459
460 self.push_byte(b'\r');
461 self.pending_carriage_return = false;
462 }
463
464 match byte {
465 b'\r' => self.pending_carriage_return = true,
466 b'\n' => self.emit(callback),
467 byte => self.push_byte(byte),
468 }
469 }
470 }
471
472 fn finish(&mut self, callback: &DebugCallback) {
473 if self.pending_carriage_return {
474 self.push_byte(b'\r');
475 self.pending_carriage_return = false;
476 }
477 if !self.current.is_empty() || self.truncated {
478 self.emit(callback);
479 }
480 }
481
482 fn push_byte(&mut self, byte: u8) {
483 if self.current.len() < STDERR_CAPTURE_LIMIT {
484 self.current.push(byte);
485 } else {
486 self.truncated = true;
487 }
488 }
489
490 fn emit(&mut self, callback: &DebugCallback) {
491 let line = String::from_utf8_lossy(&self.current);
492
493 if self.truncated {
494 let mut line = line.into_owned();
495 line.push_str(STDERR_LINE_TRUNCATION_MARKER);
496 callback(&line, LineDirection::Stderr);
497 } else {
498 callback(line.as_ref(), LineDirection::Stderr);
499 }
500
501 self.current.clear();
502 self.truncated = false;
503 }
504}
505
506async fn drain_stderr(
507 mut stderr: impl futures::AsyncRead + Unpin,
508 debug_callback: Option<DebugCallback>,
509 capture: StderrCapture,
510) -> Option<std::io::Error> {
511 use futures::AsyncReadExt as _;
512
513 let mut debug_lines = debug_callback.as_ref().map(|_| StderrDebugLines::default());
514 let mut buffer = [0; STDERR_READ_BUFFER_SIZE];
515
516 let read_error = loop {
517 match stderr.read(&mut buffer).await {
518 Ok(0) => break None,
519 Ok(read) => {
520 let bytes = &buffer[..read];
521 capture.push(bytes);
524 if let (Some(lines), Some(callback)) =
525 (debug_lines.as_mut(), debug_callback.as_ref())
526 {
527 lines.push(bytes, callback);
528 }
529 }
530 Err(error) => break Some(error),
531 }
532 };
533
534 if let (Some(lines), Some(callback)) = (debug_lines.as_mut(), debug_callback.as_ref()) {
535 lines.finish(callback);
536 }
537
538 read_error
539}
540
541struct ExitedChild {
542 guard: ChildGuard,
543 status: std::process::ExitStatus,
544 stderr_rx: futures::channel::oneshot::Receiver<()>,
545 stderr_capture: StderrCapture,
546}
547
548async fn wait_for_child(
551 mut guard: ChildGuard,
552 stderr_rx: futures::channel::oneshot::Receiver<()>,
553 stderr_capture: StderrCapture,
554) -> Result<ExitedChild, crate::Error> {
555 let status = guard
556 .wait()
557 .await
558 .map_err(|e| crate::util::internal_error(format!("Failed to wait for process: {e}")))?;
559
560 Ok(ExitedChild {
561 guard,
562 status,
563 stderr_rx,
564 stderr_capture,
565 })
566}
567
568async fn finish_child_exit(child: ExitedChild) -> Result<(), crate::Error> {
571 let ExitedChild {
572 mut guard,
573 status,
574 stderr_rx,
575 stderr_capture,
576 } = child;
577
578 guard.terminate();
581
582 if status.success() {
583 Ok(())
584 } else {
585 match futures::future::select(stderr_rx, async_io::Timer::after(SHUTDOWN_GRACE_PERIOD))
586 .await
587 {
588 futures::future::Either::Left((_, _)) => {}
589 futures::future::Either::Right((_, stderr_rx)) => {
590 tracing::debug!(
591 grace = ?SHUTDOWN_GRACE_PERIOD,
592 "Agent stderr remained open after process exit; reporting stderr captured so far"
593 );
594 drop(stderr_rx);
595 }
596 }
597 let stderr = stderr_capture.take();
600
601 let message = if stderr.is_empty() {
602 format!("Process exited with {status}")
603 } else {
604 format!("Process exited with {status}: {stderr}")
605 };
606
607 Err(crate::util::internal_error(message))
608 }
609}
610
611async fn await_protocol_shutdown_after_successful_child_exit<F>(
612 protocol_future: F,
613 grace: Duration,
614) -> Result<(), crate::Error>
615where
616 F: std::future::Future<Output = Result<(), crate::Error>> + Unpin,
617{
618 match futures::future::select(protocol_future, async_io::Timer::after(grace)).await {
619 futures::future::Either::Left((result, _)) => result,
620 futures::future::Either::Right((_, protocol_future)) => {
621 tracing::debug!(
622 ?grace,
623 "Protocol transport remained open after successful agent process exit; stopping it"
624 );
625 drop(protocol_future);
626 Ok(())
627 }
628 }
629}
630
631async fn terminate_unresponsive_child<F>(
642 child_wait: F,
643 #[cfg(unix)] child_pid: u32,
644 timeouts: ShutdownTimeouts,
645) -> Result<(), crate::Error>
646where
647 F: std::future::Future<Output = Result<ExitedChild, crate::Error>> + Unpin,
648{
649 #[cfg(unix)]
650 {
651 tracing::debug!(
652 grace = ?timeouts.exit,
653 "Agent process did not exit after its stdin closed; sending SIGTERM"
654 );
655 signal_process_group(child_pid, rustix::process::Signal::TERM);
658 match futures::future::select(child_wait, async_io::Timer::after(timeouts.terminate)).await
659 {
660 futures::future::Either::Left((child, _)) => {
661 let ExitedChild {
662 mut guard, status, ..
663 } = child?;
664 tracing::debug!(%status, "Agent process exited after SIGTERM");
665 guard.terminate();
668 }
669 futures::future::Either::Right((_, child_wait)) => {
670 tracing::debug!(
671 grace = ?timeouts.terminate,
672 "Agent process did not exit after SIGTERM; sending SIGKILL"
673 );
674 drop(child_wait);
676 }
677 }
678 }
679 #[cfg(not(unix))]
680 {
681 tracing::debug!(
682 grace = ?timeouts.exit,
683 "Agent process did not exit after its stdin closed; terminating it"
684 );
685 drop(child_wait);
687 }
688 Ok(())
689}
690
691async fn write_line_with_shutdown_timeout<W>(
692 writer: &mut W,
693 line: String,
694 stdout_eof_rx: &mut Option<futures::channel::oneshot::Receiver<()>>,
695 stdout_eof_seen: &mut bool,
696 grace: Duration,
697) -> std::io::Result<()>
698where
699 W: futures::AsyncWrite + Unpin + ?Sized,
700{
701 let write = Box::pin(crate::jsonrpc::write_line(writer, line));
702
703 if *stdout_eof_seen {
704 return await_write_during_shutdown(write, grace).await;
705 }
706
707 let Some(stdout_eof) = stdout_eof_rx.as_mut() else {
708 return write.await;
709 };
710
711 match futures::future::select(write, stdout_eof).await {
712 futures::future::Either::Left((result, _)) => result,
713 futures::future::Either::Right((stdout_eof, write)) => {
714 *stdout_eof_rx = None;
715 if stdout_eof.is_err() {
716 return write.await;
719 }
720
721 *stdout_eof_seen = true;
722 await_write_during_shutdown(write, grace).await
723 }
724 }
725}
726
727async fn await_write_during_shutdown<F>(write: F, grace: Duration) -> std::io::Result<()>
728where
729 F: std::future::Future<Output = std::io::Result<()>> + Unpin,
730{
731 match futures::future::select(write, async_io::Timer::after(grace)).await {
732 futures::future::Either::Left((result, _)) => result,
733 futures::future::Either::Right((_, write)) => {
734 tracing::debug!(
735 ?grace,
736 "Pending protocol output did not drain after agent stdout closed"
737 );
738 drop(write);
739 Err(std::io::Error::new(
740 std::io::ErrorKind::TimedOut,
741 format!(
742 "Agent closed its protocol output but pending protocol output did not drain within {grace:?}"
743 ),
744 ))
745 }
746 }
747}
748
749pub trait AcpAgentCounterpartRole: Role {}
751
752impl AcpAgentCounterpartRole for Client {}
753
754impl AcpAgentCounterpartRole for Conductor {}
755
756impl<Counterpart: AcpAgentCounterpartRole> crate::ConnectTo<Counterpart> for AcpAgent {
757 async fn connect_to(
758 self,
759 client: impl crate::ConnectTo<Counterpart::Counterpart>,
760 ) -> Result<(), crate::Error> {
761 let (lines, process) = self.spawn_connection()?;
762 process
763 .supervise(crate::ConnectTo::<Counterpart>::connect_to(lines, client))
764 .await
765 }
766
767 fn into_channel_and_future(self) -> (crate::Channel, Option<crate::ConnectionDriver>) {
775 match self.spawn_connection() {
776 Ok((lines, process)) => {
777 let (channel, driver) =
778 crate::ConnectTo::<Counterpart>::into_channel_and_future(lines);
779 let driver = driver
780 .expect("the line transport owns its I/O")
781 .map_future(|work| process.supervise(work));
782 (channel, Some(driver))
783 }
784 Err(error) => {
785 let (channel, peer) = crate::Channel::duplex();
786 let driver = crate::ConnectionDriver::new(async move {
787 drop(peer);
788 Err(error)
789 });
790 (channel, Some(driver))
791 }
792 }
793 }
794}
795
796type AgentOutgoingLines =
797 std::pin::Pin<Box<dyn futures::Sink<String, Error = std::io::Error> + Send>>;
798type AgentIncomingLines =
799 std::pin::Pin<Box<dyn futures::Stream<Item = std::io::Result<String>> + Send>>;
800
801struct AgentProcess {
804 stderr_future: futures::future::BoxFuture<'static, ()>,
805 child_wait: futures::future::BoxFuture<'static, Result<ExitedChild, crate::Error>>,
806 #[cfg(unix)]
807 child_pid: u32,
808 shutdown_timeouts: ShutdownTimeouts,
809}
810
811impl AcpAgent {
812 fn spawn_connection(
815 self,
816 ) -> Result<
817 (
818 crate::Lines<AgentOutgoingLines, AgentIncomingLines>,
819 AgentProcess,
820 ),
821 crate::Error,
822 > {
823 use futures::io::BufReader;
824 use futures::{AsyncBufReadExt, StreamExt};
825
826 let (child_stdin, child_stdout, child_stderr, child) = self.spawn_process()?;
827
828 let (stderr_tx, stderr_rx) = futures::channel::oneshot::channel();
831 let stderr_capture = StderrCapture::default();
832
833 let debug_callback = self.debug_callback.clone();
837 let capture = stderr_capture.clone();
838 let stderr_future = async move {
839 let read_error = drain_stderr(child_stderr, debug_callback, capture).await;
840 let _ = stderr_tx.send(());
841
842 if let Some(error) = read_error {
843 tracing::warn!(
844 ?error,
845 "Failed to read process stderr; stderr will no longer be captured"
846 );
847 }
848 };
849
850 let shutdown_timeouts = self.shutdown_timeouts;
851 #[cfg(unix)]
852 let child_pid = child.id();
853
854 let child_wait = wait_for_child(ChildGuard(child), stderr_rx, stderr_capture);
857
858 let incoming_lines: std::pin::Pin<
860 Box<dyn futures::Stream<Item = std::io::Result<String>> + Send>,
861 > = if let Some(callback) = self.debug_callback.clone() {
862 Box::pin(BufReader::new(child_stdout).lines().inspect(move |result| {
863 if let Ok(line) = result {
864 callback(line, LineDirection::Stdout);
865 }
866 }))
867 } else {
868 Box::pin(BufReader::new(child_stdout).lines())
869 };
870
871 let (stdout_eof_tx, stdout_eof_rx) = futures::channel::oneshot::channel();
876 let mut stdout_eof_tx = Some(stdout_eof_tx);
877 let mut incoming_lines = incoming_lines;
878 let incoming_lines: AgentIncomingLines = Box::pin(futures::stream::poll_fn(move |cx| {
879 let next = incoming_lines.as_mut().poll_next(cx);
880 if matches!(next, std::task::Poll::Ready(None))
881 && let Some(stdout_eof_tx) = stdout_eof_tx.take()
882 {
883 let _ = stdout_eof_tx.send(());
884 }
885 next
886 }));
887
888 let outgoing_sink: AgentOutgoingLines = Box::pin(futures::sink::unfold(
890 (
891 child_stdin,
892 self.debug_callback.clone(),
893 Some(stdout_eof_rx),
894 false,
895 ),
896 async move |(mut writer, callback, mut stdout_eof_rx, mut stdout_eof_seen),
897 line: String| {
898 if let Some(callback) = callback.as_ref() {
899 callback(&line, LineDirection::Stdin);
900 }
901 write_line_with_shutdown_timeout(
902 &mut writer,
903 line,
904 &mut stdout_eof_rx,
905 &mut stdout_eof_seen,
906 SHUTDOWN_GRACE_PERIOD,
907 )
908 .await?;
909 Ok::<_, std::io::Error>((writer, callback, stdout_eof_rx, stdout_eof_seen))
910 },
911 ));
912
913 Ok((
914 crate::Lines::new(outgoing_sink, incoming_lines),
915 AgentProcess {
916 stderr_future: Box::pin(stderr_future),
917 child_wait: Box::pin(child_wait),
918 #[cfg(unix)]
919 child_pid,
920 shutdown_timeouts,
921 },
922 ))
923 }
924}
925
926impl AgentProcess {
927 async fn supervise(
930 self,
931 protocol_future: impl std::future::Future<Output = Result<(), crate::Error>>,
932 ) -> Result<(), crate::Error> {
933 let Self {
934 stderr_future,
935 child_wait,
936 #[cfg(unix)]
937 child_pid,
938 shutdown_timeouts,
939 } = self;
940 let protocol_future = Box::pin(protocol_future);
942
943 let main_race = async {
948 match futures::future::select(protocol_future, child_wait).await {
949 futures::future::Either::Left((result, child_wait)) => {
950 result?;
951 match futures::future::select(
956 child_wait,
957 async_io::Timer::after(shutdown_timeouts.exit),
958 )
959 .await
960 {
961 futures::future::Either::Left((child, _)) => {
962 finish_child_exit(child?).await
963 }
964 futures::future::Either::Right((_, child_wait)) => {
965 terminate_unresponsive_child(
966 child_wait,
967 #[cfg(unix)]
968 child_pid,
969 shutdown_timeouts,
970 )
971 .await
972 }
973 }
974 }
975 futures::future::Either::Right((child, protocol_future)) => {
976 finish_child_exit(child?).await?;
977 await_protocol_shutdown_after_successful_child_exit(
978 protocol_future,
979 SHUTDOWN_GRACE_PERIOD,
980 )
981 .await
982 }
983 }
984 };
985
986 let main_race = pin!(main_race);
989 match futures::future::select(main_race, stderr_future).await {
990 futures::future::Either::Left((result, _)) => result,
991 futures::future::Either::Right(((), main_race)) => main_race.await,
992 }
993 }
994}
995
996impl AcpAgent {
997 pub fn from_args<I, T>(args: I) -> Result<Self, crate::Error>
1015 where
1016 I: IntoIterator<Item = T>,
1017 T: ToString,
1018 {
1019 let args: Vec<String> = args.into_iter().map(|s| s.to_string()).collect();
1020
1021 if args.is_empty() {
1022 return Err(crate::util::internal_error("Arguments cannot be empty"));
1023 }
1024
1025 let mut env = BTreeMap::new();
1026 let mut command_idx = 0;
1027
1028 for (i, arg) in args.iter().enumerate() {
1029 if let Some((name, value)) = parse_env_var(arg) {
1030 env.insert(name, value);
1031 command_idx = i + 1;
1032 } else {
1033 break;
1034 }
1035 }
1036
1037 if command_idx >= args.len() {
1038 return Err(crate::util::internal_error(
1039 "No command found (only environment variables provided)",
1040 ));
1041 }
1042
1043 let command = PathBuf::from(&args[command_idx]);
1044 let cmd_args = args[command_idx + 1..].to_vec();
1045
1046 Ok(Self::new(
1047 AcpAgentConfig::new(command).args(cmd_args).envs(env),
1048 ))
1049 }
1050}
1051
1052fn parse_env_var(s: &str) -> Option<(String, String)> {
1054 let eq_pos = s.find('=')?;
1055 if eq_pos == 0 {
1056 return None;
1057 }
1058
1059 let name = &s[..eq_pos];
1060 let value = &s[eq_pos + 1..];
1061
1062 let mut chars = name.chars();
1063 let first = chars.next()?;
1064 if !first.is_ascii_alphabetic() && first != '_' {
1065 return None;
1066 }
1067 if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
1068 return None;
1069 }
1070
1071 Some((name.to_string(), value.to_string()))
1072}
1073
1074impl FromStr for AcpAgent {
1075 type Err = crate::Error;
1076
1077 fn from_str(s: &str) -> Result<Self, Self::Err> {
1078 let trimmed = s.trim();
1079
1080 if trimmed.starts_with('{') {
1081 let config = serde_json::from_str(trimmed)
1082 .map_err(|e| crate::util::internal_error(format!("Failed to parse JSON: {e}")))?;
1083 return Ok(Self::new(config));
1084 }
1085
1086 let parts = shell_words::split(trimmed)
1087 .map_err(|e| crate::util::internal_error(format!("Failed to parse command: {e}")))?;
1088
1089 Self::from_args(parts)
1090 }
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095 use super::*;
1096 use std::sync::Mutex;
1097 use std::sync::atomic::{AtomicUsize, Ordering};
1098
1099 fn recording_debug_callback() -> (DebugCallback, Arc<Mutex<Vec<String>>>) {
1100 let lines = Arc::new(Mutex::new(Vec::new()));
1101 let recorded = lines.clone();
1102 let callback = Arc::new(move |line: &str, direction| {
1103 assert_eq!(direction, LineDirection::Stderr);
1104 recorded.lock().unwrap().push(line.to_owned());
1105 });
1106 (callback, lines)
1107 }
1108
1109 #[test]
1110 fn stderr_tail_keeps_last_bytes() {
1111 let initial = vec![b'a'; STDERR_CAPTURE_LIMIT];
1112
1113 let mut exact = StderrTail::default();
1114 exact.push(&initial);
1115 assert_eq!(exact.into_string(), String::from_utf8(initial).unwrap());
1116
1117 let mut truncated = StderrTail::default();
1118 truncated.push(&vec![b'a'; STDERR_CAPTURE_LIMIT]);
1119 truncated.push(b"the end");
1120 let captured = truncated.into_string();
1121 let (notice, tail) = captured.split_once('\n').unwrap();
1122 assert_eq!(
1123 notice,
1124 format!("[stderr truncated; showing last {STDERR_CAPTURE_LIMIT} bytes]")
1125 );
1126 assert_eq!(tail.len(), STDERR_CAPTURE_LIMIT);
1127 assert!(tail.ends_with("the end"));
1128 }
1129
1130 #[test]
1131 fn stderr_debug_callback_preserves_lines() {
1132 let (callback, recorded) = recording_debug_callback();
1133 let mut lines = StderrDebugLines::default();
1134
1135 lines.push(b"one\r", &callback);
1136 lines.push(b"\n\ntw", &callback);
1137 lines.push(b"o\nbad\xff\nlast\r", &callback);
1138 lines.finish(&callback);
1139
1140 assert_eq!(
1141 *recorded.lock().unwrap(),
1142 ["one", "", "two", "bad\u{fffd}", "last\r"]
1143 );
1144 }
1145
1146 #[test]
1147 fn stderr_debug_callback_truncates_oversized_lines() {
1148 let (callback, recorded) = recording_debug_callback();
1149 let mut lines = StderrDebugLines::default();
1150 let exact = vec![b'y'; STDERR_CAPTURE_LIMIT];
1151 let oversized = vec![b'x'; STDERR_CAPTURE_LIMIT + 1];
1152
1153 lines.push(&exact, &callback);
1154 lines.push(b"\r\n", &callback);
1155 lines.push(&oversized, &callback);
1156 assert_eq!(lines.current.len(), STDERR_CAPTURE_LIMIT);
1157 assert!(lines.truncated);
1158 lines.push(b"\nnext\n", &callback);
1159
1160 let recorded = recorded.lock().unwrap();
1161 assert_eq!(recorded.len(), 3);
1162 assert_eq!(recorded[0].len(), STDERR_CAPTURE_LIMIT);
1163 assert!(!recorded[0].ends_with(STDERR_LINE_TRUNCATION_MARKER));
1164 assert_eq!(
1165 recorded[1].len(),
1166 STDERR_CAPTURE_LIMIT + STDERR_LINE_TRUNCATION_MARKER.len()
1167 );
1168 assert!(recorded[1].ends_with(STDERR_LINE_TRUNCATION_MARKER));
1169 assert_eq!(recorded[2], "next");
1170 }
1171
1172 struct ErrorAfterData {
1173 polls: Arc<AtomicUsize>,
1174 }
1175
1176 impl futures::AsyncRead for ErrorAfterData {
1177 fn poll_read(
1178 self: std::pin::Pin<&mut Self>,
1179 _cx: &mut std::task::Context<'_>,
1180 buffer: &mut [u8],
1181 ) -> std::task::Poll<std::io::Result<usize>> {
1182 match self.polls.fetch_add(1, Ordering::SeqCst) {
1183 0 => {
1184 buffer[..7].copy_from_slice(b"partial");
1185 std::task::Poll::Ready(Ok(7))
1186 }
1187 1 => std::task::Poll::Ready(Err(std::io::Error::other("read failed"))),
1188 _ => panic!("stderr reader was polled again after an error"),
1189 }
1190 }
1191 }
1192
1193 struct HeldOpenStderr(futures::io::Cursor<Vec<u8>>);
1194
1195 impl futures::AsyncRead for HeldOpenStderr {
1196 fn poll_read(
1197 self: std::pin::Pin<&mut Self>,
1198 cx: &mut std::task::Context<'_>,
1199 buffer: &mut [u8],
1200 ) -> std::task::Poll<std::io::Result<usize>> {
1201 match std::pin::Pin::new(&mut self.get_mut().0).poll_read(cx, buffer) {
1202 std::task::Poll::Ready(Ok(0)) => std::task::Poll::Pending,
1204 result => result,
1205 }
1206 }
1207 }
1208
1209 #[test]
1210 fn stderr_capture_is_available_before_eof_and_outside_callback_locks() {
1211 use futures::FutureExt as _;
1212
1213 let capture = StderrCapture::default();
1214 let (record, recorded) = recording_debug_callback();
1215 let callback: DebugCallback = Arc::new({
1216 let capture = capture.clone();
1217 move |line, direction| {
1218 assert!(
1219 capture.0.try_lock().is_ok(),
1220 "debug callbacks must not run under the capture lock"
1221 );
1222 record(line, direction);
1223 }
1224 });
1225 let mut drain = pin!(drain_stderr(
1226 HeldOpenStderr(futures::io::Cursor::new(b"diagnostic\npartial".to_vec())),
1227 Some(callback),
1228 capture.clone(),
1229 ));
1230 assert!((&mut drain).now_or_never().is_none());
1231 assert_eq!(capture.take(), "diagnostic\npartial");
1232 assert_eq!(*recorded.lock().unwrap(), ["diagnostic"]);
1233 }
1234
1235 #[cfg(unix)]
1236 #[tokio::test]
1237 async fn nonzero_exit_preserves_captured_stderr_without_eof() {
1238 let agent = AcpAgent::from_args(["/bin/sh", "-c", "exit 17"]).unwrap();
1239 let (stdin, stdout, stderr, child) = agent.spawn_process().unwrap();
1240 drop((stdin, stdout, stderr));
1241
1242 let mut bytes = vec![b'x'; STDERR_CAPTURE_LIMIT + 1024];
1243 bytes.extend_from_slice(b"\nACP_BUFFERED_ERROR\nunterminated");
1244 let (callback, recorded) = recording_debug_callback();
1245 let (stderr_tx, stderr_rx) = futures::channel::oneshot::channel();
1246 let stderr_capture = StderrCapture::default();
1247 let capture = stderr_capture.clone();
1248 let drain = Box::pin(async move {
1249 let _error = drain_stderr(
1250 HeldOpenStderr(futures::io::Cursor::new(bytes)),
1251 Some(callback),
1252 capture,
1253 )
1254 .await;
1255 let _ = stderr_tx.send(());
1256 });
1257 let report = async move {
1258 let child = wait_for_child(ChildGuard(child), stderr_rx, stderr_capture).await?;
1259 finish_child_exit(child).await
1260 };
1261
1262 let error = tokio::time::timeout(std::time::Duration::from_secs(5), async {
1263 match futures::future::select(pin!(report), drain).await {
1264 futures::future::Either::Left((result, _)) => result,
1265 futures::future::Either::Right(_) => panic!("stderr must remain open"),
1266 }
1267 })
1268 .await
1269 .expect("stderr reporting must remain bounded")
1270 .expect_err("nonzero child exit should be reported");
1271
1272 assert!(
1273 recorded
1274 .lock()
1275 .unwrap()
1276 .iter()
1277 .any(|line| line == "ACP_BUFFERED_ERROR"),
1278 "the diagnostic was read before reporting the exit"
1279 );
1280 let detail = error
1281 .data
1282 .as_ref()
1283 .and_then(serde_json::Value::as_str)
1284 .unwrap();
1285 assert!(detail.contains("exit status: 17"), "{error:?}");
1286 assert!(detail.contains("ACP_BUFFERED_ERROR"), "{error:?}");
1287 assert!(
1288 detail.contains("[stderr truncated; showing last"),
1289 "{error:?}"
1290 );
1291 assert!(detail.ends_with("unterminated"), "{error:?}");
1292 assert_eq!(
1293 detail.split_once('\n').unwrap().1.len(),
1294 STDERR_CAPTURE_LIMIT
1295 );
1296 }
1297
1298 #[tokio::test]
1299 async fn stderr_drain_stops_after_read_error() {
1300 let polls = Arc::new(AtomicUsize::new(0));
1301 let (callback, recorded) = recording_debug_callback();
1302 let capture = StderrCapture::default();
1303
1304 let error = drain_stderr(
1305 ErrorAfterData {
1306 polls: polls.clone(),
1307 },
1308 Some(callback),
1309 capture.clone(),
1310 )
1311 .await;
1312
1313 assert_eq!(capture.take(), "partial");
1314 assert_eq!(error.unwrap().to_string(), "read failed");
1315 assert_eq!(polls.load(Ordering::SeqCst), 2);
1316 assert_eq!(*recorded.lock().unwrap(), ["partial"]);
1317 }
1318
1319 #[tokio::test]
1320 async fn successful_child_exit_bounds_protocol_shutdown_cleanly() {
1321 let grace = std::time::Duration::from_millis(10);
1322 tokio::time::timeout(
1323 std::time::Duration::from_secs(1),
1324 await_protocol_shutdown_after_successful_child_exit(
1325 futures::future::pending::<Result<(), crate::Error>>(),
1326 grace,
1327 ),
1328 )
1329 .await
1330 .expect("protocol shutdown wait should be bounded")
1331 .expect("a successful child exit should stop the pending protocol cleanly");
1332 }
1333
1334 #[tokio::test]
1335 async fn successful_child_exit_preserves_ready_protocol_error() {
1336 let error = await_protocol_shutdown_after_successful_child_exit(
1337 futures::future::ready(Err(crate::util::internal_error(
1338 "protocol failed during shutdown",
1339 ))),
1340 std::time::Duration::from_secs(1),
1341 )
1342 .await
1343 .expect_err("a ready protocol error should remain authoritative");
1344 let detail = error
1345 .data
1346 .as_ref()
1347 .and_then(serde_json::Value::as_str)
1348 .unwrap_or_default();
1349
1350 assert!(
1351 detail.contains("protocol failed during shutdown"),
1352 "unexpected protocol error: {error:?}"
1353 );
1354 }
1355
1356 #[cfg(unix)]
1357 #[tokio::test]
1358 async fn large_unterminated_stderr_is_fully_drained() {
1359 let agent = AcpAgent::from_args([
1360 "/bin/sh",
1361 "-c",
1362 r#"i=0; while [ "$i" -lt 4096 ]; do printf '%01024d' 0; i=$((i + 1)); done >&2; printf ACP_END >&2; exit 17"#,
1363 ])
1364 .unwrap();
1365 let (child_stdin, child_stdout, child_stderr, child) = agent.spawn_process().unwrap();
1366 drop(child_stdin);
1367 drop(child_stdout);
1368 let mut guard = ChildGuard(child);
1369 let capture = StderrCapture::default();
1370
1371 let (read_error, status) =
1372 tokio::time::timeout(std::time::Duration::from_secs(10), async {
1373 futures::join!(
1374 drain_stderr(child_stderr, None, capture.clone()),
1375 guard.wait()
1376 )
1377 })
1378 .await
1379 .expect("stderr drain should not block after its retained tail is full");
1380
1381 assert_eq!(status.unwrap().code(), Some(17));
1382 assert!(read_error.is_none());
1383 let captured = capture.take();
1384 let (notice, tail) = captured.split_once('\n').unwrap();
1385 assert_eq!(
1386 notice,
1387 format!("[stderr truncated; showing last {STDERR_CAPTURE_LIMIT} bytes]")
1388 );
1389 assert_eq!(tail.len(), STDERR_CAPTURE_LIMIT);
1390 assert!(tail.ends_with("ACP_END"));
1391 }
1392
1393 #[cfg(unix)]
1394 #[tokio::test]
1395 async fn protocol_eof_still_reports_nonzero_child_exit() {
1396 let agent = AcpAgent::from_args([
1397 "/bin/sh",
1398 "-c",
1399 "exec 1>&-; cat >/dev/null; printf ACP_TEST_FAILURE_AFTER_STDOUT_EOF >&2; exit 17",
1400 ])
1401 .unwrap();
1402
1403 let error = tokio::time::timeout(
1404 std::time::Duration::from_secs(5),
1405 Client.v1().connect_to(agent),
1406 )
1407 .await
1408 .expect("connection should finish after the child exits")
1409 .expect_err("nonzero child exit after protocol EOF should be reported");
1410 let detail = error
1411 .data
1412 .as_ref()
1413 .map(serde_json::Value::to_string)
1414 .unwrap_or_default();
1415
1416 assert!(
1417 detail.contains("exit status: 17"),
1418 "child exit status should be preserved: {error:?}"
1419 );
1420 assert!(
1421 detail.contains("ACP_TEST_FAILURE_AFTER_STDOUT_EOF"),
1422 "child stderr should be preserved: {error:?}"
1423 );
1424 }
1425
1426 #[cfg(unix)]
1427 #[tokio::test]
1428 async fn successful_child_exit_does_not_cancel_active_foreground() {
1429 let agent = AcpAgent::from_args(["/bin/sh", "-c", "exit 0"]).unwrap();
1430 let (started_tx, started_rx) = futures::channel::oneshot::channel();
1431 let (closed_tx, closed_rx) = futures::channel::oneshot::channel();
1432 let (close_release_tx, close_release_rx) = futures::channel::oneshot::channel();
1433 let (release_tx, release_rx) = futures::channel::oneshot::channel();
1434 let connection = tokio::spawn(
1435 Client
1436 .v1()
1437 .on_close(async move |_cx| {
1438 closed_tx.send(()).map_err(|()| {
1439 crate::Error::internal_error().data("close observer dropped")
1440 })?;
1441 close_release_rx.await.map_err(|_| {
1442 crate::Error::internal_error().data("close callback release dropped")
1443 })
1444 })
1445 .connect_with(agent, async move |_cx| {
1446 started_tx.send(()).map_err(|()| {
1447 crate::Error::internal_error().data("foreground observer dropped")
1448 })?;
1449 release_rx.await.map_err(|_| {
1450 crate::Error::internal_error().data("foreground release dropped")
1451 })
1452 }),
1453 );
1454
1455 tokio::time::timeout(std::time::Duration::from_secs(5), started_rx)
1456 .await
1457 .expect("foreground should start")
1458 .expect("foreground should report that it started");
1459
1460 tokio::time::timeout(std::time::Duration::from_secs(5), closed_rx)
1461 .await
1462 .expect("successful child exit should close the protocol transport")
1463 .expect("successful child exit should invoke close callbacks");
1464
1465 tokio::time::sleep(SHUTDOWN_GRACE_PERIOD + std::time::Duration::from_millis(250)).await;
1466 assert!(
1467 !connection.is_finished(),
1468 "successful child exit canceled active cleanup"
1469 );
1470
1471 close_release_tx
1472 .send(())
1473 .expect("clean child exit should preserve close callbacks");
1474 release_tx
1475 .send(())
1476 .expect("clean child exit should preserve the foreground");
1477 tokio::time::timeout(std::time::Duration::from_secs(5), connection)
1478 .await
1479 .expect("released foreground should finish")
1480 .expect("connection task should not panic")
1481 .expect("successful child exit should remain a clean EOF");
1482 }
1483
1484 #[cfg(unix)]
1485 struct KillOnDrop(Option<rustix::process::Pid>);
1486
1487 #[cfg(unix)]
1488 impl KillOnDrop {
1489 fn disarm(&mut self) {
1490 self.0 = None;
1491 }
1492 }
1493
1494 #[cfg(unix)]
1495 impl Drop for KillOnDrop {
1496 fn drop(&mut self) {
1497 if let Some(pid) = self.0 {
1498 let _result = rustix::process::kill_process(pid, rustix::process::Signal::KILL);
1499 }
1500 }
1501 }
1502
1503 #[cfg(unix)]
1504 fn wrapper_agent(script: &str) -> (AcpAgent, tokio::sync::mpsc::UnboundedReceiver<String>) {
1505 let (pid_tx, pid_rx) = tokio::sync::mpsc::unbounded_channel();
1506 let agent = AcpAgent::from_args(["/bin/sh", "-c", script])
1507 .unwrap()
1508 .with_debug(move |line, direction| {
1509 if direction == LineDirection::Stderr {
1510 drop(pid_tx.send(line.to_owned()));
1511 }
1512 });
1513 (agent, pid_rx)
1514 }
1515
1516 #[cfg(unix)]
1517 fn process_is_running(pid: rustix::process::Pid) -> bool {
1518 if rustix::process::test_kill_process(pid).is_err() {
1519 return false;
1520 }
1521
1522 match std::process::Command::new("ps")
1525 .args(["-o", "stat=", "-p", &pid.to_string()])
1526 .output()
1527 {
1528 Ok(output) if output.status.success() => {
1529 let state = String::from_utf8_lossy(&output.stdout);
1530 !state.trim().is_empty() && !state.trim_start().starts_with('Z')
1531 }
1532 Ok(_) => false,
1533 Err(_) => true,
1534 }
1535 }
1536
1537 #[cfg(unix)]
1538 async fn reported_descendant_pid(
1539 connection: &mut (impl Future<Output = Result<(), crate::Error>> + Unpin),
1540 pid_rx: &mut tokio::sync::mpsc::UnboundedReceiver<String>,
1541 ) -> rustix::process::Pid {
1542 tokio::time::timeout(std::time::Duration::from_secs(5), async {
1543 loop {
1544 tokio::select! {
1545 biased;
1546 line = pid_rx.recv() => {
1547 let line = line.expect("wrapper stderr should remain open");
1548 if let Some(pid) = line.strip_prefix("ACP_TEST_CHILD_PID=") {
1549 let pid = pid.parse::<i32>().expect("valid descendant PID");
1550 break rustix::process::Pid::from_raw(pid)
1551 .expect("nonzero descendant PID");
1552 }
1553 }
1554 result = &mut *connection => {
1555 panic!("agent connection exited before reporting descendant PID: {result:?}");
1556 }
1557 }
1558 }
1559 })
1560 .await
1561 .expect("wrapper should report descendant PID")
1562 }
1563
1564 #[cfg(unix)]
1565 async fn assert_process_exits(pid: rustix::process::Pid) {
1566 let exited = tokio::time::timeout(std::time::Duration::from_secs(5), async {
1567 while process_is_running(pid) {
1568 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1569 }
1570 })
1571 .await
1572 .is_ok();
1573 assert!(exited, "descendant process {pid} remained alive");
1574 }
1575
1576 #[cfg(unix)]
1577 #[tokio::test]
1578 async fn protocol_eof_terminates_a_child_that_does_not_exit() {
1579 let (agent, mut pid_rx) =
1580 wrapper_agent("echo ACP_TEST_CHILD_PID=$$ >&2; exec 1>&-; while :; do sleep 30; done");
1581 let mut connection: futures::future::BoxFuture<'static, Result<(), crate::Error>> =
1582 Box::pin(Client.v1().connect_to(agent));
1583 let child_pid = reported_descendant_pid(&mut connection, &mut pid_rx).await;
1584 let mut cleanup = KillOnDrop(Some(child_pid));
1585
1586 assert!(process_is_running(child_pid));
1587 tokio::time::timeout(std::time::Duration::from_secs(5), &mut connection)
1588 .await
1589 .expect("protocol shutdown should bound its child-exit wait")
1590 .expect("clean protocol shutdown should terminate a non-exiting child");
1591 assert_process_exits(child_pid).await;
1592 cleanup.disarm();
1593 }
1594
1595 #[cfg(unix)]
1596 #[tokio::test]
1597 async fn protocol_eof_bounds_a_blocked_outgoing_drain() {
1598 let (agent, mut pid_rx) = wrapper_agent(
1599 "echo ACP_TEST_CHILD_PID=$$ >&2; exec 1>&-; sleep 30 & child=$!; wait \"$child\"",
1600 );
1601 let (channel, connection) = crate::ConnectTo::<Client>::into_channel_and_future(agent);
1602 let mut connection = connection.expect("AcpAgent owns its process connection");
1603 let crate::Channel {
1604 rx: _incoming,
1605 tx: outgoing,
1606 } = channel;
1607
1608 let response = crate::RawJsonRpcMessage::response(
1609 crate::RawJsonRpcId::Number(1),
1610 Ok(serde_json::json!({ "payload": "x".repeat(4 * 1024 * 1024) })),
1611 );
1612 outgoing
1613 .unbounded_send(crate::TransportFrame::Single(response))
1614 .expect("response should be accepted before the connection starts");
1615 outgoing.close_channel();
1616
1617 let child_pid = reported_descendant_pid(&mut connection, &mut pid_rx).await;
1618 let mut cleanup = KillOnDrop(Some(child_pid));
1619
1620 let error = tokio::time::timeout(std::time::Duration::from_secs(5), &mut connection)
1621 .await
1622 .expect("stdout EOF should bound a blocked outgoing drain")
1623 .expect_err("an undelivered accepted response must not report success");
1624 let detail = error
1625 .data
1626 .as_ref()
1627 .and_then(serde_json::Value::as_str)
1628 .unwrap_or_default();
1629 assert!(
1630 detail.contains("pending protocol output did not drain"),
1631 "the error should identify the blocked outgoing drain: {error:?}"
1632 );
1633
1634 assert_process_exits(child_pid).await;
1635 cleanup.disarm();
1636 }
1637
1638 #[cfg(unix)]
1639 #[tokio::test]
1640 async fn test_connection_drop_kills_wrapper_descendant() {
1641 let (agent, mut pid_rx) = wrapper_agent(
1642 "sleep 30 & child=$!; echo ACP_TEST_CHILD_PID=$child >&2; wait \"$child\"",
1643 );
1644 let (_channel, connection) = crate::ConnectTo::<Client>::into_channel_and_future(agent);
1645 let mut connection = connection.expect("AcpAgent owns its process connection");
1646 let descendant_pid = reported_descendant_pid(&mut connection, &mut pid_rx).await;
1647 let mut cleanup = KillOnDrop(Some(descendant_pid));
1648
1649 assert!(process_is_running(descendant_pid));
1650 drop(connection);
1651 assert_process_exits(descendant_pid).await;
1652 cleanup.disarm();
1653 }
1654
1655 #[cfg(unix)]
1656 #[tokio::test]
1657 async fn test_launcher_exit_kills_descendant_before_stderr_wait() {
1658 let (agent, mut pid_rx) = wrapper_agent(
1659 "sh -c 'trap \"\" HUP; exec sleep 30' >/dev/null & child=$!; echo ACP_TEST_CHILD_PID=$child >&2; exit 17",
1660 );
1661 let (_channel, connection) = crate::ConnectTo::<Client>::into_channel_and_future(agent);
1662 let mut connection = connection.expect("AcpAgent owns its process connection");
1663 let descendant_pid = reported_descendant_pid(&mut connection, &mut pid_rx).await;
1664 let mut cleanup = KillOnDrop(Some(descendant_pid));
1665
1666 let result = tokio::time::timeout(std::time::Duration::from_secs(5), &mut connection)
1667 .await
1668 .expect("connection should observe the launcher exit");
1669 let error = result.expect_err("nonzero launcher exit should be an error");
1670 let detail = error
1671 .data
1672 .as_ref()
1673 .and_then(serde_json::Value::as_str)
1674 .unwrap_or_default();
1675 assert!(
1676 detail.contains("ACP_TEST_CHILD_PID="),
1677 "launcher stderr should be preserved: {error:?}"
1678 );
1679 assert_process_exits(descendant_pid).await;
1680 cleanup.disarm();
1681 }
1682
1683 #[cfg(unix)]
1686 struct ShutdownMarkers {
1687 dir: PathBuf,
1688 }
1689
1690 #[cfg(unix)]
1691 impl ShutdownMarkers {
1692 fn new(name: &str) -> Self {
1693 static NEXT: AtomicUsize = AtomicUsize::new(0);
1694 let dir = std::env::temp_dir().join(format!(
1695 "acp-shutdown-{name}-{}-{}",
1696 std::process::id(),
1697 NEXT.fetch_add(1, Ordering::Relaxed)
1698 ));
1699 std::fs::create_dir_all(&dir).unwrap();
1700 Self { dir }
1701 }
1702
1703 fn pid_path(&self) -> PathBuf {
1704 self.dir.join("pid")
1705 }
1706
1707 fn term_path(&self) -> PathBuf {
1708 self.dir.join("term")
1709 }
1710
1711 fn agent(&self, script: &str, timeouts: ShutdownTimeouts) -> AcpAgent {
1716 let mut agent = AcpAgent::from_args([
1717 "/bin/sh",
1718 "-c",
1719 script,
1720 "sh",
1721 self.pid_path().to_str().unwrap(),
1722 self.term_path().to_str().unwrap(),
1723 ])
1724 .unwrap();
1725 agent.shutdown_timeouts = timeouts;
1726 agent
1727 }
1728
1729 fn pid(&self) -> rustix::process::Pid {
1730 let pid = std::fs::read_to_string(self.pid_path()).expect("agent wrote its PID");
1731 rustix::process::Pid::from_raw(pid.trim().parse().unwrap()).unwrap()
1732 }
1733
1734 fn received_sigterm(&self) -> bool {
1735 self.term_path().exists()
1736 }
1737 }
1738
1739 #[cfg(unix)]
1740 impl Drop for ShutdownMarkers {
1741 fn drop(&mut self) {
1742 let _result = std::fs::remove_dir_all(&self.dir);
1743 }
1744 }
1745
1746 #[cfg(unix)]
1750 async fn shut_down_once_started(
1751 agent: AcpAgent,
1752 markers: &ShutdownMarkers,
1753 ) -> (Result<(), crate::Error>, Duration) {
1754 let pid_path = markers.pid_path();
1755 let (stopped_tx, stopped_rx) = futures::channel::oneshot::channel();
1756 let connection = Client.v1().connect_with(agent, async move |_cx| {
1757 while !pid_path.exists() {
1758 tokio::time::sleep(Duration::from_millis(10)).await;
1759 }
1760 let _sent = stopped_tx.send(std::time::Instant::now());
1761 Ok(())
1762 });
1763 let result = tokio::time::timeout(Duration::from_secs(10), connection)
1764 .await
1765 .expect("agent shutdown should be bounded");
1766 let stopped = stopped_rx.await.expect("the foreground ran");
1767 (result, stopped.elapsed())
1768 }
1769
1770 #[cfg(unix)]
1771 #[tokio::test]
1772 async fn graceful_shutdown_child_exiting_on_stdin_eof_never_receives_sigterm() {
1773 let markers = ShutdownMarkers::new("eof");
1774 let agent = markers.agent(
1775 r#"trap 'echo term > "$2"' TERM; echo $$ > "$1"; cat >/dev/null; echo eof > "$1.eof"; exit 0"#,
1776 ShutdownTimeouts {
1777 exit: Duration::from_secs(5),
1778 terminate: Duration::from_secs(5),
1779 },
1780 );
1781
1782 let (result, elapsed) = shut_down_once_started(agent, &markers).await;
1783
1784 result.expect("an agent that exits on stdin EOF shuts down cleanly");
1785 assert!(
1786 elapsed < Duration::from_secs(5),
1787 "the agent should exit on stdin EOF, not after the grace period ({elapsed:?})"
1788 );
1789 assert!(!markers.received_sigterm(), "the agent received SIGTERM");
1790 assert!(
1791 markers.dir.join("pid.eof").exists(),
1792 "the agent should have read EOF on its stdin"
1793 );
1794 assert_process_exits(markers.pid()).await;
1795 }
1796
1797 #[cfg(unix)]
1798 #[tokio::test]
1799 async fn graceful_shutdown_sigterms_child_ignoring_stdin_eof() {
1800 let markers = ShutdownMarkers::new("term");
1801 let agent = markers.agent(
1802 r#"trap 'echo term > "$2"; exit 0' TERM; echo $$ > "$1"; while :; do sleep 1; done"#,
1803 ShutdownTimeouts {
1804 exit: Duration::from_millis(200),
1805 terminate: Duration::from_secs(5),
1806 },
1807 );
1808
1809 let (result, elapsed) = shut_down_once_started(agent, &markers).await;
1810 let pid = markers.pid();
1811 let mut cleanup = KillOnDrop(Some(pid));
1812
1813 result.expect("an agent stopped by SIGTERM shuts down cleanly");
1814 assert!(markers.received_sigterm(), "the agent did not get SIGTERM");
1815 assert!(
1816 elapsed < Duration::from_secs(5),
1817 "the agent should exit on SIGTERM, not wait for SIGKILL ({elapsed:?})"
1818 );
1819 assert_process_exits(pid).await;
1820 cleanup.disarm();
1821 }
1822
1823 #[cfg(unix)]
1824 #[tokio::test]
1825 async fn graceful_shutdown_sigkills_child_ignoring_sigterm() {
1826 let markers = ShutdownMarkers::new("kill");
1827 let agent = markers.agent(
1833 r#"trap 'echo term > "$2"' TERM; echo $$ > "$1"; while :; do sleep 1; done"#,
1834 ShutdownTimeouts {
1835 exit: Duration::from_millis(100),
1836 terminate: Duration::from_secs(1),
1837 },
1838 );
1839
1840 let (result, elapsed) = shut_down_once_started(agent, &markers).await;
1841 let pid = markers.pid();
1842 let mut cleanup = KillOnDrop(Some(pid));
1843
1844 result.expect("an agent killed during shutdown is not an error");
1845 assert!(markers.received_sigterm(), "SIGTERM should precede SIGKILL");
1846 assert!(
1847 elapsed >= Duration::from_millis(1100),
1848 "SIGKILL should wait for the SIGTERM grace period ({elapsed:?})"
1849 );
1850 assert_process_exits(pid).await;
1851 cleanup.disarm();
1852 }
1853
1854 #[test]
1855 fn test_parse_simple_command() {
1856 let agent = AcpAgent::from_str("python agent.py").unwrap();
1857 let config = agent.config();
1858 assert_eq!(config.command(), Path::new("python"));
1859 assert_eq!(config.arguments(), ["agent.py"]);
1860 assert!(config.environment().is_empty());
1861 }
1862
1863 #[test]
1864 fn test_parse_environment_from_args() {
1865 let agent =
1866 AcpAgent::from_args(["RUST_LOG=debug", "NO_COLOR=1", "python", "agent.py"]).unwrap();
1867 let config = agent.config();
1868
1869 assert_eq!(config.command(), Path::new("python"));
1870 assert_eq!(config.arguments(), ["agent.py"]);
1871 assert_eq!(
1872 config.environment(),
1873 &BTreeMap::from([
1874 ("NO_COLOR".to_owned(), "1".to_owned()),
1875 ("RUST_LOG".to_owned(), "debug".to_owned()),
1876 ])
1877 );
1878 }
1879
1880 #[test]
1881 fn test_new_accepts_agent_configuration() {
1882 let config = AcpAgentConfig::new("/usr/bin/agent")
1883 .arg("--verbose")
1884 .env("RUST_LOG", "debug");
1885 let agent = AcpAgent::new(config.clone());
1886
1887 assert_eq!(agent.into_config(), config);
1888 }
1889
1890 #[test]
1891 fn test_parse_command_with_args() {
1892 let agent = AcpAgent::from_str("node server.js --port 8080 --verbose").unwrap();
1893 let config = agent.config();
1894 assert_eq!(config.command(), Path::new("node"));
1895 assert_eq!(
1896 config.arguments(),
1897 ["server.js", "--port", "8080", "--verbose"]
1898 );
1899 assert!(config.environment().is_empty());
1900 }
1901
1902 #[test]
1903 fn test_parse_command_with_quotes() {
1904 let agent = AcpAgent::from_str(r#"python "my agent.py" --name "Test Agent""#).unwrap();
1905 let config = agent.into_config();
1906 assert_eq!(config.command(), Path::new("python"));
1907 assert_eq!(config.arguments(), ["my agent.py", "--name", "Test Agent"]);
1908 assert!(config.environment().is_empty());
1909 }
1910
1911 #[test]
1912 fn test_parse_json_config() {
1913 let json = r#"{
1914 "command": "/usr/bin/python",
1915 "args": ["agent.py", "--verbose"],
1916 "env": {"RUST_LOG": "debug"}
1917 }"#;
1918 let agent = AcpAgent::from_str(json).unwrap();
1919 let config = agent.config();
1920 assert_eq!(config.command(), Path::new("/usr/bin/python"));
1921 assert_eq!(config.arguments(), ["agent.py", "--verbose"]);
1922 assert_eq!(
1923 config.environment().get("RUST_LOG").map(String::as_str),
1924 Some("debug")
1925 );
1926 }
1927
1928 #[test]
1929 fn test_config_json_round_trip() {
1930 let config = AcpAgentConfig::new("agent")
1931 .args(["--mode", "fast"])
1932 .envs([("RUST_LOG", "debug"), ("NO_COLOR", "1")]);
1933
1934 let json = serde_json::to_value(&config).unwrap();
1935 assert_eq!(
1936 json,
1937 serde_json::json!({
1938 "command": "agent",
1939 "args": ["--mode", "fast"],
1940 "env": {
1941 "NO_COLOR": "1",
1942 "RUST_LOG": "debug"
1943 }
1944 })
1945 );
1946 assert_eq!(
1947 serde_json::from_value::<AcpAgentConfig>(json).unwrap(),
1948 config
1949 );
1950 }
1951
1952 #[test]
1953 fn test_config_json_defaults_and_omits_empty_collections() {
1954 let config = serde_json::from_value::<AcpAgentConfig>(serde_json::json!({
1955 "command": "agent"
1956 }))
1957 .unwrap();
1958
1959 assert_eq!(config.arguments(), &[] as &[String]);
1960 assert!(config.environment().is_empty());
1961 assert_eq!(
1962 serde_json::to_value(config).unwrap(),
1963 serde_json::json!({ "command": "agent" })
1964 );
1965 }
1966
1967 #[test]
1968 fn test_reject_mcp_server_json() {
1969 let json = r#"{
1970 "type": "stdio",
1971 "name": "my-agent",
1972 "command": "/usr/bin/python",
1973 "args": ["agent.py"],
1974 "env": []
1975 }"#;
1976 let error = AcpAgent::from_str(json).unwrap_err();
1977 assert!(
1978 error
1979 .data
1980 .as_ref()
1981 .and_then(serde_json::Value::as_str)
1982 .is_some_and(|message| message.contains("unknown field")),
1983 "unexpected error: {error:?}"
1984 );
1985 }
1986}