fix(terminal): flush xterm's parked renderer resize when releasing the pause latch (#18510)

This commit is contained in:
Jinwoo Hong
2026-09-03 16:03:41 -04:00
committed by Jinwoo-H
parent 0f22e1e905
commit dfdda91ef5
2 changed files with 140 additions and 9 deletions
@@ -0,0 +1,113 @@
import { describe, expect, it, vi } from 'vitest'
import {
forceFullViewportPresent,
forceRepaintThroughRenderPause,
requestFullViewportPresent
} from './terminal-render-pause-release'
// Why a separate file: the parked-resize contract is one hazard shared by all
// three helpers, and the main spec is already at the max-lines budget.
type FakeRenderService = {
_isPaused: boolean
_needsFullRefresh: boolean
_pausedResizeTask?: { flush: ReturnType<typeof vi.fn> } | null
refreshRows: ReturnType<typeof vi.fn>
_renderer?: { value?: { renderRows?: ReturnType<typeof vi.fn> } }
}
function createPausedTerminal(options: {
synchronizedOutput?: boolean
withoutTask?: boolean
flushThrows?: boolean
}): { terminal: unknown; service: FakeRenderService; order: string[] } {
const order: string[] = []
const flush = vi.fn(() => {
order.push('flush')
if (options.flushThrows) {
throw new Error('renderer disposed')
}
})
const service: FakeRenderService = {
_isPaused: true,
_needsFullRefresh: true,
_pausedResizeTask: options.withoutTask ? null : { flush },
refreshRows: vi.fn(() => order.push('refreshRows')),
_renderer: { value: { renderRows: vi.fn(() => order.push('renderRows')) } }
}
const terminal = {
rows: 24,
_core: {
_renderService: service,
coreService: { decPrivateModes: { synchronizedOutput: options.synchronizedOutput === true } }
}
}
return { terminal, service, order }
}
const helpers = [
['forceRepaintThroughRenderPause', forceRepaintThroughRenderPause],
['requestFullViewportPresent', requestFullViewportPresent],
['forceFullViewportPresent', forceFullViewportPresent]
] as const
describe.each(helpers)('%s parked renderer resize', (_name, present) => {
it('flushes the resize xterm parked while paused before presenting', () => {
// A resize that lands under _isPaused only parks WebglRenderer.handleResize;
// xterm flushes it solely from the observer callback we are pre-empting.
const { terminal, service, order } = createPausedTerminal({})
expect(present(terminal)).toBe(true)
expect(service._pausedResizeTask?.flush).toHaveBeenCalledTimes(1)
expect(order[0]).toBe('flush')
expect(order).toHaveLength(2)
expect(service._isPaused).toBe(false)
expect(service._needsFullRefresh).toBe(false)
})
it('flushes before a DEC 2026 present too', () => {
const { terminal, service, order } = createPausedTerminal({ synchronizedOutput: true })
expect(present(terminal)).toBe(true)
expect(service._pausedResizeTask?.flush).toHaveBeenCalledTimes(1)
expect(order[0]).toBe('flush')
})
it('still presents when the parked-task internal is unavailable', () => {
const { terminal, service } = createPausedTerminal({ withoutTask: true })
expect(present(terminal)).toBe(true)
expect(service._isPaused).toBe(false)
})
it('still presents when the parked resize throws', () => {
const { terminal, order } = createPausedTerminal({ flushThrows: true })
expect(present(terminal)).toBe(true)
expect(order).toEqual(['flush', expect.any(String)])
})
})
describe('parked renderer resize on an unpaused terminal', () => {
it('is left to xterm when the pause latch is not set', () => {
const flush = vi.fn()
const service = {
_isPaused: false,
_needsFullRefresh: false,
_pausedResizeTask: { flush },
refreshRows: vi.fn()
}
const terminal = {
rows: 24,
_core: {
_renderService: service,
coreService: { decPrivateModes: { synchronizedOutput: true } }
}
}
expect(requestFullViewportPresent(terminal)).toBe(true)
expect(forceFullViewportPresent(terminal)).toBe(true)
expect(forceRepaintThroughRenderPause(terminal)).toBe(false)
expect(flush).not.toHaveBeenCalled()
})
})
@@ -24,6 +24,7 @@ type MaybeWebglRenderer = {
type MaybePausableRenderService = {
_isPaused?: boolean
_needsFullRefresh?: boolean
_pausedResizeTask?: { flush?: () => void } | null
refreshRows?: (start: number, end: number, sync?: boolean) => void
_renderer?: { value?: MaybeWebglRenderer | null } | MaybeWebglRenderer | null
}
@@ -41,6 +42,29 @@ type TerminalWithRenderService = {
}
}
/**
* Clears xterm's observer-pause latches and runs the renderer resize xterm parked
* while paused.
*
* Why the flush: `RenderService.handleResize` under `_isPaused` only parks the
* WebGL renderer's own resize on an idle task, and xterm flushes that task solely
* from the observer callback gated on `_needsFullRefresh`. Clearing the latch
* without flushing lets the present below paint the new grid through the old
* canvas/model geometry (misplaced fragments, stray bars until a user resize).
*/
function releaseRenderPause(service: PausableRenderService): void {
// Why: leave the latch as if the pending full refresh was serviced — we are
// about to service it — so the observer's next callback doesn't queue a
// redundant second full repaint.
service._isPaused = false
service._needsFullRefresh = false
try {
service._pausedResizeTask?.flush?.()
} catch {
// Why: a resize that throws mid-dispose must not block the present.
}
}
function getRenderService(terminal: unknown): PausableRenderService | null {
const service = (terminal as TerminalWithRenderService | null)?._core?._renderService
return service && typeof service.refreshRows === 'function'
@@ -66,11 +90,7 @@ export function forceRepaintThroughRenderPause(terminal: unknown): boolean {
return false
}
// Why: leave the latch as if the pending full refresh was serviced — we are
// about to service it — so the observer's next callback doesn't queue a
// redundant second full repaint.
service._isPaused = false
service._needsFullRefresh = false
releaseRenderPause(service)
try {
service.refreshRows(0, rows - 1, true)
return true
@@ -102,8 +122,7 @@ export function requestFullViewportPresent(terminal: unknown): boolean {
}
if (paused) {
service._isPaused = false
service._needsFullRefresh = false
releaseRenderPause(service)
}
try {
@@ -160,8 +179,7 @@ export function forceFullViewportPresent(terminal: unknown): boolean {
}
if (paused) {
service._isPaused = false
service._needsFullRefresh = false
releaseRenderPause(service)
}
const renderer = getRenderer(service)