Skip to content

Commit 6b73c07

Browse files
Brooooooklynclaude
andcommitted
fix(cli): adopt DA1 sandwich technique for robust OSC color query detection
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) <noreply@anthropic.com>
1 parent 6c4b979 commit 6b73c07

1 file changed

Lines changed: 272 additions & 22 deletions

File tree

crates/vite_shared/src/header.rs

Lines changed: 272 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//! - Colorization and truecolor capability gates
55
//! - Foreground color OSC query (`ESC ] 10 ; ? ESC \\`) with timeout
66
//! - ANSI palette queries for blue/magenta with timeout
7+
//! - DA1 sandwich technique to detect unsupported terminals
78
//! - Gradient/fade generation and RGB ANSI coloring
89
910
use std::{
@@ -179,6 +180,79 @@ fn parse_osc4_rgb(buffer: &str, index: u8) -> Option<Rgb> {
179180
parse_rgb_triplet(&tail[rgb_start + 4..])
180181
}
181182

183+
/// Returns `true` if the terminal is known to not support OSC color queries
184+
/// or if the environment is unreliable for escape-sequence round-trips.
185+
///
186+
/// Modelled after `terminal-colorsaurus`'s quirks detection, extended with
187+
/// additional checks for Docker, CI, devcontainers, and other environments.
188+
#[cfg(unix)]
189+
fn is_osc_query_unsupported() -> bool {
190+
static UNSUPPORTED: OnceLock<bool> = OnceLock::new();
191+
*UNSUPPORTED.get_or_init(|| {
192+
if !std::io::stdout().is_terminal() || !std::io::stdin().is_terminal() {
193+
return true;
194+
}
195+
196+
// CI environments have no real terminal emulator behind the PTY.
197+
if std::env::var_os("CI").is_some() || std::env::var_os("GITHUB_ACTIONS").is_some() {
198+
return true;
199+
}
200+
201+
// Warp terminal does not respond to OSC color queries in its
202+
// block-mode renderer, causing a hang until the user presses a key.
203+
if is_warp_terminal() {
204+
return true;
205+
}
206+
207+
// Emacs terminal emulators (ansi-term, vterm, eshell) don't support
208+
// OSC queries.
209+
if std::env::var_os("INSIDE_EMACS").is_some() {
210+
return true;
211+
}
212+
213+
// Docker containers and devcontainers may have a PTY with no real
214+
// terminal emulator, causing OSC responses to leak as visible text.
215+
if std::path::Path::new("/.dockerenv").exists()
216+
|| std::env::var_os("REMOTE_CONTAINERS").is_some()
217+
|| std::env::var_os("CODESPACES").is_some()
218+
|| std::env::var_os("KUBERNETES_SERVICE_HOST").is_some()
219+
{
220+
return true;
221+
}
222+
223+
match std::env::var("TERM") {
224+
// Missing or non-unicode TERM is highly suspect.
225+
Err(_) => return true,
226+
// `TERM=dumb` indicates a minimal terminal with no escape support.
227+
Ok(term) if term == "dumb" => return true,
228+
// GNU Screen responds to OSC queries in the wrong order, breaking
229+
// the DA1 sandwich technique. It also only supports OSC 11, not
230+
// OSC 10 or OSC 4.
231+
Ok(term) if term == "screen" || term.starts_with("screen.") => return true,
232+
// Eterm doesn't support DA1, so we skip to avoid the timeout.
233+
Ok(term) if term == "Eterm" => return true,
234+
_ => {}
235+
}
236+
237+
// tmux and GNU Screen (via STY) do not reliably forward OSC color
238+
// query responses back to the child process.
239+
if std::env::var_os("TMUX").is_some() || std::env::var_os("STY").is_some() {
240+
return true;
241+
}
242+
243+
false
244+
})
245+
}
246+
247+
/// DA1 (Primary Device Attributes) query — supported by virtually all
248+
/// terminals. Used as a sentinel in the "DA1 sandwich" technique:
249+
/// we send our OSC queries followed by DA1, then read responses. If the
250+
/// DA1 response (`ESC [ ? ...`) arrives first, the terminal doesn't
251+
/// support OSC queries and we bail out immediately instead of waiting
252+
/// for a timeout.
253+
#[cfg(unix)]
254+
const DA1: &str = "\x1b[c";
255+
182256
#[cfg(unix)]
183257
fn query_terminal_colors(palette_indices: &[u8]) -> (Option<Rgb>, Vec<(u8, Rgb)>) {
184258
use std::{
@@ -192,20 +266,7 @@ fn query_terminal_colors(palette_indices: &[u8]) -> (Option<Rgb>, Vec<(u8, Rgb)>
192266
unistd::read,
193267
};
194268

195-
if std::env::var_os("CI").is_some() {
196-
return (None, vec![]);
197-
}
198-
199-
// Warp terminal does not respond to OSC color queries in its block-mode
200-
// renderer. Sending the queries causes the process to appear stuck until
201-
// the user presses a key (which is consumed as a fake "response").
202-
if is_warp_terminal() {
203-
return (None, vec![]);
204-
}
205-
206-
// tmux does not reliably forward OSC color query responses back to the
207-
// child process, causing the same hang-until-keypress behavior as Warp.
208-
if std::env::var_os("TMUX").is_some() {
269+
if is_osc_query_unsupported() {
209270
return (None, vec![]);
210271
}
211272

@@ -214,10 +275,6 @@ fn query_terminal_colors(palette_indices: &[u8]) -> (Option<Rgb>, Vec<(u8, Rgb)>
214275
Err(_) => return (None, vec![]),
215276
};
216277

217-
if !std::io::stdout().is_terminal() {
218-
return (None, vec![]);
219-
}
220-
221278
struct RawGuard {
222279
fd: RawFd,
223280
original: Termios,
@@ -242,21 +299,40 @@ fn query_terminal_colors(palette_indices: &[u8]) -> (Option<Rgb>, Vec<(u8, Rgb)>
242299
}
243300
let _guard = RawGuard { fd: tty.as_raw_fd(), original };
244301

245-
let mut query = format!("{ESC}]10;?{ESC}\\");
302+
// Build the query: OSC 10 (foreground) + OSC 4 (palette) + DA1 (sentinel).
303+
// BEL (\x07) is used as string terminator instead of ST (\x1b\\) because
304+
// urxvt has a bug where it terminates responses with bare ESC instead of
305+
// ST, causing a parse hang. BEL-terminated queries produce BEL-terminated
306+
// responses, avoiding this issue.
307+
let mut query = format!("{ESC}]10;?\x07");
246308
for index in palette_indices {
247-
query.push_str(&format!("{ESC}]4;{index};?{ESC}\\"));
309+
query.push_str(&format!("{ESC}]4;{index};?\x07"));
248310
}
311+
// DA1 sentinel — its response acts as a fence to detect unsupported
312+
// terminals.
313+
query.push_str(DA1);
314+
249315
if tty.write_all(query.as_bytes()).is_err() {
250316
return (None, vec![]);
251317
}
252318
if tty.flush().is_err() {
253319
return (None, vec![]);
254320
}
255321

256-
let deadline = Instant::now() + Duration::from_millis(100);
322+
// Use a longer timeout for SSH to account for round-trip latency.
323+
let timeout_ms = if std::env::var_os("SSH_CONNECTION").is_some()
324+
|| std::env::var_os("SSH_TTY").is_some()
325+
{
326+
1000
327+
} else {
328+
200
329+
};
330+
331+
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
257332
let mut last_data = Instant::now();
258333
let mut buffer = String::new();
259334
let mut foreground = None;
335+
let mut da1_arrived_first = false;
260336
let mut palette_colors: Vec<(u8, Option<Rgb>)> =
261337
palette_indices.iter().copied().map(|index| (index, None)).collect();
262338

@@ -296,6 +372,14 @@ fn query_terminal_colors(palette_indices: &[u8]) -> (Option<Rgb>, Vec<(u8, Rgb)>
296372
buffer = buffer[keep_from..].to_string();
297373
}
298374

375+
// DA1 response starts with `ESC [ ?`. If it arrives before any OSC
376+
// response, the terminal doesn't support OSC queries — bail out
377+
// immediately instead of waiting for the full timeout.
378+
if foreground.is_none() && buffer.contains("\x1b[?") {
379+
da1_arrived_first = true;
380+
break;
381+
}
382+
299383
if foreground.is_none() {
300384
foreground = parse_osc10_rgb(&buffer);
301385
}
@@ -306,17 +390,64 @@ fn query_terminal_colors(palette_indices: &[u8]) -> (Option<Rgb>, Vec<(u8, Rgb)>
306390
}
307391

308392
if foreground.is_some() && palette_colors.iter().all(|(_, color)| color.is_some()) {
393+
// All expected responses received. Drain the trailing DA1
394+
// response so it doesn't leak into the user's input buffer.
395+
drain_da1(&tty, &deadline);
309396
break;
310397
}
311398
}
312399

400+
if da1_arrived_first {
401+
return (None, vec![]);
402+
}
403+
313404
let resolved = palette_colors
314405
.into_iter()
315406
.filter_map(|(index, color)| color.map(|rgb| (index, rgb)))
316407
.collect();
317408
(foreground, resolved)
318409
}
319410

411+
/// Consume the trailing DA1 response (`ESC [ ? ... c`) so it doesn't leak
412+
/// as visible text after raw mode is restored.
413+
#[cfg(unix)]
414+
fn drain_da1(tty: &std::fs::File, deadline: &Instant) {
415+
use std::os::fd::AsFd;
416+
417+
use nix::{
418+
poll::{PollFd, PollFlags, PollTimeout, poll},
419+
unistd::read,
420+
};
421+
422+
while Instant::now() < *deadline {
423+
let remaining = deadline.saturating_duration_since(Instant::now());
424+
let wait = remaining.min(Duration::from_millis(20));
425+
426+
let mut fds = [PollFd::new(tty.as_fd(), PollFlags::POLLIN)];
427+
let timeout = match PollTimeout::try_from(wait) {
428+
Ok(value) => value,
429+
Err(_) => break,
430+
};
431+
let ready = match poll(&mut fds, timeout) {
432+
Ok(value) => value,
433+
Err(_) => break,
434+
};
435+
if ready == 0 {
436+
break;
437+
}
438+
439+
let mut chunk = [0_u8; 64];
440+
let n = match read(tty.as_fd(), &mut chunk) {
441+
Ok(value) => value,
442+
Err(_) => break,
443+
};
444+
// DA1 response ends with 'c'. Once we see it, we're done.
445+
if chunk[..n].contains(&b'c') {
446+
break;
447+
}
448+
}
449+
}
450+
320451
#[cfg(not(unix))]
321452
fn query_terminal_colors(_palette_indices: &[u8]) -> (Option<Rgb>, Vec<(u8, Rgb)>) {
322453
(None, vec![])
@@ -376,7 +507,10 @@ pub fn vite_plus_header() -> String {
376507

377508
#[cfg(all(test, unix))]
378509
mod tests {
379-
use super::{Rgb, gradient_eased, parse_osc4_rgb, parse_osc10_rgb, to_8bit};
510+
use super::{
511+
Rgb, gradient_eased, parse_osc4_rgb, parse_osc10_rgb, parse_rgb_triplet,
512+
query_terminal_colors, to_8bit,
513+
};
380514

381515
#[test]
382516
fn to_8bit_matches_js_rules() {
@@ -387,21 +521,137 @@ mod tests {
387521
assert_eq!(to_8bit("fff"), Some(255));
388522
}
389523

524+
#[test]
525+
fn to_8bit_single_digit() {
526+
assert_eq!(to_8bit("f"), Some(255));
527+
assert_eq!(to_8bit("0"), Some(0));
528+
assert_eq!(to_8bit("a"), Some(170));
529+
}
530+
531+
#[test]
532+
fn to_8bit_three_digit() {
533+
assert_eq!(to_8bit("fff"), Some(255));
534+
assert_eq!(to_8bit("000"), Some(0));
535+
assert_eq!(to_8bit("800"), Some(128));
536+
}
537+
538+
#[test]
539+
fn to_8bit_empty_returns_none() {
540+
assert_eq!(to_8bit(""), None);
541+
}
542+
543+
#[test]
544+
fn to_8bit_invalid_hex_returns_none() {
545+
assert_eq!(to_8bit("zz"), None);
546+
assert_eq!(to_8bit("gg"), None);
547+
}
548+
549+
#[test]
550+
fn parse_rgb_triplet_standard() {
551+
assert_eq!(parse_rgb_triplet("ff/ff/ff"), Some(Rgb(255, 255, 255)));
552+
assert_eq!(parse_rgb_triplet("00/00/00"), Some(Rgb(0, 0, 0)));
553+
}
554+
555+
#[test]
556+
fn parse_rgb_triplet_four_digit_channels() {
557+
assert_eq!(parse_rgb_triplet("ffff/ffff/ffff"), Some(Rgb(255, 255, 255)));
558+
assert_eq!(parse_rgb_triplet("0000/0000/0000"), Some(Rgb(0, 0, 0)));
559+
assert_eq!(parse_rgb_triplet("aaaa/bbbb/cccc"), Some(Rgb(170, 187, 204)));
560+
}
561+
562+
#[test]
563+
fn parse_rgb_triplet_mixed_digit_channels() {
564+
// Single digit channels
565+
assert_eq!(parse_rgb_triplet("f/e/d"), Some(Rgb(255, 238, 221)));
566+
}
567+
568+
#[test]
569+
fn parse_rgb_triplet_trailing_junk_ignored() {
570+
// The parser stops at non-hex chars for the blue channel
571+
assert_eq!(parse_rgb_triplet("ff/ff/ff\x1b\\"), Some(Rgb(255, 255, 255)));
572+
}
573+
574+
#[test]
575+
fn parse_rgb_triplet_missing_channel_returns_none() {
576+
assert_eq!(parse_rgb_triplet("ff/ff"), None);
577+
assert_eq!(parse_rgb_triplet("ff"), None);
578+
}
579+
390580
#[test]
391581
fn parse_osc10_response_extracts_rgb() {
392582
let response = "\x1b]10;rgb:aaaa/bbbb/cccc\x1b\\";
393583
assert_eq!(parse_osc10_rgb(response), Some(Rgb(170, 187, 204)));
394584
}
395585

586+
#[test]
587+
fn parse_osc10_bel_terminated() {
588+
let response = "\x1b]10;rgb:aaaa/bbbb/cccc\x07";
589+
assert_eq!(parse_osc10_rgb(response), Some(Rgb(170, 187, 204)));
590+
}
591+
592+
#[test]
593+
fn parse_osc10_no_match_returns_none() {
594+
assert_eq!(parse_osc10_rgb("garbage"), None);
595+
assert_eq!(parse_osc10_rgb(""), None);
596+
}
597+
396598
#[test]
397599
fn parse_osc4_response_extracts_rgb() {
398600
let response = "\x1b]4;5;rgb:aaaa/bbbb/cccc\x1b\\";
399601
assert_eq!(parse_osc4_rgb(response, 5), Some(Rgb(170, 187, 204)));
400602
}
401603

604+
#[test]
605+
fn parse_osc4_bel_terminated() {
606+
let response = "\x1b]4;4;rgb:5858/9292/ffff\x07";
607+
assert_eq!(parse_osc4_rgb(response, 4), Some(Rgb(88, 146, 255)));
608+
}
609+
610+
#[test]
611+
fn parse_osc4_wrong_index_returns_none() {
612+
let response = "\x1b]4;5;rgb:aaaa/bbbb/cccc\x1b\\";
613+
assert_eq!(parse_osc4_rgb(response, 4), None);
614+
}
615+
616+
#[test]
617+
fn parse_osc4_no_match_returns_none() {
618+
assert_eq!(parse_osc4_rgb("garbage", 5), None);
619+
assert_eq!(parse_osc4_rgb("", 0), None);
620+
}
621+
622+
#[test]
623+
fn parse_osc_multiple_responses_in_buffer() {
624+
// Simulates a buffer containing OSC 10 + OSC 4;4 + OSC 4;5 responses
625+
let buffer = "\x1b]10;rgb:d0d0/d0d0/d0d0\x07\
626+
\x1b]4;4;rgb:5858/9292/ffff\x07\
627+
\x1b]4;5;rgb:bbbb/7474/f7f7\x07";
628+
assert_eq!(parse_osc10_rgb(buffer), Some(Rgb(208, 208, 208)));
629+
assert_eq!(parse_osc4_rgb(buffer, 4), Some(Rgb(88, 146, 255)));
630+
assert_eq!(parse_osc4_rgb(buffer, 5), Some(Rgb(187, 116, 247)));
631+
}
632+
633+
#[test]
634+
fn parse_osc_buffer_with_da1_response() {
635+
// DA1 response mixed in — OSC parsers should still find their data
636+
let buffer = "\x1b]10;rgb:d0d0/d0d0/d0d0\x07\x1b[?64;1;2;4c";
637+
assert_eq!(parse_osc10_rgb(buffer), Some(Rgb(208, 208, 208)));
638+
}
639+
402640
#[test]
403641
fn gradient_counts_match() {
404642
assert_eq!(gradient_eased(0, Rgb(0, 0, 0), Rgb(255, 255, 255), 1.0).len(), 1);
405643
assert_eq!(gradient_eased(5, Rgb(10, 20, 30), Rgb(40, 50, 60), 1.0).len(), 5);
406644
}
645+
646+
/// Regression test ported from terminal-colorsaurus (issue #38).
647+
/// In CI there is no real terminal, so `query_terminal_colors` must
648+
/// return `(None, vec![])` without hanging.
649+
#[test]
650+
fn query_terminal_colors_does_not_hang() {
651+
let (fg, palette) = query_terminal_colors(&[4, 5]);
652+
// In CI, the environment pre-screening or DA1 sandwich will cause an
653+
// early return. We don't assert specific values — just that it
654+
// completes promptly and doesn't panic.
655+
let _ = (fg, palette);
656+
}
407657
}

0 commit comments

Comments
 (0)