9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
288 lines
11 KiB
Rust
288 lines
11 KiB
Rust
//! 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_fully(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_fully(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_fully(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_fully(b"wide enough line");
|
|
|
|
let mut encoder = Encoder::new();
|
|
let before = shared.snapshot(&mut encoder);
|
|
assert_eq!(before.viewport.columns, 20);
|
|
let first_generation = before.viewport.generation;
|
|
|
|
let resized = shared.resize(Size {
|
|
columns: 10,
|
|
screen_lines: 4,
|
|
scrollback: 100,
|
|
});
|
|
assert_eq!(resized.columns, 10);
|
|
assert!(
|
|
resized.generation > first_generation,
|
|
"an applied resize advances the generation"
|
|
);
|
|
|
|
// 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"
|
|
);
|
|
// Columns alone does not identify a grid. `Viewport`'s own doc says the
|
|
// three fields travel together *because* a consumer comparing two of the
|
|
// three can be wrong -- and this fixture used to compare one. A resize
|
|
// that changed only `screen_lines`, or 20 -> 10 -> 20, leaves columns
|
|
// matching while the generation has moved. `resize.rs` asserts this on
|
|
// `render()` frames five times and never once on a snapshot, which is
|
|
// what Sami's T3 mutant walked through; Mari's reattach reads this stamp.
|
|
assert_eq!(
|
|
after.viewport, resized,
|
|
"a snapshot stamps the identity of the grid it actually captured"
|
|
);
|
|
assert!(after.full, "a snapshot is a repaint");
|
|
assert!(
|
|
!after.rows.is_empty(),
|
|
"stale hashes must not suppress rows after a resize"
|
|
);
|
|
}
|
|
|
|
/// A snapshot carries *every* row of the viewport, including the last one,
|
|
/// and stamps the cursor plane truthfully.
|
|
///
|
|
/// Both properties are asserted here rather than in the fixtures above
|
|
/// because of what those fixtures' helper hides: `visible_text` trims and
|
|
/// drops empty lines, so a capture that skipped the bottom row of the screen
|
|
/// reads identically to one that didn't whenever the content sits in the top
|
|
/// rows -- which it does in every other fixture in this file. Sami's T2
|
|
/// mutant (`0..screen_lines - 1`) survived all four for exactly that reason.
|
|
/// So this fixture puts content on the last row and asserts the row *set*,
|
|
/// not the text.
|
|
///
|
|
/// The cursor half is the same shape of gap: nothing checked that a snapshot's
|
|
/// cursor was the terminal's cursor rather than a plausible default.
|
|
#[test]
|
|
fn a_snapshot_carries_every_row_and_the_true_cursor() {
|
|
let (shared, _actions) = terminal(20, 4);
|
|
// Write the bottom row of the screen, then park the cursor at line 4,
|
|
// column 6 (1-based) -- row 3, column 5 to us.
|
|
shared.feed_fully(b"\x1b[4;1Hbottom\x1b[4;6H");
|
|
|
|
let mut attaching = Encoder::new();
|
|
let frame = shared.snapshot(&mut attaching);
|
|
|
|
let lines: Vec<usize> = frame.rows.iter().map(|row| row.line).collect();
|
|
assert_eq!(
|
|
lines,
|
|
vec![0, 1, 2, 3],
|
|
"a snapshot must carry the whole viewport, last row included"
|
|
);
|
|
assert!(
|
|
visible_text(&frame).contains(&"bottom".to_string()),
|
|
"content on the last row must reach an attaching subscriber, got {:?}",
|
|
visible_text(&frame)
|
|
);
|
|
|
|
assert_eq!(frame.cursor.line, 3, "the snapshot's cursor line is real");
|
|
assert_eq!(
|
|
frame.cursor.column, 5,
|
|
"the snapshot's cursor column is real"
|
|
);
|
|
assert!(frame.cursor.visible, "the cursor is shown by default");
|
|
|
|
// ...and a hidden cursor is reported hidden, so `visible` tracks the mode
|
|
// rather than being a constant that happens to match the default.
|
|
shared.feed_fully(b"\x1b[?25l");
|
|
let mut second = Encoder::new();
|
|
assert!(
|
|
!shared.snapshot(&mut second).cursor.visible,
|
|
"DECTCEM off must reach the attaching subscriber"
|
|
);
|
|
}
|
|
|
|
/// Taking a snapshot is billed to the renderer plane.
|
|
///
|
|
/// The two planes are metered separately because pooling them lets the
|
|
/// reader's millions of fast acquires dilute the renderer's tail into a false
|
|
/// pass (`shared.rs` module docs). A full-grid copy is the single most
|
|
/// expensive thing that takes this lock, so misfiling it under the reader
|
|
/// would corrupt the very instrument the renderer's budget is judged by --
|
|
/// and no fixture noticed until Sami's T4.
|
|
#[test]
|
|
fn a_snapshot_is_billed_to_the_renderer_plane() {
|
|
let (shared, _actions) = terminal(20, 4);
|
|
shared.feed_fully(b"content");
|
|
|
|
shared.reader_acquire().reset();
|
|
shared.renderer_acquire().reset();
|
|
|
|
let mut attaching = Encoder::new();
|
|
let _ = shared.snapshot(&mut attaching);
|
|
|
|
assert_eq!(
|
|
shared.renderer_acquire().snapshot().acquisitions,
|
|
1,
|
|
"the snapshot's lock acquisition belongs to the renderer plane"
|
|
);
|
|
assert_eq!(
|
|
shared.reader_acquire().snapshot().acquisitions,
|
|
0,
|
|
"a full-grid copy must not be charged to the reader plane"
|
|
);
|
|
}
|
|
|
|
/// 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_fully(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"
|
|
);
|
|
}
|