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

* 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:
TheKelvinPerez
2026-03-29 19:05:44 -05:00
committed by GitHub
parent 2595cf7f89
commit 66cc2ea0b8
2 changed files with 234 additions and 3 deletions

View File

@@ -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_ENTER_LEGACY: &[u8] = b"\x1b[?47h";
pub const ALT_SCREEN_EXIT_LEGACY: &[u8] = b"\x1b[?47l"; 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 SYNC_BUFFER_CAPACITY: usize = 1024 * 1024;
pub const OUTPUT_BUFFER_CAPACITY: usize = 32768; pub const OUTPUT_BUFFER_CAPACITY: usize = 32768;
pub const INPUT_BUFFER_CAPACITY: usize = 64; pub const INPUT_BUFFER_CAPACITY: usize = 64;

View File

@@ -1,5 +1,6 @@
use crate::escape_sequences::{ use crate::escape_sequences::{
ALT_SCREEN_ENTER, ALT_SCREEN_ENTER_LEGACY, ALT_SCREEN_EXIT, ALT_SCREEN_EXIT_LEGACY, 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, CLEAR_SCREEN, CURSOR_HOME, INPUT_BUFFER_CAPACITY, OUTPUT_BUFFER_CAPACITY, SYNC_BUFFER_CAPACITY,
SYNC_END, SYNC_START, SYNC_END, SYNC_START,
}; };
@@ -112,6 +113,7 @@ pub struct Proxy {
in_sync_block: bool, in_sync_block: bool,
in_lookback_mode: bool, in_lookback_mode: bool,
in_alternate_screen: bool, in_alternate_screen: bool,
in_bracketed_paste: bool,
kitty_mode_supported: bool, kitty_mode_supported: bool,
kitty_mode_stack: u32, kitty_mode_stack: u32,
kitty_output_parser: TermwizParser, kitty_output_parser: TermwizParser,
@@ -127,6 +129,10 @@ pub struct Proxy {
alt_screen_exit_finder: memmem::Finder<'static>, alt_screen_exit_finder: memmem::Finder<'static>,
alt_screen_enter_legacy_finder: memmem::Finder<'static>, alt_screen_enter_legacy_finder: memmem::Finder<'static>,
alt_screen_exit_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 /// Returns (supported, initial_flags) - if flags > 0, terminal is already in Kitty mode
@@ -257,6 +263,13 @@ impl Proxy {
drop(pty.slave); drop(pty.slave);
set_nonblocking(&pty.master)?; 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); let vt_parser = vt100::Parser::new(winsize.ws_row, winsize.ws_col, 0);
// Seed history with clear screen so replay starts fresh // Seed history with clear screen so replay starts fresh
@@ -286,6 +299,7 @@ impl Proxy {
in_sync_block: false, in_sync_block: false,
in_lookback_mode: false, in_lookback_mode: false,
in_alternate_screen: false, in_alternate_screen: false,
in_bracketed_paste: false,
kitty_mode_supported: kitty_supported, kitty_mode_supported: kitty_supported,
kitty_mode_stack: kitty_initial_stack, kitty_mode_stack: kitty_initial_stack,
kitty_output_parser: TermwizParser::new(), kitty_output_parser: TermwizParser::new(),
@@ -301,6 +315,10 @@ impl Proxy {
alt_screen_exit_finder: memmem::Finder::new(ALT_SCREEN_EXIT), alt_screen_exit_finder: memmem::Finder::new(ALT_SCREEN_EXIT),
alt_screen_enter_legacy_finder: memmem::Finder::new(ALT_SCREEN_ENTER_LEGACY), alt_screen_enter_legacy_finder: memmem::Finder::new(ALT_SCREEN_ENTER_LEGACY),
alt_screen_exit_legacy_finder: memmem::Finder::new(ALT_SCREEN_EXIT_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) { match nix_read(&stdin_fd, &mut buf) {
Ok(0) => break, 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(Errno::EAGAIN) => {}
Err(e) => anyhow::bail!("read from stdin failed: {}", e), 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<()> { fn process_input<F: AsFd>(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> {
self.last_stdin_time = Some(Instant::now()); 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 { 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() { let lookback_sequence = if self.kitty_mode_enabled() {
self.config.lookback_sequence_kitty.clone() self.config.lookback_sequence_kitty.clone()
} else { } else {
@@ -1000,6 +1105,10 @@ impl Proxy {
impl Drop for Proxy { impl Drop for Proxy {
fn drop(&mut self) { 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 { if let Some(ref termios) = self.original_termios {
let _ = tcsetattr(io::stdin(), SetArg::TCSANOW, termios); let _ = tcsetattr(io::stdin(), SetArg::TCSANOW, termios);
} }
@@ -1083,10 +1192,55 @@ fn write_all<F: AsFd>(fd: &F, data: &[u8]) -> Result<()> {
Ok(()) 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> { fn nix_read<F: AsFd>(fd: &F, buf: &mut [u8]) -> Result<usize, Errno> {
read(fd.as_fd(), buf) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1346,4 +1500,76 @@ mod tests {
SequenceMatch::Complete 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");
}
} }