mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
test(terminal): pin the hidden-output restore loss invariant (STA-4869)
Three suites cover every route where a hidden pane's withheld bytes can end up neither reconciled nor disclosed. Each traces a marker that exists only in the authority buffer and asserts it is written exactly once OR the loss banner appears. Three of these fail at this commit, deliberately: the loss-disclosure suite's two [invariant] cases (host retry budget and permanently-unavailable answers landing while the pane is hidden) and the marker-handoff [invariant] case (a retiring hidden pane spending main's drop latch on a marker no handler receives). The fix lands in the next commit; keeping them red here proves the suites are not vacuous. The remaining routes (flood-abandon overflow, iteration cap, local deferred retry) are green controls: they are unreachable while hidden and self-heal. Refs STA-4869
This commit is contained in:
+351
@@ -0,0 +1,351 @@
|
||||
import type * as React from 'react'
|
||||
import { afterEach, beforeEach, 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'
|
||||
|
||||
// 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
|
||||
// banner for one deferred post-flood repaint. Same invariant as the non-quiet
|
||||
// routes: every retained byte is reconciled exactly once, or the loss is
|
||||
// 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(),
|
||||
warnTerminalLifecycleAnomaly: vi.fn()
|
||||
}))
|
||||
|
||||
// The iteration-cap warning is the only externally visible proof that R4's
|
||||
// backstop fired rather than the loop finishing on its own.
|
||||
vi.mock('./terminal-lifecycle-diagnostics', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
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 { connectPanePty } = await import('./pty-connection')
|
||||
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 = connectPanePty(pane as never, createManager(1) as never, deps as never)
|
||||
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 as { current: boolean }).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
|
||||
}
|
||||
|
||||
type Disclosure = {
|
||||
bannerWrites: number
|
||||
markerWrites: number
|
||||
disclosedOrRecovered: boolean
|
||||
}
|
||||
|
||||
function observeDisclosure(drive: PaneDrive, marker: string): Disclosure {
|
||||
const written = drive.writtenChunks()
|
||||
const bannerWrites = written.filter((data) => data.includes(BANNER_FRAGMENT)).length
|
||||
const markerWrites = countOccurrences(written.join(''), marker)
|
||||
return {
|
||||
bannerWrites,
|
||||
markerWrites,
|
||||
disclosedOrRecovered: bannerWrites > 0 || markerWrites === 1
|
||||
}
|
||||
}
|
||||
|
||||
function expectDisclosedOrRecovered(disclosure: Disclosure): void {
|
||||
expect(
|
||||
disclosure.disclosedOrRecovered,
|
||||
`hidden output was neither reconciled nor disclosed: ${JSON.stringify(disclosure)}`
|
||||
).toBe(true)
|
||||
}
|
||||
|
||||
describe('hidden-output restore quiet abandonment (STA-4869)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
transportFactoryQueue = []
|
||||
createdTransportOptions = []
|
||||
storeSubscribers = []
|
||||
mockStoreState = createInitialStoreState(() => mockStoreState)
|
||||
installTerminalTestGlobals()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await restoreTerminalTestGlobals()
|
||||
})
|
||||
|
||||
// ── R3: foreground pending-queue overflow ───────────────────────────────
|
||||
describe('R3 pending-queue overflow', () => {
|
||||
/** Drives one restore into a 512KB queue overflow and returns once the
|
||||
* quiet abandon has run. */
|
||||
async function overflowRestoreQueue(drive: PaneDrive): Promise<void> {
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
const firstSnapshot = createDeferred<MainSnapshot>()
|
||||
getMainBufferSnapshot.mockReturnValueOnce(firstSnapshot.promise as never)
|
||||
await announceMainDrop(64)
|
||||
await flushAsyncTicks(20)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The live stream outruns fetch+replay and blows the queue cap.
|
||||
drive.deliver(OVERFLOW_CHUNK, 64 + OVERFLOW_CHUNK.length)
|
||||
drive.deliver(OVERFLOW_CHUNK, 64 + 2 * OVERFLOW_CHUNK.length)
|
||||
// Resolve inside the 750ms foreground deadline so the drain, not the
|
||||
// deadline timer, owns the abandonment.
|
||||
firstSnapshot.resolve(modelSnapshot('pre-overflow-model', 64))
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
// Proof the overflow arm ran: the snapshot replayed, and the queued
|
||||
// backlog was discarded rather than drained under it.
|
||||
const written = drive.writtenChunks().join('')
|
||||
expect(written).toContain('pre-overflow-model')
|
||||
expect(written).not.toContain('o'.repeat(1024))
|
||||
}
|
||||
|
||||
it('[invariant] reconciles the retained bytes through the deferred post-flood repaint', async () => {
|
||||
const drive = await connectVisiblePane()
|
||||
vi.useFakeTimers()
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
await overflowRestoreQueue(drive)
|
||||
|
||||
expect(drive.writtenChunks().join('')).not.toContain(BANNER_FRAGMENT)
|
||||
getMainBufferSnapshot.mockResolvedValue(modelSnapshot(R3_MARKER, 4096) as never)
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
const disclosure = observeDisclosure(drive, R3_MARKER)
|
||||
expect(disclosure.markerWrites).toBeLessThanOrEqual(1)
|
||||
expectDisclosedOrRecovered(disclosure)
|
||||
drive.disposable.dispose()
|
||||
})
|
||||
|
||||
it('[invariant] keeps recovery armed while the user reads scrollback', async () => {
|
||||
const { markTerminalFollowOutput, markTerminalPinnedViewport } =
|
||||
await import('@/lib/pane-manager/terminal-scroll-intent')
|
||||
const drive = await connectVisiblePane()
|
||||
drive.pane.terminal.buffer.active.baseY = 100
|
||||
drive.pane.terminal.buffer.active.viewportY = 42
|
||||
markTerminalPinnedViewport(drive.pane.terminal as never)
|
||||
vi.useFakeTimers()
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
await overflowRestoreQueue(drive)
|
||||
|
||||
getMainBufferSnapshot.mockResolvedValue(modelSnapshot(R4_MARKER, 4096) as never)
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
await flushAsyncTicks(20)
|
||||
// Parked, not abandoned: the repaint must not yank the viewport, and must
|
||||
// not claim a loss it can still heal.
|
||||
expect(drive.writtenChunks().join('')).not.toContain(BANNER_FRAGMENT)
|
||||
expect(drive.pane.terminal.buffer.active.viewportY).toBe(42)
|
||||
|
||||
// A background stint must not consume the parked recovery either.
|
||||
drive.setVisible(false)
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
drive.setVisible(true)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
drive.pane.terminal.buffer.active.viewportY = drive.pane.terminal.buffer.active.baseY
|
||||
markTerminalFollowOutput(drive.pane.terminal as never)
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
const disclosure = observeDisclosure(drive, R4_MARKER)
|
||||
expect(disclosure.markerWrites).toBeLessThanOrEqual(1)
|
||||
expectDisclosedOrRecovered(disclosure)
|
||||
drive.disposable.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
// ── R4: restore loop iteration cap ──────────────────────────────────────
|
||||
describe('R4 loop iteration cap', () => {
|
||||
it('[invariant] reconciles the retained bytes after the iteration backstop fires', async () => {
|
||||
const drive = await connectVisiblePane()
|
||||
vi.useFakeTimers()
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
const rounds = [0, 1, 2].map(() => createDeferred<MainSnapshot>())
|
||||
for (const round of rounds) {
|
||||
getMainBufferSnapshot.mockReturnValueOnce(round.promise as never)
|
||||
}
|
||||
await announceMainDrop(64)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
// Each round: the pane goes background long enough for one chunk to arrive
|
||||
// with a restore in flight, which marks the in-flight snapshot stale and
|
||||
// sends the loop around again.
|
||||
for (const [index, round] of rounds.entries()) {
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(index + 1)
|
||||
drive.setVisible(false)
|
||||
drive.deliver(`stale-round-${index}\r\n`, 128 + index * 32)
|
||||
drive.setVisible(true)
|
||||
round.resolve(modelSnapshot(`round-${index}-model`, 64 + index))
|
||||
await flushAsyncTicks(20)
|
||||
}
|
||||
|
||||
// The backstop cut the loop without a banner; the deferred repaint owes
|
||||
// the pane its bytes.
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(3)
|
||||
expect(warnTerminalLifecycleAnomaly).toHaveBeenCalledWith(
|
||||
'hidden output restore hit its iteration cap',
|
||||
expect.objectContaining({ ptyId: PTY_ID })
|
||||
)
|
||||
expect(drive.writtenChunks().join('')).not.toContain(BANNER_FRAGMENT)
|
||||
getMainBufferSnapshot.mockResolvedValue(modelSnapshot(R4_MARKER, 8192) as never)
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
const disclosure = observeDisclosure(drive, R4_MARKER)
|
||||
expect(disclosure.markerWrites).toBeLessThanOrEqual(1)
|
||||
expectDisclosedOrRecovered(disclosure)
|
||||
drive.disposable.dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
+471
@@ -0,0 +1,471 @@
|
||||
import type * as React from 'react'
|
||||
import { afterEach, beforeEach, 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'
|
||||
|
||||
// STA-4869 — routes R1 (host retry budget), R2 (permanently-unavailable) and R5
|
||||
// (local deferred-retry exhaustion) all end in the NON-QUIET abandon at
|
||||
// pty-connection.ts:7106, whose only loss disclosure is
|
||||
// writeRestoreUnavailableWarning (7302). That function early-returns the banner
|
||||
// when the pane is not foreground, and the abandon has already zeroed
|
||||
// hiddenOutputRestoreNeeded/PtyId and cleared the flood repaint timer.
|
||||
//
|
||||
// Invariant asserted here: when renderer delivery was withheld while hidden,
|
||||
// reveal must either reconcile every retained byte exactly once or leave a
|
||||
// durable loss banner on the pane. Never neither.
|
||||
|
||||
const {
|
||||
resetAndRefreshAllTerminalWebglAtlases,
|
||||
scheduleTerminalWebglAtlasRecovery,
|
||||
scheduleRuntimeGraphSync,
|
||||
shouldSeedCacheTimerOnInitialTitle,
|
||||
toastInfo,
|
||||
notifyCodexPaneBoundForStaleSweep
|
||||
} = vi.hoisted(() => ({
|
||||
resetAndRefreshAllTerminalWebglAtlases: vi.fn(),
|
||||
scheduleTerminalWebglAtlasRecovery: vi.fn(),
|
||||
scheduleRuntimeGraphSync: vi.fn(),
|
||||
shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false),
|
||||
toastInfo: vi.fn(),
|
||||
notifyCodexPaneBoundForStaleSweep: vi.fn()
|
||||
}))
|
||||
|
||||
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 REMOTE_PTY_ID = 'remote:env-1@@terminal-sta4869'
|
||||
const LOCAL_PTY_ID = 'pty-id'
|
||||
// Overruns the renderer's 2MB hidden background queue: the bytes exist only in
|
||||
// the authority's buffer afterwards, which is what reveal has to reconcile.
|
||||
const HIDDEN_FLOOD = 'x'.repeat(2 * 1024 * 1024 + 1)
|
||||
const LIVE_CHUNK = 'live-after-reveal\r\n'
|
||||
const BANNER_FRAGMENT = 'main recovery was unavailable'
|
||||
const R1_MARKER = 'R1-LOST-7f3a'
|
||||
const R2_MARKER = 'R2-LOST-91c4'
|
||||
const R5_MARKER = 'R5-LOST-b28e'
|
||||
|
||||
type RetainedSnapshot = {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq: number
|
||||
}
|
||||
|
||||
/** The authority's retained content — the only carrier of the dropped bytes. */
|
||||
function retainedSnapshot(marker: string): RetainedSnapshot {
|
||||
return {
|
||||
data: `${marker}\r\n`,
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
seq: HIDDEN_FLOOD.length + LIVE_CHUNK.length
|
||||
}
|
||||
}
|
||||
|
||||
function hostAnswers(snapshot: RetainedSnapshot): unknown {
|
||||
return { availability: { kind: 'snapshot' }, snapshot }
|
||||
}
|
||||
|
||||
type PaneDrive = {
|
||||
pane: MockPane
|
||||
deps: PaneConnectionDeps
|
||||
disposable: { dispose: () => void }
|
||||
deliver: (data: string, seq: number) => void
|
||||
setVisible: (visible: boolean) => void
|
||||
writtenChunks: () => string[]
|
||||
}
|
||||
|
||||
async function connectHiddenPane(
|
||||
ptyId: string,
|
||||
configureTransport: (transport: MockTransport) => void
|
||||
): Promise<PaneDrive> {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport(ptyId)
|
||||
configureTransport(transport)
|
||||
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 ptyId
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
const pane = createPane(1)
|
||||
const deps = buildPaneConnectionDeps(() => mockStoreState, {
|
||||
isVisibleRef: { current: false }
|
||||
})
|
||||
const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never)
|
||||
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 as { current: boolean }).current = visible
|
||||
},
|
||||
writtenChunks: () => pane.terminal.write.mock.calls.map(([data]) => String(data))
|
||||
}
|
||||
}
|
||||
|
||||
/** Hidden-time bytes are dropped, then the pane is revealed with live output
|
||||
* still streaming — the reveal path the gate's recovery loop owns. */
|
||||
function floodWhileHiddenThenReveal(drive: PaneDrive): void {
|
||||
drive.deliver(HIDDEN_FLOOD, HIDDEN_FLOOD.length)
|
||||
drive.setVisible(true)
|
||||
drive.deliver(LIVE_CHUNK, HIDDEN_FLOOD.length + LIVE_CHUNK.length)
|
||||
}
|
||||
|
||||
async function revealPane(drive: PaneDrive): Promise<void> {
|
||||
drive.setVisible(true)
|
||||
const { requestTerminalBacklogRecovery } =
|
||||
await import('@/lib/pane-manager/pane-terminal-output-scheduler')
|
||||
requestTerminalBacklogRecovery(drive.pane.terminal as never)
|
||||
await flushAsyncTicks(20)
|
||||
}
|
||||
|
||||
function countOccurrences(haystack: string, needle: string): number {
|
||||
return haystack.split(needle).length - 1
|
||||
}
|
||||
|
||||
type Disclosure = {
|
||||
bannerWrites: number
|
||||
markerWrites: number
|
||||
disclosedOrRecovered: boolean
|
||||
}
|
||||
|
||||
function observeDisclosure(drive: PaneDrive, marker: string): Disclosure {
|
||||
const written = drive.writtenChunks()
|
||||
const bannerWrites = written.filter((data) => data.includes(BANNER_FRAGMENT)).length
|
||||
const markerWrites = countOccurrences(written.join(''), marker)
|
||||
return {
|
||||
bannerWrites,
|
||||
markerWrites,
|
||||
disclosedOrRecovered: bannerWrites > 0 || markerWrites === 1
|
||||
}
|
||||
}
|
||||
|
||||
function expectDisclosedOrRecovered(disclosure: Disclosure): void {
|
||||
expect(
|
||||
disclosure.disclosedOrRecovered,
|
||||
`hidden output was neither reconciled nor disclosed: ${JSON.stringify(disclosure)}`
|
||||
).toBe(true)
|
||||
}
|
||||
|
||||
/** Long enough for every deferred repaint, re-arm cycle and retry tick. */
|
||||
async function allowRecoveryWindow(): Promise<void> {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
await flushAsyncTicks(20)
|
||||
}
|
||||
|
||||
describe('hidden-output restore loss disclosure (STA-4869)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
transportFactoryQueue = []
|
||||
createdTransportOptions = []
|
||||
storeSubscribers = []
|
||||
mockStoreState = createInitialStoreState(() => mockStoreState)
|
||||
installTerminalTestGlobals()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await restoreTerminalTestGlobals()
|
||||
})
|
||||
|
||||
// ── R1: host retry-worthy budget (7 answers) ────────────────────────────
|
||||
describe('R1 host retry budget exhaustion', () => {
|
||||
const retryWorthy = {
|
||||
availability: { kind: 'retry-worthy', cause: 'host-pending-output-overflowed' },
|
||||
snapshot: null
|
||||
}
|
||||
|
||||
async function driveToSeventhAnswer(
|
||||
serializeBufferOutcome: ReturnType<typeof vi.fn>
|
||||
): Promise<PaneDrive> {
|
||||
const drive = await connectHiddenPane(REMOTE_PTY_ID, (transport) => {
|
||||
transport.serializeBuffer = vi.fn()
|
||||
transport.serializeBufferOutcome = serializeBufferOutcome
|
||||
})
|
||||
vi.useFakeTimers()
|
||||
floodWhileHiddenThenReveal(drive)
|
||||
await flushAsyncTicks(20)
|
||||
expect(serializeBufferOutcome).toHaveBeenCalledTimes(1)
|
||||
// Each retry-worthy answer quietly re-arms one post-suppression repaint.
|
||||
for (let request = 2; request <= 6; request += 1) {
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
await flushAsyncTicks(20)
|
||||
expect(serializeBufferOutcome).toHaveBeenCalledTimes(request)
|
||||
}
|
||||
expect(drive.writtenChunks().join('')).not.toContain(BANNER_FRAGMENT)
|
||||
return drive
|
||||
}
|
||||
|
||||
it('[control] discloses the loss when the budget ends while the pane is visible', async () => {
|
||||
const serializeBufferOutcome = vi.fn().mockResolvedValue(retryWorthy)
|
||||
const drive = await driveToSeventhAnswer(serializeBufferOutcome)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
await flushAsyncTicks(20)
|
||||
serializeBufferOutcome.mockResolvedValue(hostAnswers(retainedSnapshot(R1_MARKER)))
|
||||
await allowRecoveryWindow()
|
||||
|
||||
expect(observeDisclosure(drive, R1_MARKER)).toMatchObject({ disclosedOrRecovered: true })
|
||||
drive.disposable.dispose()
|
||||
})
|
||||
|
||||
it('[invariant] discloses or recovers when the budget ends while the pane is hidden', async () => {
|
||||
const seventhAnswer = createDeferred<unknown>()
|
||||
const serializeBufferOutcome = vi.fn().mockResolvedValue(retryWorthy)
|
||||
const drive = await driveToSeventhAnswer(serializeBufferOutcome)
|
||||
|
||||
serializeBufferOutcome.mockReturnValueOnce(seventhAnswer.promise)
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
await flushAsyncTicks(20)
|
||||
expect(serializeBufferOutcome).toHaveBeenCalledTimes(7)
|
||||
|
||||
// The user switches tabs while the seventh request is still outstanding;
|
||||
// the restore loop keeps running across the await.
|
||||
drive.setVisible(false)
|
||||
seventhAnswer.resolve(retryWorthy)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
// The host is healthy again and still holds the dropped bytes.
|
||||
serializeBufferOutcome.mockResolvedValue(hostAnswers(retainedSnapshot(R1_MARKER)))
|
||||
const requestsBeforeReveal = serializeBufferOutcome.mock.calls.length
|
||||
await revealPane(drive)
|
||||
await allowRecoveryWindow()
|
||||
|
||||
const disclosure = observeDisclosure(drive, R1_MARKER)
|
||||
expect(disclosure.markerWrites).toBeLessThanOrEqual(1)
|
||||
expectDisclosedOrRecovered(disclosure)
|
||||
// Whichever way the gap is closed, silence is not one of them: recovering
|
||||
// it costs a fresh request, disclosing it costs a banner.
|
||||
expect(
|
||||
serializeBufferOutcome.mock.calls.length - requestsBeforeReveal
|
||||
).toBeGreaterThanOrEqual(disclosure.bannerWrites > 0 ? 0 : 1)
|
||||
drive.disposable.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
// ── R2: permanently-unavailable ─────────────────────────────────────────
|
||||
describe('R2 permanently-unavailable answer', () => {
|
||||
const permanentlyUnavailable = {
|
||||
availability: { kind: 'permanently-unavailable', reason: 'exceeds-client-replay-limit' },
|
||||
snapshot: null
|
||||
}
|
||||
|
||||
it('[control] banners once when the answer lands while the pane is visible', async () => {
|
||||
const serializeBufferOutcome = vi.fn().mockResolvedValue(permanentlyUnavailable)
|
||||
const drive = await connectHiddenPane(REMOTE_PTY_ID, (transport) => {
|
||||
transport.serializeBuffer = vi.fn()
|
||||
transport.serializeBufferOutcome = serializeBufferOutcome
|
||||
})
|
||||
vi.useFakeTimers()
|
||||
floodWhileHiddenThenReveal(drive)
|
||||
await flushAsyncTicks(20)
|
||||
await allowRecoveryWindow()
|
||||
|
||||
expect(observeDisclosure(drive, R2_MARKER)).toMatchObject({
|
||||
bannerWrites: 1,
|
||||
disclosedOrRecovered: true
|
||||
})
|
||||
drive.disposable.dispose()
|
||||
})
|
||||
|
||||
it('[invariant] discloses or recovers when the answer lands while the pane is hidden', async () => {
|
||||
const answer = createDeferred<unknown>()
|
||||
const serializeBufferOutcome = vi.fn().mockReturnValue(answer.promise)
|
||||
const drive = await connectHiddenPane(REMOTE_PTY_ID, (transport) => {
|
||||
transport.serializeBuffer = vi.fn()
|
||||
transport.serializeBufferOutcome = serializeBufferOutcome
|
||||
})
|
||||
vi.useFakeTimers()
|
||||
floodWhileHiddenThenReveal(drive)
|
||||
await flushAsyncTicks(20)
|
||||
expect(serializeBufferOutcome).toHaveBeenCalledTimes(1)
|
||||
|
||||
drive.setVisible(false)
|
||||
answer.resolve(permanentlyUnavailable)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
serializeBufferOutcome.mockResolvedValue(hostAnswers(retainedSnapshot(R2_MARKER)))
|
||||
await revealPane(drive)
|
||||
await allowRecoveryWindow()
|
||||
|
||||
const disclosure = observeDisclosure(drive, R2_MARKER)
|
||||
expect(disclosure.markerWrites).toBeLessThanOrEqual(1)
|
||||
expectDisclosedOrRecovered(disclosure)
|
||||
drive.disposable.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
// ── R5: local snapshot unavailable, deferred retries exhausted ──────────
|
||||
describe('R5 local deferred-retry exhaustion', () => {
|
||||
async function connectLocalHiddenPane(): Promise<PaneDrive> {
|
||||
// A codex startup makes the renderer skip hidden bytes locally, which is
|
||||
// the local mirror of the remote pause the other routes model.
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport(LOCAL_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 LOCAL_PTY_ID
|
||||
}
|
||||
)
|
||||
transportFactoryQueue.push(transport)
|
||||
const pane = createPane(1)
|
||||
const deps = buildPaneConnectionDeps(() => mockStoreState, {
|
||||
isVisibleRef: { current: false },
|
||||
startup: { command: 'codex' }
|
||||
})
|
||||
const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never)
|
||||
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 as { current: boolean }).current = visible
|
||||
},
|
||||
writtenChunks: () => pane.terminal.write.mock.calls.map(([data]) => String(data))
|
||||
}
|
||||
}
|
||||
|
||||
it('[control] banners once when the retries run out while the pane is visible', async () => {
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
getMainBufferSnapshot.mockResolvedValue(null)
|
||||
const drive = await connectLocalHiddenPane()
|
||||
vi.useFakeTimers()
|
||||
floodWhileHiddenThenReveal(drive)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
await flushAsyncTicks(20)
|
||||
}
|
||||
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(4)
|
||||
expect(observeDisclosure(drive, R5_MARKER)).toMatchObject({
|
||||
bannerWrites: 1,
|
||||
disclosedOrRecovered: true
|
||||
})
|
||||
drive.disposable.dispose()
|
||||
})
|
||||
|
||||
it('[invariant] keeps recovery armed when the pane hides before the retries run out', async () => {
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
getMainBufferSnapshot.mockResolvedValue(null)
|
||||
const drive = await connectLocalHiddenPane()
|
||||
vi.useFakeTimers()
|
||||
floodWhileHiddenThenReveal(drive)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
// The pane goes background one retry into the budget.
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
await flushAsyncTicks(20)
|
||||
drive.setVisible(false)
|
||||
await allowRecoveryWindow()
|
||||
|
||||
// Main still holds the dropped bytes; reveal must spend them.
|
||||
getMainBufferSnapshot.mockResolvedValue(retainedSnapshot(R5_MARKER))
|
||||
await revealPane(drive)
|
||||
await allowRecoveryWindow()
|
||||
|
||||
const disclosure = observeDisclosure(drive, R5_MARKER)
|
||||
expect(disclosure.markerWrites).toBeLessThanOrEqual(1)
|
||||
expectDisclosedOrRecovered(disclosure)
|
||||
drive.disposable.dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
import type * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, 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'
|
||||
|
||||
// STA-4869 route R6 — the drop latch (pty-hidden-delivery-gate.ts:29) is
|
||||
// single-use: unmarkHiddenRendererPty consumes it and main re-emits one
|
||||
// 'unhide' marker (pty.ts:7675). The renderer channel has no latch of its own —
|
||||
// dispatchPtyModelRestoreNeeded (pty-model-restore-channel.ts:18) drops a marker
|
||||
// for which no handler is registered. A pane that releases the last hidden claim
|
||||
// while retiring therefore spends the latch on a marker nobody can receive.
|
||||
//
|
||||
// This suite drives the REAL renderer channel from behind the
|
||||
// setHiddenRendererPty IPC, delivering main's marker one turn later the way
|
||||
// Electron does. Main's gate is modelled by mainDropLatch below rather than
|
||||
// imported — src/main is outside the renderer tsconfig project — and its two
|
||||
// rules are pinned against the real module in
|
||||
// src/main/ipc/pty-hidden-delivery-gate.test.ts.
|
||||
|
||||
const {
|
||||
resetAndRefreshAllTerminalWebglAtlases,
|
||||
scheduleTerminalWebglAtlasRecovery,
|
||||
scheduleRuntimeGraphSync,
|
||||
shouldSeedCacheTimerOnInitialTitle,
|
||||
toastInfo,
|
||||
notifyCodexPaneBoundForStaleSweep
|
||||
} = vi.hoisted(() => ({
|
||||
resetAndRefreshAllTerminalWebglAtlases: vi.fn(),
|
||||
scheduleTerminalWebglAtlasRecovery: vi.fn(),
|
||||
scheduleRuntimeGraphSync: vi.fn(),
|
||||
shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false),
|
||||
toastInfo: vi.fn(),
|
||||
notifyCodexPaneBoundForStaleSweep: vi.fn()
|
||||
}))
|
||||
|
||||
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 R6_MARKER = 'R6-LOST-c5e2'
|
||||
const HIDDEN_CHUNK = 'hidden-agent-output\r\n'
|
||||
|
||||
type PaneDrive = {
|
||||
pane: MockPane
|
||||
deps: PaneConnectionDeps
|
||||
binding: { syncProcessTracking: () => void; dispose: () => void }
|
||||
deliver: (data: string, seq: number) => void
|
||||
setVisible: (visible: boolean) => void
|
||||
writtenChunks: () => string[]
|
||||
}
|
||||
|
||||
/** Main's droppedSinceHiddenPtys latch (pty-hidden-delivery-gate.ts:29): the
|
||||
* first gated drop arms it, only unmark consumes it, and re-marking hidden
|
||||
* preserves it. */
|
||||
const mainDropLatch = {
|
||||
hidden: false,
|
||||
dropped: false,
|
||||
markHidden(): void {
|
||||
this.hidden = true
|
||||
},
|
||||
/** @returns whether main owes the renderer a restore marker. */
|
||||
recordDrop(): boolean {
|
||||
if (this.dropped) {
|
||||
return false
|
||||
}
|
||||
this.dropped = true
|
||||
return true
|
||||
},
|
||||
/** @returns droppedWhileHidden — the latch, consumed. */
|
||||
unmarkHidden(): boolean {
|
||||
this.hidden = false
|
||||
const dropped = this.dropped
|
||||
this.dropped = false
|
||||
return dropped
|
||||
},
|
||||
reset(): void {
|
||||
this.hidden = false
|
||||
this.dropped = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Routes the renderer's hidden-state IPC into main's real gate and delivers
|
||||
* main's marker back one turn later, as the IPC round trip does. */
|
||||
async function wireMainGateToRendererChannel(): Promise<{ markersDelivered: () => number }> {
|
||||
const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel')
|
||||
let markersDelivered = 0
|
||||
vi.mocked(window.api.pty.setHiddenRendererPty).mockImplementation(
|
||||
(id: string, hidden: boolean) => {
|
||||
if (hidden) {
|
||||
mainDropLatch.markHidden()
|
||||
return
|
||||
}
|
||||
if (!mainDropLatch.unmarkHidden()) {
|
||||
return
|
||||
}
|
||||
markersDelivered += 1
|
||||
queueMicrotask(() => {
|
||||
_dispatchPtyModelRestoreNeededForTest({ id, reason: 'unhide', markerSeq: 4096 })
|
||||
})
|
||||
}
|
||||
)
|
||||
return { markersDelivered: () => markersDelivered }
|
||||
}
|
||||
|
||||
/** Main ingests the chunk into its model and drops the renderer copy. */
|
||||
async function dropHiddenBytesInMain(): Promise<void> {
|
||||
const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel')
|
||||
expect(mainDropLatch.hidden).toBe(true)
|
||||
if (mainDropLatch.recordDrop()) {
|
||||
_dispatchPtyModelRestoreNeededForTest({ id: PTY_ID, reason: 'hidden-drop', markerSeq: 2048 })
|
||||
}
|
||||
await flushAsyncTicks(6)
|
||||
}
|
||||
|
||||
async function connectPane(visible: boolean): Promise<PaneDrive> {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
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: visible }
|
||||
})
|
||||
const binding = connectPanePty(pane as never, createManager(1) as never, deps as never) as {
|
||||
syncProcessTracking: () => void
|
||||
dispose: () => void
|
||||
}
|
||||
await flushAsyncTicks(6)
|
||||
expect(captured.current).not.toBeNull()
|
||||
return {
|
||||
pane,
|
||||
deps,
|
||||
binding,
|
||||
deliver: (data, seq) => captured.current?.(data, { seq, rawLength: data.length }),
|
||||
setVisible: (nextVisible) => {
|
||||
;(deps.isVisibleRef as { current: boolean }).current = nextVisible
|
||||
},
|
||||
writtenChunks: () => pane.terminal.write.mock.calls.map(([data]) => String(data))
|
||||
}
|
||||
}
|
||||
|
||||
function observeDisclosure(drive: PaneDrive): {
|
||||
bannerWrites: number
|
||||
markerWrites: number
|
||||
disclosedOrRecovered: boolean
|
||||
} {
|
||||
const written = drive.writtenChunks()
|
||||
const bannerWrites = written.filter((data) => data.includes(BANNER_FRAGMENT)).length
|
||||
const markerWrites = written.join('').split(R6_MARKER).length - 1
|
||||
return {
|
||||
bannerWrites,
|
||||
markerWrites,
|
||||
disclosedOrRecovered: bannerWrites > 0 || markerWrites === 1
|
||||
}
|
||||
}
|
||||
|
||||
describe('hidden-output restore marker handoff (STA-4869 R6)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
transportFactoryQueue = []
|
||||
createdTransportOptions = []
|
||||
storeSubscribers = []
|
||||
mainDropLatch.reset()
|
||||
mockStoreState = createInitialStoreState(() => mockStoreState)
|
||||
mockStoreState.settings = {
|
||||
...mockStoreState.settings,
|
||||
terminalMainSideEffectAuthority: true
|
||||
} as StoreState['settings']
|
||||
installTerminalTestGlobals()
|
||||
vi.mocked(window.api.pty.getMainBufferSnapshot).mockResolvedValue({
|
||||
data: `${R6_MARKER}\r\n`,
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
seq: 4096
|
||||
} as never)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await restoreTerminalTestGlobals()
|
||||
const { _resetPtyRendererDeliveryClaimsForTest } =
|
||||
await import('./pty-renderer-delivery-claims')
|
||||
_resetPtyRendererDeliveryClaimsForTest()
|
||||
})
|
||||
|
||||
it('[mechanism] the renderer channel drops a marker with no registered handler and never replays it', async () => {
|
||||
const { registerPtyModelRestoreNeededHandler, _dispatchPtyModelRestoreNeededForTest } =
|
||||
await import('./pty-model-restore-channel')
|
||||
const retiringHandler = vi.fn()
|
||||
const unregister = registerPtyModelRestoreNeededHandler(PTY_ID, retiringHandler)
|
||||
unregister()
|
||||
|
||||
_dispatchPtyModelRestoreNeededForTest({ id: PTY_ID, reason: 'unhide', markerSeq: 4096 })
|
||||
expect(retiringHandler).not.toHaveBeenCalled()
|
||||
|
||||
// The replacement view registers a moment later: nothing is held for it.
|
||||
const replacementHandler = vi.fn()
|
||||
registerPtyModelRestoreNeededHandler(PTY_ID, replacementHandler)
|
||||
await flushAsyncTicks(6)
|
||||
expect(replacementHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('[control] a revealed pane consumes the unhide marker and repaints from the model', async () => {
|
||||
const gate = await wireMainGateToRendererChannel()
|
||||
const drive = await connectPane(false)
|
||||
drive.deliver(HIDDEN_CHUNK, HIDDEN_CHUNK.length)
|
||||
await dropHiddenBytesInMain()
|
||||
|
||||
drive.setVisible(true)
|
||||
drive.binding.syncProcessTracking()
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(gate.markersDelivered()).toBe(1)
|
||||
expect(observeDisclosure(drive)).toMatchObject({ markerWrites: 1 })
|
||||
drive.binding.dispose()
|
||||
})
|
||||
|
||||
it('[invariant] a hidden pane that retires must not spend the drop latch on a marker nobody receives', async () => {
|
||||
const gate = await wireMainGateToRendererChannel()
|
||||
const retiring = await connectPane(false)
|
||||
retiring.deliver(HIDDEN_CHUNK, HIDDEN_CHUNK.length)
|
||||
await dropHiddenBytesInMain()
|
||||
expect(vi.mocked(window.api.pty.setHiddenRendererPty)).toHaveBeenCalledWith(PTY_ID, true)
|
||||
|
||||
// The pane retires (tab remount / recovery): releasing the last hidden claim
|
||||
// unhides the PTY in main, which answers with the one restore marker.
|
||||
retiring.binding.dispose()
|
||||
await flushAsyncTicks(20)
|
||||
expect(gate.markersDelivered()).toBe(1)
|
||||
|
||||
// The replacement pane binds the same PTY while visible.
|
||||
const replacement = await connectPane(true)
|
||||
replacement.binding.syncProcessTracking()
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
// Main still holds the dropped bytes and answers any snapshot request with
|
||||
// them, so the replacement pane owes the user either those bytes or a banner.
|
||||
const disclosure = {
|
||||
...observeDisclosure(replacement),
|
||||
modelSnapshotRequests: vi.mocked(window.api.pty.getMainBufferSnapshot).mock.calls.length
|
||||
}
|
||||
expect(
|
||||
disclosure.disclosedOrRecovered,
|
||||
`hidden output was neither reconciled nor disclosed after remount: ${JSON.stringify(disclosure)}`
|
||||
).toBe(true)
|
||||
replacement.binding.dispose()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user