fix(terminal): preserve parser carry on empty snapshots

This commit is contained in:
Brennan Benson
2026-08-14 17:09:12 -07:00
parent b7c90f6bc0
commit e9646e648f
3 changed files with 85 additions and 18 deletions
+4 -9
View File
@@ -2349,13 +2349,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
source: serializedContent?.source,
kittyKeyboardFlags: serializedContent?.kittyKeyboardFlags,
oscLinks: serializedContent?.oscLinks,
pendingEscapeTailAnsi: serializedContent?.pendingEscapeTailAnsi,
pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi,
truncated: false,
truncatedByByteBudget: serialized?.truncatedByByteBudget,
// Why: `unavailable` means nobody answered, so the client retries and can
// eventually banner. A serializer that DID answer with an empty buffer has
// answered — reporting that as unavailable banners genuinely empty panes.
// Empty content still suppresses the seq/source metadata below.
// Why: only absence is retry-worthy; empty visual data still suppresses provenance.
unavailable: serialized ? undefined : 'no-serializable-buffer',
data: serializedContent?.data ?? ''
})
@@ -2696,10 +2693,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
source: serializedContent?.source,
kittyKeyboardFlags: serializedContent?.kittyKeyboardFlags,
oscLinks: serializedContent?.oscLinks,
pendingEscapeTailAnsi: serializedContent?.pendingEscapeTailAnsi,
// Why: same rule as the tagged path — only an absent serializer answer is
// "unavailable". A serializer that answered empty, with an empty retained
// tail, is a genuinely empty pane and must not be reported as a failure.
pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi,
// Why: only absence without retained tail is retry-worthy; empty answers are valid.
unavailable:
serialized || read.tail.length > 0 ? undefined : 'no-serializable-buffer',
data:
@@ -20,6 +20,8 @@ type SerializedBuffer = {
rows: number
seq?: number
source?: 'headless' | 'renderer'
cwd?: string
pendingEscapeTailAnsi?: string
} | null
function makeRequest(method: string, params?: unknown): RpcRequest {
@@ -121,7 +123,14 @@ describe('terminal multiplex initial snapshot fallback', () => {
it('serves the retained tail when serialization returns an empty snapshot object', async () => {
const snapshot = await subscribeSnapshot({
connectionId: 'conn-empty-snapshot-tail',
serialized: { data: '', cols: 120, rows: 40, seq: 42, source: 'renderer' },
serialized: {
data: '',
cols: 120,
rows: 40,
seq: 42,
source: 'renderer',
cwd: '/stale'
},
tail: ['retained line 1', 'retained line 2']
})
@@ -129,6 +138,7 @@ describe('terminal multiplex initial snapshot fallback', () => {
expect(snapshot.start.unavailable).toBeUndefined()
expect(snapshot.start.seq).toBeUndefined()
expect(snapshot.start.source).toBeUndefined()
expect(snapshot.start.cwd).toBeUndefined()
})
it('keeps the retained-tail fallback when serialization returns null', async () => {
@@ -165,4 +175,37 @@ describe('terminal multiplex initial snapshot fallback', () => {
expect(snapshot.data).toBe('')
expect(snapshot.start.unavailable).toBe('no-serializable-buffer')
})
it('reports a successful empty snapshot when the serializer answered and no tail exists', async () => {
const snapshot = await subscribeSnapshot({
connectionId: 'conn-empty-snapshot-no-tail',
serialized: { data: '', cols: 120, rows: 40, seq: 42, source: 'renderer' },
tail: []
})
expect(snapshot.data).toBe('')
expect(snapshot.start.unavailable).toBeUndefined()
expect(snapshot.start.seq).toBeUndefined()
expect(snapshot.start.source).toBeUndefined()
})
it('preserves parser carry state when serialized visual data is empty', async () => {
const snapshot = await subscribeSnapshot({
connectionId: 'conn-empty-snapshot-parser-tail',
serialized: {
data: '',
cols: 120,
rows: 40,
seq: 42,
source: 'renderer',
pendingEscapeTailAnsi: '\x1b[38;2;255'
},
tail: []
})
expect(snapshot.data).toBe('')
expect(snapshot.start.pendingEscapeTailAnsi).toBe('\x1b[38;2;255')
expect(snapshot.start.seq).toBeUndefined()
expect(snapshot.start.source).toBeUndefined()
})
})
@@ -13,7 +13,16 @@ import {
encodeTerminalStreamJson
} from '../../../shared/terminal-stream-protocol'
type SerializedBuffer = { data: string; scrollbackAnsi?: string; cols: number; rows: number } | null
type SerializedBuffer = {
data: string
scrollbackAnsi?: string
cols: number
rows: number
seq?: number
cwd?: string
source?: 'headless' | 'renderer'
pendingEscapeTailAnsi?: string
} | null
type SnapshotStartPayload = Record<string, unknown>
// Why: 256 KiB is the pending-output budget, so this many 1 KiB chunks always trips the overflow guard.
@@ -168,9 +177,7 @@ describe('requested terminal snapshot unavailability reasons', () => {
expect(start.unavailable).toBeUndefined()
})
// Why: `unavailable` drives client retries and eventually a loss banner. A serializer
// that answered with an empty buffer HAS answered; a genuinely empty pane must not be
// reported as a failure or every fresh pane banners. Only an absent answer is a failure.
// Why: unavailable answers retry and eventually banner; empty answers are valid.
it('does not report a reason when the serializer answered with an empty buffer', async () => {
const { start, chunks } = await requestSnapshotReply({
connectionId: 'conn-reason-empty',
@@ -181,15 +188,37 @@ describe('requested terminal snapshot unavailability reasons', () => {
expect(start.unavailable).toBeUndefined()
})
// Why: an empty answer must still not publish seq/source metadata, or the client
// anchors its stream to a snapshot that carries no content.
it('omits seq and source metadata for an empty serialized answer', async () => {
// Why: empty visual data cannot anchor stream provenance.
it('omits seq, source, and cwd metadata for an empty serialized answer', async () => {
const { start } = await requestSnapshotReply({
connectionId: 'conn-reason-empty-metadata',
serializeRequested: async () => ({ data: '', cols: 120, rows: 40 })
serializeRequested: async () => ({
data: '',
cols: 120,
rows: 40,
seq: 42,
source: 'renderer',
cwd: '/stale'
})
})
expect(start.seq).toBeUndefined()
expect(start.source).toBeUndefined()
expect(start.cwd).toBeUndefined()
})
it('preserves parser carry state when serialized visual data is empty', async () => {
const { start, chunks } = await requestSnapshotReply({
connectionId: 'conn-reason-empty-parser-tail',
serializeRequested: async () => ({
data: '',
cols: 120,
rows: 40,
pendingEscapeTailAnsi: '\x1b[38;2;255'
})
})
expect(chunks).toBe('')
expect(start.pendingEscapeTailAnsi).toBe('\x1b[38;2;255')
expect(start.unavailable).toBeUndefined()
})
it('accepts scrollback-only serialized content', async () => {