mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
fix(terminal): preserve snapshot grid through rollback
This commit is contained in:
@@ -32,16 +32,27 @@ it('does not remove handlers installed by a remount while sleep is pending', ()
|
||||
|
||||
it('replays rollback output in its original replay and live arrival order', () => {
|
||||
const ptyId = 'pty-shutdown-output-order'
|
||||
const delivered: string[] = []
|
||||
ptyDataHandlers.set(ptyId, (data) => delivered.push(`data:${data}`))
|
||||
ptyReplayHandlers.set(ptyId, (data) => delivered.push(`replay:${data}`))
|
||||
const delivered: unknown[] = []
|
||||
ptyDataHandlers.set(ptyId, (data) => delivered.push({ kind: 'data', data }))
|
||||
ptyReplayHandlers.set(ptyId, (data, meta) => delivered.push({ kind: 'replay', data, meta }))
|
||||
|
||||
const [snapshot] = unregisterPtyDataHandlers([ptyId])
|
||||
bufferPtyShutdownReplayData(ptyId, 'old')
|
||||
bufferPtyShutdownReplayData(ptyId, 'old', {
|
||||
snapshotCols: 80,
|
||||
snapshotRows: 24,
|
||||
pendingEscapeTailAnsi: '\x1b[3'
|
||||
})
|
||||
bufferPtyShutdownData(ptyId, 'new')
|
||||
snapshot.rollback()
|
||||
|
||||
expect(delivered).toEqual(['replay:old', 'data:new'])
|
||||
expect(delivered).toEqual([
|
||||
{
|
||||
kind: 'replay',
|
||||
data: 'old',
|
||||
meta: { snapshotCols: 80, snapshotRows: 24, pendingEscapeTailAnsi: '\x1b[3' }
|
||||
},
|
||||
{ kind: 'data', data: 'new' }
|
||||
])
|
||||
ptyDataHandlers.delete(ptyId)
|
||||
ptyReplayHandlers.delete(ptyId)
|
||||
})
|
||||
|
||||
@@ -5,7 +5,13 @@ import type { PtyDataMeta } from './pty-dispatcher'
|
||||
|
||||
export const ptyDataHandlers = new Map<string, (data: string, meta?: PtyDataMeta) => void>()
|
||||
export const ptyDataSidecars = new Map<string, Set<(data: string) => void>>()
|
||||
export const ptyReplayHandlers = new Map<string, (data: string) => void>()
|
||||
export type PtyReplayDataMeta = {
|
||||
clearBeforeReplay?: boolean
|
||||
pendingEscapeTailAnsi?: string
|
||||
snapshotCols?: number
|
||||
snapshotRows?: number
|
||||
}
|
||||
export const ptyReplayHandlers = new Map<string, (data: string, meta?: PtyReplayDataMeta) => void>()
|
||||
export const ptyExitHandlers = new Map<string, (code: number) => void>()
|
||||
export const ptyTeardownHandlers = new Map<string, () => void>()
|
||||
export const ptyShutdownLifecycleHandlers = new Map<
|
||||
@@ -16,7 +22,7 @@ export const ptyShutdownLifecycleHandlers = new Map<
|
||||
export type PtyDataHandlerShutdownSnapshot = {
|
||||
ptyId: string
|
||||
dataHandler?: (data: string, meta?: PtyDataMeta) => void
|
||||
replayHandler?: (data: string) => void
|
||||
replayHandler?: (data: string, meta?: PtyReplayDataMeta) => void
|
||||
teardownHandler?: () => void
|
||||
commit: () => void
|
||||
rollback: () => void
|
||||
@@ -28,7 +34,7 @@ type PendingPtyHandlerShutdown = {
|
||||
bufferedBytes: number
|
||||
events: PtyShutdownOutputEvent[]
|
||||
dataHandler?: (data: string, meta?: PtyDataMeta) => void
|
||||
replayHandler?: (data: string) => void
|
||||
replayHandler?: (data: string, meta?: PtyReplayDataMeta) => void
|
||||
teardownHandler?: () => void
|
||||
lifecycleHandler?: { pause: () => void; rollback: () => void; commit: () => void }
|
||||
}
|
||||
@@ -36,7 +42,7 @@ type PendingPtyHandlerShutdown = {
|
||||
const pendingPtyHandlerShutdowns = new Map<string, PendingPtyHandlerShutdown>()
|
||||
type PtyShutdownOutputEvent =
|
||||
| { kind: 'data'; data: string; meta?: PtyDataMeta }
|
||||
| { kind: 'replay'; data: string }
|
||||
| { kind: 'replay'; data: string; meta?: PtyReplayDataMeta }
|
||||
|
||||
const rolledBackShutdownEvents = new Map<string, PtyShutdownOutputEvent[]>()
|
||||
const ROLLED_BACK_SHUTDOWN_REPLAY_MAX_PTYS = 64
|
||||
@@ -100,8 +106,12 @@ export function isPtyDataHandlerShutdownPending(ptyId: string): boolean {
|
||||
return pendingPtyHandlerShutdowns.has(ptyId)
|
||||
}
|
||||
|
||||
export function bufferPtyShutdownReplayData(ptyId: string, data: string): boolean {
|
||||
return bufferPtyShutdownOutput(ptyId, { kind: 'replay', data })
|
||||
export function bufferPtyShutdownReplayData(
|
||||
ptyId: string,
|
||||
data: string,
|
||||
meta?: PtyReplayDataMeta
|
||||
): boolean {
|
||||
return bufferPtyShutdownOutput(ptyId, { kind: 'replay', data, meta })
|
||||
}
|
||||
|
||||
export function bufferPtyShutdownData(ptyId: string, data: string, meta?: PtyDataMeta): boolean {
|
||||
@@ -201,11 +211,15 @@ function deliverShutdownEvents(
|
||||
ptyId: string,
|
||||
events: readonly PtyShutdownOutputEvent[],
|
||||
dataHandler: (data: string, meta?: PtyDataMeta) => void,
|
||||
replayHandler: (data: string) => void
|
||||
replayHandler: (data: string, meta?: PtyReplayDataMeta) => void
|
||||
): void {
|
||||
for (const event of events) {
|
||||
if (event.kind === 'replay') {
|
||||
replayHandler(event.data)
|
||||
if (event.meta) {
|
||||
replayHandler(event.data, event.meta)
|
||||
} else {
|
||||
replayHandler(event.data)
|
||||
}
|
||||
continue
|
||||
}
|
||||
dataHandler(event.data, event.meta)
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { TerminalOscColorQueryReplyColors } from '../../../../shared/termin
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { PtyDataMeta } from './pty-dispatcher'
|
||||
import type { PtyReplayDataMeta } from './pty-shutdown-data-suspension'
|
||||
import type { RemoteRuntimeSnapshotOutcome } from '../../runtime/remote-runtime-terminal-multiplexer'
|
||||
|
||||
export type PtyBufferSnapshot = {
|
||||
@@ -78,15 +79,7 @@ type PtyCallbacks = {
|
||||
onConnect?: () => void
|
||||
onDisconnect?: () => void
|
||||
onData?: (data: string, meta?: PtyDataMeta) => void
|
||||
onReplayData?: (
|
||||
data: string,
|
||||
meta?: {
|
||||
clearBeforeReplay?: boolean
|
||||
pendingEscapeTailAnsi?: string
|
||||
snapshotCols?: number
|
||||
snapshotRows?: number
|
||||
}
|
||||
) => void
|
||||
onReplayData?: (data: string, meta?: PtyReplayDataMeta) => void
|
||||
onStatus?: (shell: string) => void
|
||||
onError?: (message: string, errors?: string[]) => void
|
||||
onExit?: (code: number) => void
|
||||
|
||||
@@ -392,6 +392,37 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
transport.destroy?.()
|
||||
})
|
||||
|
||||
it('preserves the authoritative snapshot grid through shutdown rollback', async () => {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const { unregisterPtyDataHandlers } = await import('./pty-shutdown-data-suspension')
|
||||
const onReplayData = vi.fn()
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' })
|
||||
|
||||
transport.attach({
|
||||
existingPtyId: 'remote:terminal-1',
|
||||
callbacks: { onReplayData }
|
||||
})
|
||||
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
|
||||
const ptyId = transport.getPtyId()
|
||||
if (!ptyId) {
|
||||
throw new Error('missing remote PTY id')
|
||||
}
|
||||
const [shutdown] = unregisterPtyDataHandlers([ptyId])
|
||||
|
||||
emitSnapshot(latestSubscribePayload().streamId, 'authoritative state', {
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
|
||||
expect(onReplayData).not.toHaveBeenCalled()
|
||||
shutdown.rollback()
|
||||
expect(onReplayData).toHaveBeenCalledWith('authoritative state', {
|
||||
snapshotCols: 80,
|
||||
snapshotRows: 24
|
||||
})
|
||||
transport.destroy?.()
|
||||
})
|
||||
|
||||
it('recovers when the first restored-terminal subscription attempt is offline', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -80,7 +80,8 @@ import {
|
||||
isPtyDataHandlerShutdownPending,
|
||||
ptyDataHandlers,
|
||||
ptyReplayHandlers,
|
||||
ptyShutdownLifecycleHandlers
|
||||
ptyShutdownLifecycleHandlers,
|
||||
type PtyReplayDataMeta
|
||||
} from './pty-shutdown-data-suspension'
|
||||
import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision'
|
||||
|
||||
@@ -372,10 +373,11 @@ export function createRemoteRuntimePtyTransport(
|
||||
): void => {
|
||||
outputProcessor.processData(data, storedCallbacks, undefined, meta)
|
||||
}
|
||||
const shutdownReplayHandler = (data: string): void => {
|
||||
const shutdownReplayHandler = (data: string, meta?: PtyReplayDataMeta): void => {
|
||||
outputProcessor.processData(data, storedCallbacks, {
|
||||
replayingBufferedData: true,
|
||||
suppressAttentionEvents: true
|
||||
suppressAttentionEvents: true,
|
||||
...meta
|
||||
})
|
||||
}
|
||||
const shutdownLifecycle = {
|
||||
@@ -1760,17 +1762,20 @@ export function createRemoteRuntimePtyTransport(
|
||||
onSnapshot: (data, meta) => {
|
||||
// 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
|
||||
}
|
||||
outputProcessor.processData(data, storedCallbacks, {
|
||||
replayingBufferedData: true,
|
||||
suppressAttentionEvents: true,
|
||||
const replayMeta: PtyReplayDataMeta = {
|
||||
...(meta?.cols !== undefined ? { snapshotCols: meta.cols } : {}),
|
||||
...(meta?.rows !== undefined ? { snapshotRows: meta.rows } : {}),
|
||||
...(meta?.pendingEscapeTailAnsi
|
||||
? { pendingEscapeTailAnsi: meta.pendingEscapeTailAnsi }
|
||||
: {})
|
||||
}
|
||||
if (subscribedPtyId && bufferPtyShutdownReplayData(subscribedPtyId, data, replayMeta)) {
|
||||
return
|
||||
}
|
||||
outputProcessor.processData(data, storedCallbacks, {
|
||||
replayingBufferedData: true,
|
||||
suppressAttentionEvents: true,
|
||||
...replayMeta
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user