fix: restore hidden remote terminal output snapshots (#4759)
This commit is contained in:
parent
ca5ccdd126
commit
9db0985c7f
|
|
@ -4920,7 +4920,8 @@ describe('registerPtyHandlers', () => {
|
|||
data: 'snapshot\r\n',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
seq: 42
|
||||
seq: 42,
|
||||
source: 'headless'
|
||||
})
|
||||
}
|
||||
handlers.clear()
|
||||
|
|
@ -4934,7 +4935,13 @@ describe('registerPtyHandlers', () => {
|
|||
expect(runtime.serializeMainTerminalBuffer).toHaveBeenCalledWith('pty-1', {
|
||||
scrollbackRows: 50_000
|
||||
})
|
||||
expect(result).toEqual({ data: 'snapshot\r\n', cols: 120, rows: 40, seq: 42 })
|
||||
expect(result).toEqual({
|
||||
data: 'snapshot\r\n',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
seq: 42,
|
||||
source: 'headless'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1715,7 +1715,13 @@ export function registerPtyHandlers(
|
|||
async (
|
||||
_event,
|
||||
args: { id?: unknown; opts?: { scrollbackRows?: unknown } }
|
||||
): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> => {
|
||||
): Promise<{
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
} | null> => {
|
||||
if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ describe('mobile subscribe integration', () => {
|
|||
runtime.onPtyData('pty-1', first, Date.now())
|
||||
const initialSnapshot = await runtime.serializeMainTerminalBuffer('pty-1')
|
||||
expect(initialSnapshot?.seq).toBe(first.length)
|
||||
expect(initialSnapshot?.source).toBe('headless')
|
||||
|
||||
type HeadlessStateForTest = {
|
||||
emulator: { write: (data: string) => Promise<void> | void }
|
||||
|
|
@ -179,12 +180,14 @@ describe('mobile subscribe integration', () => {
|
|||
|
||||
const snapshot = await racedSnapshot
|
||||
expect(snapshot?.seq).toBe(first.length + second.length)
|
||||
expect(snapshot?.source).toBe('headless')
|
||||
expect(runtime.getPtyOutputSequence('pty-1')).toBe(
|
||||
first.length + second.length + third.length
|
||||
)
|
||||
|
||||
const finalSnapshot = await runtime.serializeMainTerminalBuffer('pty-1')
|
||||
expect(finalSnapshot?.seq).toBe(first.length + second.length + third.length)
|
||||
expect(finalSnapshot?.source).toBe('headless')
|
||||
} finally {
|
||||
headless!.emulator.write = originalWrite
|
||||
secondWriteGate.release?.()
|
||||
|
|
@ -222,7 +225,8 @@ describe('mobile subscribe integration', () => {
|
|||
data: '',
|
||||
cols: 90,
|
||||
rows: 30,
|
||||
seq: 17
|
||||
seq: 17,
|
||||
source: 'headless'
|
||||
})
|
||||
await expect(runtime.serializeTerminalBuffer('pty-empty')).resolves.toBeNull()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1289,7 +1289,10 @@ export class OrcaRuntimeService {
|
|||
// Why: mobile clients subscribe to terminal output via terminal.subscribe.
|
||||
// These listeners fire on every onPtyData call, enabling real-time streaming
|
||||
// without polling. Keyed by ptyId for O(1) lookup per data event.
|
||||
private dataListeners = new Map<string, Set<(data: string) => void>>()
|
||||
private dataListeners = new Map<
|
||||
string,
|
||||
Set<(data: string, meta?: { seq?: number; rawLength?: number }) => void>
|
||||
>()
|
||||
// Why: startup draft paste can subscribe after the agent already emitted its
|
||||
// ready marker. Keep a bounded raw buffer so fast startup output is replayed.
|
||||
private recentPtyOutputById = new Map<string, string>()
|
||||
|
|
@ -3106,8 +3109,9 @@ export class OrcaRuntimeService {
|
|||
|
||||
const listeners = this.dataListeners.get(ptyId)
|
||||
if (listeners) {
|
||||
const meta = { seq: outputSequence, rawLength: data.length }
|
||||
for (const listener of listeners) {
|
||||
listener(data)
|
||||
listener(data, meta)
|
||||
}
|
||||
}
|
||||
return outputSequence
|
||||
|
|
@ -3117,7 +3121,10 @@ export class OrcaRuntimeService {
|
|||
return this.ptyOutputSequenceById.get(ptyId) ?? 0
|
||||
}
|
||||
|
||||
subscribeToTerminalData(ptyId: string, listener: (data: string) => void): () => void {
|
||||
subscribeToTerminalData(
|
||||
ptyId: string,
|
||||
listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
): () => void {
|
||||
let listeners = this.dataListeners.get(ptyId)
|
||||
if (!listeners) {
|
||||
listeners = new Set()
|
||||
|
|
@ -3183,14 +3190,26 @@ export class OrcaRuntimeService {
|
|||
serializeTerminalBuffer(
|
||||
ptyId: string,
|
||||
opts: { scrollbackRows?: number } = {}
|
||||
): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> {
|
||||
): Promise<{
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
} | null> {
|
||||
return this.serializeTerminalBufferFromAvailableState(ptyId, opts)
|
||||
}
|
||||
|
||||
serializeMainTerminalBuffer(
|
||||
ptyId: string,
|
||||
opts: { scrollbackRows?: number } = {}
|
||||
): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> {
|
||||
): Promise<{
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
} | null> {
|
||||
return this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, includeEmpty: true })
|
||||
}
|
||||
|
||||
|
|
@ -3385,7 +3404,13 @@ export class OrcaRuntimeService {
|
|||
private async serializeTerminalBufferFromAvailableState(
|
||||
ptyId: string,
|
||||
opts: { scrollbackRows?: number } = {}
|
||||
): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> {
|
||||
): Promise<{
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
} | null> {
|
||||
const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts)
|
||||
if (headlessSnapshot) {
|
||||
return headlessSnapshot
|
||||
|
|
@ -3412,15 +3437,21 @@ export class OrcaRuntimeService {
|
|||
// below can still preserve colored terminal state.
|
||||
}
|
||||
if (rendererSnapshot && rendererSnapshot.data.length > 0) {
|
||||
return rendererSnapshot
|
||||
return { ...rendererSnapshot, source: 'renderer' }
|
||||
}
|
||||
return rendererSnapshot
|
||||
return rendererSnapshot ? { ...rendererSnapshot, source: 'renderer' } : null
|
||||
}
|
||||
|
||||
private async serializeHeadlessTerminalBuffer(
|
||||
ptyId: string,
|
||||
opts: { scrollbackRows?: number; includeEmpty?: boolean } = {}
|
||||
): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> {
|
||||
): Promise<{
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
source?: 'headless'
|
||||
} | null> {
|
||||
const state = this.headlessTerminals.get(ptyId)
|
||||
if (!state) {
|
||||
return null
|
||||
|
|
@ -3438,7 +3469,13 @@ export class OrcaRuntimeService {
|
|||
const snapshot = state.emulator.getSnapshot({ scrollbackRows })
|
||||
const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi
|
||||
return data.length > 0 || opts.includeEmpty === true
|
||||
? { data, cols: snapshot.cols, rows: snapshot.rows, seq: state.outputSequence }
|
||||
? {
|
||||
data,
|
||||
cols: snapshot.cols,
|
||||
rows: snapshot.rows,
|
||||
seq: state.outputSequence,
|
||||
source: 'headless'
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../shared/feature-educatio
|
|||
// WebView's xterm has a 5000-row buffer so this fits comfortably.
|
||||
const MOBILE_SUBSCRIBE_SCROLLBACK_ROWS = 1000
|
||||
const MOBILE_SNAPSHOT_BYTE_BUDGET = 512 * 1024
|
||||
const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024
|
||||
const TERMINAL_STREAM_CHUNK_BYTES = 48 * 1024
|
||||
const TERMINAL_OUTPUT_FLUSH_MS = 5
|
||||
const TERMINAL_OUTPUT_BATCH_MAX_CHARS = 64 * 1024
|
||||
|
|
@ -33,17 +34,21 @@ type SnapshotFrameOptions = {
|
|||
cols: number
|
||||
rows: number
|
||||
data: string
|
||||
requestId?: number
|
||||
displayMode?: string
|
||||
reason?: string
|
||||
seq?: number
|
||||
truncated?: boolean
|
||||
truncatedByByteBudget?: boolean
|
||||
source?: 'headless' | 'renderer'
|
||||
}
|
||||
|
||||
type SerializedSnapshot = {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
scrollbackRows: number
|
||||
truncatedByByteBudget: boolean
|
||||
} | null
|
||||
|
|
@ -60,8 +65,9 @@ type TerminalMultiplexStream = {
|
|||
client: TerminalViewportClient | undefined
|
||||
isMobile: boolean
|
||||
buffering: boolean
|
||||
pendingOutput: string[]
|
||||
pendingOutput: TerminalOutputChunk[]
|
||||
pendingOutputChars: number
|
||||
pendingOutputOverflowed: boolean
|
||||
outputBatcher: ReturnType<typeof createTerminalOutputBatcher>
|
||||
unsubscribeData: () => void
|
||||
unsubscribeResize: () => void
|
||||
|
|
@ -70,13 +76,21 @@ type TerminalMultiplexStream = {
|
|||
unregisterBinaryHandler: () => void
|
||||
}
|
||||
|
||||
function createTerminalOutputBatcher(onFlush: (data: string) => void): {
|
||||
push: (data: string) => void
|
||||
type TerminalOutputChunk = {
|
||||
data: string
|
||||
meta?: { seq?: number; rawLength?: number }
|
||||
}
|
||||
|
||||
function createTerminalOutputBatcher(
|
||||
onFlush: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
): {
|
||||
push: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
flush: () => void
|
||||
dispose: () => void
|
||||
} {
|
||||
let chunks: string[] = []
|
||||
let chars = 0
|
||||
let lastSeq: number | undefined
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const clearTimer = (): void => {
|
||||
|
|
@ -93,18 +107,23 @@ function createTerminalOutputBatcher(onFlush: (data: string) => void): {
|
|||
return
|
||||
}
|
||||
const data = chunks.length === 1 ? chunks[0]! : chunks.join('')
|
||||
const meta = typeof lastSeq === 'number' ? { seq: lastSeq, rawLength: data.length } : undefined
|
||||
chunks = []
|
||||
chars = 0
|
||||
onFlush(data)
|
||||
lastSeq = undefined
|
||||
onFlush(data, meta)
|
||||
}
|
||||
|
||||
return {
|
||||
push(data: string): void {
|
||||
push(data: string, meta?: { seq?: number; rawLength?: number }): void {
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
chunks.push(data)
|
||||
chars += data.length
|
||||
if (typeof meta?.seq === 'number') {
|
||||
lastSeq = meta.seq
|
||||
}
|
||||
if (chars >= TERMINAL_OUTPUT_BATCH_MAX_CHARS) {
|
||||
flush()
|
||||
return
|
||||
|
|
@ -157,28 +176,35 @@ function resolveMobileFloorClientId(
|
|||
return null
|
||||
}
|
||||
|
||||
function appendPendingMultiplexOutput(stream: TerminalMultiplexStream, data: string): void {
|
||||
stream.pendingOutput.push(data)
|
||||
function appendPendingMultiplexOutput(
|
||||
stream: TerminalMultiplexStream,
|
||||
data: string,
|
||||
meta?: { seq?: number; rawLength?: number }
|
||||
): void {
|
||||
stream.pendingOutput.push({ data, meta })
|
||||
stream.pendingOutputChars += data.length
|
||||
stream.pendingOutputChars = trimPendingOutputToBudget(
|
||||
stream.pendingOutput,
|
||||
stream.pendingOutputChars
|
||||
)
|
||||
const trimmed = trimPendingOutputToBudget(stream.pendingOutput, stream.pendingOutputChars)
|
||||
stream.pendingOutputChars = trimmed.chars
|
||||
stream.pendingOutputOverflowed ||= trimmed.overflowed
|
||||
}
|
||||
|
||||
function trimPendingOutputToBudget(pendingOutput: string[], pendingOutputChars: number): number {
|
||||
function trimPendingOutputToBudget(
|
||||
pendingOutput: (string | TerminalOutputChunk)[],
|
||||
pendingOutputChars: number
|
||||
): { chars: number; overflowed: boolean } {
|
||||
let omittedChunkCount = 0
|
||||
while (
|
||||
pendingOutputChars > TERMINAL_MULTIPLEX_PENDING_MAX_CHARS &&
|
||||
omittedChunkCount < pendingOutput.length
|
||||
) {
|
||||
pendingOutputChars -= pendingOutput[omittedChunkCount].length
|
||||
const chunk = pendingOutput[omittedChunkCount]
|
||||
pendingOutputChars -= typeof chunk === 'string' ? chunk.length : chunk.data.length
|
||||
omittedChunkCount += 1
|
||||
}
|
||||
if (omittedChunkCount > 0) {
|
||||
pendingOutput.splice(0, omittedChunkCount)
|
||||
}
|
||||
return pendingOutputChars
|
||||
return { chars: pendingOutputChars, overflowed: omittedChunkCount > 0 }
|
||||
}
|
||||
|
||||
function isTerminalReadPayloadIncomplete(read: { truncated: boolean; limited?: boolean }): boolean {
|
||||
|
|
@ -187,6 +213,43 @@ function isTerminalReadPayloadIncomplete(read: { truncated: boolean; limited?: b
|
|||
return read.truncated || read.limited === true
|
||||
}
|
||||
|
||||
function normalizeMultiplexSnapshotScrollbackRows(value: number | undefined): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return undefined
|
||||
}
|
||||
return Math.max(0, Math.min(50_000, Math.floor(value)))
|
||||
}
|
||||
|
||||
function requestedSnapshotScrollbackCandidates(requestedRows: number | undefined): number[] {
|
||||
const candidates = [requestedRows ?? 0, 1000, 500, 250, 100, 25, 0]
|
||||
.filter((rows): rows is number => typeof rows === 'number')
|
||||
.map((rows) => Math.max(0, Math.min(50_000, Math.floor(rows))))
|
||||
return [...new Set(candidates)]
|
||||
}
|
||||
|
||||
async function serializeBudgetedRequestedSnapshot(
|
||||
runtime: OrcaRuntimeService,
|
||||
ptyId: string,
|
||||
scrollbackRows: number | undefined
|
||||
): Promise<SerializedSnapshot> {
|
||||
const requestedRows = scrollbackRows ?? 0
|
||||
for (const rows of requestedSnapshotScrollbackCandidates(scrollbackRows)) {
|
||||
const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: rows })
|
||||
if (!serialized) {
|
||||
return null
|
||||
}
|
||||
const bytes = new TextEncoder().encode(serialized.data).byteLength
|
||||
if (bytes <= REQUESTED_SNAPSHOT_BYTE_BUDGET || rows === 0) {
|
||||
return {
|
||||
...serialized,
|
||||
scrollbackRows: rows,
|
||||
truncatedByByteBudget: rows < requestedRows || bytes > REQUESTED_SNAPSHOT_BYTE_BUDGET
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function sendSnapshotFrames(
|
||||
sendFrame: (opcode: TerminalStreamOpcode, payload?: Uint8Array<ArrayBufferLike>) => void,
|
||||
options: SnapshotFrameOptions
|
||||
|
|
@ -197,9 +260,11 @@ function sendSnapshotFrames(
|
|||
kind: options.kind,
|
||||
cols: options.cols,
|
||||
rows: options.rows,
|
||||
requestId: options.requestId,
|
||||
displayMode: options.displayMode,
|
||||
reason: options.reason,
|
||||
seq: options.seq,
|
||||
source: options.source,
|
||||
truncated: options.truncated === true,
|
||||
truncatedByByteBudget: options.truncatedByByteBudget === true
|
||||
})
|
||||
|
|
@ -404,6 +469,11 @@ const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({
|
|||
viewport: TerminalViewport.optional()
|
||||
})
|
||||
|
||||
const TerminalMultiplexSnapshotRequestFrame = z.object({
|
||||
requestId: z.number().int().positive().optional(),
|
||||
scrollbackRows: z.number().finite().optional()
|
||||
})
|
||||
|
||||
const TerminalSetDisplayMode = TerminalHandle.extend({
|
||||
// Why: 'phone' was previously a "stay at phone dims after unsubscribe"
|
||||
// mode that the toggle UI never produced and nothing in product
|
||||
|
|
@ -732,12 +802,20 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
const sendFrame = (
|
||||
streamId: number,
|
||||
opcode: TerminalStreamOpcode,
|
||||
payload: Uint8Array<ArrayBufferLike> = new Uint8Array()
|
||||
payload: Uint8Array<ArrayBufferLike> = new Uint8Array(),
|
||||
seq?: number
|
||||
): void => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: cursor++, payload }))
|
||||
sendBinary(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode,
|
||||
streamId,
|
||||
seq: typeof seq === 'number' ? seq : cursor++,
|
||||
payload
|
||||
})
|
||||
)
|
||||
}
|
||||
const sendStreamError = (streamId: number, message: string): void => {
|
||||
sendFrame(streamId, TerminalStreamOpcode.Error, encodeTerminalStreamText(message))
|
||||
|
|
@ -817,6 +895,99 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
{ cols: viewport.cols, rows: viewport.rows },
|
||||
stream.isMobile ? 'mobile' : 'desktop'
|
||||
).catch(() => {})
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.SnapshotRequest) {
|
||||
const payload = TerminalMultiplexSnapshotRequestFrame.safeParse(
|
||||
decodeTerminalStreamJson<unknown>(frame.payload) ?? {}
|
||||
)
|
||||
void sendRequestedSnapshot(stream, payload.success ? payload.data : {})
|
||||
}
|
||||
}
|
||||
const sendRequestedSnapshot = async (
|
||||
stream: TerminalMultiplexStream,
|
||||
request: z.infer<typeof TerminalMultiplexSnapshotRequestFrame>
|
||||
): Promise<void> => {
|
||||
if (closed || streams.get(stream.streamId) !== stream) {
|
||||
return
|
||||
}
|
||||
stream.outputBatcher.flush()
|
||||
stream.pendingOutputOverflowed = false
|
||||
stream.buffering = true
|
||||
const requestId = request.requestId
|
||||
try {
|
||||
const scrollbackRows = normalizeMultiplexSnapshotScrollbackRows(request.scrollbackRows)
|
||||
let serialized = await serializeBudgetedRequestedSnapshot(
|
||||
runtime,
|
||||
stream.ptyId,
|
||||
scrollbackRows
|
||||
)
|
||||
if (closed || streams.get(stream.streamId) !== stream) {
|
||||
return
|
||||
}
|
||||
let size = runtime.getTerminalSize(stream.ptyId)
|
||||
let displayMode = runtime.getMobileDisplayMode(stream.ptyId)
|
||||
if (stream.pendingOutputOverflowed) {
|
||||
// Why: the overflowed tail is newer than the first snapshot. Retry
|
||||
// so hidden restore receives a current terminal image instead of null.
|
||||
stream.pendingOutput.splice(0)
|
||||
stream.pendingOutputChars = 0
|
||||
stream.pendingOutputOverflowed = false
|
||||
serialized = await serializeBudgetedRequestedSnapshot(
|
||||
runtime,
|
||||
stream.ptyId,
|
||||
scrollbackRows
|
||||
)
|
||||
if (closed || streams.get(stream.streamId) !== stream) {
|
||||
return
|
||||
}
|
||||
size = runtime.getTerminalSize(stream.ptyId)
|
||||
displayMode = runtime.getMobileDisplayMode(stream.ptyId)
|
||||
if (stream.pendingOutputOverflowed) {
|
||||
sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), {
|
||||
kind: 'scrollback',
|
||||
cols: size?.cols ?? 80,
|
||||
rows: size?.rows ?? 24,
|
||||
requestId,
|
||||
displayMode,
|
||||
truncated: true,
|
||||
truncatedByByteBudget: false,
|
||||
data: ''
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), {
|
||||
kind: 'scrollback',
|
||||
cols: serialized?.cols ?? size?.cols ?? 80,
|
||||
rows: serialized?.rows ?? size?.rows ?? 24,
|
||||
requestId,
|
||||
displayMode,
|
||||
seq: serialized?.seq,
|
||||
source: serialized?.source,
|
||||
truncated: false,
|
||||
truncatedByByteBudget: serialized?.truncatedByByteBudget,
|
||||
data: serialized?.data ?? ''
|
||||
})
|
||||
} catch (error) {
|
||||
sendStreamError(
|
||||
stream.streamId,
|
||||
error instanceof Error ? error.message : 'Remote terminal snapshot failed.'
|
||||
)
|
||||
} finally {
|
||||
if (streams.get(stream.streamId) === stream) {
|
||||
const shouldFlushPendingOutput = !stream.pendingOutputOverflowed
|
||||
stream.buffering = false
|
||||
const pendingOutput = stream.pendingOutput.splice(0)
|
||||
if (shouldFlushPendingOutput) {
|
||||
for (const chunk of pendingOutput) {
|
||||
stream.outputBatcher.push(chunk.data, chunk.meta)
|
||||
}
|
||||
}
|
||||
stream.pendingOutputChars = 0
|
||||
stream.pendingOutputOverflowed = false
|
||||
stream.outputBatcher.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
const handleSubscribeFrame = async (payload: Uint8Array<ArrayBufferLike>): Promise<void> => {
|
||||
|
|
@ -857,8 +1028,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
buffering: true,
|
||||
pendingOutput: [],
|
||||
pendingOutputChars: 0,
|
||||
outputBatcher: createTerminalOutputBatcher((data) => {
|
||||
sendFrame(request.streamId, TerminalStreamOpcode.Output, encodeTerminalStreamText(data))
|
||||
pendingOutputOverflowed: false,
|
||||
outputBatcher: createTerminalOutputBatcher((data, meta) => {
|
||||
sendFrame(
|
||||
request.streamId,
|
||||
TerminalStreamOpcode.Output,
|
||||
encodeTerminalStreamText(data),
|
||||
meta?.seq
|
||||
)
|
||||
}),
|
||||
unsubscribeData: () => {},
|
||||
unsubscribeResize: () => {},
|
||||
|
|
@ -872,15 +1049,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
)
|
||||
|
||||
try {
|
||||
stream.unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => {
|
||||
stream.unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
|
||||
if (closed || streams.get(request.streamId) !== stream) {
|
||||
return
|
||||
}
|
||||
if (stream.buffering) {
|
||||
appendPendingMultiplexOutput(stream, data)
|
||||
appendPendingMultiplexOutput(stream, data, meta)
|
||||
return
|
||||
}
|
||||
stream.outputBatcher.push(data)
|
||||
stream.outputBatcher.push(data, meta)
|
||||
})
|
||||
|
||||
if (isMobile && request.client?.id) {
|
||||
|
|
@ -924,7 +1101,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
}
|
||||
const size = runtime.getTerminalSize(ptyId)
|
||||
const displayMode = runtime.getMobileDisplayMode(ptyId)
|
||||
const seq = runtime.getLayout(ptyId)?.seq
|
||||
const layoutSeq = runtime.getLayout(ptyId)?.seq
|
||||
const snapshotSeq = serialized?.seq ?? layoutSeq
|
||||
if (!isMobile) {
|
||||
const fitOverride = runtime.getTerminalFitOverride(ptyId)
|
||||
emit({
|
||||
|
|
@ -947,7 +1125,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
cols: serialized?.cols ?? size?.cols,
|
||||
rows: serialized?.rows ?? size?.rows,
|
||||
displayMode,
|
||||
seq,
|
||||
seq: layoutSeq,
|
||||
truncated: serialized ? read.truncated : isTerminalReadPayloadIncomplete(read)
|
||||
})
|
||||
sendSnapshotFrames((opcode, payload) => sendFrame(request.streamId, opcode, payload), {
|
||||
|
|
@ -955,16 +1133,18 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
cols: serialized?.cols ?? size?.cols ?? 80,
|
||||
rows: serialized?.rows ?? size?.rows ?? 24,
|
||||
displayMode,
|
||||
seq,
|
||||
seq: snapshotSeq,
|
||||
truncated: serialized ? read.truncated : isTerminalReadPayloadIncomplete(read),
|
||||
truncatedByByteBudget: serialized?.truncatedByByteBudget,
|
||||
source: serialized?.source,
|
||||
data: serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '')
|
||||
})
|
||||
stream.buffering = false
|
||||
for (const data of stream.pendingOutput.splice(0)) {
|
||||
stream.outputBatcher.push(data)
|
||||
for (const chunk of stream.pendingOutput.splice(0)) {
|
||||
stream.outputBatcher.push(chunk.data, chunk.meta)
|
||||
}
|
||||
stream.pendingOutputChars = 0
|
||||
stream.pendingOutputOverflowed = false
|
||||
stream.outputBatcher.flush()
|
||||
|
||||
stream.unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => {
|
||||
|
|
@ -1000,10 +1180,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
}
|
||||
}
|
||||
const unregisterControlHandler = registerBinaryStreamHandler(0, (frame) => {
|
||||
if (frame.opcode !== TerminalStreamOpcode.Subscribe) {
|
||||
return
|
||||
if (frame.opcode === TerminalStreamOpcode.Subscribe) {
|
||||
void handleSubscribeFrame(frame.payload)
|
||||
}
|
||||
void handleSubscribeFrame(frame.payload)
|
||||
})
|
||||
|
||||
runtime.registerSubscriptionCleanup(
|
||||
|
|
@ -1238,7 +1417,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
if (buffering) {
|
||||
pendingOutput.push(data)
|
||||
pendingOutputChars += data.length
|
||||
pendingOutputChars = trimPendingOutputToBudget(pendingOutput, pendingOutputChars)
|
||||
pendingOutputChars = trimPendingOutputToBudget(pendingOutput, pendingOutputChars).chars
|
||||
return
|
||||
}
|
||||
outputBatcher?.push(data)
|
||||
|
|
|
|||
|
|
@ -185,6 +185,46 @@ describe('terminal multiplex RPC', () => {
|
|||
rows: 40
|
||||
})
|
||||
|
||||
const frameCountBeforeSnapshotRequest = binaryFrames.length
|
||||
handlers.get(5)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.SnapshotRequest,
|
||||
streamId: 5,
|
||||
seq: 4,
|
||||
payload: encodeTerminalStreamJson({ requestId: 7, scrollbackRows: 5000 })
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
binaryFrames
|
||||
.slice(frameCountBeforeSnapshotRequest)
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.some((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotEnd)
|
||||
).toBe(true)
|
||||
)
|
||||
const requestedSnapshotFrames = binaryFrames
|
||||
.slice(frameCountBeforeSnapshotRequest)
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
const requestedSnapshotStart = requestedSnapshotFrames.find(
|
||||
(frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart
|
||||
)
|
||||
expect(
|
||||
requestedSnapshotStart && decodeTerminalStreamJson(requestedSnapshotStart.payload)
|
||||
).toMatchObject({
|
||||
requestId: 7
|
||||
})
|
||||
expect(runtime.serializeTerminalBuffer).toHaveBeenLastCalledWith('pty-1', {
|
||||
scrollbackRows: 5000
|
||||
})
|
||||
expect(
|
||||
requestedSnapshotFrames
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk)
|
||||
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
|
||||
.join('')
|
||||
).toBe('snapshot')
|
||||
|
||||
runtime.cleanupSubscription('terminal-multiplex:conn-1')
|
||||
await dispatchPromise
|
||||
} finally {
|
||||
|
|
@ -288,6 +328,115 @@ describe('terminal multiplex RPC', () => {
|
|||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('falls back to smaller requested snapshots when serialized data exceeds the send budget', async () => {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const handlers = new Map<
|
||||
number,
|
||||
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ data: 'initial', cols: 120, rows: 40 })
|
||||
.mockResolvedValueOnce({ data: 'x'.repeat(2 * 1024 * 1024 + 1), cols: 120, rows: 40 })
|
||||
.mockResolvedValueOnce({ data: 'budgeted snapshot', cols: 120, rows: 40 }),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
getTerminalFitOverride: vi.fn().mockReturnValue(null),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
|
||||
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
|
||||
updateDesktopViewport: vi.fn().mockResolvedValue(true)
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.multiplex', {}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-budgeted-request',
|
||||
sendBinary: (bytes) => binaryFrames.push(bytes),
|
||||
registerBinaryStreamHandler: (streamId, handler) => {
|
||||
handlers.set(streamId, handler)
|
||||
return () => handlers.delete(streamId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
|
||||
)
|
||||
handlers.get(0)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
seq: 1,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId: 14,
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' },
|
||||
viewport: { cols: 120, rows: 40 }
|
||||
})
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
|
||||
)
|
||||
const frameCountBeforeSnapshotRequest = binaryFrames.length
|
||||
|
||||
handlers.get(14)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.SnapshotRequest,
|
||||
streamId: 14,
|
||||
seq: 2,
|
||||
payload: encodeTerminalStreamJson({ requestId: 55, scrollbackRows: 5000 })
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(3))
|
||||
|
||||
const requestedFrames = binaryFrames
|
||||
.slice(frameCountBeforeSnapshotRequest)
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
const requestedStart = requestedFrames.find(
|
||||
(frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart
|
||||
)
|
||||
expect(requestedStart && decodeTerminalStreamJson(requestedStart.payload)).toMatchObject({
|
||||
requestId: 55,
|
||||
truncatedByByteBudget: true
|
||||
})
|
||||
expect(runtime.serializeTerminalBuffer).toHaveBeenNthCalledWith(2, 'pty-1', {
|
||||
scrollbackRows: 5000
|
||||
})
|
||||
expect(runtime.serializeTerminalBuffer).toHaveBeenNthCalledWith(3, 'pty-1', {
|
||||
scrollbackRows: 1000
|
||||
})
|
||||
expect(
|
||||
requestedFrames
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk)
|
||||
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
|
||||
.join('')
|
||||
).toBe('budgeted snapshot')
|
||||
|
||||
cleanups.get('terminal-multiplex:conn-budgeted-request')?.()
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('drops desktop multiplex input while a mobile client owns the terminal floor', async () => {
|
||||
const messages: string[] = []
|
||||
const handlers = new Map<
|
||||
|
|
@ -732,4 +881,155 @@ describe('terminal multiplex RPC', () => {
|
|||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('retries requested snapshots after live output overflows during serialization', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const handlers = new Map<
|
||||
number,
|
||||
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const dataListenerRef: { current?: (data: string) => void } = {}
|
||||
let resolveRequestedSnapshot: (value: {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
}) => void = () => {}
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ data: 'initial', cols: 120, rows: 40 })
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ data: string; cols: number; rows: number }>((resolve) => {
|
||||
resolveRequestedSnapshot = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ data: 'retry snapshot', cols: 120, rows: 40 }),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn((_: string, listener: (data: string) => void) => {
|
||||
dataListenerRef.current = listener
|
||||
return vi.fn()
|
||||
}),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
getTerminalFitOverride: vi.fn().mockReturnValue(null),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
|
||||
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
|
||||
updateDesktopViewport: vi.fn().mockResolvedValue(true)
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.multiplex', {}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-request-overflow',
|
||||
sendBinary: (bytes) => binaryFrames.push(bytes),
|
||||
registerBinaryStreamHandler: (streamId, handler) => {
|
||||
handlers.set(streamId, handler)
|
||||
return () => handlers.delete(streamId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
|
||||
)
|
||||
handlers.get(0)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
seq: 1,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId: 12,
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' },
|
||||
viewport: { cols: 120, rows: 40 }
|
||||
})
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
|
||||
)
|
||||
const frameCountBeforeSnapshotRequest = binaryFrames.length
|
||||
|
||||
handlers.get(12)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.SnapshotRequest,
|
||||
streamId: 12,
|
||||
seq: 2,
|
||||
payload: encodeTerminalStreamJson({ requestId: 44, scrollbackRows: 5000 })
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(2))
|
||||
for (let index = 0; index < 400; index += 1) {
|
||||
dataListenerRef.current?.(String(index).padStart(3, '0') + 'x'.repeat(1021))
|
||||
}
|
||||
resolveRequestedSnapshot({ data: 'requested', cols: 120, rows: 40 })
|
||||
await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(3))
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
binaryFrames
|
||||
.slice(frameCountBeforeSnapshotRequest)
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.some((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotEnd)
|
||||
).toBe(true)
|
||||
)
|
||||
|
||||
const requestedFrames = binaryFrames
|
||||
.slice(frameCountBeforeSnapshotRequest)
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
const requestedStart = requestedFrames.find(
|
||||
(frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart
|
||||
)
|
||||
expect(requestedStart && decodeTerminalStreamJson(requestedStart.payload)).toMatchObject({
|
||||
requestId: 44,
|
||||
truncated: false
|
||||
})
|
||||
expect(
|
||||
requestedFrames
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk)
|
||||
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
|
||||
.join('')
|
||||
).toBe('retry snapshot')
|
||||
expect(
|
||||
requestedFrames
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
|
||||
.join('')
|
||||
).toBe('')
|
||||
|
||||
dataListenerRef.current?.('live-after-overflow')
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
const outputAfterOverflow = binaryFrames
|
||||
.slice(frameCountBeforeSnapshotRequest)
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
|
||||
.join('')
|
||||
expect(outputAfterOverflow).toBe('live-after-overflow')
|
||||
|
||||
cleanups.get('terminal-multiplex:conn-request-overflow')?.()
|
||||
await dispatchPromise
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -908,7 +908,13 @@ export type PreloadApi = {
|
|||
getMainBufferSnapshot: (
|
||||
id: string,
|
||||
opts?: { scrollbackRows?: number }
|
||||
) => Promise<{ data: string; cols: number; rows: number; seq?: number } | null>
|
||||
) => Promise<{
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
} | null>
|
||||
onData: (
|
||||
callback: (data: { id: string; data: string; seq?: number; rawLength?: number }) => void
|
||||
) => () => void
|
||||
|
|
|
|||
|
|
@ -693,8 +693,13 @@ const api = {
|
|||
getMainBufferSnapshot: (
|
||||
id: string,
|
||||
opts?: { scrollbackRows?: number }
|
||||
): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> =>
|
||||
ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }),
|
||||
): Promise<{
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
} | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }),
|
||||
|
||||
/** Check if a PTY's shell has child processes (e.g. a running command).
|
||||
* Returns false for an idle shell prompt. */
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ type MockTransport = {
|
|||
sendInputAccepted?: ReturnType<typeof vi.fn>
|
||||
resize: ReturnType<typeof vi.fn>
|
||||
getPtyId: ReturnType<typeof vi.fn>
|
||||
serializeBuffer?: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const scheduleRuntimeGraphSync = vi.fn()
|
||||
|
|
@ -221,7 +222,8 @@ function createMockTransport(initialPtyId: string | null = null): MockTransport
|
|||
}),
|
||||
sendInput: vi.fn(() => true),
|
||||
resize: vi.fn(() => true),
|
||||
getPtyId: vi.fn(() => ptyId)
|
||||
getPtyId: vi.fn(() => ptyId),
|
||||
serializeBuffer: undefined
|
||||
} as MockTransport
|
||||
const sendInput = transport.sendInput as unknown as (data: string) => boolean
|
||||
transport.sendInputAccepted = vi.fn(async (data: string) => sendInput(data))
|
||||
|
|
@ -3169,6 +3171,110 @@ describe('connectPanePty', () => {
|
|||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('restores skipped hidden remote runtime output from the transport snapshot', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('remote:env-1@@terminal-1')
|
||||
const capturedDataCallback: {
|
||||
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
|
||||
} = { current: null }
|
||||
transport.serializeBuffer = vi.fn().mockResolvedValue({
|
||||
data: 'remote snapshot\r\n',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
seq: 40,
|
||||
source: 'headless'
|
||||
})
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'remote:env-1@@terminal-1'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>
|
||||
|
||||
const pane = createPane(1)
|
||||
const manager = createManager(1)
|
||||
const deps = createDeps({ isVisibleRef: { current: false } })
|
||||
const disposable = connectPanePty(pane as never, manager as never, deps as never)
|
||||
await flushAsyncTicks(6)
|
||||
|
||||
const hidden = 'hidden remote output\r\n'
|
||||
const live = 'visible remote output\r\n'
|
||||
capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length })
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden, expect.any(Function))
|
||||
|
||||
;(deps.isVisibleRef as { current: boolean }).current = true
|
||||
capturedDataCallback.current?.(live, {
|
||||
seq: 40 + live.length,
|
||||
rawLength: live.length
|
||||
})
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(getMainBufferSnapshot).not.toHaveBeenCalled()
|
||||
expect(transport.serializeBuffer).toHaveBeenCalledWith({ scrollbackRows: 5000 })
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('remote snapshot\r\n', expect.any(Function))
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function))
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('retries hidden remote runtime restore after a null transport snapshot', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('remote:env-1@@terminal-1')
|
||||
const capturedDataCallback: {
|
||||
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
|
||||
} = { current: null }
|
||||
transport.serializeBuffer = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({
|
||||
data: 'remote recovered snapshot\r\n',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
seq: 80,
|
||||
source: 'headless'
|
||||
})
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'remote:env-1@@terminal-1'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
const pane = createPane(1)
|
||||
const manager = createManager(1)
|
||||
const deps = createDeps({ isVisibleRef: { current: false } })
|
||||
const disposable = connectPanePty(pane as never, manager as never, deps as never)
|
||||
await flushAsyncTicks(6)
|
||||
|
||||
const hidden = 'hidden remote output\r\n'
|
||||
const firstLive = 'first visible output\r\n'
|
||||
capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length })
|
||||
|
||||
;(deps.isVisibleRef as { current: boolean }).current = true
|
||||
capturedDataCallback.current?.(firstLive, {
|
||||
seq: hidden.length + firstLive.length,
|
||||
rawLength: firstLive.length
|
||||
})
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(transport.serializeBuffer).toHaveBeenCalledTimes(1)
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('Orca skipped hidden terminal output'),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
'remote recovered snapshot\r\n',
|
||||
expect.any(Function)
|
||||
)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(transport.serializeBuffer).toHaveBeenCalledTimes(2)
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
'remote recovered snapshot\r\n',
|
||||
expect.any(Function)
|
||||
)
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('restores hidden backlog overflow from the main terminal snapshot on foreground output', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('pty-id')
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors'
|
||||
import type { PtyConnectResult } from './pty-transport'
|
||||
import type { PtyBufferSnapshot, PtyConnectResult } from './pty-transport'
|
||||
import { createIpcPtyTransport } from './pty-transport'
|
||||
import { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport'
|
||||
import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding'
|
||||
|
|
@ -88,6 +88,8 @@ const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000
|
|||
const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500
|
||||
const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000
|
||||
const HIDDEN_OUTPUT_RESTORE_PENDING_CHARS = 512 * 1024
|
||||
const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS = 50
|
||||
const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX = 3
|
||||
const HIDDEN_STARTUP_RENDERER_QUERY_WINDOW_MS = 10_000
|
||||
const STARTUP_COMMAND_EXTENSION_RE = /\.(?:exe|cmd|bat|ps1)$/i
|
||||
const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256
|
||||
|
|
@ -108,15 +110,29 @@ const HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING =
|
|||
'\x18\x1b[0m\r\n[Orca skipped hidden terminal output because main recovery was unavailable.]\r\n'
|
||||
|
||||
type E2eTerminalPtyDataInjectionApi = {
|
||||
inject: (paneKey: string, data: string) => boolean
|
||||
inject: (paneKey: string, data: string, meta?: PtyDataMeta) => boolean
|
||||
keys: () => string[]
|
||||
}
|
||||
|
||||
type E2eTerminalPtyDataInjectionWindow = Window & {
|
||||
__terminalPtyDataInjection?: E2eTerminalPtyDataInjectionApi
|
||||
__terminalHiddenSnapshotOverride?: E2eTerminalHiddenSnapshotOverrideApi
|
||||
}
|
||||
|
||||
const e2eTerminalPtyDataInjectors = new Map<string, (data: string) => void>()
|
||||
const e2eTerminalPtyDataInjectors = new Map<string, (data: string, meta?: PtyDataMeta) => void>()
|
||||
|
||||
type E2eTerminalHiddenSnapshotOverrideApi = {
|
||||
setPending: (ptyId: string, snapshot: PtyBufferSnapshot) => void
|
||||
resolve: (ptyId: string) => void
|
||||
clear: (ptyId: string) => void
|
||||
}
|
||||
|
||||
type E2eTerminalHiddenSnapshotOverride = {
|
||||
promise: Promise<PtyBufferSnapshot | null>
|
||||
resolve: () => void
|
||||
}
|
||||
|
||||
const e2eTerminalHiddenSnapshotOverrides = new Map<string, E2eTerminalHiddenSnapshotOverride>()
|
||||
|
||||
type E2eTerminalPtyOutputDebugSnapshot = {
|
||||
hiddenRendererSkipCount: number
|
||||
|
|
@ -181,21 +197,39 @@ function exposeE2eTerminalPtyDataInjection(): void {
|
|||
// e2e-only seam lets tests replay the renderer-side data callback exactly.
|
||||
const target = window as E2eTerminalPtyDataInjectionWindow
|
||||
target.__terminalPtyDataInjection ??= {
|
||||
inject: (paneKey, data) => {
|
||||
inject: (paneKey, data, meta) => {
|
||||
const inject = e2eTerminalPtyDataInjectors.get(paneKey)
|
||||
if (!inject) {
|
||||
return false
|
||||
}
|
||||
inject(data)
|
||||
inject(data, meta)
|
||||
return true
|
||||
},
|
||||
keys: () => [...e2eTerminalPtyDataInjectors.keys()]
|
||||
}
|
||||
target.__terminalHiddenSnapshotOverride ??= {
|
||||
setPending: (ptyId, snapshot) => {
|
||||
let resolve = (): void => {}
|
||||
const wait = new Promise<void>((nextResolve) => {
|
||||
resolve = nextResolve
|
||||
})
|
||||
e2eTerminalHiddenSnapshotOverrides.set(ptyId, {
|
||||
promise: wait.then(() => snapshot),
|
||||
resolve
|
||||
})
|
||||
},
|
||||
resolve: (ptyId) => {
|
||||
e2eTerminalHiddenSnapshotOverrides.get(ptyId)?.resolve()
|
||||
},
|
||||
clear: (ptyId) => {
|
||||
e2eTerminalHiddenSnapshotOverrides.delete(ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function registerE2eTerminalPtyDataInjection(
|
||||
paneKey: string,
|
||||
inject: (data: string) => void
|
||||
inject: (data: string, meta?: PtyDataMeta) => void
|
||||
): () => void {
|
||||
if (!e2eConfig.exposeStore) {
|
||||
return () => {}
|
||||
|
|
@ -209,6 +243,23 @@ function registerE2eTerminalPtyDataInjection(
|
|||
}
|
||||
}
|
||||
|
||||
function readE2eHiddenSnapshotOverride(ptyId: string): Promise<PtyBufferSnapshot | null> | null {
|
||||
if (!e2eConfig.exposeStore) {
|
||||
return null
|
||||
}
|
||||
const override = e2eTerminalHiddenSnapshotOverrides.get(ptyId)
|
||||
if (!override) {
|
||||
return null
|
||||
}
|
||||
// Why: visual E2E needs to hold a hidden restore snapshot in flight so a
|
||||
// newer live TUI frame can race it deterministically.
|
||||
return override.promise.finally(() => {
|
||||
if (e2eTerminalHiddenSnapshotOverrides.get(ptyId) === override) {
|
||||
e2eTerminalHiddenSnapshotOverrides.delete(ptyId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function firstStartupCommandToken(command: string): string {
|
||||
const trimmed = command.trim()
|
||||
const quote = trimmed[0]
|
||||
|
|
@ -546,6 +597,7 @@ export function connectPanePty(
|
|||
let connectFrame: number | null = null
|
||||
let unregisterBacklogRecovery: (() => void) | null = null
|
||||
let unregisterDocumentVisibilityRecovery: (() => void) | null = null
|
||||
let cleanupHiddenOutputRestoreDeferredRetry = (): void => {}
|
||||
let unregisterE2ePtyDataInjection = (): void => {}
|
||||
let startupInjectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let agentTaskCompleteNotificationGraceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
|
@ -1791,6 +1843,9 @@ export function connectPanePty(
|
|||
let hiddenOutputRestorePendingChars = 0
|
||||
let hiddenOutputRestorePendingOverflow = false
|
||||
let hiddenOutputRestoreFreshSnapshotNeeded = false
|
||||
let hiddenOutputRestoreRetryDeferred = false
|
||||
let hiddenOutputRestoreDeferredRetryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let hiddenOutputRestoreDeferredRetryAttempts = 0
|
||||
// Why: hidden recovery state belongs to one PTY stream. Reattach/restart
|
||||
// can reuse the pane object for a different session before visibility.
|
||||
let hiddenOutputRestorePtyId: string | null = null
|
||||
|
|
@ -1806,6 +1861,33 @@ export function connectPanePty(
|
|||
return Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId)
|
||||
}
|
||||
|
||||
function canUseHiddenOutputSnapshot(ptyId: string | null): ptyId is string {
|
||||
if (!ptyId) {
|
||||
return false
|
||||
}
|
||||
if (canUseMainBufferSnapshot(ptyId)) {
|
||||
return true
|
||||
}
|
||||
return transport.getPtyId() === ptyId && typeof transport.serializeBuffer === 'function'
|
||||
}
|
||||
|
||||
async function serializeHiddenOutputSnapshot(
|
||||
ptyId: string,
|
||||
opts: { scrollbackRows?: number }
|
||||
): Promise<PtyBufferSnapshot | null> {
|
||||
const e2eSnapshot = readE2eHiddenSnapshotOverride(ptyId)
|
||||
if (e2eSnapshot) {
|
||||
return e2eSnapshot
|
||||
}
|
||||
if (canUseMainBufferSnapshot(ptyId)) {
|
||||
return window.api.pty.getMainBufferSnapshot(ptyId, opts)
|
||||
}
|
||||
if (transport.getPtyId() !== ptyId || typeof transport.serializeBuffer !== 'function') {
|
||||
return null
|
||||
}
|
||||
return transport.serializeBuffer(opts)
|
||||
}
|
||||
|
||||
function isHiddenStartupRendererQueryWindowActive(): boolean {
|
||||
return (
|
||||
paneStartup !== null &&
|
||||
|
|
@ -1936,7 +2018,7 @@ export function connectPanePty(
|
|||
}
|
||||
const parseHiddenStartupOutput =
|
||||
!foreground &&
|
||||
canUseMainBufferSnapshot(transport.getPtyId()) &&
|
||||
canUseHiddenOutputSnapshot(transport.getPtyId()) &&
|
||||
isHiddenStartupRendererQueryWindowActive()
|
||||
const synchronizedOutputStarted =
|
||||
shouldProtectNativeWindowsSynchronizedOutput &&
|
||||
|
|
@ -1992,7 +2074,7 @@ export function connectPanePty(
|
|||
|
||||
function markHiddenOutputRestoreNeeded(): void {
|
||||
const ptyId = transport.getPtyId()
|
||||
if (!canUseMainBufferSnapshot(ptyId)) {
|
||||
if (!canUseHiddenOutputSnapshot(ptyId)) {
|
||||
return
|
||||
}
|
||||
if (hiddenOutputRestorePtyId !== null && hiddenOutputRestorePtyId !== ptyId) {
|
||||
|
|
@ -2009,7 +2091,7 @@ export function connectPanePty(
|
|||
return (
|
||||
!foreground &&
|
||||
!deps.isVisibleRef.current &&
|
||||
canUseMainBufferSnapshot(transport.getPtyId()) &&
|
||||
canUseHiddenOutputSnapshot(transport.getPtyId()) &&
|
||||
!isHiddenStartupRendererQueryWindowActive()
|
||||
)
|
||||
}
|
||||
|
|
@ -2028,7 +2110,7 @@ export function connectPanePty(
|
|||
return
|
||||
}
|
||||
const ptyId = transport.getPtyId()
|
||||
if (!canUseMainBufferSnapshot(ptyId)) {
|
||||
if (!canUseHiddenOutputSnapshot(ptyId)) {
|
||||
return
|
||||
}
|
||||
if (hiddenOutputRestorePtyId !== null && hiddenOutputRestorePtyId !== ptyId) {
|
||||
|
|
@ -2115,6 +2197,44 @@ export function connectPanePty(
|
|||
hiddenOutputRestorePendingChars = 0
|
||||
hiddenOutputRestorePendingOverflow = false
|
||||
hiddenOutputRestoreFreshSnapshotNeeded = false
|
||||
hiddenOutputRestoreRetryDeferred = false
|
||||
clearHiddenOutputRestoreDeferredRetryTimer()
|
||||
hiddenOutputRestoreDeferredRetryAttempts = 0
|
||||
}
|
||||
|
||||
function clearHiddenOutputRestoreDeferredRetryTimer(): void {
|
||||
if (hiddenOutputRestoreDeferredRetryTimer === null) {
|
||||
return
|
||||
}
|
||||
clearTimeout(hiddenOutputRestoreDeferredRetryTimer)
|
||||
hiddenOutputRestoreDeferredRetryTimer = null
|
||||
}
|
||||
cleanupHiddenOutputRestoreDeferredRetry = clearHiddenOutputRestoreDeferredRetryTimer
|
||||
|
||||
function scheduleHiddenOutputRestoreDeferredRetry(): void {
|
||||
if (
|
||||
disposed ||
|
||||
hiddenOutputRestoreDeferredRetryTimer !== null ||
|
||||
!shouldWritePtyOutputForeground(deps.isVisibleRef.current)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (hiddenOutputRestoreDeferredRetryAttempts >= HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX) {
|
||||
clearHiddenOutputRestoreState()
|
||||
writeRestoreUnavailableWarning()
|
||||
return
|
||||
}
|
||||
hiddenOutputRestoreDeferredRetryAttempts += 1
|
||||
// Why: null requested snapshots usually mean remote output was still
|
||||
// mutating. Retry after one quiet tick instead of spinning synchronously.
|
||||
hiddenOutputRestoreDeferredRetryTimer = setTimeout(() => {
|
||||
hiddenOutputRestoreDeferredRetryTimer = null
|
||||
if (disposed || !hiddenOutputRestoreNeeded) {
|
||||
return
|
||||
}
|
||||
hiddenOutputRestoreRetryDeferred = false
|
||||
requestHiddenOutputRestoreIfNeeded()
|
||||
}, HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS)
|
||||
}
|
||||
|
||||
function clearHiddenOutputRestoreState(): void {
|
||||
|
|
@ -2214,13 +2334,15 @@ export function connectPanePty(
|
|||
if (!hiddenOutputRestoreNeeded && hiddenOutputRestorePendingChunks.length === 0) {
|
||||
return false
|
||||
}
|
||||
if (!canUseMainBufferSnapshot(ptyId)) {
|
||||
if (!canUseHiddenOutputSnapshot(ptyId)) {
|
||||
return false
|
||||
}
|
||||
hiddenOutputRestorePtyId = ptyId
|
||||
if (hiddenOutputRestoreInFlight) {
|
||||
return true
|
||||
}
|
||||
clearHiddenOutputRestoreDeferredRetryTimer()
|
||||
hiddenOutputRestoreRetryDeferred = false
|
||||
|
||||
hiddenOutputRestoreInFlight = (async () => {
|
||||
while (!disposed) {
|
||||
|
|
@ -2229,7 +2351,7 @@ export function connectPanePty(
|
|||
clearHiddenOutputRestoreState()
|
||||
return
|
||||
}
|
||||
if (!canUseMainBufferSnapshot(currentPtyId)) {
|
||||
if (!canUseHiddenOutputSnapshot(currentPtyId)) {
|
||||
if (hiddenOutputRestorePtyId === currentPtyId) {
|
||||
clearHiddenOutputRestoreState()
|
||||
}
|
||||
|
|
@ -2244,9 +2366,9 @@ export function connectPanePty(
|
|||
}
|
||||
const restoreGeneration = hiddenOutputRestoreGeneration
|
||||
hiddenOutputRestoreNeeded = false
|
||||
let snapshot: { data: string; cols: number; rows: number; seq?: number } | null = null
|
||||
let snapshot: PtyBufferSnapshot | null = null
|
||||
try {
|
||||
snapshot = await window.api.pty.getMainBufferSnapshot(currentPtyId, {
|
||||
snapshot = await serializeHiddenOutputSnapshot(currentPtyId, {
|
||||
scrollbackRows: HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS
|
||||
})
|
||||
} catch {
|
||||
|
|
@ -2268,10 +2390,13 @@ export function connectPanePty(
|
|||
return
|
||||
}
|
||||
if (!snapshot) {
|
||||
clearHiddenOutputRestoreState()
|
||||
writeRestoreUnavailableWarning()
|
||||
hiddenOutputRestoreNeeded = true
|
||||
hiddenOutputRestoreFreshSnapshotNeeded = false
|
||||
hiddenOutputRestoreRetryDeferred = true
|
||||
scheduleHiddenOutputRestoreDeferredRetry()
|
||||
return
|
||||
}
|
||||
hiddenOutputRestoreDeferredRetryAttempts = 0
|
||||
applyMainBufferSnapshot(snapshot)
|
||||
const needsFreshSnapshot = hiddenOutputRestoreFreshSnapshotNeeded
|
||||
hiddenOutputRestoreFreshSnapshotNeeded = false
|
||||
|
|
@ -2295,6 +2420,7 @@ export function connectPanePty(
|
|||
hiddenOutputRestoreNeeded = true
|
||||
}
|
||||
if (
|
||||
!hiddenOutputRestoreRetryDeferred &&
|
||||
hiddenOutputRestoreNeeded &&
|
||||
shouldWritePtyOutputForeground(deps.isVisibleRef.current)
|
||||
) {
|
||||
|
|
@ -2384,9 +2510,9 @@ export function connectPanePty(
|
|||
}, 50)
|
||||
}
|
||||
}
|
||||
unregisterE2ePtyDataInjection = registerE2eTerminalPtyDataInjection(cacheKey, (data) => {
|
||||
unregisterE2ePtyDataInjection = registerE2eTerminalPtyDataInjection(cacheKey, (data, meta) => {
|
||||
if (!disposed) {
|
||||
dataCallback(data)
|
||||
dataCallback(data, meta)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -2998,6 +3124,7 @@ export function connectPanePty(
|
|||
pendingTerminalBellNotification = false
|
||||
clearTerminalBellNotificationTimer()
|
||||
clearReattachIdleAgentCursorResetTimer()
|
||||
cleanupHiddenOutputRestoreDeferredRetry()
|
||||
unregisterBacklogRecovery?.()
|
||||
unregisterBacklogRecovery = null
|
||||
unregisterDocumentVisibilityRecovery?.()
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@ export type PtyDataMeta = {
|
|||
rawLength?: number
|
||||
}
|
||||
|
||||
export type PtyBufferSnapshot = {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
}
|
||||
|
||||
export const ptyDataHandlers = new Map<string, (data: string, meta?: PtyDataMeta) => void>()
|
||||
/** Sidecar subscriptions that observe PTY data without owning the primary
|
||||
* handler. Used by features that need to react to the live byte stream
|
||||
|
|
@ -323,6 +331,7 @@ export type PtyTransport = {
|
|||
) => boolean
|
||||
isConnected: () => boolean
|
||||
getPtyId: () => string | null
|
||||
serializeBuffer?: (opts?: { scrollbackRows?: number }) => Promise<PtyBufferSnapshot | null>
|
||||
preserve?: () => void
|
||||
/** Unregister PTY handlers without killing the process, so a remounted
|
||||
* pane can reattach to the same running shell. */
|
||||
|
|
|
|||
|
|
@ -1104,7 +1104,7 @@ describe('createRemoteRuntimePtyTransport', () => {
|
|||
|
||||
expect(onReplayData).toHaveBeenCalledWith('hello')
|
||||
expect(onConnect).toHaveBeenCalled()
|
||||
expect(onData).toHaveBeenCalledWith(' world')
|
||||
expect(onData).toHaveBeenCalledWith(' world', expect.objectContaining({ seq: 4 }))
|
||||
})
|
||||
|
||||
it('forwards input and cleanup through runtime RPC', async () => {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export {
|
|||
export type {
|
||||
EagerPtyHandle,
|
||||
PtyTransport,
|
||||
PtyBufferSnapshot,
|
||||
PtyConnectResult,
|
||||
IpcPtyTransportOptions
|
||||
} from './pty-dispatcher'
|
||||
|
|
|
|||
|
|
@ -91,6 +91,13 @@ describe('createRemoteRuntimePtyTransport', () => {
|
|||
)
|
||||
}
|
||||
|
||||
function latestFrameForOpcode(opcode: TerminalStreamOpcode) {
|
||||
return subscriptionSendBinary.mock.calls
|
||||
.map((call) => decodeTerminalStreamFrame(call[0]))
|
||||
.filter((frame) => frame?.opcode === opcode)
|
||||
.at(-1)
|
||||
}
|
||||
|
||||
function emitSnapshotFrame(
|
||||
streamId: number,
|
||||
opcode:
|
||||
|
|
@ -788,7 +795,10 @@ describe('createRemoteRuntimePtyTransport', () => {
|
|||
'before\x1b]9999;{"state":"working","prompt":"ship it","agentType":"codex"}\x07after\x1b]0;. Claude working\x07\x07'
|
||||
)
|
||||
|
||||
expect(onData).toHaveBeenCalledWith('beforeafter\x1b]0;. Claude working\x07\x07')
|
||||
expect(onData).toHaveBeenCalledWith(
|
||||
'beforeafter\x1b]0;. Claude working\x07\x07',
|
||||
expect.objectContaining({ seq: 1 })
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(onAgentStatus).toHaveBeenCalledWith({
|
||||
state: 'working',
|
||||
|
|
@ -821,7 +831,7 @@ describe('createRemoteRuntimePtyTransport', () => {
|
|||
'before\x1b]9999;{"state":"working","prompt":"ship it","agentType":"codex"}\x07after'
|
||||
)
|
||||
|
||||
expect(onData).toHaveBeenCalledWith('beforeafter')
|
||||
expect(onData).toHaveBeenCalledWith('beforeafter', expect.objectContaining({ seq: 1 }))
|
||||
await vi.waitFor(() =>
|
||||
expect(onAgentStatus).toHaveBeenCalledWith({
|
||||
state: 'working',
|
||||
|
|
@ -1164,6 +1174,112 @@ describe('createRemoteRuntimePtyTransport', () => {
|
|||
expect(onConnect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves explicit binary snapshot requests without replaying into xterm', async () => {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const onReplayData = vi.fn()
|
||||
const onData = vi.fn()
|
||||
const onConnect = vi.fn()
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'wt-1'
|
||||
})
|
||||
|
||||
await transport.connect({ url: '', callbacks: { onReplayData, onData, onConnect } })
|
||||
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
|
||||
const { streamId } = latestSubscribePayload()
|
||||
emitSnapshot(streamId, 'initial')
|
||||
expect(onReplayData).toHaveBeenCalledWith('initial')
|
||||
expect(onConnect).toHaveBeenCalled()
|
||||
|
||||
const snapshotPromise = transport.serializeBuffer?.({ scrollbackRows: 5000 })
|
||||
const snapshotRequestFrame = latestFrameForOpcode(TerminalStreamOpcode.SnapshotRequest)
|
||||
const snapshotRequestPayload = snapshotRequestFrame
|
||||
? decodeTerminalStreamJson<{ requestId?: number; scrollbackRows?: number }>(
|
||||
snapshotRequestFrame.payload
|
||||
)
|
||||
: null
|
||||
expect(snapshotRequestFrame?.streamId).toBe(streamId)
|
||||
expect(snapshotRequestPayload).toMatchObject({ requestId: 1, scrollbackRows: 5000 })
|
||||
|
||||
emitSnapshotFrame(
|
||||
streamId,
|
||||
TerminalStreamOpcode.SnapshotStart,
|
||||
encodeTerminalStreamJson({
|
||||
kind: 'scrollback',
|
||||
requestId: snapshotRequestPayload?.requestId,
|
||||
cols: 132,
|
||||
rows: 43,
|
||||
seq: 17,
|
||||
source: 'headless'
|
||||
})
|
||||
)
|
||||
emitSnapshotFrame(
|
||||
streamId,
|
||||
TerminalStreamOpcode.SnapshotChunk,
|
||||
encodeTerminalStreamText('requested snapshot')
|
||||
)
|
||||
emitSnapshotFrame(streamId, TerminalStreamOpcode.SnapshotEnd, new Uint8Array())
|
||||
|
||||
await expect(snapshotPromise).resolves.toEqual({
|
||||
data: 'requested snapshot',
|
||||
cols: 132,
|
||||
rows: 43,
|
||||
seq: 17,
|
||||
source: 'headless'
|
||||
})
|
||||
expect(onReplayData).toHaveBeenCalledTimes(1)
|
||||
expect(onData).not.toHaveBeenCalledWith('requested snapshot', expect.anything())
|
||||
})
|
||||
|
||||
it('keeps initial replay separate from in-flight explicit binary snapshot requests', async () => {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const onReplayData = vi.fn()
|
||||
const onConnect = vi.fn()
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'wt-1'
|
||||
})
|
||||
|
||||
await transport.connect({ url: '', callbacks: { onReplayData, onConnect } })
|
||||
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
|
||||
const { streamId } = latestSubscribePayload()
|
||||
|
||||
const snapshotPromise = transport.serializeBuffer?.({ scrollbackRows: 5000 })
|
||||
const snapshotRequestFrame = latestFrameForOpcode(TerminalStreamOpcode.SnapshotRequest)
|
||||
const snapshotRequestPayload = snapshotRequestFrame
|
||||
? decodeTerminalStreamJson<{ requestId?: number }>(snapshotRequestFrame.payload)
|
||||
: null
|
||||
expect(snapshotRequestPayload?.requestId).toBe(1)
|
||||
|
||||
emitSnapshot(streamId, 'initial replay')
|
||||
expect(onReplayData).toHaveBeenCalledWith('initial replay')
|
||||
expect(onConnect).toHaveBeenCalled()
|
||||
|
||||
emitSnapshotFrame(
|
||||
streamId,
|
||||
TerminalStreamOpcode.SnapshotStart,
|
||||
encodeTerminalStreamJson({
|
||||
kind: 'scrollback',
|
||||
requestId: snapshotRequestPayload?.requestId,
|
||||
cols: 100,
|
||||
rows: 20
|
||||
})
|
||||
)
|
||||
emitSnapshotFrame(
|
||||
streamId,
|
||||
TerminalStreamOpcode.SnapshotChunk,
|
||||
encodeTerminalStreamText('requested replay')
|
||||
)
|
||||
emitSnapshotFrame(streamId, TerminalStreamOpcode.SnapshotEnd, new Uint8Array())
|
||||
|
||||
await expect(snapshotPromise).resolves.toEqual({
|
||||
data: 'requested replay',
|
||||
cols: 100,
|
||||
rows: 20,
|
||||
seq: undefined,
|
||||
source: undefined
|
||||
})
|
||||
expect(onReplayData).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('bounds oversized binary snapshots without closing the live stream', async () => {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const onReplayData = vi.fn()
|
||||
|
|
@ -1194,6 +1310,6 @@ describe('createRemoteRuntimePtyTransport', () => {
|
|||
'Remote terminal snapshot exceeded the 2 MiB replay limit; live output will continue.'
|
||||
)
|
||||
expect(onConnect).toHaveBeenCalled()
|
||||
expect(onData).toHaveBeenCalledWith('live-after-overflow')
|
||||
expect(onData).toHaveBeenCalledWith('live-after-overflow', expect.objectContaining({ seq: 1 }))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -310,7 +310,7 @@ export function createRemoteRuntimePtyTransport(
|
|||
client: { id: clientId, type: 'desktop' },
|
||||
viewport: desiredViewport ?? undefined,
|
||||
callbacks: {
|
||||
onData: (data) => outputProcessor.processData(data, storedCallbacks),
|
||||
onData: (data, meta) => outputProcessor.processData(data, storedCallbacks, undefined, meta),
|
||||
onSnapshot: (data) => {
|
||||
if (data) {
|
||||
outputProcessor.processData(data, storedCallbacks, {
|
||||
|
|
@ -506,6 +506,13 @@ export function createRemoteRuntimePtyTransport(
|
|||
return remotePtyId
|
||||
},
|
||||
|
||||
async serializeBuffer(opts) {
|
||||
if (!connected || !multiplexedStream) {
|
||||
return null
|
||||
}
|
||||
return multiplexedStream.serializeBuffer(opts)
|
||||
},
|
||||
|
||||
destroy() {
|
||||
destroyed = true
|
||||
this.disconnect()
|
||||
|
|
|
|||
|
|
@ -624,6 +624,26 @@ describe('pane terminal output scheduler', () => {
|
|||
expect(terminals[0].write).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps draining background chunks without per-write parse callback backpressure', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
const terminal = createTerminal()
|
||||
const chunk = 'x'.repeat(16 * 1024)
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
writeTerminalOutput(terminal, chunk, { foreground: false })
|
||||
}
|
||||
|
||||
vi.advanceTimersByTime(50)
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(terminal.write).toHaveBeenCalledTimes(4)
|
||||
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(terminal.write).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
|
||||
it('promotes large background backlogs to high-priority drains', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
|
|||
import {
|
||||
TerminalStreamOpcode,
|
||||
decodeTerminalStreamFrame,
|
||||
decodeTerminalStreamJson,
|
||||
decodeTerminalStreamText,
|
||||
encodeTerminalStreamFrame,
|
||||
encodeTerminalStreamJson,
|
||||
|
|
@ -35,7 +36,7 @@ type TerminalMultiplexEvent =
|
|||
| { type: string; streamId?: number; [key: string]: unknown }
|
||||
|
||||
export type RemoteRuntimeMultiplexedTerminalCallbacks = {
|
||||
onData: (data: string) => void
|
||||
onData: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
onSnapshot: (data: string) => void
|
||||
onSubscribed?: () => void
|
||||
onEnd?: () => void
|
||||
|
|
@ -55,6 +56,13 @@ export type RemoteRuntimeMultiplexedTerminal = {
|
|||
streamId: number
|
||||
sendInput: (text: string) => boolean
|
||||
resize: (cols: number, rows: number) => boolean
|
||||
serializeBuffer: (opts?: { scrollbackRows?: number }) => Promise<{
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
} | null>
|
||||
close: () => void
|
||||
}
|
||||
|
||||
|
|
@ -65,10 +73,39 @@ type RemoteRuntimeMultiplexedTerminalState = {
|
|||
snapshotChunks: Uint8Array<ArrayBufferLike>[]
|
||||
snapshotBytes: number
|
||||
snapshotOverflowed: boolean
|
||||
snapshotTarget: 'initial' | 'request'
|
||||
snapshotInfo: RemoteRuntimeSnapshotInfo | null
|
||||
initialSnapshotReceived: boolean
|
||||
pendingSnapshotRequest: RemoteRuntimeSnapshotRequest | null
|
||||
}
|
||||
|
||||
type RemoteRuntimeSnapshotInfo = {
|
||||
cols?: number
|
||||
rows?: number
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
requestId?: number
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
type RemoteRuntimeSnapshotRequest = {
|
||||
requestId: number
|
||||
resolve: (
|
||||
snapshot: {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
} | null
|
||||
) => void
|
||||
reject: (error: Error) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
const CONTROL_STREAM_ID = 0
|
||||
const MAX_REMOTE_TERMINAL_SNAPSHOT_BYTES = 2 * 1024 * 1024
|
||||
const REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS = 10_000
|
||||
const REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE =
|
||||
'Remote terminal snapshot exceeded the 2 MiB replay limit; live output will continue.'
|
||||
|
||||
|
|
@ -80,6 +117,7 @@ class RemoteRuntimeTerminalMultiplexer {
|
|||
private readyRejecter: ((error: Error) => void) | null = null
|
||||
private ready = false
|
||||
private nextStreamId = 1
|
||||
private nextSnapshotRequestId = 1
|
||||
|
||||
constructor(
|
||||
private readonly environmentId: string,
|
||||
|
|
@ -102,7 +140,11 @@ class RemoteRuntimeTerminalMultiplexer {
|
|||
callbacks: args.callbacks,
|
||||
snapshotChunks: [],
|
||||
snapshotBytes: 0,
|
||||
snapshotOverflowed: false
|
||||
snapshotOverflowed: false,
|
||||
snapshotTarget: 'initial',
|
||||
snapshotInfo: null,
|
||||
initialSnapshotReceived: false,
|
||||
pendingSnapshotRequest: null
|
||||
}
|
||||
this.streams.set(streamId, state)
|
||||
|
||||
|
|
@ -116,9 +158,11 @@ class RemoteRuntimeTerminalMultiplexer {
|
|||
TerminalStreamOpcode.Resize,
|
||||
encodeTerminalStreamJson({ cols, rows })
|
||||
),
|
||||
serializeBuffer: (opts) => this.requestSnapshot(state, opts),
|
||||
close: () => {
|
||||
if (this.streams.get(streamId) === state) {
|
||||
this.sendFrame(streamId, TerminalStreamOpcode.Unsubscribe)
|
||||
rejectPendingSnapshotRequest(state, 'Remote terminal stream closed.')
|
||||
this.streams.delete(streamId)
|
||||
this.closeIfIdle()
|
||||
}
|
||||
|
|
@ -240,11 +284,16 @@ class RemoteRuntimeTerminalMultiplexer {
|
|||
}
|
||||
if (event.type === 'end') {
|
||||
clearSnapshot(stream)
|
||||
rejectPendingSnapshotRequest(stream, 'Remote terminal stream ended.')
|
||||
this.streams.delete(event.streamId)
|
||||
stream.callbacks.onEnd?.()
|
||||
this.closeIfIdle()
|
||||
} else if (event.type === 'error') {
|
||||
clearSnapshot(stream)
|
||||
rejectPendingSnapshotRequest(
|
||||
stream,
|
||||
typeof event.message === 'string' ? event.message : 'Remote terminal stream failed.'
|
||||
)
|
||||
stream.callbacks.onError?.(
|
||||
typeof event.message === 'string' ? event.message : 'Remote terminal stream failed.'
|
||||
)
|
||||
|
|
@ -279,11 +328,22 @@ class RemoteRuntimeTerminalMultiplexer {
|
|||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Output) {
|
||||
stream.callbacks.onData(decodeTerminalStreamText(frame.payload))
|
||||
const data = decodeTerminalStreamText(frame.payload)
|
||||
stream.callbacks.onData(data, {
|
||||
seq: typeof frame.seq === 'number' && frame.seq > 0 ? frame.seq : undefined,
|
||||
rawLength: data.length
|
||||
})
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.SnapshotStart) {
|
||||
clearSnapshot(stream)
|
||||
stream.snapshotInfo = decodeSnapshotInfo(frame.payload)
|
||||
const requestId = stream.snapshotInfo?.requestId
|
||||
stream.snapshotTarget =
|
||||
typeof requestId === 'number' ||
|
||||
(stream.initialSnapshotReceived && stream.pendingSnapshotRequest)
|
||||
? 'request'
|
||||
: 'initial'
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.SnapshotChunk) {
|
||||
|
|
@ -292,28 +352,112 @@ class RemoteRuntimeTerminalMultiplexer {
|
|||
}
|
||||
stream.snapshotBytes += frame.payload.byteLength
|
||||
if (stream.snapshotBytes > MAX_REMOTE_TERMINAL_SNAPSHOT_BYTES) {
|
||||
clearSnapshot(stream)
|
||||
stream.snapshotOverflowed = true
|
||||
stream.callbacks.onError?.(REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE)
|
||||
if (stream.snapshotTarget === 'initial') {
|
||||
stream.callbacks.onError?.(REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE)
|
||||
}
|
||||
return
|
||||
}
|
||||
stream.snapshotChunks.push(frame.payload)
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.SnapshotEnd) {
|
||||
if (!stream.snapshotOverflowed) {
|
||||
stream.callbacks.onSnapshot(decodeTerminalStreamText(concatBytes(stream.snapshotChunks)))
|
||||
const data = stream.snapshotOverflowed
|
||||
? null
|
||||
: decodeTerminalStreamText(concatBytes(stream.snapshotChunks))
|
||||
const target = stream.snapshotTarget
|
||||
const info = stream.snapshotInfo
|
||||
const pendingRequest = stream.pendingSnapshotRequest
|
||||
const matchesPendingRequest =
|
||||
target === 'request' &&
|
||||
pendingRequest &&
|
||||
(typeof info?.requestId === 'number'
|
||||
? info.requestId === pendingRequest.requestId
|
||||
: stream.initialSnapshotReceived)
|
||||
if (!stream.snapshotOverflowed && info?.truncated !== true) {
|
||||
if (matchesPendingRequest) {
|
||||
pendingRequest.resolve({
|
||||
data: data ?? '',
|
||||
cols: info?.cols ?? 80,
|
||||
rows: info?.rows ?? 24,
|
||||
seq: info?.seq,
|
||||
source: info?.source
|
||||
})
|
||||
clearPendingSnapshotRequest(stream)
|
||||
} else if (target === 'initial') {
|
||||
stream.callbacks.onSnapshot(data ?? '')
|
||||
}
|
||||
} else if (matchesPendingRequest) {
|
||||
pendingRequest.resolve(null)
|
||||
clearPendingSnapshotRequest(stream)
|
||||
}
|
||||
clearSnapshot(stream)
|
||||
stream.callbacks.onSubscribed?.()
|
||||
if (target === 'initial') {
|
||||
stream.initialSnapshotReceived = true
|
||||
stream.callbacks.onSubscribed?.()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Error) {
|
||||
clearSnapshot(stream)
|
||||
const pendingSnapshotRequest = stream.pendingSnapshotRequest
|
||||
if (pendingSnapshotRequest) {
|
||||
clearPendingSnapshotRequest(stream)
|
||||
pendingSnapshotRequest.reject(new Error(decodeTerminalStreamText(frame.payload)))
|
||||
return
|
||||
}
|
||||
stream.callbacks.onError?.(decodeTerminalStreamText(frame.payload))
|
||||
}
|
||||
}
|
||||
|
||||
private requestSnapshot(
|
||||
stream: RemoteRuntimeMultiplexedTerminalState,
|
||||
opts?: { scrollbackRows?: number }
|
||||
): Promise<{
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
} | null> {
|
||||
if (this.streams.get(stream.streamId) !== stream || !this.ready || !this.subscription) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (stream.pendingSnapshotRequest) {
|
||||
return Promise.reject(new Error('Remote terminal snapshot already in flight.'))
|
||||
}
|
||||
const requestId = this.allocateSnapshotRequestId()
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (stream.pendingSnapshotRequest?.timer === timer) {
|
||||
stream.pendingSnapshotRequest = null
|
||||
reject(new Error('Remote terminal snapshot timed out.'))
|
||||
}
|
||||
}, REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS)
|
||||
if (typeof timer.unref === 'function') {
|
||||
timer.unref()
|
||||
}
|
||||
stream.pendingSnapshotRequest = { requestId, resolve, reject, timer }
|
||||
if (
|
||||
!this.sendFrame(
|
||||
stream.streamId,
|
||||
TerminalStreamOpcode.SnapshotRequest,
|
||||
encodeTerminalStreamJson({ requestId, scrollbackRows: opts?.scrollbackRows })
|
||||
)
|
||||
) {
|
||||
clearPendingSnapshotRequest(stream)
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private allocateSnapshotRequestId(): number {
|
||||
const id = this.nextSnapshotRequestId
|
||||
this.nextSnapshotRequestId =
|
||||
this.nextSnapshotRequestId >= 0x7fffffff ? 1 : this.nextSnapshotRequestId + 1
|
||||
return id
|
||||
}
|
||||
|
||||
private sendFrame(
|
||||
streamId: number,
|
||||
opcode: TerminalStreamOpcode,
|
||||
|
|
@ -357,6 +501,7 @@ class RemoteRuntimeTerminalMultiplexer {
|
|||
this.streams.clear()
|
||||
for (const stream of streams) {
|
||||
clearSnapshot(stream)
|
||||
rejectPendingSnapshotRequest(stream, message ?? 'Remote runtime connection closed.')
|
||||
const canHandleClose = Boolean(stream.callbacks.onTransportClose)
|
||||
stream.callbacks.onTransportClose?.()
|
||||
if (message && !canHandleClose) {
|
||||
|
|
@ -429,6 +574,52 @@ function clearSnapshot(stream: RemoteRuntimeMultiplexedTerminalState): void {
|
|||
stream.snapshotChunks = []
|
||||
stream.snapshotBytes = 0
|
||||
stream.snapshotOverflowed = false
|
||||
stream.snapshotTarget = 'initial'
|
||||
stream.snapshotInfo = null
|
||||
}
|
||||
|
||||
function clearPendingSnapshotRequest(stream: RemoteRuntimeMultiplexedTerminalState): void {
|
||||
const request = stream.pendingSnapshotRequest
|
||||
stream.pendingSnapshotRequest = null
|
||||
if (request) {
|
||||
clearTimeout(request.timer)
|
||||
}
|
||||
}
|
||||
|
||||
function rejectPendingSnapshotRequest(
|
||||
stream: RemoteRuntimeMultiplexedTerminalState,
|
||||
message: string
|
||||
): void {
|
||||
const request = stream.pendingSnapshotRequest
|
||||
if (!request) {
|
||||
return
|
||||
}
|
||||
clearPendingSnapshotRequest(stream)
|
||||
request.reject(new Error(message))
|
||||
}
|
||||
|
||||
function decodeSnapshotInfo(
|
||||
payload: Uint8Array<ArrayBufferLike>
|
||||
): RemoteRuntimeSnapshotInfo | null {
|
||||
const raw = decodeTerminalStreamJson<{
|
||||
cols?: unknown
|
||||
rows?: unknown
|
||||
seq?: unknown
|
||||
source?: unknown
|
||||
requestId?: unknown
|
||||
truncated?: unknown
|
||||
}>(payload)
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
cols: typeof raw.cols === 'number' ? raw.cols : undefined,
|
||||
rows: typeof raw.rows === 'number' ? raw.rows : undefined,
|
||||
seq: typeof raw.seq === 'number' ? raw.seq : undefined,
|
||||
source: raw.source === 'headless' || raw.source === 'renderer' ? raw.source : undefined,
|
||||
requestId: typeof raw.requestId === 'number' ? raw.requestId : undefined,
|
||||
truncated: raw.truncated === true
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminalDriverState(
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export async function subscribeToRuntimeTerminalData(
|
|||
terminal,
|
||||
client: { id: clientId, type: 'desktop' },
|
||||
callbacks: {
|
||||
onData: watcher,
|
||||
onData: (data) => watcher(data),
|
||||
onSnapshot: watcher
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
export const RUNTIME_PROTOCOL_VERSION = 3
|
||||
export const MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION = 2
|
||||
export const MIN_COMPATIBLE_RUNTIME_SERVER_VERSION = 2
|
||||
export const MIN_COMPATIBLE_RUNTIME_SERVER_VERSION = 3
|
||||
|
||||
export const RUNTIME_CAPABILITIES = [
|
||||
'runtime.status.compat.v1',
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ describe('terminal-stream-protocol', () => {
|
|||
expect(resize && decodeTerminalStreamJson(resize.payload)).toEqual({ cols: 120, rows: 40 })
|
||||
})
|
||||
|
||||
it('round-trips multiplex subscribe and unsubscribe frames', () => {
|
||||
it('round-trips multiplex subscribe, snapshot request, and unsubscribe frames', () => {
|
||||
const subscribe = decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
|
|
@ -89,12 +89,22 @@ describe('terminal-stream-protocol', () => {
|
|||
payload: new Uint8Array()
|
||||
})
|
||||
)
|
||||
const snapshotRequest = decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.SnapshotRequest,
|
||||
streamId: 12,
|
||||
seq: 3,
|
||||
payload: new Uint8Array()
|
||||
})
|
||||
)
|
||||
|
||||
expect(subscribe?.opcode).toBe(TerminalStreamOpcode.Subscribe)
|
||||
expect(subscribe && decodeTerminalStreamJson(subscribe.payload)).toMatchObject({
|
||||
streamId: 12,
|
||||
terminal: 'terminal-1'
|
||||
})
|
||||
expect(snapshotRequest?.opcode).toBe(TerminalStreamOpcode.SnapshotRequest)
|
||||
expect(snapshotRequest?.streamId).toBe(12)
|
||||
expect(unsubscribe?.opcode).toBe(TerminalStreamOpcode.Unsubscribe)
|
||||
expect(unsubscribe?.streamId).toBe(12)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ export enum TerminalStreamOpcode {
|
|||
Input = 7,
|
||||
Resize = 8,
|
||||
Subscribe = 9,
|
||||
Unsubscribe = 10
|
||||
Unsubscribe = 10,
|
||||
SnapshotRequest = 11
|
||||
}
|
||||
|
||||
export type TerminalStreamFrame = {
|
||||
|
|
@ -90,6 +91,7 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
|
|||
value === TerminalStreamOpcode.Input ||
|
||||
value === TerminalStreamOpcode.Resize ||
|
||||
value === TerminalStreamOpcode.Subscribe ||
|
||||
value === TerminalStreamOpcode.Unsubscribe
|
||||
value === TerminalStreamOpcode.Unsubscribe ||
|
||||
value === TerminalStreamOpcode.SnapshotRequest
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,305 @@
|
|||
import type { Page, TestInfo } from '@stablyai/playwright-test'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import {
|
||||
ensureTerminalVisible,
|
||||
getActiveWorktreeId,
|
||||
getAllWorktreeIds,
|
||||
switchToWorktree,
|
||||
waitForActiveWorktree,
|
||||
waitForSessionReady
|
||||
} from './helpers/store'
|
||||
import {
|
||||
getTerminalContent,
|
||||
sendToTerminal,
|
||||
waitForActiveTerminalManager,
|
||||
waitForPaneIdentitySnapshot
|
||||
} from './helpers/terminal'
|
||||
|
||||
type HiddenTuiWindow = Window & {
|
||||
__terminalPtyDataInjection?: {
|
||||
inject: (paneKey: string, data: string, meta?: { seq?: number; rawLength?: number }) => boolean
|
||||
}
|
||||
__terminalPtyOutputDebug?: {
|
||||
reset: () => void
|
||||
snapshot: () => {
|
||||
hiddenRendererSkipCount: number
|
||||
hiddenRendererSkippedChars: number
|
||||
hiddenRendererMode2031ReplyCount: number
|
||||
}
|
||||
}
|
||||
__terminalHiddenSnapshotOverride?: {
|
||||
setPending: (
|
||||
ptyId: string,
|
||||
snapshot: { data: string; cols: number; rows: number; seq: number; source: 'headless' }
|
||||
) => void
|
||||
resolve: (ptyId: string) => void
|
||||
clear: (ptyId: string) => void
|
||||
}
|
||||
}
|
||||
|
||||
type HiddenTuiDebugSnapshot = {
|
||||
hiddenRendererSkipCount: number
|
||||
hiddenRendererSkippedChars: number
|
||||
hiddenRendererMode2031ReplyCount: number
|
||||
}
|
||||
|
||||
function tuiFrame(runId: string, frame: number): string {
|
||||
const rows = [
|
||||
`OpenCode visual restore ${runId}`,
|
||||
`Frame ${String(frame).padStart(3, '0')}`,
|
||||
`Status ${frame % 2 === 0 ? 'thinking' : 'streaming'}`,
|
||||
`Input echo ${'#'.repeat((frame % 18) + 1)}`,
|
||||
`Diff +${frame * 3} -${frame}`,
|
||||
`VISUAL_RESTORE_FINAL_${runId}_${frame}`
|
||||
]
|
||||
return [
|
||||
'\x1b[?2026h',
|
||||
'\x1b[?1049h',
|
||||
'\x1b[2J\x1b[H',
|
||||
rows.map((row) => `\x1b[2;36m${row}\x1b[0m`).join('\r\n'),
|
||||
'\x1b[?2026l'
|
||||
].join('')
|
||||
}
|
||||
|
||||
async function resetHiddenDebug(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
;(window as HiddenTuiWindow).__terminalPtyOutputDebug?.reset()
|
||||
})
|
||||
}
|
||||
|
||||
function writeHiddenFrameScript(scriptPath: string, runId: string): void {
|
||||
const frames = Array.from({ length: 25 }, (_, frame) => tuiFrame(runId, frame))
|
||||
writeFileSync(scriptPath, `process.stdout.write(${JSON.stringify(frames.join(''))})\n`)
|
||||
}
|
||||
|
||||
async function writeHiddenFrames(page: Page, ptyId: string, scriptPath: string): Promise<void> {
|
||||
await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
|
||||
}
|
||||
|
||||
async function readHiddenDebug(page: Page): Promise<HiddenTuiDebugSnapshot | null> {
|
||||
return page.evaluate(() => {
|
||||
return (window as HiddenTuiWindow).__terminalPtyOutputDebug?.snapshot() ?? null
|
||||
})
|
||||
}
|
||||
|
||||
async function injectPaneData(
|
||||
page: Page,
|
||||
paneKey: string,
|
||||
data: string,
|
||||
meta?: { seq?: number; rawLength?: number }
|
||||
): Promise<void> {
|
||||
const injected = await page.evaluate(
|
||||
({ paneKey, data, meta }) => {
|
||||
return (window as HiddenTuiWindow).__terminalPtyDataInjection?.inject(paneKey, data, meta)
|
||||
},
|
||||
{ paneKey, data, meta }
|
||||
)
|
||||
if (!injected) {
|
||||
throw new Error(`No terminal PTY data injector registered for ${paneKey}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function installDelayedMainSnapshot(
|
||||
page: Page,
|
||||
ptyId: string,
|
||||
snapshot: { data: string; cols: number; rows: number; seq: number; source: 'headless' }
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ ptyId, snapshot }) => {
|
||||
;(window as HiddenTuiWindow).__terminalHiddenSnapshotOverride?.setPending(ptyId, snapshot)
|
||||
},
|
||||
{ ptyId, snapshot }
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveDelayedMainSnapshot(page: Page, ptyId: string): Promise<void> {
|
||||
await page.evaluate((ptyId) => {
|
||||
;(window as HiddenTuiWindow).__terminalHiddenSnapshotOverride?.resolve(ptyId)
|
||||
}, ptyId)
|
||||
}
|
||||
|
||||
async function clearDelayedMainSnapshot(page: Page, ptyId: string): Promise<void> {
|
||||
await page.evaluate((ptyId) => {
|
||||
;(window as HiddenTuiWindow).__terminalHiddenSnapshotOverride?.clear(ptyId)
|
||||
}, ptyId)
|
||||
}
|
||||
|
||||
async function readMainSnapshotSource(
|
||||
page: Page,
|
||||
ptyId: string
|
||||
): Promise<'headless' | 'renderer' | null> {
|
||||
return page.evaluate(async (ptyId) => {
|
||||
const snapshot = await window.api.pty.getMainBufferSnapshot(ptyId, {
|
||||
scrollbackRows: 200
|
||||
})
|
||||
return snapshot?.source ?? null
|
||||
}, ptyId)
|
||||
}
|
||||
|
||||
test.describe('Hidden terminal TUI visual restore', () => {
|
||||
test('restores skipped hidden full-screen TUI output without visible corruption', async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo: TestInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
const firstWorktreeId = await waitForActiveWorktree(orcaPage)
|
||||
const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find(
|
||||
(id) => id !== firstWorktreeId
|
||||
)
|
||||
test.skip(!secondWorktreeId, 'hidden TUI restore needs the seeded secondary worktree')
|
||||
if (!secondWorktreeId) {
|
||||
return
|
||||
}
|
||||
|
||||
await switchToWorktree(orcaPage, secondWorktreeId)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const hiddenSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1)
|
||||
const hiddenPane = hiddenSnapshot.panes[0]
|
||||
if (!hiddenPane?.ptyId) {
|
||||
throw new Error('hidden visual restore pane did not bind a PTY')
|
||||
}
|
||||
await switchToWorktree(orcaPage, firstWorktreeId)
|
||||
await expect
|
||||
.poll(() => getActiveWorktreeId(orcaPage), {
|
||||
timeout: 10_000,
|
||||
message: 'first worktree did not become active before hidden TUI injection'
|
||||
})
|
||||
.toBe(firstWorktreeId)
|
||||
|
||||
const runId = randomUUID()
|
||||
const finalMarker = `VISUAL_RESTORE_FINAL_${runId}_24`
|
||||
const scriptPath = path.join(testRepoPath, `.orca-hidden-tui-visual-${runId}.mjs`)
|
||||
writeHiddenFrameScript(scriptPath, runId)
|
||||
await resetHiddenDebug(orcaPage)
|
||||
await writeHiddenFrames(orcaPage, hiddenPane.ptyId, scriptPath)
|
||||
|
||||
await expect
|
||||
.poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, {
|
||||
timeout: 10_000,
|
||||
message: 'hidden TUI output did not exercise the skipped-renderer path'
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
await expect
|
||||
.poll(() => readMainSnapshotSource(orcaPage, hiddenPane.ptyId!), {
|
||||
timeout: 10_000,
|
||||
message: 'hidden TUI restore did not use the runtime headless snapshot'
|
||||
})
|
||||
.toBe('headless')
|
||||
|
||||
await switchToWorktree(orcaPage, secondWorktreeId)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage, 12_000), {
|
||||
timeout: 10_000,
|
||||
message: 'hidden TUI final frame did not restore when the workspace became visible'
|
||||
})
|
||||
.toContain(finalMarker)
|
||||
|
||||
const content = await getTerminalContent(orcaPage, 12_000)
|
||||
expect(content).toContain(`Frame 024`)
|
||||
expect(content).not.toContain('Orca skipped hidden terminal output')
|
||||
|
||||
const screenshotPath = testInfo.outputPath('hidden-tui-restore-final.png')
|
||||
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
|
||||
await testInfo.attach('hidden-tui-restore-final.png', {
|
||||
path: screenshotPath,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
rmSync(scriptPath, { force: true })
|
||||
})
|
||||
|
||||
test('keeps newer live TUI output visually correct while hidden restore is in flight', async ({
|
||||
orcaPage
|
||||
}, testInfo: TestInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
const firstWorktreeId = await waitForActiveWorktree(orcaPage)
|
||||
const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find(
|
||||
(id) => id !== firstWorktreeId
|
||||
)
|
||||
test.skip(!secondWorktreeId, 'hidden TUI restore needs the seeded secondary worktree')
|
||||
if (!secondWorktreeId) {
|
||||
return
|
||||
}
|
||||
|
||||
await switchToWorktree(orcaPage, secondWorktreeId)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const hiddenSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1)
|
||||
const hiddenPane = hiddenSnapshot.panes[0]
|
||||
if (!hiddenPane?.ptyId) {
|
||||
throw new Error('hidden visual restore pane did not bind a PTY')
|
||||
}
|
||||
const paneKey = `${hiddenSnapshot.tabId}:${hiddenPane.leafId}`
|
||||
|
||||
await switchToWorktree(orcaPage, firstWorktreeId)
|
||||
await expect
|
||||
.poll(() => getActiveWorktreeId(orcaPage), {
|
||||
timeout: 10_000,
|
||||
message: 'first worktree did not become active before hidden TUI injection'
|
||||
})
|
||||
.toBe(firstWorktreeId)
|
||||
|
||||
const runId = randomUUID()
|
||||
const hiddenFrame = tuiFrame(runId, 40)
|
||||
const liveFrame = tuiFrame(runId, 41)
|
||||
const finalMarker = `VISUAL_RESTORE_FINAL_${runId}_41`
|
||||
await resetHiddenDebug(orcaPage)
|
||||
await injectPaneData(orcaPage, paneKey, hiddenFrame, {
|
||||
seq: hiddenFrame.length,
|
||||
rawLength: hiddenFrame.length
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, {
|
||||
timeout: 10_000,
|
||||
message: 'hidden injected TUI output did not skip renderer parsing'
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await installDelayedMainSnapshot(orcaPage, hiddenPane.ptyId, {
|
||||
data: hiddenFrame,
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
seq: hiddenFrame.length,
|
||||
source: 'headless'
|
||||
})
|
||||
|
||||
try {
|
||||
await switchToWorktree(orcaPage, secondWorktreeId)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
await injectPaneData(orcaPage, paneKey, liveFrame, {
|
||||
seq: hiddenFrame.length + liveFrame.length,
|
||||
rawLength: liveFrame.length
|
||||
})
|
||||
await resolveDelayedMainSnapshot(orcaPage, hiddenPane.ptyId)
|
||||
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage, 12_000), {
|
||||
timeout: 10_000,
|
||||
message: 'newer live TUI frame did not render after delayed hidden snapshot'
|
||||
})
|
||||
.toContain(finalMarker)
|
||||
|
||||
const content = await getTerminalContent(orcaPage, 12_000)
|
||||
expect(content).toContain('Frame 041')
|
||||
expect(content).not.toContain('Frame 040')
|
||||
expect(content).not.toContain('Orca skipped hidden terminal output')
|
||||
|
||||
const screenshotPath = testInfo.outputPath('hidden-tui-delayed-restore-final.png')
|
||||
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
|
||||
await testInfo.attach('hidden-tui-delayed-restore-final.png', {
|
||||
path: screenshotPath,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
} finally {
|
||||
await clearDelayedMainSnapshot(orcaPage, hiddenPane.ptyId)
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue