fix(terminal): restore preserved remote PTYs after host relaunch (#12990)

* fix(terminal): foreground preserved daemon PTYs

* fix(terminal): keep snapshot sequence domains distinct

* test(terminal): use active reconnect control

* test(terminal): await reconnect control activation

* test(terminal): validate reconnect with fresh control

* test(terminal): tighten host restart evidence

* fix(terminal): retry preserved PTY attach after inventory

* fix(terminal): retry attach after overlapping inventory

---------

Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com>
This commit is contained in:
Jinwoo Hong
2026-08-07 01:25:18 -07:00
committed by GitHub
co-authored by Jinwoo-H
parent 9e4e6ddae5
commit ddf58d6d6a
9 changed files with 728 additions and 23 deletions
+4 -5
View File
@@ -2623,7 +2623,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const size = runtime.getTerminalSize(ptyId)
const displayMode = runtime.getMobileDisplayMode(ptyId)
const layoutSeq = runtime.getLayout(ptyId)?.seq
const snapshotFrameSeq = serialized?.seq ?? layoutSeq
// Why: layout versions and output offsets are different sequence domains.
const snapshotOutputSeq = serialized?.seq
emit({
type: 'subscribed',
@@ -2664,7 +2664,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
cols: serialized?.cols ?? size?.cols ?? 80,
rows: serialized?.rows ?? size?.rows ?? 24,
displayMode,
seq: snapshotFrameSeq,
seq: snapshotOutputSeq,
cwd: serialized?.cwd,
truncated: initialOutputOverflowed,
truncatedByByteBudget: serialized?.truncatedByByteBudget,
@@ -3424,8 +3424,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const displayMode = runtime.getMobileDisplayMode(ptyId)
// Why: layout seq is the mobile stale-event filter's high-water mark (undefined pre-transition is fail-open). See docs/mobile-terminal-layout-state-machine.md.
const layoutSeq = runtime.getLayout(ptyId)?.seq
const snapshotFrameSeq = serialized?.seq ?? layoutSeq
// Why: track the seq that actually covered the buffered chunks (recovery snapshots advance it) or an absorbed query gets zero replies.
// Why: only an output offset can cover buffered chunks; layout versions are a separate sequence domain.
let snapshotOutputSeq = serialized?.seq
emit({
type: 'subscribed',
@@ -3445,7 +3444,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols ?? 80,
rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows ?? 24,
displayMode,
seq: snapshotFrameSeq,
seq: snapshotOutputSeq,
cwd: serialized?.cwd,
truncated: initialOutputOverflowed,
truncatedByByteBudget: serialized?.truncatedByByteBudget,
@@ -444,6 +444,58 @@ function startSourceRangeOverflowHarness(options: {
}
describe('terminal multiplex RPC', () => {
it('keeps layout versions out of the output sequence domain', async () => {
let dataListener: ((data: string, meta?: RuntimeTerminalDataMeta) => void) | undefined
const harness = startDesktopMultiplexSubscribe({
getLayout: vi.fn().mockReturnValue({ seq: 675 }),
serializeTerminalBuffer: vi.fn().mockResolvedValue({
data: 'restored terminal',
cols: 120,
rows: 40,
source: 'renderer'
}),
subscribeToTerminalData: vi.fn((_ptyId, listener) => {
dataListener = listener
return vi.fn()
})
})
await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true))
sendDesktopMultiplexSubscribe(harness.handlers)
await vi.waitFor(() =>
expect(
harness.messages.some((message) => JSON.parse(message).result?.type === 'subscribed')
).toBe(true)
)
const subscribed = harness.messages
.map((message) => JSON.parse(message).result)
.find((event) => event?.type === 'subscribed')
const snapshotStart = harness.binaryFrames
.map(decodeTerminalStreamFrame)
.find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart)
expect(subscribed.seq).toBe(675)
if (!snapshotStart) {
throw new Error('Missing multiplex snapshot start frame')
}
const snapshotPayload = decodeTerminalStreamJson(snapshotStart.payload)
expect(snapshotPayload).toMatchObject({ kind: 'scrollback' })
expect(snapshotPayload).not.toHaveProperty('seq')
harness.binaryFrames.splice(0)
dataListener?.('live', { seq: 4, rawLength: 4 })
await vi.waitFor(() =>
expect(
harness.binaryFrames.some((bytes) => {
const frame = decodeTerminalStreamFrame(bytes)
return frame?.opcode === TerminalStreamOpcode.Output && frame.seq === 4
})
).toBe(true)
)
harness.cleanups.get('terminal-multiplex:conn-desktop-first-paint')?.()
await harness.dispatchPromise
})
it.each(['headless', 'renderer'] as const)(
'commits a source-range replacement only after the %s snapshot publishes',
async (source) => {
@@ -833,15 +833,13 @@ describe('terminal subscribe buffering', () => {
const decodedFrames = binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.filter((frame): frame is NonNullable<typeof frame> => frame !== null)
const snapshotStarts = decodedFrames.filter(
const snapshotStart = decodedFrames.find(
(frame) => frame.opcode === TerminalStreamOpcode.SnapshotStart
)
// Why seq 1 (layout seq): a no-output-seq snapshot falls back to the
// layout seq on the wire; the recovered data still ships as the first
// and only scrollback snapshot.
expect(snapshotStarts.map((frame) => decodeTerminalStreamJson(frame.payload))).toEqual([
expect.objectContaining({ kind: 'scrollback', seq: 1 })
])
// Why: layout versions and output offsets are different sequence domains.
const snapshotInfo = decodeTerminalStreamJson(snapshotStart!.payload)
expect(snapshotInfo).toMatchObject({ kind: 'scrollback' })
expect(snapshotInfo).not.toHaveProperty('seq')
const snapshotText = decodedFrames
.filter((frame) => frame.opcode === TerminalStreamOpcode.SnapshotChunk)
.map((frame) => decodeTerminalStreamText(frame.payload))