diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 99e126c1273..45c75dbd3d8 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -1716,23 +1716,23 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (closed || streams.get(stream.streamId) !== stream) { return } - const size = runtime.getTerminalSize(stream.ptyId) + if (!serialized) { + throw new Error('Remote terminal recovery snapshot unavailable.') + } const displayMode = runtime.getMobileDisplayMode(stream.ptyId) // Why: dropped ACK-pending output breaks live replay; send a fresh snapshot before resuming output. - // Why: clients discard truncated snapshots, so mark truncated only when serialization actually failed. sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), { kind: 'scrollback', - cols: serialized?.cols ?? size?.cols ?? 80, - rows: serialized?.rows ?? size?.rows ?? 24, + cols: serialized.cols, + rows: serialized.rows, displayMode, reason: 'ack-pending-overflow', - seq: serialized?.seq, - source: serialized?.source, - truncated: !serialized, - truncatedByByteBudget: serialized?.truncatedByByteBudget, - data: serialized?.data ?? '' + seq: serialized.seq, + source: serialized.source, + truncatedByByteBudget: serialized.truncatedByByteBudget, + data: serialized.data }) - if (serialized && typeof serialized.seq === 'number') { + if (typeof serialized.seq === 'number') { // Why: chunks queued before the snapshot serialized are already in it; replaying them would duplicate output. const snapshotSeq = serialized.seq const retained = stream.ackPendingOutput.filter( diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index b37a2ee6aca..d86161d0321 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -1676,14 +1676,23 @@ describe('terminal multiplex RPC', () => { await dispatchPromise }) - it('ends a stream when ACK overflow recovery serialization fails', async () => { + it.each([ + { + failure: 'throws', + recover: () => Promise.reject(new Error('snapshot unavailable')) + }, + { + failure: 'returns no snapshot', + recover: () => Promise.resolve(null) + } + ])('ends a stream when ACK overflow recovery serialization $failure', async ({ recover }) => { const dataListenerRef: { current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void } = {} const serializeTerminalBuffer = vi .fn() .mockResolvedValueOnce({ data: 'initial snapshot', cols: 120, rows: 40 }) - .mockRejectedValue(new Error('snapshot unavailable')) + .mockImplementation(recover) const harness = startDesktopMultiplexSubscribe({ serializeTerminalBuffer, subscribeToTerminalData: vi.fn(