Skip to content

Commit 58eeaa5

Browse files
wan9chicodex
andauthored
fix(test): support Namespace Windows runner (#516)
## What changed - Switch the Windows test runner from `windows-latest` to `namespace-profile-windows-4c-8g`. - Replace OSC hyperlink milestones with generic, random window-title milestones. - Queue raw title updates in the terminal and decode milestone titles in the test harness. - Keep milestone instrumentation disabled in normal builds and support descendant milestone producers. ## Motivation Namespace uses Windows Server 2022 build 20348, whose ConPTY renderer can forward control sequences before earlier screen updates and coalesce intermediate frames. Window-title state is emitted by the renderer after preceding text and cursor state without modifying screen cells. Unix PTYs use the same title token through ordered OSC 2 output, preserving one cross-platform milestone API with latest-observable semantics. --------- Co-authored-by: GPT-5 Codex <codex@openai.com>
1 parent 7bd4e61 commit 58eeaa5

12 files changed

Lines changed: 154 additions & 135 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ jobs:
237237
- shard: windows-ignored
238238
partition: ''
239239
mode: ignored
240-
runs-on: windows-latest
240+
runs-on: namespace-profile-windows-4c-8g
241241
env:
242242
PARTITION: ${{ matrix.partition }}
243243
steps:

Cargo.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ fspy_shared_unix = { path = "crates/fspy_shared_unix" }
8181
futures = "0.3.31"
8282
futures-util = "0.3.31"
8383
globset = "0.4.18"
84+
getrandom = "0.4.2"
8485
jsonc-parser = { version = "0.32.0", features = ["serde"] }
8586
libc = "0.2.185"
8687
libtest-mimic = "0.8.2"

crates/pty_terminal/src/terminal.rs

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,12 @@ pub struct Terminal {
5555

5656
struct Vt100Callbacks {
5757
writer: Arc<Mutex<Option<Box<dyn Write + Send>>>>,
58-
unhandled_osc_sequences: VecDeque<Vec<Vec<u8>>>,
58+
window_titles: VecDeque<Vec<u8>>,
5959
}
6060

6161
impl vt100::Callbacks for Vt100Callbacks {
62-
fn unhandled_osc(&mut self, _screen: &mut vt100::Screen, params: &[&[u8]]) {
63-
let owned: Vec<Vec<u8>> = params.iter().map(|p| p.to_vec()).collect();
64-
self.unhandled_osc_sequences.push_back(owned);
62+
fn set_window_title(&mut self, _screen: &mut vt100::Screen, title: &[u8]) {
63+
self.window_titles.push_back(title.to_vec());
6564
}
6665

6766
fn unhandled_csi(
@@ -175,17 +174,14 @@ impl PtyReader {
175174
out
176175
}
177176

178-
/// Drains and returns all unhandled OSC sequences received since the last call.
179-
///
180-
/// Each entry is a list of byte-vector parameters from a single OSC sequence
181-
/// (`ESC ] param1 ; param2 ; ... ST`).
177+
/// Takes the next window title received while parsing PTY output.
182178
///
183179
/// # Panics
184180
///
185181
/// Panics if the parser lock is poisoned.
186182
#[must_use]
187-
pub fn take_unhandled_osc_sequences(&self) -> VecDeque<Vec<Vec<u8>>> {
188-
std::mem::take(&mut self.parser.lock().unwrap().callbacks_mut().unhandled_osc_sequences)
183+
pub fn take_window_title(&self) -> Option<Vec<u8>> {
184+
self.parser.lock().unwrap().callbacks_mut().window_titles.pop_front()
189185
}
190186

191187
/// Returns the current cursor position as `(row, col)`, both 0-indexed.
@@ -371,10 +367,7 @@ impl Terminal {
371367
size.rows,
372368
size.cols,
373369
0,
374-
Vt100Callbacks {
375-
writer: Arc::clone(&writer),
376-
unhandled_osc_sequences: VecDeque::new(),
377-
},
370+
Vt100Callbacks { writer: Arc::clone(&writer), window_titles: VecDeque::new() },
378371
)));
379372

380373
Ok(Self {

crates/pty_terminal_test/README.md

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,26 +45,25 @@ assert!(status.success());
4545

4646
## Milestone protocol
4747

48-
Milestones are encoded as an OSC 8 hyperlink:
48+
Milestones are encoded as unique window titles:
4949

50-
- open: `ESC ] 8 ; ; https://milestone.invalid/<hex(name)> ESC \`
51-
- hypertext: zero-width space (`U+200B`)
52-
- close: `ESC ] 8 ; ; ESC \`
50+
```text
51+
pty-terminal-test:<32-hex-random-id>:<name-base64url>
52+
```
5353

5454
`Reader::expect_milestone` works like this:
5555

56-
1. Drain parsed unhandled OSC sequences from `PtyReader`.
57-
2. Decode OSC 8 URI payload back into milestone name.
58-
3. If no match yet, continue reading from PTY and repeat.
59-
4. On match, return current `screen_contents()`.
60-
61-
The helper strips the protocol's zero-width space from returned screen text.
56+
1. Drain title events captured by `PtyReader`.
57+
2. Ignore ordinary titles and completed non-target milestones.
58+
3. If no match exists, continue reading from the PTY and repeat.
59+
4. Return the current screen once the requested title is observed.
6260

6361
## Cross-platform behavior
6462

65-
The OSC 8 + zero-width anchor approach is used because it works across Unix and
66-
Windows ConPTY in this project. In particular, zero-length hyperlink opens can
67-
be lost on some Windows output paths, so the zero-width anchor is intentional.
63+
On Windows the client calls `SetConsoleTitleW`; ConPTY emits the resulting title
64+
through its asynchronous renderer after preceding text and cursor state. On Unix
65+
the client emits an OSC 2 title update, which follows normal PTY byte ordering.
66+
The same token decoder and test API are used on every platform.
6867

6968
## Typical test pattern
7069

crates/pty_terminal_test/src/lib.rs

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ pub use pty_terminal::{
88
terminal::{ChildHandle, PtyWriter},
99
};
1010

11-
const MILESTONE_HYPERTEXT: char = '\u{200b}';
12-
1311
/// A test-oriented terminal that provides milestone-based synchronization.
1412
///
1513
/// Wraps a PTY terminal, splitting it into a [`PtyWriter`] for sending input
@@ -44,12 +42,10 @@ impl TestTerminal {
4442
}
4543

4644
impl Reader {
47-
/// Returns terminal screen contents with milestone hyperlink text removed.
45+
/// Returns the current terminal screen contents.
4846
#[must_use]
4947
pub fn screen_contents(&self) -> String {
50-
let mut contents = self.pty.get_ref().screen_contents();
51-
contents.retain(|ch| ch != MILESTONE_HYPERTEXT);
52-
contents
48+
self.pty.get_ref().screen_contents()
5349
}
5450

5551
/// Returns the screen contents with inline ANSI SGR escape codes preserved.
@@ -63,11 +59,7 @@ impl Reader {
6359
///
6460
/// Returns the terminal screen contents at the moment the milestone is detected.
6561
///
66-
/// Milestones use a uniform protocol across platforms: the milestone name
67-
/// is encoded in an OSC 8 hyperlink URI. We parse unhandled OSC sequences
68-
/// from the VT parser state (instead of raw byte matching), then decode the
69-
/// milestone URI payload. The zero-width milestone hyperlink anchor is
70-
/// stripped from returned screen contents.
62+
/// Milestones use a uniform title token across platforms.
7163
///
7264
/// # Panics
7365
///
@@ -78,17 +70,12 @@ impl Reader {
7870
let mut buf = [0u8; 4096];
7971

8072
loop {
81-
let found = self
82-
.pty
83-
.get_ref()
84-
.take_unhandled_osc_sequences()
85-
.into_iter()
86-
.filter_map(|params| {
87-
pty_terminal_test_client::decode_milestone_from_osc8_params(&params)
88-
})
89-
.any(|decoded| decoded == name);
90-
if found {
91-
return self.screen_contents();
73+
while let Some(title) = self.pty.get_ref().take_window_title() {
74+
if pty_terminal_test_client::decode_milestone_title(&title)
75+
.is_some_and(|milestone| milestone == name)
76+
{
77+
return self.screen_contents();
78+
}
9279
}
9380

9481
let n = self.pty.read(&mut buf).expect("PTY read failed");

crates/pty_terminal_test/tests/milestone.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ fn milestone_raw_mode_keystrokes() {
7171
assert!(status.success());
7272
}
7373

74-
/// Verifies that the non-visual milestone fence in `mark_milestone` does not
74+
/// Verifies that the non-visual milestone title in `mark_milestone` does not
7575
/// pollute `screen_contents()`. The subprocess appends characters without
7676
/// clearing the screen, so any leftover space would appear between them.
7777
#[test]
@@ -125,3 +125,30 @@ fn milestone_does_not_pollute_screen() {
125125
let status = reader.wait_for_exit().unwrap();
126126
assert!(status.success());
127127
}
128+
129+
#[test]
130+
#[timeout(5000)]
131+
#[expect(clippy::redundant_clone, reason = "command_for_fn evaluates its argument twice")]
132+
fn descendant_process_can_mark_milestone() {
133+
let nested = command_for_fn!((), |(): ()| {
134+
pty_terminal_test_client::mark_milestone("nested");
135+
});
136+
let nested_program = nested.program.to_string_lossy().into_owned();
137+
let nested_args =
138+
nested.args.iter().map(|arg| arg.to_string_lossy().into_owned()).collect::<Vec<_>>();
139+
140+
let cmd = CommandBuilder::from(command_for_fn!(
141+
(nested_program.clone(), nested_args.clone()),
142+
|(nested_program, nested_args): (String, Vec<String>)| {
143+
pty_terminal_test_client::mark_milestone("root");
144+
let status =
145+
std::process::Command::new(nested_program).args(nested_args).status().unwrap();
146+
assert!(status.success());
147+
}
148+
));
149+
150+
let TestTerminal { writer: _, mut reader, child_handle: _ } =
151+
TestTerminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap();
152+
let _ = reader.expect_milestone("nested");
153+
assert!(reader.wait_for_exit().unwrap().success());
154+
}

crates/pty_terminal_test_client/Cargo.toml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,20 @@ rust-version.workspace = true
99

1010
[features]
1111
default = []
12-
testing = []
12+
testing = ["dep:getrandom", "dep:winapi"]
13+
14+
[dependencies]
15+
base64 = { workspace = true }
16+
getrandom = { workspace = true, optional = true }
17+
18+
[target.'cfg(windows)'.dependencies]
19+
winapi = { workspace = true, features = ["wincon"], optional = true }
20+
21+
[dev-dependencies]
22+
getrandom = { workspace = true }
1323

1424
[lints]
1525
workspace = true
1626

1727
[lib]
18-
test = false
1928
doctest = false

0 commit comments

Comments
 (0)