Merge pull request #153 from levineuwirth/vterm-pty-flake

test(vterm): match host output past the differ's cell-skipping
This commit is contained in:
Levi Neuwirth 2026-07-24 18:37:23 +00:00 committed by GitHub
commit f07b75b4a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 241 additions and 16 deletions

View File

@ -573,6 +573,74 @@ fn describe_startup(pty: &mut PmacsPty, startup: &[(&str, &Path)]) -> String {
out
}
/// Host bytes with ANSI escape sequences removed.
///
/// The TUI differ paints only cells that CHANGED and skips ones already
/// matching, so a run the emulator holds contiguously on one screen row can
/// still reach the host as `PREF<cursor-move>IX`. Assertions about child
/// output therefore fall back to matching over this stripped stream.
///
/// This cannot mask the failure that matters: text the child never wrote is
/// absent from the stripped bytes too. It only removes the false negative
/// where the differ split a run that did render.
fn strip_ansi(bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] != 0x1b {
out.push(bytes[i]);
i += 1;
continue;
}
match bytes.get(i + 1) {
// CSI: parameters/intermediates, then a final byte in 0x40..=0x7e.
Some(b'[') => {
i += 2;
while i < bytes.len() && !(0x40..=0x7e).contains(&bytes[i]) {
i += 1;
}
i += usize::from(i < bytes.len());
}
// OSC: terminated by BEL or ST (`ESC \`).
Some(b']') => {
i += 2;
while i < bytes.len() && bytes[i] != 0x07 {
if bytes[i] == 0x1b && bytes.get(i + 1) == Some(&b'\\') {
i += 1;
break;
}
i += 1;
}
i += usize::from(i < bytes.len());
}
// nF form: intermediates in 0x20..=0x2f then one final byte
// (`ESC ( B` designates ASCII into G0 and is three bytes, not two).
Some(0x20..=0x2f) => {
i += 1;
while i < bytes.len() && (0x20..=0x2f).contains(&bytes[i]) {
i += 1;
}
i += usize::from(i < bytes.len());
}
// Single-byte final (`ESC 7`, `ESC M`, …).
Some(_) => i += 2,
None => i += 1,
}
}
out
}
/// Longest prefix of `needle` that appears anywhere in `haystack`.
///
/// Diagnostic only (see the failure arm of [`wait_for_output`]): it tells a
/// failed match whether the child's bytes reached the host at all.
fn longest_rendered_prefix(haystack: &[u8], needle: &[u8]) -> usize {
(1..=needle.len())
.rev()
.find(|&n| haystack.windows(n).any(|window| window == &needle[..n]))
.unwrap_or(0)
}
fn wait_for_output(
pty: &mut PmacsPty,
needle: &[u8],
@ -580,29 +648,78 @@ fn wait_for_output(
startup: &[(&str, &Path)],
) {
let deadline = Instant::now() + timeout;
// Matching is STRICT, deliberately. An earlier revision also tried an
// escape-stripped match to tolerate a run the differ had split; that is
// unsound for painted CELLS, because `cell::diff` drops an already-matching
// cell entirely rather than merely interrupting the run, and no match
// strategy recovers a byte that was never sent. Assertions here are
// therefore limited to protocol escapes pmacs writes straight to the host
// (clipboard, mode resets), which the differ never touches. Content that
// must be seen on SCREEN is asserted in-process over `snapshot_text`.
loop {
if pty
.output()
.windows(needle.len())
.any(|window| window == needle)
{
let output = pty.output();
if output.windows(needle.len()).any(|window| window == needle) {
return;
}
if Instant::now() >= deadline {
let diagnosis = describe_startup(pty, startup);
let output = pty.output();
let start = output.len().saturating_sub(4_000);
// Report how much of the needle reached the host at all. The
// printed tail cannot answer that on its own: a settled screen
// emits empty diffs forever and pushes any real text out of the
// window. A prefix strictly between 0 and the full length means
// the bytes arrived mutilated rather than never — which for cell
// content is the differ dropping an already-matching cell.
let visible = strip_ansi(&output);
let seen = longest_rendered_prefix(&visible, needle)
.max(longest_rendered_prefix(&output, needle));
let verdict = if seen == 0 {
"no child text reached the host"
} else {
"child text rendered only partially"
};
panic!(
"host output never contained {:?} after {timeout:?}\n \
startup: {diagnosis}\n tail: {}",
String::from_utf8_lossy(needle),
output[start..].escape_ascii()
"host output never contained {needle:?} after {timeout:?}\n \
startup: {diagnosis}\n \
rendered prefix: {seen}/{len} bytes ({prefix:?}) {verdict}\n \
tail: {tail}",
needle = String::from_utf8_lossy(needle),
len = needle.len(),
prefix = String::from_utf8_lossy(&needle[..seen]),
tail = output[start..].escape_ascii()
);
}
thread::sleep(Duration::from_millis(20));
}
}
/// [`wait_for_file`] that reports startup breadcrumbs when it times out.
///
/// The plain helper panics with only the missing path, which is the least
/// useful thing to know: a readiness file that never appears is exactly when
/// "how far did startup get" decides where to look next.
fn wait_for_published_file(
pty: &mut PmacsPty,
path: &Path,
timeout: Duration,
startup: &[(&str, &Path)],
) -> Vec<u8> {
let deadline = Instant::now() + timeout;
loop {
if let Ok(bytes) = fs::read(path) {
return bytes;
}
assert!(
Instant::now() < deadline,
"child never published {} within {timeout:?}\n startup: {}",
path.display(),
describe_startup(pty, startup)
);
thread::sleep(Duration::from_millis(20));
}
}
fn wait_for_file(path: &Path, timeout: Duration) -> Vec<u8> {
let deadline = Instant::now() + timeout;
loop {
@ -625,6 +742,8 @@ fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_a
let config_root = temp.path().join("config");
let config_dir = config_root.join("pmacs");
let state_root = temp.path().join("state");
// Readiness is published as a FILE, not as host bytes. See the wait below.
let alt_ready_path = temp.path().join("alt-ready");
let input_path = temp.path().join("child-input");
let size_path = temp.path().join("child-size");
let init_path = temp.path().join("init-reached");
@ -643,18 +762,30 @@ fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_a
" data = b''\n",
" while marker not in data:\n",
" data += os.read(0, 4096)\n",
// CRLF, not bare LF. The supervisor's PTY trampoline runs
// `stty raw`, which clears OPOST, so a lone `\n` moves DOWN
// without returning to column 1 and every line staircases five
// columns right. Against this session's 40-column child that
// walks the readiness marker into the right margin, where it
// wraps mid-word and reaches the host as two pieces separated by
// other repainted cells — unmatchable, and intermittent because
// the column depends on whether pmacs has resized the PTY to the
// window width yet. Explicit carriage returns keep every write
// column-stable, so the markers below start at column 1.
"os.write(1, b'\\x1b[?1049h\\x1b[2J')\n",
"for i in range(20): os.write(1, b'alt%02d\\n' % i)\n",
"for i in range(20): os.write(1, b'alt%02d\\r\\n' % i)\n",
"os.write(1, b'VTERM_ALT_READY')\n",
"open({:?}, 'wb').write(b'1')\n",
"read_until(b'ALT_GATE\\n')\n",
"os.write(1, b'\\x1b[?1049l')\n",
"for i in range(40): os.write(1, b'main%02d\\n' % i)\n",
"for i in range(40): os.write(1, b'main%02d\\r\\n' % i)\n",
"os.write(1, b'VTERM_MAIN_READY\\x07')\n",
"data = read_exact(18)\n",
"open({:?}, 'wb').write(data)\n",
"size = os.get_terminal_size(0)\n",
"open({:?}, 'w').write(f'{{size.lines}} {{size.columns}}\\n')\n"
),
alt_ready_path.to_str().expect("UTF-8 alt-ready path"),
input_path.to_str().expect("UTF-8 input path"),
size_path.to_str().expect("UTF-8 size path")
);
@ -710,11 +841,27 @@ fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_a
("init.lua reached", init_path.as_path()),
("terminal.open", open_path.as_path()),
];
wait_for_output(
&mut pty,
b"VTERM_ALT_READY",
Duration::from_secs(10),
startup,
// Gate on a file the child publishes, not on its text appearing in the
// host stream. Host bytes cannot carry this assertion: `cell::diff` splits
// a run at any cell where `prev == next` and NEVER TRANSMITS that cell
// (pinned by `cell::tests::diff_split_by_unchanged_cell_is_two_spans`), so
// whenever a character of the marker already happens to sit at its
// destination the host receives the marker with that byte missing. No
// matching strategy can recover a byte that was never sent — which is what
// the macOS failures were, reporting a stable `rendered prefix: 6/15`
// across two different child layouts.
//
// This is a synchronisation gate, not the assertion. That the child's
// output reaches the SCREEN is pinned in-process, at the layer that can
// see it, by `lua_surface_is_strict_...` asserting over `snapshot_text`.
// What this test uniquely owns is host lifecycle — the clipboard escape,
// geometry propagation, and terminal restore asserted below — and those
// are protocol escapes pmacs writes directly, never painted cells, so the
// differ cannot split them.
assert_eq!(
wait_for_published_file(&mut pty, &alt_ready_path, Duration::from_secs(10), startup),
b"1",
"alt-screen readiness breadcrumb was published but malformed"
);
pty.resize(30, 90).expect("resize host PTY");
@ -761,3 +908,81 @@ fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_a
"pmacs must disable host bracketed paste on exit"
);
}
/// `strip_ansi` joins a run interrupted by escapes, WITHOUT inventing text.
///
/// It backs the failure diagnostic in [`wait_for_output`], not the match: it
/// separates "arrived, interrupted by cursor moves" from "never arrived".
/// It deliberately does NOT rescue a run the cell differ split, because that
/// path drops the matching cell rather than escaping around it — the case
/// pinned below and by `cell::tests::diff_split_by_unchanged_cell_is_two_spans`.
#[test]
fn strip_ansi_rejoins_a_split_run_but_never_invents_absent_text() {
let needle = b"VTERM_ALT_READY";
// Split by a cursor move mid-run: stripping rejoins it.
let split = b"\x1b[9;30HVTERM_ALT_\x1b[10;1HREADY".to_vec();
let visible = strip_ansi(&split);
assert!(visible.windows(needle.len()).any(|w| w == needle));
// A silent child stays silent: no amount of stripping conjures the text.
let silent = b"\x1b[?2026h\x1b[22;42H\x1b[?25h\x1b[?2026l".repeat(4);
let visible = strip_ansi(&silent);
assert!(
!visible.windows(needle.len()).any(|w| w == needle),
"stripping must not manufacture text the child never wrote"
);
assert!(
visible.is_empty(),
"pure escapes strip to nothing: {visible:?}"
);
// OSC (clipboard) and two-byte escapes are consumed, payload text kept.
assert_eq!(strip_ansi(b"a\x1b]52;c;Zm9v\x07b"), b"ab");
assert_eq!(strip_ansi(b"x\x1b(By"), b"xy");
// The case that defeated two attempted fixes, kept here so the wrong
// remedy is not reached for again. `cell::diff` splits a run at an
// already-matching cell and never transmits it, so the host sees the
// marker with an interior byte MISSING, not merely escaped around.
// Stripping is powerless; the longest prefix stops at the hole, which is
// exactly the `6/15 ("VTERM_")` the macOS runs reported.
let dropped = b"\x1b[9;1HVTERM_\x1b[9;8HLT_READY".to_vec();
let visible = strip_ansi(&dropped);
assert_eq!(visible, b"VTERM_LT_READY", "the 'A' was never sent");
assert!(!visible.windows(needle.len()).any(|w| w == needle));
assert_eq!(
longest_rendered_prefix(&visible, needle),
6,
"a dropped interior cell caps the prefix at the hole"
);
}
/// The flake diagnostic's discriminator (see [`wait_for_output`]).
///
/// The macOS `VTERM_ALT_READY` failure reports a settled screen whose tail is
/// pure cursor/sync escapes, which alone cannot say whether the child ever
/// wrote. These two cases are exactly what the failure arm must tell apart.
#[test]
fn longest_rendered_prefix_separates_absent_child_text_from_a_split_render() {
let needle = b"VTERM_ALT_READY";
// Nothing from the child: a settled screen of cursor moves only.
let silent = b"\x1b[?2026h\x1b[22;42H\x1b[?25h\x1b[?2026l".repeat(4);
assert_eq!(longest_rendered_prefix(&silent, needle), 0);
// Rendered, but the emulator held it across two screen rows, so the host
// stream carries a cursor move mid-marker and the contiguous match fails.
let mut split = Vec::new();
split.extend_from_slice(b"\x1b[9;30HVTERM_ALT_");
split.extend_from_slice(b"\x1b[10;1HREADY");
let seen = longest_rendered_prefix(&split, needle);
assert_eq!(seen, 10, "must report the rendered prefix, not zero");
assert_eq!(&needle[..seen], b"VTERM_ALT_");
// Fully contiguous is the passing case and never reaches the failure arm.
assert_eq!(
longest_rendered_prefix(b"\x1b[9;1HVTERM_ALT_READY", needle),
needle.len()
);
}