1#[cfg(unix)]
2use std::io;
3use std::{
4 fmt::Display,
5 io::{Read, Write},
6 path::Path,
7 sync::Arc,
8 thread,
9 time::{Duration, Instant},
10};
11
12use crossbeam_channel::{Receiver, RecvTimeoutError, Sender, TryRecvError, bounded};
13use portable_pty::{CommandBuilder, PtySize as PortablePtySize, native_pty_system};
14
15use crate::{
16 constants::MUSTER_PROJECT_ENV,
17 domain::{
18 agent_session::AgentSessionId,
19 port::{OutputSink, ProcessHandle, ProcessRunner},
20 process::StopSignal,
21 pty::{ExitOutcome, ProcessOutput, PtyError, PtySize, SpawnRequest},
22 value::CommandLine,
23 },
24};
25
26const READ_BUFFER_BYTES: usize = 4096;
28#[cfg(unix)]
30const SHELL_PROGRAM: &str = "/bin/sh";
31#[cfg(windows)]
33const SHELL_PROGRAM: &str = "cmd.exe";
34#[cfg(unix)]
36const SHELL_COMMAND_FLAG: &str = "-c";
37#[cfg(windows)]
39const SHELL_COMMAND_FLAG: &str = "/C";
40const HOOK_SUBCOMMAND: &str = "hook";
42const LAUNCH_SUBCOMMAND: &str = "launch";
44const SESSION_ARGUMENT: &str = "--session";
46const TERM_VAR: &str = "TERM";
48const DEFAULT_TERM: &str = "xterm-256color";
50const EXIT_DRAIN_GRACE: Duration = Duration::from_millis(200);
53const PROCESS_GROUP_POLL_INTERVAL: Duration = Duration::from_millis(10);
56#[cfg(not(unix))]
59const SUSPEND_UNSUPPORTED: &str = "suspend and resume are only supported on Unix";
60
61#[derive(Clone, Copy, Default)]
63pub struct PortablePtyRunner;
64
65impl ProcessRunner for PortablePtyRunner {
66 fn spawn(
67 &self,
68 request: SpawnRequest,
69 sink: Box<dyn OutputSink>,
70 ) -> Result<Box<dyn ProcessHandle>, PtyError> {
71 let pair = native_pty_system()
72 .openpty(to_portable_size(request.size()))
73 .map_err(system_error)?;
74
75 let mut command = match request.command() {
76 Some(command_line) => {
77 if let Some(session_id) = request.agent_session_id() {
78 Self::agent_launcher(session_id, command_line)?
79 } else {
80 let mut builder = CommandBuilder::new(SHELL_PROGRAM);
81 builder.arg(SHELL_COMMAND_FLAG);
82 builder.arg(command_line.as_ref());
83 builder
84 }
85 },
86 None => CommandBuilder::new_default_prog(),
87 };
88 if let Some(dir) = request.working_dir() {
89 if !dir.is_dir() {
90 return Err(PtyError::InvalidWorkingDir(dir.clone()));
91 }
92 command.cwd(dir);
93 } else if let Ok(cwd) = std::env::current_dir() {
94 command.cwd(cwd);
95 }
96 if std::env::var_os(TERM_VAR).is_none() {
97 command.env(TERM_VAR, DEFAULT_TERM);
98 }
99 if let Some(project) = request.project() {
100 command.env(MUSTER_PROJECT_ENV, project);
101 }
102 for (name, value) in request.environment() {
103 command.env(name, value);
104 }
105
106 let mut child = pair.slave.spawn_command(command).map_err(system_error)?;
107 let mut killer = child.clone_killer();
108 let mut waiter_killer = child.clone_killer();
109 let pid = child.process_id();
110 drop(pair.slave);
111
112 let io = pair
113 .master
114 .try_clone_reader()
115 .and_then(|reader| pair.master.take_writer().map(|writer| (reader, writer)));
116 let (mut reader, writer) = match io {
117 Ok(io) => io,
118 Err(error) => {
119 let _ = killer.kill();
120 return Err(system_error(error));
121 },
122 };
123
124 let sink: Arc<dyn OutputSink> = Arc::from(sink);
125 let (reader_done_tx, reader_done_rx) = bounded(1);
126 let (grace_tx, grace_rx) = bounded(1);
127
128 let reader_sink = Arc::clone(&sink);
129 let reader_handle = thread::spawn(move || {
130 let mut buffer = [0u8; READ_BUFFER_BYTES];
131 loop {
132 match reader.read(&mut buffer) {
133 Ok(0) | Err(_) => break,
134 Ok(read) => reader_sink.send(ProcessOutput::Chunk(buffer[..read].to_vec())),
135 }
136 }
137 let _ = reader_done_tx.send(());
138 });
139 thread::spawn(move || {
140 let outcome = match child.wait() {
141 Ok(status) if status.success() => ExitOutcome::Succeeded,
142 _ => ExitOutcome::Failed,
143 };
144 let drain_grace = grace_rx.try_recv().unwrap_or(EXIT_DRAIN_GRACE);
149 if wait_for_drain(&reader_done_rx, drain_grace, || process_group_is_alive(pid))
150 == DrainStatus::TimedOut
151 {
152 let _ = terminate_group(pid, &mut waiter_killer);
153 let _ = reader_done_rx.recv();
154 }
155 let _ = reader_handle.join();
158 sink.send(ProcessOutput::Exited(outcome));
159 });
160
161 Ok(Box::new(PtyProcessHandle {
162 master: pair.master,
163 writer,
164 killer,
165 grace_tx,
166 pid,
167 }))
168 }
169}
170
171impl PortablePtyRunner {
172 fn agent_launcher(
177 session_id: &AgentSessionId,
178 command: &CommandLine,
179 ) -> Result<CommandBuilder, PtyError> {
180 let executable = std::env::current_exe().map_err(system_error)?;
181 Ok(Self::agent_launcher_for(&executable, session_id, command))
182 }
183
184 fn agent_launcher_for(
186 executable: &Path,
187 session_id: &AgentSessionId,
188 command: &CommandLine,
189 ) -> CommandBuilder {
190 let mut launcher = CommandBuilder::new(executable);
191 launcher.args([
192 HOOK_SUBCOMMAND,
193 LAUNCH_SUBCOMMAND,
194 SESSION_ARGUMENT,
195 session_id.as_ref(),
196 "--",
197 command.as_ref(),
198 ]);
199 launcher
200 }
201}
202
203#[derive(Clone, Copy, Debug, PartialEq, Eq)]
205enum DrainStatus {
206 Complete,
207 TimedOut,
208}
209
210fn wait_for_drain<F>(
213 reader_done_rx: &Receiver<()>,
214 grace: Duration,
215 mut group_is_alive: F,
216) -> DrainStatus
217where
218 F: FnMut() -> bool,
219{
220 let started = Instant::now();
221 let mut reader_done = match reader_done_rx.try_recv() {
222 Ok(()) | Err(TryRecvError::Disconnected) => true,
223 Err(TryRecvError::Empty) => false,
224 };
225 loop {
226 if reader_done && !group_is_alive() {
227 return DrainStatus::Complete;
228 }
229
230 let remaining = grace.saturating_sub(started.elapsed());
231 if remaining.is_zero() {
232 return DrainStatus::TimedOut;
233 }
234 let poll = remaining.min(PROCESS_GROUP_POLL_INTERVAL);
235 if reader_done {
236 thread::sleep(poll);
237 continue;
238 }
239 match reader_done_rx.recv_timeout(poll) {
240 Ok(()) | Err(RecvTimeoutError::Disconnected) => reader_done = true,
241 Err(RecvTimeoutError::Timeout) => {},
242 }
243 }
244}
245
246fn process_group_is_alive(pid: Option<u32>) -> bool {
249 #[cfg(unix)]
250 {
251 let Some(pid) = pid else {
252 return false;
253 };
254 if unsafe { libc::kill(-(pid as libc::pid_t), 0) } == 0 {
255 return true;
256 }
257 io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
258 }
259 #[cfg(not(unix))]
260 {
261 let _ = pid;
262 false
263 }
264}
265
266struct PtyProcessHandle {
268 master: Box<dyn portable_pty::MasterPty + Send>,
269 writer: Box<dyn Write + Send>,
270 killer: Box<dyn portable_pty::ChildKiller + Send + Sync>,
271 grace_tx: Sender<Duration>,
272 pid: Option<u32>,
273}
274
275impl ProcessHandle for PtyProcessHandle {
276 fn process_id(&self) -> Option<u32> {
277 self.pid
278 }
279
280 fn write_input(&mut self, bytes: &[u8]) -> Result<(), PtyError> {
281 self.writer.write_all(bytes)?;
282 self.writer.flush()?;
283 Ok(())
284 }
285
286 fn resize(&mut self, size: PtySize) -> Result<(), PtyError> {
287 self.master
288 .resize(to_portable_size(size))
289 .map_err(system_error)
290 }
291
292 fn pause(&mut self) -> Result<(), PtyError> {
293 #[cfg(unix)]
294 {
295 signal_group(self.pid, libc::SIGSTOP)
296 }
297 #[cfg(not(unix))]
298 {
299 Err(PtyError::Unsupported(SUSPEND_UNSUPPORTED.to_string()))
300 }
301 }
302
303 fn resume(&mut self) -> Result<(), PtyError> {
304 #[cfg(unix)]
305 {
306 signal_group(self.pid, libc::SIGCONT)
307 }
308 #[cfg(not(unix))]
309 {
310 Err(PtyError::Unsupported(SUSPEND_UNSUPPORTED.to_string()))
311 }
312 }
313
314 fn terminate(&mut self, signal: StopSignal, grace: Duration) -> Result<(), PtyError> {
315 let _ = self.grace_tx.try_send(grace);
318 #[cfg(unix)]
319 {
320 let _ = signal_group(self.pid, libc::SIGCONT);
322 signal_group(self.pid, unix_signal(signal))
323 }
324 #[cfg(not(unix))]
325 {
326 let _ = signal;
327 self.killer.kill().map_err(system_error)
328 }
329 }
330
331 fn kill(&mut self) -> Result<(), PtyError> {
332 terminate_group(self.pid, &mut self.killer)
333 }
334}
335
336#[cfg(unix)]
338fn unix_signal(signal: StopSignal) -> libc::c_int {
339 match signal {
340 StopSignal::Terminate => libc::SIGTERM,
341 StopSignal::Interrupt => libc::SIGINT,
342 }
343}
344
345fn to_portable_size(size: PtySize) -> PortablePtySize {
347 PortablePtySize {
348 rows: size.rows().into_inner(),
349 cols: size.cols().into_inner(),
350 pixel_width: 0,
351 pixel_height: 0,
352 }
353}
354
355fn system_error<E: Display>(error: E) -> PtyError {
357 PtyError::System(error.to_string())
358}
359
360#[cfg(unix)]
367fn signal_group(pid: Option<u32>, signal: libc::c_int) -> Result<(), PtyError> {
368 let pid = pid.ok_or_else(|| PtyError::Unsupported("no pid to signal".to_string()))?;
369 if unsafe { libc::kill(-(pid as libc::pid_t), signal) } == -1 {
370 return Err(PtyError::Io(std::io::Error::last_os_error()));
371 }
372 Ok(())
373}
374
375fn terminate_group(
382 pid: Option<u32>,
383 killer: &mut Box<dyn portable_pty::ChildKiller + Send + Sync>,
384) -> Result<(), PtyError> {
385 #[cfg(unix)]
386 {
387 let group_result = signal_group(pid, libc::SIGKILL);
388 let child_result = killer.kill().map_err(system_error);
389 match (group_result, child_result) {
390 (Ok(()), _) | (_, Ok(())) => Ok(()),
391 (Err(error), Err(_)) => Err(error),
392 }
393 }
394 #[cfg(not(unix))]
395 {
396 let _ = pid;
397 killer.kill().map_err(system_error)
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use std::{
404 cell::Cell,
405 path::PathBuf,
406 time::{Duration, Instant},
407 };
408
409 use super::*;
410 use crate::domain::{
411 agent_session::AgentSessionId,
412 value::{Cols, CommandLine, Rows},
413 };
414
415 const OUTPUT_TIMEOUT: Duration = Duration::from_secs(5);
417 const TEST_STOP_GRACE: Duration = Duration::from_secs(3);
420 #[cfg(target_os = "macos")]
423 const DESCENDANT_CLEANUP_MIN_WAIT: Duration = Duration::from_millis(750);
424 const DESCENDANT_CLEANUP_COMMAND: &str = r#"sh -c 'trap "" TERM HUP; printf ready; sleep 1; printf descendant-clean; sleep 1' & trap 'exit 0' TERM; wait"#;
428
429 struct ChannelSink(crossbeam_channel::Sender<ProcessOutput>);
430
431 impl OutputSink for ChannelSink {
432 fn send(&self, output: ProcessOutput) {
433 let _ = self.0.send(output);
434 }
435 }
436
437 fn request(command: &str) -> SpawnRequest {
438 SpawnRequest::builder()
439 .command(Some(CommandLine::try_new(command).unwrap()))
440 .size(
441 PtySize::builder()
442 .rows(Rows::new(24))
443 .cols(Cols::new(80))
444 .build(),
445 )
446 .build()
447 }
448
449 #[test]
452 fn agent_launch_handoff_preserves_shell_expressions() {
453 let session = AgentSessionId::try_new("session $(injection)").unwrap();
454 let command = CommandLine::try_new("FOO=bar codex | tee agent.log").unwrap();
455 let launch = PortablePtyRunner::agent_launcher_for(
456 &PathBuf::from("/tmp/muster"),
457 &session,
458 &command,
459 );
460
461 assert_eq!(launch.get_argv(), &vec![
462 PathBuf::from("/tmp/muster").into_os_string(),
463 HOOK_SUBCOMMAND.into(),
464 LAUNCH_SUBCOMMAND.into(),
465 SESSION_ARGUMENT.into(),
466 "session $(injection)".into(),
467 "--".into(),
468 "FOO=bar codex | tee agent.log".into(),
469 ]);
470 }
471
472 #[test]
474 fn drain_waits_for_the_process_group_after_the_reader_finishes() {
475 let (tx, rx) = bounded(1);
476 tx.send(()).unwrap();
477 drop(tx);
478 let polls = Cell::new(0);
479
480 let status = wait_for_drain(&rx, OUTPUT_TIMEOUT, || {
481 let next = polls.get() + 1;
482 polls.set(next);
483 next < 3
484 });
485
486 assert_eq!(status, DrainStatus::Complete);
487 assert_eq!(polls.get(), 3);
488 }
489
490 #[test]
491 fn streams_output_then_reports_success() {
492 let (tx, rx) = crossbeam_channel::unbounded();
493 let _handle = PortablePtyRunner
494 .spawn(request("printf hello"), Box::new(ChannelSink(tx)))
495 .unwrap();
496
497 let mut bytes = Vec::new();
498 let mut outcome = None;
499 while let Ok(output) = rx.recv_timeout(OUTPUT_TIMEOUT) {
500 match output {
501 ProcessOutput::Chunk(chunk) => bytes.extend_from_slice(&chunk),
502 ProcessOutput::Exited(exit) => outcome = Some(exit),
503 }
504 }
505
506 assert!(String::from_utf8_lossy(&bytes).contains("hello"));
507 assert_eq!(outcome, Some(ExitOutcome::Succeeded));
508 }
509
510 #[test]
511 fn final_output_is_delivered_before_exit() {
512 let (tx, rx) = crossbeam_channel::unbounded();
513 let _handle = PortablePtyRunner
514 .spawn(request("printf hello; exit 1"), Box::new(ChannelSink(tx)))
515 .unwrap();
516
517 let mut events = Vec::new();
518 while let Ok(output) = rx.recv_timeout(OUTPUT_TIMEOUT) {
519 events.push(output);
520 }
521
522 let exit_pos = events
523 .iter()
524 .position(|event| matches!(event, ProcessOutput::Exited(_)))
525 .expect("an exit is reported");
526 assert_eq!(exit_pos, events.len() - 1, "exit must be the final event");
527 let output: Vec<u8> = events[..exit_pos]
528 .iter()
529 .flat_map(|event| match event {
530 ProcessOutput::Chunk(chunk) => chunk.clone(),
531 ProcessOutput::Exited(_) => Vec::new(),
532 })
533 .collect();
534 assert!(String::from_utf8_lossy(&output).contains("hello"));
535 }
536
537 #[cfg(unix)]
538 #[test]
539 fn graceful_termination_drains_shutdown_output() {
540 let (tx, rx) = crossbeam_channel::unbounded();
541 let mut handle = PortablePtyRunner
542 .spawn(
543 request(
544 "trap 'printf shutdown; exit 0' TERM; printf ready; while :; do sleep 1; done",
545 ),
546 Box::new(ChannelSink(tx)),
547 )
548 .unwrap();
549
550 let mut bytes = Vec::new();
551 while !String::from_utf8_lossy(&bytes).contains("ready") {
552 match rx.recv_timeout(OUTPUT_TIMEOUT).unwrap() {
553 ProcessOutput::Chunk(chunk) => bytes.extend(chunk),
554 ProcessOutput::Exited(_) => panic!("command exited before termination"),
555 }
556 }
557 handle
558 .terminate(StopSignal::Terminate, TEST_STOP_GRACE)
559 .unwrap();
560 while let Ok(output) = rx.recv_timeout(OUTPUT_TIMEOUT) {
561 match output {
562 ProcessOutput::Chunk(chunk) => bytes.extend(chunk),
563 ProcessOutput::Exited(_) => break,
564 }
565 }
566
567 assert!(String::from_utf8_lossy(&bytes).contains("shutdown"));
568 }
569
570 #[cfg(unix)]
571 #[test]
572 fn graceful_termination_preserves_descendant_cleanup_after_shell_exit() {
573 let (tx, rx) = crossbeam_channel::unbounded();
574 let mut handle = PortablePtyRunner
575 .spawn(
576 request(DESCENDANT_CLEANUP_COMMAND),
577 Box::new(ChannelSink(tx)),
578 )
579 .unwrap();
580
581 let mut bytes = Vec::new();
582 while !String::from_utf8_lossy(&bytes).contains("ready") {
583 match rx.recv_timeout(OUTPUT_TIMEOUT).unwrap() {
584 ProcessOutput::Chunk(chunk) => bytes.extend(chunk),
585 ProcessOutput::Exited(_) => panic!("command exited before termination"),
586 }
587 }
588 #[cfg(target_os = "macos")]
589 let termination_started = Instant::now();
590 handle
591 .terminate(StopSignal::Terminate, TEST_STOP_GRACE)
592 .unwrap();
593 let mut exited = false;
594 while let Ok(output) = rx.recv_timeout(OUTPUT_TIMEOUT) {
595 match output {
596 ProcessOutput::Chunk(chunk) => bytes.extend(chunk),
597 ProcessOutput::Exited(_) => {
598 exited = true;
599 break;
600 },
601 }
602 }
603
604 assert!(exited, "the runner must report the completed cleanup");
605 #[cfg(target_os = "macos")]
606 assert!(
607 termination_started.elapsed() >= DESCENDANT_CLEANUP_MIN_WAIT,
608 "an early macOS PTY EOF must not bypass descendant cleanup"
609 );
610 #[cfg(not(target_os = "macos"))]
611 assert!(String::from_utf8_lossy(&bytes).contains("descendant-clean"));
612 }
613
614 #[test]
615 fn a_slow_drain_delivers_all_output_before_exit() {
616 const OUTPUT_LEN: usize = 200_000;
619 let (tx, rx) = crossbeam_channel::bounded(1);
620 let _handle = PortablePtyRunner
621 .spawn(
622 request(r"head -c 200000 /dev/zero | tr '\0' x"),
623 Box::new(ChannelSink(tx)),
624 )
625 .unwrap();
626
627 let mut events = Vec::new();
628 while let Ok(output) = rx.recv_timeout(OUTPUT_TIMEOUT) {
629 events.push(output);
630 thread::sleep(Duration::from_millis(5));
631 }
632
633 let exit_pos = events
634 .iter()
635 .position(|event| matches!(event, ProcessOutput::Exited(_)))
636 .expect("an exit is reported");
637 assert_eq!(exit_pos, events.len() - 1, "exit must be the final event");
638 let total: usize = events[..exit_pos]
639 .iter()
640 .map(|event| match event {
641 ProcessOutput::Chunk(chunk) => chunk.len(),
642 ProcessOutput::Exited(_) => 0,
643 })
644 .sum();
645 assert_eq!(
646 total, OUTPUT_LEN,
647 "all output delivered, not truncated by the exit"
648 );
649 }
650
651 #[test]
652 fn reports_failure_for_nonzero_exit() {
653 let (tx, rx) = crossbeam_channel::unbounded();
654 let _handle = PortablePtyRunner
655 .spawn(request("exit 3"), Box::new(ChannelSink(tx)))
656 .unwrap();
657
658 let mut outcome = None;
659 while let Ok(output) = rx.recv_timeout(OUTPUT_TIMEOUT) {
660 if let ProcessOutput::Exited(exit) = output {
661 outcome = Some(exit);
662 }
663 }
664
665 assert_eq!(outcome, Some(ExitOutcome::Failed));
666 }
667
668 #[test]
669 fn invalid_working_directory_is_rejected() {
670 let (tx, _rx) = crossbeam_channel::unbounded();
671 let request = SpawnRequest::builder()
672 .command(Some(CommandLine::try_new("true").unwrap()))
673 .working_dir(Some(PathBuf::from("/no/such/muster/dir")))
674 .size(
675 PtySize::builder()
676 .rows(Rows::new(24))
677 .cols(Cols::new(80))
678 .build(),
679 )
680 .build();
681
682 let result = PortablePtyRunner.spawn(request, Box::new(ChannelSink(tx)));
683 assert!(matches!(result, Err(PtyError::InvalidWorkingDir(_))));
684 }
685
686 #[test]
687 fn inherits_the_current_directory_when_no_working_dir_is_set() {
688 let (tx, rx) = crossbeam_channel::unbounded();
689 let _handle = PortablePtyRunner
690 .spawn(request("pwd -P"), Box::new(ChannelSink(tx)))
691 .unwrap();
692
693 let mut bytes = Vec::new();
694 while let Ok(output) = rx.recv_timeout(OUTPUT_TIMEOUT) {
695 if let ProcessOutput::Chunk(chunk) = output {
696 bytes.extend_from_slice(&chunk);
697 }
698 }
699
700 let expected = std::env::current_dir().unwrap();
701 assert!(String::from_utf8_lossy(&bytes).contains(expected.to_str().unwrap()));
702 }
703
704 #[test]
705 fn exit_is_observed_before_a_backgrounded_descendant_closes_the_pty() {
706 let (tx, rx) = crossbeam_channel::unbounded();
707 let start = Instant::now();
708 let _handle = PortablePtyRunner
709 .spawn(request("sleep 5 &"), Box::new(ChannelSink(tx)))
710 .unwrap();
711
712 loop {
713 match rx.recv_timeout(OUTPUT_TIMEOUT) {
714 Ok(ProcessOutput::Exited(_)) => break,
715 Ok(_) => {},
716 Err(_) => break,
717 }
718 }
719
720 assert!(start.elapsed() < Duration::from_secs(2));
721 }
722}