From e2196403c397b44964f6ec80b93a3cfee27aa0df Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:41:02 -0700 Subject: [PATCH] fix(terminal): realign mobile snapshot residual metadata after query stripping (#31348) Align plain mobile/legacy residual metadata after snapshot-boundary query stripping; preserve genuinely transformed span metadata. Fixes #9682. Based on the report and suggested correction by @bbingz. Co-authored-by: bbingz --- .../terminal-subscribe-binary-snapshot.ts | 6 +- ...rminal-subscribe-residual-metadata.test.ts | 123 ++++++++++++++++++ 2 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 src/main/runtime/rpc/terminal-subscribe-residual-metadata.test.ts diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary-snapshot.ts index 22c32381348..76b7065ccc9 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary-snapshot.ts @@ -300,15 +300,15 @@ export async function publishTerminalSubscribeBinarySnapshot( typeof item.meta?.seq === 'number' && typeof item.meta.rawLength === 'number' ) { - if (item.meta.rawLength === item.data.length) { - uncoveredMeta = { ...item.meta, rawLength: uncoveredData.length } - } uncoveredData = stripSnapshotBoundaryQuerySuffixes( uncoveredData, snapshotOutputSeq, snapshotOutputSeq, state.pendingQuerySequences ) + if (!item.meta.transformed && item.meta.rawLength === item.data.length) { + uncoveredMeta = { ...item.meta, rawLength: uncoveredData.length } + } } if (uncoveredData) { state.outputBatcher.push(uncoveredData, uncoveredMeta) diff --git a/src/main/runtime/rpc/terminal-subscribe-residual-metadata.test.ts b/src/main/runtime/rpc/terminal-subscribe-residual-metadata.test.ts new file mode 100644 index 00000000000..033f2c9a835 --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-residual-metadata.test.ts @@ -0,0 +1,123 @@ +import './unused-default-rpc-methods.test-fixture' +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' +import { stubRuntime, makeRequest } from './terminal-multiplex-test-harness' +import type { TerminalOutputMeta } from './terminal-output-frame-chunks' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + decodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' + +describe('mobile subscribe residual metadata', () => { + it.each([undefined, 5, 12])( + 'strips a snapshot-boundary query without changing transformed span rawLength %s', + async (transformedRawLength) => { + vi.useFakeTimers() + const controller = new AbortController() + try { + const binaryFrames: Uint8Array[] = [] + let emitOutput: ((data: string, meta?: TerminalOutputMeta) => void) | undefined + let releaseSnapshot: (() => void) | undefined + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: (_ptyId, listener) => { + emitOutput = listener + return vi.fn() + }, + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn(async () => { + await new Promise((resolve) => { + releaseSnapshot = resolve + }) + // The snapshot ends inside the cursor-position query in the buffered output. + return { data: 'before', cols: 40, rows: 24, seq: 8 } + }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 40, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + vi.fn(), + { + signal: controller.signal, + connectionId: 'phone-connection', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + await vi.runOnlyPendingTimersAsync() + expect(releaseSnapshot).toBeTypeOf('function') + emitOutput?.('before\x1b[6nafter', { seq: 15, rawLength: 15 }) + if (transformedRawLength !== undefined) { + emitOutput?.('PAINT', { + seq: 15 + transformedRawLength, + rawLength: transformedRawLength, + transformed: true, + cwd: '/workspace' + }) + } + releaseSnapshot?.() + await vi.runOnlyPendingTimersAsync() + + const frames = binaryFrames.flatMap((bytes) => { + const frame = decodeTerminalStreamFrame(bytes) + return frame ? [frame] : [] + }) + const output = frames.filter( + (frame) => + frame.opcode === TerminalStreamOpcode.Output || + frame.opcode === TerminalStreamOpcode.OutputSpan + ) + expect( + output.map((frame) => ({ + opcode: frame.opcode, + seq: frame.seq, + payload: + frame.opcode === TerminalStreamOpcode.Output + ? decodeTerminalStreamText(frame.payload) + : decodeTerminalStreamJson(frame.payload) + })) + ).toEqual([ + { opcode: TerminalStreamOpcode.Output, seq: 15, payload: '\x1b[6nafter' }, + ...(transformedRawLength === undefined + ? [] + : [ + { + opcode: TerminalStreamOpcode.OutputSpan, + seq: 15 + transformedRawLength, + payload: { data: 'PAINT', rawLength: transformedRawLength, transformed: true } + } + ]) + ]) + if (transformedRawLength !== undefined) { + expect( + frames + .filter((frame) => frame.opcode === TerminalStreamOpcode.Metadata) + .map((frame) => decodeTerminalStreamJson(frame.payload)) + ).toEqual([{ cwd: '/workspace' }]) + } + controller.abort() + await dispatchPromise + } finally { + controller.abort() + vi.useRealTimers() + } + } + ) +})