Make Scrollback live, instead of read once and never again - #108
Conversation
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.
Perf comparison — this change, against its base3 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.
Each corpus is gated at assemblies measured
Perf comparison — cumulative, everything since 2.0.0-rc0023 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.
Each corpus is gated at assemblies measured
Regressions
|
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.
|
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.
|
|
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: So this will now happen to every PR, whatever it changes, until a release tag is cut on current Nothing to change in this PR for it. |
Found while auditing what the downstream Avalonia terminal needs for 2.0 — it forwards its own
Scrollbacksetter straight through to_terminal.Options.Scrollback, which has been doing nothing.The bug
Options.Scrollbackis read exactly once, atTerminal.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:
Disposeclears it, since the options object outlives the terminal whenever the caller kept its reference.Shrinking drops the oldest, which
CircularList.Resizewill not do for youResizekeeps 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.SetScrollbacktherefore 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
LiveOptionsTestsclass 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
Scrollbackwas 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.