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>
400 lines
16 KiB
Rust
400 lines
16 KiB
Rust
//! Adversarial slicing cases for resize debt, oversized atoms, and arithmetic extremes.
|
|
//!
|
|
//! **This is half a suite.** The remaining work-bound cases live in
|
|
//! `slicing.rs`; the two files are one set of contracts split only for the
|
|
//! file-size ratchet. A mutation check scoped with `--test slicing_adversarial`
|
|
//! covers 4 of 76 package tests and can report a confident pass while the
|
|
//! killing fixture sits in the sibling file. Sizing from the whole budget does
|
|
//! exactly that, then dies under the package.
|
|
//!
|
|
//! Mutation checks run the package, never a file: `cargo test -p buzz-terminal`.
|
|
|
|
use buzz_terminal::fences::{
|
|
max_atom_work, max_drain_work, slice_bytes_remaining, Fences, WORK_BUDGET,
|
|
};
|
|
use buzz_terminal::{Size, Terminal};
|
|
|
|
/// A scrollback change reprices RIS *and* the slicing derived from it.
|
|
///
|
|
/// Kills: updating the feeder's columns and lines on resize but not its
|
|
/// scrollback -- and, separately, a repair that reprices the charge while
|
|
/// leaving slice width stale. Those are different failures and neither
|
|
/// observable sees the other: fix only the charge and the drain count stays
|
|
/// wrong; fix only the derivation and the charge stays wrong.
|
|
///
|
|
/// Two properties, because one is not enough:
|
|
///
|
|
/// * The exact RIS charge at the new depth. Direct, and it is what a
|
|
/// pricing-only repair passes.
|
|
/// * Equality with a terminal *constructed* at the new depth, across work
|
|
/// and drain count. A resized feeder that is genuinely repaired is
|
|
/// indistinguishable from one that was born there. This is stronger than a
|
|
/// hand-picked threshold and immune to `WORK_BUDGET`/`MIN_SLICE` moving,
|
|
/// since both arms move together -- and the sanity arm proves the
|
|
/// comparison is deterministic before it is used to judge anything.
|
|
///
|
|
/// `completed_units` is deliberately *not* the discriminator here: it reads
|
|
/// 200 in both arms, because the same callbacks run either way and only their
|
|
/// cost and slicing differ. It is asserted anyway as the invariant that must
|
|
/// hold -- no unit lost or duplicated across a resize -- while carrying none
|
|
/// of the discrimination.
|
|
#[test]
|
|
fn a_scrollback_change_reprices_the_densest_atom_and_the_slicing() {
|
|
let shallow = Size {
|
|
columns: 200,
|
|
screen_lines: 50,
|
|
scrollback: 100,
|
|
};
|
|
let deep = Size {
|
|
scrollback: 10_000,
|
|
..shallow
|
|
};
|
|
let cells = (deep.columns * deep.screen_lines) as u64;
|
|
|
|
// Preconditions, asserted rather than assumed, because both are easy to
|
|
// break by "generalising" this fixture later:
|
|
//
|
|
// * The geometry must let the *scheduling* fields separate. They only do
|
|
// when the two depths land on different slice widths, and the deep side
|
|
// is always floored -- so the shallow side must not be. At 1600x50 the
|
|
// visible grid alone floors every depth from 0 upward, and three of the
|
|
// four observables below go silently inert.
|
|
// * The payload must be RIS. It is the only escape reaching the only
|
|
// weight carrying a scrollback term (`units::reset_state`); DECALN and
|
|
// every other atom are priced on cells or columns and are blind to
|
|
// depth, so a conforming repair would show work identical to the
|
|
// control and the assertions here would invert into false failures.
|
|
assert!(
|
|
slice_bytes_remaining(
|
|
shallow.columns,
|
|
shallow.screen_lines,
|
|
shallow.scrollback,
|
|
0,
|
|
0
|
|
) > 1,
|
|
"geometry cannot discriminate: the shallow arm is already floored",
|
|
);
|
|
assert_eq!(
|
|
slice_bytes_remaining(deep.columns, deep.screen_lines, deep.scrollback, 0, 0),
|
|
1,
|
|
);
|
|
|
|
// How a terminal at `size` retires 200 RIS: work, and how many
|
|
// acquisitions it took. Both are feeder behaviour, not helper output.
|
|
let run = |size: Size, resize_from: Option<Size>| {
|
|
let (mut term, _a) = Terminal::new(resize_from.unwrap_or(size), Fences::ALL);
|
|
if resize_from.is_some() {
|
|
term.resize(size);
|
|
}
|
|
term.reset_stats();
|
|
let mut drains = 1;
|
|
let mut more = term.feed(&b"c".repeat(200));
|
|
while more {
|
|
more = term.drain();
|
|
drains += 1;
|
|
}
|
|
(
|
|
term.stats().completed_units,
|
|
term.stats().completed_work,
|
|
drains,
|
|
)
|
|
};
|
|
|
|
let control = run(deep, None);
|
|
let sanity = run(deep, None);
|
|
assert_eq!(
|
|
control, sanity,
|
|
"two terminals built the same way must agree before this comparison can judge anything",
|
|
);
|
|
|
|
let resized = run(deep, Some(shallow));
|
|
assert_eq!(resized.0, 200, "no unit may be lost or duplicated");
|
|
assert_eq!(
|
|
resized, control,
|
|
"a feeder resized to a depth must be indistinguishable from one constructed at it -- in charge and in how many acquisitions it took",
|
|
);
|
|
|
|
// The exact charge, stated rather than inferred from the equality: a
|
|
// repair that made both arms equally *wrong* would pass the comparison.
|
|
let (mut term, _a) = Terminal::new(shallow, Fences::ALL);
|
|
term.resize(deep);
|
|
term.reset_stats();
|
|
term.feed_fully(b"c");
|
|
assert_eq!(
|
|
term.stats().completed_work,
|
|
2 * cells + (deep.scrollback * deep.columns) as u64,
|
|
);
|
|
|
|
// Shrinking retains the debt, and the fixture proves retention rather
|
|
// than merely permitting it.
|
|
//
|
|
// `>= fresh` alone is the predicate three of us proposed and all three
|
|
// withdrew: a feeder that dropped the debt reads *exactly* equal to a
|
|
// fresh shallow one, so `>=` passes on the unrepaired state. Strictness
|
|
// on the pricing field is what rejects it. The scheduling fields are
|
|
// asserted directionally with per-field signs -- `first_units` inverts,
|
|
// because a narrower slice retires fewer atoms per un-preemptable drain,
|
|
// which is the fence working -- but none of them is the discriminator:
|
|
// they separate only when the two depths straddle the slice floor, and
|
|
// `completed_work` separates at every positive depth gap.
|
|
//
|
|
// Every comparison is against the fresh control's own field, never a
|
|
// literal: a constant or geometry change must move both sides together,
|
|
// or the fixture starts asserting the arithmetic of the day it was
|
|
// written.
|
|
let measure = |term: &mut Terminal| {
|
|
term.reset_stats();
|
|
let mut drains = 1;
|
|
let mut more = term.feed(&b"\x1bc".repeat(200));
|
|
let first_units = term.stats().completed_units;
|
|
let first_pending = term.pending_bytes();
|
|
while more {
|
|
more = term.drain();
|
|
drains += 1;
|
|
}
|
|
(
|
|
first_units,
|
|
first_pending,
|
|
drains,
|
|
term.stats().completed_units,
|
|
term.stats().completed_work,
|
|
)
|
|
};
|
|
|
|
// The terminal under test stays alive past its measurement, so the
|
|
// geometry arm below runs on the feeder that actually shrank rather than
|
|
// on a lookalike that only ever grew.
|
|
let (mut shrunk_term, _a) = Terminal::new(shallow, Fences::ALL);
|
|
shrunk_term.resize(deep);
|
|
shrunk_term.resize(shallow);
|
|
let shrunk = measure(&mut shrunk_term);
|
|
|
|
let (mut fresh_term, _a) = Terminal::new(shallow, Fences::ALL);
|
|
let fresh = measure(&mut fresh_term);
|
|
|
|
assert_eq!(
|
|
shrunk.3, fresh.3,
|
|
"no unit may be lost on the way down either"
|
|
);
|
|
assert!(
|
|
shrunk.4 > fresh.4,
|
|
"a feeder that has been deep must still price deep after shrinking: \
|
|
{} against a fresh shallow {}. Equality here is the signature of a \
|
|
feeder that dropped the debt, which is indistinguishable from one \
|
|
that never had it",
|
|
shrunk.4,
|
|
fresh.4,
|
|
);
|
|
assert!(
|
|
shrunk.0 <= fresh.0,
|
|
"narrower slices retire fewer atoms per drain: {} against {}",
|
|
shrunk.0,
|
|
fresh.0,
|
|
);
|
|
assert!(
|
|
shrunk.1 >= fresh.1,
|
|
"and leave more pending after the first call: {} against {}",
|
|
shrunk.1,
|
|
fresh.1,
|
|
);
|
|
assert!(
|
|
shrunk.2 >= fresh.2,
|
|
"and take more drains to finish: {} against {}",
|
|
shrunk.2,
|
|
fresh.2,
|
|
);
|
|
|
|
// The debt survives a later resize on a different axis. Two things make
|
|
// this arm bite, and it was inert without either:
|
|
//
|
|
// * It runs on the terminal that actually went shallow -> deep ->
|
|
// shallow. A lookalike that only ever grew passes it while an
|
|
// implementation that retains on shrink and drops on the next geometry
|
|
// change fails.
|
|
// * The resize carries the *shallow* depth. Passing the debt's own value
|
|
// back in means `max(debt, new)` and a plain assignment agree, so the
|
|
// arm cannot tell them apart -- which is how it survived a mutant that
|
|
// retained only when columns and lines were unchanged.
|
|
shrunk_term.resize(Size {
|
|
columns: shallow.columns * 2,
|
|
screen_lines: shallow.screen_lines,
|
|
scrollback: shallow.scrollback,
|
|
});
|
|
shrunk_term.reset_stats();
|
|
shrunk_term.feed_fully(b"\x1bc");
|
|
assert_eq!(
|
|
shrunk_term.stats().completed_work,
|
|
2 * (shallow.columns * 2 * shallow.screen_lines) as u64
|
|
+ (deep.scrollback * shallow.columns * 2) as u64,
|
|
"a columns resize must keep the deep scrollback debt, not fall back \
|
|
to the current shallow depth",
|
|
);
|
|
}
|
|
|
|
/// One oversized atom per drain -- no callback runs after the one that
|
|
/// crosses the budget.
|
|
///
|
|
/// Kills: sizing slices from the *whole* budget rather than what remains of
|
|
/// it. RIS at any real scrollback depth is worth more than an entire budget,
|
|
/// so a slice wide enough for several callbacks runs several: measured
|
|
/// `completed_units == 3` for `ESC c` followed by `Xmore`, where the law
|
|
/// permits exactly one. The fix makes slice width a function of `remaining`,
|
|
/// which is a single byte once an atom this size is in play.
|
|
///
|
|
/// Also asserts the tail survives it: yielding after the crossing atom is
|
|
/// only correct if what follows is still parsed, exactly once.
|
|
#[test]
|
|
fn an_oversized_atom_yields_before_the_next_callback() {
|
|
let size = Size {
|
|
columns: 400,
|
|
screen_lines: 100,
|
|
scrollback: 10_000,
|
|
};
|
|
let (mut term, _a) = Terminal::new(size, Fences::ALL);
|
|
let ris_work =
|
|
2 * (size.columns * size.screen_lines) as u64 + (size.scrollback * size.columns) as u64;
|
|
assert!(
|
|
ris_work > WORK_BUDGET,
|
|
"this arm needs an atom bigger than the whole budget",
|
|
);
|
|
|
|
let more = term.feed(b"\x1bcXmore");
|
|
|
|
assert!(more, "the drain must yield with a tail");
|
|
assert_eq!(
|
|
term.stats().completed_units,
|
|
1,
|
|
"exactly the crossing atom ran: a callback after it is post-atom \
|
|
overrun, which is the thing the budget cannot preempt and therefore \
|
|
must not start",
|
|
);
|
|
assert_eq!(term.stats().completed_work, ris_work);
|
|
|
|
while term.drain() {}
|
|
assert_eq!(
|
|
term.stats().completed_units,
|
|
1 + 5,
|
|
"the five characters after it must still be parsed, exactly once",
|
|
);
|
|
assert_eq!(term.pending_bytes(), 0);
|
|
}
|
|
|
|
/// Extreme dimensions saturate rather than wrapping or panicking.
|
|
///
|
|
/// Kills: `columns * lines` in `usize` before the cast. `Size` is unclamped
|
|
/// and reaches the weight path from a caller, so this product is a reachable
|
|
/// overflow -- a debug panic inside the accounting path, or a release wrap
|
|
/// that reports the most expensive callback in the emulator as one of the
|
|
/// cheapest. Saturating is the only one of the three that fails safe.
|
|
#[test]
|
|
fn extreme_dimensions_saturate_instead_of_wrapping() {
|
|
let huge = usize::MAX / 2;
|
|
assert_eq!(max_atom_work(huge, huge, huge), u64::MAX);
|
|
assert_eq!(max_drain_work(huge, huge, huge), u64::MAX);
|
|
|
|
// The *direction* is the assertion, not merely the absence of a panic.
|
|
// A wrapping build does not produce a slightly-wrong bound, it produces a
|
|
// tiny one -- and `slice_bytes_remaining` divides the budget by it, so an
|
|
// undercharged atom yields an *oversized* slice exactly when the atom is
|
|
// most expensive. Wrapping inverts the fence. So: the widest possible
|
|
// atom must give the narrowest possible slice.
|
|
assert_eq!(
|
|
slice_bytes_remaining(huge, huge, huge, 0, 0),
|
|
1,
|
|
"an overflowing grid must clamp to the smallest slice; a wrapped \
|
|
`max_atom_work` would hand back a generous one",
|
|
);
|
|
assert_eq!(
|
|
slice_bytes_remaining(huge, huge, huge, 0, 0),
|
|
1,
|
|
"and the escape at the front of such a grid gets a single byte",
|
|
);
|
|
|
|
// The property behind those endpoints, and the stronger statement: a
|
|
// grid that costs more may never buy a wider slice. Endpoints pin the
|
|
// ends; only a sweep catches a non-monotone middle, and a wrap *is* a
|
|
// non-monotone middle -- it makes the worst grid look cheap and hands it
|
|
// the widest slice of all.
|
|
// Every axis independently: a wrap on any one of the three products is a
|
|
// non-monotone middle on that axis alone, and sweeping only scrollback
|
|
// would miss a truncating `columns * lines`.
|
|
for (axis, at) in [
|
|
(
|
|
"scrollback",
|
|
(|n| slice_bytes_remaining(200, 50, n, 0, 0)) as fn(usize) -> usize,
|
|
),
|
|
("columns", |n| slice_bytes_remaining(n.max(1), 50, 0, 0, 0)),
|
|
("lines", |n| slice_bytes_remaining(200, n.max(1), 0, 0, 0)),
|
|
] {
|
|
let mut previous = usize::MAX;
|
|
for exponent in 0..60 {
|
|
let width = at(1usize << exponent);
|
|
assert!(
|
|
width <= previous,
|
|
"slice widened from {previous} to {width} at {axis} \
|
|
2^{exponent}: more expensive grid, more generous slice",
|
|
);
|
|
assert!(width >= 1);
|
|
previous = width;
|
|
}
|
|
}
|
|
|
|
// Just past 32 bits on one axis: large enough that a narrowing cast
|
|
// shows (`1 << 32` truncates to 0 in `u32`, pricing an enormous grid at
|
|
// nothing), small enough that the honest answer is exact rather than
|
|
// saturated. Neither the extreme endpoints above nor the ordinary grids
|
|
// below can see this -- the endpoints saturate either way and the
|
|
// ordinary ones fit in 32 bits.
|
|
assert_eq!(max_atom_work(1 << 32, 1, 0), 2 * (1u64 << 32));
|
|
assert_eq!(max_atom_work(1, 1 << 32, 0), 2 * (1u64 << 32));
|
|
assert_eq!(max_atom_work(1, 1, 1 << 32), 2 + (1u64 << 32));
|
|
|
|
// Ordinary grids are untouched by the saturation: exact, not clamped.
|
|
assert_eq!(max_atom_work(80, 24, 0), 2 * 80 * 24);
|
|
assert_eq!(max_atom_work(80, 24, 100), 2 * 80 * 24 + 100 * 80);
|
|
}
|
|
|
|
/// An escape split across slices keeps its escape metering.
|
|
///
|
|
/// Kills: deciding "plain run or escape?" by looking only at the bytes ahead.
|
|
/// After a slice ending on a lone `ESC`, the next byte is `c` -- which looks
|
|
/// like ordinary text and is in fact a full grid reset. Meter it as text and
|
|
/// the oversized atom rides into a wide slice with whatever follows, which is
|
|
/// the post-atom overrun arriving through a different door. Found by the
|
|
/// oversized-atom fixture failing after I "optimised" the plain path, which
|
|
/// is the argument for keeping both.
|
|
#[test]
|
|
fn an_escape_split_across_slices_keeps_its_metering() {
|
|
let size = Size {
|
|
columns: 400,
|
|
screen_lines: 100,
|
|
scrollback: 10_000,
|
|
};
|
|
let ris_work =
|
|
2 * (size.columns * size.screen_lines) as u64 + (size.scrollback * size.columns) as u64;
|
|
|
|
// Deliver the escape one byte at a time, so the parser is left mid-
|
|
// sequence with a tail that begins on the continuation byte.
|
|
let (mut term, _a) = Terminal::new(size, Fences::ALL);
|
|
term.feed(b"\x1b");
|
|
assert_eq!(
|
|
term.stats().completed_units,
|
|
0,
|
|
"ESC alone dispatches nothing"
|
|
);
|
|
|
|
let more = term.feed(b"cXmore");
|
|
|
|
assert!(more, "the completed RIS must still yield with a tail");
|
|
assert_eq!(
|
|
term.stats().completed_units,
|
|
1,
|
|
"the continuation byte completed a grid reset; nothing may run after it",
|
|
);
|
|
assert_eq!(term.stats().completed_work, ris_work);
|
|
|
|
while term.drain() {}
|
|
assert_eq!(term.stats().completed_units, 1 + 5);
|
|
assert_eq!(term.pending_bytes(), 0);
|
|
}
|