Skip to content

Commit 9bad38f

Browse files
wan9chicodex
andcommitted
fix(test): identify rendered milestone fences
Co-authored-by: GPT-5.6 <codex@openai.com>
1 parent 7c36ce5 commit 9bad38f

5 files changed

Lines changed: 267 additions & 170 deletions

File tree

crates/pty_terminal_test/README.md

Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -47,24 +47,23 @@ assert!(status.success());
4747

4848
Milestones are encoded as an OSC 8 hyperlink:
4949

50-
- open: `ESC ] 8 ; ; https://milestone.invalid/<hex(name)> ESC \`
51-
- hypertext: zero-width space (`U+200B`)
50+
- open: `ESC ] 8 ; ; https://milestone.invalid/<id>/<hex(name)> ESC \`
51+
- hypertext: the same ID encoded with zero-width rendered characters
5252
- close: `ESC ] 8 ; ; ESC \`
5353

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

5656
1. Decode OSC 8 URI payloads from the PTY stream back into milestone names.
57-
2. Pair each marker with its following zero-width rendered anchor.
57+
2. Pair each marker with the rendered fence carrying the same ID.
5858
3. Wait until both the requested marker and its anchor have arrived.
5959
4. Return the current `screen_contents()`.
6060

61-
The helper strips the protocol's zero-width space from returned screen text.
61+
The helper strips the protocol's zero-width characters from returned screen text.
6262

63-
Only one marker may await its rendered anchor at a time. ConPTY can coalesce
64-
intermediate screen frames, so anonymous anchors cannot be paired reliably when
65-
several renders are outstanding. Before sending input that can trigger another
66-
render, consume the current milestone with `expect_milestone`, or use
67-
`wait_for_next_milestone` for a silent flow-control wait.
63+
ConPTY can coalesce intermediate screen frames while forwarding their OSC
64+
markers. Self-identifying fences let `expect_milestone` consume and ignore
65+
completed non-target milestones until the requested state arrives, without
66+
mistaking a surviving older fence for a newer marker.
6867

6968
## Cross-platform behavior
7069

@@ -92,19 +91,3 @@ writer.write_all(b"input")?;
9291
writer.flush()?;
9392
let screen = reader.expect_milestone("after-input");
9493
```
95-
96-
When a single test interaction sends several logical input events, synchronize
97-
each event before sending the next one:
98-
99-
```rust
100-
let mut encoded = [0; 4];
101-
for character in "input".chars() {
102-
writer.write_all(character.encode_utf8(&mut encoded).as_bytes())?;
103-
writer.flush()?;
104-
reader.wait_for_next_milestone();
105-
}
106-
let screen = reader.expect_milestone("after-input");
107-
```
108-
109-
`wait_for_next_milestone` leaves the completed marker available, so the final
110-
named expectation still validates and snapshots the requested state.

crates/pty_terminal_test/src/lib.rs

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

11-
const MILESTONE_HYPERTEXT: char = '\u{200b}';
12-
1311
/// Tracks the two independently delivered parts of each milestone.
1412
///
15-
/// A milestone starts with an OSC 8 hyperlink carrying its name and contains a
16-
/// zero-width printable character. On `ConPTY`, the OSC control sequence can be
17-
/// forwarded before earlier screen updates, while the printable character
18-
/// follows those updates through the asynchronous rendering path. A milestone
19-
/// is therefore complete only after both parts have arrived.
13+
/// A milestone starts with an OSC 8 hyperlink carrying its name and identifier,
14+
/// followed by the same identifier encoded with zero-width rendered characters.
15+
/// On `ConPTY`, the OSC control sequence can be forwarded before earlier screen
16+
/// updates, while the fence follows those updates through the asynchronous
17+
/// rendering path. A milestone is complete only after both matching parts arrive.
2018
///
21-
/// `ConPTY` may coalesce intermediate rendered frames, so the protocol permits
22-
/// only one marker to await its fence at a time. The test harness must consume
23-
/// that fence before sending input that can trigger another render.
19+
/// Self-identifying fences allow `ConPTY` to coalesce intermediate frames without
20+
/// making a later fence complete the wrong marker.
2421
#[derive(Default)]
2522
struct MilestoneTracker {
26-
/// Marker whose rendered zero-width anchor has not arrived yet.
27-
awaiting_fence: Option<String>,
23+
pending: VecDeque<pty_terminal_test_client::DecodedMilestone>,
2824
/// Marker names whose matching rendered anchors have arrived.
2925
completed: VecDeque<String>,
30-
/// Monotonic completion count used for silent flow-control waits.
31-
completion_count: usize,
26+
fence_decoder: RenderFenceDecoder,
3227
}
3328

3429
impl MilestoneTracker {
35-
fn take_completed(&mut self, name: &str) -> bool {
36-
// Keep unrelated completed milestones available for later calls. A PTY
37-
// read can contain more than the milestone currently being requested.
38-
self.completed
39-
.iter()
40-
.position(|completed| completed == name)
41-
.and_then(|index| self.completed.remove(index))
42-
.is_some()
30+
fn pop_completed(&mut self) -> Option<String> {
31+
self.completed.pop_front()
4332
}
4433
}
4534

4635
impl vte::Perform for MilestoneTracker {
4736
fn print(&mut self, character: char) {
48-
// `print` is called only for rendered characters, not for bytes inside
49-
// OSC metadata. With stop-and-wait flow control, the anchor can only
50-
// belong to the single marker awaiting a fence.
51-
if character == MILESTONE_HYPERTEXT
52-
&& let Some(name) = self.awaiting_fence.take()
37+
if let Some(id) = self.fence_decoder.advance(character)
38+
&& let Some(index) = self.pending.iter().position(|marker| marker.id == id)
5339
{
54-
self.completed.push_back(name);
55-
self.completion_count += 1;
40+
// A later rendered fence proves that any older pending frames were
41+
// coalesced. Drop them together with the matching marker.
42+
let marker = self.pending.drain(..=index).next_back().unwrap();
43+
self.completed.push_back(marker.name);
5644
}
5745
}
5846

5947
fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) {
6048
// The decoder accepts only milestone hyperlink opens. Ordinary OSC
6149
// sequences and the empty OSC 8 close sequence are ignored.
62-
if let Some(name) = pty_terminal_test_client::decode_milestone_from_osc8_params(params) {
63-
assert!(
64-
self.awaiting_fence.is_none(),
65-
"milestone protocol violation: marker '{name}' arrived before the rendered fence for '{}'",
66-
self.awaiting_fence.as_deref().unwrap()
67-
);
68-
self.awaiting_fence = Some(name);
50+
if let Some(marker) = pty_terminal_test_client::decode_milestone_from_osc8_params(params) {
51+
if let Some(index) = self.pending.iter().position(|pending| pending.id == marker.id) {
52+
self.pending.remove(index);
53+
}
54+
self.pending.push_back(marker);
6955
}
7056
}
7157
}
7258

59+
#[derive(Default)]
60+
struct RenderFenceDecoder {
61+
active: bool,
62+
id: u32,
63+
digits: u8,
64+
}
65+
66+
impl RenderFenceDecoder {
67+
fn advance(&mut self, character: char) -> Option<u32> {
68+
if pty_terminal_test_client::is_milestone_render_boundary(character) {
69+
if self.active && self.digits == pty_terminal_test_client::MILESTONE_RENDER_DIGITS {
70+
let id = self.id;
71+
// The closing boundary can also be the opening boundary of a
72+
// following fence if the previous close was coalesced away.
73+
self.id = 0;
74+
self.digits = 0;
75+
return Some(id);
76+
}
77+
self.active = true;
78+
self.id = 0;
79+
self.digits = 0;
80+
return None;
81+
}
82+
83+
let Some(digit) = pty_terminal_test_client::decode_milestone_render_digit(character) else {
84+
self.active = false;
85+
return None;
86+
};
87+
if !self.active || self.digits == pty_terminal_test_client::MILESTONE_RENDER_DIGITS {
88+
self.active = false;
89+
return None;
90+
}
91+
self.id = (self.id << 2) | u32::from(digit);
92+
self.digits += 1;
93+
None
94+
}
95+
}
96+
7397
/// A test-oriented terminal that provides milestone-based synchronization.
7498
///
7599
/// Wraps a PTY terminal, splitting it into a [`PtyWriter`] for sending input
@@ -139,15 +163,19 @@ impl Reader {
139163
#[must_use]
140164
pub fn screen_contents(&self) -> String {
141165
let mut contents = self.pty.screen_contents();
142-
contents.retain(|ch| ch != MILESTONE_HYPERTEXT);
166+
contents.retain(|character| {
167+
!pty_terminal_test_client::is_milestone_render_character(character)
168+
});
143169
contents
144170
}
145171

146172
/// Returns the screen contents with inline ANSI SGR escape codes preserved.
147173
/// Useful for snapshot tests that need to assert colour or style attributes.
148174
#[must_use]
149175
pub fn screen_contents_formatted(&self) -> Vec<u8> {
150-
self.pty.screen_contents_formatted()
176+
pty_terminal_test_client::strip_milestone_render_characters(
177+
&self.pty.screen_contents_formatted(),
178+
)
151179
}
152180

153181
/// Reads from the PTY until a milestone with the given name is encountered.
@@ -171,35 +199,17 @@ impl Reader {
171199
let mut buf = [0u8; 4096];
172200

173201
loop {
174-
if self.milestone_tracker.take_completed(name) {
175-
return self.screen_contents();
202+
while let Some(completed) = self.milestone_tracker.pop_completed() {
203+
if completed == name {
204+
return self.screen_contents();
205+
}
176206
}
177207

178208
let n = self.read(&mut buf).expect("PTY read failed");
179209
assert!(n > 0, "EOF reached before milestone '{name}'");
180210
}
181211
}
182212

183-
/// Waits until the next milestone marker and its rendered fence arrive.
184-
///
185-
/// Unlike [`Self::expect_milestone`], this does not consume the completed
186-
/// milestone. It is intended for stop-and-wait flow control when a test
187-
/// sends several logical input events but only snapshots the final state.
188-
///
189-
/// # Panics
190-
///
191-
/// Panics if the child process exits before another milestone completes,
192-
/// or if a PTY read fails.
193-
pub fn wait_for_next_milestone(&mut self) {
194-
let completion_count = self.milestone_tracker.completion_count;
195-
let mut buf = [0u8; 4096];
196-
197-
while self.milestone_tracker.completion_count == completion_count {
198-
let n = self.read(&mut buf).expect("PTY read failed");
199-
assert!(n > 0, "EOF reached before the next milestone");
200-
}
201-
}
202-
203213
/// Reads all remaining PTY output until the child exits, then returns the exit status.
204214
///
205215
/// # Errors
@@ -220,16 +230,18 @@ impl Reader {
220230
mod tests {
221231
use super::*;
222232

223-
fn marker_without_fence(name: &str) -> Vec<u8> {
233+
fn marker_without_fence(name: &str) -> (Vec<u8>, Vec<u8>) {
224234
// Model ConPTY's fast control path by delivering the complete OSC marker
225235
// before its printable anchor reaches the output pipe.
226236
let mut marker = pty_terminal_test_client::encoded_milestone(name);
227237
let index = marker
228-
.windows(pty_terminal_test_client::MILESTONE_RENDER_FENCE.len())
229-
.position(|window| window == pty_terminal_test_client::MILESTONE_RENDER_FENCE)
238+
.windows(pty_terminal_test_client::MILESTONE_RENDER_FENCE_START.len())
239+
.position(|window| window == pty_terminal_test_client::MILESTONE_RENDER_FENCE_START)
230240
.unwrap();
231-
marker.drain(index..index + pty_terminal_test_client::MILESTONE_RENDER_FENCE.len());
232-
marker
241+
let fence = marker
242+
.drain(index..index + pty_terminal_test_client::MILESTONE_RENDER_FENCE_LEN)
243+
.collect();
244+
(marker, fence)
233245
}
234246

235247
fn advance(parser: &mut vte::Parser, tracker: &mut MilestoneTracker, bytes: &[u8]) {
@@ -243,12 +255,13 @@ mod tests {
243255

244256
// Receiving the marker and subsequent printable output is insufficient:
245257
// only the protocol's rendered anchor establishes the screen barrier.
246-
advance(&mut parser, &mut tracker, &marker_without_fence("target"));
258+
let (marker, fence) = marker_without_fence("target");
259+
advance(&mut parser, &mut tracker, &marker);
247260
advance(&mut parser, &mut tracker, b"rendered output");
248-
assert!(!tracker.take_completed("target"));
261+
assert!(tracker.pop_completed().is_none());
249262

250-
advance(&mut parser, &mut tracker, pty_terminal_test_client::MILESTONE_RENDER_FENCE);
251-
assert!(tracker.take_completed("target"));
263+
advance(&mut parser, &mut tracker, &fence);
264+
assert_eq!(tracker.pop_completed().as_deref(), Some("target"));
252265
}
253266

254267
#[test]
@@ -260,7 +273,11 @@ mod tests {
260273
let mut tracker = MilestoneTracker::default();
261274
advance(&mut parser, &mut tracker, &marker[..split]);
262275
advance(&mut parser, &mut tracker, &marker[split..]);
263-
assert!(tracker.take_completed("target"), "failed at split {split}");
276+
assert_eq!(
277+
tracker.pop_completed().as_deref(),
278+
Some("target"),
279+
"failed at split {split}"
280+
);
264281
}
265282
}
266283

@@ -270,19 +287,65 @@ mod tests {
270287
let mut tracker = MilestoneTracker::default();
271288

272289
advance(&mut parser, &mut tracker, &pty_terminal_test_client::encoded_milestone("first"));
273-
assert!(tracker.take_completed("first"));
290+
assert_eq!(tracker.pop_completed().as_deref(), Some("first"));
274291
advance(&mut parser, &mut tracker, &pty_terminal_test_client::encoded_milestone("second"));
275-
assert!(tracker.take_completed("second"));
292+
assert_eq!(tracker.pop_completed().as_deref(), Some("second"));
293+
}
294+
295+
#[test]
296+
fn coalesced_markers_complete_the_latest_state() {
297+
let mut parser = vte::Parser::new();
298+
let mut tracker = MilestoneTracker::default();
299+
let (mut markers, _) = marker_without_fence("first");
300+
let (second_marker, second_fence) = marker_without_fence("second");
301+
markers.extend(second_marker);
302+
303+
advance(&mut parser, &mut tracker, &markers);
304+
advance(&mut parser, &mut tracker, &second_fence);
305+
assert_eq!(tracker.pop_completed().as_deref(), Some("second"));
306+
assert!(tracker.pop_completed().is_none());
307+
assert!(tracker.pending.is_empty());
276308
}
277309

278310
#[test]
279-
#[should_panic(expected = "milestone protocol violation")]
280-
fn second_marker_before_fence_is_a_protocol_violation() {
311+
fn surviving_fences_complete_their_own_markers() {
281312
let mut parser = vte::Parser::new();
282313
let mut tracker = MilestoneTracker::default();
283-
let mut markers = marker_without_fence("first");
284-
markers.extend(marker_without_fence("second"));
314+
let (mut markers, first_fence) = marker_without_fence("first");
315+
let (second_marker, second_fence) = marker_without_fence("second");
316+
markers.extend(second_marker);
285317

286318
advance(&mut parser, &mut tracker, &markers);
319+
advance(&mut parser, &mut tracker, &first_fence);
320+
advance(&mut parser, &mut tracker, &second_fence);
321+
assert_eq!(tracker.pop_completed().as_deref(), Some("first"));
322+
assert_eq!(tracker.pop_completed().as_deref(), Some("second"));
323+
}
324+
325+
#[test]
326+
fn a_missing_close_does_not_consume_the_next_fence() {
327+
let mut parser = vte::Parser::new();
328+
let mut tracker = MilestoneTracker::default();
329+
let (mut markers, mut first_fence) = marker_without_fence("first");
330+
let (second_marker, second_fence) = marker_without_fence("second");
331+
markers.extend(second_marker);
332+
first_fence.truncate(
333+
first_fence.len() - pty_terminal_test_client::MILESTONE_RENDER_FENCE_START.len(),
334+
);
335+
336+
advance(&mut parser, &mut tracker, &markers);
337+
advance(&mut parser, &mut tracker, &first_fence);
338+
advance(&mut parser, &mut tracker, &second_fence);
339+
assert_eq!(tracker.pop_completed().as_deref(), Some("first"));
340+
assert_eq!(tracker.pop_completed().as_deref(), Some("second"));
341+
}
342+
343+
#[test]
344+
fn screen_stripping_removes_reserved_protocol_characters() {
345+
let standalone = "before\u{2060}\u{2061}after";
346+
assert_eq!(
347+
pty_terminal_test_client::strip_milestone_render_characters(standalone.as_bytes()),
348+
b"beforeafter"
349+
);
287350
}
288351
}

0 commit comments

Comments
 (0)