Skip to content

Commit ab0d96c

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 e3607ec commit ab0d96c

1 file changed

Lines changed: 275 additions & 22 deletions

File tree

crates/vite_shared/src/header.rs

Lines changed: 275 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,39 @@ 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 =
324+
if std::env::var_os("SSH_CONNECTION").is_some() || std::env::var_os("SSH_TTY").is_some() {
325+
1000
326+
} else {
327+
200
328+
};
329+
330+
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
257331
let mut last_data = Instant::now();
258332
let mut buffer = String::new();
259333
let mut foreground = None;
334+
let mut da1_arrived_first = false;
260335
let mut palette_colors: Vec<(u8, Option<Rgb>)> =
261336
palette_indices.iter().copied().map(|index| (index, None)).collect();
262337

@@ -296,6 +371,10 @@ fn query_terminal_colors(palette_indices: &[u8]) -> (Option<Rgb>, Vec<(u8, Rgb)>
296371
buffer = buffer[keep_from..].to_string();
297372
}
298373

374+
// Try to parse OSC responses before checking for DA1. On fast
375+
// terminals all responses (OSC + DA1) may arrive in a single read,
376+
// so we must extract OSC data first to avoid a false "DA1 arrived
377+
// first" conclusion.
299378
if foreground.is_none() {
300379
foreground = parse_osc10_rgb(&buffer);
301380
}
@@ -305,18 +384,73 @@ fn query_terminal_colors(palette_indices: &[u8]) -> (Option<Rgb>, Vec<(u8, Rgb)>
305384
}
306385
}
307386

387+
// DA1 response starts with `ESC [ ?`. If it arrives before any OSC
388+
// response, the terminal doesn't support OSC queries — bail out
389+
// immediately instead of waiting for the full timeout.
390+
if foreground.is_none() && buffer.contains("\x1b[?") {
391+
da1_arrived_first = true;
392+
break;
393+
}
394+
308395
if foreground.is_some() && palette_colors.iter().all(|(_, color)| color.is_some()) {
396+
// All expected responses received. Drain the trailing DA1
397+
// response so it doesn't leak into the user's input buffer.
398+
drain_da1(&tty, &deadline);
309399
break;
310400
}
311401
}
312402

403+
if da1_arrived_first {
404+
return (None, vec![]);
405+
}
406+
313407
let resolved = palette_colors
314408
.into_iter()
315409
.filter_map(|(index, color)| color.map(|rgb| (index, rgb)))
316410
.collect();
317411
(foreground, resolved)
318412
}
319413

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

377511
#[cfg(all(test, unix))]
378512
mod tests {
379-
use super::{Rgb, gradient_eased, parse_osc4_rgb, parse_osc10_rgb, to_8bit};
513+
use super::{
514+
Rgb, gradient_eased, parse_osc4_rgb, parse_osc10_rgb, parse_rgb_triplet,
515+
query_terminal_colors, to_8bit,
516+
};
380517

381518
#[test]
382519
fn to_8bit_matches_js_rules() {
@@ -387,21 +524,137 @@ mod tests {
387524
assert_eq!(to_8bit("fff"), Some(255));
388525
}
389526

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

589+
#[test]
590+
fn parse_osc10_bel_terminated() {
591+
let response = "\x1b]10;rgb:aaaa/bbbb/cccc\x07";
592+
assert_eq!(parse_osc10_rgb(response), Some(Rgb(170, 187, 204)));
593+
}
594+
595+
#[test]
596+
fn parse_osc10_no_match_returns_none() {
597+
assert_eq!(parse_osc10_rgb("garbage"), None);
598+
assert_eq!(parse_osc10_rgb(""), None);
599+
}
600+
396601
#[test]
397602
fn parse_osc4_response_extracts_rgb() {
398603
let response = "\x1b]4;5;rgb:aaaa/bbbb/cccc\x1b\\";
399604
assert_eq!(parse_osc4_rgb(response, 5), Some(Rgb(170, 187, 204)));
400605
}
401606

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

0 commit comments

Comments
 (0)