Skip to content

Commit 180c09b

Browse files
fix(terminal): make the 10 000-line scrollback promise true
libghostty's `Options::max_scrollback` is documented as a line count and is a byte budget for the history page list. Passing 10 000 for "10 000 lines" bought one page: 745 rows at 80 columns, 456 at 200, 3 310 at 20, and doubling the number changed nothing because both values are smaller than a page. A session that promised a 10 000-line replay window delivered 7% of it, and `scrollback_capacity()` reported a number that described nothing. The line count is now converted to a byte budget that scales with the width (256 + 16 bytes per column per line, against a measured ~838 at 80 columns and ~1 804 at 200), capped at 64 MiB. `scrollback()` reports what is retainable, `scrollback_request()` what was asked for, and `scrollback_bytes()` the budget libghostty holds it in — so the reported numbers describe the terminal rather than the request. Two consequences are recorded rather than hidden: capacity is a guaranteed minimum instead of Node's ceiling, because libghostty never holds less than one page; and widening a terminal lowers the line count it can retain, because libghostty takes the budget in `Options` and exposes no setter. docs/decisions/0013-scrollback-is-a-line-promise.md has the measurements, the memory cost, and the remaining gap. Refs #3 agent-identity: dev3.direct.omp.2gz9tcpa agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.1.2 agent-runtime: OMP 18.1.2 tooling-profile: dotfiles@7534055
1 parent 5af7660 commit 180c09b

3 files changed

Lines changed: 341 additions & 7 deletions

File tree

crates/pty-terminal/src/actor.rs

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,50 @@ use crate::serialize::{self, SerializeOpts};
2424
use crate::snapshot::{self, CellGrid};
2525
use crate::strip::{OutputScanner, Osc, Token};
2626

27-
/// Node's scrollback (`src/server.ts:333-338`).
27+
/// Node's scrollback (`src/server.ts:333-338`), in lines.
2828
pub const DEFAULT_SCROLLBACK: usize = 10_000;
2929

30+
/// The most memory one terminal's history may be given, whatever scrollback
31+
/// it was asked for: 64 MiB, which is 10 000 lines of a 400-column terminal.
32+
///
33+
/// A session that asks for more history than this gets what fits and says so
34+
/// ([`TerminalActor::scrollback`] reports what is actually retainable, not
35+
/// what was requested), because a silently unmet promise is what this whole
36+
/// conversion exists to remove.
37+
pub const MAX_SCROLLBACK_BYTES: usize = 64 * 1024 * 1024;
38+
39+
/// libghostty's `max_scrollback` is a **byte** budget for the history page
40+
/// list, not a line count — its own doc comment says "lines", but the
41+
/// behaviour is bytes and the page list evicts whole pages. Passing 10 000
42+
/// for "10 000 lines" buys one page: 745 rows at 80 columns, 456 at 200,
43+
/// 3 310 at 20. A line promise therefore has to be converted, and the cost
44+
/// of a row depends on how wide it is.
45+
///
46+
/// These two numbers are the conversion, measured against libghostty-vt
47+
/// 0.2.1: a plain row costs ~838 bytes at 80 columns and ~1 804 at 200, so
48+
/// ~9-10 bytes per column plus per-row overhead. They are deliberately
49+
/// generous — roughly 1.8x the measured cost — because the page list rounds
50+
/// up to whole pages and because a row of styled or multi-codepoint cells
51+
/// costs more than a plain one. Under-budgeting loses history; over-budgeting
52+
/// costs address space that is only touched when the history actually fills.
53+
const SCROLLBACK_BYTES_PER_COL: usize = 16;
54+
/// Fixed per-row cost, independent of width. See
55+
/// [`SCROLLBACK_BYTES_PER_COL`].
56+
const SCROLLBACK_ROW_OVERHEAD: usize = 256;
57+
58+
/// What one row of a `cols`-wide terminal costs in the history.
59+
fn scrollback_row_bytes(cols: u16) -> usize {
60+
SCROLLBACK_ROW_OVERHEAD + SCROLLBACK_BYTES_PER_COL * cols.max(1) as usize
61+
}
62+
63+
/// The byte budget that retains `lines` lines of a `cols`-wide terminal,
64+
/// capped at [`MAX_SCROLLBACK_BYTES`].
65+
fn scrollback_budget(lines: usize, cols: u16) -> usize {
66+
lines
67+
.saturating_mul(scrollback_row_bytes(cols))
68+
.min(MAX_SCROLLBACK_BYTES)
69+
}
70+
3071
/// A desktop notification the child asked for (OSC 9, 99, or 777).
3172
/// Shapes follow Node (`src/server.ts:421-454`).
3273
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -156,7 +197,12 @@ pub struct TerminalActor {
156197
modes: Modes,
157198
events: Vec<TerminalEvent>,
158199
last_title: Option<String>,
159-
scrollback: usize,
200+
/// Lines of history the owner asked for.
201+
scrollback_request: usize,
202+
/// The byte budget libghostty was given for the history. Fixed at
203+
/// construction: libghostty takes it in `Options` and exposes no setter,
204+
/// so a resize changes how many lines it holds, not how much memory.
205+
scrollback_bytes: usize,
160206
/// How big a cell is on the surface that draws this terminal, zero when
161207
/// undeclared. It comes from the client (a font on its host), travels on
162208
/// ATTACH and RESIZE, and decides the cell extent of any placement that
@@ -176,12 +222,17 @@ pub struct TerminalActor {
176222

177223
impl TerminalActor {
178224
/// A terminal of `rows` x `cols` with `scrollback` lines of history.
225+
///
226+
/// The line count is converted to the byte budget libghostty actually
227+
/// takes (see [`MAX_SCROLLBACK_BYTES`]); [`TerminalActor::scrollback`]
228+
/// reports how many lines that buys at the current width.
179229
pub fn new(rows: u16, cols: u16, scrollback: usize) -> TerminalActor {
180230
let shared: Rc<RefCell<Shared>> = Rc::new(RefCell::new(Shared::default()));
231+
let scrollback_bytes = scrollback_budget(scrollback, cols);
181232
let mut term = Terminal::new(Options {
182233
cols: cols.max(1),
183234
rows: rows.max(1),
184-
max_scrollback: scrollback,
235+
max_scrollback: scrollback_bytes,
185236
})
186237
.expect("libghostty terminal");
187238
{
@@ -210,7 +261,8 @@ impl TerminalActor {
210261
modes: Modes::default(),
211262
events: Vec::new(),
212263
last_title: None,
213-
scrollback,
264+
scrollback_request: scrollback,
265+
scrollback_bytes,
214266
cell: CellSize::default(),
215267
graphics: None,
216268
normal_replay: None,
@@ -653,13 +705,38 @@ impl TerminalActor {
653705
}
654706

655707
/// Node's `scrollbackCapacity`: `rows + scrollback`.
708+
///
709+
/// A guaranteed minimum, not a ceiling. libghostty's history is a list of
710+
/// pages and it never holds less than one, so a terminal asked for a
711+
/// small scrollback retains more than it promised (a 100-line request at
712+
/// 80 columns keeps ~1 000 rows). Node's number is a ceiling because
713+
/// xterm counts lines; this one is a floor because libghostty counts
714+
/// bytes and rounds to pages
715+
/// (docs/decisions/0013-scrollback-is-a-line-promise.md).
656716
pub fn scrollback_capacity(&self) -> usize {
657-
self.rows() as usize + self.scrollback
717+
self.rows() as usize + self.scrollback()
658718
}
659719

660-
/// Configured scrollback lines.
720+
/// How many lines of history this terminal retains at its current width.
721+
///
722+
/// Normally the line count the owner asked for. It is less when the byte
723+
/// budget cannot buy that many — either because the request exceeded
724+
/// [`MAX_SCROLLBACK_BYTES`], or because the terminal has since been
725+
/// widened and libghostty's budget is fixed at construction. Reporting
726+
/// the request in that case is what made the promise a lie.
661727
pub fn scrollback(&self) -> usize {
662-
self.scrollback
728+
let fits = self.scrollback_bytes / scrollback_row_bytes(self.cols());
729+
self.scrollback_request.min(fits)
730+
}
731+
732+
/// Lines of history the owner asked for, whether or not they fit.
733+
pub fn scrollback_request(&self) -> usize {
734+
self.scrollback_request
735+
}
736+
737+
/// The byte budget libghostty holds the history in.
738+
pub fn scrollback_bytes(&self) -> usize {
739+
self.scrollback_bytes
663740
}
664741

665742
/// Node's `baseY`: the buffer row where the active area starts.
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
//! The scrollback promise: a terminal asked for N lines of history retains N
2+
//! lines, and says so.
3+
//!
4+
//! libghostty's `max_scrollback` is a byte budget for the history page list,
5+
//! not a line count — its own doc comment says "lines" — and the page list
6+
//! evicts whole pages. Passing 10 000 for "10 000 lines" buys one page: 745
7+
//! rows at 80 columns, 456 at 200, 3 310 at 20. Every case here is about the
8+
//! conversion that makes the declared line count true, and about the reported
9+
//! numbers being the truth rather than the request.
10+
11+
use pty_terminal::actor::{DEFAULT_SCROLLBACK, MAX_SCROLLBACK_BYTES};
12+
use pty_terminal::{Range, TerminalActor};
13+
14+
/// Feed `n` numbered lines, each short enough that it cannot wrap, so a row
15+
/// written is a row of history and nothing here depends on reflow.
16+
fn fill(a: &mut TerminalActor, n: usize) {
17+
for i in 0..n {
18+
a.write(format!("L{i}\r\n").as_bytes());
19+
}
20+
}
21+
22+
/// The oldest of 10 000 lines is still reachable, at the geometry the
23+
/// terminal was built with. This is the substrate's promised replay window;
24+
/// before the byte conversion it retained 745 of them.
25+
#[test]
26+
fn ten_thousand_lines_of_history_are_all_retained() {
27+
let mut a = TerminalActor::new(24, 80, DEFAULT_SCROLLBACK);
28+
fill(&mut a, 10_008);
29+
30+
// 10 008 written lines plus the row the cursor sits on, all within the
31+
// 10 024-row capacity of a 24-row terminal with 10 000 lines of history.
32+
assert!(
33+
a.buffer_length() >= 10_008,
34+
"expected every line retained, got {} rows",
35+
a.buffer_length()
36+
);
37+
let text = a.plain(Range::Full);
38+
let first = text.lines().next().unwrap_or("");
39+
assert_eq!(first, "L0", "the oldest line is still the oldest line");
40+
assert!(text.contains("\nL9999\n"), "and the newest are there too");
41+
}
42+
43+
/// The same promise at widths where the byte cost of a row differs by 5x. A
44+
/// budget derived from the line count has to scale with the width, or a wide
45+
/// terminal keeps a fraction of the history a narrow one does.
46+
#[test]
47+
fn the_promise_holds_at_every_width() {
48+
for cols in [20u16, 80, 200, 400] {
49+
let mut a = TerminalActor::new(24, cols, 10_000);
50+
fill(&mut a, 10_008);
51+
assert!(
52+
a.buffer_length() >= 10_008,
53+
"{cols} columns: expected 10 008 rows, got {}",
54+
a.buffer_length()
55+
);
56+
assert_eq!(
57+
a.plain(Range::Full).lines().next().unwrap_or(""),
58+
"L0",
59+
"{cols} columns: the oldest line was evicted"
60+
);
61+
}
62+
}
63+
64+
/// A small scrollback is a line promise too, and the promise is a floor: the
65+
/// history is a list of pages and libghostty never holds less than one, so a
66+
/// terminal asked for 100 lines keeps at least 100 and in practice more.
67+
/// Bounded, though — a terminal asked for 100 lines does not keep 5 000.
68+
#[test]
69+
fn a_small_scrollback_keeps_at_least_what_it_promised() {
70+
let mut a = TerminalActor::new(24, 80, 100);
71+
fill(&mut a, 5_000);
72+
let text = a.plain(Range::Full);
73+
for i in 4_900..4_999 {
74+
assert!(
75+
text.contains(&format!("L{i}\n")),
76+
"the promised window must be there: L{i} is missing"
77+
);
78+
}
79+
assert!(text.contains("L4999"), "including the newest line");
80+
assert!(!text.contains("L0\n"), "and the far past is evicted");
81+
assert!(
82+
a.buffer_length() < 5_000,
83+
"a 100-line request must not keep 5 000 rows, got {}",
84+
a.buffer_length()
85+
);
86+
}
87+
88+
/// `scrollback_used` and `scrollback_capacity` are the numbers a consumer
89+
/// budgets against, so they have to describe the terminal rather than the
90+
/// request.
91+
#[test]
92+
fn used_and_capacity_are_honest() {
93+
let mut a = TerminalActor::new(24, 80, DEFAULT_SCROLLBACK);
94+
assert_eq!(a.scrollback(), DEFAULT_SCROLLBACK);
95+
assert_eq!(a.scrollback_capacity(), 24 + DEFAULT_SCROLLBACK);
96+
assert_eq!(a.scrollback_used(), 24, "an empty terminal is its viewport");
97+
98+
fill(&mut a, 10_008);
99+
assert_eq!(a.scrollback_used(), a.buffer_length());
100+
// Capacity is a floor, not a ceiling (see its doc comment): what it
101+
// promises has to actually be there.
102+
assert!(
103+
a.scrollback_used() >= 10_008,
104+
"capacity is not honest if the rows are not there: {}",
105+
a.scrollback_used()
106+
);
107+
}
108+
109+
/// A request beyond the memory bound is reported as what it is. libghostty
110+
/// takes the budget at construction and exposes no setter, so this cannot be
111+
/// fixed by asking for more later — it can only be told truthfully.
112+
#[test]
113+
fn a_request_beyond_the_memory_bound_reports_what_fits() {
114+
let a = TerminalActor::new(24, 80, 10_000_000);
115+
assert_eq!(a.scrollback_request(), 10_000_000);
116+
assert_eq!(a.scrollback_bytes(), MAX_SCROLLBACK_BYTES);
117+
assert!(
118+
a.scrollback() < 10_000_000,
119+
"the request cannot be met and must not be reported as met"
120+
);
121+
assert!(
122+
a.scrollback() > DEFAULT_SCROLLBACK,
123+
"but the bound still buys more than the default: {}",
124+
a.scrollback()
125+
);
126+
assert_eq!(a.scrollback_capacity(), 24 + a.scrollback());
127+
}
128+
129+
/// Widening the terminal makes each row cost more out of a budget fixed at
130+
/// construction, so the retainable line count drops. The reported number
131+
/// follows it instead of repeating the original promise.
132+
#[test]
133+
fn a_widened_terminal_reports_the_history_it_can_still_hold() {
134+
let mut a = TerminalActor::new(24, 80, DEFAULT_SCROLLBACK);
135+
assert_eq!(a.scrollback(), DEFAULT_SCROLLBACK);
136+
let budget = a.scrollback_bytes();
137+
138+
a.resize(400, 24);
139+
assert_eq!(a.scrollback_bytes(), budget, "the budget does not move");
140+
assert!(
141+
a.scrollback() < DEFAULT_SCROLLBACK,
142+
"a five-times wider row cannot hold the same line count: {}",
143+
a.scrollback()
144+
);
145+
assert_eq!(a.scrollback_capacity(), 24 + a.scrollback());
146+
assert_eq!(a.scrollback_request(), DEFAULT_SCROLLBACK);
147+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# 0013 — scrollback is a line promise, and libghostty counts bytes
2+
3+
**Status:** accepted
4+
5+
**Node behavior.** Node's daemon passes `scrollback: 10000` to
6+
`xterm-headless` (`src/server.ts:333-338`), where it is a line count: the
7+
buffer holds 10 000 lines of history and evicts the 10 001st line. `pty stats`
8+
reports `scrollbackUsed` (`buffer.active.length`, `src/server.ts:1128`) against
9+
`scrollbackCapacity` (`rows + scrollback`), and that capacity is a ceiling the
10+
buffer never exceeds.
11+
12+
**Rust behavior.** `TerminalActor::new(rows, cols, scrollback)` still takes a
13+
line count, and now converts it to the byte budget libghostty actually wants.
14+
15+
libghostty's `Options::max_scrollback` is documented as "maximum number of
16+
lines to keep in scrollback history" and is not: it is a byte budget for the
17+
history page list, and the list evicts whole pages. Measured against
18+
libghostty-vt 0.2.1, a terminal given `max_scrollback: 10_000` and fed 10 008
19+
short lines keeps:
20+
21+
| columns | rows retained | oldest line surviving |
22+
| --- | --- | --- |
23+
| 20 | 3 310 | `L6698` |
24+
| 80 | 745 | `L9263` |
25+
| 200 | 456 | `L9552` |
26+
| 400 | 149 | `L9859` |
27+
28+
The retained count scales inversely with the width, which a line count cannot
29+
do, and doubling the number to 20 000 changes nothing at 80 columns — both are
30+
smaller than one page, and one page is the floor. Passing a line count straight
31+
through therefore delivered 7% of the promised history at 80 columns, and the
32+
number `scrollback_capacity()` reported (`24 + 10_000`) described nothing that
33+
existed.
34+
35+
The conversion is `SCROLLBACK_ROW_OVERHEAD + SCROLLBACK_BYTES_PER_COL * cols`
36+
per line (256 + 16), against a measured cost of ~838 bytes per row at 80
37+
columns and ~1 804 at 200 — roughly 1.8x headroom, because the page list rounds
38+
up to whole pages and because a row of styled or multi-codepoint cells costs
39+
more than a plain one. The total is capped at `MAX_SCROLLBACK_BYTES` (64 MiB,
40+
which is 10 000 lines of a 400-column terminal).
41+
42+
Three reads describe the result instead of the request:
43+
44+
- `scrollback()` — lines actually retainable at the current width.
45+
- `scrollback_request()` — what the owner asked for, met or not.
46+
- `scrollback_bytes()` — the budget libghostty holds the history in.
47+
48+
**Why.** The alternative was to leave the number as libghostty takes it and
49+
weaken the promise to "up to N lines", which is what the first Fractal test did
50+
when it asserted `used <= capacity` — an assertion that passes when 6 398 of
51+
10 008 lines have been thrown away. A replay window is a product promise: a
52+
consumer that shows history decides what to keep on the basis of that number,
53+
and the honest options were to meet it or to publish a smaller one. Meeting it
54+
costs memory that is bounded, documented, and only touched when the history
55+
actually fills; publishing a smaller one would have meant every consumer
56+
carrying its own conversion from lines to whatever libghostty's number means
57+
this release.
58+
59+
libghostty exposes the budget only in `Options`, with no setter, so it is fixed
60+
at construction. That is the source of the one remaining gap below.
61+
62+
**Client effect.** A session asked for 10 000 lines retains 10 000 lines at the
63+
width it was created with, and `stats` reports numbers that are true. Memory
64+
per session with the default 10 000 lines: 14.6 MiB of budget at 80 columns
65+
(33 MiB at 200, 63.5 MiB at 400), against ~1 MiB before — the budget is address
66+
space the page list fills only as history accumulates, so an idle session pays
67+
nothing.
68+
69+
Residual differences a consumer can observe:
70+
71+
1. `scrollback_capacity()` is a guaranteed minimum, where Node's is a ceiling.
72+
libghostty never holds less than one page, so a terminal asked for a small
73+
scrollback keeps more than it promised: a 100-line request at 80 columns
74+
retains about 1 000 rows. The promise is met and then some; code that
75+
treated the number as an upper bound on `scrollback_used` has to stop.
76+
2. Widening a terminal reduces the line count it can retain, because the byte
77+
budget is fixed at construction and a wider row costs more. `scrollback()`
78+
and `scrollback_capacity()` follow the width down; `scrollback_request()`
79+
keeps saying what was asked for. A terminal created at 80 columns and
80+
widened to 400 holds about a fifth of the lines. Fixing this needs either a
81+
libghostty setter for the budget (upstream) or budgeting for the widest
82+
plausible width at construction (10 000 lines at 1 000 columns is 154 MiB
83+
per session, which is not worth it).
84+
3. A request whose budget exceeds `MAX_SCROLLBACK_BYTES` is clamped, and
85+
`scrollback()` reports the clamped line count rather than the request.
86+
87+
**Test.** `crates/pty-terminal/tests/scrollback.rs` — six cases, all of which
88+
fail on the pass-through:
89+
`ten_thousand_lines_of_history_are_all_retained` (the oldest of 10 008 lines is
90+
still `L0`, at 24x80 with the default scrollback),
91+
`the_promise_holds_at_every_width` (the same at 20, 80, 200 and 400 columns,
92+
where the per-row cost differs by 5x),
93+
`a_small_scrollback_keeps_at_least_what_it_promised` (the promised window is
94+
present and the far past is gone),
95+
`used_and_capacity_are_honest`,
96+
`a_request_beyond_the_memory_bound_reports_what_fits`, and
97+
`a_widened_terminal_reports_the_history_it_can_still_hold`.
98+
99+
Every line written by these tests is short enough that it cannot wrap, so no
100+
result here depends on reflow: a row written is a row of history.
101+
102+
No gated `_node` / `_rust` conformance pair exists for the byte conversion
103+
itself — it is an implementation detail of reaching Node's behaviour, not a
104+
deviation from it. The observable deviations are the three above.
105+
106+
**Migration / negotiation.** None. `TerminalActor::new` and
107+
`SpawnOptions`/`AttachOptions` still take a line count and now honour it; a
108+
consumer reading `scrollback_capacity()` as a ceiling should read it as a floor
109+
(residual 1), and one that resizes should re-read `scrollback()` rather than
110+
assume the original number (residual 2).

0 commit comments

Comments
 (0)