mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00:00
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:
parent
e78491767b
commit
3a5c7d576e
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
187
desktop/src-tauri/crates/buzz-terminal/tests/snapshot.rs
Normal file
187
desktop/src-tauri/crates/buzz-terminal/tests/snapshot.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user