fix(terminal): close the emulator state gap where the drop is announced

The restore-needed marker is the single point where "renderer-bound bytes
were dropped" is known. The handler already resets the transport's
cross-chunk parser state there for exactly this reason — a partial escape
spanning the gap would corrupt the next chunk. The emulator carries state
across chunks in the same way, so reset it in the same place.

That makes restore, abandon and overflow all start from a known pen by
construction, instead of each recovery path having to remember.
This commit is contained in:
Brennan Benson
2026-08-13 01:18:38 -07:00
parent 3f3f6ea192
commit ed50c8143e
2 changed files with 30 additions and 0 deletions
@@ -12103,6 +12103,28 @@ describe('connectPanePty', () => {
return { transport, pane, dataCallback: capturedDataCallback.current!, binding }
}
// STA-4042 root: the restore-needed marker is the ONE point where "bytes
// were dropped" is known. The emulator carries state across chunks just like
// the cross-chunk parser the handler already resets, so the gap is closed
// here rather than left to each recovery path to remember.
it('closes the emulator state gap when a drop is announced', async () => {
enableMainAuthority()
const deps = createDeps({ isVisibleRef: { current: false } })
const { pane, dataCallback } = await connectHiddenPane(deps)
dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 })
pane.terminal.write.mockClear()
const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel')
_dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 })
await flushAsyncTicks(4)
const written = pane.terminal.write.mock.calls.map(([data]) => data as string)
const gapReset = written.find((data) => data.startsWith('\x1b[0m'))
expect(gapReset).toBeDefined()
// charset too: a dropped ESC(B otherwise renders text as box characters
expect(gapReset).toContain('\x1b(B')
})
it('marks the PTY hidden on hidden output and clears it before requesting restore on reveal', async () => {
enableMainAuthority()
const deps = createDeps({ isVisibleRef: { current: false } })
@@ -6101,6 +6101,14 @@ export function connectPanePty(
})
// Why: dropped bytes invalidate cross-chunk carry — a partial OSC-9999 prefix spanning the gap would corrupt the next live chunk.
transport.resetCrossChunkParserState?.()
// Why the emulator too: it carries state across chunks exactly like the
// parser does. If the gap swallowed the `ESC[22m` closing a bold run (or
// the `ESC(B` leaving line-drawing), every cell written afterwards
// inherits it. This marker is the one point where "bytes were dropped" is
// known, so close the state gap here instead of relying on each recovery
// path to remember — restore, abandon and overflow then all start from a
// known pen (STA-4042).
writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true)
// Why gated (rc.7.perf loop): on a visible pane these markers come from our own restore starving ACKs; re-arming per marker kept the fetch loop alive all flood, so defer to one post-flood repaint.
if (isForegroundRestoreBackpressureContext()) {
noteHiddenOutputRestoreFloodBackpressure()