Skip to main content

muster/adapter/pty/
runner.rs

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
26/// Size of a single PTY read buffer, in bytes.
27const READ_BUFFER_BYTES: usize = 4096;
28/// POSIX shell used to interpret each non-agent command line.
29#[cfg(unix)]
30const SHELL_PROGRAM: &str = "/bin/sh";
31/// Windows command shell used to interpret each non-agent command line.
32#[cfg(windows)]
33const SHELL_PROGRAM: &str = "cmd.exe";
34/// POSIX flag that runs the following argument as a shell command.
35#[cfg(unix)]
36const SHELL_COMMAND_FLAG: &str = "-c";
37/// Windows flag that runs the following argument as a shell command.
38#[cfg(windows)]
39const SHELL_COMMAND_FLAG: &str = "/C";
40/// Hidden CLI namespace that binds session ownership before starting an agent.
41const HOOK_SUBCOMMAND: &str = "hook";
42/// Hidden CLI action that starts a provider after recording its owner process.
43const LAUNCH_SUBCOMMAND: &str = "launch";
44/// CLI option carrying the stable Muster session identity.
45const SESSION_ARGUMENT: &str = "--session";
46/// Environment variable naming the terminal type.
47const TERM_VAR: &str = "TERM";
48/// Terminal type advertised to children when the environment has none.
49const DEFAULT_TERM: &str = "xterm-256color";
50/// Grace the waiter gives the reader to drain a child's final output before
51/// reporting exit. Bounded so a lingering descendant never blocks the report.
52const EXIT_DRAIN_GRACE: Duration = Duration::from_millis(200);
53/// Poll interval used while a direct child has exited but its process group is
54/// still completing descendant cleanup.
55const PROCESS_GROUP_POLL_INTERVAL: Duration = Duration::from_millis(10);
56/// Reported when suspend/resume is requested on a platform without job-control
57/// signals.
58#[cfg(not(unix))]
59const SUSPEND_UNSUPPORTED: &str = "suspend and resume are only supported on Unix";
60
61/// Spawns processes under a native PTY using `portable-pty`.
62#[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            // A descendant can retain the PTY after the direct shell exits. A
145            // graceful stop supplies its full cleanup window; ordinary exits use
146            // only the short drain bound. The completion channel wakes this wait
147            // immediately when cleanup finishes instead of delaying the exit event.
148            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            // Joining before publishing exit sequences the exit strictly after
156            // every chunk sent, so channel backpressure cannot make output stale.
157            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    /// Builds a direct internal launch command for one durable agent session.
173    ///
174    /// # Errors
175    /// Returns a [`PtyError`] when the running executable cannot be located.
176    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    /// Builds a direct internal launch command using an explicit Muster executable.
185    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/// Outcome of waiting for both PTY output and descendant cleanup.
204#[derive(Clone, Copy, Debug, PartialEq, Eq)]
205enum DrainStatus {
206    Complete,
207    TimedOut,
208}
209
210/// Waits until the PTY reader finishes and the process group is empty, bounded
211/// by `grace`. The liveness callback keeps the timing logic directly testable.
212fn 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
246/// Reports whether any Unix process still belongs to the spawned process
247/// group. Other platforms rely solely on PTY reader completion.
248fn 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
266/// Live handle to a PTY-backed process.
267struct 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        // Publish the grace before signalling: if the signal makes the direct shell
316        // exit immediately, its waiter still observes the intended deadline.
317        let _ = self.grace_tx.try_send(grace);
318        #[cfg(unix)]
319        {
320            // A paused command cannot handle a shutdown signal until it resumes.
321            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/// Maps the domain shutdown signal to its Unix signal number.
337#[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
345/// Converts a domain [`PtySize`] into the crate's PTY size (pixel size unused).
346fn 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
355/// Maps a `portable-pty` error (any `Display` error) into a [`PtyError::System`].
356fn system_error<E: Display>(error: E) -> PtyError {
357    PtyError::System(error.to_string())
358}
359
360/// Sends `signal` to a process's whole group (negative pid), so it reaches
361/// backgrounded descendants and not just the direct child. Unix only.
362///
363/// # Errors
364/// Returns `Unsupported` when there is no pid to target, or the OS error when
365/// `kill` reports failure (e.g. the process has already exited, or `EPERM`).
366#[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
375/// Terminates a spawned process: a whole-group SIGKILL on Unix, with the
376/// portable child killer as the fallback and the non-Unix path. Returns success
377/// when either mechanism delivers the request.
378///
379/// # Errors
380/// Returns a `PtyError` when neither termination mechanism succeeds.
381fn 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    /// Maximum time to wait for a test process's events.
416    const OUTPUT_TIMEOUT: Duration = Duration::from_secs(5);
417    /// Grace used by termination tests, long enough for delayed descendant
418    /// cleanup while keeping the suite quick.
419    const TEST_STOP_GRACE: Duration = Duration::from_secs(3);
420    /// Minimum time the macOS waiter must preserve for descendant cleanup after
421    /// the PTY reader reports the direct shell's exit.
422    #[cfg(target_os = "macos")]
423    const DESCENDANT_CLEANUP_MIN_WAIT: Duration = Duration::from_millis(750);
424    /// Shell fixture whose direct wrapper exits on TERM while a signal-resistant
425    /// descendant takes longer than the ordinary PTY drain bound to finish. The
426    /// final sleep models cleanup that remains active after its last log line.
427    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    /// The agent-launch handoff keeps advanced shell expressions as one opaque
450    /// argument for the internal launcher on every supported platform.
451    #[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    /// Ensures an early PTY EOF does not bypass a still-live process group.
473    #[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        // A bounded channel drained slowly makes the reader outlast the grace
617        // window; a fixed timeout would truncate, progress-based waiting must not.
618        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}