mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
Revert "fix(terminal): clear stale parked frame on empty snapshot"
This reverts commit 72de2f6765.
This commit is contained in:
@@ -332,7 +332,6 @@ 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>
|
||||
}
|
||||
|
||||
@@ -21083,110 +21082,6 @@ 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,15 +8162,6 @@ 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,
|
||||
|
||||
@@ -893,25 +893,6 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
transport.destroy?.()
|
||||
})
|
||||
|
||||
it('forwards an empty authoritative snapshot so a cached parked viewport clears', async () => {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const onReplayData = vi.fn()
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'web-terminal-host-tab-1',
|
||||
leafId: 'pane:1'
|
||||
})
|
||||
|
||||
const connect = transport.connect({ url: '', callbacks: { onReplayData } })
|
||||
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
|
||||
emitSnapshot(latestSubscribePayload().streamId, '', { cols: 132, rows: 43 })
|
||||
await connect
|
||||
|
||||
expect(onReplayData).toHaveBeenCalledOnce()
|
||||
expect(onReplayData).toHaveBeenCalledWith('', { snapshotCols: 132, snapshotRows: 43 })
|
||||
transport.destroy?.()
|
||||
})
|
||||
|
||||
it('keeps the current mirror input queue when an older connect settles', async () => {
|
||||
const healthyRuntimeCall = runtimeCall.getMockImplementation()
|
||||
let releaseFirstActivation = (): void => {}
|
||||
|
||||
@@ -1858,8 +1858,8 @@ export function createRemoteRuntimePtyTransport(
|
||||
}
|
||||
},
|
||||
onSnapshot: (data, meta) => {
|
||||
// Why: an empty snapshot is still authoritative and must clear a cached parked viewport.
|
||||
if (isCurrentSubscription()) {
|
||||
// Why: an empty snapshot can still carry a pending mid-escape tail that must replay so the next live chunk completes it.
|
||||
if ((data || meta?.pendingEscapeTailAnsi) && isCurrentSubscription()) {
|
||||
if (subscribedPtyId && bufferPtyShutdownReplayData(subscribedPtyId, data)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@ export type { ParkableTerminalTabModel } from './terminal-parked-watcher-reconci
|
||||
export type ParkedTerminalPtyEligibility = (ptyId: string) => boolean
|
||||
|
||||
export function replayParkedTerminalViewportForTab(args: {
|
||||
isVisible: () => boolean
|
||||
manager: PaneManager
|
||||
paneByLeafId: ReadonlyMap<string, number>
|
||||
replayingPanesRef: ReplayingPanesRef
|
||||
@@ -73,7 +72,6 @@ export function replayParkedTerminalViewportForTab(args: {
|
||||
state.terminalLayoutsByTabId[args.tabId]?.ptyIdsByLeafId ?? {},
|
||||
tab?.ptyId ?? null
|
||||
),
|
||||
isVisible: args.isVisible,
|
||||
paneByLeafId: args.paneByLeafId,
|
||||
replayingPanesRef: args.replayingPanesRef
|
||||
})
|
||||
|
||||
@@ -8,18 +8,9 @@ import {
|
||||
replayParkedTerminalViewportFrames
|
||||
} from './terminal-parked-viewport-frame'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
replayIntoTerminalAsync: vi.fn(
|
||||
(
|
||||
_pane: unknown,
|
||||
_replayingPanesRef: unknown,
|
||||
_data: string,
|
||||
_options: { shouldReleaseRenderPause: () => boolean }
|
||||
) => Promise.resolve()
|
||||
)
|
||||
}))
|
||||
const mocks = vi.hoisted(() => ({ replayIntoTerminal: vi.fn() }))
|
||||
|
||||
vi.mock('./replay-guard', () => ({ replayIntoTerminalAsync: mocks.replayIntoTerminalAsync }))
|
||||
vi.mock('./replay-guard', () => ({ replayIntoTerminal: mocks.replayIntoTerminal }))
|
||||
|
||||
function makePane(
|
||||
id = 1,
|
||||
@@ -46,7 +37,7 @@ function makePane(
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.replayIntoTerminalAsync.mockClear()
|
||||
mocks.replayIntoTerminal.mockClear()
|
||||
})
|
||||
|
||||
describe('parked terminal viewport frames', () => {
|
||||
@@ -78,14 +69,12 @@ describe('parked terminal viewport frames', () => {
|
||||
expect(local.serialize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replays a matching-grid frame and repaints after reveal layout settles', async () => {
|
||||
it('replays a matching-grid frame and skips a stale geometry', () => {
|
||||
const matching = makePane(1, 'matching')
|
||||
const stale = makePane(2, 'stale')
|
||||
const scheduleRevealRepaint = vi.fn()
|
||||
const manager = {
|
||||
getPanes: () => [matching.pane, stale.pane],
|
||||
hasWebglRenderer: () => false,
|
||||
scheduleRevealRepaint
|
||||
hasWebglRenderer: () => false
|
||||
} as unknown as PaneManager
|
||||
const replayingPanesRef = { current: new Map<number, number>() }
|
||||
|
||||
@@ -95,7 +84,6 @@ describe('parked terminal viewport frames', () => {
|
||||
{ leafId: 'matching', frame: { data: 'matching frame', cols: 120, rows: 40 } },
|
||||
{ leafId: 'stale', frame: { data: 'stale frame', cols: 80, rows: 24 } }
|
||||
],
|
||||
isVisible: () => true,
|
||||
paneByLeafId: new Map([
|
||||
['matching', 1],
|
||||
['stale', 2]
|
||||
@@ -104,16 +92,13 @@ describe('parked terminal viewport frames', () => {
|
||||
})
|
||||
|
||||
expect(replayed).toBe(1)
|
||||
expect(mocks.replayIntoTerminalAsync).toHaveBeenCalledOnce()
|
||||
expect(mocks.replayIntoTerminalAsync).toHaveBeenCalledWith(
|
||||
expect(mocks.replayIntoTerminal).toHaveBeenCalledOnce()
|
||||
expect(mocks.replayIntoTerminal).toHaveBeenCalledWith(
|
||||
matching.pane,
|
||||
replayingPanesRef,
|
||||
'matching frame',
|
||||
expect.objectContaining({ shouldReleaseRenderPause: expect.any(Function) })
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mocks.replayIntoTerminalAsync.mock.calls[0]?.[3].shouldReleaseRenderPause()).toBe(true)
|
||||
await Promise.resolve()
|
||||
expect(scheduleRevealRepaint).toHaveBeenCalledWith({ invalidatePaneId: matching.pane.id })
|
||||
expect(consumeParkedTerminalViewportFrameMarker(matching.pane)).toBe(true)
|
||||
expect(consumeParkedTerminalViewportFrameMarker(matching.pane)).toBe(false)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
|
||||
import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor'
|
||||
import { replayIntoTerminalAsync, type ReplayingPanesRef } from './replay-guard'
|
||||
import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard'
|
||||
|
||||
const MAX_PARKED_VIEWPORT_FRAME_CHARS = 256 * 1024
|
||||
const PARKED_VIEWPORT_FRAME_DATASET_KEY = 'parkedViewportFrame'
|
||||
@@ -65,7 +65,6 @@ export function captureParkedTerminalViewportFrame(
|
||||
export function replayParkedTerminalViewportFrames(args: {
|
||||
manager: PaneManager
|
||||
frames: readonly ParkedTerminalViewportFrameByLeaf[]
|
||||
isVisible: () => boolean
|
||||
paneByLeafId: ReadonlyMap<string, number>
|
||||
replayingPanesRef: ReplayingPanesRef
|
||||
}): number {
|
||||
@@ -76,20 +75,10 @@ export function replayParkedTerminalViewportFrames(args: {
|
||||
if (!pane || pane.terminal.cols !== frame.cols || pane.terminal.rows !== frame.rows) {
|
||||
continue
|
||||
}
|
||||
const replay = replayIntoTerminalAsync(pane, args.replayingPanesRef, frame.data, {
|
||||
shouldRefreshViewportSynchronously: () => !args.manager.hasWebglRenderer(pane.id),
|
||||
shouldReleaseRenderPause: args.isVisible
|
||||
replayIntoTerminal(pane, args.replayingPanesRef, frame.data, {
|
||||
shouldRefreshViewportSynchronously: () => !args.manager.hasWebglRenderer(pane.id)
|
||||
})
|
||||
pane.container.dataset[PARKED_VIEWPORT_FRAME_DATASET_KEY] = 'true'
|
||||
void replay.then(() => {
|
||||
if (
|
||||
pane.container.dataset[PARKED_VIEWPORT_FRAME_DATASET_KEY] === 'true' &&
|
||||
args.isVisible()
|
||||
) {
|
||||
// Why: invalidate the stale WebGL cell model only after reveal layout settles.
|
||||
args.manager.scheduleRevealRepaint({ invalidatePaneId: pane.id })
|
||||
}
|
||||
})
|
||||
replayed += 1
|
||||
}
|
||||
return replayed
|
||||
|
||||
@@ -1505,7 +1505,6 @@ export function useTerminalPaneLifecycle({
|
||||
restoredViewportBlankingPanesRef
|
||||
)
|
||||
replayParkedTerminalViewportForTab({
|
||||
isVisible: () => isVisibleRef.current,
|
||||
manager,
|
||||
paneByLeafId: restoredPaneByLeafId,
|
||||
replayingPanesRef,
|
||||
|
||||
@@ -375,11 +375,11 @@ export class PaneManager {
|
||||
return this.atlasRecoveryVisible && !this.destroyed
|
||||
}
|
||||
|
||||
scheduleRevealRepaint(options?: { invalidatePaneId?: number }): void {
|
||||
scheduleRevealRepaint(): 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()), options)
|
||||
schedulePaneRevealRepaint(() => (this.destroyed ? [] : this.panes.values()))
|
||||
}
|
||||
|
||||
scheduleRevealPresent(): void {
|
||||
|
||||
@@ -10,12 +10,7 @@ type FakePaneManager = {
|
||||
refreshAllPanes: Mock<() => void>
|
||||
}
|
||||
|
||||
function createPane(
|
||||
options: {
|
||||
clearRenderModel?: ReturnType<typeof vi.fn>
|
||||
webglAddon?: FakeWebglAddon | null
|
||||
} = {}
|
||||
): ManagedPaneInternal {
|
||||
function createPane(options: { webglAddon?: FakeWebglAddon | null } = {}): ManagedPaneInternal {
|
||||
const leafId = '33333333-3333-4333-8333-333333333333' as never
|
||||
return {
|
||||
id: 1,
|
||||
@@ -25,8 +20,7 @@ function createPane(
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
refresh: vi.fn(),
|
||||
loadAddon: vi.fn(),
|
||||
_core: { _renderService: { clear: options.clearRenderModel ?? vi.fn() } }
|
||||
loadAddon: vi.fn()
|
||||
} as never,
|
||||
container: {} as never,
|
||||
xtermContainer: {} as never,
|
||||
@@ -164,46 +158,6 @@ 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,14 +1,10 @@
|
||||
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: {
|
||||
getPanes: PaneGetter
|
||||
invalidatePaneId?: number
|
||||
}[] = []
|
||||
const pendingRevealRepaints = new Set<PaneGetter>()
|
||||
let revealRepaintScheduled = false
|
||||
|
||||
function scheduleSettledFrame(callback: () => void): void {
|
||||
@@ -41,25 +37,23 @@ function forEachPaneOnSettledFrame(
|
||||
|
||||
function flushPaneRevealRepaints(): void {
|
||||
revealRepaintScheduled = false
|
||||
const paneGetters = pendingRevealRepaints.splice(0)
|
||||
const livePanes = new Map<ManagedPaneInternal, boolean>()
|
||||
const paneGetters = Array.from(pendingRevealRepaints)
|
||||
pendingRevealRepaints.clear()
|
||||
const livePanes = new Set<ManagedPaneInternal>()
|
||||
|
||||
for (const { getPanes, invalidatePaneId } of paneGetters) {
|
||||
for (const getPanes of paneGetters) {
|
||||
try {
|
||||
for (const pane of getPanes()) {
|
||||
livePanes.set(pane, livePanes.get(pane) === true || pane.id === invalidatePaneId)
|
||||
livePanes.add(pane)
|
||||
}
|
||||
} catch {
|
||||
/* ignore — a manager may be destroyed while its repaint is pending */
|
||||
}
|
||||
}
|
||||
|
||||
for (const [pane, shouldInvalidate] of livePanes) {
|
||||
for (const pane 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 */
|
||||
}
|
||||
@@ -75,14 +69,13 @@ 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. Once layout settles, invalidate the
|
||||
* replayed pane's model before one registry-wide atlas reset and refresh.
|
||||
* 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.
|
||||
*/
|
||||
export function schedulePaneRevealRepaint(
|
||||
getPanes: () => Iterable<ManagedPaneInternal>,
|
||||
options?: { invalidatePaneId?: number }
|
||||
): void {
|
||||
pendingRevealRepaints.push({ getPanes, invalidatePaneId: options?.invalidatePaneId })
|
||||
export function schedulePaneRevealRepaint(getPanes: () => Iterable<ManagedPaneInternal>): void {
|
||||
pendingRevealRepaints.add(getPanes)
|
||||
if (revealRepaintScheduled) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import { randomUUID } from 'node:crypto'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { PNG } from 'pngjs'
|
||||
import {
|
||||
HOST_TERMINAL_SURFACE_SEPARATOR,
|
||||
toWebTerminalSurfaceTabId
|
||||
@@ -52,8 +51,6 @@ writeFileSync(
|
||||
'const size = () => `${process.stdout.columns}x${process.stdout.rows}`',
|
||||
'const record = (line) => appendFileSync(sink, `${line}\\n`)',
|
||||
'record(`READY:${size()}`)',
|
||||
"process.stdout.write('\\x1b[2J\\x1b[H')",
|
||||
"process.stdout.write('\\x1b[44m \\x1b[0m\\r\\n')",
|
||||
'process.stdout.write(`READY:${size()}\\r\\n`)',
|
||||
"process.stdout.on('resize', () => {",
|
||||
' record(`SIZE:${size()}`)',
|
||||
@@ -85,7 +82,7 @@ function shellQuote(value: string): string {
|
||||
function fixtureCommand(sinkPath: string): string {
|
||||
const command = [process.execPath, fixturePath, sinkPath]
|
||||
return process.platform === 'win32'
|
||||
? `& ${command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ')}`
|
||||
? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ')
|
||||
: command.map(shellQuote).join(' ')
|
||||
}
|
||||
|
||||
@@ -207,26 +204,6 @@ async function readPaneContent(page: Page, webTabId: string): Promise<string> {
|
||||
}, webTabId)
|
||||
}
|
||||
|
||||
async function readPaintedPaneViewport(
|
||||
page: Page,
|
||||
webTabId: string
|
||||
): Promise<{ renderPaused: boolean; text: string }> {
|
||||
return page.evaluate((id) => {
|
||||
const manager = window.__paneManagers?.get(id)
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!pane) {
|
||||
return { renderPaused: false, text: '' }
|
||||
}
|
||||
const buffer = pane.terminal.buffer.active
|
||||
const text = Array.from(
|
||||
{ length: pane.terminal.rows },
|
||||
(_, row) => buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? ''
|
||||
).join('\n')
|
||||
const renderService = Reflect.get(pane.terminal, '_core')?._renderService
|
||||
return { renderPaused: renderService?._isPaused === true, text }
|
||||
}, webTabId)
|
||||
}
|
||||
|
||||
async function readPaneDiagnostics(
|
||||
page: Page,
|
||||
worktreeId: string,
|
||||
@@ -271,95 +248,6 @@ async function waitForPaneMarker(
|
||||
return false
|
||||
}
|
||||
|
||||
async function waitForPaintedPaneMarker(
|
||||
page: Page,
|
||||
webTabId: string,
|
||||
marker: string,
|
||||
budgetMs: number
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + budgetMs
|
||||
while (Date.now() < deadline) {
|
||||
const viewport = await readPaintedPaneViewport(page, webTabId)
|
||||
if (!viewport.renderPaused && viewport.text.includes(marker)) {
|
||||
return true
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function countForegroundPixels(buffer: Buffer): number {
|
||||
const image = PNG.sync.read(buffer)
|
||||
const buckets = new Map<string, { count: number; red: number; green: number; blue: number }>()
|
||||
for (let offset = 0; offset < image.data.length; offset += 4) {
|
||||
if ((image.data[offset + 3] ?? 0) < 128) {
|
||||
continue
|
||||
}
|
||||
const red = image.data[offset] ?? 0
|
||||
const green = image.data[offset + 1] ?? 0
|
||||
const blue = image.data[offset + 2] ?? 0
|
||||
const key = `${red >> 3},${green >> 3},${blue >> 3}`
|
||||
const bucket = buckets.get(key) ?? { count: 0, red, green, blue }
|
||||
bucket.count += 1
|
||||
buckets.set(key, bucket)
|
||||
}
|
||||
const background = [...buckets.values()].sort((left, right) => right.count - left.count)[0]
|
||||
if (!background) {
|
||||
return 0
|
||||
}
|
||||
let foregroundPixels = 0
|
||||
for (let offset = 0; offset < image.data.length; offset += 4) {
|
||||
const distance =
|
||||
Math.abs((image.data[offset] ?? 0) - background.red) +
|
||||
Math.abs((image.data[offset + 1] ?? 0) - background.green) +
|
||||
Math.abs((image.data[offset + 2] ?? 0) - background.blue)
|
||||
if ((image.data[offset + 3] ?? 0) >= 128 && distance > 48) {
|
||||
foregroundPixels += 1
|
||||
}
|
||||
}
|
||||
return foregroundPixels
|
||||
}
|
||||
|
||||
async function readPaneForegroundPixels(page: Page, webTabId: string): Promise<number> {
|
||||
const clip = await page.evaluate((id) => {
|
||||
const manager = window.__paneManagers?.get(id)
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
const screen = pane?.terminal.element?.querySelector('.xterm-screen')
|
||||
if (!(screen instanceof HTMLElement)) {
|
||||
return null
|
||||
}
|
||||
const rect = screen.getBoundingClientRect()
|
||||
return {
|
||||
x: Math.max(0, rect.left),
|
||||
y: Math.max(0, rect.top),
|
||||
width: Math.max(1, Math.min(rect.width, window.innerWidth - rect.left)),
|
||||
height: Math.max(1, Math.min(rect.height, 64, window.innerHeight - rect.top))
|
||||
}
|
||||
}, webTabId)
|
||||
if (!clip) {
|
||||
return 0
|
||||
}
|
||||
return countForegroundPixels(await page.screenshot({ clip }))
|
||||
}
|
||||
|
||||
async function waitForPaneForegroundPixels(
|
||||
page: Page,
|
||||
webTabId: string,
|
||||
minimum: number,
|
||||
budgetMs: number
|
||||
): Promise<number> {
|
||||
const deadline = Date.now() + budgetMs
|
||||
let foregroundPixels = 0
|
||||
do {
|
||||
foregroundPixels = Math.max(foregroundPixels, await readPaneForegroundPixels(page, webTabId))
|
||||
if (foregroundPixels > minimum) {
|
||||
return foregroundPixels
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
} while (Date.now() < deadline)
|
||||
return foregroundPixels
|
||||
}
|
||||
|
||||
function readPtyGridFromContent(content: string): { cols: number; rows: number } | null {
|
||||
const sizes = [...content.matchAll(/(?:READY|SIZE):(\d+)x(\d+)/g)]
|
||||
const last = sizes.at(-1)
|
||||
@@ -456,12 +344,6 @@ async function seedScenario(
|
||||
message: 'target terminal never painted its READY marker'
|
||||
})
|
||||
.toContain('READY:')
|
||||
await expect
|
||||
.poll(async () => (await readPaintedPaneViewport(client.page, target.webTabId)).text, {
|
||||
timeout: 60_000,
|
||||
message: 'target terminal READY marker never reached the visible viewport'
|
||||
})
|
||||
.toContain('READY:')
|
||||
return { target, decoys }
|
||||
}
|
||||
|
||||
@@ -609,12 +491,6 @@ test('paired client fast-paints a parked terminal before the runtime responds',
|
||||
const previousParkDelay = process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS
|
||||
process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS = String(PARK_DELAY_MS)
|
||||
const client = await launchPairedElectronClient(offer, testInfo, 'parked-fast-paint')
|
||||
const clientWindow = await client.app.browserWindow(client.page)
|
||||
await clientWindow.evaluate((window) => {
|
||||
window.show()
|
||||
window.focus()
|
||||
})
|
||||
await expect.poll(() => clientWindow.evaluate((window) => window.isVisible())).toBe(true)
|
||||
const createdTerminals: string[] = []
|
||||
try {
|
||||
const worktreeId = await orcaPage.evaluate(() => {
|
||||
@@ -655,20 +531,10 @@ test('paired client fast-paints a parked terminal before the runtime responds',
|
||||
|
||||
const startedAt = Date.now()
|
||||
await openClientTab(client.page, worktreeId, target.webTabId)
|
||||
const painted = await waitForPaintedPaneMarker(client.page, target.webTabId, 'READY:', 2_000)
|
||||
expect(
|
||||
painted,
|
||||
await waitForPaneMarker(client.page, target.webTabId, 'READY:', 2_000),
|
||||
'cold-parked viewport stayed blank while the paired runtime was disconnected'
|
||||
).toBe(true)
|
||||
expect(
|
||||
await waitForPaneForegroundPixels(
|
||||
client.page,
|
||||
target.webTabId,
|
||||
1_000,
|
||||
Math.max(1, 2_000 - (Date.now() - startedAt))
|
||||
),
|
||||
'cached viewport reached the buffer but did not paint terminal pixels'
|
||||
).toBeGreaterThan(1_000)
|
||||
console.log(`[paired-reveal] cached viewport painted in ${Date.now() - startedAt}ms`)
|
||||
} finally {
|
||||
await client.page
|
||||
|
||||
Reference in New Issue
Block a user