diff --git a/codex-rs/tui/src/tui/tmux.rs b/codex-rs/tui/src/tui/tmux.rs index 147c1c1fb5..49f85baf2e 100644 --- a/codex-rs/tui/src/tui/tmux.rs +++ b/codex-rs/tui/src/tui/tmux.rs @@ -3,8 +3,20 @@ //! Mouse capture is suppressed only when the containing session explicitly reports mouse off. //! Missing tmux or an inconclusive probe preserves the normal input policy. +use std::io::Read; +use std::io::Seek; use std::process::Command; use std::process::Stdio; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::sync::mpsc; +use std::time::Duration; +use std::time::Instant; + +const PROBE_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 1); +const PROBE_POLL_INTERVAL: Duration = Duration::from_millis(/*millis*/ 10); +const MAX_PROBE_OUTPUT_BYTES: u64 = 4096; +static PROBE_IN_FLIGHT: AtomicBool = AtomicBool::new(false); /// Capture policy for the current terminal setup, shared by fullscreen and overlays. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -31,16 +43,69 @@ pub(super) fn options() -> Options { let Ok(path) = codex_utils_path::system_path() else { return Options::default(); }; - read_options(pane.as_deref(), |args| { - let output = Command::new(&executable) - .env("PATH", &path) - .args(args) - .stdin(Stdio::null()) - .stderr(Stdio::null()) - .output() - .ok()?; - output.status.success().then_some(output.stdout) - }) + if PROBE_IN_FLIGHT.swap(true, Ordering::AcqRel) { + return Options::default(); + } + let deadline = Instant::now() + PROBE_TIMEOUT; + let (sender, receiver) = mpsc::sync_channel(1); + if std::thread::Builder::new() + .name("tmux-options".into()) + .spawn(move || { + let options = read_options(pane.as_deref(), |args| { + let mut command = Command::new(&executable); + command + .env("PATH", &path) + .args(args) + .stdin(Stdio::null()) + .stderr(Stdio::null()); + command_stdout_before_deadline(&mut command, deadline) + }); + PROBE_IN_FLIGHT.store(false, Ordering::Release); + let _ = sender.send(options); + }) + .is_err() + { + PROBE_IN_FLIGHT.store(false, Ordering::Release); + return Options::default(); + } + + receiver + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .unwrap_or_default() +} + +fn command_stdout_before_deadline(command: &mut Command, deadline: Instant) -> Option> { + if Instant::now() >= deadline { + return None; + } + let mut stdout = tempfile::tempfile().ok()?; + command.stdout(stdout.try_clone().ok()?); + let mut child = command.spawn().ok()?; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + match child.try_wait() { + Ok(Some(status)) => { + if !status.success() { + return None; + } + stdout.rewind().ok()?; + let mut output = Vec::new(); + stdout + .take(MAX_PROBE_OUTPUT_BYTES) + .read_to_end(&mut output) + .ok()?; + return Some(output); + } + Ok(None) if !remaining.is_zero() => { + std::thread::sleep(remaining.min(PROBE_POLL_INTERVAL)); + } + Ok(None) | Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } } fn read_options(pane: Option<&str>, mut run: impl FnMut(&[&str]) -> Option>) -> Options { diff --git a/codex-rs/tui/src/tui/tmux_tests.rs b/codex-rs/tui/src/tui/tmux_tests.rs index cc145d33a0..b4fd27898b 100644 --- a/codex-rs/tui/src/tui/tmux_tests.rs +++ b/codex-rs/tui/src/tui/tmux_tests.rs @@ -3,6 +3,81 @@ use super::*; use pretty_assertions::assert_eq; +#[cfg(unix)] +#[test] +fn completed_probe_returns_stdout() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "printf 'csi-u\\t0\\n'"]); + + assert_eq!( + command_stdout_before_deadline( + &mut command, + Instant::now() + Duration::from_secs(/*secs*/ 5) + ), + Some(b"csi-u\t0\n".to_vec()) + ); +} + +#[cfg(unix)] +#[test] +fn hanging_probe_times_out_and_reaps_child() { + let pid_file = tempfile::NamedTempFile::new().unwrap(); + let mut command = Command::new("/bin/sh"); + command + .args(["-c", "echo $$ > \"$1\"; exec /bin/sleep 30", "sh"]) + .arg(pid_file.path()); + let started = Instant::now(); + + assert_eq!( + command_stdout_before_deadline( + &mut command, + Instant::now() + Duration::from_secs(/*secs*/ 1) + ), + None + ); + assert!(started.elapsed() < Duration::from_secs(/*secs*/ 3)); + let pid = std::fs::read_to_string(pid_file.path()) + .unwrap() + .trim() + .parse::() + .unwrap(); + assert_eq!( + // SAFETY: signal 0 only checks whether this process still exists. + unsafe { + libc::kill(pid, /*sig*/ 0) + }, + -1 + ); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ESRCH) + ); +} + +#[cfg(unix)] +#[test] +fn completed_probe_does_not_wait_for_descendant_holding_stdout() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "sleep 30 & printf '%s\\tcsi-u\\t0\\n' \"$!\""]); + let started = Instant::now(); + + let output = command_stdout_before_deadline( + &mut command, + Instant::now() + Duration::from_secs(/*secs*/ 5), + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + let (pid, settings) = output.split_once('\t').unwrap(); + let pid = pid.parse::().unwrap(); + // SAFETY: the child PID came directly from the test shell. + unsafe { + libc::kill(pid, libc::SIGKILL); + } + + assert_eq!(settings, "csi-u\t0\n"); + assert!(started.elapsed() < Duration::from_secs(/*secs*/ 2)); +} + #[test] fn input_probe_targets_the_containing_pane_and_only_disables_confirmed_mouse_off() { for (mouse, mouse_capture) in [