Fix bracketed paste passthrough and PTY write deadlock (#34)
Some checks failed
CI / Version Badge (push) Has been cancelled
CI / Check (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / test-matrix (macos-latest) (push) Has been cancelled
CI / test-matrix (ubuntu-latest) (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Integration Tests (macos-latest) (push) Has been cancelled
CI / Integration Tests (ubuntu-latest) (push) Has been cancelled
CI / Nix Build (macos-latest) (push) Has been cancelled
CI / Nix Build (ubuntu-latest) (push) Has been cancelled
Some checks failed
CI / Version Badge (push) Has been cancelled
CI / Check (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / test-matrix (macos-latest) (push) Has been cancelled
CI / test-matrix (ubuntu-latest) (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Integration Tests (macos-latest) (push) Has been cancelled
CI / Integration Tests (ubuntu-latest) (push) Has been cancelled
CI / Nix Build (macos-latest) (push) Has been cancelled
CI / Nix Build (ubuntu-latest) (push) Has been cancelled
* fix: bracketed paste passthrough and pty write deadlock prevention Enable bracketed paste on the real terminal at startup so tmux/Ghostty wraps paste content in markers. Detect paste markers in process_input and forward directly to the child pty, skipping byte-by-byte lookback matching. Replace blocking write_all with write_to_pty_draining that interleaves output reads to prevent the classic pty buffer deadlock when forwarding large input. * Fix clippy/fmt, buffer drained PTY output through VT parser, handle split paste end markers - Fix clippy collapsible_if warning and cargo fmt issues - Drained child output during PTY writes now buffers into pty_drain_buffer and is processed through process_output after process_input returns, keeping VT parser state, history, and alt-screen tracking in sync - Add paste_remainder buffer to handle paste end markers split across read() boundaries, preventing permanent stuck-in-paste-mode state - Add split_trailing_marker_prefix helper with tests --------- Co-authored-by: Dave Beesley <david.beesley@pm.me>
This commit is contained in:
@@ -9,6 +9,11 @@ pub const ALT_SCREEN_EXIT: &[u8] = b"\x1b[?1049l";
|
||||
pub const ALT_SCREEN_ENTER_LEGACY: &[u8] = b"\x1b[?47h";
|
||||
pub const ALT_SCREEN_EXIT_LEGACY: &[u8] = b"\x1b[?47l";
|
||||
|
||||
pub const BRACKETED_PASTE_START: &[u8] = b"\x1b[200~";
|
||||
pub const BRACKETED_PASTE_END: &[u8] = b"\x1b[201~";
|
||||
pub const BRACKETED_PASTE_ENABLE: &[u8] = b"\x1b[?2004h";
|
||||
pub const BRACKETED_PASTE_DISABLE: &[u8] = b"\x1b[?2004l";
|
||||
|
||||
pub const SYNC_BUFFER_CAPACITY: usize = 1024 * 1024;
|
||||
pub const OUTPUT_BUFFER_CAPACITY: usize = 32768;
|
||||
pub const INPUT_BUFFER_CAPACITY: usize = 64;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::escape_sequences::{
|
||||
ALT_SCREEN_ENTER, ALT_SCREEN_ENTER_LEGACY, ALT_SCREEN_EXIT, ALT_SCREEN_EXIT_LEGACY,
|
||||
BRACKETED_PASTE_DISABLE, BRACKETED_PASTE_ENABLE, BRACKETED_PASTE_END, BRACKETED_PASTE_START,
|
||||
CLEAR_SCREEN, CURSOR_HOME, INPUT_BUFFER_CAPACITY, OUTPUT_BUFFER_CAPACITY, SYNC_BUFFER_CAPACITY,
|
||||
SYNC_END, SYNC_START,
|
||||
};
|
||||
@@ -112,6 +113,7 @@ pub struct Proxy {
|
||||
in_sync_block: bool,
|
||||
in_lookback_mode: bool,
|
||||
in_alternate_screen: bool,
|
||||
in_bracketed_paste: bool,
|
||||
kitty_mode_supported: bool,
|
||||
kitty_mode_stack: u32,
|
||||
kitty_output_parser: TermwizParser,
|
||||
@@ -127,6 +129,10 @@ pub struct Proxy {
|
||||
alt_screen_exit_finder: memmem::Finder<'static>,
|
||||
alt_screen_enter_legacy_finder: memmem::Finder<'static>,
|
||||
alt_screen_exit_legacy_finder: memmem::Finder<'static>,
|
||||
paste_start_finder: memmem::Finder<'static>,
|
||||
paste_end_finder: memmem::Finder<'static>,
|
||||
pty_drain_buffer: Vec<u8>,
|
||||
paste_remainder: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Returns (supported, initial_flags) - if flags > 0, terminal is already in Kitty mode
|
||||
@@ -257,6 +263,13 @@ impl Proxy {
|
||||
drop(pty.slave);
|
||||
set_nonblocking(&pty.master)?;
|
||||
|
||||
// Enable bracketed paste on the real terminal so tmux/Ghostty wraps
|
||||
// paste content in \x1b[200~ ... \x1b[201~ markers. Without this,
|
||||
// the child app's \x1b[?2004h gets eaten by the VT renderer before
|
||||
// it reaches the terminal, and pastes arrive as plain keystrokes.
|
||||
let stdout_fd = io::stdout();
|
||||
write_all(&stdout_fd, BRACKETED_PASTE_ENABLE)?;
|
||||
|
||||
let vt_parser = vt100::Parser::new(winsize.ws_row, winsize.ws_col, 0);
|
||||
|
||||
// Seed history with clear screen so replay starts fresh
|
||||
@@ -286,6 +299,7 @@ impl Proxy {
|
||||
in_sync_block: false,
|
||||
in_lookback_mode: false,
|
||||
in_alternate_screen: false,
|
||||
in_bracketed_paste: false,
|
||||
kitty_mode_supported: kitty_supported,
|
||||
kitty_mode_stack: kitty_initial_stack,
|
||||
kitty_output_parser: TermwizParser::new(),
|
||||
@@ -301,6 +315,10 @@ impl Proxy {
|
||||
alt_screen_exit_finder: memmem::Finder::new(ALT_SCREEN_EXIT),
|
||||
alt_screen_enter_legacy_finder: memmem::Finder::new(ALT_SCREEN_ENTER_LEGACY),
|
||||
alt_screen_exit_legacy_finder: memmem::Finder::new(ALT_SCREEN_EXIT_LEGACY),
|
||||
paste_start_finder: memmem::Finder::new(BRACKETED_PASTE_START),
|
||||
paste_end_finder: memmem::Finder::new(BRACKETED_PASTE_END),
|
||||
pty_drain_buffer: Vec::new(),
|
||||
paste_remainder: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -367,7 +385,13 @@ impl Proxy {
|
||||
{
|
||||
match nix_read(&stdin_fd, &mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => self.process_input(&buf[..n], &stdout_fd)?,
|
||||
Ok(n) => {
|
||||
self.process_input(&buf[..n], &stdout_fd)?;
|
||||
if !self.pty_drain_buffer.is_empty() {
|
||||
let drained = std::mem::take(&mut self.pty_drain_buffer);
|
||||
self.process_output(&drained, &stdout_fd)?;
|
||||
}
|
||||
}
|
||||
Err(Errno::EAGAIN) => {}
|
||||
Err(e) => anyhow::bail!("read from stdin failed: {}", e),
|
||||
}
|
||||
@@ -821,12 +845,93 @@ impl Proxy {
|
||||
fn process_input<F: AsFd>(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> {
|
||||
self.last_stdin_time = Some(Instant::now());
|
||||
|
||||
debug!("process_input: stdin={:?}", data);
|
||||
debug!(
|
||||
"process_input: stdin={:?} paste={}",
|
||||
data, self.in_bracketed_paste
|
||||
);
|
||||
|
||||
// In alternate screen, forward directly with deadlock prevention
|
||||
if self.in_alternate_screen {
|
||||
return write_all(&self.pty_master, data);
|
||||
return write_to_pty_draining(&self.pty_master, data, &mut self.pty_drain_buffer);
|
||||
}
|
||||
|
||||
// In bracketed paste, forward directly until paste end marker
|
||||
if self.in_bracketed_paste {
|
||||
let combined;
|
||||
let search_data = if self.paste_remainder.is_empty() {
|
||||
data
|
||||
} else {
|
||||
combined = [self.paste_remainder.as_slice(), data].concat();
|
||||
self.paste_remainder.clear();
|
||||
&combined
|
||||
};
|
||||
|
||||
if let Some(pos) = self.paste_end_finder.find(search_data) {
|
||||
let end = pos + BRACKETED_PASTE_END.len();
|
||||
write_to_pty_draining(
|
||||
&self.pty_master,
|
||||
&search_data[..end],
|
||||
&mut self.pty_drain_buffer,
|
||||
)?;
|
||||
self.in_bracketed_paste = false;
|
||||
debug!("process_input: bracketed paste ended");
|
||||
if end < search_data.len() {
|
||||
return self.process_input(&search_data[end..], stdout_fd);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (forward, remainder) =
|
||||
split_trailing_marker_prefix(search_data, BRACKETED_PASTE_END);
|
||||
if !forward.is_empty() {
|
||||
write_to_pty_draining(&self.pty_master, forward, &mut self.pty_drain_buffer)?;
|
||||
}
|
||||
self.paste_remainder.extend_from_slice(remainder);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check for bracketed paste start
|
||||
if let Some(pos) = self.paste_start_finder.find(data) {
|
||||
debug!("process_input: bracketed paste started");
|
||||
// Process any data before paste start through normal lookback matching
|
||||
if pos > 0 {
|
||||
self.process_input_lookback(&data[..pos], stdout_fd)?;
|
||||
}
|
||||
self.in_bracketed_paste = true;
|
||||
let paste_data = &data[pos..];
|
||||
// Check if paste end is in same chunk
|
||||
let search_start = BRACKETED_PASTE_START.len();
|
||||
if paste_data.len() > search_start
|
||||
&& let Some(end_pos) = self.paste_end_finder.find(&paste_data[search_start..])
|
||||
{
|
||||
let end = search_start + end_pos + BRACKETED_PASTE_END.len();
|
||||
write_to_pty_draining(
|
||||
&self.pty_master,
|
||||
&paste_data[..end],
|
||||
&mut self.pty_drain_buffer,
|
||||
)?;
|
||||
self.in_bracketed_paste = false;
|
||||
debug!("process_input: bracketed paste ended (same chunk)");
|
||||
if end < paste_data.len() {
|
||||
return self.process_input(&paste_data[end..], stdout_fd);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let (forward, remainder) =
|
||||
split_trailing_marker_prefix(paste_data, BRACKETED_PASTE_END);
|
||||
if !forward.is_empty() {
|
||||
write_to_pty_draining(&self.pty_master, forward, &mut self.pty_drain_buffer)?;
|
||||
}
|
||||
self.paste_remainder.extend_from_slice(remainder);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.process_input_lookback(data, stdout_fd)
|
||||
}
|
||||
|
||||
/// Byte-by-byte input processing with lookback sequence matching.
|
||||
/// Only used for normal (non-paste, non-alt-screen) input.
|
||||
fn process_input_lookback<F: AsFd>(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> {
|
||||
let lookback_sequence = if self.kitty_mode_enabled() {
|
||||
self.config.lookback_sequence_kitty.clone()
|
||||
} else {
|
||||
@@ -1000,6 +1105,10 @@ impl Proxy {
|
||||
|
||||
impl Drop for Proxy {
|
||||
fn drop(&mut self) {
|
||||
// Disable bracketed paste before restoring terminal
|
||||
let stdout_fd = io::stdout();
|
||||
let _ = write_all(&stdout_fd, BRACKETED_PASTE_DISABLE);
|
||||
|
||||
if let Some(ref termios) = self.original_termios {
|
||||
let _ = tcsetattr(io::stdin(), SetArg::TCSANOW, termios);
|
||||
}
|
||||
@@ -1083,10 +1192,55 @@ fn write_all<F: AsFd>(fd: &F, data: &[u8]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write data to the pty master, draining child output to stdout when the
|
||||
/// pty buffer is full. This prevents the classic pty deadlock where both
|
||||
/// sides block on full buffers (stdin→pty blocks because pty buffer is full,
|
||||
/// child→pty blocks because its output buffer is also full).
|
||||
fn write_to_pty_draining<P: AsFd>(pty: &P, data: &[u8], drain_buffer: &mut Vec<u8>) -> Result<()> {
|
||||
let mut written = 0;
|
||||
let mut read_buf = [0u8; 65536];
|
||||
|
||||
while written < data.len() {
|
||||
match write(pty, &data[written..]) {
|
||||
Ok(n) => written += n,
|
||||
Err(Errno::EAGAIN) => {
|
||||
// PTY buffer full — drain child output to make room
|
||||
match nix_read(pty, &mut read_buf) {
|
||||
Ok(n) if n > 0 => {
|
||||
drain_buffer.extend_from_slice(&read_buf[..n]);
|
||||
}
|
||||
_ => {
|
||||
// Nothing available yet, poll briefly
|
||||
let mut poll_fds = [PollFd::new(
|
||||
pty.as_fd(),
|
||||
PollFlags::POLLIN | PollFlags::POLLOUT,
|
||||
)];
|
||||
let _ = poll(&mut poll_fds, PollTimeout::from(10u16));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(Errno::EINTR) => continue,
|
||||
Err(e) => anyhow::bail!("write to pty failed: {}", e),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn nix_read<F: AsFd>(fd: &F, buf: &mut [u8]) -> Result<usize, Errno> {
|
||||
read(fd.as_fd(), buf)
|
||||
}
|
||||
|
||||
fn split_trailing_marker_prefix<'a>(data: &'a [u8], marker: &[u8]) -> (&'a [u8], &'a [u8]) {
|
||||
let max_prefix = marker.len().saturating_sub(1).min(data.len());
|
||||
for prefix_len in (1..=max_prefix).rev() {
|
||||
if data.ends_with(&marker[..prefix_len]) {
|
||||
let split = data.len() - prefix_len;
|
||||
return (&data[..split], &data[split..]);
|
||||
}
|
||||
}
|
||||
(data, &[])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1346,4 +1500,76 @@ mod tests {
|
||||
SequenceMatch::Complete
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_trailing_marker_prefix_no_match() {
|
||||
let marker = b"\x1b[201~";
|
||||
let data = b"hello world";
|
||||
let (forward, remainder) = split_trailing_marker_prefix(data, marker);
|
||||
assert_eq!(forward, b"hello world");
|
||||
assert!(remainder.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_trailing_marker_prefix_one_byte() {
|
||||
let marker = b"\x1b[201~";
|
||||
let data = b"paste data\x1b";
|
||||
let (forward, remainder) = split_trailing_marker_prefix(data, marker);
|
||||
assert_eq!(forward, b"paste data");
|
||||
assert_eq!(remainder, b"\x1b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_trailing_marker_prefix_partial() {
|
||||
let marker = b"\x1b[201~";
|
||||
let data = b"paste data\x1b[20";
|
||||
let (forward, remainder) = split_trailing_marker_prefix(data, marker);
|
||||
assert_eq!(forward, b"paste data");
|
||||
assert_eq!(remainder, b"\x1b[20");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_trailing_marker_prefix_max_prefix() {
|
||||
let marker = b"\x1b[201~";
|
||||
let data = b"paste\x1b[201";
|
||||
let (forward, remainder) = split_trailing_marker_prefix(data, marker);
|
||||
assert_eq!(forward, b"paste");
|
||||
assert_eq!(remainder, b"\x1b[201");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_trailing_marker_prefix_full_marker_not_held() {
|
||||
let marker = b"\x1b[201~";
|
||||
let data = b"paste\x1b[201~";
|
||||
let (forward, remainder) = split_trailing_marker_prefix(data, marker);
|
||||
assert_eq!(forward, b"paste\x1b[201~");
|
||||
assert!(remainder.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_trailing_marker_prefix_false_positive() {
|
||||
let marker = b"\x1b[201~";
|
||||
let data = b"paste data\x1b[999";
|
||||
let (forward, remainder) = split_trailing_marker_prefix(data, marker);
|
||||
assert_eq!(forward, b"paste data\x1b[999");
|
||||
assert!(remainder.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_trailing_marker_prefix_empty_data() {
|
||||
let marker = b"\x1b[201~";
|
||||
let data = b"";
|
||||
let (forward, remainder) = split_trailing_marker_prefix(data, marker);
|
||||
assert!(forward.is_empty());
|
||||
assert!(remainder.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_trailing_marker_prefix_data_shorter_than_marker() {
|
||||
let marker = b"\x1b[201~";
|
||||
let data = b"\x1b[2";
|
||||
let (forward, remainder) = split_trailing_marker_prefix(data, marker);
|
||||
assert!(forward.is_empty());
|
||||
assert_eq!(remainder, b"\x1b[2");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user