From 66cc2ea0b8920d54c403d6bc1f8ada58e7ca7ae9 Mon Sep 17 00:00:00 2001 From: TheKelvinPerez <124523284+TheKelvinPerez@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:05:44 -0500 Subject: [PATCH] Fix bracketed paste passthrough and PTY write deadlock (#34) * 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 --- crates/claude-chill/src/escape_sequences.rs | 5 + crates/claude-chill/src/proxy.rs | 232 +++++++++++++++++++- 2 files changed, 234 insertions(+), 3 deletions(-) diff --git a/crates/claude-chill/src/escape_sequences.rs b/crates/claude-chill/src/escape_sequences.rs index f6121ec..3a86ba9 100644 --- a/crates/claude-chill/src/escape_sequences.rs +++ b/crates/claude-chill/src/escape_sequences.rs @@ -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; diff --git a/crates/claude-chill/src/proxy.rs b/crates/claude-chill/src/proxy.rs index 857dba1..f294dce 100644 --- a/crates/claude-chill/src/proxy.rs +++ b/crates/claude-chill/src/proxy.rs @@ -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, + paste_remainder: Vec, } /// 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(&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(&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(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(pty: &P, data: &[u8], drain_buffer: &mut Vec) -> 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(fd: &F, buf: &mut [u8]) -> Result { 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"); + } }