fix(terminal): remote query-reply corruption (#7329) + snapshot grid-width repaint (#7279) (#7736)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-07-07 16:42:43 -07:00
committed by GitHub
co-authored by Orca
parent c256a5a417
commit 79d0de9c33
24 changed files with 1412 additions and 23 deletions
+10
View File
@@ -5707,6 +5707,7 @@ export class OrcaRuntimeService {
source?: 'headless' | 'renderer'
oscLinks?: TerminalOscLinkRange[]
alternateScreen?: boolean
pendingEscapeTailAnsi?: string
} | null> {
return this.serializeTerminalBufferFromAvailableState(ptyId, opts)
}
@@ -5741,6 +5742,7 @@ export class OrcaRuntimeService {
source?: 'headless' | 'renderer'
oscLinks?: TerminalOscLinkRange[]
alternateScreen?: boolean
pendingEscapeTailAnsi?: string
} | null> {
const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, {
...opts,
@@ -6021,6 +6023,7 @@ export class OrcaRuntimeService {
source?: 'headless' | 'renderer'
oscLinks?: TerminalOscLinkRange[]
alternateScreen?: boolean
pendingEscapeTailAnsi?: string
} | null> {
const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts)
if (headlessSnapshot) {
@@ -6138,6 +6141,10 @@ export class OrcaRuntimeService {
source?: 'headless'
oscLinks?: TerminalOscLinkRange[]
alternateScreen?: boolean
// Why: dangling mid-escape tail the restorer must write LAST, after any
// reset, so the next live chunk completes it instead of rendering it
// literally (#7329).
pendingEscapeTailAnsi?: string
} | null> {
const state = this.headlessTerminals.get(ptyId)
if (!state) {
@@ -6166,6 +6173,9 @@ export class OrcaRuntimeService {
seq: state.outputSequence,
source: 'headless',
oscLinks: snapshot.oscLinks,
...(snapshot.pendingEscapeTailAnsi
? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi }
: {}),
// Why: lets the renderer skip the destructive scrollback clear when
// restoring an alt-screen snapshot — clearing wipes xterm's own
// history that the TUI relies on for scroll-up after a tab return.
+5
View File
@@ -55,6 +55,7 @@ type SnapshotFrameOptions = {
truncatedByByteBudget?: boolean
source?: 'headless' | 'renderer'
oscLinks?: TerminalOscLinkRange[]
pendingEscapeTailAnsi?: string
}
type SerializedSnapshot = {
@@ -67,6 +68,7 @@ type SerializedSnapshot = {
oscLinks?: TerminalOscLinkRange[]
scrollbackRows: number
truncatedByByteBudget: boolean
pendingEscapeTailAnsi?: string
} | null
type TerminalViewportClient = {
@@ -466,6 +468,7 @@ function sendSnapshotFrames(
cwd: options.cwd,
source: options.source,
oscLinks: options.oscLinks,
pendingEscapeTailAnsi: options.pendingEscapeTailAnsi,
truncated: options.truncated === true,
truncatedByByteBudget: options.truncatedByByteBudget === true
})
@@ -1398,6 +1401,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
cwd: serialized?.cwd,
source: serialized?.source,
oscLinks: serialized?.oscLinks,
pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi,
truncated: false,
truncatedByByteBudget: serialized?.truncatedByByteBudget,
data: serialized?.data ?? ''
@@ -1602,6 +1606,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
truncatedByByteBudget: serialized?.truncatedByByteBudget,
source: serialized?.source,
oscLinks: serialized?.oscLinks,
pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi,
data: serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '')
})
// Why: baseline for resize re-stream gating; the client already
@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from './dispatcher'
import type { RpcRequest } from './core'
import type { OrcaRuntimeService } from '../orca-runtime'
import { TERMINAL_METHODS } from './methods/terminal'
import type { RuntimeTerminalWait } from '../../../shared/runtime-types'
import {
TerminalStreamOpcode,
decodeTerminalStreamFrame,
decodeTerminalStreamJson,
encodeTerminalStreamFrame,
encodeTerminalStreamJson
} from '../../../shared/terminal-stream-protocol'
// Transport-level regression for #7329: the daemon's mid-escape tail
// (pendingEscapeTailAnsi) must survive the terminal.multiplex wire so the
// renderer can replay it after the reset. Without threading it through the
// SnapshotStart JSON frame, the tail is lost and the next live chunk renders
// literally ("colors/garbage around what I type").
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
return {
getRuntimeId: () => 'test-runtime',
...overrides
} as OrcaRuntimeService
}
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('terminal.multiplex pending-escape-tail threading (#7329)', () => {
it('carries the daemon pendingEscapeTailAnsi through the SnapshotStart frame', 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 runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn().mockResolvedValue({
data: 'user@host:~$ ',
cols: 80,
rows: 24,
// The dangling partial the emulator could not serialize.
pendingEscapeTailAnsi: '\x1b[3'
}),
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
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)
}),
cleanupSubscription: vi.fn((id: string) => {
const cleanup = cleanups.get(id)
cleanups.delete(id)
cleanup?.()
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
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-1',
sendBinary: (bytes) => binaryFrames.push(bytes),
registerBinaryStreamHandler: (streamId, handler) => {
handlers.set(streamId, handler)
return () => handlers.delete(streamId)
}
}
)
await vi.runOnlyPendingTimersAsync()
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: 5,
terminal: 'terminal-1',
client: { id: 'desktop-1', type: 'desktop' }
})
})
)!
)
// Drive the subscribe promise chain (readTerminal/serialize awaits + the
// output-batcher flush timer) to completion.
for (let i = 0; i < 5; i += 1) {
await vi.runOnlyPendingTimersAsync()
}
const snapshotStart = binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart)!
expect(decodeTerminalStreamJson(snapshotStart.payload)).toMatchObject({
pendingEscapeTailAnsi: '\x1b[3'
})
runtime.cleanupSubscription('terminal-multiplex:conn-1')
await dispatchPromise
} finally {
vi.useRealTimers()
}
})
})