From ce6d76f80b8897fb412ad6b767ce482b26a0ac3e Mon Sep 17 00:00:00 2001 From: wan9chi Date: Wed, 8 Jul 2026 18:27:13 +0800 Subject: [PATCH 1/9] ci: run Windows tests on Namespace runner Co-authored-by: GPT-5 Codex --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06dc1f40..04dc31bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -237,7 +237,7 @@ jobs: - shard: windows-ignored partition: '' mode: ignored - runs-on: windows-latest + runs-on: namespace-profile-windows-4c-8g env: PARTITION: ${{ matrix.partition }} steps: From 17e05bb07cd342632add3d7c399cfea5bdf67a19 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 9 Jul 2026 17:57:51 +0800 Subject: [PATCH 2/9] fix(test): use window titles for milestones Co-authored-by: GPT-5.6 --- Cargo.lock | 5 + Cargo.toml | 1 + crates/pty_terminal/src/terminal.rs | 122 ++++++++++--- crates/pty_terminal_test/README.md | 25 ++- crates/pty_terminal_test/src/lib.rs | 65 ++++--- crates/pty_terminal_test/tests/milestone.rs | 27 +++ crates/pty_terminal_test_client/Cargo.toml | 8 +- crates/pty_terminal_test_client/src/lib.rs | 193 +++++++++++++------- crates/vite_task/src/session/mod.rs | 6 +- crates/vite_task_bin/Cargo.toml | 3 +- 10 files changed, 314 insertions(+), 141 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6a8fd0e9..0ba95ea1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2868,6 +2868,11 @@ dependencies = [ [[package]] name = "pty_terminal_test_client" version = "0.0.0" +dependencies = [ + "base64", + "getrandom 0.4.2", + "winapi", +] [[package]] name = "quote" diff --git a/Cargo.toml b/Cargo.toml index 93d6882e..c734a89d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,6 +81,7 @@ fspy_shared_unix = { path = "crates/fspy_shared_unix" } futures = "0.3.31" futures-util = "0.3.31" globset = "0.4.18" +getrandom = "0.4.2" jsonc-parser = { version = "0.32.0", features = ["serde"] } libc = "0.2.185" libtest-mimic = "0.8.2" diff --git a/crates/pty_terminal/src/terminal.rs b/crates/pty_terminal/src/terminal.rs index 9ecf5a1e..b4ee7378 100644 --- a/crates/pty_terminal/src/terminal.rs +++ b/crates/pty_terminal/src/terminal.rs @@ -11,6 +11,7 @@ use portable_pty::{ChildKiller, ExitStatus, MasterPty}; use crate::geo::ScreenSize; type ChildWaitResult = Result>; +const MAX_WINDOW_TITLE_EVENTS: usize = 256; /// The read half of a PTY connection. Implements [`Read`]. /// @@ -53,12 +54,51 @@ pub struct Terminal { pub child_handle: ChildHandle, } +/// Window-title update captured with the terminal screen at its parse boundary. +pub struct WindowTitleEvent { + title: Vec, + screen: vt100::Screen, +} + +impl WindowTitleEvent { + #[must_use] + pub fn title(&self) -> &[u8] { + &self.title + } + + #[must_use] + pub fn screen_contents(&self) -> String { + self.screen.contents() + } + + #[must_use] + pub fn screen_contents_formatted(&self) -> Vec { + format_screen(&self.screen) + } +} + struct Vt100Callbacks { writer: Arc>>>, unhandled_osc_sequences: VecDeque>>, + window_title_prefix: Option>, + window_title_events: VecDeque, } impl vt100::Callbacks for Vt100Callbacks { + fn set_window_title(&mut self, screen: &mut vt100::Screen, title: &[u8]) { + let Some(prefix) = self.window_title_prefix.as_deref() else { + return; + }; + if !title.starts_with(prefix) { + return; + } + if self.window_title_events.len() == MAX_WINDOW_TITLE_EVENTS { + self.window_title_events.pop_front(); + } + self.window_title_events + .push_back(WindowTitleEvent { title: title.to_vec(), screen: screen.clone() }); + } + fn unhandled_osc(&mut self, _screen: &mut vt100::Screen, params: &[&[u8]]) { let owned: Vec> = params.iter().map(|p| p.to_vec()).collect(); self.unhandled_osc_sequences.push_back(owned); @@ -145,34 +185,10 @@ impl PtyReader { /// # Panics /// /// Panics if the parser lock is poisoned. - #[expect( - clippy::significant_drop_tightening, - reason = "vt100::Screen::rows_formatted yields borrowed iterators that need the guard alive" - )] #[must_use] pub fn screen_contents_formatted(&self) -> Vec { - const RESET: &[u8] = b"\x1b[m"; let guard = self.parser.lock().unwrap(); - let screen = guard.screen(); - let cols = screen.size().1; - let rows: Vec> = screen - .rows_formatted(0, cols) - .map(|mut row| { - while let Some(idx) = row.windows(RESET.len()).position(|w| w == RESET) { - row.drain(idx..idx + RESET.len()); - } - row - }) - .collect(); - let last_non_empty = rows.iter().rposition(|r| !r.is_empty()).map_or(0, |i| i + 1); - let mut out = Vec::new(); - for (i, row) in rows[..last_non_empty].iter().enumerate() { - if i > 0 { - out.push(b'\n'); - } - out.extend_from_slice(row); - } - out + format_screen(guard.screen()) } /// Drains and returns all unhandled OSC sequences received since the last call. @@ -188,6 +204,16 @@ impl PtyReader { std::mem::take(&mut self.parser.lock().unwrap().callbacks_mut().unhandled_osc_sequences) } + /// Drains title events captured while parsing PTY output. + /// + /// # Panics + /// + /// Panics if the parser lock is poisoned. + #[must_use] + pub fn take_window_title_events(&self) -> VecDeque { + std::mem::take(&mut self.parser.lock().unwrap().callbacks_mut().window_title_events) + } + /// Returns the current cursor position as `(row, col)`, both 0-indexed. /// /// # Panics @@ -305,6 +331,27 @@ impl Terminal { /// Returns an error if the PTY cannot be opened or the command fails to spawn. /// pub fn spawn(size: ScreenSize, cmd: CommandBuilder) -> anyhow::Result { + Self::spawn_inner(size, cmd, None) + } + + /// Spawns a child and captures matching window-title updates with screen snapshots. + /// + /// # Errors + /// + /// Returns an error if the PTY cannot be opened or the command fails to spawn. + pub fn spawn_capturing_window_titles( + size: ScreenSize, + cmd: CommandBuilder, + title_prefix: &[u8], + ) -> anyhow::Result { + Self::spawn_inner(size, cmd, Some(title_prefix.to_vec())) + } + + fn spawn_inner( + size: ScreenSize, + cmd: CommandBuilder, + window_title_prefix: Option>, + ) -> anyhow::Result { // On musl libc (Alpine Linux), concurrent PTY operations trigger // SIGSEGV/SIGBUS in musl internals (sysconf, fcntl). This affects // both openpty+fork and FD cleanup (close) from background threads. @@ -374,6 +421,8 @@ impl Terminal { Vt100Callbacks { writer: Arc::clone(&writer), unhandled_osc_sequences: VecDeque::new(), + window_title_prefix, + window_title_events: VecDeque::new(), }, ))); @@ -384,3 +433,26 @@ impl Terminal { }) } } + +fn format_screen(screen: &vt100::Screen) -> Vec { + const RESET: &[u8] = b"\x1b[m"; + let cols = screen.size().1; + let rows: Vec> = screen + .rows_formatted(0, cols) + .map(|mut row| { + while let Some(idx) = row.windows(RESET.len()).position(|w| w == RESET) { + row.drain(idx..idx + RESET.len()); + } + row + }) + .collect(); + let last_non_empty = rows.iter().rposition(|r| !r.is_empty()).map_or(0, |i| i + 1); + let mut out = Vec::new(); + for (i, row) in rows[..last_non_empty].iter().enumerate() { + if i > 0 { + out.push(b'\n'); + } + out.extend_from_slice(row); + } + out +} diff --git a/crates/pty_terminal_test/README.md b/crates/pty_terminal_test/README.md index ab97238c..ee970010 100644 --- a/crates/pty_terminal_test/README.md +++ b/crates/pty_terminal_test/README.md @@ -45,26 +45,25 @@ assert!(status.success()); ## Milestone protocol -Milestones are encoded as an OSC 8 hyperlink: +Milestones are encoded as unique window titles: -- open: `ESC ] 8 ; ; https://milestone.invalid/ ESC \` -- hypertext: zero-width space (`U+200B`) -- close: `ESC ] 8 ; ; ESC \` +```text +pty-terminal-test:<32-hex-random-id>: +``` `Reader::expect_milestone` works like this: -1. Drain parsed unhandled OSC sequences from `PtyReader`. -2. Decode OSC 8 URI payload back into milestone name. -3. If no match yet, continue reading from PTY and repeat. -4. On match, return current `screen_contents()`. - -The helper strips the protocol's zero-width space from returned screen text. +1. Drain title events captured by `PtyReader`. +2. Ignore ordinary titles and completed non-target milestones. +3. If no match exists, continue reading from the PTY and repeat. +4. Return the screen snapshot captured at the requested title boundary. ## Cross-platform behavior -The OSC 8 + zero-width anchor approach is used because it works across Unix and -Windows ConPTY in this project. In particular, zero-length hyperlink opens can -be lost on some Windows output paths, so the zero-width anchor is intentional. +On Windows the client calls `SetConsoleTitleW`; ConPTY emits the resulting title +through its asynchronous renderer after preceding text and cursor state. On Unix +the client emits an OSC 2 title update, which follows normal PTY byte ordering. +The same token decoder and test API are used on every platform. ## Typical test pattern diff --git a/crates/pty_terminal_test/src/lib.rs b/crates/pty_terminal_test/src/lib.rs index af724599..9748dff2 100644 --- a/crates/pty_terminal_test/src/lib.rs +++ b/crates/pty_terminal_test/src/lib.rs @@ -1,15 +1,16 @@ -use std::io::{BufReader, Read}; +use std::{ + collections::VecDeque, + io::{BufReader, Read}, +}; pub use portable_pty::CommandBuilder; -use pty_terminal::terminal::{PtyReader, Terminal}; +use pty_terminal::terminal::{PtyReader, Terminal, WindowTitleEvent}; pub use pty_terminal::{ ExitStatus, geo::ScreenSize, terminal::{ChildHandle, PtyWriter}, }; -const MILESTONE_HYPERTEXT: char = '\u{200b}'; - /// A test-oriented terminal that provides milestone-based synchronization. /// /// Wraps a PTY terminal, splitting it into a [`PtyWriter`] for sending input @@ -24,9 +25,15 @@ pub struct TestTerminal { /// The read half of a test terminal, wrapping [`PtyReader`] with milestone support. pub struct Reader { pty: BufReader, + milestones: VecDeque, child_handle: ChildHandle, } +struct CompletedMilestone { + marker: pty_terminal_test_client::DecodedMilestone, + event: WindowTitleEvent, +} + impl TestTerminal { /// Spawns a new child process in a test terminal. /// @@ -34,22 +41,29 @@ impl TestTerminal { /// /// Returns an error if the PTY cannot be opened or the command fails to spawn. pub fn spawn(size: ScreenSize, cmd: CommandBuilder) -> anyhow::Result { - let Terminal { pty_reader, pty_writer, child_handle, .. } = Terminal::spawn(size, cmd)?; + let Terminal { pty_reader, pty_writer, child_handle, .. } = + Terminal::spawn_capturing_window_titles( + size, + cmd, + pty_terminal_test_client::MILESTONE_TITLE_MARKER.as_bytes(), + )?; Ok(Self { writer: pty_writer, - reader: Reader { pty: BufReader::new(pty_reader), child_handle: child_handle.clone() }, + reader: Reader { + pty: BufReader::new(pty_reader), + milestones: VecDeque::new(), + child_handle: child_handle.clone(), + }, child_handle, }) } } impl Reader { - /// Returns terminal screen contents with milestone hyperlink text removed. + /// Returns the current terminal screen contents. #[must_use] pub fn screen_contents(&self) -> String { - let mut contents = self.pty.get_ref().screen_contents(); - contents.retain(|ch| ch != MILESTONE_HYPERTEXT); - contents + self.pty.get_ref().screen_contents() } /// Returns the screen contents with inline ANSI SGR escape codes preserved. @@ -63,11 +77,8 @@ impl Reader { /// /// Returns the terminal screen contents at the moment the milestone is detected. /// - /// Milestones use a uniform protocol across platforms: the milestone name - /// is encoded in an OSC 8 hyperlink URI. We parse unhandled OSC sequences - /// from the VT parser state (instead of raw byte matching), then decode the - /// milestone URI payload. The zero-width milestone hyperlink anchor is - /// stripped from returned screen contents. + /// Milestones use a uniform title token across platforms. The screen is + /// captured by the terminal parser at the exact title parse boundary. /// /// # Panics /// @@ -78,17 +89,11 @@ impl Reader { let mut buf = [0u8; 4096]; loop { - let found = self - .pty - .get_ref() - .take_unhandled_osc_sequences() - .into_iter() - .filter_map(|params| { - pty_terminal_test_client::decode_milestone_from_osc8_params(¶ms) - }) - .any(|decoded| decoded == name); - if found { - return self.screen_contents(); + self.collect_milestones(); + while let Some(milestone) = self.milestones.pop_front() { + if milestone.marker.name == name { + return milestone.event.screen_contents(); + } } let n = self.pty.read(&mut buf).expect("PTY read failed"); @@ -96,6 +101,14 @@ impl Reader { } } + fn collect_milestones(&mut self) { + for event in self.pty.get_ref().take_window_title_events() { + if let Some(marker) = pty_terminal_test_client::decode_milestone_title(event.title()) { + self.milestones.push_back(CompletedMilestone { marker, event }); + } + } + } + /// Reads all remaining PTY output until the child exits, then returns the exit status. /// /// # Errors diff --git a/crates/pty_terminal_test/tests/milestone.rs b/crates/pty_terminal_test/tests/milestone.rs index e878e069..18c6dd8c 100644 --- a/crates/pty_terminal_test/tests/milestone.rs +++ b/crates/pty_terminal_test/tests/milestone.rs @@ -125,3 +125,30 @@ fn milestone_does_not_pollute_screen() { let status = reader.wait_for_exit().unwrap(); assert!(status.success()); } + +#[test] +#[timeout(5000)] +#[expect(clippy::redundant_clone, reason = "command_for_fn evaluates its argument twice")] +fn descendant_process_can_mark_milestone() { + let nested = command_for_fn!((), |(): ()| { + pty_terminal_test_client::mark_milestone("nested"); + }); + let nested_program = nested.program.to_string_lossy().into_owned(); + let nested_args = + nested.args.iter().map(|arg| arg.to_string_lossy().into_owned()).collect::>(); + + let cmd = CommandBuilder::from(command_for_fn!( + (nested_program.clone(), nested_args.clone()), + |(nested_program, nested_args): (String, Vec)| { + pty_terminal_test_client::mark_milestone("root"); + let status = + std::process::Command::new(nested_program).args(nested_args).status().unwrap(); + assert!(status.success()); + } + )); + + let TestTerminal { writer: _, mut reader, child_handle: _ } = + TestTerminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + let _ = reader.expect_milestone("nested"); + assert!(reader.wait_for_exit().unwrap().success()); +} diff --git a/crates/pty_terminal_test_client/Cargo.toml b/crates/pty_terminal_test_client/Cargo.toml index 3ac4e8ff..9743cdf1 100644 --- a/crates/pty_terminal_test_client/Cargo.toml +++ b/crates/pty_terminal_test_client/Cargo.toml @@ -11,9 +11,15 @@ rust-version.workspace = true default = [] testing = [] +[dependencies] +base64 = { workspace = true } +getrandom = { workspace = true } + +[target.'cfg(windows)'.dependencies] +winapi = { workspace = true, features = ["wincon"] } + [lints] workspace = true [lib] -test = false doctest = false diff --git a/crates/pty_terminal_test_client/src/lib.rs b/crates/pty_terminal_test_client/src/lib.rs index 921f37ab..66e40cd6 100644 --- a/crates/pty_terminal_test_client/src/lib.rs +++ b/crates/pty_terminal_test_client/src/lib.rs @@ -1,86 +1,79 @@ -/// Prefix for hyperlink URI payload that carries milestone data. -const MILESTONE_URI_PREFIX: &str = "https://milestone.invalid/"; -/// Terminator for OSC sequences using ST (`ESC \`). -const OSC_ST: &str = "\x1b\\"; -/// Invisible hyperlink text anchor. -const MILESTONE_HYPERTEXT: &str = "\u{200b}"; -/// OSC 8 close sequence. -pub const MILESTONE_FENCE: &[u8] = b"\x1b]8;;\x1b\\"; - -/// Builds an OSC 8 marker with milestone name encoded in the hyperlink URI. -/// -/// Format: -/// `OSC 8 ; ; https://milestone.invalid/ ST OSC 8 ; ; ST`. -#[must_use] -pub fn encoded_milestone(name: &str) -> Vec { - use std::fmt::Write as _; +use std::io; - let mut hex = String::with_capacity(name.len() * 2); - for &byte in name.as_bytes() { - write!(&mut hex, "{byte:02x}").unwrap(); - } +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; - let mut seq = String::new(); - write!(&mut seq, "\x1b]8;;{MILESTONE_URI_PREFIX}{hex}{OSC_ST}").unwrap(); - seq.push_str(MILESTONE_HYPERTEXT); - write!(&mut seq, "\x1b]8;;{OSC_ST}").unwrap(); - seq.into_bytes() -} +/// Prefix distinguishing milestone titles from ordinary application titles. +pub const MILESTONE_TITLE_MARKER: &str = "pty-terminal-test:"; +const MAX_MILESTONE_NAME_BYTES: usize = 144; -const fn decode_hex_nibble(byte: u8) -> Option { - match byte { - b'0'..=b'9' => Some(byte - b'0'), - b'a'..=b'f' => Some(byte - b'a' + 10), - b'A'..=b'F' => Some(byte - b'A' + 10), - _ => None, - } +/// A decoded window-title milestone. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecodedMilestone { + /// Random occurrence identity. + pub id: u128, + /// Caller-provided milestone name. + pub name: String, } -/// Decodes a milestone name from OSC 8 parameters if present. +/// Builds a unique title token for a milestone occurrence. /// -/// Expects VT parser parameters for OSC 8 in the form: -/// - open: `["8", "", ""]` -/// - close: `["8", "", ""]` +/// # Errors /// -/// Returns `Some(name)` only when the URI uses the milestone prefix and the -/// suffix is valid hex-encoded UTF-8. -#[must_use] -pub fn decode_milestone_from_osc8_params(params: &[Vec]) -> Option { - if params.first().is_none_or(|p| p.as_slice() != b"8") { - return None; +/// Returns an error when the name is empty or too long, secure randomness is +/// unavailable, or the generated title exceeds the Windows title limit. +pub fn encode_milestone_title(name: &str) -> io::Result { + if name.is_empty() || name.len() > MAX_MILESTONE_NAME_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "milestone name must contain 1 to 144 UTF-8 bytes", + )); } - let uri = params.get(2)?.as_slice(); - let encoded = uri.strip_prefix(MILESTONE_URI_PREFIX.as_bytes())?; - if encoded.is_empty() || encoded.len() % 2 != 0 { - return None; + let mut random = [0u8; 16]; + getrandom::fill(&mut random).map_err(io::Error::other)?; + let id = u128::from_be_bytes(random); + let encoded_name = URL_SAFE_NO_PAD.encode(name.as_bytes()); + let title = format!("{MILESTONE_TITLE_MARKER}{id:032x}:{encoded_name}"); + if title.len() >= 255 { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "milestone title is too long")); } + Ok(title) +} - let mut bytes = Vec::with_capacity(encoded.len() / 2); - for pair in encoded.chunks_exact(2) { - let high = decode_hex_nibble(pair[0])?; - let low = decode_hex_nibble(pair[1])?; - bytes.push((high << 4) | low); +/// Decodes a milestone title, ignoring ordinary application title updates. +#[must_use] +pub fn decode_milestone_title(title: &[u8]) -> Option { + let encoded = title.strip_prefix(MILESTONE_TITLE_MARKER.as_bytes())?; + let (encoded_id, encoded_name) = encoded.split_at_checked(32)?; + let (&b':', encoded_name) = encoded_name.split_first()? else { + return None; + }; + if !encoded_id.iter().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) + || encoded_name.is_empty() + { + return None; } - String::from_utf8(bytes).ok() + let id = u128::from_str_radix(std::str::from_utf8(encoded_id).ok()?, 16).ok()?; + let name_bytes = URL_SAFE_NO_PAD.decode(encoded_name).ok()?; + if name_bytes.is_empty() + || name_bytes.len() > MAX_MILESTONE_NAME_BYTES + || URL_SAFE_NO_PAD.encode(&name_bytes).as_bytes() != encoded_name + { + return None; + } + Some(DecodedMilestone { id, name: String::from_utf8(name_bytes).ok()? }) } -/// Emits a milestone marker as OSC 8 hyperlink metadata. +/// Emits a milestone marker as a unique window-title update. /// /// The child process calls this to signal it has reached a named synchronization /// point. The test harness (via `pty_terminal_test::Reader::expect_milestone`) /// detects this marker and returns the screen contents at that point. /// -/// On Windows, `ConPTY` passes control sequences directly to the -/// output pipe (synchronous, inline with input processing), while rendered -/// character output is generated asynchronously by a separate output thread -/// that polls the console buffer. This means the marker can arrive at the -/// reader before preceding character output has been emitted. -/// -/// Milestones include a zero-width hyperlink anchor (`U+200B`) before closing. -/// This keeps the hyperlink metadata observable in `ConPTY` output paths that can -/// drop zero-length hyperlinks. +/// Windows uses `SetConsoleTitleW`, which `ConPTY` emits through its renderer after +/// preceding text and cursor state. Other platforms emit the equivalent OSC 2 +/// title update through the ordered PTY byte stream. /// /// When the `testing` feature is disabled, this is a no-op. /// @@ -89,15 +82,43 @@ pub fn decode_milestone_from_osc8_params(params: &[Vec]) -> Option { /// Panics if writing to stdout fails. #[cfg(feature = "testing")] pub fn mark_milestone(name: &str) { - use std::io::{Write, stdout}; + try_mark_milestone(name).expect("failed to emit milestone title"); +} - let milestone = encoded_milestone(name); - let mut stdout = stdout(); - // Flush prior output, then emit milestone sequence. - stdout.flush().unwrap(); - stdout.write_all(&milestone).unwrap(); +/// Tries to emit a milestone title. +/// +/// # Errors +/// +/// Returns an error if title encoding, output flushing, or the platform title +/// operation fails. +#[cfg(feature = "testing")] +pub fn try_mark_milestone(name: &str) -> io::Result<()> { + emit_title(&encode_milestone_title(name)?) +} + +#[cfg(all(feature = "testing", windows))] +fn emit_title(title: &str) -> io::Result<()> { + use std::io::Write as _; + + std::io::stdout().flush()?; + let mut wide = title.encode_utf16().collect::>(); + wide.push(0); + // SAFETY: `wide` is a valid NUL-terminated UTF-16 title. + if unsafe { winapi::um::wincon::SetConsoleTitleW(wide.as_ptr()) } == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(all(feature = "testing", not(windows)))] +fn emit_title(title: &str) -> io::Result<()> { + use std::io::Write as _; - stdout.flush().unwrap(); + let mut stdout = std::io::stdout().lock(); + stdout.flush()?; + write!(stdout, "\x1b]2;{title}\x1b\\")?; + stdout.flush() } /// Emits a milestone marker as a private OSC escape sequence. @@ -105,3 +126,35 @@ pub fn mark_milestone(name: &str) { /// When the `testing` feature is disabled, this is a no-op. #[cfg(not(feature = "testing"))] pub const fn mark_milestone(_name: &str) {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn title_round_trip() { + let title = encode_milestone_title("task-select:lib#:0").unwrap(); + let decoded = decode_milestone_title(title.as_bytes()).unwrap(); + assert_eq!(decoded.name, "task-select:lib#:0"); + } + + #[test] + fn repeated_names_get_unique_ids() { + let first = encode_milestone_title("ready").unwrap(); + let second = encode_milestone_title("ready").unwrap(); + assert_ne!( + decode_milestone_title(first.as_bytes()).unwrap().id, + decode_milestone_title(second.as_bytes()).unwrap().id + ); + } + + #[test] + fn ignores_normal_and_malformed_titles() { + assert!(decode_milestone_title(b"normal title").is_none()); + assert!(decode_milestone_title(b"pty-terminal-test:not-hex:cmVhZHk").is_none()); + assert!( + decode_milestone_title(b"pty-terminal-test:00000000000000000000000000000000:*") + .is_none() + ); + } +} diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 5a5fb160..b7768f68 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -535,13 +535,9 @@ impl<'a> Session<'a> { }; let select_result = vite_select::select_list(&mut stdout, ¶ms, mode, |state| { - use std::io::Write; let milestone_name = vite_str::format!("task-select:{}:{}", state.query, state.selected_index); - let milestone_bytes = pty_terminal_test_client::encoded_milestone(&milestone_name); - let mut out = std::io::stdout(); - let _ = out.write_all(&milestone_bytes); - let _ = out.flush(); + pty_terminal_test_client::mark_milestone(&milestone_name); })?; if matches!(select_result, vite_select::SelectResult::Cancelled) { diff --git a/crates/vite_task_bin/Cargo.toml b/crates/vite_task_bin/Cargo.toml index b8a8b8d8..a0d8b8ba 100644 --- a/crates/vite_task_bin/Cargo.toml +++ b/crates/vite_task_bin/Cargo.toml @@ -20,7 +20,7 @@ anyhow = { workspace = true } ctrlc = { workspace = true } libc = { workspace = true } notify = { workspace = true } -pty_terminal_test_client = { workspace = true, features = ["testing"] } +pty_terminal_test_client = { workspace = true } async-trait = { workspace = true } clap = { workspace = true, features = ["derive"] } jsonc-parser = { workspace = true } @@ -38,6 +38,7 @@ libtest-mimic = { workspace = true } snapshot_test = { workspace = true } pty_terminal = { workspace = true } pty_terminal_test = { workspace = true } +pty_terminal_test_client = { workspace = true, features = ["testing"] } regex = { workspace = true } serde = { workspace = true, features = ["derive", "rc"] } shell-escape = { workspace = true } From cdd78bcab7d64e8f05580398591e12be17532a18 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 9 Jul 2026 18:19:52 +0800 Subject: [PATCH 3/9] refactor(test): decouple terminal title capture Co-authored-by: GPT-5.6 --- crates/pty_terminal/src/terminal.rs | 18 ++++------- crates/pty_terminal_test/src/lib.rs | 6 +--- crates/pty_terminal_test_client/src/lib.rs | 37 ++++++++-------------- 3 files changed, 21 insertions(+), 40 deletions(-) diff --git a/crates/pty_terminal/src/terminal.rs b/crates/pty_terminal/src/terminal.rs index b4ee7378..7bc8117d 100644 --- a/crates/pty_terminal/src/terminal.rs +++ b/crates/pty_terminal/src/terminal.rs @@ -80,16 +80,13 @@ impl WindowTitleEvent { struct Vt100Callbacks { writer: Arc>>>, unhandled_osc_sequences: VecDeque>>, - window_title_prefix: Option>, + capture_window_titles: bool, window_title_events: VecDeque, } impl vt100::Callbacks for Vt100Callbacks { fn set_window_title(&mut self, screen: &mut vt100::Screen, title: &[u8]) { - let Some(prefix) = self.window_title_prefix.as_deref() else { - return; - }; - if !title.starts_with(prefix) { + if !self.capture_window_titles { return; } if self.window_title_events.len() == MAX_WINDOW_TITLE_EVENTS { @@ -331,10 +328,10 @@ impl Terminal { /// Returns an error if the PTY cannot be opened or the command fails to spawn. /// pub fn spawn(size: ScreenSize, cmd: CommandBuilder) -> anyhow::Result { - Self::spawn_inner(size, cmd, None) + Self::spawn_inner(size, cmd, false) } - /// Spawns a child and captures matching window-title updates with screen snapshots. + /// Spawns a child and captures window-title updates with screen snapshots. /// /// # Errors /// @@ -342,15 +339,14 @@ impl Terminal { pub fn spawn_capturing_window_titles( size: ScreenSize, cmd: CommandBuilder, - title_prefix: &[u8], ) -> anyhow::Result { - Self::spawn_inner(size, cmd, Some(title_prefix.to_vec())) + Self::spawn_inner(size, cmd, true) } fn spawn_inner( size: ScreenSize, cmd: CommandBuilder, - window_title_prefix: Option>, + capture_window_titles: bool, ) -> anyhow::Result { // On musl libc (Alpine Linux), concurrent PTY operations trigger // SIGSEGV/SIGBUS in musl internals (sysconf, fcntl). This affects @@ -421,7 +417,7 @@ impl Terminal { Vt100Callbacks { writer: Arc::clone(&writer), unhandled_osc_sequences: VecDeque::new(), - window_title_prefix, + capture_window_titles, window_title_events: VecDeque::new(), }, ))); diff --git a/crates/pty_terminal_test/src/lib.rs b/crates/pty_terminal_test/src/lib.rs index 9748dff2..22b12939 100644 --- a/crates/pty_terminal_test/src/lib.rs +++ b/crates/pty_terminal_test/src/lib.rs @@ -42,11 +42,7 @@ impl TestTerminal { /// Returns an error if the PTY cannot be opened or the command fails to spawn. pub fn spawn(size: ScreenSize, cmd: CommandBuilder) -> anyhow::Result { let Terminal { pty_reader, pty_writer, child_handle, .. } = - Terminal::spawn_capturing_window_titles( - size, - cmd, - pty_terminal_test_client::MILESTONE_TITLE_MARKER.as_bytes(), - )?; + Terminal::spawn_capturing_window_titles(size, cmd)?; Ok(Self { writer: pty_writer, reader: Reader { diff --git a/crates/pty_terminal_test_client/src/lib.rs b/crates/pty_terminal_test_client/src/lib.rs index 66e40cd6..81c6cccf 100644 --- a/crates/pty_terminal_test_client/src/lib.rs +++ b/crates/pty_terminal_test_client/src/lib.rs @@ -2,9 +2,7 @@ use std::io; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -/// Prefix distinguishing milestone titles from ordinary application titles. -pub const MILESTONE_TITLE_MARKER: &str = "pty-terminal-test:"; -const MAX_MILESTONE_NAME_BYTES: usize = 144; +const MILESTONE_TITLE_MARKER: &str = "pty-terminal-test:"; /// A decoded window-title milestone. #[derive(Debug, Clone, PartialEq, Eq)] @@ -19,25 +17,13 @@ pub struct DecodedMilestone { /// /// # Errors /// -/// Returns an error when the name is empty or too long, secure randomness is -/// unavailable, or the generated title exceeds the Windows title limit. +/// Returns an error when secure randomness is unavailable. pub fn encode_milestone_title(name: &str) -> io::Result { - if name.is_empty() || name.len() > MAX_MILESTONE_NAME_BYTES { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "milestone name must contain 1 to 144 UTF-8 bytes", - )); - } - let mut random = [0u8; 16]; getrandom::fill(&mut random).map_err(io::Error::other)?; let id = u128::from_be_bytes(random); let encoded_name = URL_SAFE_NO_PAD.encode(name.as_bytes()); - let title = format!("{MILESTONE_TITLE_MARKER}{id:032x}:{encoded_name}"); - if title.len() >= 255 { - return Err(io::Error::new(io::ErrorKind::InvalidInput, "milestone title is too long")); - } - Ok(title) + Ok(format!("{MILESTONE_TITLE_MARKER}{id:032x}:{encoded_name}")) } /// Decodes a milestone title, ignoring ordinary application title updates. @@ -48,18 +34,13 @@ pub fn decode_milestone_title(title: &[u8]) -> Option { let (&b':', encoded_name) = encoded_name.split_first()? else { return None; }; - if !encoded_id.iter().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) - || encoded_name.is_empty() - { + if !encoded_id.iter().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) { return None; } let id = u128::from_str_radix(std::str::from_utf8(encoded_id).ok()?, 16).ok()?; let name_bytes = URL_SAFE_NO_PAD.decode(encoded_name).ok()?; - if name_bytes.is_empty() - || name_bytes.len() > MAX_MILESTONE_NAME_BYTES - || URL_SAFE_NO_PAD.encode(&name_bytes).as_bytes() != encoded_name - { + if URL_SAFE_NO_PAD.encode(&name_bytes).as_bytes() != encoded_name { return None; } Some(DecodedMilestone { id, name: String::from_utf8(name_bytes).ok()? }) @@ -138,6 +119,14 @@ mod tests { assert_eq!(decoded.name, "task-select:lib#:0"); } + #[test] + fn arbitrary_names_round_trip() { + for name in [String::new(), "milestone".repeat(1_000)] { + let title = encode_milestone_title(&name).unwrap(); + assert_eq!(decode_milestone_title(title.as_bytes()).unwrap().name, name); + } + } + #[test] fn repeated_names_get_unique_ids() { let first = encode_milestone_title("ready").unwrap(); From a2497fe0fbf2c211b0bcb4c4aff373a5ce347214 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 9 Jul 2026 18:31:03 +0800 Subject: [PATCH 4/9] refactor(pty): capture title events by default Co-authored-by: GPT-5.6 --- crates/pty_terminal/src/terminal.rs | 79 +++++++++-------------------- crates/pty_terminal_test/src/lib.rs | 3 +- 2 files changed, 26 insertions(+), 56 deletions(-) diff --git a/crates/pty_terminal/src/terminal.rs b/crates/pty_terminal/src/terminal.rs index 7bc8117d..c54dce67 100644 --- a/crates/pty_terminal/src/terminal.rs +++ b/crates/pty_terminal/src/terminal.rs @@ -70,25 +70,16 @@ impl WindowTitleEvent { pub fn screen_contents(&self) -> String { self.screen.contents() } - - #[must_use] - pub fn screen_contents_formatted(&self) -> Vec { - format_screen(&self.screen) - } } struct Vt100Callbacks { writer: Arc>>>, unhandled_osc_sequences: VecDeque>>, - capture_window_titles: bool, window_title_events: VecDeque, } impl vt100::Callbacks for Vt100Callbacks { fn set_window_title(&mut self, screen: &mut vt100::Screen, title: &[u8]) { - if !self.capture_window_titles { - return; - } if self.window_title_events.len() == MAX_WINDOW_TITLE_EVENTS { self.window_title_events.pop_front(); } @@ -182,10 +173,34 @@ impl PtyReader { /// # Panics /// /// Panics if the parser lock is poisoned. + #[expect( + clippy::significant_drop_tightening, + reason = "vt100::Screen::rows_formatted yields borrowed iterators that need the guard alive" + )] #[must_use] pub fn screen_contents_formatted(&self) -> Vec { + const RESET: &[u8] = b"\x1b[m"; let guard = self.parser.lock().unwrap(); - format_screen(guard.screen()) + let screen = guard.screen(); + let cols = screen.size().1; + let rows: Vec> = screen + .rows_formatted(0, cols) + .map(|mut row| { + while let Some(idx) = row.windows(RESET.len()).position(|w| w == RESET) { + row.drain(idx..idx + RESET.len()); + } + row + }) + .collect(); + let last_non_empty = rows.iter().rposition(|r| !r.is_empty()).map_or(0, |i| i + 1); + let mut out = Vec::new(); + for (i, row) in rows[..last_non_empty].iter().enumerate() { + if i > 0 { + out.push(b'\n'); + } + out.extend_from_slice(row); + } + out } /// Drains and returns all unhandled OSC sequences received since the last call. @@ -328,26 +343,6 @@ impl Terminal { /// Returns an error if the PTY cannot be opened or the command fails to spawn. /// pub fn spawn(size: ScreenSize, cmd: CommandBuilder) -> anyhow::Result { - Self::spawn_inner(size, cmd, false) - } - - /// Spawns a child and captures window-title updates with screen snapshots. - /// - /// # Errors - /// - /// Returns an error if the PTY cannot be opened or the command fails to spawn. - pub fn spawn_capturing_window_titles( - size: ScreenSize, - cmd: CommandBuilder, - ) -> anyhow::Result { - Self::spawn_inner(size, cmd, true) - } - - fn spawn_inner( - size: ScreenSize, - cmd: CommandBuilder, - capture_window_titles: bool, - ) -> anyhow::Result { // On musl libc (Alpine Linux), concurrent PTY operations trigger // SIGSEGV/SIGBUS in musl internals (sysconf, fcntl). This affects // both openpty+fork and FD cleanup (close) from background threads. @@ -417,7 +412,6 @@ impl Terminal { Vt100Callbacks { writer: Arc::clone(&writer), unhandled_osc_sequences: VecDeque::new(), - capture_window_titles, window_title_events: VecDeque::new(), }, ))); @@ -429,26 +423,3 @@ impl Terminal { }) } } - -fn format_screen(screen: &vt100::Screen) -> Vec { - const RESET: &[u8] = b"\x1b[m"; - let cols = screen.size().1; - let rows: Vec> = screen - .rows_formatted(0, cols) - .map(|mut row| { - while let Some(idx) = row.windows(RESET.len()).position(|w| w == RESET) { - row.drain(idx..idx + RESET.len()); - } - row - }) - .collect(); - let last_non_empty = rows.iter().rposition(|r| !r.is_empty()).map_or(0, |i| i + 1); - let mut out = Vec::new(); - for (i, row) in rows[..last_non_empty].iter().enumerate() { - if i > 0 { - out.push(b'\n'); - } - out.extend_from_slice(row); - } - out -} diff --git a/crates/pty_terminal_test/src/lib.rs b/crates/pty_terminal_test/src/lib.rs index 22b12939..a902bfe4 100644 --- a/crates/pty_terminal_test/src/lib.rs +++ b/crates/pty_terminal_test/src/lib.rs @@ -41,8 +41,7 @@ impl TestTerminal { /// /// Returns an error if the PTY cannot be opened or the command fails to spawn. pub fn spawn(size: ScreenSize, cmd: CommandBuilder) -> anyhow::Result { - let Terminal { pty_reader, pty_writer, child_handle, .. } = - Terminal::spawn_capturing_window_titles(size, cmd)?; + let Terminal { pty_reader, pty_writer, child_handle, .. } = Terminal::spawn(size, cmd)?; Ok(Self { writer: pty_writer, reader: Reader { From 4136e4693820c57ac5003fcc0637977ec2bc5de0 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 9 Jul 2026 18:36:57 +0800 Subject: [PATCH 5/9] refactor(test): consume milestone titles directly Co-authored-by: GPT-5.6 --- crates/pty_terminal/src/terminal.rs | 60 ++++------------------------- crates/pty_terminal_test/README.md | 2 +- crates/pty_terminal_test/src/lib.rs | 44 +++++++-------------- 3 files changed, 21 insertions(+), 85 deletions(-) diff --git a/crates/pty_terminal/src/terminal.rs b/crates/pty_terminal/src/terminal.rs index c54dce67..03c7e3ab 100644 --- a/crates/pty_terminal/src/terminal.rs +++ b/crates/pty_terminal/src/terminal.rs @@ -11,7 +11,6 @@ use portable_pty::{ChildKiller, ExitStatus, MasterPty}; use crate::geo::ScreenSize; type ChildWaitResult = Result>; -const MAX_WINDOW_TITLE_EVENTS: usize = 256; /// The read half of a PTY connection. Implements [`Read`]. /// @@ -54,42 +53,14 @@ pub struct Terminal { pub child_handle: ChildHandle, } -/// Window-title update captured with the terminal screen at its parse boundary. -pub struct WindowTitleEvent { - title: Vec, - screen: vt100::Screen, -} - -impl WindowTitleEvent { - #[must_use] - pub fn title(&self) -> &[u8] { - &self.title - } - - #[must_use] - pub fn screen_contents(&self) -> String { - self.screen.contents() - } -} - struct Vt100Callbacks { writer: Arc>>>, - unhandled_osc_sequences: VecDeque>>, - window_title_events: VecDeque, + window_titles: VecDeque>, } impl vt100::Callbacks for Vt100Callbacks { - fn set_window_title(&mut self, screen: &mut vt100::Screen, title: &[u8]) { - if self.window_title_events.len() == MAX_WINDOW_TITLE_EVENTS { - self.window_title_events.pop_front(); - } - self.window_title_events - .push_back(WindowTitleEvent { title: title.to_vec(), screen: screen.clone() }); - } - - fn unhandled_osc(&mut self, _screen: &mut vt100::Screen, params: &[&[u8]]) { - let owned: Vec> = params.iter().map(|p| p.to_vec()).collect(); - self.unhandled_osc_sequences.push_back(owned); + fn set_window_title(&mut self, _screen: &mut vt100::Screen, title: &[u8]) { + self.window_titles.push_back(title.to_vec()); } fn unhandled_csi( @@ -203,27 +174,14 @@ impl PtyReader { out } - /// Drains and returns all unhandled OSC sequences received since the last call. - /// - /// Each entry is a list of byte-vector parameters from a single OSC sequence - /// (`ESC ] param1 ; param2 ; ... ST`). - /// - /// # Panics - /// - /// Panics if the parser lock is poisoned. - #[must_use] - pub fn take_unhandled_osc_sequences(&self) -> VecDeque>> { - std::mem::take(&mut self.parser.lock().unwrap().callbacks_mut().unhandled_osc_sequences) - } - - /// Drains title events captured while parsing PTY output. + /// Drains window titles received while parsing PTY output. /// /// # Panics /// /// Panics if the parser lock is poisoned. #[must_use] - pub fn take_window_title_events(&self) -> VecDeque { - std::mem::take(&mut self.parser.lock().unwrap().callbacks_mut().window_title_events) + pub fn take_window_titles(&self) -> VecDeque> { + std::mem::take(&mut self.parser.lock().unwrap().callbacks_mut().window_titles) } /// Returns the current cursor position as `(row, col)`, both 0-indexed. @@ -409,11 +367,7 @@ impl Terminal { size.rows, size.cols, 0, - Vt100Callbacks { - writer: Arc::clone(&writer), - unhandled_osc_sequences: VecDeque::new(), - window_title_events: VecDeque::new(), - }, + Vt100Callbacks { writer: Arc::clone(&writer), window_titles: VecDeque::new() }, ))); Ok(Self { diff --git a/crates/pty_terminal_test/README.md b/crates/pty_terminal_test/README.md index ee970010..27110c3b 100644 --- a/crates/pty_terminal_test/README.md +++ b/crates/pty_terminal_test/README.md @@ -56,7 +56,7 @@ pty-terminal-test:<32-hex-random-id>: 1. Drain title events captured by `PtyReader`. 2. Ignore ordinary titles and completed non-target milestones. 3. If no match exists, continue reading from the PTY and repeat. -4. Return the screen snapshot captured at the requested title boundary. +4. Return the current screen once the requested title is observed. ## Cross-platform behavior diff --git a/crates/pty_terminal_test/src/lib.rs b/crates/pty_terminal_test/src/lib.rs index a902bfe4..0fe095c3 100644 --- a/crates/pty_terminal_test/src/lib.rs +++ b/crates/pty_terminal_test/src/lib.rs @@ -1,10 +1,7 @@ -use std::{ - collections::VecDeque, - io::{BufReader, Read}, -}; +use std::io::{BufReader, Read}; pub use portable_pty::CommandBuilder; -use pty_terminal::terminal::{PtyReader, Terminal, WindowTitleEvent}; +use pty_terminal::terminal::{PtyReader, Terminal}; pub use pty_terminal::{ ExitStatus, geo::ScreenSize, @@ -25,15 +22,9 @@ pub struct TestTerminal { /// The read half of a test terminal, wrapping [`PtyReader`] with milestone support. pub struct Reader { pty: BufReader, - milestones: VecDeque, child_handle: ChildHandle, } -struct CompletedMilestone { - marker: pty_terminal_test_client::DecodedMilestone, - event: WindowTitleEvent, -} - impl TestTerminal { /// Spawns a new child process in a test terminal. /// @@ -44,11 +35,7 @@ impl TestTerminal { let Terminal { pty_reader, pty_writer, child_handle, .. } = Terminal::spawn(size, cmd)?; Ok(Self { writer: pty_writer, - reader: Reader { - pty: BufReader::new(pty_reader), - milestones: VecDeque::new(), - child_handle: child_handle.clone(), - }, + reader: Reader { pty: BufReader::new(pty_reader), child_handle: child_handle.clone() }, child_handle, }) } @@ -72,8 +59,7 @@ impl Reader { /// /// Returns the terminal screen contents at the moment the milestone is detected. /// - /// Milestones use a uniform title token across platforms. The screen is - /// captured by the terminal parser at the exact title parse boundary. + /// Milestones use a uniform title token across platforms. /// /// # Panics /// @@ -84,11 +70,15 @@ impl Reader { let mut buf = [0u8; 4096]; loop { - self.collect_milestones(); - while let Some(milestone) = self.milestones.pop_front() { - if milestone.marker.name == name { - return milestone.event.screen_contents(); - } + let found = self + .pty + .get_ref() + .take_window_titles() + .into_iter() + .filter_map(|title| pty_terminal_test_client::decode_milestone_title(&title)) + .any(|milestone| milestone.name == name); + if found { + return self.screen_contents(); } let n = self.pty.read(&mut buf).expect("PTY read failed"); @@ -96,14 +86,6 @@ impl Reader { } } - fn collect_milestones(&mut self) { - for event in self.pty.get_ref().take_window_title_events() { - if let Some(marker) = pty_terminal_test_client::decode_milestone_title(event.title()) { - self.milestones.push_back(CompletedMilestone { marker, event }); - } - } - } - /// Reads all remaining PTY output until the child exits, then returns the exit status. /// /// # Errors From f67250027bf6e3a8075e1df2c1383b298478a382 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 9 Jul 2026 18:51:44 +0800 Subject: [PATCH 6/9] refactor(test): minimize title milestone API Co-authored-by: GPT-5.6 --- crates/pty_terminal/src/terminal.rs | 6 +- crates/pty_terminal_test/src/lib.rs | 15 ++-- crates/pty_terminal_test/tests/milestone.rs | 2 +- crates/pty_terminal_test_client/Cargo.toml | 9 ++- crates/pty_terminal_test_client/src/lib.rs | 73 ++++--------------- .../vite_task_bin/tests/e2e_snapshots/main.rs | 2 +- 6 files changed, 33 insertions(+), 74 deletions(-) diff --git a/crates/pty_terminal/src/terminal.rs b/crates/pty_terminal/src/terminal.rs index 03c7e3ab..3382cfff 100644 --- a/crates/pty_terminal/src/terminal.rs +++ b/crates/pty_terminal/src/terminal.rs @@ -174,14 +174,14 @@ impl PtyReader { out } - /// Drains window titles received while parsing PTY output. + /// Takes the next window title received while parsing PTY output. /// /// # Panics /// /// Panics if the parser lock is poisoned. #[must_use] - pub fn take_window_titles(&self) -> VecDeque> { - std::mem::take(&mut self.parser.lock().unwrap().callbacks_mut().window_titles) + pub fn take_window_title(&self) -> Option> { + self.parser.lock().unwrap().callbacks_mut().window_titles.pop_front() } /// Returns the current cursor position as `(row, col)`, both 0-indexed. diff --git a/crates/pty_terminal_test/src/lib.rs b/crates/pty_terminal_test/src/lib.rs index 0fe095c3..c3b69d8c 100644 --- a/crates/pty_terminal_test/src/lib.rs +++ b/crates/pty_terminal_test/src/lib.rs @@ -70,15 +70,12 @@ impl Reader { let mut buf = [0u8; 4096]; loop { - let found = self - .pty - .get_ref() - .take_window_titles() - .into_iter() - .filter_map(|title| pty_terminal_test_client::decode_milestone_title(&title)) - .any(|milestone| milestone.name == name); - if found { - return self.screen_contents(); + while let Some(title) = self.pty.get_ref().take_window_title() { + if pty_terminal_test_client::decode_milestone_title(&title) + .is_some_and(|milestone| milestone == name) + { + return self.screen_contents(); + } } let n = self.pty.read(&mut buf).expect("PTY read failed"); diff --git a/crates/pty_terminal_test/tests/milestone.rs b/crates/pty_terminal_test/tests/milestone.rs index 18c6dd8c..0b7bba03 100644 --- a/crates/pty_terminal_test/tests/milestone.rs +++ b/crates/pty_terminal_test/tests/milestone.rs @@ -71,7 +71,7 @@ fn milestone_raw_mode_keystrokes() { assert!(status.success()); } -/// Verifies that the non-visual milestone fence in `mark_milestone` does not +/// Verifies that the non-visual milestone title in `mark_milestone` does not /// pollute `screen_contents()`. The subprocess appends characters without /// clearing the screen, so any leftover space would appear between them. #[test] diff --git a/crates/pty_terminal_test_client/Cargo.toml b/crates/pty_terminal_test_client/Cargo.toml index 9743cdf1..8af6077a 100644 --- a/crates/pty_terminal_test_client/Cargo.toml +++ b/crates/pty_terminal_test_client/Cargo.toml @@ -9,14 +9,17 @@ rust-version.workspace = true [features] default = [] -testing = [] +testing = ["dep:getrandom", "dep:winapi"] [dependencies] base64 = { workspace = true } -getrandom = { workspace = true } +getrandom = { workspace = true, optional = true } [target.'cfg(windows)'.dependencies] -winapi = { workspace = true, features = ["wincon"] } +winapi = { workspace = true, features = ["wincon"], optional = true } + +[dev-dependencies] +getrandom = { workspace = true } [lints] workspace = true diff --git a/crates/pty_terminal_test_client/src/lib.rs b/crates/pty_terminal_test_client/src/lib.rs index 81c6cccf..c6afd8f8 100644 --- a/crates/pty_terminal_test_client/src/lib.rs +++ b/crates/pty_terminal_test_client/src/lib.rs @@ -1,34 +1,19 @@ -use std::io; - use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; const MILESTONE_TITLE_MARKER: &str = "pty-terminal-test:"; -/// A decoded window-title milestone. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DecodedMilestone { - /// Random occurrence identity. - pub id: u128, - /// Caller-provided milestone name. - pub name: String, -} - -/// Builds a unique title token for a milestone occurrence. -/// -/// # Errors -/// -/// Returns an error when secure randomness is unavailable. -pub fn encode_milestone_title(name: &str) -> io::Result { +#[cfg(any(feature = "testing", test))] +fn encode_milestone_title(name: &str) -> String { let mut random = [0u8; 16]; - getrandom::fill(&mut random).map_err(io::Error::other)?; + getrandom::fill(&mut random).expect("failed to generate milestone identity"); let id = u128::from_be_bytes(random); let encoded_name = URL_SAFE_NO_PAD.encode(name.as_bytes()); - Ok(format!("{MILESTONE_TITLE_MARKER}{id:032x}:{encoded_name}")) + format!("{MILESTONE_TITLE_MARKER}{id:032x}:{encoded_name}") } /// Decodes a milestone title, ignoring ordinary application title updates. #[must_use] -pub fn decode_milestone_title(title: &[u8]) -> Option { +pub fn decode_milestone_title(title: &[u8]) -> Option { let encoded = title.strip_prefix(MILESTONE_TITLE_MARKER.as_bytes())?; let (encoded_id, encoded_name) = encoded.split_at_checked(32)?; let (&b':', encoded_name) = encoded_name.split_first()? else { @@ -38,12 +23,11 @@ pub fn decode_milestone_title(title: &[u8]) -> Option { return None; } - let id = u128::from_str_radix(std::str::from_utf8(encoded_id).ok()?, 16).ok()?; let name_bytes = URL_SAFE_NO_PAD.decode(encoded_name).ok()?; if URL_SAFE_NO_PAD.encode(&name_bytes).as_bytes() != encoded_name { return None; } - Some(DecodedMilestone { id, name: String::from_utf8(name_bytes).ok()? }) + String::from_utf8(name_bytes).ok() } /// Emits a milestone marker as a unique window-title update. @@ -60,25 +44,14 @@ pub fn decode_milestone_title(title: &[u8]) -> Option { /// /// # Panics /// -/// Panics if writing to stdout fails. +/// Panics if secure randomness is unavailable or emitting the title fails. #[cfg(feature = "testing")] pub fn mark_milestone(name: &str) { - try_mark_milestone(name).expect("failed to emit milestone title"); -} - -/// Tries to emit a milestone title. -/// -/// # Errors -/// -/// Returns an error if title encoding, output flushing, or the platform title -/// operation fails. -#[cfg(feature = "testing")] -pub fn try_mark_milestone(name: &str) -> io::Result<()> { - emit_title(&encode_milestone_title(name)?) + emit_title(&encode_milestone_title(name)).expect("failed to emit milestone title"); } #[cfg(all(feature = "testing", windows))] -fn emit_title(title: &str) -> io::Result<()> { +fn emit_title(title: &str) -> std::io::Result<()> { use std::io::Write as _; std::io::stdout().flush()?; @@ -86,14 +59,14 @@ fn emit_title(title: &str) -> io::Result<()> { wide.push(0); // SAFETY: `wide` is a valid NUL-terminated UTF-16 title. if unsafe { winapi::um::wincon::SetConsoleTitleW(wide.as_ptr()) } == 0 { - Err(io::Error::last_os_error()) + Err(std::io::Error::last_os_error()) } else { Ok(()) } } #[cfg(all(feature = "testing", not(windows)))] -fn emit_title(title: &str) -> io::Result<()> { +fn emit_title(title: &str) -> std::io::Result<()> { use std::io::Write as _; let mut stdout = std::io::stdout().lock(); @@ -102,7 +75,7 @@ fn emit_title(title: &str) -> io::Result<()> { stdout.flush() } -/// Emits a milestone marker as a private OSC escape sequence. +/// Does nothing when milestone instrumentation is disabled. /// /// When the `testing` feature is disabled, this is a no-op. #[cfg(not(feature = "testing"))] @@ -114,27 +87,13 @@ mod tests { #[test] fn title_round_trip() { - let title = encode_milestone_title("task-select:lib#:0").unwrap(); - let decoded = decode_milestone_title(title.as_bytes()).unwrap(); - assert_eq!(decoded.name, "task-select:lib#:0"); + let title = encode_milestone_title("task-select:lib#:0"); + assert_eq!(decode_milestone_title(title.as_bytes()).as_deref(), Some("task-select:lib#:0")); } #[test] - fn arbitrary_names_round_trip() { - for name in [String::new(), "milestone".repeat(1_000)] { - let title = encode_milestone_title(&name).unwrap(); - assert_eq!(decode_milestone_title(title.as_bytes()).unwrap().name, name); - } - } - - #[test] - fn repeated_names_get_unique_ids() { - let first = encode_milestone_title("ready").unwrap(); - let second = encode_milestone_title("ready").unwrap(); - assert_ne!( - decode_milestone_title(first.as_bytes()).unwrap().id, - decode_milestone_title(second.as_bytes()).unwrap().id - ); + fn repeated_names_get_unique_titles() { + assert_ne!(encode_milestone_title("ready"), encode_milestone_title("ready")); } #[test] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 0fc54c43..ce5f8117 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -438,7 +438,7 @@ fn run_case( cmd.env("PATH", &e2e_env_path); // Use `xterm-256color` and report color support so the runner does // NOT wrap its output writers with [`anstream::StripStream`]. The - // strip layer would otherwise eat OSC8 milestone sequences that + // strip layer would otherwise eat OSC title sequences that // the test harness relies on to synchronise with the child. ANSI // escapes emitted by the runner still get flattened to plain text // by vt100's `Screen::contents()` when the snapshot is rendered, From 0344d86c57afc6a4bd8a8a6237adfec749869e20 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 9 Jul 2026 19:03:30 +0800 Subject: [PATCH 7/9] ci: stress Windows tests Co-authored-by: GPT-5.6 --- .github/workflows/ci.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04dc31bf..a9c5c675 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -263,14 +263,24 @@ jobs: # job never depends on the runner's Rust toolchain. - name: Run tests if: matrix.mode == 'default' - run: cargo-nextest nextest run --archive-file windows-tests.tar.zst --workspace-remap . --partition "$PARTITION" + run: | + for iteration in {1..50}; do + echo "::group::Iteration $iteration/50" + cargo-nextest nextest run --archive-file windows-tests.tar.zst --workspace-remap . --partition "$PARTITION" + echo "::endgroup::" + done - uses: oxc-project/setup-node@4c588e9266bd930b6ddc34307df0659ed511d187 # v1.3.1 if: matrix.mode == 'ignored' - name: Run ignored tests if: matrix.mode == 'ignored' - run: cargo-nextest nextest run --archive-file windows-tests.tar.zst --workspace-remap . --run-ignored ignored-only + run: | + for iteration in {1..50}; do + echo "::group::Iteration $iteration/50" + cargo-nextest nextest run --archive-file windows-tests.tar.zst --workspace-remap . --run-ignored ignored-only + echo "::endgroup::" + done test-musl: needs: detect-changes From 83baae323381cb054b81565c8ad6de38a036e66e Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 9 Jul 2026 19:08:50 +0800 Subject: [PATCH 8/9] ci: compare Windows runner stability Co-authored-by: GPT-5.6 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9c5c675..e92636f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -237,7 +237,7 @@ jobs: - shard: windows-ignored partition: '' mode: ignored - runs-on: namespace-profile-windows-4c-8g + runs-on: windows-latest env: PARTITION: ${{ matrix.partition }} steps: From 152c8b2b4022b3d0cb16ed4ac715b08f39255556 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 9 Jul 2026 20:15:58 +0800 Subject: [PATCH 9/9] ci: restore Windows workflow after stress test Co-authored-by: GPT-5.6 --- .github/workflows/ci.yml | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e92636f7..04dc31bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -237,7 +237,7 @@ jobs: - shard: windows-ignored partition: '' mode: ignored - runs-on: windows-latest + runs-on: namespace-profile-windows-4c-8g env: PARTITION: ${{ matrix.partition }} steps: @@ -263,24 +263,14 @@ jobs: # job never depends on the runner's Rust toolchain. - name: Run tests if: matrix.mode == 'default' - run: | - for iteration in {1..50}; do - echo "::group::Iteration $iteration/50" - cargo-nextest nextest run --archive-file windows-tests.tar.zst --workspace-remap . --partition "$PARTITION" - echo "::endgroup::" - done + run: cargo-nextest nextest run --archive-file windows-tests.tar.zst --workspace-remap . --partition "$PARTITION" - uses: oxc-project/setup-node@4c588e9266bd930b6ddc34307df0659ed511d187 # v1.3.1 if: matrix.mode == 'ignored' - name: Run ignored tests if: matrix.mode == 'ignored' - run: | - for iteration in {1..50}; do - echo "::group::Iteration $iteration/50" - cargo-nextest nextest run --archive-file windows-tests.tar.zst --workspace-remap . --run-ignored ignored-only - echo "::endgroup::" - done + run: cargo-nextest nextest run --archive-file windows-tests.tar.zst --workspace-remap . --run-ignored ignored-only test-musl: needs: detect-changes