mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
fix(terminal): unblock collapsed hidden restores
This commit is contained in:
@@ -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<void>((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(
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
type HiddenOutputRestorePriority = 'active' | 'inactive'
|
||||
|
||||
type HiddenOutputRestorePriorityResolver = () => HiddenOutputRestorePriority
|
||||
|
||||
type HiddenOutputRestoreRequest = () => void | Promise<void>
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void>(() => 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')
|
||||
|
||||
@@ -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<void>
|
||||
trackedHiddenOutputRestore = hiddenOutputRestoreTask.finally(() => {
|
||||
recordActiveHiddenRestoreTiming(
|
||||
opts?.debugPriority,
|
||||
restoreSnapshotMs,
|
||||
restoreReplayMs,
|
||||
performance.now() - restoreStartedAt
|
||||
)
|
||||
if (hiddenOutputRestoreInFlight === trackedHiddenOutputRestore) {
|
||||
hiddenOutputRestoreInFlight = null
|
||||
}
|
||||
|
||||
@@ -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<ManagedPane, RetryState>()
|
||||
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
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)
|
||||
}
|
||||
@@ -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<TMainPressure extends HiddenPressureMainSnapshot>(
|
||||
orcaPage: Page,
|
||||
deps: { readMainPtyPressureDebug: (page: Page) => Promise<TMainPressure | null> },
|
||||
pressureOutputChars: number
|
||||
minimumDroppedChars: number
|
||||
): Promise<void> {
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user