diff --git a/src/renderer/src/lib/pane-manager/terminal-render-pause-release-parked-resize.test.ts b/src/renderer/src/lib/pane-manager/terminal-render-pause-release-parked-resize.test.ts new file mode 100644 index 00000000000..7ad57fa7126 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-render-pause-release-parked-resize.test.ts @@ -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 } | null + refreshRows: ReturnType + _renderer?: { value?: { renderRows?: ReturnType } } +} + +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() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts index 552939e6cd8..9f823e84630 100644 --- a/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts +++ b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts @@ -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)