Description
renderStateGetViewport in the WASM build resolves the viewport top-left position independently for each row using pages.pin(.active). When the viewport spans multiple internal pages, this per-row resolution can produce inconsistent results, leading to corrupted viewport data.
Additionally, scrollbackLimit is passed as a line count but Terminal.init() interprets max_scrollback as bytes, causing the scrollback buffer to be much smaller than intended and making the page-spanning condition occur more frequently.
Symptoms
getScrollbackLength() drops unexpectedly (e.g., 498 → 269) after repeated writes
getViewport() returns rows with content from different terminal lines merged together
- Both
getViewport() and getLine() return the same wrong data
- Corruption frequency depends on column width:
- cols=80: OK
- cols=120: CORRUPT
- cols=130: CORRUPT
- cols=140: OK
- cols=160: scrollback drops but viewport may appear OK (merge lands on empty rows)
Root cause
The native Ghostty renderer reads cell data from cached row pins in RenderState.row_data (built during update()), which guarantees consistent page resolution across all viewport rows. The WASM renderStateGetViewport instead calls pages.pin(.active) per-row, which can resolve to different pages inconsistently when the viewport straddles a page boundary.
Reproduction
- Create a terminal at cols=130, rows=39, scrollback=10000
- Repeatedly write ~68 lines of escape-heavy output (SGR 256-color, truecolor, attributes)
- After ~8 repetitions, call
getScrollbackLength() and getViewport()
- Scrollback length will drop and/or viewport rows will contain merged content
Expected behavior
getViewport() should return consistent, correct row data regardless of internal page layout. scrollbackLimit should be interpreted consistently between the JS config and WASM init.
Fix
Draft PR with fix and regression tests: #133