mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(terminal): flush xterm's parked renderer resize when releasing the pause latch (#18510)
This commit is contained in:
+113
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user