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