Skip to content

Make Scrollback live, instead of read once and never again - #108

Merged
JohnCampionJr merged 2 commits into
mainfrom
live-scrollback
Aug 29, 2026
Merged

JohnCampionJr merged 2 commits into
mainfrom
live-scrollback

Conversation

@JohnCampionJr

Copy link
Copy Markdown
Collaborator

Found while auditing what the downstream Avalonia terminal needs for 2.0 — it forwards its own Scrollback setter straight through to _terminal.Options.Scrollback, which has been doing nothing.

The bug

Options.Scrollback is read exactly once, at Terminal.cs:456, to size the buffer ring. Nothing reads it afterwards. So a host lowering it to reclaim memory, or raising it because the user asked, sets a property that reports the new value, changes nothing, and says nothing about it.

That is the same shape as the aliasing bug #101 described, one level down: a settable property whose write silently fails to reach the thing it names.

The fix

A hook the terminal installs on the snapshot it owns. This is only safe because of #106 — options are snapshotted at construction, so exactly one terminal reads any given instance and a single callback has one unambiguous owner. Two deliberate details:

  • the copy constructor does not carry the hook, because a clone belongs to whoever cloned it and copying it would let one terminal resize another's history;
  • Dispose clears it, since the options object outlives the terminal whenever the caller kept its reference.

Shrinking drops the oldest, which CircularList.Resize will not do for you

Resize keeps the front of the list. For a scrollback that is precisely backwards — it discards the screen the user is looking at and keeps the history nobody asked to keep. SetScrollback therefore trims the front first, then recomputes the viewport against what is left rather than shifting it by the trim amount. That second part is the same care the existing resize path takes, for the same reason recorded there: a viewport held a fixed distance from rows that no longer exist leaves the live bottom unseen, and everything written afterwards lands outside the visible area.

The alternate screen has no history by definition and gains none from this.

Tests

Seven, in a LiveOptionsTests class rather than beside the DTO ones, because what they exercise is the terminal reacting rather than the property storing. Three fail against the previous commit. The others pin the parts that were already right: the alternate screen, a no-op write, and the snapshot contract from #101 — making this live must not quietly re-alias the caller's object to the terminal.

2016 tests green.

Follow-up

Scrollback was the one I tripped over. The general question — which other options are read once and which are genuinely live — is worth answering before 2.0 rather than after, and I am auditing that next.

Options.Scrollback was read at Terminal.cs:456 to size the buffer ring and
never looked at again. A host lowering it to reclaim memory, or raising it
because the user asked, set a property that reported the new value, changed
nothing, and said nothing -- and the downstream Avalonia terminal does
exactly that, forwarding its own Scrollback setter straight through.

It reaches the buffer now, through a hook the terminal installs on the
snapshot it owns. That is only safe because options are snapshotted at
construction (#106): exactly one terminal reads any given instance, so a
single callback has one unambiguous owner. The copy constructor
deliberately does not carry the hook -- a clone belongs to whoever cloned
it, and copying it would let one terminal resize another's history -- and
Dispose clears it, since the options object outlives the terminal whenever
the caller kept its reference.

Shrinking trims the OLDEST lines. CircularList.Resize keeps the front of the
list, which for a scrollback is precisely backwards: it would discard the
screen the user is looking at and keep the history nobody asked to keep. So
SetScrollback trims the front first and recomputes the viewport against what
is left rather than shifting it by the trim amount -- the same care the
resize path already takes, and for the same reason: a viewport held a fixed
distance from rows that no longer exist leaves the live bottom unseen and
everything written afterwards landing outside the visible area.

The alternate screen has no history by definition and gains none from this.

Seven tests, in a LiveOptionsTests class rather than beside the DTO ones,
because what they exercise is the terminal reacting rather than the property
storing. Three fail against the previous commit; the rest pin the parts that
were already right -- the alternate screen, a no-op write, and the snapshot
contract from #101, which making this live must not quietly re-alias.
@github-actions

github-actions Bot commented Aug 29, 2026 •

Copy link
Copy Markdown
Contributor

Perf comparison — this change, against its base

3 run(s) of each side, alternating on one machine. Allocation is a count and is gated exactly. Time is a measurement, so its gate is derived from the spread this job just observed in itself rather than fixed in advance.

corpus bytes/char gen0/Mchar ns/char Δ time noise gate
scroll-ascii 0.00 → 0.00 0.00 → 0.00 2.71 → 2.70 -0.4% ±0% 4%
sgr-churn 0.00 → 0.00 0.00 → 0.00 7.23 → 7.23 +0.0% ±1% 4%
truecolor 0.00 → 0.00 0.00 → 0.00 7.72 → 7.60 -1.6% ±2% 6%
alt-redraw 0.00 → 0.00 0.00 → 0.00 11.04 → 11.00 -0.3% ±4% 12%
unicode 7.66 → 7.66 0.45 → 0.45 27.48 → 27.62 +0.5% ±5% 16%
flood 0.00 → 0.00 0.00 → 0.00 81.47 → 81.32 -0.2% ±0% 4%

Each corpus is gated at max(4%, 3 × its own noise). A wide noise column means this runner was busy and the timing half of the table should be read as advisory; the allocation half is exact either way.

assemblies measured
  • base: XTerm.NET 2.0.0.0 mvid:547d93a1-b815-4e4d-ada2-90e0b8e7932b
  • head: XTerm.NET 2.0.0.0 mvid:fabcf13a-1d06-45b3-bdd5-69a7fe54508c

Perf comparison — cumulative, everything since 2.0.0-rc002

3 run(s) of each side, alternating on one machine. Allocation is a count and is gated exactly. Time is a measurement, so its gate is derived from the spread this job just observed in itself rather than fixed in advance.

corpus bytes/char gen0/Mchar ns/char Δ time noise gate
scroll-ascii 0.00 → 0.00 0.00 → 0.00 2.71 → 2.70 -0.2% ±0% 5%
sgr-churn 0.00 → 0.00 0.00 → 0.00 7.08 → 7.23 +2.1% ±1% 5%
truecolor 0.00 → 0.00 0.00 → 0.00 7.42 → 7.60 +2.4% ±1% 5%
alt-redraw 0.00 → 0.00 0.00 → 0.00 10.89 → 11.00 +1.0% ±2% 6%
unicode 7.66 → 7.66 0.45 → 0.45 25.94 → 27.62 +6.5% ⚠️ ±2% 5%
flood 0.00 → 0.00 0.00 → 0.00 81.59 → 81.32 -0.3% ±0% 5%

Each corpus is gated at max(5%, 3 × its own noise). A wide noise column means this runner was busy and the timing half of the table should be read as advisory; the allocation half is exact either way.

assemblies measured
  • base: XTerm.NET 2.0.0.0 mvid:1b573f7c-2562-4a81-9dc9-0cdd9bd9fb1d
  • head: XTerm.NET 2.0.0.0 mvid:fabcf13a-1d06-45b3-bdd5-69a7fe54508c

Regressions

  • unicode time 25.94 → 27.62 ns/char (6.5%, past its 5.0% gate, noise ±1.7%)

The audit that found Scrollback turned up three more options whose writes
did not reach what they name. All 94 were checked; these are the ones that
were wrong.

Theme was read once, to build the palette, and never again -- so an embedder
following the OS light/dark setting assigned a new theme and watched nothing
happen. ColorPalette.ApplyTheme already existed for exactly this and its own
doc calls itself "the runtime path for an embedder following the OS
light/dark setting", so the intent was always there; the option was simply
not wired to it. Assignment is what is observed, not mutation of a theme
object already assigned: the object is shared rather than copied, which the
remarks now say.

TabStopWidth was read only by the reset that runs at construction, on a
resize and on RIS. So setting it did nothing, and then did something later
when an unrelated resize happened to run the reset -- worse than either,
because the change looked ignored rather than pending. It lays the stops out
immediately now, discarding any an application placed with HTS, because that
is what changing the width means.

Options.Cols and Options.Rows were never written back, so they went on
reporting the size the terminal was BUILT with while Terminal.Cols reported
the size it is. Two public properties of the same name disagreeing is worse
than one stale value, since nothing about reading either says which to
believe. Resize keeps them in step. Writing them still does not resize:
honouring each separately would resize twice, the first time through a
geometry the caller never asked for, reflowing the buffer through a shape
that existed only between two statements.

Four tests, all failing against the previous commit. The rest of the audit
came back clean: about two dozen options are read at the point of use every
time, and the eighteen the emulator never reads -- fonts, bell, renderer,
scroll sensitivity -- are host concerns in a headless library rather than
gaps.
@JohnCampionJr

Copy link
Copy Markdown
Collaborator Author

Scrollback was the one I tripped over, so I audited the other 93 options for the same failure: a write that reports the new value and never reaches what it names. Three more were wrong, and they are in this PR now.

Theme — read once, never again

Colors = new ColorPalette(Options.Theme) runs at construction and Colors is { get; }, so it is never rebuilt. An embedder following the OS light/dark setting assigned a new theme and watched nothing happen.

ColorPalette.ApplyTheme already existed for exactly this, and its own doc comment calls itself "the runtime path for an embedder following the OS light/dark setting" — so the intent was always there, the option just was not wired to it.

Assignment is what is observed, not mutation of a theme object already assigned: that object is shared rather than copied, and the remarks say so rather than leaving it to be discovered.

TabStopWidth — deferred, which is worse than dead

ResetTabStops() reads it, but only runs at construction, on a resize and on RIS. So setting it did nothing, and then did something later when an unrelated resize happened to run the reset. A change that looks ignored and then fires is harder to reason about than one that never fires at all.

It lays the stops out immediately now. Stops an application placed with HTS are discarded, because that is what changing the width means.

Options.Cols / Options.Rows — a read that lies

Never written back, so they went on reporting the size the terminal was built with while Terminal.Cols reported the size it is. Two public properties of the same name disagreeing is worse than one stale value: nothing about reading either tells you which to believe.

Resize keeps them in step now. Writing them still does not resize, deliberately — honouring each separately would resize twice, the first time through a width-and-height pair the caller never asked for, reflowing the buffer through a geometry that existed only between two statements. Resize remains the atomic path and the remarks now say that where someone will look for it.

What the audit found clean

  • ~24 options are genuinely live, read at the point of use every time: Wraparound, the graphics and clipboard gates, KittyKeyboardEnabled, every Max* limit, the cell-pixel metrics, DisplayScale, TermName, ConvertEol, CursorStyle/CursorBlink, and all the WindowOptions flags.
  • ~18 have no emulator reads and should not — fonts, bell, LineHeight, LetterSpacing, RendererType, ScrollSensitivity, CursorBlinkRate. Host concerns in a headless library.

Two of those are worth a second opinion rather than a change: MinimumContrastRatio and DrawBoldTextInBrightColors are colour-resolution concerns that xterm.js handles inside the emulator, and here they have no consumer anywhere in src/. They may be correctly host-only, but as written they are options that do nothing and say nothing about who is meant to honour them.

One thing I noticed and did not touch

RIS does not appear to reset the colour palette — the ResetAllColors() call is in the OSC 104 handler, not ResetTerminal. I believe xterm's full reset restores default colours, but I have not confirmed that against the reference and did not want to assert it from memory in a PR about something else.

2020 tests. The four new ones fail against the previous commit.

@JohnCampionJr

Copy link
Copy Markdown
Collaborator Author

Perf: this change is neutral, and the failure is not about it.

The per-PR table is flat on every corpus — scroll-ascii -0.4%, sgr-churn +0.0%, truecolor -1.6%, alt-redraw -0.3%, unicode +0.5%, flood -0.2%, nothing flagged — which is what you would expect from a change that touches option setters and a buffer method nothing on the print path calls.

What failed is the cumulative gate: unicode 25.94 → 27.62 ns/char against 2.0.0-rc002. That is #94's wide-cell invariant, measured and accepted on its own PR, arriving here because the cumulative baseline still points at a tag that predates it.

So this will now happen to every PR, whatever it changes, until a release tag is cut on current main. Tagging rc003 is what turns that accepted ~5% into the new baseline instead of a check that fails forever — and leaving it is the more dangerous option, because a perf gate that is always red stops being read.

Nothing to change in this PR for it.

@JohnCampionJr
JohnCampionJr merged commit f86cb26 into main Aug 29, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant