mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
fix(terminal): cancel viewport retries when panes close (#24745)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -22,6 +22,7 @@ import { configureLazyArabicShapingJoiner } from './terminal-arabic-shaping-join
|
||||
import { TerminalLigaturesAddon } from './terminal-ligatures-addon'
|
||||
import { attachInlineImages, detachInlineImages } from './pane-inline-images'
|
||||
import { installTerminalImeCandidateAnchor } from './terminal-ime-candidate-anchor'
|
||||
import { cancelPendingTerminalViewportPresents } from './pane-viewport-present'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pane creation, terminal open/close, addon management
|
||||
@@ -185,6 +186,7 @@ export function disposePane(
|
||||
pane: ManagedPaneInternal,
|
||||
panes: Map<number, ManagedPaneInternal>
|
||||
): void {
|
||||
cancelPendingTerminalViewportPresents(pane.terminal)
|
||||
if (pane.pendingInitialFitRafId != null) {
|
||||
cancelAnimationFrame(pane.pendingInitialFitRafId)
|
||||
pane.pendingInitialFitRafId = null
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { SearchAddon } from '@xterm/addon-search'
|
||||
import { SerializeAddon } from '@xterm/addon-serialize'
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
import type { ManagedPaneInternal } from './pane-manager-types'
|
||||
import { disposePane } from './pane-lifecycle'
|
||||
import { toPublicPane } from './pane-public-view'
|
||||
import {
|
||||
cancelPendingTerminalViewportPresents,
|
||||
presentPaneViewport,
|
||||
presentPaneViewportPreservingSynchronizedOutput
|
||||
} from './pane-viewport-present'
|
||||
import {
|
||||
forceFullViewportPresent,
|
||||
requestFullViewportPresent
|
||||
} from './terminal-render-pause-release'
|
||||
|
||||
vi.mock('./terminal-render-pause-release', () => ({
|
||||
forceFullViewportPresent: vi.fn(() => false),
|
||||
requestFullViewportPresent: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
describe('pane viewport retry lifetime', () => {
|
||||
let frames: Map<number, FrameRequestCallback>
|
||||
let nextFrameId: number
|
||||
let panes: WeakRef<ManagedPaneInternal>[]
|
||||
|
||||
function createPane(): ManagedPaneInternal {
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
if (!isTerminalLeafId(leafId)) {
|
||||
throw new Error('Expected a valid fixture leaf id')
|
||||
}
|
||||
const container = document.createElement('div')
|
||||
container.style.display = 'none'
|
||||
document.body.appendChild(container)
|
||||
const pane: ManagedPaneInternal = {
|
||||
id: 1,
|
||||
leafId,
|
||||
stablePaneId: leafId,
|
||||
terminal: new Terminal({ cols: 128, rows: 24, scrollback: 5000, allowProposedApi: true }),
|
||||
container,
|
||||
xtermContainer: container,
|
||||
linkTooltip: document.createElement('div'),
|
||||
fitAddon: new FitAddon(),
|
||||
searchAddon: new SearchAddon(),
|
||||
serializeAddon: new SerializeAddon(),
|
||||
unicode11Addon: new Unicode11Addon(),
|
||||
webLinksAddon: new WebLinksAddon(),
|
||||
terminalGpuAcceleration: 'off',
|
||||
gpuRenderingEnabled: false,
|
||||
webglAttachmentDeferred: false,
|
||||
webglDisabledAfterContextLoss: false,
|
||||
hasComplexScriptOutput: false,
|
||||
webglAddon: null,
|
||||
imageAddon: null,
|
||||
ligaturesAddon: null,
|
||||
fitResizeObserver: null,
|
||||
pendingObservedFitRafId: null,
|
||||
compositionHandler: null,
|
||||
pendingSplitScrollState: null,
|
||||
debugLabel: null
|
||||
}
|
||||
panes.push(new WeakRef(pane))
|
||||
return pane
|
||||
}
|
||||
|
||||
function flushFrame(): void {
|
||||
const callbacks = [...frames.values()]
|
||||
frames.clear()
|
||||
for (const callback of callbacks) {
|
||||
callback(16)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
frames = new Map()
|
||||
nextFrameId = 0
|
||||
panes = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
const id = ++nextFrameId
|
||||
frames.set(id, callback)
|
||||
return id
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const ref of panes) {
|
||||
const pane = ref.deref()
|
||||
if (pane) {
|
||||
disposePane(pane, new Map([[pane.id, pane]]))
|
||||
pane.container.remove()
|
||||
}
|
||||
}
|
||||
frames.clear()
|
||||
document.body.replaceChildren()
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
for (const present of [presentPaneViewport, presentPaneViewportPreservingSynchronizedOutput]) {
|
||||
for (const elapsed of [0, 3]) {
|
||||
it(`cancels every public-wrapper retry after ${elapsed} ticks`, () => {
|
||||
const pane = createPane()
|
||||
present(toPublicPane(pane))
|
||||
present(toPublicPane(pane))
|
||||
for (let tick = 0; tick < elapsed; tick++) {
|
||||
flushFrame()
|
||||
}
|
||||
expect(frames.size).toBe(2)
|
||||
const lateCallbacks = [...frames.values()]
|
||||
disposePane(pane, new Map([[pane.id, pane]]))
|
||||
expect(frames.size).toBe(0)
|
||||
for (const callback of lateCallbacks) {
|
||||
callback(16)
|
||||
}
|
||||
expect(frames.size).toBe(0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
it('releases real disposed xterm buffers before paused frames resume', async () => {
|
||||
async function retireTerminal(): Promise<WeakRef<Terminal>> {
|
||||
const pane = createPane()
|
||||
await new Promise<void>((resolve) =>
|
||||
pane.terminal.write('retained history\r\n'.repeat(500), resolve)
|
||||
)
|
||||
presentPaneViewport(toPublicPane(pane))
|
||||
presentPaneViewportPreservingSynchronizedOutput(toPublicPane(pane))
|
||||
flushFrame()
|
||||
disposePane(pane, new Map([[pane.id, pane]]))
|
||||
pane.container.remove()
|
||||
return new WeakRef(pane.terminal)
|
||||
}
|
||||
const retired = await retireTerminal()
|
||||
if (typeof globalThis.gc !== 'function') {
|
||||
throw new Error('Run with the repository Vitest --expose-gc config')
|
||||
}
|
||||
for (let round = 0; round < 4; round++) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
globalThis.gc()
|
||||
}
|
||||
expect(retired.deref()).toBeUndefined()
|
||||
expect(frames.size).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps a different live terminal retry eligible when one is disposed', () => {
|
||||
const retired = createPane()
|
||||
const live = createPane()
|
||||
presentPaneViewport(toPublicPane(retired))
|
||||
presentPaneViewportPreservingSynchronizedOutput(toPublicPane(live))
|
||||
disposePane(retired, new Map([[retired.id, retired]]))
|
||||
expect(frames.size).toBe(1)
|
||||
live.container.style.display = 'block'
|
||||
flushFrame()
|
||||
expect(requestFullViewportPresent).toHaveBeenCalledWith(live.terminal)
|
||||
expect(forceFullViewportPresent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deduplicates the same wrapper and upgrades its force mode', () => {
|
||||
const pane = createPane()
|
||||
const view = toPublicPane(pane)
|
||||
presentPaneViewportPreservingSynchronizedOutput(view)
|
||||
presentPaneViewport(view)
|
||||
expect(frames.size).toBe(1)
|
||||
pane.container.style.display = 'block'
|
||||
flushFrame()
|
||||
expect(forceFullViewportPresent).toHaveBeenCalledOnce()
|
||||
expect(requestFullViewportPresent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves the sixteen-frame budget for a live collapsed pane', () => {
|
||||
const pane = createPane()
|
||||
presentPaneViewport(toPublicPane(pane))
|
||||
for (let tick = 0; tick < 15; tick++) {
|
||||
flushFrame()
|
||||
expect(frames.size).toBe(1)
|
||||
}
|
||||
flushFrame()
|
||||
expect(frames.size).toBe(0)
|
||||
pane.container.style.display = 'block'
|
||||
presentPaneViewport(toPublicPane(pane))
|
||||
expect(forceFullViewportPresent).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps immediate displayed presentation and the no-frame fallback', () => {
|
||||
const pane = createPane()
|
||||
pane.container.style.display = 'block'
|
||||
presentPaneViewportPreservingSynchronizedOutput(toPublicPane(pane))
|
||||
expect(requestFullViewportPresent).toHaveBeenCalledOnce()
|
||||
expect(frames.size).toBe(0)
|
||||
pane.container.style.display = 'none'
|
||||
vi.stubGlobal('requestAnimationFrame', undefined)
|
||||
const refresh = vi.spyOn(pane.terminal, 'refresh')
|
||||
presentPaneViewport(toPublicPane(pane))
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
expect(frames.size).toBe(0)
|
||||
})
|
||||
|
||||
it('allows a cancelled live wrapper to schedule again', () => {
|
||||
const pane = createPane()
|
||||
const view = toPublicPane(pane)
|
||||
presentPaneViewport(view)
|
||||
cancelPendingTerminalViewportPresents(pane.terminal)
|
||||
expect(frames.size).toBe(0)
|
||||
presentPaneViewport(view)
|
||||
expect(frames.size).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,14 @@ const DISPLAYED_PRESENT_RETRY_FRAMES = 16
|
||||
type ViewportPresentMode = 'preserve-synchronized-output' | 'force-current-buffer'
|
||||
type DisplayedPresentRetry = { frames: number; mode: ViewportPresentMode }
|
||||
const pendingDisplayedPresentRetries = new WeakMap<ManagedPaneInternal, DisplayedPresentRetry>()
|
||||
// Public pane views are recreated; terminal teardown must cancel every view's retry.
|
||||
const presentRetryCancellations = new WeakMap<ManagedPane['terminal'], Set<() => void>>()
|
||||
|
||||
export function cancelPendingTerminalViewportPresents(terminal: ManagedPane['terminal']): void {
|
||||
for (const cancel of presentRetryCancellations.get(terminal) ?? []) {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePresentWhenDisplayed(pane: ManagedPaneInternal, mode: ViewportPresentMode): void {
|
||||
if (typeof globalThis.requestAnimationFrame !== 'function') {
|
||||
@@ -29,25 +37,50 @@ function schedulePresentWhenDisplayed(pane: ManagedPaneInternal, mode: ViewportP
|
||||
frames: DISPLAYED_PRESENT_RETRY_FRAMES,
|
||||
mode
|
||||
})
|
||||
const terminal = pane.terminal
|
||||
const cancellations = presentRetryCancellations.get(terminal) ?? new Set<() => void>()
|
||||
presentRetryCancellations.set(terminal, cancellations)
|
||||
let frameId: number | null = null
|
||||
let cancelled = false
|
||||
const finish = (): void => {
|
||||
pendingDisplayedPresentRetries.delete(pane)
|
||||
cancellations.delete(cancel)
|
||||
if (cancellations.size === 0) {
|
||||
presentRetryCancellations.delete(terminal)
|
||||
}
|
||||
}
|
||||
const cancel = (): void => {
|
||||
cancelled = true
|
||||
if (frameId !== null) {
|
||||
globalThis.cancelAnimationFrame(frameId)
|
||||
frameId = null
|
||||
}
|
||||
finish()
|
||||
}
|
||||
cancellations.add(cancel)
|
||||
const tick = (): void => {
|
||||
frameId = null
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
const retry = pendingDisplayedPresentRetries.get(pane)
|
||||
if (!retry || retry.frames <= 0 || !pane.terminal) {
|
||||
pendingDisplayedPresentRetries.delete(pane)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
if (isManagedPaneDisplayNone(pane)) {
|
||||
if (retry.frames === 1) {
|
||||
pendingDisplayedPresentRetries.delete(pane)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
retry.frames -= 1
|
||||
globalThis.requestAnimationFrame(tick)
|
||||
frameId = globalThis.requestAnimationFrame(tick)
|
||||
return
|
||||
}
|
||||
pendingDisplayedPresentRetries.delete(pane)
|
||||
finish()
|
||||
presentPaneViewportWithMode(pane, retry.mode)
|
||||
}
|
||||
globalThis.requestAnimationFrame(tick)
|
||||
frameId = globalThis.requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
function presentPaneViewportWithMode(pane: ManagedPane, mode: ViewportPresentMode): void {
|
||||
|
||||
Reference in New Issue
Block a user