fix(app): bound terminal snapshot serialization on teardown (#47924)

This commit is contained in:
Luke Parker
2026-09-08 19:04:17 +10:00
committed by GitHub
Unverified
parent 22a534a0bb
commit 09c318094c
3 changed files with 42 additions and 6 deletions
@@ -63,6 +63,36 @@ describe("SerializeAddon", () => {
}
})
describe("scrollback option", () => {
test("reads only the requested tail and restores the cursor on its screen row", async () => {
const { term, addon } = createTerminal(20, 5)
await writeAndWait(term, Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\r\n"))
await writeAndWait(term, "\x1b[2A\x1b[3G")
expect(term.buffer.normal.length).toBe(30)
expect([term.buffer.normal.cursorX, term.buffer.normal.cursorY]).toEqual([2, 2])
const reads = spyOn(term.buffer.normal, "getLine")
const serialized = addon.serialize({ scrollback: 3 })
expect(new Set(reads.mock.calls.map((args) => args[0]))).toEqual(new Set([22, 23, 24, 25, 26, 27, 28, 29]))
reads.mockRestore()
const restored = createTerminal(20, 5)
await writeAndWait(restored.term, serialized)
expect(restored.term.getScrollbackLength()).toBe(3)
for (let row = 0; row < 8; row++) {
expect(restored.term.buffer.normal.getLine(row)?.translateToString(true)).toBe(`line ${22 + row}`)
}
expect([restored.term.buffer.normal.cursorX, restored.term.buffer.normal.cursorY]).toEqual([2, 2])
})
test("serializes the whole buffer when it has fewer rows than requested", async () => {
const { term, addon } = createTerminal(20, 5)
await writeAndWait(term, Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\r\n"))
expect(addon.serialize({ scrollback: 100 })).toBe(addon.serialize())
})
})
test("preserves color scheme reporting mode", async () => {
const { term, addon } = createTerminal()
await writeAndWait(term, "\x1b[?2031h")
@@ -481,12 +481,12 @@ class StringSerializeHandler extends BaseSerializeHandler {
if (excludeFinalCursorPosition) return content
const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER)
const cursorCol = this._buffer.cursorX + 1
content += `\u001b[${cursorRow};${cursorCol}H`
// CUP addresses the screen and ghostty-web reports cursorY relative to the screen, so the
// serialized range start must not shift the row. The cursor line sits in the screen region
// at the bottom of the buffer, after any scrollback rows.
content += `\u001b[${this._buffer.cursorY + 1};${this._buffer.cursorX + 1}H`
const line = this._buffer.getLine(absoluteCursorRow)
const line = this._buffer.getLine(this._buffer.length - this._terminal.rows + this._buffer.cursorY)
const cell = line?.getCell(this._buffer.cursorX)
const style = (() => {
if (!cell) return this._buffer.getNullCell()
@@ -20,6 +20,12 @@ import { terminalWriter } from "@/session/terminal/writer"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
// Serialization on unmount is a synchronous O(rows x cols) walk on the main thread and the
// result is written to localStorage or desktop state for every terminal in the workspace.
// Persisting the most recent 2k scrollback rows keeps restore fidelity for the history users
// actually scroll back through while capping teardown cost and snapshot size; the live
// terminal keeps its full 10k scrollback while mounted.
const persistedScrollbackRows = 2_000
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
autoFocus?: boolean
@@ -152,7 +158,7 @@ const persistTerminal = (input: {
if (!input.addon || !input.onCleanup || !input.term) return
const buffer = (() => {
try {
return input.addon.serialize()
return input.addon.serialize({ scrollback: persistedScrollbackRows })
} catch {
debugTerminal("failed to serialize terminal buffer")
return ""