mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 16:03:53 +00:00
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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user