From 8ef2fa07939490193b9ee3cead34d57c08f7ccef Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 13:49:14 -0400 Subject: [PATCH 1/3] test(vterm): match host output past the differ's cell-skipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS `VTERM_ALT_READY` flake finally reported itself through #151's breadcrumbs, identically in two runs: pmacs still running, `init.lua` reached, `terminal.open` ok, and a settled screen whose tail is nothing but `\x1b[22;42H` repeated 133 times. That cursor is the evidence. A blank terminal parks at 1;1. Column 42 is where the cursor lands after writing a 15-byte marker that ends at column 41 — so the child DID write and the emulator DID receive it. What failed was the assertion: `wait_for_output` required the needle to appear as contiguous bytes, but the TUI differ paints only changed cells and skips ones that already match, so a run held contiguously on one screen row can still reach the host as `PREFIX`. Match over escape-stripped bytes when the needle is plain text. This cannot mask the failure that matters: text the child never wrote is absent from the stripped stream too, so a genuinely silent child still fails. Needles carrying their own escape (the OSC 52 clipboard reply) keep the strict path, since stripping would consume the bytes under test. The failure arm now also reports how much of the needle rendered, so the next occurrence distinguishes "nothing reached the host" — a PTY/spawn fault — from a partial render, instead of leaving a tail of pure escapes that cannot tell them apart. Both helpers are pinned directly, including that stripping rejoins a split run without inventing absent text. `strip_ansi`'s first draft mishandled `ESC ( B`, whose intermediate byte makes it three bytes rather than two; its test caught that. Test-only; no runtime code. Co-Authored-By: Claude Opus 5 (1M context) --- tests/vterm_stage2_acceptance.rs | 168 +++++++++++++++++++++++++++++-- 1 file changed, 160 insertions(+), 8 deletions(-) diff --git a/tests/vterm_stage2_acceptance.rs b/tests/vterm_stage2_acceptance.rs index 9208785..ff1737a 100644 --- a/tests/vterm_stage2_acceptance.rs +++ b/tests/vterm_stage2_acceptance.rs @@ -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 `PREFIX`. 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 { + 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,11 +648,17 @@ fn wait_for_output( startup: &[(&str, &Path)], ) { let deadline = Instant::now() + timeout; + // A needle carrying its own escape (e.g. the OSC 52 clipboard reply) is + // matched strictly; stripping would consume the very bytes under test. + let needle_is_plain_text = !needle.contains(&0x1b); loop { - if pty - .output() - .windows(needle.len()) - .any(|window| window == needle) + let output = pty.output(); + let contiguous = output.windows(needle.len()).any(|window| window == needle); + if contiguous + || (needle_is_plain_text && { + let visible = strip_ansi(&output); + visible.windows(needle.len()).any(|window| window == needle) + }) { return; } @@ -592,11 +666,29 @@ fn wait_for_output( let diagnosis = describe_startup(pty, startup); let output = pty.output(); let start = output.len().saturating_sub(4_000); + // Both the contiguous and the escape-stripped match failed, so + // report how much of the needle rendered 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. `0` + // means no child text ever reached the host — the serious case, + // pointing at the PTY/spawn path rather than at painting. + 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)); @@ -761,3 +853,63 @@ 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 the differ split, WITHOUT inventing text. +/// +/// This is the property that makes the loose match in [`wait_for_output`] +/// safe: it rescues a marker that rendered across a cursor move, and still +/// reports absent for a child that never wrote. +#[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 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() + ); +} From f77ff3074d468d0d561603745c6a319efe99c5d5 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 14:02:25 -0400 Subject: [PATCH 2/3] test(vterm): write CRLF from the raw-mode probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diagnostic added in the previous commit answered the question on its first macOS run: rendered prefix: 6/15 bytes ("VTERM_") — child text rendered only partially So the child wrote and the host received part of the marker, but stripping escapes did not rejoin the rest: other repainted cells sit between the two pieces, not just cursor moves. Six characters is exactly what fits before the right margin of this session's 40-column child. The probe writes bare `\n`, and 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. After twenty lines the marker starts in the right margin, wraps mid-word, and reaches the host as two pieces that no contiguous match can join. That also explains the intermittency: the wrap column depends on whether pmacs has already resized the PTY from the requested 40 columns to the window width, which races the child's first writes. Write explicit carriage returns so every line returns to column 1 and the markers start there. The fixture was wrong about its own line discipline; the emulator was behaving correctly throughout. The escape-stripped matching and the rendered-prefix diagnostic from the previous commit are kept: they are what produced this answer, and they keep the next such failure legible. Test-only; no runtime code. Co-Authored-By: Claude Opus 5 (1M context) --- tests/vterm_stage2_acceptance.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/vterm_stage2_acceptance.rs b/tests/vterm_stage2_acceptance.rs index ff1737a..65bd9e8 100644 --- a/tests/vterm_stage2_acceptance.rs +++ b/tests/vterm_stage2_acceptance.rs @@ -735,12 +735,22 @@ 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", "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", From 85a07f378cde4ea2d1e8eb95c00754200f6f9007 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 14:27:21 -0400 Subject: [PATCH 3/3] test(vterm): gate terminal readiness on a file, not on host bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both earlier attempts in this branch were wrong, and the diagnostic they added is what proved it: the macOS failure reported a stable `rendered prefix: 6/15 ("VTERM_")` BEFORE and AFTER the CRLF change, with an identical cursor, across two completely different child layouts. Identical truncation under different layouts cannot be a layout problem. The real mechanism is pinned by the repository's own unit test, `cell::tests::diff_split_by_unchanged_cell_is_two_spans`: `cell::diff` splits a run at any cell where `prev == next` and never transmits that cell. So when a character of the marker already happens to sit at its destination, the host receives the marker with that byte MISSING, not merely escaped around. The constant 6 is the distance to the first such hole. That makes escape-stripped matching unsound in kind rather than merely insufficient: no matching strategy recovers a byte that was never sent. It is removed, and `wait_for_output` is strict again. What remains asserted through host bytes are protocol escapes pmacs writes directly — the OSC 52 clipboard reply, the alternate-screen and bracketed-paste resets — which are not painted cells and which the differ never touches. Readiness now gates on a file the child publishes, the pattern the reliable sibling test in this file already uses. That the child's output reaches the SCREEN stays asserted in-process over `snapshot_text`, at the layer that can actually see it; this test keeps what it uniquely owns, the host lifecycle. `strip_ansi` and `longest_rendered_prefix` are kept as failure diagnostics only, and now carry a case pinning the dropped-cell shape so the wrong remedy is not reached for again. The new readiness wait reports startup breadcrumbs on timeout; the plain helper reports only the missing path, which is the least useful thing to know at exactly that moment. Both the readiness gate and its timeout diagnostic were falsified by pointing the child at a path the test does not watch. Test-only; no runtime code. Co-Authored-By: Claude Opus 5 (1M context) --- tests/vterm_stage2_acceptance.rs | 113 ++++++++++++++++++++++++------- 1 file changed, 88 insertions(+), 25 deletions(-) diff --git a/tests/vterm_stage2_acceptance.rs b/tests/vterm_stage2_acceptance.rs index 65bd9e8..852ed1f 100644 --- a/tests/vterm_stage2_acceptance.rs +++ b/tests/vterm_stage2_acceptance.rs @@ -648,30 +648,29 @@ fn wait_for_output( startup: &[(&str, &Path)], ) { let deadline = Instant::now() + timeout; - // A needle carrying its own escape (e.g. the OSC 52 clipboard reply) is - // matched strictly; stripping would consume the very bytes under test. - let needle_is_plain_text = !needle.contains(&0x1b); + // 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 { let output = pty.output(); - let contiguous = output.windows(needle.len()).any(|window| window == needle); - if contiguous - || (needle_is_plain_text && { - let visible = strip_ansi(&output); - visible.windows(needle.len()).any(|window| window == needle) - }) - { + 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); - // Both the contiguous and the escape-stripped match failed, so - // report how much of the needle rendered 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. `0` - // means no child text ever reached the host — the serious case, - // pointing at the PTY/spawn path rather than at painting. + // 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)); @@ -695,6 +694,32 @@ fn wait_for_output( } } +/// [`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 { + 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 { let deadline = Instant::now() + timeout; loop { @@ -717,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"); @@ -748,6 +775,7 @@ fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_a "os.write(1, b'\\x1b[?1049h\\x1b[2J')\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\\r\\n' % i)\n", @@ -757,6 +785,7 @@ fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_a "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") ); @@ -812,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"); @@ -864,11 +909,13 @@ fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_a ); } -/// `strip_ansi` joins a run the differ split, WITHOUT inventing text. +/// `strip_ansi` joins a run interrupted by escapes, WITHOUT inventing text. /// -/// This is the property that makes the loose match in [`wait_for_output`] -/// safe: it rescues a marker that rendered across a cursor move, and still -/// reports absent for a child that never wrote. +/// 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"; @@ -893,6 +940,22 @@ fn strip_ansi_rejoins_a_split_run_but_never_invents_absent_text() { // 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`]).