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:
Jinwoo-H
2026-08-19 17:59:07 -04:00
parent 9b8dbd8930
commit e8aa4ef243
3 changed files with 1162 additions and 0 deletions
@@ -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()
})
})
})
@@ -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()
})
})
})
@@ -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()
})
})