fix(terminal): repaint a recovered remote pane from the host's retained buffer

A remote-runtime pane that loses its stream re-subscribes, but the recovery
subscribe only carries new bytes. When the host's push snapshot is empty --
an idle pane, or one whose PTY has exited and is preserved with its buffer --
the transport dropped it and nothing re-armed the restore, so the pane stayed
blank until a visibility flip issued the tagged snapshot request. That is why
switching worktrees once "fixed" it.

Emit onStreamRecovered from the recovery subscribe path only, and mark the
hidden-output restore needed so the pane pulls the buffer the host still holds.
The initial subscribe is untouched: it already carries the host snapshot, and
re-arming there would cost every pane a redundant restore request on open.
This commit is contained in:
Brennan Benson
2026-08-12 18:06:18 -07:00
parent a2c9f7e52b
commit 58e355aa0c
4 changed files with 72 additions and 0 deletions
@@ -5828,6 +5828,11 @@ export function connectPanePty(
reportRemoteRendererSerializerReady()
}
},
onStreamRecovered: (): void => {
if (isCurrent()) {
markHiddenOutputRestoreNeeded()
}
},
onData: (data: string, meta?: PtyDataMeta): void => {
if (isCurrent()) {
dataCallback(data, meta, generation)
@@ -104,6 +104,9 @@ type PtyCallbacks = {
/** Called before an adopted PTY can publish buffered/live bytes. */
onReattachDetermined?: () => void
onConnect?: () => void
/** A stream re-established after loss carries only new bytes, so the pane must
* re-pull the host's retained buffer or an idle/exited pane paints nothing. */
onStreamRecovered?: () => void
onDisconnect?: () => void
onData?: (data: string, meta?: PtyDataMeta) => void
onReplayData?: (data: string, meta?: PtyReplayDataMeta) => void
@@ -5054,6 +5054,64 @@ describe('createRemoteRuntimePtyTransport', () => {
transport.destroy?.()
})
it('re-arms the retained-buffer restore when a recovery subscribe replays no snapshot', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onReplayData = vi.fn()
const onStreamRecovered = vi.fn()
const onConnect = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'pane:1'
})
await transport.connect({
url: '',
callbacks: { onReplayData, onStreamRecovered, onConnect }
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
const firstStreamId = latestSubscribePayload().streamId
emitSnapshot(firstStreamId, 'INITIAL_SNAPSHOT')
subscriptionCallbacks?.onResponse({
ok: true,
result: {
type: 'subscribed',
streamId: firstStreamId,
capabilities: { outputPause: 1 }
}
})
await vi.waitFor(() => expect(onConnect).toHaveBeenCalledTimes(1))
// The first subscribe already carries the host snapshot; re-arming there would cost
// every pane a redundant restore request on open.
expect(onStreamRecovered).not.toHaveBeenCalled()
subscriptionCallbacks?.onClose?.()
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2))
await vi.waitFor(() =>
expect(
subscriptionSendBinary.mock.calls
.map((call) => decodeTerminalStreamFrame(call[0]))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe)
).toHaveLength(2)
)
const reconnectStreamId = latestSubscribePayload().streamId
// An exited-but-preserved pane has nothing to push and will never emit live bytes,
// so without the re-arm the pane stays blank until a visibility flip.
emitSnapshot(reconnectStreamId, '')
subscriptionCallbacks?.onResponse({
ok: true,
result: {
type: 'subscribed',
streamId: reconnectStreamId,
capabilities: { outputPause: 1 }
}
})
await vi.waitFor(() => expect(onStreamRecovered).toHaveBeenCalledTimes(1))
expect(onReplayData.mock.calls.map((call) => call[0])).toEqual(['INITIAL_SNAPSHOT'])
transport.destroy?.()
})
it('backs off before retrying a capacity-rejected terminal stream', async () => {
vi.useFakeTimers()
try {
@@ -1806,6 +1806,12 @@ export function createRemoteRuntimePtyTransport(
markRecoveryHealthy()
emitRecoveryState()
storedCallbacks.onConnect?.()
// Why: a recovery subscribe replays nothing when the host's push snapshot is
// empty (idle or exited pane), so ask for the retained buffer instead of
// waiting for bytes that an exited process will never send.
if (expectedRecoveryEpoch !== undefined) {
storedCallbacks.onStreamRecovered?.()
}
storedCallbacks.onStatus?.('shell')
},
onEnd: () => {