feat(terminal): give an attaching subscriber the screen as it stands

A subscriber that arrives mid-stream cannot start from `render()`. Damage
describes what changed since someone last looked, so a newcomer is handed
whatever happens next -- on a quiet terminal, the cursor's line alone --
painted onto a blank screen. Upstream's `mark_fully_damaged` is private, so
an embedder cannot ask for a full frame that way either.

`snapshot()` copies the whole visible viewport instead, marks the frame
full so a reused encoder's dedup hashes realign, and stamps the geometry it
was captured under.

The delicate part is what it does not do. Damage is a single shared cursor
across every subscriber, so a snapshot that consumed it would steal the
incumbent renderer's pending rows: the newcomer's full frame would look
perfect while the established renderer silently froze. `capture_all`
therefore never calls `damage()` or `reset_damage()`, and their absence is
the mechanism.

Proving that needed more care than the interleaving alone. Because
`Term::damage()` marks the cursor line on every call, an incumbent owed
only the row it sits on gets that row back even when its damage was
stolen -- so the first version of the fixture passed against a deliberately
naive implementation. The committed fixture rewrites row 0 and parks the
cursor on row 3, putting the owed row where the cursor cannot return it for
free; it fails when snapshot consumes damage, and again when snapshot
copies damaged rows only or omits the full flag.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
This commit is contained in:
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6
2026-08-01 21:43:48 -04:00
parent e78491767b
commit 3a5c7d576e
3 changed files with 257 additions and 0 deletions

View File

@@ -207,6 +207,58 @@ pub fn capture(terminal: &mut crate::Terminal) -> RawFrame {
}
}
/// Copy the **entire visible viewport**, leaving damage untouched.
///
/// This exists for subscribers that arrive mid-stream: attach, reattach, and
/// the successor side of a resize. Damage only describes what changed since
/// the last capture, so a newcomer that starts from [`capture`] sees whatever
/// happened to change next -- often just the cursor's line -- painted onto a
/// blank screen. Upstream's `mark_fully_damaged` is private, so an embedder
/// cannot ask for a full frame that way.
///
/// **It must not consume damage, and that is the load-bearing property.** The
/// incumbent subscriber's next [`capture`] has to still see its rows. If this
/// called `damage()`/`reset_damage()` it would steal them, and the incumbent
/// would freeze on stale content while a newcomer's full-frame test passed.
/// The absence of those two calls below is the mechanism; `snapshot_test.rs`
/// is the proof.
///
/// **Runs under the lock; does no encoding.** Costs a full grid copy rather
/// than a damaged-rows copy, so it belongs on attach, not in the frame loop.
pub fn capture_all(terminal: &mut crate::Terminal) -> RawFrame {
let viewport = terminal.viewport();
let term = terminal.term_mut();
let columns = term.columns();
let screen_lines = term.screen_lines();
let cursor_point = term.grid().cursor.point;
let visible = term
.mode()
.contains(alacritty_terminal::term::TermMode::SHOW_CURSOR);
let grid = term.grid();
let mut rows = Vec::with_capacity(screen_lines);
for line in 0..screen_lines {
let row = &grid[Line(line as i32)];
rows.push((line, row[..Column(columns)].to_vec()));
}
let cursor = CursorFrame {
line: cursor_point.line.0.max(0) as usize,
column: cursor_point.column.0,
visible,
};
// No `damage()` and no `reset_damage()`: see the note above.
RawFrame {
rows,
cursor,
// A snapshot *is* a repaint, and marking it full also resets the
// consumer's `Encoder` hashes, so its dedup state describes the grid it
// was actually given rather than a predecessor's.
full: true,
viewport,
}
}
/// Suppresses rows whose content did not actually change.
#[derive(Default)]
pub struct Encoder {

View File

@@ -168,6 +168,24 @@ impl SharedTerminal {
encoder.encode(raw)
}
/// Copy the whole viewport for a subscriber that arrived mid-stream.
/// Renderer plane.
///
/// Attach, reattach, and the successor side of a resize all need the
/// screen as it stands, not the next thing to change on it. Crucially this
/// leaves damage alone, so taking a snapshot for a newcomer cannot steal
/// the incumbent renderer's pending rows -- see [`damage::capture_all`].
///
/// Costs a full grid copy under the lock, so call it on attach rather than
/// per frame.
pub fn snapshot(&self, encoder: &mut Encoder) -> Frame {
let raw = {
let mut term = self.acquire(&self.renderer);
damage::capture_all(&mut term)
};
encoder.encode(raw)
}
/// Apply a coalesced resize. Renderer plane: this competes with the
/// renderer for the same lock and can hold it for milliseconds.
pub fn resize(&self, size: crate::Size) -> crate::Viewport {

View File

@@ -0,0 +1,187 @@
//! The attach contract: what a subscriber that arrives mid-stream is given,
//! and what taking it must not cost the subscriber already there.
//!
//! `render()` reports damage -- what changed since someone last looked. That
//! is the right thing for a steady-state renderer and the wrong thing for a
//! newcomer, who needs the screen as it stands. `snapshot()` supplies that,
//! and the delicate part is that it must do so *without* consuming damage:
//! two subscribers share one terminal, and damage is a single shared cursor.
use buzz_terminal::damage::Encoder;
use buzz_terminal::fences::Fences;
use buzz_terminal::{Action, SharedTerminal, Size, Terminal};
use std::sync::mpsc::Receiver;
/// The receiver is returned rather than dropped: dropping it disconnects the
/// channel and every subsequent listener send silently fails.
fn terminal(columns: usize, screen_lines: usize) -> (SharedTerminal, Receiver<Action>) {
let size = Size {
columns,
screen_lines,
scrollback: 100,
};
let (term, actions) = Terminal::new(size, Fences::ALL);
(SharedTerminal::new(term), actions)
}
/// Collect the non-blank text of a frame's rows, for comparing what a
/// subscriber can actually see.
fn visible_text(frame: &buzz_terminal::damage::Frame) -> Vec<String> {
frame
.rows
.iter()
.map(|row| {
row.spans
.iter()
.map(|span| span.text.as_str())
.collect::<String>()
.trim_end()
.to_string()
})
.filter(|line| !line.is_empty())
.collect()
}
/// The reason `snapshot` exists. A subscriber that attaches mid-stream and
/// starts from `render()` is handed only what changes next -- with a quiet
/// terminal that is the cursor's line alone, so the scrollback-visible screen
/// never arrives.
#[test]
fn a_late_render_shows_only_the_next_change_but_a_snapshot_shows_the_screen() {
let (shared, _actions) = terminal(20, 4);
shared.feed(b"first\r\nsecond\r\nthird");
// The incumbent consumes the damage from that output.
let mut incumbent = Encoder::new();
let seen = visible_text(&shared.render(&mut incumbent));
assert_eq!(seen, vec!["first", "second", "third"]);
// A newcomer rendering now sees essentially nothing: damage is spent.
let mut latecomer = Encoder::new();
let by_render = visible_text(&shared.render(&mut latecomer));
assert!(
!by_render.contains(&"first".to_string()),
"a late render cannot show scrollback it never saw damaged, got {by_render:?}"
);
// The same newcomer snapshotting sees the whole viewport.
let mut attaching = Encoder::new();
let by_snapshot = shared.snapshot(&mut attaching);
assert_eq!(
visible_text(&by_snapshot),
vec!["first", "second", "third"],
"a snapshot must carry the visible viewport"
);
assert!(by_snapshot.full, "a snapshot is a repaint");
}
/// **The law: `snapshot()` must not consume damage.**
///
/// Two subscribers share one terminal and damage is one shared cursor, so a
/// snapshot taken for an attaching subscriber must leave the incumbent's
/// pending rows intact. A naive implementation that calls `damage()` passes a
/// full-frame test while freezing every other subscriber -- the newcomer looks
/// perfect and the incumbent silently stops updating.
///
/// The interleaving is the point: write, snapshot, *then* let the incumbent
/// render. But the interleaving alone is not enough to discriminate, and the
/// reason is this module's own rule 2 -- `Term::damage()` marks the cursor
/// line on every call. So an incumbent owed only the line it is sitting on
/// gets that line back even when its damage was stolen, and a naive snapshot
/// passes.
///
/// The owed row therefore has to be somewhere the cursor is *not*. Here row 0
/// is rewritten and the cursor is parked on row 3, so a theft leaves the
/// incumbent holding a blank cursor line and nothing else.
#[test]
fn a_snapshot_does_not_steal_the_incumbents_damage() {
let (shared, _actions) = terminal(20, 4);
// An established renderer, caught up to a quiet terminal. The initial
// content is shorter than its replacement so the rewrite below covers it
// completely and no tail of it survives.
let mut incumbent = Encoder::new();
shared.feed(b"old");
let _ = shared.render(&mut incumbent);
// Rewrite row 0, then park the cursor on row 3. The incumbent is now owed
// row 0, which is not the row the cursor will re-damage for free.
shared.feed(b"\x1b[1;1HAFTER\x1b[4;1H");
// A second subscriber attaches and snapshots first.
let mut attaching = Encoder::new();
let attached = shared.snapshot(&mut attaching);
assert_eq!(
visible_text(&attached),
vec!["AFTER"],
"the newcomer sees the whole screen"
);
// The incumbent must still be delivered row 0.
let follow_up = shared.render(&mut incumbent);
assert!(
follow_up.rows.iter().any(|row| row.line == 0),
"snapshot consumed the incumbent's damage: row 0 was never delivered, \
got rows {:?}",
follow_up.rows.iter().map(|r| r.line).collect::<Vec<_>>()
);
assert!(
visible_text(&follow_up).contains(&"AFTER".to_string()),
"the incumbent must still see the row written before the snapshot, got {:?}",
visible_text(&follow_up)
);
}
/// A snapshot stamps the geometry it was captured under and resets the
/// consumer's dedup state, so an encoder reused across a resize cannot carry
/// hashes describing rows of a different width.
#[test]
fn a_snapshot_realigns_a_reused_encoders_dedup_state() {
let (shared, _actions) = terminal(20, 4);
shared.feed(b"wide enough line");
let mut encoder = Encoder::new();
let before = shared.snapshot(&mut encoder);
assert_eq!(before.viewport.columns, 20);
let resized = shared.resize(Size {
columns: 10,
screen_lines: 4,
scrollback: 100,
});
assert_eq!(resized.columns, 10);
// Same encoder, new geometry: every row must be re-sent, not suppressed
// as unchanged against hashes taken at the old width.
let after = shared.snapshot(&mut encoder);
assert_eq!(
after.viewport.columns, 10,
"the capture-time grid is stamped"
);
assert!(after.full, "a snapshot is a repaint");
assert!(
!after.rows.is_empty(),
"stale hashes must not suppress rows after a resize"
);
}
/// Two consecutive snapshots with no output between them still both carry the
/// screen. A snapshot is not a one-shot: reattach may happen repeatedly, and
/// nothing about the first may disarm the second.
#[test]
fn snapshots_are_repeatable() {
let (shared, _actions) = terminal(20, 4);
shared.feed(b"persistent");
let mut first = Encoder::new();
let mut second = Encoder::new();
assert_eq!(
visible_text(&shared.snapshot(&mut first)),
vec!["persistent"]
);
assert_eq!(
visible_text(&shared.snapshot(&mut second)),
vec!["persistent"],
"a second subscriber attaching later must see the same screen"
);
}