From d7c9b5d5aa3f2343a099cb11bc1d25d6963d4335 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 29 Jul 2026 06:19:28 -0700 Subject: [PATCH] fix(terminal): unblock collapsed hidden restores --- .../hidden-output-restore-scheduler.test.ts | 24 ++++ .../hidden-output-restore-scheduler.ts | 54 +++++++-- .../terminal-pane/pty-connection.test.ts | 111 ++++++++++++++++++ .../terminal-pane/pty-connection.ts | 68 ++--------- .../pane-fit-continuation-retry.ts | 21 ++++ .../src/lib/pane-manager/pane-fit.test.ts | 72 ++++++++++++ .../pane-terminal-output-scheduler.test.ts | 6 +- .../pane-terminal-output-scheduler.ts | 4 +- ...ial-opencode-hidden-pressure-completion.ts | 28 +++++ ...icial-opencode-hidden-pressure-scenario.ts | 7 +- ...cial-opencode-revisit-pressure-scenario.ts | 17 +-- .../artificial-opencode-terminal-load.spec.ts | 4 - 12 files changed, 323 insertions(+), 93 deletions(-) create mode 100644 tests/e2e/artificial-opencode-hidden-pressure-completion.ts diff --git a/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.test.ts b/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.test.ts index d1a282af9e6..6cba2d07c1c 100644 --- a/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.test.ts +++ b/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.test.ts @@ -44,6 +44,30 @@ describe('hidden output restore scheduler', () => { expect(secondRestore).toHaveBeenCalledTimes(1) }) + it('promotes queued work whose priority becomes active before the drain', async () => { + let isActive = false + let settleActive: (() => void) | undefined + const inactiveRestore = vi.fn() + const promotedRestore = vi.fn( + () => + new Promise((resolve) => { + settleActive = resolve + }) + ) + + scheduleHiddenOutputRestore({}, inactiveRestore, 'inactive') + scheduleHiddenOutputRestore({}, promotedRestore, () => (isActive ? 'active' : 'inactive')) + isActive = true + + await vi.advanceTimersByTimeAsync(16) + expect(promotedRestore).toHaveBeenCalledTimes(1) + expect(inactiveRestore).not.toHaveBeenCalled() + + settleActive?.() + await vi.advanceTimersByTimeAsync(16) + expect(inactiveRestore).toHaveBeenCalledTimes(1) + }) + it('waits for an inactive restore to finish before starting the next', async () => { let settleFirst: (() => void) | undefined const firstRestore = vi.fn( diff --git a/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.ts b/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.ts index 0ee7edac971..efcaf8aba8c 100644 --- a/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.ts +++ b/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.ts @@ -1,9 +1,12 @@ type HiddenOutputRestorePriority = 'active' | 'inactive' +type HiddenOutputRestorePriorityResolver = () => HiddenOutputRestorePriority + type HiddenOutputRestoreRequest = () => void | Promise type HiddenOutputRestoreEntry = { requestRestore: HiddenOutputRestoreRequest + priority: HiddenOutputRestorePriority | HiddenOutputRestorePriorityResolver } type InactiveHiddenOutputRestore = { @@ -95,31 +98,66 @@ function runInactiveRestore(target: object, requestRestore: HiddenOutputRestoreR finishInactiveRestore(target, token) } +function resolveRestorePriority( + priority: HiddenOutputRestorePriority | HiddenOutputRestorePriorityResolver +): HiddenOutputRestorePriority { + return typeof priority === 'function' ? priority() : priority +} + +function takeNextScheduledRestore(): { + target: object + entry: HiddenOutputRestoreEntry + priority: HiddenOutputRestorePriority +} | null { + let fallback: + | { + target: object + entry: HiddenOutputRestoreEntry + priority: HiddenOutputRestorePriority + } + | undefined + for (const [target, entry] of inactiveRestoreQueue) { + const priority = resolveRestorePriority(entry.priority) + fallback ??= { target, entry, priority } + if (priority === 'active') { + inactiveRestoreQueue.delete(target) + return { target, entry, priority } + } + } + if (!fallback) { + return null + } + inactiveRestoreQueue.delete(fallback.target) + return fallback +} + function drainInactiveRestoreQueue(): void { inactiveRestoreTimer = null if (inactiveRestore !== null || activeRestoreTokensByTarget.size > 0) { return } - const next = inactiveRestoreQueue.entries().next() - if (next.done) { + const next = takeNextScheduledRestore() + if (!next) { return } - const [target, entry] = next.value - inactiveRestoreQueue.delete(target) - runInactiveRestore(target, entry.requestRestore) + if (next.priority === 'active') { + runActiveRestore(next.target, next.entry.requestRestore) + return + } + runInactiveRestore(next.target, next.entry.requestRestore) } export function scheduleHiddenOutputRestore( target: object, requestRestore: HiddenOutputRestoreRequest, - priority: HiddenOutputRestorePriority + priority: HiddenOutputRestorePriority | HiddenOutputRestorePriorityResolver ): void { - if (priority === 'active') { + if (resolveRestorePriority(priority) === 'active') { cancelScheduledHiddenOutputRestore(target) runActiveRestore(target, requestRestore) return } - inactiveRestoreQueue.set(target, { requestRestore }) + inactiveRestoreQueue.set(target, { requestRestore, priority }) scheduleInactiveRestoreDrain() } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 9cff352ddcc..e6870ff5d50 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -11104,6 +11104,54 @@ describe('connectPanePty', () => { ) }) + it('releases post-snapshot live bytes when the active split stays layout-collapsed', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { pane, transport, dataCallback } = await connectHiddenPane(deps) + vi.useFakeTimers() + Object.defineProperties(pane.container, { + isConnected: { configurable: true, value: true }, + offsetParent: { configurable: true, value: {} }, + getBoundingClientRect: { + configurable: true, + value: () => ({ width: 0, height: 0 }) + } + }) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'source-grid hidden snapshot\r\n', + cols: 80, + rows: 24, + seq: 64 + }) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 2, rows: 1 })) as never + transport.resize.mockClear() + + dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 }) + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 }) + ;(deps.isVisibleRef as { current: boolean }).current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + + dataCallback('live-after-collapsed-restore', { seq: 92, rawLength: 28 }) + expect(pane.terminal.write.mock.calls.map((call) => String(call[0])).join('')).not.toContain( + 'live-after-collapsed-restore' + ) + + await vi.advanceTimersByTimeAsync(128) + await flushAsyncTicks(20) + + expect(transport.resize).not.toHaveBeenCalled() + expect(pane.terminal.write.mock.calls.map((call) => String(call[0])).join('')).toContain( + 'live-after-collapsed-restore' + ) + }) + it('kicks pane recovery when reveal finds the write pipeline certified dead', async () => { // 2026-07-13 fossil-pane incident: bytes drop while hidden, pipeline certified dead, cert recovery empty — reveal must re-kick it. enableMainAuthority() @@ -13935,6 +13983,69 @@ describe('connectPanePty', () => { } }) + it('promotes a deferred hidden restore when its pane becomes active before the drain', async () => { + const { resetHiddenOutputRestoreSchedulerForTests, scheduleHiddenOutputRestore } = + await import('./hidden-output-restore-scheduler') + let disposable: { dispose: () => void } | null = null + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'promoted snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + + const pane = createPane(1) + const manager = createManager(1, 1) + const deps = createDeps({ + isActiveRef: { current: false }, + isVisibleRef: { current: false } + }) + disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + const blockingRestore = vi.fn(() => new Promise(() => undefined)) + scheduleHiddenOutputRestore({}, blockingRestore, 'inactive') + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible promoted output\r\n' + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + ;(deps.isActiveRef as { current: boolean }).current = true + + await new Promise((resolve) => setTimeout(resolve, 30)) + await flushAsyncTicks(20) + + expect(blockingRestore).not.toHaveBeenCalled() + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('promoted snapshot'), + expect.any(Function) + ) + } finally { + disposable?.dispose() + resetHiddenOutputRestoreSchedulerForTests() + } + }) + it('drops a deferred inactive hidden restore when the pane is hidden again', async () => { const { resetHiddenOutputRestoreSchedulerForTests } = await import('./hidden-output-restore-scheduler') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index d2a535169d9..775554caf06 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -504,10 +504,6 @@ type E2eTerminalPtyOutputDebugSnapshot = { hiddenRendererSkipCount: number hiddenRendererSkippedChars: number hiddenRendererMode2031ReplyCount: number - activeHiddenRestoreCount: number - activeHiddenRestoreSnapshotMs: number - activeHiddenRestoreReplayMs: number - activeHiddenRestoreTotalMs: number } type E2eTerminalPtyOutputDebugApi = { @@ -541,21 +537,13 @@ type ColdRestoreAgentResumeStartup = PendingStartupCommand & { const e2eTerminalPtyOutputDebugState: E2eTerminalPtyOutputDebugSnapshot = { hiddenRendererSkipCount: 0, hiddenRendererSkippedChars: 0, - hiddenRendererMode2031ReplyCount: 0, - activeHiddenRestoreCount: 0, - activeHiddenRestoreSnapshotMs: 0, - activeHiddenRestoreReplayMs: 0, - activeHiddenRestoreTotalMs: 0 + hiddenRendererMode2031ReplyCount: 0 } function resetE2eTerminalPtyOutputDebug(): void { e2eTerminalPtyOutputDebugState.hiddenRendererSkipCount = 0 e2eTerminalPtyOutputDebugState.hiddenRendererSkippedChars = 0 e2eTerminalPtyOutputDebugState.hiddenRendererMode2031ReplyCount = 0 - e2eTerminalPtyOutputDebugState.activeHiddenRestoreCount = 0 - e2eTerminalPtyOutputDebugState.activeHiddenRestoreSnapshotMs = 0 - e2eTerminalPtyOutputDebugState.activeHiddenRestoreReplayMs = 0 - e2eTerminalPtyOutputDebugState.activeHiddenRestoreTotalMs = 0 } function exposeE2eTerminalPtyOutputDebug(): void { @@ -586,22 +574,6 @@ function recordHiddenMode2031Reply(): void { e2eTerminalPtyOutputDebugState.hiddenRendererMode2031ReplyCount += 1 } -function recordActiveHiddenRestoreTiming( - priority: 'active' | 'inactive' | undefined, - snapshotMs: number, - replayMs: number, - totalMs: number -): void { - if (!e2eConfig.exposeStore || priority !== 'active') { - return - } - exposeE2eTerminalPtyOutputDebug() - e2eTerminalPtyOutputDebugState.activeHiddenRestoreCount += 1 - e2eTerminalPtyOutputDebugState.activeHiddenRestoreSnapshotMs = snapshotMs - e2eTerminalPtyOutputDebugState.activeHiddenRestoreReplayMs = replayMs - e2eTerminalPtyOutputDebugState.activeHiddenRestoreTotalMs = totalMs -} - function exposeE2eTerminalPtyDataInjection(): void { if (!e2eConfig.exposeStore || typeof window === 'undefined') { return @@ -7014,10 +6986,7 @@ export function connectPanePty( } } - function requestHiddenOutputRestoreIfNeeded(opts?: { - bypassScheduler?: boolean - debugPriority?: 'active' | 'inactive' - }): boolean { + function requestHiddenOutputRestoreIfNeeded(opts?: { bypassScheduler?: boolean }): boolean { // Why: once the write pipeline is probe-certified dead a restore can never parse; recovery owns the pane and the remount gets a fresh xterm + restore. if (isTerminalWritePipelineCertifiedDead(pane.terminal)) { // Why the re-kick: certification's recovery request can be budget-declined or cancelled by a sibling remount; without this, a revealed dead pane keeps the stale frame forever. @@ -7048,7 +7017,9 @@ export function connectPanePty( return true } if (!opts?.bypassScheduler) { - const priority = isActiveSplitPane() ? 'active' : 'inactive' + const resolvePriority = (): 'active' | 'inactive' => + isActiveSplitPane() ? 'active' : 'inactive' + const priority = resolvePriority() if (priority === 'inactive') { if (!hiddenOutputRestoreScheduled) { hiddenOutputRestoreScheduled = true @@ -7071,12 +7042,11 @@ export function connectPanePty( return } const restoreRequested = requestHiddenOutputRestoreIfNeeded({ - bypassScheduler: true, - debugPriority: priority + bypassScheduler: true }) return restoreRequested ? (hiddenOutputRestoreInFlight ?? undefined) : undefined }, - priority + resolvePriority ) } return true @@ -7087,10 +7057,7 @@ export function connectPanePty( scheduleHiddenOutputRestore( pane.terminal, () => { - restoreRequested = requestHiddenOutputRestoreIfNeeded({ - bypassScheduler: true, - debugPriority: priority - }) + restoreRequested = requestHiddenOutputRestoreIfNeeded({ bypassScheduler: true }) return hiddenOutputRestoreInFlight ?? undefined }, priority @@ -7099,9 +7066,6 @@ export function connectPanePty( } clearHiddenOutputRestoreDeferredRetryTimer() hiddenOutputRestoreRetryDeferred = false - const restoreStartedAt = performance.now() - let restoreSnapshotMs = 0 - let restoreReplayMs = 0 hiddenOutputRestoreInFlight = (async () => { // Backstop (rc.7.perf loop): bound how many snapshot fetch+replay rounds one task burns before yielding to the live stream. @@ -7128,15 +7092,12 @@ export function connectPanePty( const restoreGeneration = hiddenOutputRestoreGeneration hiddenOutputRestoreNeeded = false let snapshot: PtyBufferSnapshot | null = null - const snapshotStartedAt = performance.now() try { snapshot = await serializeHiddenOutputSnapshot(currentPtyId, { scrollbackRows: resolveHiddenRestoreScrollbackRows(pane.terminal.options.scrollback) }) } catch { snapshot = null - } finally { - restoreSnapshotMs += performance.now() - snapshotStartedAt } if (disposed) { return @@ -7160,12 +7121,7 @@ export function connectPanePty( } hiddenOutputRestoreDeferredRetryAttempts = 0 restoreIterations += 1 - const replayStartedAt = performance.now() - try { - await applyMainBufferSnapshot(snapshot) - } finally { - restoreReplayMs += performance.now() - replayStartedAt - } + await applyMainBufferSnapshot(snapshot) if ( disposed || hiddenOutputRestoreGeneration !== restoreGeneration || @@ -7217,12 +7173,6 @@ export function connectPanePty( const hiddenOutputRestoreTask = hiddenOutputRestoreInFlight let trackedHiddenOutputRestore: Promise trackedHiddenOutputRestore = hiddenOutputRestoreTask.finally(() => { - recordActiveHiddenRestoreTiming( - opts?.debugPriority, - restoreSnapshotMs, - restoreReplayMs, - performance.now() - restoreStartedAt - ) if (hiddenOutputRestoreInFlight === trackedHiddenOutputRestore) { hiddenOutputRestoreInFlight = null } diff --git a/src/renderer/src/lib/pane-manager/pane-fit-continuation-retry.ts b/src/renderer/src/lib/pane-manager/pane-fit-continuation-retry.ts index 40a8a8b6f4c..e8d4243eee3 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit-continuation-retry.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit-continuation-retry.ts @@ -3,12 +3,14 @@ import { getLivePaneCensus } from './pane-manager-registry' import type { ManagedPane } from './pane-manager-types' const MAX_RETRY_FRAMES = 40 +const COLLAPSED_LAYOUT_RETRY_FRAMES = 4 const LAYOUT_SETTLE_MS = 16 type RetrySchedule = { cancel: () => void } type RetryState = { attempts: number + collapsedLayoutAttempts: number schedule: RetrySchedule | null retry: () => boolean onExhausted: () => void @@ -16,6 +18,15 @@ type RetryState = { const retryByPane = new WeakMap() +function isConnectedCollapsedPane(pane: ManagedPane): boolean { + const container = pane.container + if (container.isConnected !== true || container.offsetParent == null) { + return false + } + const rect = container.getBoundingClientRect?.() + return Boolean(rect && (rect.width === 0 || rect.height === 0)) +} + function scheduleRetryTick(run: () => void): RetrySchedule { if (typeof requestAnimationFrame === 'function') { let cancelled = false @@ -59,6 +70,7 @@ export function armPaneFitContinuationRetry( ): void { const state = retryByPane.get(pane) ?? { attempts: 0, + collapsedLayoutAttempts: 0, schedule: null, ...callbacks } @@ -75,6 +87,15 @@ export function armPaneFitContinuationRetry( return } state.attempts += 1 + state.collapsedLayoutAttempts = isConnectedCollapsedPane(pane) + ? state.collapsedLayoutAttempts + 1 + : 0 + if (state.collapsedLayoutAttempts >= COLLAPSED_LAYOUT_RETRY_FRAMES) { + // Why: a connected 0×0 split has no destination grid; waiting the full reveal budget only stalls replay and live output. + clearPaneFitContinuationRetry(pane) + state.onExhausted() + return + } if (state.attempts >= MAX_RETRY_FRAMES) { // Why leafId + census: `pane.id` restarts at 1 per PaneManager and there // is one manager per tab, so a burst of identical `paneId: 1` crumbs diff --git a/src/renderer/src/lib/pane-manager/pane-fit.test.ts b/src/renderer/src/lib/pane-manager/pane-fit.test.ts index a0864bcbb57..60c2cc6fd36 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit.test.ts @@ -22,6 +22,7 @@ function flushAnimationFrames(timestamp = 16): void { type TestPane = ManagedPane & { setRect: (rect: { width: number; height: number }) => void setXtermRect: (rect: { width: number; height: number }) => void + setLayoutConnection: (connected: boolean, hasOffsetParent: boolean) => void } function createPane(options: { @@ -31,6 +32,8 @@ function createPane(options: { let rect = options.rect // Why: the reveal gate measures the inner xterm host, which can differ from the outer .pane. let xtermRect: { width: number; height: number } | null = null + let layoutConnected = false + let layoutHasOffsetParent = false const leafId = '22222222-2222-4222-8222-222222222222' const pane = { id: 7, @@ -39,6 +42,12 @@ function createPane(options: { terminal: { cols: 80, rows: 24 }, container: { dataset: {}, + get isConnected() { + return layoutConnected + }, + get offsetParent() { + return layoutHasOffsetParent ? {} : null + }, getBoundingClientRect: () => ({ width: rect.width, height: rect.height }) }, xtermContainer: { @@ -59,6 +68,10 @@ function createPane(options: { }, setXtermRect: (next: { width: number; height: number }) => { xtermRect = next + }, + setLayoutConnection: (connected: boolean, hasOffsetParent: boolean) => { + layoutConnected = connected + layoutHasOffsetParent = hasOffsetParent } } return pane as unknown as TestPane @@ -155,6 +168,65 @@ describe('safeFitAndThen unmeasurable-pane retry', () => { await expect(handle.completion).resolves.toBe(true) }) + it('settles a persistently collapsed connected split without the hidden-pane retry delay', async () => { + const pane = createPane({ rect: { width: 0, height: 0 } }) + const continuation = vi.fn() + pane.setLayoutConnection(true, true) + + const handle = safeFitAndThen(pane, 'hidden-snapshot-pty-resize', continuation, { + retryIfUnmeasurable: true + }) + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrames(frame * 16) + vi.advanceTimersByTime(16) + } + + expect(continuation).not.toHaveBeenCalled() + expect(recordRendererCrashBreadcrumb).not.toHaveBeenCalled() + await expect(handle.completion).resolves.toBe(false) + }) + + it('keeps retrying a connected pane hidden by an ancestor', async () => { + const pane = createPane({ rect: { width: 0, height: 0 } }) + const continuation = vi.fn() + pane.setLayoutConnection(true, false) + + const handle = safeFitAndThen(pane, 'hidden-snapshot-pty-resize', continuation, { + retryIfUnmeasurable: true + }) + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrames(frame * 16) + vi.advanceTimersByTime(16) + } + pane.setRect({ width: 800, height: 600 }) + pane.setLayoutConnection(true, true) + flushAnimationFrames(64) + vi.advanceTimersByTime(16) + + expect(continuation).toHaveBeenCalledTimes(1) + await expect(handle.completion).resolves.toBe(true) + }) + + it('still accepts a connected split that gains layout within the collapsed retry window', async () => { + const pane = createPane({ rect: { width: 0, height: 0 } }) + const continuation = vi.fn() + pane.setLayoutConnection(true, true) + + const handle = safeFitAndThen(pane, 'hidden-snapshot-pty-resize', continuation, { + retryIfUnmeasurable: true + }) + for (let frame = 0; frame < 3; frame += 1) { + flushAnimationFrames(frame * 16) + vi.advanceTimersByTime(16) + } + pane.setRect({ width: 800, height: 600 }) + flushAnimationFrames(64) + vi.advanceTimersByTime(16) + + expect(continuation).toHaveBeenCalledTimes(1) + await expect(handle.completion).resolves.toBe(true) + }) + it('resolves failure after the bounded frame budget instead of hanging reattach', async () => { const pane = createPane({ rect: { width: 0, height: 0 } }) const continuation = vi.fn() diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts index ec7050fecb1..aaefe89f872 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts @@ -1332,14 +1332,14 @@ describe('pane terminal output scheduler', () => { expect(terminal.write).not.toHaveBeenCalled() - // Why 12: promoted backlogs use the parse-clocked high-priority budget + // Why 8: promoted backlogs use the parse-clocked high-priority budget // (HIGH_PRIORITY_MAX_WRITES_PER_DRAIN) so a visible flood drains at the // parser's pace instead of a fixed 2-write drip. vi.advanceTimersByTime(0) - expect(terminal.write).toHaveBeenCalledTimes(12) + expect(terminal.write).toHaveBeenCalledTimes(8) vi.advanceTimersByTime(4) - expect(terminal.write).toHaveBeenCalledTimes(24) + expect(terminal.write).toHaveBeenCalledTimes(16) }) it('yields high-priority backlog drains when writes spend the frame budget', async () => { diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index 2fd39f69c66..4272d4c0913 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -92,8 +92,8 @@ const BACKGROUND_DRAIN_INTERVAL_MS = 16 const HIGH_PRIORITY_DRAIN_INTERVAL_MS = 4 const BACKGROUND_CHUNK_CHARS = 16 * 1024 const MAX_WRITES_PER_DRAIN = 2 -// Why 12: visible split-pane fan-in needs 192KB/tick; the 8ms budget still bounds slow parsers. -const HIGH_PRIORITY_MAX_WRITES_PER_DRAIN = 12 +// Why 8: per-tick volume (8 x 16KB = 128KB ≈ 1.3ms parse) sets the sustained ceiling (~30MB/s) within DRAIN_TIME_BUDGET_MS; at 2 it was only 8MB/s against a ~100MB/s parser (see throughput bench). +const HIGH_PRIORITY_MAX_WRITES_PER_DRAIN = 8 const DRAIN_TIME_BUDGET_MS = 8 const LARGE_BACKLOG_CHARS = 512 * 1024 const SYNC_FOREGROUND_FLUSH_CHARS = 256 * 1024 diff --git a/tests/e2e/artificial-opencode-hidden-pressure-completion.ts b/tests/e2e/artificial-opencode-hidden-pressure-completion.ts new file mode 100644 index 00000000000..bf7f13a3f84 --- /dev/null +++ b/tests/e2e/artificial-opencode-hidden-pressure-completion.ts @@ -0,0 +1,28 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect } from '@stablyai/playwright-test' + +export async function waitForHiddenPressureCompletion( + orcaPage: Page, + hiddenPanes: { ptyId: string }[], + runId: string +): Promise { + await expect + .poll( + () => + orcaPage.evaluate( + async ({ panes, markerPrefix }) => { + const snapshots = await Promise.all( + panes.map((pane) => + window.api.pty.getMainBufferSnapshot(pane.ptyId, { scrollbackRows: 50 }) + ) + ) + return snapshots.filter((snapshot, index) => + snapshot?.data.includes(`${markerPrefix}${index}`) + ).length + }, + { panes: hiddenPanes, markerPrefix: `OPENCODE_PRESSURE_DONE_${runId}_` } + ), + { timeout: 60_000, message: 'Hidden PTY pressure generators did not finish' } + ) + .toBe(hiddenPanes.length) +} diff --git a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts index 5145a12e6fa..12914a13c3b 100644 --- a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts @@ -7,6 +7,7 @@ import { type HiddenPressureOutputMode, writePressureOutputScript } from './artificial-opencode-hidden-pressure-script' +import { waitForHiddenPressureCompletion } from './artificial-opencode-hidden-pressure-completion' import { waitForMarkerLatency } from './artificial-opencode-pane-interactions' import { ensureTerminalVisible, @@ -174,6 +175,8 @@ export async function runHiddenRealPtyPressureScenario< typingPtyId, runId ) + // Why: typing must overlap live pressure, but restore must start from a complete hidden model rather than timing the remaining generators. + await waitForHiddenPressureCompletion(orcaPage, hiddenPanes, runId) const debug = await deps.readTerminalPtyOutputDebug(orcaPage) const scheduler = await deps.readTerminalOutputSchedulerDebug(orcaPage) const mainPressure = await deps.readMainPtyPressureDebug(orcaPage) @@ -249,14 +252,14 @@ export async function runHiddenRealPtyPressureScenario< async function waitForMainHiddenDeliveryDrops( orcaPage: Page, deps: { readMainPtyPressureDebug: (page: Page) => Promise }, - pressureOutputChars: number + minimumDroppedChars: number ): Promise { await expect .poll( async () => (await deps.readMainPtyPressureDebug(orcaPage))?.hiddenDeliveryDroppedChars ?? 0, { timeout: 30_000, message: 'Main hidden-delivery gate did not drop hidden PTY output' } ) - .toBeGreaterThanOrEqual(pressureOutputChars) + .toBeGreaterThanOrEqual(minimumDroppedChars) } async function measureHiddenOutputRestoreLatency( diff --git a/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts b/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts index 36ec45fa1ee..29e1a708bb1 100644 --- a/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts @@ -28,13 +28,7 @@ type RevisitPressureMeasurement = { // Why: the renderer hidden-skip counters were deleted with the skip grammar; // only the mode-2031 fact-reply counter still exists renderer-side. -type RevisitPressureDebug = { - hiddenRendererMode2031ReplyCount: number - activeHiddenRestoreCount: number - activeHiddenRestoreSnapshotMs: number - activeHiddenRestoreReplayMs: number - activeHiddenRestoreTotalMs: number -} +type RevisitPressureDebug = { hiddenRendererMode2031ReplyCount: number } type RevisitPressureSchedulerSnapshot = { peakQueuedChars: number @@ -207,18 +201,11 @@ export async function runRendererBackpressureRevisitScenario< await deps.focusPane(orcaPage, revisitPane.paneKey) await sendToTerminal(orcaPage, revisitPane.ptyId, `printf '\\n${revisitMarker}\\n'\r`) const revisitLatencyMs = await waitForMarkerLatency(orcaPage, revisitMarker, 10_000) - const revisitDebug = await deps.readTerminalPtyOutputDebug(orcaPage) testInfo.annotations.push({ type: 'opencode-main-pressure-worktree-revisit-marker', description: `panes=${panes.length + 1} revisit=${revisitLatencyMs.toFixed( 1 - )}ms heldAckChars=${ackGate?.heldAckChars ?? 0} activeRestores=${ - revisitDebug?.activeHiddenRestoreCount ?? 0 - } activeRestoreSnapshot=${(revisitDebug?.activeHiddenRestoreSnapshotMs ?? 0).toFixed( - 1 - )}ms activeRestoreReplay=${(revisitDebug?.activeHiddenRestoreReplayMs ?? 0).toFixed( - 1 - )}ms activeRestoreTotal=${(revisitDebug?.activeHiddenRestoreTotalMs ?? 0).toFixed(1)}ms` + )}ms heldAckChars=${ackGate?.heldAckChars ?? 0}` }) // Why: this printf is measured after a worktree switch/focus while the // background panes are still ACK-gate-held, so it gets its own under-load diff --git a/tests/e2e/artificial-opencode-terminal-load.spec.ts b/tests/e2e/artificial-opencode-terminal-load.spec.ts index 8b0e45b75a5..9b937775a84 100644 --- a/tests/e2e/artificial-opencode-terminal-load.spec.ts +++ b/tests/e2e/artificial-opencode-terminal-load.spec.ts @@ -61,10 +61,6 @@ type SyntheticOpenCodeWindow = Window & { // counter still has a renderer-side producer. type TerminalPtyOutputDebugSnapshot = { hiddenRendererMode2031ReplyCount: number - activeHiddenRestoreCount: number - activeHiddenRestoreSnapshotMs: number - activeHiddenRestoreReplayMs: number - activeHiddenRestoreTotalMs: number } type TerminalOutputSchedulerDebugSnapshot = {