mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
merge: mount terminal frontend on final runtime
* commit 'e1eaf88b0d7c9b2f9922ffea6a2cbd47682e746d': docs(terminal): cite the discriminating tail payload docs(terminal): say why tail_full cannot fire, not just that nothing calls it test(terminal): require the runtime to pump deferred work docs(terminal): say that the tail-depth signals have no consumer yet docs(terminal): repoint the links the slice_bytes deletion broke test(terminal): reject stop before child reap refactor(terminal): delete the slice-sizing function nothing calls test(terminal): make the decrease and RIS arms assert what they claim fix(terminal): retain the scrollback debt a shrink does not immediately repay fix(terminal): repair three defects the gate found in the work-bounded seam fix(terminal): raw-drain while child exits feat(terminal): bound the lock hold by weighted work, not by bytes test(terminal): close the review gaps in the cluster and snapshot contracts feat(terminal): give an attaching subscriber the screen as it stands feat(terminal): give the renderer each cluster's true column Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz> Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
This commit is contained in:
commit
4e4c7495bf
@@ -381,7 +381,14 @@ fn spans(cells: &[Cell]) -> Vec<Span> {
|
||||
}
|
||||
open = joinable;
|
||||
}
|
||||
debug_assert!(
|
||||
// Enforced in release, not just in debug. This is a *wire* invariant: a
|
||||
// span that violates it is undecodable by the rule in [`Span`], and the
|
||||
// consumer's failure is silent misplacement of every cluster after it.
|
||||
// A `debug_assert` here would vanish in exactly the build where that
|
||||
// corruption ships. The cost is one pass over text already in cache --
|
||||
// the same order as building the spans -- and it buys a loud, local
|
||||
// failure instead of a renderer quietly drawing the wrong columns.
|
||||
assert!(
|
||||
spans.iter().all(Span::counts_are_consistent),
|
||||
"cluster_count must be 1 or the span's char count"
|
||||
);
|
||||
|
||||
@@ -21,6 +21,139 @@ pub const SYNC_CAP: usize = 64 << 10;
|
||||
/// Max parser-visible bytes chargeable before the parser is rebuilt.
|
||||
pub const OSC_BUDGET: usize = 256 << 10;
|
||||
|
||||
/// Max cost-weighted work one [`crate::reader::Feeder::drain`] may spend
|
||||
/// before returning, in cell-equivalents.
|
||||
///
|
||||
/// Derived, not chosen: measured worst-case density across the 2-D op sweep
|
||||
/// is 16.9 ns/work (`erase_chars` at N=1, 80x24 -- the cheapest real callback,
|
||||
/// where fixed dispatch cost dominates the single cell it touches), so a
|
||||
/// 16.67 ms frame is ~988_000 work units. This is a quarter of that. The
|
||||
/// remaining three quarters are headroom for lock acquisition, the counting
|
||||
/// wrapper's own bookkeeping, and platforms slower than the one measured;
|
||||
/// 16.9 ns/work is the max of a sample, not a proven ceiling, so it is not
|
||||
/// spent to the last unit.
|
||||
pub const WORK_BUDGET: u64 = 250_000;
|
||||
|
||||
/// Widest slice handed to the parser at once.
|
||||
///
|
||||
/// The floor is 1 byte and lives in [`slice_bytes_remaining`] rather than
|
||||
/// here: on a grid whose worst atom exceeds the whole budget -- RIS at any
|
||||
/// real scrollback depth -- no wider slice can promise to stop after the
|
||||
/// callback that crosses. This cap is the other end, set at the throughput
|
||||
/// plateau: plain-char parsing saturates by 64 bytes and is flat to 64 KiB
|
||||
/// measured, so nothing above it buys anything and a larger value only
|
||||
/// coarsens the cut.
|
||||
pub const MAX_SLICE: usize = 256;
|
||||
|
||||
/// Bytes to hand the parser next.
|
||||
///
|
||||
/// The **only** slice-sizing function, deliberately: an earlier version of
|
||||
/// this module also exported a `slice_bytes(columns, lines, scrollback)` that
|
||||
/// the scheduler stopped calling when slices became remaining-aware, and the
|
||||
/// fixtures went on asserting against it. The two disagreed exactly where the
|
||||
/// floor bound -- reporting 4 where the engine used 1 -- so the preconditions
|
||||
/// were describing a function no longer in the path. One function, one
|
||||
/// answer, and every test asserts on what `drain` actually calls.
|
||||
///
|
||||
/// The rule: a slice of `N` bytes holds at most `N / atom_bytes` atoms, so
|
||||
/// `remaining / densest` bytes cannot carry a drain past the budget.
|
||||
///
|
||||
/// `next_escape` is how far the next `ESC` is from the front of the tail.
|
||||
/// This is the difference between a correct bound and an unusable one. Only
|
||||
/// an escape can buy grid-sized work in two bytes; a run of ordinary
|
||||
/// characters costs at most `columns` per byte (a wrapping line feed that
|
||||
/// scrolls), which is four orders of magnitude cheaper than RIS. Pricing
|
||||
/// plain text as though every byte might be RIS drops throughput from
|
||||
/// 181 MB/s to 69 MB/s at the default scrollback -- measured -- while
|
||||
/// bounding something that cannot happen. So a plain run is sliced against
|
||||
/// the plain-byte cost and only the escape itself is metered against the
|
||||
/// worst atom.
|
||||
pub fn slice_bytes_remaining(
|
||||
columns: usize,
|
||||
lines: usize,
|
||||
scrollback: usize,
|
||||
spent: u64,
|
||||
next_escape: usize,
|
||||
) -> usize {
|
||||
let remaining = WORK_BUDGET.saturating_sub(spent);
|
||||
if next_escape > 0 {
|
||||
// A plain run, and it stops at the escape: an escape sharing a slice
|
||||
// with the text in front of it is how a callback runs *after* the one
|
||||
// that crossed the budget, which is the overrun this bound exists to
|
||||
// prevent. Worst case per plain byte is a line feed that scrolls,
|
||||
// which resets one row: `columns`.
|
||||
let per_byte = (columns as u64).max(1);
|
||||
return ((remaining / per_byte) as usize).clamp(1, next_escape.min(MAX_SLICE));
|
||||
}
|
||||
// An escape starts here. `ESC c` is the densest at two bytes.
|
||||
let densest = (max_atom_work(columns, lines, scrollback) / 2).max(1);
|
||||
((remaining / densest) as usize).clamp(1, MAX_SLICE)
|
||||
}
|
||||
|
||||
/// Work the single worst uninterruptible callback can cost on this grid.
|
||||
///
|
||||
/// This is the irreducible overrun past [`WORK_BUDGET`]: no scheduler outside
|
||||
/// the parser can cut inside a callback, so a caller converting a work budget
|
||||
/// into a time bound must add it.
|
||||
///
|
||||
/// It is `columns` because [`crate::units::Counting`] terminates CBT at its
|
||||
/// first fixed point. Upstream's own loop is `N x columns` -- 82 ms for eight
|
||||
/// bytes at 1600 columns -- and clamping `N` to `columns` only brings that to
|
||||
/// `columns^2`, which at 1600 is 2.56M work, **10x the whole budget**: the
|
||||
/// atom, not the budget, would decide the bound. Stopping at the fixed point
|
||||
/// makes it `columns`, and the budget goes back to being the thing that sets
|
||||
/// the bound. Every other callback is priced at or below `cells`, which is
|
||||
/// larger, so this term never dominates.
|
||||
pub fn max_atom_work(columns: usize, lines: usize, scrollback: usize) -> u64 {
|
||||
// RIS: both grids plus the primary's configured scrollback. This is the
|
||||
// largest single callback by a wide margin -- 16x the budget at the
|
||||
// default 10k depth -- and it is genuinely indivisible, so it is stated
|
||||
// rather than smoothed. CBT, once terminated at its fixed point, is
|
||||
// `columns` and never competes.
|
||||
//
|
||||
// Saturating, and widened to u64 *before* multiplying. `Size` fields are
|
||||
// unclamped `usize` with no caller bounding them, so the products here
|
||||
// are reachable overflows: in debug that is a panic in the accounting
|
||||
// path, and in release it wraps to a small number, which understates the
|
||||
// bound -- an overflow that reports the parser as cheap is the worst of
|
||||
// the three outcomes.
|
||||
let (columns, lines, scrollback) = (columns as u64, lines as u64, scrollback as u64);
|
||||
let both_grids = columns.saturating_mul(lines).saturating_mul(2);
|
||||
let history = scrollback.saturating_mul(columns);
|
||||
both_grids.saturating_add(history).max(columns)
|
||||
}
|
||||
|
||||
/// Upper bound on the work a single [`crate::reader::Feeder::drain`] can do.
|
||||
///
|
||||
/// Two irreducible terms on top of [`WORK_BUDGET`], and it is worth being
|
||||
/// exact about which is which, because I got this wrong first and the
|
||||
/// fixtures caught it:
|
||||
///
|
||||
/// * The budget is checked *between* slices, so a drain overshoots by up to
|
||||
/// one whole slice -- not one atom. [`slice_bytes_remaining`] keeps that
|
||||
/// under one budget wherever its derivation is unclamped.
|
||||
/// * A callback already running cannot be preempted. RIS at the default 10k
|
||||
/// scrollback is worth 16x the whole budget on its own, so on such a grid
|
||||
/// the floor binds and the overshoot is a few of those atoms. No scheduler
|
||||
/// outside the parser can fix that -- what it can do is *report* it, which
|
||||
/// is why this is a function callers can read rather than an assumption
|
||||
/// they inherit.
|
||||
pub fn max_drain_work(columns: usize, lines: usize, scrollback: usize) -> u64 {
|
||||
// One atom, not one slice: [`crate::reader::Feeder::drain`] sizes every
|
||||
// slice against the *remaining* budget, so it cannot start a slice able
|
||||
// to hold more work than is left. What it cannot do is preempt a callback
|
||||
// that has begun, which is where this term comes from.
|
||||
WORK_BUDGET.saturating_add(max_atom_work(columns, lines, scrollback))
|
||||
}
|
||||
|
||||
/// Max bytes that may sit unparsed before the reader must stop reading the
|
||||
/// PTY. Bounds the *queue*; [`WORK_BUDGET`] bounds only the lock hold.
|
||||
pub const TAIL_CAP: usize = 4 << 20;
|
||||
|
||||
/// Depth at which a paused reader may resume. Strictly below [`TAIL_CAP`] so
|
||||
/// the reader does not flap between full and one-byte-below-full.
|
||||
pub const TAIL_RESUME: usize = 1 << 20;
|
||||
|
||||
/// Which fences are active. Both on in production.
|
||||
///
|
||||
/// The mutation law requires exercising each fence with the other **disabled**,
|
||||
@@ -87,6 +220,37 @@ pub struct FenceStats {
|
||||
/// Parser-visible bytes charged against the F2 budget, cumulative across
|
||||
/// resets. Includes every flush route, not just directly-advanced input.
|
||||
pub charged_bytes: u64,
|
||||
/// Parser units completed: one per `Handler` callback dispatched, which is
|
||||
/// one per fully-parsed escape sequence or printed character.
|
||||
///
|
||||
/// Separate from `charged_bytes` because they answer different questions
|
||||
/// and can disagree by orders of magnitude: four bytes of `ESC#8` rewrite
|
||||
/// the whole grid, four bytes of `ESC[m` set a flag. Bytes bound memory;
|
||||
/// units are the proxy for time. See [`crate::units`].
|
||||
pub completed_units: u64,
|
||||
/// Cost-weighted work completed, in cell-equivalents: an O(cells) callback
|
||||
/// charges `columns * lines`, an O(1) callback charges 1.
|
||||
///
|
||||
/// Deliberately a second number rather than a replacement for
|
||||
/// `completed_units`. They answer different questions -- "how many things
|
||||
/// happened" versus "how much did they cost" -- and a stream of `ESC#8`
|
||||
/// makes them disagree by four orders of magnitude, which is the entire
|
||||
/// reason this fence exists.
|
||||
pub completed_work: u64,
|
||||
/// Deepest the unparsed tail has been, in bytes. The high-water mark
|
||||
/// rather than the current depth, because the current depth is zero again
|
||||
/// by the time a test looks at it.
|
||||
pub max_pending: usize,
|
||||
/// Times the tail was at or over [`TAIL_CAP`] at the end of a drain.
|
||||
///
|
||||
/// Loud on purpose. Reaching the cap means the reader kept reading past
|
||||
/// the point it was told to stop, so the queue bound is being held by
|
||||
/// nothing; a silent cap would make that indistinguishable from a reader
|
||||
/// that is obeying.
|
||||
pub tail_breaches: u64,
|
||||
/// Bytes discarded unparsed by [`crate::reader::Feeder::abandon_tail`].
|
||||
/// Non-zero anywhere but session close is a bug that ate output.
|
||||
pub abandoned_bytes: u64,
|
||||
}
|
||||
|
||||
impl FenceStats {
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod path;
|
||||
pub mod reader;
|
||||
pub mod shared;
|
||||
pub mod shell;
|
||||
pub mod units;
|
||||
|
||||
#[cfg(test)]
|
||||
mod context_tests;
|
||||
@@ -112,7 +113,12 @@ impl Terminal {
|
||||
(
|
||||
Self {
|
||||
term,
|
||||
feeder: reader::Feeder::new(fences),
|
||||
feeder: reader::Feeder::new(
|
||||
fences,
|
||||
size.columns,
|
||||
size.screen_lines,
|
||||
size.scrollback,
|
||||
),
|
||||
size,
|
||||
generation: 0,
|
||||
},
|
||||
@@ -121,8 +127,60 @@ impl Terminal {
|
||||
}
|
||||
|
||||
/// Feed PTY output through the fences into the emulator.
|
||||
pub fn feed(&mut self, bytes: &[u8]) {
|
||||
self.feeder.feed(&mut self.term, bytes);
|
||||
///
|
||||
/// Parses what one work budget affords and returns with the rest held as
|
||||
/// a pending tail, so one call cannot hold the terminal for an unbounded
|
||||
/// time. **The caller must pump [`Terminal::drain`] until it returns
|
||||
/// false**, releasing the lock between calls; that is the whole point --
|
||||
/// the tail exists to give the renderer a chance at the lock, not to defer
|
||||
/// work indefinitely. [`Terminal::pending_bytes`] and
|
||||
/// [`Terminal::tail_full`] tell the reader when to stop reading the PTY.
|
||||
pub fn feed(&mut self, bytes: &[u8]) -> bool {
|
||||
self.feeder.feed(&mut self.term, bytes)
|
||||
}
|
||||
|
||||
/// Parse more of the pending tail. Returns whether any remains.
|
||||
pub fn drain(&mut self) -> bool {
|
||||
self.feeder.drain(&mut self.term);
|
||||
self.feeder.pending_bytes() > 0
|
||||
}
|
||||
|
||||
/// Feed and parse to completion, without the intervening lock releases.
|
||||
///
|
||||
/// For tests and for callers with no renderer contending -- it reinstates
|
||||
/// exactly the unbounded hold [`Terminal::feed`] exists to prevent, so it
|
||||
/// is deliberately a separate name rather than a flag on `feed`.
|
||||
pub fn feed_fully(&mut self, bytes: &[u8]) {
|
||||
self.feed(bytes);
|
||||
while self.drain() {}
|
||||
}
|
||||
|
||||
/// Bytes accepted but not yet parsed.
|
||||
pub fn pending_bytes(&self) -> usize {
|
||||
self.feeder.pending_bytes()
|
||||
}
|
||||
|
||||
/// Whether the tail is at its cap and the reader must stop reading.
|
||||
/// See [`reader::Feeder::tail_full`] for why production deliberately has
|
||||
/// no consumer yet.
|
||||
///
|
||||
/// There is no production consumer today, deliberately: the desktop
|
||||
/// runtime pumps `drain()` to completion after every read, so the tail is
|
||||
/// empty between iterations. A future reader that defers pumping must
|
||||
/// consult this signal before accepting more PTY bytes.
|
||||
pub fn tail_full(&self) -> bool {
|
||||
self.feeder.tail_full()
|
||||
}
|
||||
|
||||
/// Whether a paused reader may resume.
|
||||
pub fn tail_drained(&self) -> bool {
|
||||
self.feeder.tail_drained()
|
||||
}
|
||||
|
||||
/// Discard the unparsed tail. Session close only -- see
|
||||
/// [`reader::Feeder::abandon_tail`].
|
||||
pub fn abandon_tail(&mut self) -> usize {
|
||||
self.feeder.abandon_tail()
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> FenceStats {
|
||||
@@ -173,6 +231,7 @@ impl Terminal {
|
||||
return self.viewport();
|
||||
}
|
||||
self.term.resize(size);
|
||||
self.feeder.resize(size);
|
||||
self.size = size;
|
||||
self.generation += 1;
|
||||
self.viewport()
|
||||
|
||||
@@ -232,7 +232,13 @@ pub const MAX_LIVE_SESSIONS: usize = 20;
|
||||
/// drained. Join the reader first and every tab close pays that, on the arm
|
||||
/// where the user is already waiting.
|
||||
pub trait DrainingReader {
|
||||
/// Stops consuming and releases the reader. Called *after* reap.
|
||||
/// Detach parser work and enter raw-drain mode before child termination.
|
||||
fn begin_closing(&self);
|
||||
|
||||
/// Wake a reader that remains blocked after the child has been reaped.
|
||||
fn stop(&self);
|
||||
|
||||
/// Releases the reader thread. Called only after [`DrainingReader::stop`].
|
||||
fn join(self: Box<Self>);
|
||||
}
|
||||
|
||||
@@ -249,11 +255,13 @@ pub fn shutdown_draining(
|
||||
child: &mut Box<dyn Child + Send + Sync>,
|
||||
reader: Box<dyn DrainingReader>,
|
||||
) -> io::Result<Shutdown> {
|
||||
reader.begin_closing();
|
||||
// Terminate and reap with the reader still running, so the child never
|
||||
// blocks in a tty write while we are waiting on it.
|
||||
let outcome = shutdown(child);
|
||||
// Unconditional: the reader must be released whether or not the shutdown
|
||||
// reported an error, or a failed close leaks the thread and its fd.
|
||||
// Unconditional: wake and release the reader whether or not shutdown
|
||||
// reported an error. EOF may already have ended it; stop is idempotent.
|
||||
reader.stop();
|
||||
reader.join();
|
||||
outcome
|
||||
}
|
||||
|
||||
@@ -409,10 +409,10 @@ fn tempdir(name: &str) -> std::path::PathBuf {
|
||||
/// Mari's noisy-child discriminator: the reader must still be draining
|
||||
/// **while** the child is being terminated and reaped.
|
||||
///
|
||||
/// The property is an ordering, and an ordering cannot be observed from the
|
||||
/// outcome -- both orders end with a dead child and a stopped reader. So the
|
||||
/// reader records *when* it read, relative to the moment close begins, and the
|
||||
/// assertion is on bytes drained after that moment.
|
||||
/// The portable contract is structural: `stop` must not be requested until
|
||||
/// the child has been reaped. The recording reader checks the child PID at the
|
||||
/// `stop` call, while the continuously noisy PTY makes the test exercise a
|
||||
/// reader that is genuinely active rather than a quiet no-op.
|
||||
///
|
||||
/// The child is deliberately noisy: it floods the PTY continuously, so a
|
||||
/// master that stops being read fills its kernel buffer within milliseconds
|
||||
@@ -427,19 +427,18 @@ fn reader_drains_through_termination_and_reap() {
|
||||
// process dies, so there is always more output pending than the buffer
|
||||
// holds.
|
||||
let mut child = spawn_noisy(&pair);
|
||||
let pid = child.process_id().expect("pid") as i32;
|
||||
|
||||
let after_close = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let closing = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let reader = RecordingReader::spawn(&pair, after_close.clone(), closing.clone());
|
||||
let order = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let reader = RecordingReader::spawn(&pair, pid, order.clone());
|
||||
|
||||
// Let the child get well ahead of the reader before we touch anything.
|
||||
// Establish that this is a live draining reader, not a quiet fixture.
|
||||
assert!(
|
||||
poll_until(|| after_close.load(Ordering::Relaxed) == 0 && reader.total_bytes() > 4096),
|
||||
poll_until(|| reader.total_bytes() > 4096),
|
||||
"test setup: the child is not producing enough output to fill the pty \
|
||||
buffer, so this cannot distinguish drain order"
|
||||
);
|
||||
|
||||
closing.store(true, Ordering::Relaxed);
|
||||
let started = Instant::now();
|
||||
let outcome = shutdown_draining(&mut child, Box::new(reader)).expect("shutdown");
|
||||
let elapsed = started.elapsed();
|
||||
@@ -450,17 +449,17 @@ fn reader_drains_through_termination_and_reap() {
|
||||
"a `yes` pipeline dies on SIGTERM; SIGKILL here means it was wedged in \
|
||||
a tty write against an undrained master"
|
||||
);
|
||||
assert!(
|
||||
after_close.load(Ordering::Relaxed) > 0,
|
||||
"the reader drained nothing after close began -- it was joined before \
|
||||
termination, which is the ordering the drain law forbids"
|
||||
);
|
||||
assert!(
|
||||
elapsed < TERM_GRACE,
|
||||
"shutdown took {elapsed:?}, at or beyond the {TERM_GRACE:?} grace \
|
||||
period: the child was blocked writing to an undrained master rather \
|
||||
than exiting on SIGTERM"
|
||||
);
|
||||
assert_eq!(
|
||||
*order.lock().unwrap(),
|
||||
["begin_closing", "stop", "join"],
|
||||
"reader close must begin before termination and stop/join only after reap"
|
||||
);
|
||||
}
|
||||
|
||||
/// Spawns a child that floods the PTY without pause.
|
||||
@@ -476,15 +475,17 @@ fn spawn_noisy(pair: &PtyPair) -> Box<dyn Child + Send + Sync> {
|
||||
|
||||
/// A [`DrainingReader`] that records how much it read after close began.
|
||||
struct RecordingReader {
|
||||
pid: i32,
|
||||
total: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
handle: std::thread::JoinHandle<()>,
|
||||
order: std::sync::Arc<std::sync::Mutex<Vec<&'static str>>>,
|
||||
}
|
||||
|
||||
impl RecordingReader {
|
||||
fn spawn(
|
||||
pair: &PtyPair,
|
||||
after_close: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
closing: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
pid: i32,
|
||||
order: std::sync::Arc<std::sync::Mutex<Vec<&'static str>>>,
|
||||
) -> Self {
|
||||
let mut reader = pair.master.try_clone_reader().expect("reader");
|
||||
let total = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
@@ -497,12 +498,14 @@ impl RecordingReader {
|
||||
break;
|
||||
}
|
||||
counter.fetch_add(n as u64, Ordering::Relaxed);
|
||||
if closing.load(Ordering::Relaxed) {
|
||||
after_close.fetch_add(n as u64, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
});
|
||||
Self { total, handle }
|
||||
Self {
|
||||
pid,
|
||||
total,
|
||||
handle,
|
||||
order,
|
||||
}
|
||||
}
|
||||
|
||||
fn total_bytes(&self) -> u64 {
|
||||
@@ -511,7 +514,20 @@ impl RecordingReader {
|
||||
}
|
||||
|
||||
impl DrainingReader for RecordingReader {
|
||||
fn begin_closing(&self) {
|
||||
self.order.lock().unwrap().push("begin_closing");
|
||||
}
|
||||
|
||||
fn stop(&self) {
|
||||
assert!(
|
||||
!pid_alive(self.pid),
|
||||
"reader stop must not be requested before the child is reaped"
|
||||
);
|
||||
self.order.lock().unwrap().push("stop");
|
||||
}
|
||||
|
||||
fn join(self: Box<Self>) {
|
||||
self.order.lock().unwrap().push("join");
|
||||
// Bounded, and that is the whole point. The read loop ends when the
|
||||
// master reports EOF, which only happens once the reaped child has
|
||||
// released the slave -- so joining *before* termination blocks
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
|
||||
use alacritty_terminal::vte::ansi::{Handler, Processor, StdSyncHandler};
|
||||
|
||||
use crate::fences::{FenceStats, Fences, OSC_BUDGET, SYNC_CAP};
|
||||
use crate::fences::{
|
||||
slice_bytes_remaining, FenceStats, Fences, MAX_SLICE, OSC_BUDGET, SYNC_CAP, TAIL_CAP,
|
||||
TAIL_RESUME, WORK_BUDGET,
|
||||
};
|
||||
use crate::units::{Counting, CursorColumn};
|
||||
|
||||
/// Owns the parser and enforces F1/F2 on every byte fed to it.
|
||||
pub struct Feeder {
|
||||
@@ -15,18 +19,73 @@ pub struct Feeder {
|
||||
stats: FenceStats,
|
||||
/// Bytes charged since the last F2 reset.
|
||||
since_reset: usize,
|
||||
/// Bytes accepted but not yet parsed. Grows when arrival outruns
|
||||
/// retirement; drained by every [`Feeder::feed`] and [`Feeder::drain`].
|
||||
pending: Vec<u8>,
|
||||
/// How much of `pending` has already been parsed. Kept as an index rather
|
||||
/// than draining the front on every slice, so a large tail is not
|
||||
/// re-shuffled once per slice; the prefix is dropped in one go on the next
|
||||
/// enqueue.
|
||||
pending_at: usize,
|
||||
/// The grid the weights are computed against. Tracked here rather than
|
||||
/// read from the `Term` because `feed` only has the handler, and kept in
|
||||
/// sync by [`Feeder::resize`]: a stale grid misprices every O(cells)
|
||||
/// callback for as long as it is wrong.
|
||||
columns: usize,
|
||||
lines: usize,
|
||||
/// Whether the parser is part-way through an escape sequence that has not
|
||||
/// yet dispatched. Governs how the next slice is metered -- see
|
||||
/// [`crate::fences::slice_bytes_remaining`].
|
||||
mid_escape: bool,
|
||||
/// Deepest scrollback this feeder has ever been configured for.
|
||||
///
|
||||
/// A high-water mark rather than the current depth, and the difference is
|
||||
/// not conservatism for its own sake -- the rows are still there. Upstream
|
||||
/// frees history lazily: `Storage::shrink_lines` truncates only once the
|
||||
/// buffer exceeds the new length by `MAX_CACHE_SIZE`, so immediately after
|
||||
/// a decrease the grid still owns rows that a reset must walk. Pricing at
|
||||
/// the new depth would charge for a grid that does not exist yet.
|
||||
///
|
||||
/// Never lowered, so it needs no clearing transition and cannot go stale
|
||||
/// in the unsafe direction. The cost is that a session which shrinks its
|
||||
/// scrollback keeps paying the deep price for the rest of its life; the
|
||||
/// alternative is a bound that is wrong immediately after every shrink.
|
||||
scrollback: usize,
|
||||
}
|
||||
|
||||
impl Feeder {
|
||||
pub fn new(fences: Fences) -> Self {
|
||||
pub fn new(fences: Fences, columns: usize, lines: usize, scrollback: usize) -> Self {
|
||||
Self {
|
||||
parser: Processor::new(),
|
||||
fences,
|
||||
stats: FenceStats::default(),
|
||||
since_reset: 0,
|
||||
pending: Vec::new(),
|
||||
pending_at: 0,
|
||||
mid_escape: false,
|
||||
columns,
|
||||
lines,
|
||||
scrollback,
|
||||
}
|
||||
}
|
||||
|
||||
/// Track a geometry change, so the cost weights describe the current grid.
|
||||
///
|
||||
/// Takes the whole [`crate::Size`] rather than a column/line pair on
|
||||
/// purpose. Scrollback is as load-bearing as the other two -- it is most
|
||||
/// of RIS's price and therefore most of the slice derivation -- and a
|
||||
/// signature that accepted only the dimensions let a caller change the
|
||||
/// depth on the `Term` while the feeder kept charging the construction
|
||||
/// value. One argument, one ownership boundary, no way to update two of
|
||||
/// three.
|
||||
pub fn resize(&mut self, size: crate::Size) {
|
||||
self.columns = size.columns;
|
||||
self.lines = size.screen_lines;
|
||||
// Grows only. See the field: a decrease does not immediately free the
|
||||
// rows a reset has to walk.
|
||||
self.scrollback = self.scrollback.max(size.scrollback);
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> FenceStats {
|
||||
self.stats
|
||||
}
|
||||
@@ -40,10 +99,188 @@ impl Feeder {
|
||||
self.parser.sync_bytes_count()
|
||||
}
|
||||
|
||||
/// Feed one chunk of PTY output to the parser, applying both fences.
|
||||
pub fn feed<H: Handler>(&mut self, handler: &mut H, bytes: &[u8]) {
|
||||
/// Bytes accepted but not yet parsed, because a previous [`Feeder::feed`]
|
||||
/// spent its work budget before reaching them.
|
||||
pub fn pending_bytes(&self) -> usize {
|
||||
self.pending.len() - self.pending_at
|
||||
}
|
||||
|
||||
/// Whether the pending tail has reached [`TAIL_CAP`].
|
||||
///
|
||||
/// Deliberately derived from the current depth rather than latched. A
|
||||
/// latch is a state the fence owns and could fail to clear, which is
|
||||
/// exactly how a paused reader strands a child mid-teardown; a reader that
|
||||
/// simply stops asking resumes by default.
|
||||
///
|
||||
/// **No production consumer today, and not an oversight.** The runtime
|
||||
/// reader pumps [`Feeder::drain`] to completion after every read
|
||||
/// (`terminal_runtime.rs`), so the tail is empty between iterations and
|
||||
/// this can never go true -- measured 0 bytes high-water against 8 MiB of
|
||||
/// pure RIS, the densest atom there is. It exists for a future reader
|
||||
/// that defers pumping, and such a reader **must** consult it: without
|
||||
/// the pump loop the same stream reaches [`TAIL_CAP`] in 257 reads of
|
||||
/// 16 KiB.
|
||||
///
|
||||
/// The numbers are here rather than "nothing calls this" because the
|
||||
/// signal and the loop are one fact from two sides. Delete the loop and
|
||||
/// this predicate stops being unreachable in the same instant it starts
|
||||
/// being needed.
|
||||
pub fn tail_full(&self) -> bool {
|
||||
self.pending_bytes() >= TAIL_CAP
|
||||
}
|
||||
|
||||
/// Whether a paused reader may resume: the tail has drained to the low
|
||||
/// water mark. Separate from `!tail_full()` so the reader does not flap
|
||||
/// between full and one-byte-below-full.
|
||||
pub fn tail_drained(&self) -> bool {
|
||||
self.pending_bytes() <= TAIL_RESUME
|
||||
}
|
||||
|
||||
/// Discard the unparsed tail.
|
||||
///
|
||||
/// For session close only, and lossless where it is used: publication is
|
||||
/// detached before shutdown drains, so this tail is bytes no renderer can
|
||||
/// consume. Draining the *PTY* remains lifecycle-critical -- this exists so
|
||||
/// parser work cannot hold teardown behind it.
|
||||
pub fn abandon_tail(&mut self) -> usize {
|
||||
let abandoned = self.pending_bytes();
|
||||
self.pending.clear();
|
||||
self.pending_at = 0;
|
||||
self.stats.abandoned_bytes += abandoned as u64;
|
||||
abandoned
|
||||
}
|
||||
|
||||
/// Accept PTY output and parse what fits in one work budget.
|
||||
///
|
||||
/// Returns whether bytes remain unparsed. Bytes beyond the budget are
|
||||
/// retained and parsed by [`Feeder::drain`], so this bounds the *lock
|
||||
/// hold*; it does not bound the queue. When arrival outruns retirement
|
||||
/// the tail grows to [`TAIL_CAP`] and [`Feeder::tail_full`] goes true,
|
||||
/// which is the reader's cue to stop reading the PTY and let the child
|
||||
/// block. No policy is applied here: a fence that dropped input to protect
|
||||
/// itself would corrupt the screen to avoid being slow.
|
||||
pub fn feed<H: Handler + CursorColumn>(&mut self, handler: &mut H, bytes: &[u8]) -> bool {
|
||||
self.enqueue(bytes);
|
||||
self.drain(handler);
|
||||
self.pending_bytes() > 0
|
||||
}
|
||||
|
||||
/// Append to the pending tail, compacting the already-parsed prefix first.
|
||||
fn enqueue(&mut self, bytes: &[u8]) {
|
||||
if self.pending_at > 0 {
|
||||
self.pending.drain(..self.pending_at);
|
||||
self.pending_at = 0;
|
||||
}
|
||||
self.pending.extend_from_slice(bytes);
|
||||
}
|
||||
|
||||
/// Parse from the pending tail until the work budget is spent.
|
||||
///
|
||||
/// The budget is checked between parser slices, never inside a callback:
|
||||
/// vte's own mid-buffer stop is driven by `Perform::terminated()`, whose
|
||||
/// implementor in the ansi layer is private, so the cut has to be made
|
||||
/// from outside, and a callback already running cannot be preempted at
|
||||
/// all. One atom is therefore the irreducible overrun -- and it is not
|
||||
/// small: `ESC[65535Z` with tabstops cleared is 8 bytes and 82 ms at 1600
|
||||
/// columns, because upstream's `move_backward_tabs` rescans the row once
|
||||
/// per count when it finds no stop.
|
||||
///
|
||||
/// What *is* bounded is the number of atoms per slice, and that bound
|
||||
/// holds from the first byte of a cold feeder: [`slice_bytes_remaining`] is derived
|
||||
/// from the densest work-per-byte upstream can produce on this grid, so
|
||||
/// no slice can contain more than one budget's worth of callbacks no
|
||||
/// matter what the payload is or what the feeder has seen before.
|
||||
///
|
||||
/// Returns the work spent, which is at least the budget whenever the tail
|
||||
/// is still non-empty on return.
|
||||
pub fn drain<H: Handler + CursorColumn>(&mut self, handler: &mut H) -> u64 {
|
||||
// Slices are copied out of the tail rather than borrowed from it,
|
||||
// because `advance_slice` needs `&mut self` and the tail is part of
|
||||
// self. A stack buffer keeps that from allocating; the copy is a
|
||||
// memcpy against a parse two orders of magnitude more expensive.
|
||||
let mut buf = [0u8; MAX_SLICE];
|
||||
let mut spent: u64 = 0;
|
||||
while self.pending_at < self.pending.len() {
|
||||
// Size each slice against what is *left* of the budget, and
|
||||
// against what is actually in front of the parser. A slice can
|
||||
// only be as expensive as the callbacks it contains, and only an
|
||||
// escape can buy grid-sized work in two bytes -- so a plain run
|
||||
// is sliced against the plain-byte cost and stops at the next
|
||||
// `ESC`, which then gets a slice metered against the worst atom.
|
||||
// The drain therefore returns on the atom that crosses the
|
||||
// budget, not at the end of a slice that ran several more.
|
||||
//
|
||||
// Where one atom is worth more than the entire budget -- RIS at
|
||||
// any real scrollback depth -- that escape gets a one-byte slice.
|
||||
// That is the honest consequence of the law: nothing wider can
|
||||
// promise to stop after the crossing atom when a single atom
|
||||
// always crosses.
|
||||
// A slice is never wider than MAX_SLICE, so the scan for the next
|
||||
// escape stops there too: searching the whole tail would be
|
||||
// O(tail) per slice and O(tail^2) per drain, which measured as a
|
||||
// 7x throughput *regression* on plain text -- a bound that costs
|
||||
// more than the thing it bounds.
|
||||
let horizon = (self.pending_at + MAX_SLICE).min(self.pending.len());
|
||||
let next_escape = if self.mid_escape {
|
||||
// Already inside a sequence whose callback has not fired. Its
|
||||
// remaining bytes are *not* plain text -- `ESC` then `c` is a
|
||||
// grid reset -- so they keep the escape's metering. Without
|
||||
// this the byte after a lone `ESC` is priced as a character
|
||||
// and the atom rides into a wide slice with whatever follows
|
||||
// it, which is the post-atom overrun by another door.
|
||||
0
|
||||
} else {
|
||||
self.pending[self.pending_at..horizon]
|
||||
.iter()
|
||||
.position(|&b| b == 0x1b)
|
||||
.unwrap_or(horizon - self.pending_at)
|
||||
};
|
||||
let width = slice_bytes_remaining(
|
||||
self.columns,
|
||||
self.lines,
|
||||
self.scrollback,
|
||||
spent,
|
||||
next_escape,
|
||||
);
|
||||
let end = (self.pending_at + width).min(self.pending.len());
|
||||
let len = end - self.pending_at;
|
||||
buf[..len].copy_from_slice(&self.pending[self.pending_at..end]);
|
||||
self.pending_at = end;
|
||||
let cost = self.advance_slice(handler, &buf[..len]);
|
||||
// A slice that contained an escape but dispatched nothing left the
|
||||
// parser mid-sequence. Work is the signal because it is the thing
|
||||
// being budgeted: a sequence that has not yet cost anything has
|
||||
// not yet run.
|
||||
self.mid_escape = (self.mid_escape || buf[..len].contains(&0x1b)) && cost == 0;
|
||||
spent = spent.saturating_add(cost);
|
||||
if spent >= WORK_BUDGET {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if self.pending_at == self.pending.len() {
|
||||
self.pending.clear();
|
||||
self.pending_at = 0;
|
||||
}
|
||||
let depth = self.pending_bytes();
|
||||
self.stats.max_pending = self.stats.max_pending.max(depth);
|
||||
if depth >= TAIL_CAP {
|
||||
self.stats.tail_breaches += 1;
|
||||
}
|
||||
spent
|
||||
}
|
||||
|
||||
/// Parse one slice, applying both fences to it. Returns the work it cost.
|
||||
fn advance_slice<H: Handler + CursorColumn>(&mut self, handler: &mut H, bytes: &[u8]) -> u64 {
|
||||
let mut spent: u64 = 0;
|
||||
let sync_before = self.parser.sync_bytes_count();
|
||||
self.parser.advance(handler, bytes);
|
||||
{
|
||||
let mut counting = Counting::new(handler, self.columns, self.lines, self.scrollback);
|
||||
self.parser.advance(&mut counting, bytes);
|
||||
self.stats.completed_units =
|
||||
self.stats.completed_units.saturating_add(counting.units());
|
||||
self.stats.completed_work = self.stats.completed_work.saturating_add(counting.work());
|
||||
spent = spent.saturating_add(counting.work());
|
||||
}
|
||||
let sync_after = self.parser.sync_bytes_count();
|
||||
|
||||
// Charge exactly the bytes the parser could see, by route:
|
||||
@@ -73,7 +310,19 @@ impl Feeder {
|
||||
// per breach; the released bytes are parser-visible and are charged.
|
||||
if self.fences.sync_abort && self.parser.sync_bytes_count() >= SYNC_CAP {
|
||||
let released = self.parser.sync_bytes_count();
|
||||
self.parser.stop_sync(handler);
|
||||
// Counted too: aborting flushes the buffered frame through the
|
||||
// handler, so these are units the lock hold paid for. Leaving them
|
||||
// out would undercount exactly on the fenced path.
|
||||
{
|
||||
let mut counting =
|
||||
Counting::new(handler, self.columns, self.lines, self.scrollback);
|
||||
self.parser.stop_sync(&mut counting);
|
||||
self.stats.completed_units =
|
||||
self.stats.completed_units.saturating_add(counting.units());
|
||||
self.stats.completed_work =
|
||||
self.stats.completed_work.saturating_add(counting.work());
|
||||
spent = spent.saturating_add(counting.work());
|
||||
}
|
||||
self.stats.sync_aborts += 1;
|
||||
self.note_release(released);
|
||||
self.charge(released);
|
||||
@@ -88,6 +337,8 @@ impl Feeder {
|
||||
self.stats.osc_resets += 1;
|
||||
self.since_reset = 0;
|
||||
}
|
||||
|
||||
spent
|
||||
}
|
||||
|
||||
fn charge(&mut self, bytes: usize) {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! metered separately: pooling them would let the reader's millions of fast
|
||||
//! acquires dilute the renderer's tail into a false pass.
|
||||
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use alacritty_terminal::sync::FairMutex;
|
||||
@@ -126,6 +126,7 @@ pub struct SharedTerminal {
|
||||
term: FairMutex<Terminal>,
|
||||
reader: AcquireMeter,
|
||||
renderer: AcquireMeter,
|
||||
closing: AtomicBool,
|
||||
}
|
||||
|
||||
impl SharedTerminal {
|
||||
@@ -134,6 +135,7 @@ impl SharedTerminal {
|
||||
term: FairMutex::new(term),
|
||||
reader: AcquireMeter::default(),
|
||||
renderer: AcquireMeter::default(),
|
||||
closing: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,8 +151,49 @@ impl SharedTerminal {
|
||||
}
|
||||
|
||||
/// Feed PTY output into the emulator. Reader plane.
|
||||
pub fn feed(&self, bytes: &[u8]) {
|
||||
self.acquire(&self.reader).feed(bytes);
|
||||
///
|
||||
/// Returns whether a tail remains: one acquisition parses one work
|
||||
/// budget, then **drops the lock** so the renderer can have it. The
|
||||
/// caller pumps [`SharedTerminal::drain`] until it returns false. Doing
|
||||
/// the whole buffer under one acquisition is what an unbounded hold *is*,
|
||||
/// so it is not offered here.
|
||||
pub fn feed(&self, bytes: &[u8]) -> bool {
|
||||
let mut term = self.acquire(&self.reader);
|
||||
if self.closing.load(Ordering::Acquire) {
|
||||
false
|
||||
} else {
|
||||
term.feed(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse more of the pending tail under a fresh acquisition. Reader
|
||||
/// plane. Returns whether any remains.
|
||||
pub fn drain(&self) -> bool {
|
||||
let mut term = self.acquire(&self.reader);
|
||||
if self.closing.load(Ordering::Acquire) {
|
||||
false
|
||||
} else {
|
||||
term.drain()
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed and pump to completion, re-acquiring between slices.
|
||||
pub fn feed_fully(&self, bytes: &[u8]) {
|
||||
let mut more = self.feed(bytes);
|
||||
while more {
|
||||
more = self.drain();
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically enter close mode and discard parser work. Subsequent PTY
|
||||
/// bytes are raw-drained by the embedder and never reach callbacks.
|
||||
pub fn begin_closing(&self) -> usize {
|
||||
self.closing.store(true, Ordering::Release);
|
||||
self.acquire(&self.reader).abandon_tail()
|
||||
}
|
||||
|
||||
pub fn is_closing(&self) -> bool {
|
||||
self.closing.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Sample damage and encode a frame. Renderer plane.
|
||||
@@ -216,3 +259,31 @@ impl SharedTerminal {
|
||||
guard
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Fences, Size};
|
||||
|
||||
#[test]
|
||||
fn closing_abandons_tail_and_permanently_refuses_parser_callbacks() {
|
||||
let (terminal, _actions) = Terminal::new(Size::default(), Fences::ALL);
|
||||
let shared = SharedTerminal::new(terminal);
|
||||
let payload = b"\x1b#8".repeat(10_000);
|
||||
|
||||
assert!(shared.feed(&payload), "fixture must create parser tail");
|
||||
let before = shared.lock().stats();
|
||||
let abandoned = shared.begin_closing();
|
||||
assert!(abandoned > 0, "close must abandon without draining first");
|
||||
assert!(shared.is_closing());
|
||||
|
||||
assert!(!shared.feed(b"parser callback after close"));
|
||||
assert!(!shared.drain());
|
||||
let after = shared.lock().stats();
|
||||
assert_eq!(after.completed_units, before.completed_units);
|
||||
assert_eq!(
|
||||
after.abandoned_bytes,
|
||||
before.abandoned_bytes + abandoned as u64
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
//! Counting what the parser *does*, not how many bytes it read.
|
||||
//!
|
||||
//! Both fences in [`crate::fences`] meter bytes. That is the right denominator
|
||||
//! for memory -- a buffer's size is bytes -- and the wrong one for time. `ESC[m`
|
||||
//! and `ESC#8` are four bytes each; the first sets an attribute and the second
|
||||
//! rewrites every cell of the grid. Metering the reader's lock hold in bytes
|
||||
//! therefore prices those identically, and a stream of the second one holds the
|
||||
//! lock for as long as it likes without ever tripping a byte budget.
|
||||
//!
|
||||
//! Measured: a DECALN flood at 200x50 reaches p95 65535us against a 4000us
|
||||
//! budget with **zero** F1 aborts -- the fence never fires, because nothing is
|
||||
//! buffered. Unfenced, one acquisition was observed at 22.1s, about 1300
|
||||
//! dropped frames in a single lock hold.
|
||||
//!
|
||||
//! So this module adds a third quantity: the number of *completed parser
|
||||
//! units* -- one per `Handler` callback the parser dispatches, which is one
|
||||
//! per fully-parsed escape sequence or printed character. It is a proxy for
|
||||
//! work rather than a measure of it, but it has the property the byte count
|
||||
//! lacks: it advances once per thing the emulator actually did.
|
||||
//!
|
||||
//! ## Why a wrapper, and not vte's own stopping point
|
||||
//!
|
||||
//! `Parser::advance_until_terminated` already supports stopping mid-buffer,
|
||||
//! but termination is driven by `Perform::terminated()`, and in the ansi layer
|
||||
//! the implementor is `Performer`, which is private (`vte-0.15.0/src/ansi.rs`:
|
||||
//! `struct Performer` at 425, `terminated` at 1825, set only for BSU handling).
|
||||
//! An embedder cannot reach it, so the stopping point has to be built outside
|
||||
//! the parser rather than inside it.
|
||||
//!
|
||||
//! ## What this deliberately does not do
|
||||
//!
|
||||
//! It does not skip or veto expensive callbacks once a budget is spent. That
|
||||
//! would bound the lock hold perfectly and silently corrupt the screen, which
|
||||
//! is a worse failure than the one being fixed: a slow terminal recovers, a
|
||||
//! wrong one does not. Every unit is delegated; the count only decides where
|
||||
//! the *caller* may cut the input.
|
||||
|
||||
use alacritty_terminal::event::EventListener;
|
||||
use alacritty_terminal::term::Term;
|
||||
use alacritty_terminal::vte::ansi::cursor_icon::CursorIcon;
|
||||
use alacritty_terminal::vte::ansi::{
|
||||
Attr, CharsetIndex, ClearMode, CursorShape, CursorStyle, Handler, Hyperlink, KeyboardModes,
|
||||
KeyboardModesApplyBehavior, LineClearMode, Mode, ModifyOtherKeys, PrivateMode, Rgb,
|
||||
ScpCharPath, ScpUpdateMode, StandardCharset, TabulationClearMode,
|
||||
};
|
||||
|
||||
/// Read access to the cursor column of whatever the wrapper is driving.
|
||||
///
|
||||
/// Exists for exactly one callback. CBT's cost is bounded by *cursor
|
||||
/// movement*, and the only way to charge it honestly -- or to stop it early
|
||||
/// -- is to watch the cursor between steps. Everything else in this module is
|
||||
/// priced from the grid alone, which is why this is a separate trait and a
|
||||
/// separate bound rather than a field on [`Counting`].
|
||||
///
|
||||
/// Implemented over the public path in `alacritty_terminal-0.26.0`:
|
||||
/// `Term::grid` (term/mod.rs:645) -> `Grid::cursor` (grid/mod.rs:113) ->
|
||||
/// `Cursor::point` (grid/mod.rs:36). No private field, no fork.
|
||||
pub trait CursorColumn {
|
||||
fn cursor_column(&self) -> usize;
|
||||
}
|
||||
|
||||
impl<L: EventListener> CursorColumn for Term<L> {
|
||||
#[inline]
|
||||
fn cursor_column(&self) -> usize {
|
||||
self.grid().cursor.point.column.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps a [`Handler`], forwarding every callback and counting them.
|
||||
///
|
||||
/// Every one of the trait's 71 methods has an empty default body upstream, so
|
||||
/// a method left undelegated here would compile cleanly and silently discard
|
||||
/// that escape sequence. The delegations are therefore generated by a macro
|
||||
/// over the full method list rather than written out: the failure mode of
|
||||
/// hand-copying is invisible.
|
||||
pub struct Counting<'a, H: Handler + CursorColumn> {
|
||||
inner: &'a mut H,
|
||||
/// Callbacks dispatched, one per unit regardless of cost. This is the
|
||||
/// fixture-facing number: it says what the parser *did*, and it is kept
|
||||
/// separate from `work` because collapsing them is precisely the mistake
|
||||
/// that made the first version of this seam useless.
|
||||
units: u64,
|
||||
/// Cost-weighted work, in cell-equivalents. This is the scheduling number.
|
||||
work: u64,
|
||||
columns: u64,
|
||||
lines: u64,
|
||||
/// Configured scrollback depth, not current fill. See `reset_state`.
|
||||
scrollback: u64,
|
||||
}
|
||||
|
||||
impl<'a, H: Handler + CursorColumn> Counting<'a, H> {
|
||||
/// `columns` and `lines` are the grid the handler is about to act on, and
|
||||
/// they are the weights' only input: an O(cells) callback is charged
|
||||
/// `columns * lines` because that is what it touches.
|
||||
pub fn new(inner: &'a mut H, columns: usize, lines: usize, scrollback: usize) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
units: 0,
|
||||
work: 0,
|
||||
columns: columns as u64,
|
||||
lines: lines as u64,
|
||||
scrollback: scrollback as u64,
|
||||
}
|
||||
}
|
||||
|
||||
/// Callbacks dispatched since this wrapper was created.
|
||||
pub fn units(&self) -> u64 {
|
||||
self.units
|
||||
}
|
||||
|
||||
/// Cost-weighted work dispatched, in cell-equivalents.
|
||||
pub fn work(&self) -> u64 {
|
||||
self.work
|
||||
}
|
||||
|
||||
/// Cells in the grid. Saturating: `Size` is unclamped `usize`, so this
|
||||
/// product is reachable, and a wrapped weight prices the most expensive
|
||||
/// callbacks as the cheapest.
|
||||
#[inline]
|
||||
fn cells(&self) -> u64 {
|
||||
self.columns.saturating_mul(self.lines)
|
||||
}
|
||||
|
||||
/// A parameter charged at its clamped value.
|
||||
///
|
||||
/// Upstream clamps most counts to the grid before acting on them, so the
|
||||
/// bound is the clamp, not the parameter: `ESC[65535X` on an 80-column
|
||||
/// grid touches 80 cells. Charging the raw parameter would let a
|
||||
/// four-byte escape spend the whole slice budget without doing the work,
|
||||
/// which stalls the parser as surely as under-charging lets it run away.
|
||||
#[inline]
|
||||
fn clamp(&self, n: usize, bound: u64) -> u64 {
|
||||
(n as u64).min(bound).max(1)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn charge(&mut self, weight: u64) {
|
||||
self.units = self.units.saturating_add(1);
|
||||
self.work = self.work.saturating_add(weight);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a delegating, counting implementation for every `Handler` method.
|
||||
///
|
||||
/// Two groups, because the methods differ in *cost*, not in kind. `plain`
|
||||
/// methods are charged one unit. `weighted` methods are charged what they
|
||||
/// touch, using the expressions in the table below -- these are the ones a
|
||||
/// hostile stream can use to buy grid-sized work with a four-byte escape.
|
||||
///
|
||||
/// The count is incremented *before* delegating, so a callback that panics
|
||||
/// still leaves evidence it was attempted.
|
||||
macro_rules! counting_handler {
|
||||
(
|
||||
plain { $($pname:ident($($parg:ident: $pty:ty),* $(,)?);)* }
|
||||
weighted { $($wname:ident($($warg:ident: $wty:ty),* $(,)?) => |$this:ident| $weight:expr;)* }
|
||||
) => {
|
||||
impl<H: Handler + CursorColumn> Handler for Counting<'_, H> {
|
||||
$(
|
||||
#[inline]
|
||||
fn $pname(&mut self $(, $parg: $pty)*) {
|
||||
self.charge(1);
|
||||
self.inner.$pname($($parg),*);
|
||||
}
|
||||
)*
|
||||
$(
|
||||
#[inline]
|
||||
fn $wname(&mut self $(, $warg: $wty)*) {
|
||||
let weight = { let $this = &*self; $weight };
|
||||
self.charge(weight);
|
||||
self.inner.$wname($($warg),*);
|
||||
}
|
||||
)*
|
||||
|
||||
/// The one callback this wrapper does not delegate verbatim.
|
||||
///
|
||||
/// CBT (`ESC[NZ`) is upstream's only unbounded atom. With no
|
||||
/// tabstop below the cursor, `move_backward_tabs`
|
||||
/// (`term/mod.rs:1580`) assigns `col` *inside* the `if
|
||||
/// self.tabs[i]` test, so the cursor never moves, the `col == 0`
|
||||
/// break is unreachable, and all N iterations rescan the row.
|
||||
/// `ESC[3g ESC[65535Z` is eight bytes and 82 ms at 1600 columns.
|
||||
/// Its twin `move_forward_tabs` (1605) assigns *outside* the
|
||||
/// test, always advances, and is fine: same file, same loop
|
||||
/// skeleton, and the entire difference is one assignment's
|
||||
/// placement relative to one branch.
|
||||
///
|
||||
/// The fix is a termination condition, not a smaller number.
|
||||
/// Each step either moves the cursor strictly left or is a fixed
|
||||
/// point, and **a fixed point is permanent** -- the scan depends
|
||||
/// only on the cursor, which did not move. So the loop can stop
|
||||
/// at the first one. The leftward distances telescope to at most
|
||||
/// the starting column, plus one final failed scan, so the whole
|
||||
/// callback is O(columns) and the delegated-call count is at most
|
||||
/// `columns - 1`.
|
||||
///
|
||||
/// Equivalence is not argued, it is checked: every tabstop subset
|
||||
/// of a 12-column grid x 4 start columns x 7 counts (114_688
|
||||
/// cases) lands on the same column as the naive loop, on a real
|
||||
/// `Term`. See `examples/probe_cbt_equiv.rs`. Deleting the
|
||||
/// fixed-point break leaves the *landing column correct* and only
|
||||
/// the cost wrong, so the fixture that guards this must assert
|
||||
/// units, never the cursor.
|
||||
#[inline]
|
||||
fn move_backward_tabs(&mut self, count: u16) {
|
||||
// One unit for the escape, as every other callback gets.
|
||||
self.charge(1);
|
||||
for _ in 0..count {
|
||||
let before = self.inner.cursor_column();
|
||||
// No `before == 0` guard: column 0 is already a fixed
|
||||
// point (upstream's own `col == 0` break leaves the
|
||||
// cursor alone), so the check below covers it and a
|
||||
// second one would be unreachable-by-construction code
|
||||
// that no test could distinguish.
|
||||
self.inner.move_backward_tabs(1);
|
||||
let after = self.inner.cursor_column();
|
||||
// Charge the cells this step scanned. A step that finds a
|
||||
// stop scans the distance it moved; a step that finds
|
||||
// none scans the whole prefix and moves nothing --
|
||||
// charging that one zero would leave a loop that spins
|
||||
// without ever paying, which is precisely the mutant this
|
||||
// pricing has to make visible.
|
||||
let scanned = if after == before { before } else { before - after };
|
||||
self.work = self.work.saturating_add(scanned as u64);
|
||||
if after == before {
|
||||
// A fixed point is permanent: the scan depends only
|
||||
// on the cursor, and the cursor did not move.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
counting_handler! {
|
||||
plain {
|
||||
set_title(a0: Option<String>);
|
||||
set_cursor_style(a0: Option<CursorStyle>);
|
||||
set_cursor_shape(shape: CursorShape);
|
||||
input(c: char);
|
||||
goto(line: i32, col: usize);
|
||||
goto_line(line: i32);
|
||||
goto_col(col: usize);
|
||||
move_up(a0: usize);
|
||||
move_down(a0: usize);
|
||||
identify_terminal(intermediate: Option<char>);
|
||||
device_status(a0: usize);
|
||||
move_forward(col: usize);
|
||||
move_backward(col: usize);
|
||||
move_down_and_cr(row: usize);
|
||||
move_up_and_cr(row: usize);
|
||||
backspace();
|
||||
carriage_return();
|
||||
linefeed();
|
||||
bell();
|
||||
substitute();
|
||||
newline();
|
||||
set_horizontal_tabstop();
|
||||
save_cursor_position();
|
||||
restore_cursor_position();
|
||||
clear_tabs(mode: TabulationClearMode);
|
||||
set_tabs(interval: u16);
|
||||
reverse_index();
|
||||
terminal_attribute(attr: Attr);
|
||||
set_mode(mode: Mode);
|
||||
unset_mode(mode: Mode);
|
||||
report_mode(mode: Mode);
|
||||
set_private_mode(mode: PrivateMode);
|
||||
unset_private_mode(mode: PrivateMode);
|
||||
report_private_mode(mode: PrivateMode);
|
||||
set_scrolling_region(top: usize, bottom: Option<usize>);
|
||||
set_keypad_application_mode();
|
||||
unset_keypad_application_mode();
|
||||
set_active_charset(a0: CharsetIndex);
|
||||
configure_charset(a0: CharsetIndex, a1: StandardCharset);
|
||||
set_color(a0: usize, a1: Rgb);
|
||||
dynamic_color_sequence(a0: String, a1: usize, a2: &str);
|
||||
reset_color(a0: usize);
|
||||
clipboard_store(a0: u8, a1: &[u8]);
|
||||
clipboard_load(a0: u8, a1: &str);
|
||||
push_title();
|
||||
pop_title();
|
||||
text_area_size_pixels();
|
||||
text_area_size_chars();
|
||||
set_hyperlink(a0: Option<Hyperlink>);
|
||||
set_mouse_cursor_icon(a0: CursorIcon);
|
||||
report_keyboard_mode();
|
||||
push_keyboard_mode(mode: KeyboardModes);
|
||||
pop_keyboard_modes(to_pop: u16);
|
||||
set_keyboard_mode(mode: KeyboardModes, behavior: KeyboardModesApplyBehavior);
|
||||
set_modify_other_keys(mode: ModifyOtherKeys);
|
||||
report_modify_other_keys();
|
||||
set_scp(char_path: ScpCharPath, update_mode: ScpUpdateMode);
|
||||
}
|
||||
weighted {
|
||||
// Every weight below is an upper bound on the cells the callback can
|
||||
// touch, **read from `alacritty_terminal-0.26.0/src/term/mod.rs`** and
|
||||
// then checked against measurement -- never fitted to a curve. The
|
||||
// direction of the error is the whole point: an over-charge slices
|
||||
// early and costs throughput, an under-charge is an attack surface, so
|
||||
// where source and measurement disagree the source bound wins and the
|
||||
// slack is recorded here rather than tuned away.
|
||||
//
|
||||
// `min(N, ...)` appears wherever upstream clamps the parameter; a raw
|
||||
// `N` would let `ESC[65535X` charge 65535 on an 80-column grid and
|
||||
// stall the parser on a cheap escape.
|
||||
|
||||
// O(min(N, columns)): `end = min(start + count, columns)`, loop
|
||||
// `row[start..end]` (1519). Knee measured exactly at N == columns.
|
||||
erase_chars(count: usize) => |this| this.clamp(count, this.columns);
|
||||
// O(columns) for *every* N, worst at N=1: the swap loop runs
|
||||
// `columns - end` times where `end = min(start + N, columns - 1)`
|
||||
// (1538), so cost *falls* as N rises. Charging by N would be backwards
|
||||
// and would under-charge the worst case by the full terminal width --
|
||||
// measured 3422ns at N=1/1600 columns against 863ns at N=65535.
|
||||
delete_chars(a0: usize) => |this| this.columns;
|
||||
// O(columns) for every N, worst at N=1. Same shape as `delete_chars`:
|
||||
// `num_cells = columns - (column + count)` (1187).
|
||||
insert_blank(a0: usize) => |this| this.columns;
|
||||
// O(columns): scans to the next tabstop per count, and always advances
|
||||
// (`col` is assigned unconditionally at 1592), so the whole loop is
|
||||
// bounded by one traversal of the row. This is the sibling that CBT
|
||||
// should have been, one asymmetric line apart in the same file.
|
||||
put_tab(count: u16) => |this| this.columns;
|
||||
move_forward_tabs(count: u16) => |this| this.columns;
|
||||
// NOTE: `move_backward_tabs` is NOT in this table. It is the one
|
||||
// callback whose argument is rewritten, so it is written out by hand
|
||||
// below the macro's generated methods -- a weight can price an atom but
|
||||
// cannot shrink one.
|
||||
// O(min(N, lines) x columns) in steady state: the row rotation is O(1)
|
||||
// on the ring buffer, but `positions` rows are `reset()`, and a row
|
||||
// reset is O(columns).
|
||||
//
|
||||
// **Known overshoot, measured and frequency-bounded.** While scrollback
|
||||
// is still growing, `Grid::increase_scroll_limit` -> `Storage::initialize`
|
||||
// reallocates in blocks of `MAX_CACHE_SIZE` = 1000 rows and `rezero`s
|
||||
// the ring (`grid/storage.rs`). That is not chargeable from here -- the
|
||||
// weight function cannot see history depth -- and it is real: at 1600
|
||||
// columns the spikes land at call 0, 1000, 2000, 3000 of a 4000-call
|
||||
// scroll, ~4 ms each, against a 125 ns median. It is bounded in
|
||||
// frequency (once per 1000 new history rows, and never once history
|
||||
// saturates: with scrollback=100 only call 0 spikes) and it is upstream
|
||||
// allocation rather than anything a stream can amplify, so it is
|
||||
// recorded here instead of being priced into every scroll -- charging
|
||||
// 1000x on 999 calls out of 1000 to cover the thousandth would make
|
||||
// ordinary scrolling the slow path.
|
||||
scroll_up(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
|
||||
delete_lines(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
|
||||
scroll_down(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
|
||||
insert_blank_lines(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
|
||||
// O(columns): one row, `damage_line(line, 0, columns - 1)`.
|
||||
clear_line(mode: LineClearMode) => |this| this.columns;
|
||||
// O(cells): 18.7us at 200x50, doubling on both axes.
|
||||
clear_screen(mode: ClearMode) => |this| this.cells();
|
||||
// O(cells): rewrites every cell.
|
||||
decaln() => |this| this.cells();
|
||||
// Both grids, plus the scrollback the primary owns.
|
||||
//
|
||||
// `reset_state` (1835) resets the primary *and* the alternate, and each
|
||||
// `Grid::reset` runs `clear_history` -> `shrink_lines` -> `truncate` +
|
||||
// `rezero`, which walks the raw buffer. So the cost carries a history
|
||||
// axis that `cells` alone cannot see: measured 0.5 us empty against
|
||||
// 1.68 ms with 10k rows filled at 400x100, a 42x per-cell miss, with
|
||||
// the knee exactly at `screen_lines + MAX_CACHE_SIZE` where
|
||||
// `shrink_lines` starts calling `truncate`.
|
||||
//
|
||||
// Priced on **configured** depth rather than current fill, which is the
|
||||
// conservative choice and the only correct one: `history_size()` reads
|
||||
// the *active* grid, so a filled primary followed by `ESC[?1049h`
|
||||
// reports an empty history while RIS still pays for the inactive
|
||||
// primary's rows -- underpriced 41x on exactly the arm an attacker
|
||||
// would pick. The inactive grid is private, so there is no stateless
|
||||
// way to observe the real fill; the configured depth bounds both.
|
||||
//
|
||||
// This is the one weight that can exceed [`crate::fences::WORK_BUDGET`]
|
||||
// on its own -- 16x at the default 10k scrollback -- which is correct:
|
||||
// it is a genuinely oversized uninterruptible atom, and a budget that
|
||||
// hid that would be lying about what one drain can cost.
|
||||
reset_state() => |this| this.cells().saturating_mul(2)
|
||||
.saturating_add(this.scrollback.saturating_mul(this.columns));
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ fn render(input: &str) -> (Vec<Span>, Receiver<Action>) {
|
||||
};
|
||||
let (term, actions) = Terminal::new(size, Fences::ALL);
|
||||
let shared = SharedTerminal::new(term);
|
||||
shared.feed(input.as_bytes());
|
||||
shared.feed_fully(input.as_bytes());
|
||||
let mut encoder = Encoder::new();
|
||||
let frame = shared.render(&mut encoder);
|
||||
let spans = frame
|
||||
@@ -199,6 +199,84 @@ fn cluster_count_distinguishes_a_marked_cluster_from_a_plain_run() {
|
||||
assert!(marked.counts_are_consistent() && plain.counts_are_consistent());
|
||||
}
|
||||
|
||||
/// The join guard has two halves: the previous cell must not have carried
|
||||
/// marks (`open`), and the current cell must not carry them (`joinable`).
|
||||
/// Every fixture above exercises only the first half -- a plain cluster
|
||||
/// following a marked one. This one exercises the second: a *marked* cluster
|
||||
/// arriving after a plain run, which is the only path on which the run in
|
||||
/// progress is handed text holding more `char`s than the one cluster its
|
||||
/// count is about to be incremented by.
|
||||
///
|
||||
/// Sami found the hole. With `joinable` dropped from the guard, a release
|
||||
/// build silently emits `Span { column: 0, text: "xyé", cluster_count: 3 }`:
|
||||
/// four chars counted as three, so the consumer's rule splits per char and
|
||||
/// places the combining mark on top of `z`.
|
||||
#[test]
|
||||
fn a_marked_cluster_after_a_plain_run_starts_its_own_span() {
|
||||
let (spans, _actions) = render("xye\u{0301}z");
|
||||
assert_eq!(
|
||||
placements(&spans),
|
||||
vec![
|
||||
(0, "x".into()),
|
||||
(1, "y".into()),
|
||||
(2, "e\u{0301}".into()),
|
||||
(3, "z".into()),
|
||||
],
|
||||
"a marked cluster must not be absorbed into the run in front of it"
|
||||
);
|
||||
}
|
||||
|
||||
/// `cluster_count` is a `u16` and `Size.columns` is an unclamped `usize`
|
||||
/// (`lib.rs:50`) that no production caller bounds yet, so a row of uniform
|
||||
/// cells wider than `u16::MAX` reaches the join guard's overflow refusal.
|
||||
/// The guard is live code, not paranoia, and this fixture is what says so.
|
||||
///
|
||||
/// Refusing to join produces a shape the consumer already handles -- the run
|
||||
/// ends and a new span starts at the next column -- whereas wrapping produces
|
||||
/// an undecodable span, the same failure as the marked-after-plain case above.
|
||||
#[test]
|
||||
fn a_run_longer_than_u16_max_splits_rather_than_wrapping() {
|
||||
let columns = 70_000;
|
||||
let size = Size {
|
||||
columns,
|
||||
screen_lines: 1,
|
||||
scrollback: 0,
|
||||
};
|
||||
let (term, _actions) = Terminal::new(size, Fences::ALL);
|
||||
let shared = SharedTerminal::new(term);
|
||||
// One character is enough: the rest of the row is blank cells of the same
|
||||
// style, so the whole row is a single candidate run.
|
||||
shared.feed_fully(b"a");
|
||||
let mut encoder = Encoder::new();
|
||||
let frame = shared.render(&mut encoder);
|
||||
|
||||
let spans = &frame
|
||||
.rows
|
||||
.iter()
|
||||
.find(|row| row.line == 0)
|
||||
.expect("the fed row must be present")
|
||||
.spans;
|
||||
|
||||
assert!(
|
||||
spans.iter().all(|span| span.counts_are_consistent()),
|
||||
"an oversized run must not wrap its count: {spans:?}"
|
||||
);
|
||||
let counts: Vec<u16> = spans.iter().map(|span| span.cluster_count).collect();
|
||||
let columns_at: Vec<usize> = spans.iter().map(|span| span.column).collect();
|
||||
assert_eq!(
|
||||
counts,
|
||||
vec![u16::MAX, (columns - u16::MAX as usize) as u16],
|
||||
"the run must end at the last representable count"
|
||||
);
|
||||
assert_eq!(
|
||||
columns_at,
|
||||
vec![0, u16::MAX as usize],
|
||||
"the second span starts where the first left off"
|
||||
);
|
||||
let chars: usize = spans.iter().map(|span| span.text.chars().count()).sum();
|
||||
assert_eq!(chars, columns, "no cell may be dropped by the split");
|
||||
}
|
||||
|
||||
/// Wrapping marks the last cell of the row with `WRAPLINE` (upstream
|
||||
/// `term/mod.rs:968`). That bit records where the text happened to wrap, not
|
||||
/// how the text looks, so it must not reach the style key: if it did, the last
|
||||
@@ -219,7 +297,7 @@ fn wrapping_does_not_split_a_uniform_run() {
|
||||
let shared = SharedTerminal::new(term);
|
||||
// Six narrow cells in one style: five fill row 0 and set WRAPLINE on the
|
||||
// last of them, the sixth lands on row 1.
|
||||
shared.feed(b"abcdef");
|
||||
shared.feed_fully(b"abcdef");
|
||||
let mut encoder = Encoder::new();
|
||||
let frame = shared.render(&mut encoder);
|
||||
|
||||
@@ -249,7 +327,7 @@ fn leading_wide_glyph_after_wrap_is_positioned_from_column_zero() {
|
||||
let shared = SharedTerminal::new(term);
|
||||
// Four narrow cells fill 0..=3, leaving one column: the wide glyph cannot
|
||||
// fit and moves to the next row.
|
||||
shared.feed("abcd\u{4E00}".as_bytes());
|
||||
shared.feed_fully("abcd\u{4E00}".as_bytes());
|
||||
let mut encoder = Encoder::new();
|
||||
let frame = shared.render(&mut encoder);
|
||||
|
||||
|
||||
@@ -23,12 +23,12 @@ fn size() -> Size {
|
||||
}
|
||||
|
||||
fn feed_synchronized(term: &mut Terminal, payload: &[u8], close: bool) {
|
||||
term.feed(b"\x1b[?2026h");
|
||||
term.feed_fully(b"\x1b[?2026h");
|
||||
for chunk in payload.chunks(CHUNK) {
|
||||
term.feed(chunk);
|
||||
term.feed_fully(chunk);
|
||||
}
|
||||
if close {
|
||||
term.feed(b"\x1b[?2026l");
|
||||
term.feed_fully(b"\x1b[?2026l");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,9 +107,9 @@ fn g1_sync_abort_bounds_all_hostile_shapes() {
|
||||
#[test]
|
||||
fn g2_hostile_unsynchronized_osc_resets_parser() {
|
||||
let (mut term, _) = Terminal::new(size(), Fences::ALL);
|
||||
term.feed(b"\x1b]0;");
|
||||
term.feed_fully(b"\x1b]0;");
|
||||
for chunk in repeated(b"A", OSC_BUDGET * 4).chunks(CHUNK) {
|
||||
term.feed(chunk);
|
||||
term.feed_fully(chunk);
|
||||
}
|
||||
assert!(term.stats().osc_resets > 0, "F2 never rebuilt the parser");
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ fn flood(shared: &SharedTerminal, stop: &AtomicBool) {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
shared.feed(chunk);
|
||||
shared.feed_fully(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ fn grid(columns: usize, screen_lines: usize) -> Size {
|
||||
fn resize_forces_a_full_frame_at_the_new_width() {
|
||||
let (shared, _actions) = shared(size(40));
|
||||
let mut encoder = Encoder::new();
|
||||
shared.feed(b"\x1b[2J\x1b[Hhello world\r\nsecond line\r\n");
|
||||
shared.feed_fully(b"\x1b[2J\x1b[Hhello world\r\nsecond line\r\n");
|
||||
|
||||
let first = shared.render(&mut encoder);
|
||||
assert!(first.full, "first frame after a fresh Term must be full");
|
||||
@@ -100,7 +100,7 @@ fn resize_forces_a_full_frame_at_the_new_width() {
|
||||
fn identical_resize_is_inert() {
|
||||
let (shared, _actions) = shared(size(40));
|
||||
let mut encoder = Encoder::new();
|
||||
shared.feed(b"hello");
|
||||
shared.feed_fully(b"hello");
|
||||
shared.render(&mut encoder);
|
||||
|
||||
let applied = shared.resize(size(40));
|
||||
@@ -134,7 +134,7 @@ fn identical_resize_is_inert() {
|
||||
fn full_frame_after_height_resize_republishes_unchanged_rows() {
|
||||
let (shared, _actions) = shared(grid(40, 10));
|
||||
let mut encoder = Encoder::new();
|
||||
shared.feed(b"\x1b[2J\x1b[Hhello world\r\nsecond line");
|
||||
shared.feed_fully(b"\x1b[2J\x1b[Hhello world\r\nsecond line");
|
||||
let first = shared.render(&mut encoder);
|
||||
assert!(first.full);
|
||||
assert_eq!(first.rows.len(), 10);
|
||||
@@ -173,7 +173,7 @@ fn full_frame_after_height_resize_republishes_unchanged_rows() {
|
||||
fn a_frame_is_stamped_with_the_grid_it_was_captured_on() {
|
||||
let (shared, _actions) = shared(grid(40, 10));
|
||||
let mut encoder = Encoder::new();
|
||||
shared.feed(b"\x1b[2J\x1b[Hhello world");
|
||||
shared.feed_fully(b"\x1b[2J\x1b[Hhello world");
|
||||
|
||||
let in_flight = shared.render(&mut encoder);
|
||||
assert_eq!(in_flight.viewport.generation, 0);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,7 +49,7 @@ fn visible_text(frame: &buzz_terminal::damage::Frame) -> Vec<String> {
|
||||
#[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");
|
||||
shared.feed_fully(b"first\r\nsecond\r\nthird");
|
||||
|
||||
// The incumbent consumes the damage from that output.
|
||||
let mut incumbent = Encoder::new();
|
||||
@@ -101,12 +101,12 @@ fn a_snapshot_does_not_steal_the_incumbents_damage() {
|
||||
// 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");
|
||||
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(b"\x1b[1;1HAFTER\x1b[4;1H");
|
||||
shared.feed_fully(b"\x1b[1;1HAFTER\x1b[4;1H");
|
||||
|
||||
// A second subscriber attaches and snapshots first.
|
||||
let mut attaching = Encoder::new();
|
||||
@@ -138,11 +138,12 @@ fn a_snapshot_does_not_steal_the_incumbents_damage() {
|
||||
#[test]
|
||||
fn a_snapshot_realigns_a_reused_encoders_dedup_state() {
|
||||
let (shared, _actions) = terminal(20, 4);
|
||||
shared.feed(b"wide enough line");
|
||||
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,
|
||||
@@ -150,6 +151,10 @@ fn a_snapshot_realigns_a_reused_encoders_dedup_state() {
|
||||
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.
|
||||
@@ -158,6 +163,17 @@ fn a_snapshot_realigns_a_reused_encoders_dedup_state() {
|
||||
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(),
|
||||
@@ -165,13 +181,97 @@ fn a_snapshot_realigns_a_reused_encoders_dedup_state() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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(b"persistent");
|
||||
shared.feed_fully(b"persistent");
|
||||
|
||||
let mut first = Encoder::new();
|
||||
let mut second = Encoder::new();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Rust-owned PTY sessions and the typed Tauri transport for Buzz Substrate.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
@@ -202,10 +203,32 @@ fn wire_publication(publication: Publication) -> Result<FrameMessage> {
|
||||
})
|
||||
}
|
||||
|
||||
struct ReaderThread(Option<JoinHandle<()>>);
|
||||
fn feed_and_drain(terminal: &SharedTerminal, bytes: &[u8]) -> bool {
|
||||
let mut more = terminal.feed(bytes);
|
||||
let deferred = more;
|
||||
while more && !terminal.is_closing() {
|
||||
more = terminal.drain();
|
||||
}
|
||||
deferred
|
||||
}
|
||||
|
||||
struct ReaderThread {
|
||||
handle: Option<JoinHandle<()>>,
|
||||
terminal: Arc<SharedTerminal>,
|
||||
stopping: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl buzz_terminal::lifecycle::DrainingReader for ReaderThread {
|
||||
fn begin_closing(&self) {
|
||||
self.terminal.begin_closing();
|
||||
}
|
||||
|
||||
fn stop(&self) {
|
||||
self.stopping.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
fn join(mut self: Box<Self>) {
|
||||
if let Some(handle) = self.0.take() {
|
||||
if let Some(handle) = self.handle.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
@@ -253,7 +276,12 @@ impl Session {
|
||||
if let Ok(mut channel) = self.channel.lock() {
|
||||
*channel = None;
|
||||
}
|
||||
// The slave closes on child reap; the reader continues draining until then.
|
||||
// Publication is detached before the reader enters close mode; the
|
||||
// lifecycle helper then abandons parser work and keeps raw-draining
|
||||
// through child termination and reap.
|
||||
if let Ok(mut publisher) = self.publisher.lock() {
|
||||
publisher.close();
|
||||
}
|
||||
if let Some(reader) = self.reader.take() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -261,12 +289,13 @@ impl Session {
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
reader.begin_closing();
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
reader.stop();
|
||||
reader.join();
|
||||
}
|
||||
}
|
||||
self.master.take();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,6 +494,8 @@ pub(crate) fn terminal_attach(
|
||||
let reader_terminal = Arc::clone(&terminal);
|
||||
let reader_publisher = Arc::clone(&publisher);
|
||||
let reader_channel = Arc::clone(&channel);
|
||||
let reader_stopping = Arc::new(AtomicBool::new(false));
|
||||
let thread_stopping = Arc::clone(&reader_stopping);
|
||||
let reader_handle = std::thread::spawn(move || {
|
||||
let mut buffer = [0u8; 16 * 1024];
|
||||
let mut encoder = buzz_terminal::damage::Encoder::new();
|
||||
@@ -474,7 +505,16 @@ pub(crate) fn terminal_attach(
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(count) => count,
|
||||
};
|
||||
reader_terminal.feed(&buffer[..count]);
|
||||
if thread_stopping.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
if reader_terminal.is_closing() {
|
||||
continue;
|
||||
}
|
||||
let _ = feed_and_drain(&reader_terminal, &buffer[..count]);
|
||||
if reader_terminal.is_closing() {
|
||||
continue;
|
||||
}
|
||||
let needs_snapshot = reader_publisher
|
||||
.lock()
|
||||
.map(|publisher| publisher.requires_snapshot())
|
||||
@@ -527,12 +567,16 @@ pub(crate) fn terminal_attach(
|
||||
.map_err(|_| "terminal snapshot rejected".to_string())?;
|
||||
let session = Session {
|
||||
id,
|
||||
terminal,
|
||||
terminal: Arc::clone(&terminal),
|
||||
master: Some(pair.master),
|
||||
writer,
|
||||
pty_size: current_pty_size,
|
||||
child,
|
||||
reader: Some(Box::new(ReaderThread(Some(reader_handle)))),
|
||||
reader: Some(Box::new(ReaderThread {
|
||||
handle: Some(reader_handle),
|
||||
terminal: Arc::clone(&terminal),
|
||||
stopping: reader_stopping,
|
||||
})),
|
||||
publisher,
|
||||
channel,
|
||||
};
|
||||
@@ -777,6 +821,22 @@ mod tests {
|
||||
assert!(successor.frame.full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reader_pumps_a_deferred_tail_without_an_external_event() {
|
||||
let (terminal, _actions) = Terminal::new(Size::default(), Fences::ALL);
|
||||
let terminal = SharedTerminal::new(terminal);
|
||||
let payload = "\u{1b}c".repeat(2_102_714);
|
||||
|
||||
assert!(
|
||||
feed_and_drain(&terminal, payload.as_bytes()),
|
||||
"fixture must defer parser work before the runtime pumps it"
|
||||
);
|
||||
|
||||
let terminal = terminal.lock();
|
||||
assert_eq!(terminal.pending_bytes(), 0);
|
||||
assert_eq!(terminal.stats().completed_units, 2_102_714);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_attach_retains_output_captured_after_its_bootstrap_snapshot() {
|
||||
let viewport = marker_frame(0, true).viewport;
|
||||
|
||||
@@ -186,6 +186,11 @@ impl FramePublisher {
|
||||
}
|
||||
}
|
||||
|
||||
/// Permanently detach publication as the session begins closing.
|
||||
pub(crate) fn close(&mut self) {
|
||||
self.subscription = None;
|
||||
}
|
||||
|
||||
fn require_current_snapshot(&self, frame: &Frame) -> Result<(), OfferError> {
|
||||
if frame.viewport == self.applied && frame.full {
|
||||
Ok(())
|
||||
|
||||
Reference in New Issue
Block a user