Skip to main content

aft/bash_background/
pty_process.rs

1use std::collections::HashMap;
2use std::ffi::OsString;
3use std::fs::{self, File};
4use std::io::{self, Read, Seek, SeekFrom, Write};
5#[cfg(unix)]
6use std::os::unix::fs::PermissionsExt;
7use std::path::Path;
8#[cfg(unix)]
9use std::path::PathBuf;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::{Arc, Mutex};
12use std::thread;
13
14use portable_pty::PtySize;
15
16use crate::sandbox_spawn::SpawnPlan;
17
18use super::persistence::{ExitMarker, TaskArtifact, TaskIoHandles, TaskPaths};
19use super::pty_runtime::{CompletionCoordinator, PtyRuntime};
20
21#[allow(clippy::too_many_arguments)]
22pub(crate) fn spawn_pty_for_command(
23    spawn_plan: &SpawnPlan,
24    task_id: &str,
25    session_id: &str,
26    user_command: &str,
27    paths: &TaskPaths,
28    workdir: &Path,
29    env: &HashMap<String, String>,
30    rows: u16,
31    cols: u16,
32    wake_tx: crossbeam_channel::Sender<()>,
33    io_handles: &mut TaskIoHandles,
34) -> Result<PtyRuntime, String> {
35    #[cfg(unix)]
36    {
37        let shell = spawn_plan
38            .host_shell_path()
39            .map(Path::to_path_buf)
40            .unwrap_or_else(resolve_posix_shell);
41        let args = if let Some(prepared) = spawn_plan.prepared_task() {
42            vec![
43                OsString::from("-c"),
44                OsString::from(prepared.command_text()?),
45            ]
46        } else {
47            vec![OsString::from("-c"), OsString::from(user_command)]
48        };
49        try_spawn_pty(
50            spawn_plan,
51            task_id,
52            session_id,
53            shell.as_os_str(),
54            &args,
55            paths,
56            workdir,
57            env,
58            rows,
59            cols,
60            wake_tx,
61            io_handles,
62        )
63    }
64    #[cfg(windows)]
65    {
66        use crate::windows_shell::shell_candidates;
67
68        let candidates = shell_candidates();
69        let mut last_err = String::from("no Windows shell candidates available");
70
71        for shell in candidates {
72            let wrapper_body = shell.wrapper_script_bytes(user_command, &paths.exit);
73            let wrapper_path = windows_wrapper_path(paths, &shell);
74            if let Err(error) = fs::write(&wrapper_path, wrapper_body) {
75                last_err = format!("write wrapper {wrapper_path:?}: {error}");
76                continue;
77            }
78
79            let args: Vec<OsString> = shell
80                .pty_wrapper_args(&wrapper_path)
81                .into_iter()
82                .map(OsString::from)
83                .collect();
84
85            match try_spawn_pty(
86                spawn_plan,
87                task_id,
88                session_id,
89                std::ffi::OsStr::new(shell.binary().as_ref()),
90                &args,
91                paths,
92                workdir,
93                env,
94                rows,
95                cols,
96                wake_tx.clone(),
97                io_handles,
98            ) {
99                Ok(runtime) => return Ok(runtime),
100                Err(error) => {
101                    let msg = format!("{shell:?}: {error}");
102                    if msg.contains("NotFound") || msg.contains("not recognized") {
103                        last_err = msg;
104                        continue;
105                    }
106                    return Err(msg);
107                }
108            }
109        }
110
111        Err(last_err)
112    }
113}
114
115#[cfg(unix)]
116pub(crate) fn resolve_posix_shell() -> PathBuf {
117    resolve_posix_shell_with(
118        || std::env::var_os("SHELL").map(PathBuf::from),
119        is_executable_file,
120    )
121}
122
123#[cfg(unix)]
124fn resolve_posix_shell_with<S, X>(shell_env: S, is_executable: X) -> PathBuf
125where
126    S: FnOnce() -> Option<PathBuf>,
127    X: Fn(&Path) -> bool,
128{
129    if let Some(shell) =
130        shell_env().filter(|path| !path.as_os_str().is_empty() && is_executable(path.as_path()))
131    {
132        return shell;
133    }
134
135    for fallback in ["/bin/bash", "/bin/sh", "/bin/zsh"] {
136        let path = PathBuf::from(fallback);
137        if is_executable(&path) {
138            return path;
139        }
140    }
141
142    PathBuf::from("/bin/sh")
143}
144
145#[cfg(unix)]
146fn is_executable_file(path: &Path) -> bool {
147    fs::metadata(path)
148        .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
149        .unwrap_or(false)
150}
151
152#[cfg(windows)]
153fn windows_wrapper_path(
154    paths: &TaskPaths,
155    shell: &crate::windows_shell::WindowsShell,
156) -> std::path::PathBuf {
157    let extension = match shell {
158        crate::windows_shell::WindowsShell::Pwsh
159        | crate::windows_shell::WindowsShell::Powershell => "ps1",
160        crate::windows_shell::WindowsShell::Cmd => "bat",
161        crate::windows_shell::WindowsShell::Posix(_) => "sh",
162    };
163    let stem = paths
164        .json
165        .file_stem()
166        .and_then(|stem| stem.to_str())
167        .unwrap_or("wrapper");
168    paths.dir.join(format!("{stem}.{extension}"))
169}
170
171#[allow(clippy::too_many_arguments)]
172fn try_spawn_pty(
173    spawn_plan: &SpawnPlan,
174    task_id: &str,
175    session_id: &str,
176    program: &std::ffi::OsStr,
177    args: &[OsString],
178    paths: &TaskPaths,
179    workdir: &Path,
180    env: &HashMap<String, String>,
181    rows: u16,
182    cols: u16,
183    wake_tx: crossbeam_channel::Sender<()>,
184    io_handles: &mut TaskIoHandles,
185) -> Result<PtyRuntime, String> {
186    let (command, profile_handle) = crate::sandbox_spawn::pty_command_for_plan(
187        spawn_plan,
188        program,
189        args,
190        &paths.json,
191        workdir,
192        env,
193    )?;
194    let pty_system = portable_pty::native_pty_system();
195    let pair = pty_system
196        .openpty(PtySize {
197            rows,
198            cols,
199            pixel_width: 0,
200            pixel_height: 0,
201        })
202        .map_err(|error| format!("open PTY failed: {error}"))?;
203    let child = pair
204        .slave
205        .spawn_command(command)
206        .map_err(|error| format!("spawn PTY command failed: {error}"))?;
207    drop(profile_handle);
208    let child_pid = child.process_id();
209    let killer = child.clone_killer();
210    let reader = pair
211        .master
212        .try_clone_reader()
213        .map_err(|error| format!("clone PTY reader failed: {error}"))?;
214    let writer = pair
215        .master
216        .take_writer()
217        .map_err(|error| format!("take PTY writer failed: {error}"))?;
218
219    let reader_done = Arc::new(AtomicBool::new(false));
220    let exit_observed = Arc::new(AtomicBool::new(false));
221    let was_killed = Arc::new(AtomicBool::new(false));
222    let coordinator = Arc::new(CompletionCoordinator::new(
223        task_id.to_string(),
224        session_id.to_string(),
225        wake_tx,
226    ));
227
228    let writer = Arc::new(Mutex::new(writer));
229    let spill = io_handles
230        .clone_file(TaskArtifact::Pty)
231        .map_err(|error| format!("failed to clone PTY spill handle: {error}"))?;
232    let exit = io_handles
233        .clone_file(TaskArtifact::Exit)
234        .map_err(|error| format!("failed to clone PTY exit handle: {error}"))?;
235    spawn_reader(
236        reader,
237        spill,
238        Arc::clone(&reader_done),
239        Arc::clone(&coordinator),
240        Some(Arc::clone(&writer)),
241    );
242    spawn_waiter(
243        child,
244        exit,
245        Arc::clone(&was_killed),
246        Arc::clone(&exit_observed),
247        Arc::clone(&coordinator),
248    );
249
250    Ok(PtyRuntime {
251        master: Some(pair.master),
252        writer,
253        killer,
254        child_pid,
255        reader_done,
256        exit_observed,
257        was_killed,
258        coordinator,
259    })
260}
261
262/// DSR escape sequence `\x1b[6n` is 4 bytes, so a carry of the last 3 bytes of
263/// the running stream is enough to detect a needle straddling any read boundary
264/// (see `DsrScanner`).
265const DSR_CARRY_OVER: usize = 3;
266
267pub(crate) fn spawn_reader(
268    mut reader: Box<dyn Read + Send>,
269    mut file: std::fs::File,
270    reader_done: Arc<AtomicBool>,
271    coordinator: Arc<CompletionCoordinator>,
272    writer: Option<Arc<Mutex<Box<dyn Write + Send>>>>,
273) {
274    thread::spawn(move || {
275        let result = (|| -> io::Result<()> {
276            let mut buf = [0_u8; 8192];
277            let mut dsr = DsrScanner::default();
278            loop {
279                match reader.read(&mut buf) {
280                    Ok(0) => break,
281                    Ok(n) => {
282                        file.write_all(&buf[..n])?;
283                        file.flush()?;
284                        if dsr.scan(&buf[..n]) {
285                            // Some Windows console hosts/apps query the
286                            // terminal cursor position with DSR (ESC[6n)
287                            // before accepting input. A real terminal answers
288                            // with ESC[row;colR; without that response the
289                            // process can sit forever after emitting only the
290                            // query. We own both ends of the PTY, so provide a
291                            // conservative 1;1 response.
292                            if let Some(writer) = writer.as_ref() {
293                                if let Ok(mut writer) = writer.lock() {
294                                    let _ = writer.write_all(b"\x1b[1;1R");
295                                    let _ = writer.flush();
296                                }
297                            }
298                        }
299                    }
300                    Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
301                    Err(error) => return Err(error),
302                }
303            }
304            Ok(())
305        })();
306        if let Err(error) = result {
307            crate::slog_warn!(
308                "PTY reader for {}:{} stopped with error: {error}",
309                coordinator.session_id,
310                coordinator.task_id
311            );
312        }
313        reader_done.store(true, Ordering::SeqCst);
314        coordinator.signal_one_done();
315    });
316}
317
318/// Detects the DSR cursor-position query `\x1b[6n` (4 bytes) in a byte stream
319/// delivered in arbitrary chunks. A `read()` may return as little as one byte,
320/// so the 4-byte needle can split across ANY number of reads. We keep a rolling
321/// carry of the last 3 bytes seen and prepend it to each new chunk before
322/// scanning. Crucially the carry is taken from the COMBINED (carry + chunk)
323/// buffer, not just the chunk's own tail — that is what lets detection survive
324/// more than two reads (e.g. `\x1b`, `[`, `6`, `n` arriving as four reads).
325#[derive(Default)]
326struct DsrScanner {
327    carry: Vec<u8>,
328}
329
330impl DsrScanner {
331    fn scan(&mut self, chunk: &[u8]) -> bool {
332        let mut combined = Vec::with_capacity(self.carry.len() + chunk.len());
333        combined.extend_from_slice(&self.carry);
334        combined.extend_from_slice(chunk);
335        let detected = combined.windows(4).any(|w| w == b"\x1b[6n");
336        // Carry the last 3 bytes of the combined stream forward: a 4-byte
337        // needle straddling this boundary keeps at most 3 bytes on this side.
338        let start = combined.len().saturating_sub(DSR_CARRY_OVER);
339        self.carry.clear();
340        self.carry.extend_from_slice(&combined[start..]);
341        detected
342    }
343}
344
345pub(crate) fn spawn_waiter(
346    mut child: Box<dyn portable_pty::Child + Send + Sync>,
347    mut exit_file: std::fs::File,
348    was_killed: Arc<AtomicBool>,
349    exit_observed: Arc<AtomicBool>,
350    coordinator: Arc<CompletionCoordinator>,
351) {
352    thread::spawn(move || {
353        let marker = loop {
354            match child.wait() {
355                Ok(status) => {
356                    if was_killed.load(Ordering::SeqCst) {
357                        break ExitMarker::Killed;
358                    }
359                    let code = i32::try_from(status.exit_code()).unwrap_or(i32::MAX);
360                    break ExitMarker::Code(code);
361                }
362                Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
363                Err(error) => {
364                    crate::slog_warn!(
365                        "PTY waiter for {}:{} failed: {error}",
366                        coordinator.session_id,
367                        coordinator.task_id
368                    );
369                    break ExitMarker::Killed;
370                }
371            }
372        };
373
374        if let Err(error) = write_exit_marker(&mut exit_file, &marker) {
375            crate::slog_warn!(
376                "PTY waiter for {}:{} failed to write exit marker: {error}",
377                coordinator.session_id,
378                coordinator.task_id
379            );
380        }
381        exit_observed.store(true, Ordering::SeqCst);
382        coordinator.signal_one_done();
383    });
384}
385
386fn write_exit_marker(file: &mut File, marker: &ExitMarker) -> io::Result<()> {
387    if file.metadata()?.len() > 0 {
388        return Ok(());
389    }
390    let content = match marker {
391        ExitMarker::Code(code) => code.to_string(),
392        ExitMarker::Killed => "killed".to_string(),
393    };
394    file.set_len(0)?;
395    file.seek(SeekFrom::Start(0))?;
396    file.write_all(content.as_bytes())?;
397    file.sync_all()
398}
399
400// Every test in this module exercises Unix-only PTY paths (`#[cfg(unix)]`
401// shell resolution + the spawn_waiter), so gate the whole module on `unix` to
402// avoid unused-import / dead-code warnings when cross-compiling for Windows.
403#[cfg(all(test, unix))]
404mod tests {
405    use std::io;
406    use std::sync::atomic::{AtomicBool, Ordering};
407    use std::sync::Arc;
408    use std::time::{Duration, Instant};
409
410    use portable_pty::{Child, ChildKiller, ExitStatus};
411
412    use super::*;
413
414    #[derive(Debug)]
415    struct FakeKiller;
416
417    impl ChildKiller for FakeKiller {
418        fn kill(&mut self) -> io::Result<()> {
419            Ok(())
420        }
421
422        fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
423            Box::new(FakeKiller)
424        }
425    }
426
427    #[derive(Debug)]
428    struct InterruptedOnceChild {
429        waits: usize,
430    }
431
432    impl ChildKiller for InterruptedOnceChild {
433        fn kill(&mut self) -> io::Result<()> {
434            Ok(())
435        }
436
437        fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
438            Box::new(FakeKiller)
439        }
440    }
441
442    impl Child for InterruptedOnceChild {
443        fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
444            Ok(None)
445        }
446
447        fn wait(&mut self) -> io::Result<ExitStatus> {
448            self.waits += 1;
449            if self.waits == 1 {
450                Err(io::Error::from(io::ErrorKind::Interrupted))
451            } else {
452                Ok(ExitStatus::with_exit_code(0))
453            }
454        }
455
456        fn process_id(&self) -> Option<u32> {
457            None
458        }
459
460        #[cfg(windows)]
461        fn as_raw_handle(&self) -> Option<std::os::windows::io::RawHandle> {
462            None
463        }
464    }
465
466    #[cfg(unix)]
467    #[test]
468    fn pty_shell_prefers_executable_shell_env() {
469        let shell = PathBuf::from("/custom/zsh");
470        let resolved =
471            resolve_posix_shell_with(|| Some(shell.clone()), |path| path == shell.as_path());
472
473        assert_eq!(resolved, shell);
474    }
475
476    #[cfg(unix)]
477    #[test]
478    fn pty_shell_ignores_unusable_shell_env_and_uses_fallback_order() {
479        let resolved = resolve_posix_shell_with(
480            || Some(PathBuf::from("/missing/fish")),
481            |path| path == Path::new("/bin/sh") || path == Path::new("/bin/zsh"),
482        );
483
484        assert_eq!(resolved, PathBuf::from("/bin/sh"));
485    }
486
487    #[cfg(unix)]
488    #[test]
489    fn pty_shell_uses_bin_bash_before_later_fallbacks() {
490        let resolved = resolve_posix_shell_with(
491            || None,
492            |path| path == Path::new("/bin/bash") || path == Path::new("/bin/sh"),
493        );
494
495        assert_eq!(resolved, PathBuf::from("/bin/bash"));
496    }
497
498    #[cfg(unix)]
499    #[test]
500    fn pty_waiter_retries_wait_on_interrupted() {
501        let temp = tempfile::tempdir().unwrap();
502        let exit_path = temp.path().join("task.exit");
503        let exit_file = File::create(&exit_path).unwrap();
504        let (wake_tx, wake_rx) = crossbeam_channel::bounded(1);
505        let coordinator = Arc::new(CompletionCoordinator::new(
506            "task".to_string(),
507            "session".to_string(),
508            wake_tx,
509        ));
510        let was_killed = Arc::new(AtomicBool::new(false));
511        let exit_observed = Arc::new(AtomicBool::new(false));
512
513        spawn_waiter(
514            Box::new(InterruptedOnceChild { waits: 0 }),
515            exit_file,
516            was_killed,
517            Arc::clone(&exit_observed),
518            Arc::clone(&coordinator),
519        );
520        coordinator.signal_one_done();
521
522        let started = Instant::now();
523        while !exit_observed.load(Ordering::SeqCst) {
524            assert!(started.elapsed() < Duration::from_secs(2));
525            std::thread::sleep(Duration::from_millis(10));
526        }
527        wake_rx.recv_timeout(Duration::from_secs(1)).unwrap();
528        assert_eq!(fs::read_to_string(exit_path).unwrap(), "0");
529    }
530
531    /// Feed `chunks` to a fresh scanner and return how many chunks reported a
532    /// detection. The needle appears exactly once across all chunks, so a
533    /// correct scanner returns exactly 1 (detect once, never double-fire).
534    fn scan_chunks(chunks: &[&[u8]]) -> usize {
535        let mut scanner = DsrScanner::default();
536        chunks.iter().filter(|chunk| scanner.scan(chunk)).count()
537    }
538
539    #[test]
540    fn dsr_detected_within_single_read() {
541        assert_eq!(scan_chunks(&[b"\x1b[6n"]), 1);
542    }
543
544    #[test]
545    fn dsr_detected_two_read_splits() {
546        assert_eq!(scan_chunks(&[b"\x1b[6", b"n"]), 1); // 3/1
547        assert_eq!(scan_chunks(&[b"\x1b[", b"6n"]), 1); // 2/2
548        assert_eq!(scan_chunks(&[b"\x1b", b"[6n"]), 1); // 1/3
549    }
550
551    #[test]
552    fn dsr_detected_across_more_than_two_reads() {
553        // The original carry-over (keep only the chunk's own last 3 bytes)
554        // missed these because by the time `n` arrives the `\x1b` had aged out.
555        assert_eq!(scan_chunks(&[b"\x1b", b"[", b"6", b"n"]), 1); // 1/1/1/1
556        assert_eq!(scan_chunks(&[b"\x1b", b"[", b"6n"]), 1); // 1/1/2
557        assert_eq!(scan_chunks(&[b"\x1b[", b"6", b"n"]), 1); // 2/1/1
558                                                             // With unrelated leading noise that pushes the needle across reads.
559        assert_eq!(scan_chunks(&[b"junk\x1b", b"[6", b"n more"]), 1);
560    }
561
562    #[test]
563    fn dsr_detected_once_with_surrounding_output() {
564        // Single sequence embedded in a larger single read fires exactly once.
565        assert_eq!(scan_chunks(&[b"hello\x1b[6nworld"]), 1);
566    }
567
568    #[test]
569    fn dsr_not_detected_no_match() {
570        assert_eq!(scan_chunks(&[b"abc", b"def"]), 0);
571        assert_eq!(scan_chunks(&[b"hello"]), 0);
572        // Partial-but-never-completed sequence must not fire.
573        assert_eq!(scan_chunks(&[b"\x1b[6", b"x"]), 0);
574    }
575}