mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(terminal): avoid duplicate snapshot replays after reveal (#30205)
Retain reveal sequence coverage until a parsed snapshot covers it, preventing a duplicate text-only replay from clearing newer terminal images. Keep conservative fallback recovery for unknown episodes, missing metadata and different terminal processes. Verified against current main: 406 tests, full application typecheck, changed-code quality and four fresh hidden rendered restore checks pass. No xterm patches or dependency changes.
This commit is contained in:
+14
-183
@@ -1,22 +1,17 @@
|
||||
import type * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import './pty-connection-mocks.spec'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { flushAsyncTicks, createDeferred } from './pty-connection-test-async'
|
||||
import {
|
||||
createMockTransport,
|
||||
createPane,
|
||||
createManager,
|
||||
type ConnectCallbacks,
|
||||
type MockPane,
|
||||
type MockTransport
|
||||
} from './pty-connection-test-pane-fixtures'
|
||||
import { buildPaneConnectionDeps, type PaneConnectionDeps } from './pty-connection-test-deps'
|
||||
import { createInitialStoreState } from './pty-connection-test-store-fixtures'
|
||||
import type { StoreState } from './pty-connection-test-store-state'
|
||||
import {
|
||||
installTerminalTestGlobals,
|
||||
restoreTerminalTestGlobals
|
||||
} from './pty-connection-test-environment'
|
||||
import { connectMockPanePty } from './pty-connection-test-connect-mock-pane'
|
||||
announceMainDrop,
|
||||
BANNER_FRAGMENT,
|
||||
connectVisiblePane,
|
||||
installHiddenRestoreFloodSuiteHooks,
|
||||
modelSnapshot,
|
||||
OVERFLOW_CHUNK,
|
||||
PTY_ID,
|
||||
type MainSnapshot,
|
||||
type PaneDrive
|
||||
} from './pty-connection-test-hidden-restore-flood'
|
||||
|
||||
// STA-4869 — routes R3 (pending-queue overflow, pty-connection.ts:7696) and R4
|
||||
// (loop iteration cap, 7702) end in the QUIET abandon, which trades the loss
|
||||
@@ -25,21 +20,7 @@ import { connectMockPanePty } from './pty-connection-test-connect-mock-pane'
|
||||
// disclosed. These cases must also survive the pane being scrolled back or
|
||||
// hidden when the deferred repaint comes due.
|
||||
|
||||
const {
|
||||
resetAndRefreshAllTerminalWebglAtlases,
|
||||
scheduleTerminalWebglAtlasRecovery,
|
||||
scheduleRuntimeGraphSync,
|
||||
shouldSeedCacheTimerOnInitialTitle,
|
||||
toastInfo,
|
||||
notifyCodexPaneBoundForStaleSweep,
|
||||
warnTerminalLifecycleAnomaly
|
||||
} = vi.hoisted(() => ({
|
||||
resetAndRefreshAllTerminalWebglAtlases: vi.fn(),
|
||||
scheduleTerminalWebglAtlasRecovery: vi.fn(),
|
||||
scheduleRuntimeGraphSync: vi.fn(),
|
||||
shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false),
|
||||
toastInfo: vi.fn(),
|
||||
notifyCodexPaneBoundForStaleSweep: vi.fn(),
|
||||
const { warnTerminalLifecycleAnomaly } = vi.hoisted(() => ({
|
||||
warnTerminalLifecycleAnomaly: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -50,146 +31,8 @@ vi.mock('./terminal-lifecycle-diagnostics', async (importOriginal) => ({
|
||||
warnTerminalLifecycleAnomaly
|
||||
}))
|
||||
|
||||
let mockStoreState: StoreState
|
||||
let transportFactoryQueue: MockTransport[] = []
|
||||
let createdTransportOptions: Record<string, unknown>[] = []
|
||||
let storeSubscribers: ((state: StoreState) => void)[] = []
|
||||
|
||||
vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync }))
|
||||
|
||||
vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
resetAndRefreshAllTerminalWebglAtlases
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-webgl-atlas-recovery', () => ({
|
||||
scheduleTerminalWebglAtlasRecovery
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => mockStoreState,
|
||||
subscribe: (listener: (state: StoreState) => void) => {
|
||||
storeSubscribers.push(listener)
|
||||
return () => {
|
||||
storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment')
|
||||
return buildAgentStatusModuleMock(await importOriginal<Record<string, unknown>>())
|
||||
})
|
||||
|
||||
vi.mock('./cache-timer-seeding', () => ({
|
||||
shouldSeedCacheTimerOnInitialTitle
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { info: toastInfo } }))
|
||||
|
||||
vi.mock('@/lib/codex-stale-pane-sweep', () => ({
|
||||
notifyCodexPaneBoundForStaleSweep
|
||||
}))
|
||||
|
||||
vi.mock('react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof React>()
|
||||
return {
|
||||
...actual,
|
||||
useCallback: <T extends (...args: unknown[]) => unknown>(fn: T): T => fn
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./pty-transport', () => ({
|
||||
createIpcPtyTransport: vi.fn((options: Record<string, unknown>) => {
|
||||
createdTransportOptions.push(options)
|
||||
const nextTransport = transportFactoryQueue.shift()
|
||||
if (!nextTransport) {
|
||||
throw new Error('No mock transport queued')
|
||||
}
|
||||
return nextTransport
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./remote-runtime-pty-transport', () => ({
|
||||
createRemoteRuntimePtyTransport: vi.fn(
|
||||
(_environmentId: string, options: Record<string, unknown>) => {
|
||||
createdTransportOptions.push(options)
|
||||
const nextTransport = transportFactoryQueue.shift()
|
||||
if (!nextTransport) {
|
||||
throw new Error('No mock transport queued')
|
||||
}
|
||||
return nextTransport
|
||||
}
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('./pty-dispatcher', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
getEagerPtyBufferHandle: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
const PTY_ID = 'pty-id'
|
||||
const BANNER_FRAGMENT = 'main recovery was unavailable'
|
||||
const R3_MARKER = 'R3-LOST-4d17'
|
||||
const R4_MARKER = 'R4-LOST-8ba0'
|
||||
// One char over HIDDEN_OUTPUT_RESTORE_PENDING_CHARS (512KB): the queue discards
|
||||
// its whole backlog and latches overflow.
|
||||
const OVERFLOW_CHUNK = 'o'.repeat(512 * 1024 + 1)
|
||||
|
||||
type MainSnapshot = { data: string; cols: number; rows: number; seq: number }
|
||||
|
||||
function modelSnapshot(marker: string, seq: number): MainSnapshot {
|
||||
return { data: `${marker}\r\n`, cols: 120, rows: 40, seq }
|
||||
}
|
||||
|
||||
type PaneDrive = {
|
||||
pane: MockPane
|
||||
deps: PaneConnectionDeps
|
||||
disposable: { dispose: () => void }
|
||||
deliver: (data: string, seq: number) => void
|
||||
setVisible: (visible: boolean) => void
|
||||
writtenChunks: () => string[]
|
||||
}
|
||||
|
||||
async function connectVisiblePane(): Promise<PaneDrive> {
|
||||
const transport = createMockTransport(PTY_ID)
|
||||
const captured: {
|
||||
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
|
||||
} = { current: null }
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks?: ConnectCallbacks }) => {
|
||||
captured.current = callbacks?.onData ?? null
|
||||
return PTY_ID
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
const pane = createPane(1)
|
||||
const deps = buildPaneConnectionDeps(() => mockStoreState, {
|
||||
isVisibleRef: { current: true }
|
||||
})
|
||||
const disposable = await connectMockPanePty(pane, createManager(1), deps)
|
||||
await flushAsyncTicks(6)
|
||||
expect(captured.current).not.toBeNull()
|
||||
return {
|
||||
pane,
|
||||
deps,
|
||||
disposable,
|
||||
deliver: (data, seq) => captured.current?.(data, { seq, rawLength: data.length }),
|
||||
setVisible: (visible) => {
|
||||
deps.isVisibleRef.current = visible
|
||||
},
|
||||
writtenChunks: () => pane.terminal.write.mock.calls.map(([data]) => String(data))
|
||||
}
|
||||
}
|
||||
|
||||
async function announceMainDrop(markerSeq: number): Promise<void> {
|
||||
const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel')
|
||||
_dispatchPtyModelRestoreNeededForTest({
|
||||
id: PTY_ID,
|
||||
reason: 'hidden-drop',
|
||||
markerSeq
|
||||
})
|
||||
}
|
||||
|
||||
function countOccurrences(haystack: string, needle: string): number {
|
||||
return haystack.split(needle).length - 1
|
||||
@@ -220,19 +63,7 @@ function expectDisclosedOrRecovered(disclosure: Disclosure): void {
|
||||
}
|
||||
|
||||
describe('hidden-output restore quiet abandonment (STA-4869)', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
transportFactoryQueue = []
|
||||
createdTransportOptions = []
|
||||
storeSubscribers = []
|
||||
mockStoreState = createInitialStoreState(() => mockStoreState)
|
||||
await installTerminalTestGlobals()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await restoreTerminalTestGlobals()
|
||||
})
|
||||
installHiddenRestoreFloodSuiteHooks()
|
||||
|
||||
// ── R3: foreground pending-queue overflow ───────────────────────────────
|
||||
describe('R3 pending-queue overflow', () => {
|
||||
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
import './pty-connection-mocks.spec'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { flushAsyncTicks, createDeferred } from './pty-connection-test-async'
|
||||
import {
|
||||
announceMainDrop,
|
||||
BANNER_FRAGMENT,
|
||||
connectVisiblePane,
|
||||
installHiddenRestoreFloodSuiteHooks,
|
||||
modelSnapshot,
|
||||
PTY_ID,
|
||||
type MainSnapshot
|
||||
} from './pty-connection-test-hidden-restore-flood'
|
||||
|
||||
// An unhide marker during an in-flight restore is retired by that snapshot when it covers
|
||||
// the marker's seq, instead of costing a second destructive replay.
|
||||
|
||||
describe('hidden-output restore unhide coverage (STA-4869)', () => {
|
||||
installHiddenRestoreFloodSuiteHooks()
|
||||
|
||||
it('acknowledges a first-seen unhide episode before newer live output avoids another replay', async () => {
|
||||
const drive = await connectVisiblePane()
|
||||
vi.useFakeTimers()
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
const first = createDeferred<MainSnapshot>()
|
||||
getMainBufferSnapshot.mockReturnValueOnce(first.promise)
|
||||
getMainBufferSnapshot.mockResolvedValue(modelSnapshot('ACKNOWLEDGED_CAPTURE', 128))
|
||||
const ackRestore = vi.mocked(window.api.pty.ackHiddenOutputRestoreApplied)
|
||||
try {
|
||||
await announceMainDrop(64, undefined, 'pending-cap')
|
||||
await announceMainDrop(64, 1, 'unhide')
|
||||
first.resolve(modelSnapshot('FIRST_CAPTURE', 128))
|
||||
await flushAsyncTicks(40)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1)
|
||||
expect(ackRestore.mock.calls).toEqual([[PTY_ID, undefined]])
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
await flushAsyncTicks(40)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2)
|
||||
expect(ackRestore.mock.calls).toEqual([
|
||||
[PTY_ID, undefined],
|
||||
[PTY_ID, 1]
|
||||
])
|
||||
const live = 'LIVE_IMAGE_AFTER_ACK_WINDOW\r\n'
|
||||
drive.deliver(live, 128 + live.length)
|
||||
await flushAsyncTicks(10)
|
||||
expect(drive.writtenChunks().join('')).toContain(live)
|
||||
const writesBeforeQuietReveal = drive.writtenChunks().length
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
await flushAsyncTicks(40)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2)
|
||||
expect(ackRestore).toHaveBeenCalledTimes(2)
|
||||
expect(drive.writtenChunks().slice(writesBeforeQuietReveal).join('')).not.toContain('\x1b[2J')
|
||||
} finally {
|
||||
drive.disposable.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the unhide requirement until a repeatedly stale snapshot finally covers it', async () => {
|
||||
const drive = await connectVisiblePane()
|
||||
vi.useFakeTimers()
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
const first = createDeferred<MainSnapshot>()
|
||||
getMainBufferSnapshot
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockResolvedValueOnce(modelSnapshot('SECOND_STALE_CAPTURE', 128))
|
||||
.mockResolvedValueOnce(modelSnapshot('THIRD_COVERING_CAPTURE', 512))
|
||||
try {
|
||||
await announceMainDrop(64, 1)
|
||||
await announceMainDrop(256, 1, 'unhide')
|
||||
first.resolve(modelSnapshot('FIRST_STALE_CAPTURE', 128))
|
||||
await flushAsyncTicks(100)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(3)
|
||||
expect(drive.writtenChunks().join('')).toContain('THIRD_COVERING_CAPTURE')
|
||||
expect(drive.writtenChunks().join('')).not.toContain(BANNER_FRAGMENT)
|
||||
} finally {
|
||||
drive.disposable.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([undefined, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])(
|
||||
'does not treat snapshot sequence %s as covering the pending unhide',
|
||||
async (seq) => {
|
||||
const drive = await connectVisiblePane()
|
||||
vi.useFakeTimers()
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
const first = createDeferred<{
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
}>()
|
||||
getMainBufferSnapshot.mockReturnValueOnce(first.promise)
|
||||
getMainBufferSnapshot.mockResolvedValue(modelSnapshot('FINITE_COVERING_CAPTURE', 512))
|
||||
try {
|
||||
await announceMainDrop(64, 1)
|
||||
await announceMainDrop(256, 1, 'unhide')
|
||||
first.resolve({ ...modelSnapshot('UNPROVEN_CAPTURE', 128), seq })
|
||||
await flushAsyncTicks(70)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2)
|
||||
expect(drive.writtenChunks().join('')).toContain('FINITE_COVERING_CAPTURE')
|
||||
} finally {
|
||||
drive.disposable.dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('consumes a covered unhide marker received after the response while its replay is parsing', async () => {
|
||||
const drive = await connectVisiblePane()
|
||||
vi.useFakeTimers()
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
getMainBufferSnapshot.mockResolvedValue(modelSnapshot('REPLAY_PARSING', 128))
|
||||
const pendingParses: (() => void)[] = []
|
||||
const originalWrite = drive.pane.terminal.write.getMockImplementation()
|
||||
drive.pane.terminal.write.mockImplementation((data, callback) => {
|
||||
if ((data === '' || callback?.name === 'runParsedSteps') && callback) {
|
||||
pendingParses.push(callback)
|
||||
}
|
||||
})
|
||||
const finishParsing = async (): Promise<void> => {
|
||||
for (let round = 0; round < 10; round += 1) {
|
||||
while (pendingParses.length > 0) {
|
||||
pendingParses.shift()?.()
|
||||
}
|
||||
await flushAsyncTicks(10)
|
||||
}
|
||||
}
|
||||
try {
|
||||
await announceMainDrop(64, 1)
|
||||
await flushAsyncTicks(20)
|
||||
expect(drive.writtenChunks().join('')).toContain('REPLAY_PARSING')
|
||||
expect(window.api.pty.ackHiddenOutputRestoreApplied).not.toHaveBeenCalled()
|
||||
await announceMainDrop(128, 1, 'unhide')
|
||||
await finishParsing()
|
||||
expect(window.api.pty.ackHiddenOutputRestoreApplied).toHaveBeenCalledWith(PTY_ID, 1)
|
||||
const live = 'LIVE_AFTER_PARSED_REPLAY\r\n'
|
||||
drive.deliver(live, 128 + live.length)
|
||||
await finishParsing()
|
||||
const beforeTimer = drive.writtenChunks().length
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
await finishParsing()
|
||||
expect.soft(getMainBufferSnapshot).toHaveBeenCalledTimes(1)
|
||||
expect(drive.writtenChunks().slice(beforeTimer).join('')).not.toContain('\x1b[2J')
|
||||
} finally {
|
||||
if (originalWrite) {
|
||||
drive.pane.terminal.write.mockImplementation(originalWrite)
|
||||
}
|
||||
drive.disposable.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not clear newer live output for an unhide marker the successful snapshot covered', async () => {
|
||||
const drive = await connectVisiblePane()
|
||||
vi.useFakeTimers()
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
const first = createDeferred<MainSnapshot>()
|
||||
getMainBufferSnapshot.mockReturnValueOnce(first.promise)
|
||||
getMainBufferSnapshot.mockResolvedValue(modelSnapshot('UNNECESSARY_SECOND_REPLAY', 256))
|
||||
try {
|
||||
await announceMainDrop(64, 1)
|
||||
await announceMainDrop(64, 1, 'unhide')
|
||||
first.resolve(modelSnapshot('COVERED_DROP', 128))
|
||||
await flushAsyncTicks(30)
|
||||
expect(window.api.pty.ackHiddenOutputRestoreApplied).toHaveBeenCalledWith(PTY_ID, 1)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1)
|
||||
|
||||
const live = 'LIVE_AFTER_SUCCESS\r\n'
|
||||
drive.deliver(live, 128 + live.length)
|
||||
await flushAsyncTicks(10)
|
||||
expect(drive.writtenChunks().join('')).toContain(live)
|
||||
const beforeTimer = drive.writtenChunks().length
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
await flushAsyncTicks(30)
|
||||
|
||||
expect.soft(getMainBufferSnapshot).toHaveBeenCalledTimes(1)
|
||||
expect(drive.writtenChunks().slice(beforeTimer).join('')).not.toContain('\x1b[2J')
|
||||
} finally {
|
||||
drive.disposable.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['pending-cap', 'delivery-heal'] as const)(
|
||||
'keeps a %s marker gated right after a covering unhide restore completes',
|
||||
async (reason) => {
|
||||
const drive = await connectVisiblePane()
|
||||
vi.useFakeTimers()
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
const first = createDeferred<MainSnapshot>()
|
||||
getMainBufferSnapshot.mockReturnValueOnce(first.promise)
|
||||
getMainBufferSnapshot.mockResolvedValue(modelSnapshot('POST_FLOOD_REPAINT', 512))
|
||||
try {
|
||||
await announceMainDrop(64, 1)
|
||||
await announceMainDrop(64, 1, 'unhide')
|
||||
first.resolve(modelSnapshot('COVERED_DROP', 128))
|
||||
await flushAsyncTicks(30)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await announceMainDrop(256, undefined, reason)
|
||||
await flushAsyncTicks(30)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
await flushAsyncTicks(30)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2)
|
||||
expect(drive.writtenChunks().join('')).toContain('POST_FLOOD_REPAINT')
|
||||
} finally {
|
||||
drive.disposable.dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ loss: 'a newer episode', markerSeq: 256, episode: 2 },
|
||||
{
|
||||
loss: 'a repeatedly reported newer episode',
|
||||
markerSeq: 256,
|
||||
episode: 2,
|
||||
repeat: true
|
||||
},
|
||||
{
|
||||
loss: 'a sequence-less repeat after a sequenced marker',
|
||||
markerSeq: 256,
|
||||
episode: 1,
|
||||
fallback: 'unhide'
|
||||
},
|
||||
{
|
||||
loss: 'a pending cap after a sequenced marker',
|
||||
markerSeq: 256,
|
||||
episode: 1,
|
||||
fallback: 'pending-cap'
|
||||
},
|
||||
{
|
||||
loss: 'covered bytes in a newly reported episode',
|
||||
markerSeq: 64,
|
||||
episode: 2
|
||||
},
|
||||
{
|
||||
loss: 'covered bytes without an episode',
|
||||
markerSeq: 64,
|
||||
episode: undefined
|
||||
},
|
||||
{ loss: 'newer bytes in the same episode', markerSeq: 256, episode: 1 },
|
||||
{ loss: 'an episode-less marker', markerSeq: 256, episode: undefined },
|
||||
{ loss: 'a sequence-less marker', markerSeq: undefined, episode: 1 }
|
||||
])('still repaints $loss reported while the older snapshot was fetching', async (scenario) => {
|
||||
const { markerSeq, episode } = scenario
|
||||
const drive = await connectVisiblePane()
|
||||
vi.useFakeTimers()
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
const first = createDeferred<MainSnapshot>()
|
||||
getMainBufferSnapshot.mockReturnValueOnce(first.promise)
|
||||
getMainBufferSnapshot.mockResolvedValue(modelSnapshot('NEW_EPISODE_RECOVERED', 512))
|
||||
try {
|
||||
await announceMainDrop(64, 1)
|
||||
await announceMainDrop(markerSeq, episode, 'unhide')
|
||||
if ('repeat' in scenario && scenario.repeat) {
|
||||
await announceMainDrop(markerSeq, episode, 'unhide')
|
||||
}
|
||||
if ('fallback' in scenario && scenario.fallback === 'unhide') {
|
||||
await announceMainDrop(undefined, episode, 'unhide')
|
||||
} else if ('fallback' in scenario && scenario.fallback === 'pending-cap') {
|
||||
await announceMainDrop(undefined, undefined, 'pending-cap')
|
||||
}
|
||||
first.resolve(modelSnapshot('ONLY_OLD_EPISODE_COVERED', 128))
|
||||
await flushAsyncTicks(30)
|
||||
expect(window.api.pty.ackHiddenOutputRestoreApplied).toHaveBeenCalledWith(PTY_ID, 1)
|
||||
|
||||
const repairAlreadyPainted = getMainBufferSnapshot.mock.calls.length === 2
|
||||
const live = 'LIVE_AFTER_COVERING_REPAIR\r\n'
|
||||
if (repairAlreadyPainted) {
|
||||
drive.deliver(live, 512 + live.length)
|
||||
await flushAsyncTicks(10)
|
||||
}
|
||||
const writesBeforeTimer = drive.writtenChunks().length
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
await flushAsyncTicks(30)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2)
|
||||
if (repairAlreadyPainted) {
|
||||
expect(drive.writtenChunks().slice(writesBeforeTimer).join('')).not.toContain('\x1b[2J')
|
||||
}
|
||||
expect(drive.writtenChunks().join('')).toContain('NEW_EPISODE_RECOVERED')
|
||||
if (episode !== undefined) {
|
||||
expect(window.api.pty.ackHiddenOutputRestoreApplied).toHaveBeenCalledWith(PTY_ID, episode)
|
||||
}
|
||||
} finally {
|
||||
drive.disposable.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
import './pty-connection-mocks.spec'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { flushAsyncTicks, createDeferred } from './pty-connection-test-async'
|
||||
import {
|
||||
announceMainDrop,
|
||||
BANNER_FRAGMENT,
|
||||
connectVisiblePane,
|
||||
installHiddenRestoreFloodSuiteHooks,
|
||||
modelSnapshot,
|
||||
OVERFLOW_CHUNK,
|
||||
PTY_ID,
|
||||
type MainSnapshot
|
||||
} from './pty-connection-test-hidden-restore-flood'
|
||||
|
||||
// Unhide markers the in-flight snapshot cannot retire fall back to the deferred quiet repaint
|
||||
// or a retry, and must still recover without a loss banner.
|
||||
|
||||
describe('hidden-output restore unhide repaint fallback (STA-4869)', () => {
|
||||
installHiddenRestoreFloodSuiteHooks()
|
||||
|
||||
it('does not leave a parked repaint after a repeated episode is repaired', async () => {
|
||||
const { markTerminalFollowOutput, markTerminalPinnedViewport } =
|
||||
await import('@/lib/pane-manager/terminal-scroll-intent')
|
||||
const drive = await connectVisiblePane()
|
||||
vi.useFakeTimers()
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
const first = createDeferred<MainSnapshot>()
|
||||
getMainBufferSnapshot.mockReturnValueOnce(first.promise)
|
||||
getMainBufferSnapshot.mockResolvedValue(modelSnapshot('PARKED_EPISODE_RECOVERED', 512))
|
||||
try {
|
||||
await announceMainDrop(64, 1)
|
||||
drive.pane.terminal.buffer.active.baseY = 100
|
||||
drive.pane.terminal.buffer.active.viewportY = 42
|
||||
markTerminalPinnedViewport(drive.pane.terminal)
|
||||
await announceMainDrop(256, 2, 'unhide')
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
await flushAsyncTicks(20)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1)
|
||||
await announceMainDrop(256, 2, 'unhide')
|
||||
first.resolve(modelSnapshot('PRE_EPISODE_CAPTURE', 128))
|
||||
await flushAsyncTicks(50)
|
||||
const repairAlreadyPainted = getMainBufferSnapshot.mock.calls.length === 2
|
||||
if (repairAlreadyPainted) {
|
||||
const live = 'LIVE_AFTER_PARKED_REPAIR\r\n'
|
||||
drive.deliver(live, 512 + live.length)
|
||||
await flushAsyncTicks(10)
|
||||
}
|
||||
const writesBeforeFollowing = drive.writtenChunks().length
|
||||
drive.pane.terminal.buffer.active.viewportY = drive.pane.terminal.buffer.active.baseY
|
||||
markTerminalFollowOutput(drive.pane.terminal)
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
await flushAsyncTicks(50)
|
||||
expect.soft(getMainBufferSnapshot).toHaveBeenCalledTimes(2)
|
||||
if (repairAlreadyPainted) {
|
||||
expect(drive.writtenChunks().slice(writesBeforeFollowing).join('')).not.toContain('\x1b[2J')
|
||||
}
|
||||
} finally {
|
||||
drive.disposable.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the deferred repaint when pending live bytes overflow before the snapshot drains', async () => {
|
||||
const drive = await connectVisiblePane()
|
||||
vi.useFakeTimers()
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
const first = createDeferred<MainSnapshot>()
|
||||
getMainBufferSnapshot.mockReturnValueOnce(first.promise)
|
||||
getMainBufferSnapshot.mockResolvedValue(modelSnapshot('OVERFLOW_RECOVERED', 1_000_000))
|
||||
try {
|
||||
await announceMainDrop(64, 1)
|
||||
await announceMainDrop(64, 1)
|
||||
drive.deliver(OVERFLOW_CHUNK, 64 + OVERFLOW_CHUNK.length)
|
||||
first.resolve(modelSnapshot('PRE_OVERFLOW_ONLY', 128))
|
||||
await flushAsyncTicks(30)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
await flushAsyncTicks(30)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2)
|
||||
expect(drive.writtenChunks().join('')).toContain('OVERFLOW_RECOVERED')
|
||||
expect(drive.writtenChunks().join('')).not.toContain(BANNER_FRAGMENT)
|
||||
} finally {
|
||||
drive.disposable.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the existing repaint behavior for a pending-cap marker', async () => {
|
||||
const drive = await connectVisiblePane()
|
||||
vi.useFakeTimers()
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
const first = createDeferred<MainSnapshot>()
|
||||
getMainBufferSnapshot.mockReturnValueOnce(first.promise)
|
||||
getMainBufferSnapshot.mockResolvedValue(modelSnapshot('PENDING_CAP_RECOVERED', 512))
|
||||
try {
|
||||
await announceMainDrop(64, 1)
|
||||
await announceMainDrop(64, undefined, 'pending-cap')
|
||||
first.resolve(modelSnapshot('EARLIER_CAPTURE', 128))
|
||||
await flushAsyncTicks(30)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
await flushAsyncTicks(30)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2)
|
||||
expect(drive.writtenChunks().join('')).toContain('PENDING_CAP_RECOVERED')
|
||||
} finally {
|
||||
drive.disposable.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('fetches a fresh snapshot when hidden bytes arrived during the first fetch', async () => {
|
||||
const drive = await connectVisiblePane()
|
||||
vi.useFakeTimers()
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
const first = createDeferred<MainSnapshot>()
|
||||
const second = createDeferred<MainSnapshot>()
|
||||
getMainBufferSnapshot.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
|
||||
try {
|
||||
await announceMainDrop(64, 1)
|
||||
await announceMainDrop(64, 1)
|
||||
drive.setVisible(false)
|
||||
drive.deliver('HIDDEN_AFTER_CAPTURE\r\n', 256)
|
||||
drive.setVisible(true)
|
||||
first.resolve(modelSnapshot('FIRST_CAPTURE', 128))
|
||||
await flushAsyncTicks(30)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
second.resolve(modelSnapshot('FRESH_CAPTURE', 512))
|
||||
await flushAsyncTicks(30)
|
||||
expect(drive.writtenChunks().join('')).toContain('FRESH_CAPTURE')
|
||||
expect(drive.writtenChunks().join('')).not.toContain(BANNER_FRAGMENT)
|
||||
} finally {
|
||||
drive.disposable.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['reject', 'unavailable'] as const)(
|
||||
'keeps recovery pending after the first snapshot is %s',
|
||||
async (failure) => {
|
||||
const drive = await connectVisiblePane()
|
||||
vi.useFakeTimers()
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
const first = createDeferred<MainSnapshot | null>()
|
||||
getMainBufferSnapshot.mockReturnValueOnce(first.promise)
|
||||
getMainBufferSnapshot.mockResolvedValue(modelSnapshot('RECOVERED_AFTER_REFUSAL', 512))
|
||||
try {
|
||||
await announceMainDrop(64, 1)
|
||||
await announceMainDrop(64, 1, 'unhide')
|
||||
if (failure === 'reject') {
|
||||
first.reject(new Error('snapshot unavailable'))
|
||||
} else {
|
||||
first.resolve(null)
|
||||
}
|
||||
await flushAsyncTicks(30)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1)
|
||||
expect(window.api.pty.ackHiddenOutputRestoreApplied).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(75)
|
||||
await flushAsyncTicks(30)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2)
|
||||
expect(drive.writtenChunks().join('')).toContain('RECOVERED_AFTER_REFUSAL')
|
||||
expect(window.api.pty.ackHiddenOutputRestoreApplied).toHaveBeenCalledWith(PTY_ID, 1)
|
||||
expect(drive.writtenChunks().join('')).not.toContain(BANNER_FRAGMENT)
|
||||
} finally {
|
||||
drive.disposable.dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import './pty-connection-mocks.spec'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { flushAsyncTicks, createDeferred } from './pty-connection-test-async'
|
||||
import {
|
||||
announceMainDrop,
|
||||
connectVisiblePane,
|
||||
installHiddenRestoreFloodSuiteHooks,
|
||||
modelSnapshot,
|
||||
PTY_ID,
|
||||
type MainSnapshot
|
||||
} from './pty-connection-test-hidden-restore-flood'
|
||||
|
||||
// A restore task retired by timeout, PTY swap, or dispose must neither paint nor consume
|
||||
// the unhide recovery its successor owes the pane.
|
||||
|
||||
describe('hidden-output restore unhide with a retired task (STA-4869)', () => {
|
||||
installHiddenRestoreFloodSuiteHooks()
|
||||
|
||||
it('ignores a timed-out task without consuming the next restore request', async () => {
|
||||
const drive = await connectVisiblePane()
|
||||
vi.useFakeTimers()
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
const first = createDeferred<MainSnapshot>()
|
||||
const second = createDeferred<MainSnapshot>()
|
||||
getMainBufferSnapshot.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
|
||||
try {
|
||||
await announceMainDrop(64, 1)
|
||||
await announceMainDrop(64, 1, 'unhide')
|
||||
const waitingLive = 'LIVE_HELD_FOR_TIMEOUT\r\n'
|
||||
drive.deliver(waitingLive, 64 + waitingLive.length)
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
await flushAsyncTicks(30)
|
||||
expect(drive.writtenChunks().join('')).toContain(waitingLive)
|
||||
|
||||
await announceMainDrop(256, 2)
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
await flushAsyncTicks(30)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2)
|
||||
first.resolve(modelSnapshot('STALE_TIMED_OUT_CAPTURE', 128))
|
||||
await flushAsyncTicks(30)
|
||||
second.resolve(modelSnapshot('RECOVERED_AFTER_TIMEOUT', 512))
|
||||
await flushAsyncTicks(30)
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
await flushAsyncTicks(30)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2)
|
||||
expect(drive.writtenChunks().join('')).not.toContain('STALE_TIMED_OUT_CAPTURE')
|
||||
expect(drive.writtenChunks().join('')).toContain('RECOVERED_AFTER_TIMEOUT')
|
||||
expect(window.api.pty.ackHiddenOutputRestoreApplied).toHaveBeenCalledWith(PTY_ID, 2)
|
||||
} finally {
|
||||
drive.disposable.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('recovers a successor unhide with one quiet repaint, not a retired-task requirement too', async () => {
|
||||
const drive = await connectVisiblePane()
|
||||
vi.useFakeTimers()
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
const oldPtySnapshot = createDeferred<MainSnapshot>()
|
||||
const newPtySnapshot = createDeferred<MainSnapshot>()
|
||||
getMainBufferSnapshot
|
||||
.mockReturnValueOnce(oldPtySnapshot.promise)
|
||||
.mockReturnValueOnce(newPtySnapshot.promise)
|
||||
.mockResolvedValue(modelSnapshot('SECOND_SUCCESSOR_REPLAY', 1024))
|
||||
try {
|
||||
await announceMainDrop(64, 1)
|
||||
await announceMainDrop(64, 1, 'unhide')
|
||||
drive.transport.getPtyId.mockReturnValue('successor-pty')
|
||||
drive.setVisible(false)
|
||||
drive.deliver('NEW_PTY_OUTPUT\r\n', 32)
|
||||
drive.setVisible(true)
|
||||
await announceMainDrop(32, 2, 'hidden-drop', 'successor-pty')
|
||||
await announceMainDrop(512, 2, 'unhide', 'successor-pty')
|
||||
|
||||
oldPtySnapshot.resolve(modelSnapshot('RETIRED_PTY_CAPTURE', 2048))
|
||||
await flushAsyncTicks(30)
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
await flushAsyncTicks(30)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2)
|
||||
// Seq below the marker: only a recorded unhide requirement would force a second replay.
|
||||
newPtySnapshot.resolve(modelSnapshot('SUCCESSOR_QUIET_REPAINT', 128))
|
||||
await flushAsyncTicks(30)
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
await flushAsyncTicks(30)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2)
|
||||
const written = drive.writtenChunks().join('')
|
||||
expect(written).not.toContain('RETIRED_PTY_CAPTURE')
|
||||
expect(written).toContain('SUCCESSOR_QUIET_REPAINT')
|
||||
expect(written).not.toContain('SECOND_SUCCESSOR_REPLAY')
|
||||
expect(window.api.pty.ackHiddenOutputRestoreApplied).not.toHaveBeenCalledWith(PTY_ID, 1)
|
||||
expect(window.api.pty.ackHiddenOutputRestoreApplied).toHaveBeenCalledWith('successor-pty', 2)
|
||||
} finally {
|
||||
drive.disposable.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('recovers an unhide-only successor while a retired PTY snapshot is pending', async () => {
|
||||
const drive = await connectVisiblePane()
|
||||
vi.useFakeTimers()
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
const oldPtySnapshot = createDeferred<MainSnapshot>()
|
||||
getMainBufferSnapshot
|
||||
.mockReturnValueOnce(oldPtySnapshot.promise)
|
||||
.mockResolvedValue(modelSnapshot('SUCCESSOR_UNHIDE_RECOVERED', 1024))
|
||||
try {
|
||||
await announceMainDrop(64, 1)
|
||||
drive.transport.getPtyId.mockReturnValue('successor-pty')
|
||||
drive.setVisible(false)
|
||||
drive.deliver('NEW_PTY_OUTPUT\r\n', 32)
|
||||
drive.setVisible(true)
|
||||
await announceMainDrop(512, 2, 'unhide', 'successor-pty')
|
||||
|
||||
oldPtySnapshot.resolve(modelSnapshot('RETIRED_PTY_CAPTURE', 2048))
|
||||
await flushAsyncTicks(30)
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
await flushAsyncTicks(30)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2)
|
||||
expect(drive.writtenChunks().join('')).not.toContain('RETIRED_PTY_CAPTURE')
|
||||
expect(drive.writtenChunks().join('')).toContain('SUCCESSOR_UNHIDE_RECOVERED')
|
||||
expect(window.api.pty.ackHiddenOutputRestoreApplied).not.toHaveBeenCalledWith(PTY_ID, 1)
|
||||
expect(window.api.pty.ackHiddenOutputRestoreApplied).toHaveBeenCalledWith('successor-pty', 2)
|
||||
} finally {
|
||||
drive.disposable.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('cannot replay or acknowledge an unhide after its pane is disposed', async () => {
|
||||
const drive = await connectVisiblePane()
|
||||
vi.useFakeTimers()
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
const first = createDeferred<MainSnapshot>()
|
||||
getMainBufferSnapshot.mockReturnValueOnce(first.promise)
|
||||
await announceMainDrop(64, 1)
|
||||
await announceMainDrop(64, 1, 'unhide')
|
||||
drive.disposable.dispose()
|
||||
const writesBeforeDisposal = drive.writtenChunks().slice()
|
||||
first.resolve(modelSnapshot('DISPOSED_PANE_CAPTURE', 128))
|
||||
await flushAsyncTicks(30)
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
await flushAsyncTicks(30)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1)
|
||||
expect(drive.writtenChunks()).toEqual(writesBeforeDisposal)
|
||||
expect(window.api.pty.ackHiddenOutputRestoreApplied).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec'
|
||||
import { afterEach, beforeEach, expect, vi } from 'vitest'
|
||||
import { flushAsyncTicks } from './pty-connection-test-async'
|
||||
import {
|
||||
createMockTransport,
|
||||
createPane,
|
||||
createManager,
|
||||
type ConnectCallbacks,
|
||||
type MockPane,
|
||||
type MockTransport
|
||||
} from './pty-connection-test-pane-fixtures'
|
||||
import { buildPaneConnectionDeps, type PaneConnectionDeps } from './pty-connection-test-deps'
|
||||
import { createInitialStoreState } from './pty-connection-test-store-fixtures'
|
||||
import type { StoreState } from './pty-connection-test-store-state'
|
||||
import {
|
||||
installTerminalTestGlobals,
|
||||
restoreTerminalTestGlobals
|
||||
} from './pty-connection-test-environment'
|
||||
import { connectMockPanePty } from './pty-connection-test-connect-mock-pane'
|
||||
import type { PtyModelRestoreReason } from '../../../../shared/pty-model-restore-marker'
|
||||
|
||||
// Shared visible-pane driver for the hidden-restore flood/unhide suites (STA-4869).
|
||||
|
||||
let mockStoreState: StoreState
|
||||
let transportFactoryQueue: MockTransport[] = []
|
||||
let createdTransportOptions: Record<string, unknown>[] = []
|
||||
let storeSubscribers: ((state: StoreState) => void)[] = []
|
||||
|
||||
export const PTY_ID = 'pty-id'
|
||||
export const BANNER_FRAGMENT = 'main recovery was unavailable'
|
||||
// One char over HIDDEN_OUTPUT_RESTORE_PENDING_CHARS (512KB): the queue discards
|
||||
// its whole backlog and latches overflow.
|
||||
export const OVERFLOW_CHUNK = 'o'.repeat(512 * 1024 + 1)
|
||||
|
||||
export type MainSnapshot = {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq: number
|
||||
}
|
||||
|
||||
export function modelSnapshot(marker: string, seq: number): MainSnapshot {
|
||||
return { data: `${marker}\r\n`, cols: 120, rows: 40, seq }
|
||||
}
|
||||
|
||||
export type PaneDrive = {
|
||||
pane: MockPane
|
||||
transport: MockTransport
|
||||
deps: PaneConnectionDeps
|
||||
disposable: { dispose: () => void }
|
||||
deliver: (data: string, seq: number) => void
|
||||
setVisible: (visible: boolean) => void
|
||||
writtenChunks: () => string[]
|
||||
}
|
||||
|
||||
export function installHiddenRestoreFloodSuiteHooks(): void {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
transportFactoryQueue = []
|
||||
createdTransportOptions = []
|
||||
storeSubscribers = []
|
||||
mockStoreState = createInitialStoreState(() => mockStoreState)
|
||||
bindPtyConnectionTestMocks({
|
||||
getState: () => mockStoreState,
|
||||
getTransportQueue: () => transportFactoryQueue,
|
||||
getTransportOptions: () => createdTransportOptions,
|
||||
getStoreSubscribers: () => storeSubscribers,
|
||||
setStoreSubscribers: (subscribers) => {
|
||||
storeSubscribers = subscribers
|
||||
}
|
||||
})
|
||||
await installTerminalTestGlobals()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await restoreTerminalTestGlobals()
|
||||
})
|
||||
}
|
||||
|
||||
export async function connectVisiblePane(): Promise<PaneDrive> {
|
||||
const transport = createMockTransport(PTY_ID)
|
||||
const captured: {
|
||||
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
|
||||
} = { current: null }
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks?: ConnectCallbacks }) => {
|
||||
captured.current = callbacks?.onData ?? null
|
||||
return PTY_ID
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
const pane = createPane(1)
|
||||
const deps = buildPaneConnectionDeps(() => mockStoreState, {
|
||||
isVisibleRef: { current: true }
|
||||
})
|
||||
const disposable = await connectMockPanePty(pane, createManager(1), deps)
|
||||
await flushAsyncTicks(6)
|
||||
expect(captured.current).not.toBeNull()
|
||||
return {
|
||||
pane,
|
||||
transport,
|
||||
deps,
|
||||
disposable,
|
||||
deliver: (data, seq) => captured.current?.(data, { seq, rawLength: data.length }),
|
||||
setVisible: (visible) => {
|
||||
deps.isVisibleRef.current = visible
|
||||
},
|
||||
writtenChunks: () => pane.terminal.write.mock.calls.map(([data]) => String(data))
|
||||
}
|
||||
}
|
||||
|
||||
export async function announceMainDrop(
|
||||
markerSeq: number | undefined,
|
||||
hiddenDropEpisode?: number,
|
||||
reason: PtyModelRestoreReason = 'hidden-drop',
|
||||
ptyId = PTY_ID
|
||||
): Promise<void> {
|
||||
const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel')
|
||||
_dispatchPtyModelRestoreNeededForTest({
|
||||
id: ptyId,
|
||||
reason,
|
||||
...(markerSeq !== undefined ? { markerSeq } : {}),
|
||||
...(hiddenDropEpisode !== undefined ? { hiddenDropEpisode } : {})
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export type ConnectPanePtySession = {
|
||||
deps: PtyConnectionDeps
|
||||
// Why typed: every PTY write must name its input kind, and the bag's `any` would hide a missing one.
|
||||
transport: PtyTransport
|
||||
hiddenOutputRestoreUnhideSeq: number | null
|
||||
// oxlint-disable-next-line typescript/no-explicit-any -- session bag for mechanical extract
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
+4
-3
@@ -1,3 +1,4 @@
|
||||
import * as unhideCoverage from './hidden-unhide-snapshot-coverage'
|
||||
import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime-pty-id'
|
||||
import { recordTerminalFreezeBreadcrumb } from '../terminal-freeze-breadcrumbs'
|
||||
import { redactPtyIdForDiagnostics } from '../../../../../shared/pty-delivery-diagnostics'
|
||||
@@ -36,8 +37,7 @@ import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
export function bindForegroundOutputRefresh(session: ConnectPanePtySession): void {
|
||||
// Extends the suppression window; every backpressure signal resets the timer so the deferred repaint fires once, SUPPRESS_MS after the last signal.
|
||||
session.noteHiddenOutputRestoreFloodBackpressure = function (): void {
|
||||
session.hiddenOutputRestoreFloodSuppressedUntil =
|
||||
Date.now() + HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS
|
||||
session.extendHiddenOutputRestoreFloodSuppression()
|
||||
const ptyId = session.transport.getPtyId()
|
||||
if (ptyId === null) {
|
||||
return
|
||||
@@ -59,6 +59,7 @@ export function bindForegroundOutputRefresh(session: ConnectPanePtySession): voi
|
||||
return
|
||||
}
|
||||
const episode = typeof event.hiddenDropEpisode === 'number' ? event.hiddenDropEpisode : null
|
||||
const previousDropEpisode = session.hiddenOutputRestoreLatestDropEpisode
|
||||
// Why: main's latch stays armed until a restore paints, so a gap it can never serve re-marks
|
||||
// on every unhide. A repeat of the disclosed episode is that same gap; any other marker may
|
||||
// be new loss and proceeds, failing toward recovery.
|
||||
@@ -81,7 +82,7 @@ export function bindForegroundOutputRefresh(session: ConnectPanePtySession): voi
|
||||
session.transport.resetCrossChunkParserState?.()
|
||||
// Why gated (rc.7.perf loop): on a visible pane these markers come from our own restore starving ACKs; re-arming per marker kept the fetch loop alive all flood, so defer to one post-flood repaint.
|
||||
if (session.isForegroundRestoreBackpressureContext()) {
|
||||
session.noteHiddenOutputRestoreFloodBackpressure()
|
||||
unhideCoverage.handleUnhideRestoreBackpressure(session, event, previousDropEpisode)
|
||||
return
|
||||
}
|
||||
// Why the emulator too: it carries state across chunks exactly like the
|
||||
|
||||
+1
@@ -40,6 +40,7 @@ export function bindAbandonHiddenOutputRestore(session: ConnectPanePtySession):
|
||||
const hadPendingOverflow = session.hiddenOutputRestorePendingOverflow
|
||||
const replayingSnapshot = session.hiddenOutputRestoreReplayingSnapshot
|
||||
session.hiddenOutputRestoreReplayingSnapshot = null
|
||||
session.hiddenOutputRestoreUnhideSeq = null
|
||||
session.hiddenOutputRestoreGeneration += 1
|
||||
if (
|
||||
session.hiddenOutputSnapshotScrollRestore?.valid &&
|
||||
|
||||
+3
-3
@@ -1,3 +1,4 @@
|
||||
import * as unhideCoverage from './hidden-unhide-snapshot-coverage'
|
||||
import { useAppStore } from '@/store'
|
||||
import { isTerminalWritePipelineCertifiedDead } from '@/lib/pane-manager/terminal-write-pipeline-health'
|
||||
import { requestTerminalPaneRecovery } from '../terminal-pane-recovery'
|
||||
@@ -226,9 +227,7 @@ export function bindHiddenOutputRestoreRequest(session: ConnectPanePtySession):
|
||||
restoredSnapshotPaintsPrintableContent(snapshot)
|
||||
)
|
||||
session.ackHiddenOutputRestoreApplied(currentPtyId, coveredDropEpisode)
|
||||
session.hiddenOutputRestoreReplayingSnapshot = null
|
||||
const needsFreshSnapshot = session.hiddenOutputRestoreFreshSnapshotNeeded
|
||||
session.hiddenOutputRestoreFreshSnapshotNeeded = false
|
||||
const needsFreshSnapshot = unhideCoverage.consumeSnapshotRequirement(session, snapshot.seq)
|
||||
const drainOutcome = session.drainPendingLiveChunksAfterSnapshot(snapshot.seq)
|
||||
if (drainOutcome === 'drained' && !needsFreshSnapshot) {
|
||||
session.hiddenOutputRestoreNeeded = false
|
||||
@@ -267,6 +266,7 @@ export function bindHiddenOutputRestoreRequest(session: ConnectPanePtySession):
|
||||
const hiddenOutputRestoreTask = session.hiddenOutputRestoreInFlight
|
||||
let trackedHiddenOutputRestore: Promise<void>
|
||||
trackedHiddenOutputRestore = hiddenOutputRestoreTask.finally(() => {
|
||||
unhideCoverage.repairUnhandledMarker(session, trackedHiddenOutputRestore, ptyId)
|
||||
if (session.hiddenOutputRestoreInFlight === trackedHiddenOutputRestore) {
|
||||
session.hiddenOutputRestoreInFlight = null
|
||||
}
|
||||
|
||||
+6
@@ -3,6 +3,7 @@ import { isHostAnsweredSnapshotRetryCause } from '@/runtime/remote-runtime-termi
|
||||
import { onTerminalScrollIntentFollowOutput } from '@/lib/pane-manager/terminal-scroll-intent'
|
||||
|
||||
import { shouldWritePtyOutputForeground } from './foreground-output-scan'
|
||||
import { HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS } from './hidden-output-restore-limits'
|
||||
import { readE2eHiddenSnapshotOverride } from './e2e-terminal-pty-harness'
|
||||
|
||||
import type { PtyBufferSnapshot } from '../pty-transport-types'
|
||||
@@ -152,6 +153,11 @@ export function bindSerializeHiddenOutputSnapshot(session: ConnectPanePtySession
|
||||
}
|
||||
}
|
||||
|
||||
session.extendHiddenOutputRestoreFloodSuppression = function (): void {
|
||||
session.hiddenOutputRestoreFloodSuppressedUntil =
|
||||
Date.now() + HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS
|
||||
}
|
||||
|
||||
session.resetHiddenOutputRestoreFloodSuppression = function (): void {
|
||||
session.hiddenOutputRestoreFloodSuppressedUntil = 0
|
||||
session.clearHiddenOutputRestoreFloodRepaintTimer()
|
||||
|
||||
@@ -61,6 +61,7 @@ export function bindHiddenRestoreState(session: ConnectPanePtySession): void {
|
||||
session.hiddenOutputRestoreNeeded = false
|
||||
session.hiddenOutputRestorePtyId = null
|
||||
session.hiddenOutputRestoreReplayingSnapshot = null
|
||||
session.hiddenOutputRestoreUnhideSeq = null
|
||||
session.hiddenOutputRestoreGeneration += 1
|
||||
}
|
||||
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import type { PtyModelRestoreNeededEvent } from '../../../../../shared/pty-model-restore-marker'
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function handleUnhideRestoreBackpressure(
|
||||
session: ConnectPanePtySession,
|
||||
event: PtyModelRestoreNeededEvent,
|
||||
previousDropEpisode: number | null
|
||||
): void {
|
||||
if (
|
||||
event.reason !== 'unhide' ||
|
||||
session.hiddenOutputRestoreInFlight === null ||
|
||||
// An in-flight restore for another PTY can't cover this marker; the quiet repaint is its only recovery.
|
||||
session.hiddenOutputRestorePtyId !== event.id ||
|
||||
typeof event.markerSeq !== 'number' ||
|
||||
!Number.isFinite(event.markerSeq)
|
||||
) {
|
||||
if (session.hiddenOutputRestorePtyId === event.id) {
|
||||
session.hiddenOutputRestoreUnhideSeq = null
|
||||
}
|
||||
session.noteHiddenOutputRestoreFloodBackpressure()
|
||||
return
|
||||
}
|
||||
if (
|
||||
previousDropEpisode === null ||
|
||||
!Number.isFinite(previousDropEpisode) ||
|
||||
previousDropEpisode !== event.hiddenDropEpisode ||
|
||||
session.hiddenOutputRestoreFloodRepaintTimer !== null ||
|
||||
session.cancelHiddenOutputRestoreFloodRepaintPark !== null
|
||||
) {
|
||||
// Leave recovery to the quiet timer when this snapshot cannot retire the episode.
|
||||
session.hiddenOutputRestoreUnhideSeq = null
|
||||
session.noteHiddenOutputRestoreFloodBackpressure()
|
||||
return
|
||||
}
|
||||
// A current or successor restore must cover the retained marker before finishing.
|
||||
session.hiddenOutputRestoreUnhideSeq = Math.max(
|
||||
session.hiddenOutputRestoreUnhideSeq ?? 0,
|
||||
event.markerSeq
|
||||
)
|
||||
// Still backpressure: keep later markers gated, but the covering restore replaces the quiet repaint.
|
||||
session.extendHiddenOutputRestoreFloodSuppression()
|
||||
}
|
||||
|
||||
export function consumeSnapshotRequirement(
|
||||
session: ConnectPanePtySession,
|
||||
snapshotSeq: number | undefined
|
||||
): boolean {
|
||||
const unhideSeq = session.hiddenOutputRestoreUnhideSeq
|
||||
const freshSnapshotNeeded = session.hiddenOutputRestoreFreshSnapshotNeeded
|
||||
const uncoveredUnhide =
|
||||
typeof unhideSeq === 'number' &&
|
||||
(typeof snapshotSeq !== 'number' || !Number.isFinite(snapshotSeq) || snapshotSeq < unhideSeq)
|
||||
session.hiddenOutputRestoreReplayingSnapshot = null
|
||||
session.hiddenOutputRestoreUnhideSeq = uncoveredUnhide ? unhideSeq : null
|
||||
session.hiddenOutputRestoreFreshSnapshotNeeded = false
|
||||
return freshSnapshotNeeded || uncoveredUnhide
|
||||
}
|
||||
|
||||
export function repairUnhandledMarker(
|
||||
session: ConnectPanePtySession,
|
||||
task: Promise<void>,
|
||||
ptyId: string
|
||||
): void {
|
||||
if (
|
||||
session.hiddenOutputRestoreInFlight === task &&
|
||||
!session.disposed &&
|
||||
session.transport.getPtyId() === ptyId &&
|
||||
typeof session.hiddenOutputRestoreUnhideSeq === 'number' &&
|
||||
!session.hiddenOutputRestoreNeeded &&
|
||||
!session.hiddenOutputRestoreRetryDeferred
|
||||
) {
|
||||
session.hiddenOutputRestoreUnhideSeq = null
|
||||
session.noteHiddenOutputRestoreFloodBackpressure()
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,7 @@ export function bindCaptureTransportOutputCallbacks(session: ConnectPanePtySessi
|
||||
// Latest hidden-drop episode main reported, and the one a banner already disclosed:
|
||||
// main's drop latch outlives unhide, so an unservable gap re-marks on every reveal.
|
||||
session.hiddenOutputRestoreLatestDropEpisode = null
|
||||
session.hiddenOutputRestoreUnhideSeq = null
|
||||
session.hiddenOutputRestoreDisclosedGap = null
|
||||
session.hiddenOutputSnapshotScrollRestore = null
|
||||
// Why: hidden recovery state belongs to one PTY stream. Reattach/restart
|
||||
|
||||
Reference in New Issue
Block a user