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:
commit
f07b75b4a3
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue