Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 7 additions & 14 deletions crates/pty_terminal/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,12 @@ pub struct Terminal {

struct Vt100Callbacks {
writer: Arc<Mutex<Option<Box<dyn Write + Send>>>>,
unhandled_osc_sequences: VecDeque<Vec<Vec<u8>>>,
window_titles: VecDeque<Vec<u8>>,
}

impl vt100::Callbacks for Vt100Callbacks {
fn unhandled_osc(&mut self, _screen: &mut vt100::Screen, params: &[&[u8]]) {
let owned: Vec<Vec<u8>> = 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(
Expand Down Expand Up @@ -175,17 +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`).
/// Takes the next window title received while parsing PTY output.
///
/// # Panics
///
/// Panics if the parser lock is poisoned.
#[must_use]
pub fn take_unhandled_osc_sequences(&self) -> VecDeque<Vec<Vec<u8>>> {
std::mem::take(&mut self.parser.lock().unwrap().callbacks_mut().unhandled_osc_sequences)
pub fn take_window_title(&self) -> Option<Vec<u8>> {
self.parser.lock().unwrap().callbacks_mut().window_titles.pop_front()
}

/// Returns the current cursor position as `(row, col)`, both 0-indexed.
Expand Down Expand Up @@ -371,10 +367,7 @@ impl Terminal {
size.rows,
size.cols,
0,
Vt100Callbacks {
writer: Arc::clone(&writer),
unhandled_osc_sequences: VecDeque::new(),
},
Vt100Callbacks { writer: Arc::clone(&writer), window_titles: VecDeque::new() },
)));

Ok(Self {
Expand Down
25 changes: 12 additions & 13 deletions crates/pty_terminal_test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<hex(name)> ESC \`
- hypertext: zero-width space (`U+200B`)
- close: `ESC ] 8 ; ; ESC \`
```text
pty-terminal-test:<32-hex-random-id>:<name-base64url>
```

`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 current screen once the requested title is observed.

## 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

Expand Down
31 changes: 9 additions & 22 deletions crates/pty_terminal_test/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ pub use pty_terminal::{
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
Expand Down Expand Up @@ -44,12 +42,10 @@ impl TestTerminal {
}

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.
Expand All @@ -63,11 +59,7 @@ 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.
///
/// # Panics
///
Expand All @@ -78,17 +70,12 @@ 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(&params)
})
.any(|decoded| decoded == 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");
Expand Down
29 changes: 28 additions & 1 deletion crates/pty_terminal_test/tests/milestone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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::<Vec<_>>();

let cmd = CommandBuilder::from(command_for_fn!(
(nested_program.clone(), nested_args.clone()),
|(nested_program, nested_args): (String, Vec<String>)| {
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());
}
13 changes: 11 additions & 2 deletions crates/pty_terminal_test_client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,20 @@ rust-version.workspace = true

[features]
default = []
testing = []
testing = ["dep:getrandom", "dep:winapi"]

[dependencies]
base64 = { workspace = true }
getrandom = { workspace = true, optional = true }

[target.'cfg(windows)'.dependencies]
winapi = { workspace = true, features = ["wincon"], optional = true }

[dev-dependencies]
getrandom = { workspace = true }

[lints]
workspace = true

[lib]
test = false
doctest = false
Loading
Loading