Skip to main content

aft/bash_background/
pty_process.rs

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