From 55642c23d6f8818103a8900017a004ac2c6ad016 Mon Sep 17 00:00:00 2001 From: Brooooooklyn Date: Wed, 18 Mar 2026 07:02:13 +0000 Subject: [PATCH] fix(cli): adopt DA1 sandwich technique for robust OSC color query detection (#986) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ad-hoc environment checks (CI, Warp, tmux, Docker) with a comprehensive `is_osc_query_unsupported()` pre-screen modelled after `terminal-colorsaurus`, and implement the DA1 sandwich technique to detect unsupported terminals at runtime without waiting for timeouts. Key changes: - Centralized quirks detection covering CI, Docker, devcontainers, Kubernetes, Emacs, GNU Screen, Eterm, tmux, and TERM=dumb - DA1 sentinel query appended after OSC queries; if its response arrives first the terminal doesn't support OSC and we bail immediately - drain_da1() consumes the trailing DA1 response to prevent leaks - BEL terminator instead of ST to work around urxvt response bug - SSH-aware timeout (1000ms vs 200ms local) - Ported regression test from terminal-colorsaurus issue #38 - Extended test coverage for parsing edge cases Co-Authored-By: Claude Opus 4.6 (1M context) --- > [!NOTE] > **Medium Risk** > Changes low-level `/dev/tty` I/O and escape-sequence parsing to avoid hangs/leaked output across many terminal environments; mistakes could regress header color detection or terminal interaction timing. > > **Overview** > Makes Vite+ header color probing **more robust across terminal emulators and container/CI environments** by replacing ad-hoc OSC query handling with a centralized `is_osc_query_unsupported()` gate plus the *DA1 sandwich* technique to detect lack of OSC support without waiting for timeouts. > > Reworks `query_terminal_colors` to send **BEL-terminated** OSC queries (urxvt workaround), parse responses **sequentially from the tty stream** via `BufReader` (with a poll-based, deadline-aware reader), and drain trailing DA1 output; also adds SSH-aware timeouts. > > Expands unix test coverage for hex/RGB parsing, BEL termination, mixed-response buffers, `read_until_either`, and includes a regression test ensuring `query_terminal_colors` doesn’t hang in CI. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a2f33995a33cc4bf08dcce61202c5f78588ba8c4. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- crates/vite_shared/src/header.rs | 484 ++++++++++++++++++++++++++----- 1 file changed, 414 insertions(+), 70 deletions(-) diff --git a/crates/vite_shared/src/header.rs b/crates/vite_shared/src/header.rs index 92c01dcd82..c6bf7c70e6 100644 --- a/crates/vite_shared/src/header.rs +++ b/crates/vite_shared/src/header.rs @@ -2,8 +2,10 @@ //! //! Header coloring behavior: //! - Colorization and truecolor capability gates -//! - Foreground color OSC query (`ESC ] 10 ; ? ESC \\`) with timeout +//! - Foreground color OSC query (`ESC ] 10 ; ? BEL`) with timeout //! - ANSI palette queries for blue/magenta with timeout +//! - DA1 sandwich technique to detect unsupported terminals +//! - Stream-based response parsing (modelled after `terminal-colorsaurus`) //! - Gradient/fade generation and RGB ANSI coloring use std::{ @@ -179,33 +181,132 @@ fn parse_osc4_rgb(buffer: &str, index: u8) -> Option { parse_rgb_triplet(&tail[rgb_start + 4..]) } +/// Returns `true` if the terminal is known to not support OSC color queries +/// or if the environment is unreliable for escape-sequence round-trips. +/// +/// Modelled after `terminal-colorsaurus`'s quirks detection, extended with +/// additional checks for Docker, CI, devcontainers, and other environments. +#[cfg(unix)] +fn is_osc_query_unsupported() -> bool { + static UNSUPPORTED: OnceLock = OnceLock::new(); + *UNSUPPORTED.get_or_init(|| { + if !std::io::stdout().is_terminal() || !std::io::stdin().is_terminal() { + return true; + } + + // CI environments have no real terminal emulator behind the PTY. + if std::env::var_os("CI").is_some() || std::env::var_os("GITHUB_ACTIONS").is_some() { + return true; + } + + // Warp terminal does not respond to OSC color queries in its + // block-mode renderer, causing a hang until the user presses a key. + if is_warp_terminal() { + return true; + } + + // Emacs terminal emulators (ansi-term, vterm, eshell) don't support + // OSC queries. + if std::env::var_os("INSIDE_EMACS").is_some() { + return true; + } + + // Docker containers and devcontainers may have a PTY with no real + // terminal emulator, causing OSC responses to leak as visible text. + if std::path::Path::new("/.dockerenv").exists() + || std::env::var_os("REMOTE_CONTAINERS").is_some() + || std::env::var_os("CODESPACES").is_some() + || std::env::var_os("KUBERNETES_SERVICE_HOST").is_some() + { + return true; + } + + match std::env::var("TERM") { + // Missing or non-unicode TERM is highly suspect. + Err(_) => return true, + // `TERM=dumb` indicates a minimal terminal with no escape support. + Ok(term) if term == "dumb" => return true, + // GNU Screen responds to OSC queries in the wrong order, breaking + // the DA1 sandwich technique. It also only supports OSC 11, not + // OSC 10 or OSC 4. + Ok(term) if term == "screen" || term.starts_with("screen.") => return true, + // Eterm doesn't support DA1, so we skip to avoid the timeout. + Ok(term) if term == "Eterm" => return true, + _ => {} + } + + // tmux and GNU Screen (via STY) do not reliably forward OSC color + // query responses back to the child process. + if std::env::var_os("TMUX").is_some() || std::env::var_os("STY").is_some() { + return true; + } + + false + }) +} + +/// DA1 (Primary Device Attributes) query — supported by virtually all +/// terminals. Used as a sentinel in the "DA1 sandwich" technique: +/// we send our OSC queries followed by DA1, then read responses. If the +/// DA1 response (`ESC [ ? ...`) arrives first, the terminal doesn't +/// support OSC queries and we bail out immediately instead of waiting +/// for a timeout. +#[cfg(unix)] +const DA1: &str = "\x1b[c"; + +/// Reads from a `BufRead` until one of two delimiter bytes is found. +/// Modelled after `terminal-colorsaurus`'s `read_until2`. +#[cfg(unix)] +fn read_until_either( + r: &mut impl std::io::BufRead, + d1: u8, + d2: u8, + buf: &mut Vec, +) -> std::io::Result { + let mut total = 0; + loop { + let available = match r.fill_buf() { + Ok(b) => b, + Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + }; + if available.is_empty() { + return Ok(total); + } + if let Some(i) = available.iter().position(|&b| b == d1 || b == d2) { + buf.extend_from_slice(&available[..=i]); + let used = i + 1; + r.consume(used); + total += used; + return Ok(total); + } + let len = available.len(); + buf.extend_from_slice(available); + r.consume(len); + total += len; + } +} + +/// Queries terminal colors using the DA1 sandwich technique with +/// stream-based response parsing (modelled after `terminal-colorsaurus`). +/// +/// Responses are read sequentially using `BufReader` + `read_until`, +/// which provides exact response boundaries and eliminates the +/// ordering/completeness ambiguities of flat-buffer pattern matching. #[cfg(unix)] fn query_terminal_colors(palette_indices: &[u8]) -> (Option, Vec<(u8, Rgb)>) { use std::{ fs::OpenOptions, + io::{self, BufRead, BufReader}, os::fd::{AsFd, AsRawFd, BorrowedFd, RawFd}, }; use nix::{ poll::{PollFd, PollFlags, PollTimeout, poll}, sys::termios::{SetArg, Termios, cfmakeraw, tcgetattr, tcsetattr}, - unistd::read, }; - if std::env::var_os("CI").is_some() { - return (None, vec![]); - } - - // Warp terminal does not respond to OSC color queries in its block-mode - // renderer. Sending the queries causes the process to appear stuck until - // the user presses a key (which is consumed as a fake "response"). - if is_warp_terminal() { - return (None, vec![]); - } - - // tmux does not reliably forward OSC color query responses back to the - // child process, causing the same hang-until-keypress behavior as Warp. - if std::env::var_os("TMUX").is_some() { + if is_osc_query_unsupported() { return (None, vec![]); } @@ -214,10 +315,6 @@ fn query_terminal_colors(palette_indices: &[u8]) -> (Option, Vec<(u8, Rgb)> Err(_) => return (None, vec![]), }; - if !std::io::stdout().is_terminal() { - return (None, vec![]); - } - struct RawGuard { fd: RawFd, original: Termios, @@ -240,12 +337,21 @@ fn query_terminal_colors(palette_indices: &[u8]) -> (Option, Vec<(u8, Rgb)> if tcsetattr(tty.as_fd(), SetArg::TCSANOW, &raw).is_err() { return (None, vec![]); } + // `_guard` is declared after `tty` so it drops first (reverse declaration + // order), restoring terminal mode while the fd is still open. let _guard = RawGuard { fd: tty.as_raw_fd(), original }; - let mut query = format!("{ESC}]10;?{ESC}\\"); + // Build the query: OSC 10 (foreground) + OSC 4 (palette) + DA1 (sentinel). + // BEL (\x07) is used as string terminator instead of ST (\x1b\\) because + // urxvt has a bug where it terminates responses with bare ESC instead of + // ST, causing a parse hang. BEL-terminated queries produce BEL-terminated + // responses, avoiding this issue. + let mut query = format!("{ESC}]10;?\x07"); for index in palette_indices { - query.push_str(&format!("{ESC}]4;{index};?{ESC}\\")); + query.push_str(&format!("{ESC}]4;{index};?\x07")); } + query.push_str(DA1); + if tty.write_all(query.as_bytes()).is_err() { return (None, vec![]); } @@ -253,68 +359,124 @@ fn query_terminal_colors(palette_indices: &[u8]) -> (Option, Vec<(u8, Rgb)> return (None, vec![]); } - let deadline = Instant::now() + Duration::from_millis(100); - let mut last_data = Instant::now(); - let mut buffer = String::new(); - let mut foreground = None; - let mut palette_colors: Vec<(u8, Option)> = - palette_indices.iter().copied().map(|index| (index, None)).collect(); - - while Instant::now() < deadline { - let remaining = deadline.saturating_duration_since(Instant::now()); - let wait = remaining.min(Duration::from_millis(10)); - - let mut fds = [PollFd::new(tty.as_fd(), PollFlags::POLLIN)]; - let timeout = match PollTimeout::try_from(wait) { - Ok(value) => value, - Err(_) => break, - }; - let ready = match poll(&mut fds, timeout) { - Ok(value) => value, - Err(_) => break, + // Use a longer timeout for SSH to account for round-trip latency. + let timeout_ms = + if std::env::var_os("SSH_CONNECTION").is_some() || std::env::var_os("SSH_TTY").is_some() { + 1000 + } else { + 200 }; - if ready == 0 { - if Instant::now().saturating_duration_since(last_data) >= Duration::from_millis(50) { - buffer.clear(); + let deadline = Instant::now() + Duration::from_millis(timeout_ms); + + // Timeout-aware reader: polls for readability before each read, + // returning `TimedOut` when the deadline expires. Wrapping in + // `BufReader` gives us `read_until` and `fill_buf`/`buffer` for + // delimiter-based parsing with peek-ahead. + struct TtyReader<'a> { + tty: &'a std::fs::File, + deadline: Instant, + } + + impl io::Read for TtyReader<'_> { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let remaining = self.deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(io::Error::new(io::ErrorKind::TimedOut, "tty read timed out")); } - continue; + let mut fds = [PollFd::new(self.tty.as_fd(), PollFlags::POLLIN)]; + let timeout = PollTimeout::try_from(remaining) + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "tty read timed out"))?; + let ready = poll(&mut fds, timeout).map_err(io::Error::from)?; + if ready == 0 { + return Err(io::Error::new(io::ErrorKind::TimedOut, "tty read timed out")); + } + io::Read::read(&mut &*self.tty, buf) } + } - let mut chunk = [0_u8; 256]; - let read_size = match read(tty.as_fd(), &mut chunk) { - Ok(value) => value, - Err(_) => break, + let tty_reader = TtyReader { tty: &tty, deadline }; + let mut reader = BufReader::with_capacity(64, tty_reader); + + const ESC_BYTE: u8 = 0x1b; + const BEL_BYTE: u8 = 0x07; + + // Read a single OSC response from the stream. Returns: + // Ok(bytes) — an OSC response (ESC ] ... BEL/ST) + // Err(true) — DA1 response arrived (terminal doesn't support this query) + // Err(false) — timeout or I/O error + // + // This mirrors `terminal-colorsaurus`'s `read_color_response`: read until + // ESC, peek at the next byte to distinguish OSC (']') from DA1 ('['), + // then read until the response terminator. + let read_osc_response = |r: &mut BufReader| -> Result, bool> { + let mut buf = Vec::new(); + + // Read until ESC — start of next response. + r.read_until(ESC_BYTE, &mut buf).map_err(|_| false)?; + + // Peek at the next byte in BufReader's internal buffer. + // ']' = OSC response, '[' = DA1/CSI response. + let next = match r.fill_buf() { + Ok(b) if !b.is_empty() => b[0], + _ => return Err(false), }; - if read_size == 0 { - continue; + + if next != b']' { + // DA1 response (ESC [ ? ... c). Consume it so it doesn't leak. + let mut discard = Vec::new(); + let _ = r.read_until(b'[', &mut discard); + let _ = r.read_until(b'c', &mut discard); + return Err(true); } - last_data = Instant::now(); - buffer.push_str(&String::from_utf8_lossy(&chunk[..read_size])); - if buffer.len() > 1024 { - let keep_from = buffer.len() - 1024; - buffer = buffer[keep_from..].to_string(); + // OSC response — read until BEL or ESC (for ST termination). + read_until_either(r, BEL_BYTE, ESC_BYTE, &mut buf).map_err(|_| false)?; + if buf.last() == Some(&ESC_BYTE) { + // ST-terminated: ESC followed by '\'. + r.read_until(b'\\', &mut buf).map_err(|_| false)?; } - if foreground.is_none() { - foreground = parse_osc10_rgb(&buffer); + Ok(buf) + }; + + // Read foreground color (OSC 10 response). + let foreground = match read_osc_response(&mut reader) { + Ok(data) => { + let s = String::from_utf8_lossy(&data); + parse_osc10_rgb(&s) } - for (index, color) in &mut palette_colors { - if color.is_none() { - *color = parse_osc4_rgb(&buffer, *index); + Err(true) => return (None, vec![]), // DA1 first → unsupported + Err(false) => return (None, vec![]), // timeout/error + }; + + // Read palette colors (OSC 4 responses). + let mut palette_results = Vec::new(); + let mut da1_consumed = false; + for &index in palette_indices { + match read_osc_response(&mut reader) { + Ok(data) => { + let s = String::from_utf8_lossy(&data); + if let Some(rgb) = parse_osc4_rgb(&s, index) { + palette_results.push((index, rgb)); + } + } + Err(is_da1) => { + da1_consumed = is_da1; + break; } } + } - if foreground.is_some() && palette_colors.iter().all(|(_, color)| color.is_some()) { - break; - } + // Drain the trailing DA1 response (ESC [ ? ... c) so it doesn't leak. + // Skip if the DA1 was already consumed inside read_osc_response. + if !da1_consumed { + let mut discard = Vec::new(); + let _ = reader.read_until(ESC_BYTE, &mut discard); + let _ = reader.read_until(b'[', &mut discard); + let _ = reader.read_until(b'c', &mut discard); } - let resolved = palette_colors - .into_iter() - .filter_map(|(index, color)| color.map(|rgb| (index, rgb))) - .collect(); - (foreground, resolved) + (foreground, palette_results) } #[cfg(not(unix))] @@ -376,7 +538,12 @@ pub fn vite_plus_header() -> String { #[cfg(all(test, unix))] mod tests { - use super::{Rgb, gradient_eased, parse_osc4_rgb, parse_osc10_rgb, to_8bit}; + use std::io::{BufReader, Cursor}; + + use super::{ + Rgb, gradient_eased, parse_osc4_rgb, parse_osc10_rgb, parse_rgb_triplet, + query_terminal_colors, read_until_either, to_8bit, + }; #[test] fn to_8bit_matches_js_rules() { @@ -387,21 +554,198 @@ mod tests { assert_eq!(to_8bit("fff"), Some(255)); } + #[test] + fn to_8bit_single_digit() { + assert_eq!(to_8bit("f"), Some(255)); + assert_eq!(to_8bit("0"), Some(0)); + assert_eq!(to_8bit("a"), Some(170)); + } + + #[test] + fn to_8bit_three_digit() { + assert_eq!(to_8bit("fff"), Some(255)); + assert_eq!(to_8bit("000"), Some(0)); + assert_eq!(to_8bit("800"), Some(128)); + } + + #[test] + fn to_8bit_empty_returns_none() { + assert_eq!(to_8bit(""), None); + } + + #[test] + fn to_8bit_invalid_hex_returns_none() { + assert_eq!(to_8bit("zz"), None); + assert_eq!(to_8bit("gg"), None); + } + + #[test] + fn parse_rgb_triplet_standard() { + assert_eq!(parse_rgb_triplet("ff/ff/ff"), Some(Rgb(255, 255, 255))); + assert_eq!(parse_rgb_triplet("00/00/00"), Some(Rgb(0, 0, 0))); + } + + #[test] + fn parse_rgb_triplet_four_digit_channels() { + assert_eq!(parse_rgb_triplet("ffff/ffff/ffff"), Some(Rgb(255, 255, 255))); + assert_eq!(parse_rgb_triplet("0000/0000/0000"), Some(Rgb(0, 0, 0))); + assert_eq!(parse_rgb_triplet("aaaa/bbbb/cccc"), Some(Rgb(170, 187, 204))); + } + + #[test] + fn parse_rgb_triplet_mixed_digit_channels() { + // Single digit channels + assert_eq!(parse_rgb_triplet("f/e/d"), Some(Rgb(255, 238, 221))); + } + + #[test] + fn parse_rgb_triplet_trailing_junk_ignored() { + // The parser stops at non-hex chars for the blue channel + assert_eq!(parse_rgb_triplet("ff/ff/ff\x1b\\"), Some(Rgb(255, 255, 255))); + } + + #[test] + fn parse_rgb_triplet_missing_channel_returns_none() { + assert_eq!(parse_rgb_triplet("ff/ff"), None); + assert_eq!(parse_rgb_triplet("ff"), None); + } + #[test] fn parse_osc10_response_extracts_rgb() { let response = "\x1b]10;rgb:aaaa/bbbb/cccc\x1b\\"; assert_eq!(parse_osc10_rgb(response), Some(Rgb(170, 187, 204))); } + #[test] + fn parse_osc10_bel_terminated() { + let response = "\x1b]10;rgb:aaaa/bbbb/cccc\x07"; + assert_eq!(parse_osc10_rgb(response), Some(Rgb(170, 187, 204))); + } + + #[test] + fn parse_osc10_no_match_returns_none() { + assert_eq!(parse_osc10_rgb("garbage"), None); + assert_eq!(parse_osc10_rgb(""), None); + } + #[test] fn parse_osc4_response_extracts_rgb() { let response = "\x1b]4;5;rgb:aaaa/bbbb/cccc\x1b\\"; assert_eq!(parse_osc4_rgb(response, 5), Some(Rgb(170, 187, 204))); } + #[test] + fn parse_osc4_bel_terminated() { + let response = "\x1b]4;4;rgb:5858/9292/ffff\x07"; + assert_eq!(parse_osc4_rgb(response, 4), Some(Rgb(88, 146, 255))); + } + + #[test] + fn parse_osc4_wrong_index_returns_none() { + let response = "\x1b]4;5;rgb:aaaa/bbbb/cccc\x1b\\"; + assert_eq!(parse_osc4_rgb(response, 4), None); + } + + #[test] + fn parse_osc4_no_match_returns_none() { + assert_eq!(parse_osc4_rgb("garbage", 5), None); + assert_eq!(parse_osc4_rgb("", 0), None); + } + + #[test] + fn parse_osc_multiple_responses_in_buffer() { + // Simulates a buffer containing OSC 10 + OSC 4;4 + OSC 4;5 responses + let buffer = "\x1b]10;rgb:d0d0/d0d0/d0d0\x07\ + \x1b]4;4;rgb:5858/9292/ffff\x07\ + \x1b]4;5;rgb:bbbb/7474/f7f7\x07"; + assert_eq!(parse_osc10_rgb(buffer), Some(Rgb(208, 208, 208))); + assert_eq!(parse_osc4_rgb(buffer, 4), Some(Rgb(88, 146, 255))); + assert_eq!(parse_osc4_rgb(buffer, 5), Some(Rgb(187, 116, 247))); + } + + #[test] + fn parse_osc_buffer_with_da1_response() { + // DA1 response mixed in — OSC parsers should still find their data + let buffer = "\x1b]10;rgb:d0d0/d0d0/d0d0\x07\x1b[?64;1;2;4c"; + assert_eq!(parse_osc10_rgb(buffer), Some(Rgb(208, 208, 208))); + } + #[test] fn gradient_counts_match() { assert_eq!(gradient_eased(0, Rgb(0, 0, 0), Rgb(255, 255, 255), 1.0).len(), 1); assert_eq!(gradient_eased(5, Rgb(10, 20, 30), Rgb(40, 50, 60), 1.0).len(), 5); } + + /// Regression test ported from terminal-colorsaurus (issue #38). + /// In CI there is no real terminal, so `query_terminal_colors` must + /// return `(None, vec![])` without hanging. + #[test] + fn query_terminal_colors_does_not_hang() { + let (fg, palette) = query_terminal_colors(&[4, 5]); + // In CI, the environment pre-screening or DA1 sandwich will cause an + // early return. We don't assert specific values — just that it + // completes promptly and doesn't panic. + let _ = (fg, palette); + } + + #[test] + fn read_until_either_stops_at_first_delimiter() { + let data = b"hello\x07world"; + let mut reader = BufReader::new(Cursor::new(data.as_slice())); + let mut buf = Vec::new(); + let n = read_until_either(&mut reader, 0x07, 0x1b, &mut buf).unwrap(); + assert_eq!(n, 6); // "hello" + BEL + assert_eq!(&buf, b"hello\x07"); + } + + #[test] + fn read_until_either_stops_at_second_delimiter() { + let data = b"hello\x1bworld"; + let mut reader = BufReader::new(Cursor::new(data.as_slice())); + let mut buf = Vec::new(); + let n = read_until_either(&mut reader, 0x07, 0x1b, &mut buf).unwrap(); + assert_eq!(n, 6); // "hello" + ESC + assert_eq!(&buf, b"hello\x1b"); + } + + #[test] + fn read_until_either_no_delimiter_reads_all() { + let data = b"hello world"; + let mut reader = BufReader::new(Cursor::new(data.as_slice())); + let mut buf = Vec::new(); + let n = read_until_either(&mut reader, 0x07, 0x1b, &mut buf).unwrap(); + assert_eq!(n, 11); + assert_eq!(&buf, b"hello world"); + } + + #[test] + fn read_until_either_empty_input() { + let data: &[u8] = b""; + let mut reader = BufReader::new(Cursor::new(data)); + let mut buf = Vec::new(); + let n = read_until_either(&mut reader, 0x07, 0x1b, &mut buf).unwrap(); + assert_eq!(n, 0); + assert!(buf.is_empty()); + } + + #[test] + fn read_until_either_delimiter_at_start() { + let data = b"\x07rest"; + let mut reader = BufReader::new(Cursor::new(data.as_slice())); + let mut buf = Vec::new(); + let n = read_until_either(&mut reader, 0x07, 0x1b, &mut buf).unwrap(); + assert_eq!(n, 1); + assert_eq!(&buf, b"\x07"); + } + + #[test] + fn read_until_either_multi_chunk() { + // Use a tiny BufReader capacity to force multiple fill_buf calls. + let data = b"abcdefgh\x07rest"; + let mut reader = BufReader::with_capacity(3, Cursor::new(data.as_slice())); + let mut buf = Vec::new(); + let n = read_until_either(&mut reader, 0x07, 0x1b, &mut buf).unwrap(); + assert_eq!(n, 9); // "abcdefgh" + BEL + assert_eq!(&buf, b"abcdefgh\x07"); + } }