mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
fix(terminal): preserve parked remote viewport during recovery
This commit is contained in:
@@ -332,6 +332,7 @@ type MockTransport = {
|
||||
resize: ReturnType<typeof vi.fn>
|
||||
getPtyId: ReturnType<typeof vi.fn>
|
||||
getConnectionId: ReturnType<typeof vi.fn>
|
||||
getRecoveryState?: ReturnType<typeof vi.fn>
|
||||
serializeBuffer?: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
@@ -21082,6 +21083,110 @@ describe('connectPanePty', () => {
|
||||
expect(transport.getPtyId).toHaveReturnedWith(remotePtyId)
|
||||
})
|
||||
|
||||
it.each(['disconnected', 'offline'] as const)(
|
||||
'preserves a parked paired frame and PTY identity while recovery is %s',
|
||||
async (phase) => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const remotePtyId = 'remote:env-1@@terminal-1'
|
||||
const transport = createMockTransport()
|
||||
transport.connect.mockResolvedValue(undefined)
|
||||
transport.getRecoveryState = vi.fn(() => ({ phase, epoch: 1, attempt: 1 }))
|
||||
transportFactoryQueue.push(transport)
|
||||
await parkTabForReveal('tab-1', remotePtyId)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: remotePtyId }] },
|
||||
ptyIdsByTabId: { 'tab-1': [remotePtyId] },
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: LEAF_1 },
|
||||
activeLeafId: LEAF_1,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_1]: remotePtyId }
|
||||
}
|
||||
},
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
[
|
||||
'env-1',
|
||||
{
|
||||
checkedAt: Date.now(),
|
||||
status: { capabilities: ['terminal.paired-parking.v1'] }
|
||||
}
|
||||
]
|
||||
])
|
||||
}
|
||||
|
||||
const pane = createPane(1)
|
||||
pane.container.dataset.parkedViewportFrame = 'true'
|
||||
const { writes } = captureCallbackTerminalWrites(pane)
|
||||
const deps = createDeps({
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: remotePtyId }
|
||||
})
|
||||
connectPanePty(pane as never, createManager(1) as never, deps as never)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(transport.connect).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ sessionId: remotePtyId })
|
||||
)
|
||||
expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled()
|
||||
expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenCalledWith(1, null)
|
||||
expect(deps.clearTabPtyId).not.toHaveBeenCalled()
|
||||
expect(writes.join('')).not.toContain(
|
||||
buildFreshShellViewportBlankingSequence(pane.terminal.rows)
|
||||
)
|
||||
expect(pane.container.dataset.parkedViewportFrame).toBe('true')
|
||||
expect(mockStoreState.tabsByWorktree['wt-1'][0]?.ptyId).toBe(remotePtyId)
|
||||
expect(mockStoreState.terminalLayoutsByTabId?.['tab-1']?.ptyIdsByLeafId?.[LEAF_1]).toBe(
|
||||
remotePtyId
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('replaces an ended paired session after a parked reattach returns no PTY', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const remotePtyId = 'remote:env-1@@terminal-1'
|
||||
const replacementPtyId = 'remote:env-1@@terminal-2'
|
||||
const transport = createMockTransport()
|
||||
transport.connect.mockImplementation(async ({ sessionId }) => {
|
||||
if (sessionId) {
|
||||
transport.getPtyId.mockReturnValue(null)
|
||||
return undefined
|
||||
}
|
||||
transport.getPtyId.mockReturnValue(replacementPtyId)
|
||||
return replacementPtyId
|
||||
})
|
||||
transport.getRecoveryState = vi.fn(() => ({ phase: 'ended', epoch: 1, attempt: 0 }))
|
||||
transportFactoryQueue.push(transport)
|
||||
await parkTabForReveal('tab-1', remotePtyId)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: remotePtyId }] },
|
||||
ptyIdsByTabId: { 'tab-1': [remotePtyId] },
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
[
|
||||
'env-1',
|
||||
{
|
||||
checkedAt: Date.now(),
|
||||
status: { capabilities: ['terminal.paired-parking.v1'] }
|
||||
}
|
||||
]
|
||||
])
|
||||
}
|
||||
|
||||
const deps = createDeps({
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: remotePtyId }
|
||||
})
|
||||
connectPanePty(createPane(1) as never, createManager(1) as never, deps as never)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(transport.connect).toHaveBeenCalledTimes(2)
|
||||
expect(transport.connect.mock.calls[1]?.[0]).not.toHaveProperty('sessionId')
|
||||
expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledWith(1, remotePtyId)
|
||||
expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', remotePtyId)
|
||||
})
|
||||
|
||||
it('restores a local main-model snapshot after a contentless park reattach', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const localPtyId = 'global-floating-terminal@@terminal-1'
|
||||
|
||||
@@ -8162,6 +8162,15 @@ export function connectPanePty(
|
||||
const ptyId =
|
||||
connectResult?.id ?? (typeof result === 'string' ? result : transport.getPtyId())
|
||||
if (!ptyId) {
|
||||
// Why: paired recovery retains the valid parked frame while host liveness is unknown.
|
||||
const recoveryPhase = transport.getRecoveryState?.().phase
|
||||
if (
|
||||
mountFollowsTerminalPark &&
|
||||
isRemoteRuntimePtyId(staleSessionId) &&
|
||||
(recoveryPhase === 'disconnected' || recoveryPhase === 'offline')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
warnTerminalLifecycleAnomaly('restored PTY reattach returned no PTY id', {
|
||||
tabId: deps.tabId,
|
||||
worktreeId: deps.worktreeId,
|
||||
|
||||
@@ -113,7 +113,7 @@ describe('parked terminal viewport frames', () => {
|
||||
)
|
||||
expect(mocks.replayIntoTerminalAsync.mock.calls[0]?.[3].shouldReleaseRenderPause()).toBe(true)
|
||||
await Promise.resolve()
|
||||
expect(scheduleRevealRepaint).toHaveBeenCalledOnce()
|
||||
expect(scheduleRevealRepaint).toHaveBeenCalledWith({ invalidatePaneId: matching.pane.id })
|
||||
expect(consumeParkedTerminalViewportFrameMarker(matching.pane)).toBe(true)
|
||||
expect(consumeParkedTerminalViewportFrameMarker(matching.pane)).toBe(false)
|
||||
})
|
||||
|
||||
@@ -86,8 +86,8 @@ export function replayParkedTerminalViewportFrames(args: {
|
||||
pane.container.dataset[PARKED_VIEWPORT_FRAME_DATASET_KEY] === 'true' &&
|
||||
args.isVisible()
|
||||
) {
|
||||
// Why: the immediate refresh can precede measurable layout; repaint the parsed frame after reveal settles.
|
||||
args.manager.scheduleRevealRepaint()
|
||||
// Why: invalidate the stale WebGL cell model only after reveal layout settles.
|
||||
args.manager.scheduleRevealRepaint({ invalidatePaneId: pane.id })
|
||||
}
|
||||
})
|
||||
replayed += 1
|
||||
|
||||
@@ -375,11 +375,11 @@ export class PaneManager {
|
||||
return this.atlasRecoveryVisible && !this.destroyed
|
||||
}
|
||||
|
||||
scheduleRevealRepaint(): void {
|
||||
scheduleRevealRepaint(options?: { invalidatePaneId?: number }): void {
|
||||
// Why: the settled-frame callback can fire after destroy(); repainting
|
||||
// disposed panes could throw in attach and latch the global WebGL
|
||||
// attach backoff, downgrading unrelated new panes to the DOM renderer.
|
||||
schedulePaneRevealRepaint(() => (this.destroyed ? [] : this.panes.values()))
|
||||
schedulePaneRevealRepaint(() => (this.destroyed ? [] : this.panes.values()), options)
|
||||
}
|
||||
|
||||
scheduleRevealPresent(): void {
|
||||
|
||||
@@ -10,7 +10,12 @@ type FakePaneManager = {
|
||||
refreshAllPanes: Mock<() => void>
|
||||
}
|
||||
|
||||
function createPane(options: { webglAddon?: FakeWebglAddon | null } = {}): ManagedPaneInternal {
|
||||
function createPane(
|
||||
options: {
|
||||
clearRenderModel?: ReturnType<typeof vi.fn>
|
||||
webglAddon?: FakeWebglAddon | null
|
||||
} = {}
|
||||
): ManagedPaneInternal {
|
||||
const leafId = '33333333-3333-4333-8333-333333333333' as never
|
||||
return {
|
||||
id: 1,
|
||||
@@ -20,7 +25,8 @@ function createPane(options: { webglAddon?: FakeWebglAddon | null } = {}): Manag
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
refresh: vi.fn(),
|
||||
loadAddon: vi.fn()
|
||||
loadAddon: vi.fn(),
|
||||
_core: { _renderService: { clear: options.clearRenderModel ?? vi.fn() } }
|
||||
} as never,
|
||||
container: {} as never,
|
||||
xtermContainer: {} as never,
|
||||
@@ -158,6 +164,46 @@ describe('schedulePaneRevealRepaint', () => {
|
||||
expect(pane.terminal.refresh).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invalidates a requested WebGL pane only after reveal layout settles', () => {
|
||||
const clearRenderModel = vi.fn()
|
||||
const originalAddon = { clearTextureAtlas: vi.fn() }
|
||||
const pane = createPane({ clearRenderModel, webglAddon: originalAddon })
|
||||
registerPaneManager(() => [pane])
|
||||
schedulePaneRevealRepaint(() => [pane], { invalidatePaneId: pane.id })
|
||||
|
||||
flushFrame()
|
||||
expect(clearRenderModel).not.toHaveBeenCalled()
|
||||
|
||||
flushFrame()
|
||||
expect(clearRenderModel).toHaveBeenCalledOnce()
|
||||
expect(pane.webglAddon).toBe(originalAddon)
|
||||
})
|
||||
|
||||
it('coalesces invalidation requests for different panes into one recovery', () => {
|
||||
const firstClear = vi.fn()
|
||||
const secondClear = vi.fn()
|
||||
const firstPane = createPane({
|
||||
clearRenderModel: firstClear,
|
||||
webglAddon: { clearTextureAtlas: vi.fn() }
|
||||
})
|
||||
const secondPane = createPane({
|
||||
clearRenderModel: secondClear,
|
||||
webglAddon: { clearTextureAtlas: vi.fn() }
|
||||
})
|
||||
secondPane.id = 2
|
||||
const manager = registerPaneManager(() => [firstPane, secondPane])
|
||||
const getPanes = (): ManagedPaneInternal[] => [firstPane, secondPane]
|
||||
schedulePaneRevealRepaint(getPanes, { invalidatePaneId: firstPane.id })
|
||||
schedulePaneRevealRepaint(getPanes, { invalidatePaneId: secondPane.id })
|
||||
|
||||
flushFrame()
|
||||
flushFrame()
|
||||
|
||||
expect(firstClear).toHaveBeenCalledOnce()
|
||||
expect(secondClear).toHaveBeenCalledOnce()
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('resolves the pane list at repaint time, not at scheduling time', () => {
|
||||
const stalePane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } })
|
||||
const livePane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } })
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { ManagedPaneInternal } from './pane-manager-types'
|
||||
import { reattachWebglIfNeeded } from './pane-webgl-reattach'
|
||||
import { resetAndRefreshAllTerminalWebglAtlases } from './pane-manager-registry'
|
||||
import { invalidateTerminalRenderModel } from './terminal-render-model-invalidation'
|
||||
|
||||
type PaneGetter = () => Iterable<ManagedPaneInternal>
|
||||
|
||||
const pendingRevealRepaints = new Set<PaneGetter>()
|
||||
const pendingRevealRepaints: {
|
||||
getPanes: PaneGetter
|
||||
invalidatePaneId?: number
|
||||
}[] = []
|
||||
let revealRepaintScheduled = false
|
||||
|
||||
function scheduleSettledFrame(callback: () => void): void {
|
||||
@@ -37,23 +41,25 @@ function forEachPaneOnSettledFrame(
|
||||
|
||||
function flushPaneRevealRepaints(): void {
|
||||
revealRepaintScheduled = false
|
||||
const paneGetters = Array.from(pendingRevealRepaints)
|
||||
pendingRevealRepaints.clear()
|
||||
const livePanes = new Set<ManagedPaneInternal>()
|
||||
const paneGetters = pendingRevealRepaints.splice(0)
|
||||
const livePanes = new Map<ManagedPaneInternal, boolean>()
|
||||
|
||||
for (const getPanes of paneGetters) {
|
||||
for (const { getPanes, invalidatePaneId } of paneGetters) {
|
||||
try {
|
||||
for (const pane of getPanes()) {
|
||||
livePanes.add(pane)
|
||||
livePanes.set(pane, livePanes.get(pane) === true || pane.id === invalidatePaneId)
|
||||
}
|
||||
} catch {
|
||||
/* ignore — a manager may be destroyed while its repaint is pending */
|
||||
}
|
||||
}
|
||||
|
||||
for (const pane of livePanes) {
|
||||
for (const [pane, shouldInvalidate] of livePanes) {
|
||||
try {
|
||||
reattachWebglIfNeeded(pane)
|
||||
if (shouldInvalidate && pane.webglAddon && !pane.webglDisabledAfterContextLoss) {
|
||||
invalidateTerminalRenderModel(pane.terminal)
|
||||
}
|
||||
} catch {
|
||||
/* ignore — one pane's teardown must not block global recovery */
|
||||
}
|
||||
@@ -69,13 +75,14 @@ function flushPaneRevealRepaints(): void {
|
||||
* Why: while a pane is hidden, parsed output can update the WebGL renderer's
|
||||
* per-cell model without ever presenting a frame. At reveal the model diff
|
||||
* reports those cells unchanged, so plain refreshes skip them and the canvas
|
||||
* keeps compositing pre-hide pixels until a selection or resize rebuilds the
|
||||
* model. Once layout settles, reattach every revealed renderer before one
|
||||
* registry-wide atlas reset so no delayed pane-local clear can invalidate a
|
||||
* sibling terminal's rebuilt model.
|
||||
* keeps compositing pre-hide pixels. Once layout settles, invalidate the
|
||||
* replayed pane's model before one registry-wide atlas reset and refresh.
|
||||
*/
|
||||
export function schedulePaneRevealRepaint(getPanes: () => Iterable<ManagedPaneInternal>): void {
|
||||
pendingRevealRepaints.add(getPanes)
|
||||
export function schedulePaneRevealRepaint(
|
||||
getPanes: () => Iterable<ManagedPaneInternal>,
|
||||
options?: { invalidatePaneId?: number }
|
||||
): void {
|
||||
pendingRevealRepaints.push({ getPanes, invalidatePaneId: options?.invalidatePaneId })
|
||||
if (revealRepaintScheduled) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { invalidateTerminalRenderModel } from './terminal-render-model-invalidation'
|
||||
|
||||
describe('invalidateTerminalRenderModel', () => {
|
||||
it('clears an available render service', () => {
|
||||
const clear = vi.fn()
|
||||
|
||||
expect(invalidateTerminalRenderModel({ _core: { _renderService: { clear } } })).toBe(true)
|
||||
expect(clear).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('safely ignores unavailable or disposed render services', () => {
|
||||
expect(invalidateTerminalRenderModel(null)).toBe(false)
|
||||
expect(invalidateTerminalRenderModel({ _core: {} })).toBe(false)
|
||||
expect(
|
||||
invalidateTerminalRenderModel({
|
||||
_core: {
|
||||
_renderService: {
|
||||
clear: () => {
|
||||
throw new Error('disposed')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
type MaybeClearableRenderService = {
|
||||
clear?: () => void
|
||||
}
|
||||
|
||||
type TerminalWithRenderService = {
|
||||
_core?: {
|
||||
_renderService?: MaybeClearableRenderService
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears xterm's retained cell model so the next refresh redraws every cell. */
|
||||
export function invalidateTerminalRenderModel(terminal: unknown): boolean {
|
||||
const service = (terminal as TerminalWithRenderService | null)?._core?._renderService
|
||||
if (!service || typeof service.clear !== 'function') {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
service.clear()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user