Bound tmux option probes to one second (#50219)

## Why

A hung tmux command or a descendant holding stdout open can block terminal input setup indefinitely.

## What changed

Run tmux option probes on a worker thread with a shared one-second deadline, returning default input options when the wait times out. Prevent overlapping probes and kill and reap commands that exceed the deadline.

Capture stdout in a temporary file so completed commands do not wait for descendants to close it, and read at most 4 KiB of output.

## Testing

Add Unix regression tests for successful output, killing and reaping a timed-out command, and returning promptly when a descendant keeps stdout open.

GitOrigin-RevId: a5a4c9323a3e18d1baada5ab1cf3734ce6a9403a
This commit is contained in:
Eric Traut
2026-10-02 07:28:35 +00:00
committed by copyberry
parent 37f53199a6
commit 14a477ea89
2 changed files with 150 additions and 10 deletions
+75 -10
View File
@@ -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<Vec<u8>> {
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<Vec<u8>>) -> Options {
+75
View File
@@ -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::<libc::pid_t>()
.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::<libc::pid_t>().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 [