mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(terminal): preserve synchronized frames on reveal (#16026)
This commit is contained in:
+182
-22
@@ -2674,7 +2674,7 @@
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-browser-link.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "failed",
|
||||
"durationSeconds": 60,
|
||||
"durationSeconds": 53,
|
||||
"summary": "The byte-identical final Electron oracle failed at its exact host/client identity barrier when the sole source delta replaced src/renderer/src/store/index.ts registration lines 110-113 with registerRuntimeHttpLinkBrowserOpener(null); no host-owned browser appeared within the deterministic 60-second bound."
|
||||
}
|
||||
],
|
||||
@@ -12284,7 +12284,7 @@
|
||||
"id": "terminal-render.pixel-refresh-repair",
|
||||
"title": "Forced repaint does not change revealed pane pixels",
|
||||
"maturity": "experimental",
|
||||
"protection": "none",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-rendering",
|
||||
"layer": "electron-visual-golden",
|
||||
"surfaces": [
|
||||
@@ -12296,47 +12296,207 @@
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon"],
|
||||
"coveredPlatforms": [],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Registered gap only; the live repro harness with a refresh-repair oracle exists but is not productized into the release-blocking terminal-rendering-golden suite.",
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local"],
|
||||
"coverageNotes": "A headed macOS Electron test drives explicit destructive atlas recovery while DEC 2026 output is held, then compares compositor screenshots before and after a direct full-buffer present. The same renderer choreography is provider-local, but daemon, SSH, remote-runtime, Linux, and Windows GPU runs remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/7133",
|
||||
"https://github.com/stablyai/orca/pull/7054"
|
||||
],
|
||||
"invariant": "After any reveal, restore, or wake, the pixels a pane displays must equal what a forced full model-invalidating repaint would draw from the buffer; a material refresh-repair diff means stale rendering escaped, whatever the mechanism.",
|
||||
"oracle": "Screenshot the revealed pane, force a model-invalidating full repaint, screenshot again, and assert the bounded pixel diff is immaterial; pair with a JS-level render-model-vs-buffer comparison. Compositor screenshots only, because WebGL canvas readback is blank with preserveDrawingBuffer disabled.",
|
||||
"commands": [],
|
||||
"testFiles": [],
|
||||
"assertionRefs": [],
|
||||
"evidenceRuns": [],
|
||||
"invariant": "After proven renderer damage triggers destructive recovery, visible pixels equal a full model-invalidating present of the current buffer.",
|
||||
"oracle": "Hold DEC 2026, invoke explicit atlas recovery, require an atlas clear, screenshot the pane, force a direct renderer present, and assert the bounded pixel diff is immaterial. Compositor screenshots only, because WebGL canvas readback is blank with preserveDrawingBuffer disabled.",
|
||||
"commands": [
|
||||
"pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
|
||||
"ORCA_E2E_RECORD_VIDEO=1 pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --repeat-each=3"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
|
||||
"tests/e2e/terminal-reveal-paused-render-repro.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
|
||||
"assertions": [
|
||||
"deduplicates a deferred dpr retry and consumes it after repair succeeds",
|
||||
"caps a deferred dpr transition at sixteen animation-frame retries"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
|
||||
"assertions": ["defers a heavy-reveal dpr present until the shared atlas recovery"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts",
|
||||
"assertions": [
|
||||
"repairs when the canvas and renderer dimensions share a stale dpr cache",
|
||||
"defers an unmeasurable WebGL canvas but accepts a renderer-less pane"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-reveal-paused-render-repro.spec.ts",
|
||||
"assertions": ["@headful atlas recovery presents a synchronized-output WebGL frame"]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 2,
|
||||
"summary": "All 98 renderer contracts passed, including same-DPR zero-scan behavior, visible-only fanout, tri-state DPR repair, deduplicated bounded retry, heavy-reveal repair, and listener cleanup."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 60,
|
||||
"summary": "Both headed oracles passed together. Explicit destructive atlas recovery presented the held synchronized-output buffer with zero pixels different from a direct renderer present; the test attached before/after screenshots plus pixel-diff metadata."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-22",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_RECORD_VIDEO=1 pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --repeat-each=3",
|
||||
"result": "passed",
|
||||
"durationSeconds": 48,
|
||||
"summary": "Three consecutive headed real-WebGL runs passed in 15.0-15.7 seconds each and emitted a 1920x1080 video per BrowserWindow."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 300,
|
||||
"scope": "release-blocking golden Electron visual test"
|
||||
"p95Seconds": 120,
|
||||
"scope": "focused prebuilt headed Electron visual test"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "Runs nightly only while the per-platform diff threshold stabilizes; the destination is the release-blocking terminal-rendering-golden suite in release-cut.yml."
|
||||
"status": "unknown",
|
||||
"evidence": "Five local macOS headed runs passed, including one on the final branch state and three consecutive video-recorded repeats; CI soak history and cross-platform GPU runs are not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "missing",
|
||||
"evidence": "Needs an intentionally fossilized render model (skip the model invalidation on reveal) proving the refresh-repair diff fires."
|
||||
"status": "complete",
|
||||
"evidence": "Without the forced synchronized-output present, the damaged compositor differed from a direct renderer present across 87.68% of pixels even though serialization already contained the new frame. With explicit destructive recovery, the same oracle passes the 1.5% material-diff threshold."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Soak-only; must not add product-path repaints beyond the existing reveal hardening."
|
||||
"evidence": "Destructive recovery remains event-driven and limited to confirmed renderer-lifecycle boundaries. Gate-local contracts prove ordinary same-DPR resize performs zero pane scans; a true transition scans only the visible manager; unavailable canvas retry is deduplicated and capped at 16 frames; and heavy reveal adds one bounded visible-pane scan. The shared terminal-render.atlas-recovery-fanout contracts separately prove zero hidden-manager atlas clears or presents and a per-pane display:none retry capped at 16 frames."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Productize the existing stale-render repro harness with the refresh-repair oracle and failure artifacts.",
|
||||
"Establish a stable material-diff threshold across platforms via nightly runs.",
|
||||
"Promotion means membership in the release-blocking terminal-rendering-golden suite that release-cut.yml runs against every release tag; never PR-blocking.",
|
||||
"Register the gate command as a direct spec-file invocation; the checker rejects --grep tag selectors, so do not reuse the existing --grep-based golden script as the command."
|
||||
"Add Linux and Windows GPU evidence or retain those platform gaps explicitly."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No manifest command yet.",
|
||||
"Buffer-clean, pixels-stale variants are otherwise invisible to every buffer oracle in the manifest."
|
||||
"The deterministic recovery test disables xterm's one-second safety timeout so the held-frame damage remains stable until recovery.",
|
||||
"The renderer-level frame injection isolates the paint failure; provider delivery and hidden-output snapshot restore have separate gates.",
|
||||
"Linux, Windows, daemon, SSH, and remote-runtime live GPU evidence is not yet recorded."
|
||||
],
|
||||
"demotionRule": "Drop from the golden suite back to nightly diagnostic if diff thresholds cannot be kept stable across CI runners."
|
||||
},
|
||||
{
|
||||
"id": "terminal-render.synchronized-reveal-coherence",
|
||||
"title": "Ordinary reveal preserves synchronized-output frame coherence",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-rendering",
|
||||
"layer": "electron-visual-golden",
|
||||
"surfaces": ["WebGL rendering", "tab reveal", "snapshot restore", "DEC 2026"],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local"],
|
||||
"coverageNotes": "A headed macOS Electron test uses a real xterm WebGL renderer and production tab reveal. It proves the coherent pre-hide canvas remains visible while DEC 2026 holds a newer buffer, then proves normal release and xterm's one-second watchdog publish complete frames. Renderer behavior is provider-neutral; live Linux, Windows, daemon, SSH, and remote-runtime GPU runs remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/7133",
|
||||
"https://github.com/stablyai/orca/pull/7054"
|
||||
],
|
||||
"invariant": "terminal-render.synchronized-reveal-coherence: an ordinary reveal never clears a healthy shared atlas or publishes a partially built DEC 2026 frame; it retains the last coherent canvas until release or xterm's bounded watchdog.",
|
||||
"oracle": "Paint and capture a coherent frame, hide the real terminal tab, build a distinct full-screen frame while DEC 2026 is held, reveal through production, and require zero atlas clears plus pixel-equivalence with the coherent frame. Then release normally and through the one-second watchdog, requiring both compositor frames to match a direct renderer present.",
|
||||
"commands": [
|
||||
"pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
|
||||
"tests/e2e/terminal-reveal-paused-render-repro.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
|
||||
"assertions": [
|
||||
"schedules an atlas-preserving present on a light tab reveal",
|
||||
"keeps heavy atlas recovery off a plain window refocus"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts",
|
||||
"assertions": ["preserves a synchronized frame on an ordinary reveal present"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
|
||||
"assertions": [
|
||||
"routes synchronized output through RenderService instead of the renderer",
|
||||
"releases observer pause before requesting the synchronized frame"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-reveal-paused-render-repro.spec.ts",
|
||||
"assertions": [
|
||||
"@headful reveal preserves the coherent frame until synchronized output releases"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 53,
|
||||
"summary": "Both final-head headed oracles passed together. The coherent held frame differed by 73 of 1,404,288 pixels (0.0052%), ordinary reveal performed zero atlas clears, normal release differed from the direct present by 95 pixels (0.0068%), and watchdog release matched exactly. All three ratios passed the 1.5% compositor threshold."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 120,
|
||||
"scope": "focused prebuilt headed Electron prevention and watchdog test"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "One final watchdog-inclusive macOS headed run passed; CI soak history and cross-platform GPU runs are not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "Before the prevention change, the ordinary reveal oracle failed deterministically because it observed three atlas clears. After the change, reveal performs zero clears and the first visible frame remains pixel-equivalent to the coherent pre-hide frame."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Ordinary reveal removes three shared-atlas clears and their global visible-manager presents. It adds one settled, event-driven full-viewport refresh request per revealed pane; DEC 2026 coalesces it and uses xterm's existing one-second watchdog. No polling, subprocess, provider call, PTY inventory, background-output work, listener, or unbounded retry was added; the display-none retry remains deduplicated and capped at 16 frames."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Accumulate stable macOS runs for both normal release and watchdog release.",
|
||||
"Add Linux and Windows GPU evidence or retain those platform gaps explicitly.",
|
||||
"Keep the destructive atlas-recovery oracle separate and passing."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The deterministic test injects full-screen marker frames through xterm rather than invoking a live model; genuine OpenCode full-window evidence separately proves the original corruption.",
|
||||
"Linux, Windows, daemon, SSH, and remote-runtime live GPU evidence is not yet recorded.",
|
||||
"The 1.5% compositor threshold tolerates cursor and shell-prompt timing noise; exact buffer content and atlas-clear counts are asserted separately."
|
||||
],
|
||||
"demotionRule": "Demote if ordinary reveal clears the shared atlas, the held frame stops matching its coherent baseline, release/watchdog output diverges from a direct present, or the headed oracle cannot remain deterministic."
|
||||
},
|
||||
{
|
||||
"id": "pty-delivery.renderer-lifecycle-accounting-reset",
|
||||
"title": "Renderer lifecycle reset clears leaked PTY delivery accounting so surviving PTYs cannot freeze",
|
||||
|
||||
+10
-2
@@ -33,14 +33,16 @@ const {
|
||||
scheduleRuntimeGraphSync,
|
||||
shouldSeedCacheTimerOnInitialTitle,
|
||||
toastInfo,
|
||||
notifyCodexPaneBoundForStaleSweep
|
||||
notifyCodexPaneBoundForStaleSweep,
|
||||
presentPaneViewportPreservingSynchronizedOutput
|
||||
} = vi.hoisted(() => ({
|
||||
resetAndRefreshAllTerminalWebglAtlases: vi.fn(),
|
||||
scheduleTerminalWebglAtlasRecovery: vi.fn(),
|
||||
scheduleRuntimeGraphSync: vi.fn(),
|
||||
shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false),
|
||||
toastInfo: vi.fn(),
|
||||
notifyCodexPaneBoundForStaleSweep: vi.fn()
|
||||
notifyCodexPaneBoundForStaleSweep: vi.fn(),
|
||||
presentPaneViewportPreservingSynchronizedOutput: vi.fn()
|
||||
}))
|
||||
|
||||
let mockStoreState: StoreState
|
||||
@@ -57,6 +59,11 @@ vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({
|
||||
resetAndRefreshAllTerminalWebglAtlases
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/pane-manager/pane-webgl-renderer', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
presentPaneViewportPreservingSynchronizedOutput
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-webgl-atlas-recovery', () => ({
|
||||
scheduleTerminalWebglAtlasRecovery
|
||||
}))
|
||||
@@ -201,6 +208,7 @@ describe('connectPanePty', () => {
|
||||
expect.stringContaining('remote snapshot with hidden remote output'),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(presentPaneViewportPreservingSynchronizedOutput).toHaveBeenCalledWith(pane)
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
|
||||
+8
@@ -11,6 +11,7 @@ import {
|
||||
writeTerminalOutput
|
||||
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll'
|
||||
import { presentPaneViewportPreservingSynchronizedOutput } from '@/lib/pane-manager/pane-webgl-renderer'
|
||||
import {
|
||||
buildMainModelSnapshotReplayWrites,
|
||||
hasPositiveTerminalDimensions,
|
||||
@@ -22,6 +23,7 @@ import { HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING } from './hidden-output-resto
|
||||
import { shouldWritePtyOutputForeground } from './foreground-output-scan'
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
import { restoredSnapshotPaintsPrintableContent } from '../restored-snapshot-coverage'
|
||||
import { recordTerminalFreezeBreadcrumb } from '../terminal-freeze-breadcrumbs'
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
@@ -152,6 +154,12 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession):
|
||||
session.recordRendererOrderedSeq(snapshot)
|
||||
recordTerminalOutput(session.pane.terminal)
|
||||
await waitForTerminalReplayWritesParsed(session.pane.terminal)
|
||||
if (session.deps.isVisibleRef.current) {
|
||||
presentPaneViewportPreservingSynchronizedOutput(session.pane)
|
||||
recordTerminalFreezeBreadcrumb('stale-pixel-restore-present', {
|
||||
paneId: session.pane.id
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
shouldRestore: () =>
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
vi.mock('@/lib/pane-manager/pane-manager-registry', () => ({
|
||||
resetAndRefreshAllTerminalWebglAtlases: vi.fn()
|
||||
}))
|
||||
const presentPaneViewport = vi.fn()
|
||||
vi.mock('@/lib/pane-manager/pane-webgl-renderer', () => ({
|
||||
presentPaneViewport: (pane: unknown) => presentPaneViewport(pane)
|
||||
}))
|
||||
vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({
|
||||
flushTerminalOutput: vi.fn(),
|
||||
requestTerminalBacklogRecovery: vi.fn()
|
||||
@@ -21,10 +25,6 @@ vi.mock('./pane-helpers', () => ({
|
||||
fitPanes: vi.fn(),
|
||||
focusActivePane: vi.fn()
|
||||
}))
|
||||
const scheduleTabRevealWebglAtlasRecovery = vi.fn()
|
||||
vi.mock('./terminal-webgl-atlas-recovery', () => ({
|
||||
scheduleTabRevealWebglAtlasRecovery: () => scheduleTabRevealWebglAtlasRecovery()
|
||||
}))
|
||||
const flushDeferredPaneMetricOptionsIfMeasurable = vi.fn((_pane: unknown) => false)
|
||||
vi.mock('@/lib/pane-manager/pane-fit', () => ({
|
||||
flushDeferredPaneMetricOptionsIfMeasurable: (pane: unknown) =>
|
||||
@@ -77,18 +77,19 @@ function resumeArgs(manager: FakeManager, shouldUseLightTabResume: boolean) {
|
||||
describe('resumeTerminalVisibility reveal repaint', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
repairPaneWebglCanvasDprMismatch.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('schedules a pane-scoped repaint on a light tab reveal', () => {
|
||||
it('schedules an atlas-preserving present on a light tab reveal', () => {
|
||||
// The light path is the "click the tab that was not open" gesture: it has
|
||||
// no rendering resume or fit, so without this repaint a hidden-while-
|
||||
// working pane keeps compositing pre-hide pixels.
|
||||
const manager = createManager()
|
||||
resumeTerminalVisibility(resumeArgs(manager, true))
|
||||
|
||||
expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1)
|
||||
expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled()
|
||||
expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1)
|
||||
expect(manager.resumeRendering).not.toHaveBeenCalled()
|
||||
expect(scheduleTabRevealWebglAtlasRecovery).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('captures native trim movement before enforcing viewport intent', async () => {
|
||||
@@ -147,6 +148,29 @@ describe('resumeTerminalVisibility reveal repaint', () => {
|
||||
expect(flushDeferredPaneMetricOptionsIfMeasurable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defers a heavy-reveal dpr present until the shared atlas recovery', async () => {
|
||||
const pane = { terminal: {} }
|
||||
const manager = createManager()
|
||||
manager.getPanes.mockReturnValue([pane])
|
||||
repairPaneWebglCanvasDprMismatch.mockReturnValueOnce(true)
|
||||
const { resetAndRefreshAllTerminalWebglAtlases } = vi.mocked(
|
||||
await import('@/lib/pane-manager/pane-manager-registry')
|
||||
)
|
||||
|
||||
resumeTerminalVisibility(resumeArgs(manager, false))
|
||||
|
||||
expect(repairPaneWebglCanvasDprMismatch).toHaveBeenCalledWith(pane)
|
||||
expect(presentPaneViewport).not.toHaveBeenCalled()
|
||||
expect(resetAndRefreshAllTerminalWebglAtlases).toHaveBeenCalledTimes(1)
|
||||
const atlasResetCallOrder = resetAndRefreshAllTerminalWebglAtlases.mock.invocationCallOrder[0]
|
||||
if (atlasResetCallOrder === undefined) {
|
||||
throw new Error('Shared atlas recovery call order missing')
|
||||
}
|
||||
expect(repairPaneWebglCanvasDprMismatch.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
atlasResetCallOrder
|
||||
)
|
||||
})
|
||||
|
||||
it('does not fit on a light tab reveal', () => {
|
||||
const manager = createManager()
|
||||
resumeTerminalVisibility(resumeArgs(manager, true))
|
||||
@@ -193,6 +217,27 @@ describe('resumeTerminalVisibility reveal repaint', () => {
|
||||
expect(manager.fitAllPanes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('repairs WebGL canvas backing-store dpr on window wake', () => {
|
||||
// Clamshell undock: dpr changes while the pane stayed "visible" with a
|
||||
// stale backing store; tab-reveal is not in the path.
|
||||
const first = { terminal: { name: 'pane-a' } }
|
||||
const second = { terminal: { name: 'pane-b' } }
|
||||
const manager = createManager()
|
||||
manager.getPanes.mockReturnValue([first, second])
|
||||
repairPaneWebglCanvasDprMismatch.mockReturnValueOnce(true)
|
||||
|
||||
recoverVisibleTerminalWindowWake({
|
||||
manager: manager as never as PaneManager,
|
||||
isActive: true,
|
||||
clearGlyphAtlases: false
|
||||
})
|
||||
|
||||
expect(repairPaneWebglCanvasDprMismatch).toHaveBeenCalledTimes(2)
|
||||
expect(repairPaneWebglCanvasDprMismatch).toHaveBeenNthCalledWith(1, first)
|
||||
expect(repairPaneWebglCanvasDprMismatch).toHaveBeenNthCalledWith(2, second)
|
||||
expect(presentPaneViewport).toHaveBeenCalledWith(first)
|
||||
})
|
||||
|
||||
it('latches viewport intent before refocus recovery flushes streaming output', async () => {
|
||||
const terminal = { name: 'streaming-terminal' }
|
||||
const manager = createManager()
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
resetTerminalLinkifierHoverState
|
||||
} from '@/lib/pane-manager/terminal-linkifier-hover-reset'
|
||||
import { focusActivePane } from './pane-helpers'
|
||||
import { scheduleTabRevealWebglAtlasRecovery } from './terminal-webgl-atlas-recovery'
|
||||
import { flushDeferredPaneMetricOptionsIfMeasurable } from '@/lib/pane-manager/pane-fit'
|
||||
import { repairPaneWebglCanvasDprMismatch } from '@/lib/pane-manager/terminal-canvas-dpr-repair'
|
||||
import { presentPaneViewport } from '@/lib/pane-manager/pane-webgl-renderer'
|
||||
|
||||
const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024
|
||||
const WINDOW_WAKE_FLUSH_CHARS = 64 * 1024
|
||||
@@ -88,8 +88,6 @@ export function resumeTerminalVisibility({
|
||||
// overlay's delayed geometry fit. Still request hidden-output recovery:
|
||||
// agent TUIs can suppress hidden bytes until the pane is foregrounded.
|
||||
requestLightTabBacklogRecovery(manager)
|
||||
// Why: reveal is the lifecycle boundary that owns hidden renderer repair.
|
||||
scheduleTabRevealWebglAtlasRecovery()
|
||||
if (flushedDeferredMetrics) {
|
||||
// Why: the light path normally skips fitting, but flushed metrics changed
|
||||
// cell size — refit so cols/rows match before the overlay settles.
|
||||
@@ -109,10 +107,15 @@ export function resumeTerminalVisibility({
|
||||
// terminals; refresh after reset so rebuilt atlases repaint from xterm.
|
||||
resetAndRefreshAllTerminalWebglAtlases('visibility-resume')
|
||||
}
|
||||
// Why: the synchronous recovery above can fire before the revealed pane is
|
||||
// attached and laid out. Follow up after layout with one shared-atlas-safe
|
||||
// recovery covering every visible terminal manager.
|
||||
manager.scheduleRevealRepaint()
|
||||
if (shouldUseLightTabResume) {
|
||||
// Why: preserve the last coherent frame while a TUI holds DEC 2026. The
|
||||
// settled refresh arms xterm's watchdog without clearing shared GPU data.
|
||||
manager.scheduleRevealPresent()
|
||||
} else {
|
||||
// Why: rendering was recreated, so the heavy path still needs the proven
|
||||
// shared-atlas recovery after layout settles.
|
||||
manager.scheduleRevealRepaint()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -161,6 +164,13 @@ export function recoverVisibleTerminalWindowWake({
|
||||
// Why: backlog writes can expose transient viewport geometry while parsing.
|
||||
syncTerminalViewportIntents(manager)
|
||||
for (const pane of manager.getPanes()) {
|
||||
// Why: clamshell undock / monitor move changes devicePixelRatio while the
|
||||
// pane can stay "visible" with a stale WebGL backing store. The addon's
|
||||
// device-pixel observer misses that (no CSS-box change, or no box while
|
||||
// the lid was closed). Repair here — not only on tab reveal.
|
||||
if (repairPaneWebglCanvasDprMismatch(pane)) {
|
||||
presentPaneViewport(pane)
|
||||
}
|
||||
requestTerminalBacklogRecovery(pane.terminal)
|
||||
flushTerminalOutput(pane.terminal, { maxChars: WINDOW_WAKE_FLUSH_CHARS })
|
||||
// Why: window blur fires mouseleave, clearing xterm's current link but not
|
||||
@@ -214,6 +224,12 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean):
|
||||
// Windows (ANGLE -> D3D11) it can be 100-500 ms but a deferred resume
|
||||
// would paint a stretched DOM-fallback flash, which is worse UX.
|
||||
manager.resumeRendering()
|
||||
// Why: unchanged grid geometry can skip the reveal fit, but a retained WebGL
|
||||
// canvas may still carry the previous display's backing-store DPR. The
|
||||
// caller's atlas recovery presents the final shared-atlas generation.
|
||||
for (const pane of manager.getPanes()) {
|
||||
repairPaneWebglCanvasDprMismatch(pane)
|
||||
}
|
||||
// Why: resumeRendering just re-attached WebGL, whose cell metrics briefly differ
|
||||
// from the DOM renderer's; a raw fit here reflows on a transient one-column-off
|
||||
// grid and garbles diff-painting inline TUIs (grok minimize→restore).
|
||||
|
||||
@@ -5,8 +5,7 @@ import {
|
||||
} from '@/lib/pane-manager/pane-manager-registry'
|
||||
import * as terminalWebglAtlasRecovery from './terminal-webgl-atlas-recovery'
|
||||
|
||||
const { scheduleImagePasteWebglAtlasRecovery, scheduleTabRevealWebglAtlasRecovery } =
|
||||
terminalWebglAtlasRecovery
|
||||
const { scheduleImagePasteWebglAtlasRecovery } = terminalWebglAtlasRecovery
|
||||
|
||||
describe('terminal WebGL atlas recovery', () => {
|
||||
const registeredManagers: { resetWebglTextureAtlases(): void }[] = []
|
||||
@@ -151,26 +150,4 @@ describe('terminal WebGL atlas recovery', () => {
|
||||
expect(healthyManager.refreshAllPanes).toHaveBeenCalledTimes(3)
|
||||
expect(manager.refreshAllPanes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recovers immediately on a tab reveal', () => {
|
||||
vi.useFakeTimers()
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
rafCallbacks.push(callback)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
)
|
||||
const manager = registerManager()
|
||||
|
||||
scheduleTabRevealWebglAtlasRecovery()
|
||||
rafCallbacks[0]?.(0)
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
|
||||
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1)
|
||||
vi.advanceTimersByTime(120)
|
||||
vi.advanceTimersByTime(380)
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(3)
|
||||
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,9 +33,3 @@ export function scheduleImagePasteWebglAtlasRecovery(): void {
|
||||
// one-shot event, so recover immediately rather than debouncing.
|
||||
scheduleAtlasRecoveryBurst('image-paste')
|
||||
}
|
||||
|
||||
export function scheduleTabRevealWebglAtlasRecovery(): void {
|
||||
// Why: a tab reveal is an explicit renderer lifecycle boundary where hidden
|
||||
// GPU state can be stale; ordinary PTY output must not clear the shared atlas.
|
||||
scheduleAtlasRecoveryBurst('tab-reveal')
|
||||
}
|
||||
|
||||
+12
-4
@@ -260,6 +260,8 @@ describe('useTerminalPaneGlobalEffects', () => {
|
||||
|
||||
manager.resumeRendering.mockClear()
|
||||
manager.resetWebglTextureAtlases.mockClear()
|
||||
manager.scheduleRevealRepaint.mockClear()
|
||||
manager.scheduleRevealPresent.mockClear()
|
||||
manager.refreshAllPanes.mockClear()
|
||||
manager.suspendRendering.mockClear()
|
||||
mocks.fitAndFocusPanes.mockClear()
|
||||
@@ -289,8 +291,10 @@ describe('useTerminalPaneGlobalEffects', () => {
|
||||
expect(manager.resumeRendering).not.toHaveBeenCalled()
|
||||
expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled()
|
||||
expect(mocks.fitPanes).not.toHaveBeenCalled()
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
|
||||
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1)
|
||||
expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled()
|
||||
expect(manager.refreshAllPanes).not.toHaveBeenCalled()
|
||||
expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled()
|
||||
expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.focusActivePane).toHaveBeenCalledWith(manager)
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
@@ -342,6 +346,8 @@ describe('useTerminalPaneGlobalEffects', () => {
|
||||
|
||||
manager.resumeRendering.mockClear()
|
||||
manager.resetWebglTextureAtlases.mockClear()
|
||||
manager.scheduleRevealRepaint.mockClear()
|
||||
manager.scheduleRevealPresent.mockClear()
|
||||
manager.refreshAllPanes.mockClear()
|
||||
mocks.fitAndFocusPanes.mockClear()
|
||||
mocks.fitPanes.mockClear()
|
||||
@@ -361,8 +367,10 @@ describe('useTerminalPaneGlobalEffects', () => {
|
||||
expect(manager.resumeRendering).not.toHaveBeenCalled()
|
||||
expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled()
|
||||
expect(mocks.fitPanes).not.toHaveBeenCalled()
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
|
||||
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1)
|
||||
expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled()
|
||||
expect(manager.refreshAllPanes).not.toHaveBeenCalled()
|
||||
expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled()
|
||||
expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.focusActivePane).toHaveBeenCalledWith(manager)
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
+134
-2
@@ -2,15 +2,30 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { PaneWebglCanvasDprRepairState } from '@/lib/pane-manager/terminal-canvas-dpr-repair'
|
||||
|
||||
const { recoverVisibleTerminalWindowWakeMock } = vi.hoisted(() => ({
|
||||
recoverVisibleTerminalWindowWakeMock: vi.fn()
|
||||
const {
|
||||
recoverVisibleTerminalWindowWakeMock,
|
||||
repairPaneWebglCanvasDprMock,
|
||||
presentPaneViewportMock
|
||||
} = vi.hoisted(() => ({
|
||||
recoverVisibleTerminalWindowWakeMock: vi.fn(),
|
||||
repairPaneWebglCanvasDprMock: vi.fn<() => PaneWebglCanvasDprRepairState>(() => 'current'),
|
||||
presentPaneViewportMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-visibility-resume', () => ({
|
||||
recoverVisibleTerminalWindowWake: recoverVisibleTerminalWindowWakeMock
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/pane-manager/terminal-canvas-dpr-repair', () => ({
|
||||
repairPaneWebglCanvasDpr: repairPaneWebglCanvasDprMock
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/pane-manager/pane-webgl-renderer', () => ({
|
||||
presentPaneViewport: presentPaneViewportMock
|
||||
}))
|
||||
|
||||
import { useTerminalWindowWakeRecovery } from './use-terminal-window-wake-recovery'
|
||||
import {
|
||||
getTerminalFreezeBreadcrumbs,
|
||||
@@ -29,6 +44,9 @@ describe('useTerminalWindowWakeRecovery', () => {
|
||||
beforeEach(() => {
|
||||
systemResumedCallback = null
|
||||
recoverVisibleTerminalWindowWakeMock.mockClear()
|
||||
repairPaneWebglCanvasDprMock.mockClear()
|
||||
presentPaneViewportMock.mockClear()
|
||||
repairPaneWebglCanvasDprMock.mockReturnValue('current')
|
||||
unsubscribeSystemResumed.mockClear()
|
||||
onSystemResumed.mockClear()
|
||||
resetTerminalFreezeBreadcrumbsForTesting()
|
||||
@@ -180,4 +198,118 @@ describe('useTerminalWindowWakeRecovery', () => {
|
||||
|
||||
expect(onSystemResumed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('repairs WebGL canvas dpr on window resize without wiping the glyph atlas', () => {
|
||||
// Chromium emits resize when devicePixelRatio changes (undock / monitor
|
||||
// move) even if the CSS box is unchanged.
|
||||
const pane = { id: 1, terminal: {} }
|
||||
const resizeManager = { getPanes: () => [pane] } as unknown as PaneManager
|
||||
repairPaneWebglCanvasDprMock.mockReturnValue('repaired')
|
||||
vi.stubGlobal('devicePixelRatio', 1)
|
||||
const { unmount } = renderHook(() =>
|
||||
useTerminalWindowWakeRecovery({
|
||||
isVisible: true,
|
||||
managerRef: { current: resizeManager },
|
||||
isActiveRef: { current: true },
|
||||
isVisibleRef: { current: true }
|
||||
})
|
||||
)
|
||||
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
expect(repairPaneWebglCanvasDprMock).not.toHaveBeenCalled()
|
||||
|
||||
vi.stubGlobal('devicePixelRatio', 2)
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
|
||||
expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledWith(pane)
|
||||
expect(presentPaneViewportMock).toHaveBeenCalledWith(pane)
|
||||
expect(recoverVisibleTerminalWindowWakeMock).not.toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
vi.stubGlobal('devicePixelRatio', 3)
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retains a dpr transition until the visible manager is available', () => {
|
||||
const pane = { id: 1, terminal: {} }
|
||||
const managerRef: { current: PaneManager | null } = { current: null }
|
||||
vi.stubGlobal('devicePixelRatio', 1)
|
||||
renderHook(() =>
|
||||
useTerminalWindowWakeRecovery({
|
||||
isVisible: true,
|
||||
managerRef,
|
||||
isActiveRef: { current: true },
|
||||
isVisibleRef: { current: true }
|
||||
})
|
||||
)
|
||||
|
||||
vi.stubGlobal('devicePixelRatio', 2)
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
expect(repairPaneWebglCanvasDprMock).not.toHaveBeenCalled()
|
||||
|
||||
managerRef.current = { getPanes: () => [pane] } as unknown as PaneManager
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledWith(pane)
|
||||
})
|
||||
|
||||
it('deduplicates a deferred dpr retry and consumes it after repair succeeds', () => {
|
||||
const pane = { id: 1, terminal: {} }
|
||||
const callbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callbacks.push(callback)
|
||||
return callbacks.length
|
||||
})
|
||||
vi.stubGlobal('devicePixelRatio', 1)
|
||||
repairPaneWebglCanvasDprMock.mockReturnValueOnce('deferred').mockReturnValue('repaired')
|
||||
renderHook(() =>
|
||||
useTerminalWindowWakeRecovery({
|
||||
isVisible: true,
|
||||
managerRef: { current: { getPanes: () => [pane] } as unknown as PaneManager },
|
||||
isActiveRef: { current: true },
|
||||
isVisibleRef: { current: true }
|
||||
})
|
||||
)
|
||||
|
||||
vi.stubGlobal('devicePixelRatio', 2)
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(1)
|
||||
expect(callbacks).toHaveLength(1)
|
||||
|
||||
callbacks.shift()?.(performance.now())
|
||||
expect(presentPaneViewportMock).toHaveBeenCalledWith(pane)
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('caps a deferred dpr transition at sixteen animation-frame retries', () => {
|
||||
const pane = { id: 1, terminal: {} }
|
||||
const callbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callbacks.push(callback)
|
||||
return callbacks.length
|
||||
})
|
||||
vi.stubGlobal('devicePixelRatio', 1)
|
||||
repairPaneWebglCanvasDprMock.mockReturnValue('deferred')
|
||||
renderHook(() =>
|
||||
useTerminalWindowWakeRecovery({
|
||||
isVisible: true,
|
||||
managerRef: { current: { getPanes: () => [pane] } as unknown as PaneManager },
|
||||
isActiveRef: { current: true },
|
||||
isVisibleRef: { current: true }
|
||||
})
|
||||
)
|
||||
|
||||
vi.stubGlobal('devicePixelRatio', 2)
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
let callbackCount = 0
|
||||
while (callbacks.length > 0) {
|
||||
callbacks.shift()?.(performance.now())
|
||||
callbackCount += 1
|
||||
}
|
||||
|
||||
expect(callbackCount).toBe(16)
|
||||
expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(17)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import { recoverVisibleTerminalWindowWake } from './terminal-visibility-resume'
|
||||
import { repairPaneWebglCanvasDpr } from '@/lib/pane-manager/terminal-canvas-dpr-repair'
|
||||
import { presentPaneViewport } from '@/lib/pane-manager/pane-webgl-renderer'
|
||||
import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs'
|
||||
import type { IDisposable } from '@xterm/xterm'
|
||||
|
||||
@@ -16,6 +18,8 @@ type WindowWakePtyBinding = IDisposable & {
|
||||
reassertPtySizeAfterWindowWake?: () => void
|
||||
}
|
||||
|
||||
const DPR_RECOVERY_RETRY_FRAMES = 16
|
||||
|
||||
export function useTerminalWindowWakeRecovery({
|
||||
isVisible,
|
||||
managerRef,
|
||||
@@ -28,7 +32,10 @@ export function useTerminalWindowWakeRecovery({
|
||||
return
|
||||
}
|
||||
let wakeRecoveryFrameId: number | null = null
|
||||
let dprRecoveryFrameId: number | null = null
|
||||
let dprRecoveryFramesRemaining = 0
|
||||
let settledClearGlyphAtlases = false
|
||||
let observedDevicePixelRatio = window.devicePixelRatio
|
||||
const cancelScheduledWakeRecovery = (): void => {
|
||||
if (wakeRecoveryFrameId === null || typeof cancelAnimationFrame !== 'function') {
|
||||
wakeRecoveryFrameId = null
|
||||
@@ -37,6 +44,13 @@ export function useTerminalWindowWakeRecovery({
|
||||
cancelAnimationFrame(wakeRecoveryFrameId)
|
||||
wakeRecoveryFrameId = null
|
||||
}
|
||||
const cancelScheduledDprRecovery = (): void => {
|
||||
if (dprRecoveryFrameId !== null && typeof cancelAnimationFrame === 'function') {
|
||||
cancelAnimationFrame(dprRecoveryFrameId)
|
||||
}
|
||||
dprRecoveryFrameId = null
|
||||
dprRecoveryFramesRemaining = 0
|
||||
}
|
||||
const reassertPanePtySizes = (): void => {
|
||||
for (const binding of panePtyBindingsRef?.current.values() ?? []) {
|
||||
// Why: one settled read avoids duplicate SSH RPCs while still detecting a dropped resize.
|
||||
@@ -111,7 +125,59 @@ export function useTerminalWindowWakeRecovery({
|
||||
recoverVisibleWake(true, 'system-resumed')
|
||||
}
|
||||
}
|
||||
const repairVisiblePanesForDpr = (devicePixelRatio: number): boolean => {
|
||||
const manager = managerRef.current
|
||||
if (!manager || !isVisibleRef.current) {
|
||||
return false
|
||||
}
|
||||
let deferred = false
|
||||
for (const pane of manager.getPanes?.() ?? []) {
|
||||
const state = repairPaneWebglCanvasDpr(pane)
|
||||
deferred ||= state === 'deferred'
|
||||
if (state === 'repaired') {
|
||||
presentPaneViewport(pane)
|
||||
}
|
||||
}
|
||||
if (!deferred) {
|
||||
observedDevicePixelRatio = devicePixelRatio
|
||||
}
|
||||
return !deferred
|
||||
}
|
||||
const scheduleDprRecovery = (): void => {
|
||||
if (dprRecoveryFrameId !== null || typeof requestAnimationFrame !== 'function') {
|
||||
return
|
||||
}
|
||||
dprRecoveryFramesRemaining = DPR_RECOVERY_RETRY_FRAMES
|
||||
const retry = (): void => {
|
||||
dprRecoveryFrameId = null
|
||||
if (repairVisiblePanesForDpr(window.devicePixelRatio)) {
|
||||
dprRecoveryFramesRemaining = 0
|
||||
return
|
||||
}
|
||||
dprRecoveryFramesRemaining -= 1
|
||||
if (dprRecoveryFramesRemaining > 0) {
|
||||
dprRecoveryFrameId = requestAnimationFrame(retry)
|
||||
}
|
||||
}
|
||||
dprRecoveryFrameId = requestAnimationFrame(retry)
|
||||
}
|
||||
const onWindowResize = (): void => {
|
||||
// Why: Chromium emits window resize on devicePixelRatio changes even when
|
||||
// the CSS box is unchanged (monitor move / undock). xterm's own observer
|
||||
// misses that while the canvas had no box (laptop lid closed).
|
||||
const devicePixelRatio = window.devicePixelRatio
|
||||
if (devicePixelRatio === observedDevicePixelRatio) {
|
||||
return
|
||||
}
|
||||
if (dprRecoveryFrameId !== null) {
|
||||
return
|
||||
}
|
||||
if (!repairVisiblePanesForDpr(devicePixelRatio)) {
|
||||
scheduleDprRecovery()
|
||||
}
|
||||
}
|
||||
window.addEventListener('focus', onFocus)
|
||||
window.addEventListener('resize', onWindowResize)
|
||||
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
@@ -125,7 +191,9 @@ export function useTerminalWindowWakeRecovery({
|
||||
: null
|
||||
return () => {
|
||||
cancelScheduledWakeRecovery()
|
||||
cancelScheduledDprRecovery()
|
||||
window.removeEventListener('focus', onFocus)
|
||||
window.removeEventListener('resize', onWindowResize)
|
||||
if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
|
||||
@@ -85,9 +85,43 @@ describe('pane manager registry', () => {
|
||||
expect(order).toEqual(['first-reset', 'second-reset', 'first-refresh', 'second-refresh'])
|
||||
})
|
||||
|
||||
it('clears every recovered atlas before presenting any pane', () => {
|
||||
// Why: per-pane clear+present interleaves a present against atlas generation
|
||||
// N with the next pane's wipe to N+1. The first synchronized-output column
|
||||
// then keeps pre-hide footer pixels. Wipe first, present once the generation
|
||||
// is final.
|
||||
const order: string[] = []
|
||||
const first = {
|
||||
resetWebglTextureAtlases: vi.fn<() => void>(() => order.push('first-reset')),
|
||||
clearWebglTextureAtlases: vi.fn<() => void>(() => order.push('first-clear')),
|
||||
presentForcedViewports: vi.fn<() => void>(() => order.push('first-present')),
|
||||
refreshAllPanes: vi.fn<() => void>(() => order.push('first-refresh')),
|
||||
isVisibleForAtlasRecovery: () => true
|
||||
}
|
||||
const second = {
|
||||
resetWebglTextureAtlases: vi.fn<() => void>(() => order.push('second-reset')),
|
||||
clearWebglTextureAtlases: vi.fn<() => void>(() => order.push('second-clear')),
|
||||
presentForcedViewports: vi.fn<() => void>(() => order.push('second-present')),
|
||||
refreshAllPanes: vi.fn<() => void>(() => order.push('second-refresh')),
|
||||
isVisibleForAtlasRecovery: () => true
|
||||
}
|
||||
registerLivePaneManager(first)
|
||||
registeredManagers.push(first)
|
||||
registerLivePaneManager(second)
|
||||
registeredManagers.push(second)
|
||||
|
||||
resetAndRefreshAllTerminalWebglAtlases()
|
||||
|
||||
expect(order).toEqual(['first-clear', 'second-clear', 'first-present', 'second-present'])
|
||||
expect(first.resetWebglTextureAtlases).not.toHaveBeenCalled()
|
||||
expect(second.refreshAllPanes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bounds atlas recovery to visible managers', () => {
|
||||
const visible = {
|
||||
resetWebglTextureAtlases: vi.fn<() => void>(),
|
||||
clearWebglTextureAtlases: vi.fn<() => void>(),
|
||||
presentForcedViewports: vi.fn<() => void>(),
|
||||
refreshAllPanes: vi.fn<() => void>(),
|
||||
isVisibleForAtlasRecovery: () => true
|
||||
}
|
||||
@@ -95,6 +129,8 @@ describe('pane manager registry', () => {
|
||||
registeredManagers.push(visible)
|
||||
const hidden = Array.from({ length: 64 }, () => ({
|
||||
resetWebglTextureAtlases: vi.fn<() => void>(),
|
||||
clearWebglTextureAtlases: vi.fn<() => void>(),
|
||||
presentForcedViewports: vi.fn<() => void>(),
|
||||
refreshAllPanes: vi.fn<() => void>(),
|
||||
isVisibleForAtlasRecovery: () => false
|
||||
}))
|
||||
@@ -105,12 +141,14 @@ describe('pane manager registry', () => {
|
||||
|
||||
resetAndRefreshAllTerminalWebglAtlases()
|
||||
|
||||
expect(visible.resetWebglTextureAtlases).toHaveBeenCalledOnce()
|
||||
expect(visible.refreshAllPanes).toHaveBeenCalledOnce()
|
||||
expect(visible.clearWebglTextureAtlases).toHaveBeenCalledOnce()
|
||||
expect(visible.presentForcedViewports).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
hidden.every((manager) => manager.resetWebglTextureAtlases.mock.calls.length === 0)
|
||||
hidden.every((manager) => manager.clearWebglTextureAtlases.mock.calls.length === 0)
|
||||
).toBe(true)
|
||||
expect(hidden.every((manager) => manager.refreshAllPanes.mock.calls.length === 0)).toBe(true)
|
||||
expect(hidden.every((manager) => manager.presentForcedViewports.mock.calls.length === 0)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('continues reset-and-refresh recovery when one manager throws', () => {
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { PaneRenderingDiagnostics } from './pane-manager-types'
|
||||
|
||||
type RegisteredPaneManager = {
|
||||
resetWebglTextureAtlases(): void
|
||||
clearWebglTextureAtlases?: () => void
|
||||
presentForcedViewports?: () => void
|
||||
fitAllPanes?: () => void
|
||||
refreshAllPanes?: () => void
|
||||
getRenderingDiagnostics?: () => PaneRenderingDiagnostics[]
|
||||
@@ -60,9 +62,17 @@ export function resetAndRefreshAllTerminalWebglAtlases(reason?: string): void {
|
||||
...(reason ? { reason } : {})
|
||||
})
|
||||
const resetManagers: RegisteredPaneManager[] = []
|
||||
// Why: clearTextureAtlas() is module-global. Clearing then presenting per
|
||||
// pane interleaves a present against generation N with the next pane's wipe
|
||||
// to N+1, so the first synchronized-output column keeps pre-hide footer pixels.
|
||||
// Wipe every recovered atlas first; present only once the generation is final.
|
||||
for (const manager of recoveryManagers) {
|
||||
try {
|
||||
manager.resetWebglTextureAtlases()
|
||||
if (manager.clearWebglTextureAtlases) {
|
||||
manager.clearWebglTextureAtlases()
|
||||
} else {
|
||||
manager.resetWebglTextureAtlases()
|
||||
}
|
||||
resetManagers.push(manager)
|
||||
} catch {
|
||||
// Why: recovery is best-effort during pane teardown; a disposed manager
|
||||
@@ -71,7 +81,11 @@ export function resetAndRefreshAllTerminalWebglAtlases(reason?: string): void {
|
||||
}
|
||||
for (const manager of resetManagers) {
|
||||
try {
|
||||
manager.refreshAllPanes?.()
|
||||
if (manager.presentForcedViewports) {
|
||||
manager.presentForcedViewports()
|
||||
} else {
|
||||
manager.refreshAllPanes?.()
|
||||
}
|
||||
} catch {
|
||||
// Why: a pane can unmount between atlas reset and repaint; later
|
||||
// managers still need to repaint from their xterm buffers.
|
||||
|
||||
@@ -26,6 +26,8 @@ import { applyTerminalGpuAcceleration } from './pane-terminal-gpu-acceleration'
|
||||
import { rebuildAttachedWebgl } from './pane-webgl-reattach'
|
||||
import {
|
||||
markPaneComplexScriptOutput,
|
||||
clearPaneWebglTextureAtlases,
|
||||
presentPaneViewports,
|
||||
resetPaneWebglTextureAtlases,
|
||||
resumePaneRendering,
|
||||
setPaneGpuRenderingState,
|
||||
@@ -280,6 +282,14 @@ export class PaneManager {
|
||||
resetPaneWebglTextureAtlases(this.panes.values())
|
||||
}
|
||||
|
||||
clearWebglTextureAtlases(): void {
|
||||
clearPaneWebglTextureAtlases(this.panes.values())
|
||||
}
|
||||
|
||||
presentForcedViewports(): void {
|
||||
presentPaneViewports(this.panes.values())
|
||||
}
|
||||
|
||||
setAtlasRecoveryVisible(visible: boolean): void {
|
||||
this.atlasRecoveryVisible = visible
|
||||
}
|
||||
@@ -296,8 +306,7 @@ export class PaneManager {
|
||||
}
|
||||
|
||||
scheduleRevealPresent(): void {
|
||||
// Why: same destroy guard as scheduleRevealRepaint, but presents without
|
||||
// clearing the shared glyph atlas — used by the plain-refocus recovery.
|
||||
// Why: ordinary reveal keeps the coherent canvas until DEC 2026 releases.
|
||||
schedulePaneRevealPresent(() => (this.destroyed ? [] : this.panes.values()))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
disposeWebgl,
|
||||
isPaneWebglContextLost,
|
||||
markComplexScriptOutput,
|
||||
clearWebglTextureAtlas,
|
||||
presentPaneViewport,
|
||||
resetWebglTextureAtlas
|
||||
} from './pane-webgl-renderer'
|
||||
import { rebuildAttachedWebgl, reattachWebglIfNeeded } from './pane-webgl-reattach'
|
||||
@@ -101,3 +103,15 @@ export function resetPaneWebglTextureAtlases(panes: Iterable<ManagedPaneInternal
|
||||
resetWebglTextureAtlas(pane)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPaneWebglTextureAtlases(panes: Iterable<ManagedPaneInternal>): void {
|
||||
for (const pane of panes) {
|
||||
clearWebglTextureAtlas(pane)
|
||||
}
|
||||
}
|
||||
|
||||
export function presentPaneViewports(panes: Iterable<ManagedPaneInternal>): void {
|
||||
for (const pane of panes) {
|
||||
presentPaneViewport(pane)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ManagedPaneInternal } from './pane-manager-types'
|
||||
import { reattachWebglIfNeeded } from './pane-webgl-reattach'
|
||||
import { presentPaneViewportPreservingSynchronizedOutput } from './pane-webgl-renderer'
|
||||
import { resetAndRefreshAllTerminalWebglAtlases } from './pane-manager-registry'
|
||||
|
||||
type PaneGetter = () => Iterable<ManagedPaneInternal>
|
||||
@@ -83,21 +84,10 @@ export function schedulePaneRevealRepaint(getPanes: () => Iterable<ManagedPaneIn
|
||||
scheduleSettledFrame(flushPaneRevealRepaints)
|
||||
}
|
||||
|
||||
/**
|
||||
* Presents already-visible panes without clearing the shared glyph atlas.
|
||||
*
|
||||
* Why: a plain window refocus never hid its panes, so their WebGL model is
|
||||
* already current — a `refresh` re-presents the live buffer (covering a
|
||||
* compositor that dropped frames while occluded). Using the atlas-clearing
|
||||
* reveal repaint here would wipe the atlas shared by every same-config pane and
|
||||
* re-arm the mid-stream page-merge garble race (xterm.js issue 4480); this path
|
||||
* must stay texture-atlas-preserving.
|
||||
*/
|
||||
/** Presents panes without clearing the shared glyph atlas or bypassing DEC 2026. */
|
||||
export function schedulePaneRevealPresent(getPanes: () => Iterable<ManagedPaneInternal>): void {
|
||||
forEachPaneOnSettledFrame(getPanes, (pane) => {
|
||||
reattachWebglIfNeeded(pane)
|
||||
if (pane.terminal.rows > 0) {
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
}
|
||||
presentPaneViewportPreservingSynchronizedOutput(pane)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { ManagedPaneInternal } from './pane-manager-types'
|
||||
import {
|
||||
attachWebgl,
|
||||
clearTerminalWebglAttachBackoff,
|
||||
presentPaneViewport,
|
||||
presentPaneViewportPreservingSynchronizedOutput,
|
||||
resetTerminalWebglSuggestion,
|
||||
resetWebglTextureAtlas
|
||||
} from './pane-webgl-renderer'
|
||||
@@ -75,6 +77,7 @@ type FakeRenderService = {
|
||||
function createPausedPane(display: 'block' | 'none'): {
|
||||
pane: ManagedPaneInternal
|
||||
renderService: FakeRenderService
|
||||
setDisplay: (next: 'block' | 'none') => void
|
||||
} {
|
||||
const pane = createPane()
|
||||
const renderService: FakeRenderService = {
|
||||
@@ -87,7 +90,8 @@ function createPausedPane(display: 'block' | 'none'): {
|
||||
}
|
||||
})
|
||||
}
|
||||
const view = { getComputedStyle: () => ({ display }) }
|
||||
let currentDisplay = display
|
||||
const view = { getComputedStyle: () => ({ display: currentDisplay }) }
|
||||
const element = { ownerDocument: { defaultView: view }, parentElement: null }
|
||||
pane.container = element as never
|
||||
pane.xtermContainer = element as never
|
||||
@@ -96,7 +100,13 @@ function createPausedPane(display: 'block' | 'none'): {
|
||||
refresh: vi.fn(() => renderService.refreshRows(0, pane.terminal.rows - 1)),
|
||||
_core: { _renderService: renderService }
|
||||
} as never
|
||||
return { pane, renderService }
|
||||
return {
|
||||
pane,
|
||||
renderService,
|
||||
setDisplay: (next) => {
|
||||
currentDisplay = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('terminal WebGL addon lifecycle', () => {
|
||||
@@ -168,6 +178,58 @@ describe('terminal WebGL addon lifecycle', () => {
|
||||
expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23)
|
||||
})
|
||||
|
||||
it('falls back to terminal.refresh on an unpaused, unsynchronized pane', () => {
|
||||
// Restore-after-replay and other callers use presentPaneViewport even when
|
||||
// forceFullViewportPresent is a no-op (not paused, no DEC 2026).
|
||||
const refreshRows = vi.fn()
|
||||
const renderRows = vi.fn()
|
||||
const pane = createPane()
|
||||
pane.terminal = {
|
||||
...pane.terminal,
|
||||
refresh: vi.fn(),
|
||||
_core: {
|
||||
_renderService: {
|
||||
_isPaused: false,
|
||||
_needsFullRefresh: false,
|
||||
refreshRows,
|
||||
_renderer: { value: { renderRows } }
|
||||
},
|
||||
coreService: { decPrivateModes: { synchronizedOutput: false } }
|
||||
}
|
||||
} as never
|
||||
|
||||
presentPaneViewport(pane)
|
||||
|
||||
expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23)
|
||||
expect(refreshRows).not.toHaveBeenCalled()
|
||||
expect(renderRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves a synchronized frame on an ordinary reveal present', () => {
|
||||
const refreshRows = vi.fn()
|
||||
const renderRows = vi.fn()
|
||||
const pane = createPane()
|
||||
pane.terminal = {
|
||||
...pane.terminal,
|
||||
refresh: vi.fn(),
|
||||
_core: {
|
||||
_renderService: {
|
||||
_isPaused: false,
|
||||
_needsFullRefresh: false,
|
||||
refreshRows,
|
||||
_renderer: { value: { renderRows } }
|
||||
},
|
||||
coreService: { decPrivateModes: { synchronizedOutput: true } }
|
||||
}
|
||||
} as never
|
||||
|
||||
presentPaneViewportPreservingSynchronizedOutput(pane)
|
||||
|
||||
expect(refreshRows).toHaveBeenCalledWith(0, 23, true)
|
||||
expect(renderRows).not.toHaveBeenCalled()
|
||||
expect(pane.terminal.refresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the render pause latched when resetting a pane that has no layout box', () => {
|
||||
// Regression: the atlas reset released xterm's pause for every pane of a
|
||||
// visible manager, including a collapsed sibling of an expanded pane. That
|
||||
@@ -185,6 +247,52 @@ describe('terminal WebGL addon lifecycle', () => {
|
||||
expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23)
|
||||
})
|
||||
|
||||
it('retries a display:none present after the overlay actually shows a box', () => {
|
||||
// Field traces show paused=true needFull=true because the
|
||||
// first present ran while the overlay was still display:none. Selection
|
||||
// later healed the inner rows; only a resize cleared the sides. Flush the
|
||||
// retry after the box exists so the full present actually runs.
|
||||
const queued: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
queued.push(callback)
|
||||
return queued.length
|
||||
})
|
||||
const { pane, renderService, setDisplay } = createPausedPane('none')
|
||||
|
||||
presentPaneViewport(pane)
|
||||
|
||||
expect(renderService._isPaused).toBe(true)
|
||||
expect(queued).toHaveLength(1)
|
||||
|
||||
setDisplay('block')
|
||||
queued.shift()?.(16)
|
||||
|
||||
expect(renderService._isPaused).toBe(false)
|
||||
expect(renderService.refreshRows).toHaveBeenCalledWith(0, 23, true)
|
||||
})
|
||||
|
||||
it('caps display:none present retries', () => {
|
||||
const queued: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
queued.push(callback)
|
||||
return queued.length
|
||||
})
|
||||
const { pane, renderService } = createPausedPane('none')
|
||||
|
||||
presentPaneViewport(pane)
|
||||
presentPaneViewport(pane)
|
||||
expect(queued).toHaveLength(1)
|
||||
let callbacks = 0
|
||||
while (queued.length > 0) {
|
||||
queued.shift()?.(callbacks * 16)
|
||||
callbacks += 1
|
||||
}
|
||||
|
||||
expect(callbacks).toBe(16)
|
||||
expect(renderService._isPaused).toBe(true)
|
||||
expect(renderService.refreshRows).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('still forces the paused render through for a pane that has a layout box', () => {
|
||||
// The reveal case the release exists for: DOM-visible, but xterm's observer
|
||||
// has not caught up, so a plain refresh() would be swallowed.
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { WebglAddon } from '@xterm/addon-webgl'
|
||||
import type { ManagedPaneInternal } from './pane-manager-types'
|
||||
import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types'
|
||||
import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics'
|
||||
import { getLivePaneCensus } from './pane-manager-registry'
|
||||
import { isManagedPaneDisplayNone } from './pane-display-visibility'
|
||||
import { forceRepaintThroughRenderPause } from './terminal-render-pause-release'
|
||||
import {
|
||||
forceFullViewportPresent,
|
||||
requestFullViewportPresent
|
||||
} from './terminal-render-pause-release'
|
||||
import {
|
||||
getTerminalWebglAutoDecision,
|
||||
resetTerminalWebglAutoDecision
|
||||
@@ -138,7 +141,7 @@ export function markComplexScriptOutput(pane: ManagedPaneInternal): void {
|
||||
pane.hasComplexScriptOutput = true
|
||||
}
|
||||
|
||||
export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void {
|
||||
export function clearWebglTextureAtlas(pane: ManagedPaneInternal): void {
|
||||
if (pane.webglDisabledAfterContextLoss) {
|
||||
return
|
||||
}
|
||||
@@ -147,22 +150,89 @@ export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void {
|
||||
// context-loss event. Clearing the atlas preserves GPU rendering and forces
|
||||
// a fresh paint when the pane becomes visible/focused again.
|
||||
pane.webglAddon?.clearTextureAtlas()
|
||||
} catch {
|
||||
/* ignore — pane may have been disposed in the meantime */
|
||||
}
|
||||
}
|
||||
|
||||
const DISPLAYED_PRESENT_RETRY_FRAMES = 16
|
||||
type ViewportPresentMode = 'preserve-synchronized-output' | 'force-current-buffer'
|
||||
type DisplayedPresentRetry = { frames: number; mode: ViewportPresentMode }
|
||||
const pendingDisplayedPresentRetries = new WeakMap<ManagedPaneInternal, DisplayedPresentRetry>()
|
||||
|
||||
function schedulePresentWhenDisplayed(pane: ManagedPaneInternal, mode: ViewportPresentMode): void {
|
||||
if (typeof globalThis.requestAnimationFrame !== 'function') {
|
||||
return
|
||||
}
|
||||
const pending = pendingDisplayedPresentRetries.get(pane)
|
||||
if (pending) {
|
||||
if (mode === 'force-current-buffer') {
|
||||
pending.mode = mode
|
||||
}
|
||||
return
|
||||
}
|
||||
pendingDisplayedPresentRetries.set(pane, {
|
||||
frames: DISPLAYED_PRESENT_RETRY_FRAMES,
|
||||
mode
|
||||
})
|
||||
const tick = (): void => {
|
||||
const retry = pendingDisplayedPresentRetries.get(pane)
|
||||
if (!retry || retry.frames <= 0 || !pane.terminal) {
|
||||
pendingDisplayedPresentRetries.delete(pane)
|
||||
return
|
||||
}
|
||||
if (isManagedPaneDisplayNone(pane)) {
|
||||
if (retry.frames === 1) {
|
||||
pendingDisplayedPresentRetries.delete(pane)
|
||||
return
|
||||
}
|
||||
retry.frames -= 1
|
||||
globalThis.requestAnimationFrame(tick)
|
||||
return
|
||||
}
|
||||
pendingDisplayedPresentRetries.delete(pane)
|
||||
presentPaneViewportWithMode(pane, retry.mode)
|
||||
}
|
||||
globalThis.requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
function presentPaneViewportWithMode(pane: ManagedPane, mode: ViewportPresentMode): void {
|
||||
const internal = pane as ManagedPaneInternal
|
||||
if (internal.webglDisabledAfterContextLoss) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Why: on reveal xterm's IntersectionObserver can still report the pane as
|
||||
// not intersecting, so a plain refresh() is swallowed by RenderService's
|
||||
// paused-render gate and the cleared model never repaints (stale bottom rows
|
||||
// until a drag-select forces a redraw). Force the paused render through
|
||||
// first; only fall back to refresh() when the terminal was not gated.
|
||||
// paused-render gate and the pending model never repaints (stale bottom rows
|
||||
// until a drag-select forces a redraw). Request one synchronous full present
|
||||
// even if the observer already unpaused; only fall back to refresh() when
|
||||
// internals are unavailable.
|
||||
//
|
||||
// Why the display check: that release is only right for a pane that is
|
||||
// DOM-visible. A pane with no box at all (collapsed sibling of an expanded
|
||||
// pane, a restore that stays display:none for its whole reattach) is
|
||||
// legitimately paused, and releasing it paints the just-cleared model into
|
||||
// nothing and then leaves the service unpaused for good — the observer only
|
||||
// legitimately paused, and releasing it paints into nothing and then leaves
|
||||
// the service unpaused for good — the observer only
|
||||
// fires on a change, so it never re-pauses. Clearing _needsFullRefresh with
|
||||
// it also drops the full repaint the observer owes the pane on reveal, and
|
||||
// the deferred _pausedResizeTask that flushes alongside it. Latching is what
|
||||
// xterm's own gate does, and the reveal repaints from the latch.
|
||||
if (isManagedPaneDisplayNone(pane) || !forceRepaintThroughRenderPause(pane.terminal)) {
|
||||
if (isManagedPaneDisplayNone(pane)) {
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
// Why: light tab reveal runs while the overlay is still display:none
|
||||
// (field trace: paused=true needFull=true at click). A plain refresh only
|
||||
// latches _needsFullRefresh; if IntersectionObserver never fires, the
|
||||
// canvas keeps pre-hide pixels until a user resize. Retry once the box
|
||||
// exists so the full present actually runs.
|
||||
schedulePresentWhenDisplayed(internal, mode)
|
||||
return
|
||||
}
|
||||
const presented =
|
||||
mode === 'force-current-buffer'
|
||||
? forceFullViewportPresent(pane.terminal)
|
||||
: requestFullViewportPresent(pane.terminal)
|
||||
if (!presented) {
|
||||
// Why: refresh even without a WebGL addon so recovery never silently
|
||||
// no-ops — a DOM-rendered pane can hold stale pixels after reveal too.
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
@@ -172,6 +242,19 @@ export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function presentPaneViewport(pane: ManagedPane): void {
|
||||
presentPaneViewportWithMode(pane, 'force-current-buffer')
|
||||
}
|
||||
|
||||
export function presentPaneViewportPreservingSynchronizedOutput(pane: ManagedPane): void {
|
||||
presentPaneViewportWithMode(pane, 'preserve-synchronized-output')
|
||||
}
|
||||
|
||||
export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void {
|
||||
clearWebglTextureAtlas(pane)
|
||||
presentPaneViewport(pane)
|
||||
}
|
||||
|
||||
function refitAfterFitAnchoredWebglAttach(pane: ManagedPaneInternal): void {
|
||||
// Why: the fit that triggered this attach measured DOM cell metrics, but WebGL
|
||||
// floors the device cell width — keeping that grid leaves an unpainted right
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ManagedPane } from './pane-manager-types'
|
||||
import { repairPaneWebglCanvasDprMismatch } from './terminal-canvas-dpr-repair'
|
||||
import {
|
||||
repairPaneWebglCanvasDpr,
|
||||
repairPaneWebglCanvasDprMismatch
|
||||
} from './terminal-canvas-dpr-repair'
|
||||
|
||||
function makePane(args: {
|
||||
backingWidth: number
|
||||
expectedWidth: number
|
||||
dpr: number
|
||||
cachedDpr?: number
|
||||
backingHeight?: number
|
||||
expectedHeight?: number
|
||||
connected?: boolean
|
||||
@@ -32,6 +36,7 @@ function makePane(args: {
|
||||
(args.hasRenderer ?? true)
|
||||
? {
|
||||
_canvas: canvas,
|
||||
_devicePixelRatio: args.cachedDpr ?? args.dpr,
|
||||
dimensions: {
|
||||
device: {
|
||||
canvas: { width: args.expectedWidth, height: args.expectedHeight ?? 1200 }
|
||||
@@ -79,6 +84,19 @@ describe('repairPaneWebglCanvasDprMismatch', () => {
|
||||
expect(handleResize).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('repairs when the canvas and renderer dimensions share a stale dpr cache', () => {
|
||||
const { pane, handleDevicePixelRatioChange, handleResize } = makePane({
|
||||
backingWidth: 1080,
|
||||
expectedWidth: 1080,
|
||||
dpr: 2,
|
||||
cachedDpr: 1
|
||||
})
|
||||
|
||||
expect(repairPaneWebglCanvasDprMismatch(pane)).toBe(true)
|
||||
expect(handleDevicePixelRatioChange).toHaveBeenCalledTimes(1)
|
||||
expect(handleResize).toHaveBeenCalledWith(120, 40)
|
||||
})
|
||||
|
||||
it('is a no-op when backing matches the renderer device dimensions', () => {
|
||||
const { pane, handleResize, refresh } = makePane({
|
||||
backingWidth: 2160,
|
||||
@@ -136,6 +154,24 @@ describe('repairPaneWebglCanvasDprMismatch', () => {
|
||||
expect(repairPaneWebglCanvasDprMismatch(noRenderer.pane)).toBe(false)
|
||||
})
|
||||
|
||||
it('defers an unmeasurable WebGL canvas but accepts a renderer-less pane', () => {
|
||||
const detached = makePane({
|
||||
backingWidth: 2160,
|
||||
expectedWidth: 1080,
|
||||
dpr: 1,
|
||||
connected: false
|
||||
})
|
||||
const noRenderer = makePane({
|
||||
backingWidth: 2160,
|
||||
expectedWidth: 1080,
|
||||
dpr: 1,
|
||||
hasRenderer: false
|
||||
})
|
||||
|
||||
expect(repairPaneWebglCanvasDpr(detached.pane)).toBe('deferred')
|
||||
expect(repairPaneWebglCanvasDpr(noRenderer.pane)).toBe('current')
|
||||
})
|
||||
|
||||
it('reports failure without throwing when the repair path throws mid-teardown', () => {
|
||||
const { pane, handleResize } = makePane({
|
||||
backingWidth: 2160,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl
|
||||
*/
|
||||
type XtermRendererInternals = {
|
||||
_canvas?: HTMLCanvasElement
|
||||
_devicePixelRatio?: number
|
||||
_gl?: { canvas?: HTMLCanvasElement }
|
||||
dimensions?: {
|
||||
device?: { canvas?: { width?: number; height?: number } }
|
||||
@@ -22,33 +23,42 @@ type XtermRendererInternals = {
|
||||
handleResize?: (cols: number, rows: number) => void
|
||||
}
|
||||
|
||||
export function repairPaneWebglCanvasDprMismatch(pane: ManagedPane): boolean {
|
||||
export type PaneWebglCanvasDprRepairState = 'current' | 'deferred' | 'repaired'
|
||||
|
||||
export function repairPaneWebglCanvasDpr(pane: ManagedPane): PaneWebglCanvasDprRepairState {
|
||||
const renderer = (
|
||||
pane.terminal as unknown as {
|
||||
_core?: { _renderService?: { _renderer?: { value?: XtermRendererInternals } } }
|
||||
}
|
||||
)._core?._renderService?._renderer?.value
|
||||
const canvas = renderer?._canvas ?? renderer?._gl?.canvas
|
||||
if (!renderer || !canvas?.isConnected) {
|
||||
return false
|
||||
if (!renderer || !canvas) {
|
||||
return 'current'
|
||||
}
|
||||
if (!canvas.isConnected) {
|
||||
return 'deferred'
|
||||
}
|
||||
const view = canvas.ownerDocument?.defaultView
|
||||
const expected = renderer.dimensions?.device?.canvas
|
||||
const expectedWidth = expected?.width ?? 0
|
||||
const expectedHeight = expected?.height ?? 0
|
||||
if (!view || expectedWidth <= 0 || expectedHeight <= 0) {
|
||||
return false
|
||||
return 'deferred'
|
||||
}
|
||||
const staleBackingWidth = canvas.width
|
||||
const staleBackingHeight = canvas.height
|
||||
const cachedDevicePixelRatio = renderer._devicePixelRatio
|
||||
const devicePixelRatioChanged =
|
||||
typeof cachedDevicePixelRatio === 'number' && cachedDevicePixelRatio !== view.devicePixelRatio
|
||||
// xterm rounds its CSS canvas size before ResizeObserver converts it back to
|
||||
// device pixels; allow that round trip without forcing layout on every fit.
|
||||
const roundingTolerance = Math.max(1, Math.ceil(view.devicePixelRatio / 2))
|
||||
if (
|
||||
!devicePixelRatioChanged &&
|
||||
Math.abs(staleBackingWidth - expectedWidth) <= roundingTolerance &&
|
||||
Math.abs(staleBackingHeight - expectedHeight) <= roundingTolerance
|
||||
) {
|
||||
return false
|
||||
return 'current'
|
||||
}
|
||||
try {
|
||||
// Order matters: refresh the renderer's cached dpr/dimensions first, then
|
||||
@@ -58,13 +68,18 @@ export function repairPaneWebglCanvasDprMismatch(pane: ManagedPane): boolean {
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
} catch {
|
||||
// Pane may be mid-teardown; the next reveal/fit retries the check.
|
||||
return false
|
||||
return 'deferred'
|
||||
}
|
||||
recordTerminalWebglDiagnostic('webgl-canvas-dpr-repair', {
|
||||
paneId: pane.id,
|
||||
staleBackingWidth,
|
||||
expectedBackingWidth: expectedWidth,
|
||||
...(cachedDevicePixelRatio === undefined ? {} : { cachedDevicePixelRatio }),
|
||||
devicePixelRatio: view.devicePixelRatio
|
||||
})
|
||||
return true
|
||||
return 'repaired'
|
||||
}
|
||||
|
||||
export function repairPaneWebglCanvasDprMismatch(pane: ManagedPane): boolean {
|
||||
return repairPaneWebglCanvasDpr(pane) === 'repaired'
|
||||
}
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { forceRepaintThroughRenderPause } from './terminal-render-pause-release'
|
||||
import {
|
||||
forceFullViewportPresent,
|
||||
forceRepaintThroughRenderPause,
|
||||
requestFullViewportPresent
|
||||
} from './terminal-render-pause-release'
|
||||
|
||||
type FakeRenderService = {
|
||||
_isPaused?: boolean
|
||||
_needsFullRefresh?: boolean
|
||||
refreshRows?: ReturnType<typeof vi.fn>
|
||||
_renderer?: {
|
||||
value?: { clear?: ReturnType<typeof vi.fn>; renderRows?: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
}
|
||||
|
||||
function createTerminal(options: {
|
||||
rows?: number
|
||||
renderService?: FakeRenderService | null
|
||||
withoutCore?: boolean
|
||||
synchronizedOutput?: boolean
|
||||
}): unknown {
|
||||
const { rows = 24, renderService, withoutCore } = options
|
||||
const { rows = 24, renderService, withoutCore, synchronizedOutput } = options
|
||||
if (withoutCore) {
|
||||
return { rows }
|
||||
}
|
||||
return {
|
||||
rows,
|
||||
_core: { _renderService: renderService ?? null }
|
||||
_core: {
|
||||
_renderService: renderService ?? null,
|
||||
coreService: { decPrivateModes: { synchronizedOutput: synchronizedOutput === true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,3 +96,131 @@ describe('forceRepaintThroughRenderPause', () => {
|
||||
expect(renderService._isPaused).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('forceFullViewportPresent', () => {
|
||||
it('fails closed when xterm internals are unavailable', () => {
|
||||
expect(forceFullViewportPresent(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves an unpaused, unsynchronized terminal to the normal refresh path', () => {
|
||||
// A forced sync renderRows on first splash paints before cell metrics
|
||||
// settle and shows a 1px black gutter under the TUI composer.
|
||||
const refreshRows = vi.fn()
|
||||
const renderRows = vi.fn()
|
||||
const terminal = createTerminal({
|
||||
rows: 24,
|
||||
renderService: {
|
||||
_isPaused: false,
|
||||
_needsFullRefresh: false,
|
||||
refreshRows,
|
||||
_renderer: { value: { renderRows } }
|
||||
}
|
||||
})
|
||||
|
||||
expect(forceFullViewportPresent(terminal)).toBe(false)
|
||||
expect(renderRows).not.toHaveBeenCalled()
|
||||
expect(refreshRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('paints through the renderer so DEC 2026 cannot swallow the reveal present', () => {
|
||||
const refreshRows = vi.fn()
|
||||
const renderRows = vi.fn()
|
||||
const renderService = {
|
||||
_isPaused: false,
|
||||
_needsFullRefresh: false,
|
||||
refreshRows,
|
||||
_renderer: { value: { renderRows } }
|
||||
}
|
||||
const terminal = createTerminal({
|
||||
rows: 24,
|
||||
renderService,
|
||||
synchronizedOutput: true
|
||||
})
|
||||
|
||||
expect(forceFullViewportPresent(terminal)).toBe(true)
|
||||
expect(renderRows).toHaveBeenCalledWith(0, 23)
|
||||
expect(refreshRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses RenderService refreshRows when paused without DEC 2026, matching production splash', () => {
|
||||
const refreshRows = vi.fn()
|
||||
const renderRows = vi.fn()
|
||||
const renderService = {
|
||||
_isPaused: true,
|
||||
_needsFullRefresh: true,
|
||||
refreshRows,
|
||||
_renderer: { value: { renderRows } }
|
||||
}
|
||||
const terminal = createTerminal({ rows: 24, renderService })
|
||||
|
||||
expect(forceFullViewportPresent(terminal)).toBe(true)
|
||||
expect(refreshRows).toHaveBeenCalledWith(0, 23, true)
|
||||
expect(renderRows).not.toHaveBeenCalled()
|
||||
expect(renderService._isPaused).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves pause cleared when the forced render throws so callers can refresh()', () => {
|
||||
const renderService = {
|
||||
_isPaused: true,
|
||||
_needsFullRefresh: true,
|
||||
refreshRows: vi.fn(() => {
|
||||
throw new Error('terminal disposed')
|
||||
}),
|
||||
_renderer: { value: { renderRows: vi.fn() } }
|
||||
}
|
||||
const terminal = createTerminal({ rows: 24, renderService })
|
||||
|
||||
expect(forceFullViewportPresent(terminal)).toBe(false)
|
||||
expect(renderService._isPaused).toBe(false)
|
||||
expect(renderService._needsFullRefresh).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('requestFullViewportPresent', () => {
|
||||
it('fails closed when xterm internals are unavailable', () => {
|
||||
expect(requestFullViewportPresent(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves a normal visible terminal on the debounced refresh path', () => {
|
||||
const refreshRows = vi.fn()
|
||||
const terminal = createTerminal({
|
||||
renderService: { _isPaused: false, refreshRows }
|
||||
})
|
||||
|
||||
expect(requestFullViewportPresent(terminal)).toBe(false)
|
||||
expect(refreshRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes synchronized output through RenderService instead of the renderer', () => {
|
||||
const refreshRows = vi.fn()
|
||||
const renderRows = vi.fn()
|
||||
const terminal = createTerminal({
|
||||
rows: 24,
|
||||
synchronizedOutput: true,
|
||||
renderService: {
|
||||
_isPaused: false,
|
||||
refreshRows,
|
||||
_renderer: { value: { renderRows } }
|
||||
}
|
||||
})
|
||||
|
||||
expect(requestFullViewportPresent(terminal)).toBe(true)
|
||||
expect(refreshRows).toHaveBeenCalledWith(0, 23, true)
|
||||
expect(renderRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases observer pause before requesting the synchronized frame', () => {
|
||||
const refreshRows = vi.fn()
|
||||
const renderService = {
|
||||
_isPaused: true,
|
||||
_needsFullRefresh: true,
|
||||
refreshRows
|
||||
}
|
||||
const terminal = createTerminal({ rows: 30, renderService, synchronizedOutput: true })
|
||||
|
||||
expect(requestFullViewportPresent(terminal)).toBe(true)
|
||||
expect(renderService._isPaused).toBe(false)
|
||||
expect(renderService._needsFullRefresh).toBe(false)
|
||||
expect(refreshRows).toHaveBeenCalledWith(0, 29, true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,10 +17,15 @@
|
||||
* never throws into a render frame.
|
||||
*/
|
||||
|
||||
type MaybeWebglRenderer = {
|
||||
renderRows?: (start: number, end: number) => void
|
||||
}
|
||||
|
||||
type MaybePausableRenderService = {
|
||||
_isPaused?: boolean
|
||||
_needsFullRefresh?: boolean
|
||||
refreshRows?: (start: number, end: number, sync?: boolean) => void
|
||||
_renderer?: { value?: MaybeWebglRenderer | null } | MaybeWebglRenderer | null
|
||||
}
|
||||
|
||||
type PausableRenderService = MaybePausableRenderService & {
|
||||
@@ -31,6 +36,8 @@ type TerminalWithRenderService = {
|
||||
rows?: number
|
||||
_core?: {
|
||||
_renderService?: MaybePausableRenderService
|
||||
coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } }
|
||||
_coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,3 +78,108 @@ export function forceRepaintThroughRenderPause(terminal: unknown): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a full viewport while preserving a TUI's synchronized-output frame.
|
||||
*
|
||||
* Why: ordinary reveal must not publish a half-built DEC 2026 frame. Routing
|
||||
* through RenderService keeps the previous canvas coherent and arms xterm's
|
||||
* bounded safety timeout if the TUI never closes the frame.
|
||||
*/
|
||||
export function requestFullViewportPresent(terminal: unknown): boolean {
|
||||
const service = getRenderService(terminal)
|
||||
if (!service) {
|
||||
return false
|
||||
}
|
||||
const rows = (terminal as TerminalWithRenderService).rows
|
||||
if (typeof rows !== 'number' || rows < 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
const paused = service._isPaused === true
|
||||
if (!paused && !isSynchronizedOutputHeld(terminal)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (paused) {
|
||||
service._isPaused = false
|
||||
service._needsFullRefresh = false
|
||||
}
|
||||
|
||||
try {
|
||||
service.refreshRows(0, rows - 1, true)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getRenderer(service: MaybePausableRenderService): MaybeWebglRenderer | null {
|
||||
const holder = service._renderer
|
||||
if (!holder) {
|
||||
return null
|
||||
}
|
||||
if (typeof (holder as MaybeWebglRenderer).renderRows === 'function') {
|
||||
return holder as MaybeWebglRenderer
|
||||
}
|
||||
const wrapped = (holder as { value?: MaybeWebglRenderer | null }).value
|
||||
return wrapped ?? null
|
||||
}
|
||||
|
||||
function isSynchronizedOutputHeld(terminal: unknown): boolean {
|
||||
const core = (terminal as TerminalWithRenderService)._core
|
||||
return (
|
||||
(core?.coreService?.decPrivateModes ?? core?._coreService?.decPrivateModes)
|
||||
?.synchronizedOutput === true
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One synchronous full-viewport present when xterm would otherwise swallow it:
|
||||
* IntersectionObserver pause, or DEC 2026 synchronized output.
|
||||
*
|
||||
* Why not on every paint: a forced renderer.renderRows on a fresh, unpaused
|
||||
* splash paints before cell metrics settle and leaves a 1px black gutter under
|
||||
* a TUI composer — production never does that. Callers fall back to
|
||||
* terminal.refresh() for the normal path.
|
||||
*/
|
||||
export function forceFullViewportPresent(terminal: unknown): boolean {
|
||||
const service = getRenderService(terminal)
|
||||
if (!service) {
|
||||
return false
|
||||
}
|
||||
const rows = (terminal as TerminalWithRenderService).rows
|
||||
if (typeof rows !== 'number' || rows < 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
const paused = service._isPaused === true
|
||||
const syncHeld = isSynchronizedOutputHeld(terminal)
|
||||
if (!paused && !syncHeld) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (paused) {
|
||||
service._isPaused = false
|
||||
service._needsFullRefresh = false
|
||||
}
|
||||
|
||||
const renderer = getRenderer(service)
|
||||
try {
|
||||
// Why: a new TUI tab is often still paused (observer lag). Painting
|
||||
// via renderer.renderRows skips RenderService's dimension clamp and draws
|
||||
// 1px short of the composer box. Production uses refreshRows here.
|
||||
// renderer.renderRows is only for DEC 2026, which swallows refreshRows.
|
||||
if (syncHeld && typeof renderer?.renderRows === 'function') {
|
||||
renderer.renderRows(0, rows - 1)
|
||||
return true
|
||||
}
|
||||
service.refreshRows(0, rows - 1, true)
|
||||
return true
|
||||
} catch {
|
||||
// Why: same as forceRepaintThroughRenderPause — leave the latch cleared so
|
||||
// the caller's terminal.refresh() fallback can still paint. Restoring
|
||||
// _isPaused would swallow that refresh until IntersectionObserver fires.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { getActiveTabId, waitForSessionReady } from './helpers/store'
|
||||
import { getActiveTabId, getActiveWorktreeId, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
execInTerminal,
|
||||
waitForActivePanePtyId,
|
||||
@@ -19,7 +20,7 @@ import { compareTerminalScreenshots } from './terminal-screenshot-diff'
|
||||
* is then swallowed and the freshly-cleared render model never repaints.
|
||||
*
|
||||
* This spec drives the REAL production reveal path (manager.resetWebglTextureAtlases
|
||||
* -> resetWebglTextureAtlas -> forceRepaintThroughRenderPause) against a real
|
||||
* -> resetWebglTextureAtlas -> forceFullViewportPresent) against a real
|
||||
* xterm Terminal + RenderService. It:
|
||||
* 1. proves the bug: while paused, a plain refresh() renders nothing;
|
||||
* 2. proves the fix: the real reveal repaint forces a full-viewport render
|
||||
@@ -50,6 +51,16 @@ type RevealRenderDebug = {
|
||||
|
||||
type RevealProbeWindow = Window & {
|
||||
__revealRenderProbe?: RevealRenderDebug
|
||||
__syncRevealProbe?: {
|
||||
paintFrame: (marker: string, background: number, release: boolean) => Promise<void>
|
||||
forceRendererPresent: () => void
|
||||
read: () => {
|
||||
atlasClears: number
|
||||
fullViewportRenderRows: number
|
||||
screen: string
|
||||
synchronizedOutput: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,6 +193,154 @@ async function forceWebglOn(page: Page, tabId: string): Promise<void> {
|
||||
}, tabId)
|
||||
}
|
||||
|
||||
async function installSynchronizedRevealProbe(page: Page, tabId: string): Promise<boolean> {
|
||||
return page.evaluate((tabId) => {
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
const pane = [
|
||||
...((manager as unknown as { panes?: Map<number, unknown> } | undefined)?.panes?.values() ??
|
||||
[])
|
||||
][0] as
|
||||
| {
|
||||
serializeAddon?: { serialize?: () => string }
|
||||
terminal: unknown
|
||||
webglAddon?: unknown
|
||||
}
|
||||
| undefined
|
||||
const addon = pane?.webglAddon as { clearTextureAtlas: () => void } | null | undefined
|
||||
type SyncRenderService = {
|
||||
_renderer?:
|
||||
| { renderRows?: (start: number, end: number) => void }
|
||||
| { value?: { renderRows?: (start: number, end: number) => void } | null }
|
||||
_syncOutputHandler?: { _timeout?: number }
|
||||
}
|
||||
const terminal = pane?.terminal as
|
||||
| {
|
||||
cols: number
|
||||
rows: number
|
||||
write: (data: string, callback: () => void) => void
|
||||
_core?: {
|
||||
coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } }
|
||||
_coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } }
|
||||
_renderService?: SyncRenderService
|
||||
}
|
||||
}
|
||||
| undefined
|
||||
const service = terminal?._core?._renderService
|
||||
const modes =
|
||||
terminal?._core?.coreService?.decPrivateModes ??
|
||||
terminal?._core?._coreService?.decPrivateModes
|
||||
const rendererHolder = service?._renderer
|
||||
const renderer =
|
||||
rendererHolder && 'renderRows' in rendererHolder
|
||||
? rendererHolder
|
||||
: rendererHolder && 'value' in rendererHolder
|
||||
? rendererHolder.value
|
||||
: null
|
||||
if (!pane || !addon || !terminal || !service || !modes || !renderer?.renderRows) {
|
||||
throw new Error(
|
||||
JSON.stringify({
|
||||
addon: Boolean(addon),
|
||||
modes: Boolean(modes),
|
||||
pane: Boolean(pane),
|
||||
renderer: Boolean(renderer?.renderRows),
|
||||
rendererKeys: rendererHolder ? Object.keys(rendererHolder) : [],
|
||||
service: Boolean(service),
|
||||
terminal: Boolean(terminal)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
let atlasClears = 0
|
||||
let fullViewportRenderRows = 0
|
||||
const originalClear = addon.clearTextureAtlas.bind(addon)
|
||||
addon.clearTextureAtlas = () => {
|
||||
atlasClears += 1
|
||||
originalClear()
|
||||
}
|
||||
const originalRenderRows = renderer.renderRows.bind(renderer)
|
||||
renderer.renderRows = (start: number, end: number) => {
|
||||
if (start === 0 && end >= terminal.rows - 1) {
|
||||
fullViewportRenderRows += 1
|
||||
}
|
||||
originalRenderRows(start, end)
|
||||
}
|
||||
|
||||
const paintFrame = (marker: string, background: number, release: boolean): Promise<void> => {
|
||||
const width = Math.max(1, terminal.cols)
|
||||
const rows = Math.max(1, terminal.rows)
|
||||
const line = ` ${marker} `.padEnd(width, marker[0] ?? '#').slice(0, width)
|
||||
const frame = Array.from(
|
||||
{ length: rows },
|
||||
(_, row) => `\x1b[${row + 1};1H\x1b[48;5;${background}m\x1b[38;5;231m${line}`
|
||||
).join('')
|
||||
return new Promise((resolve) => {
|
||||
terminal.write(
|
||||
`\x1b[?2026h\x1b[?1049h\x1b[2J${frame}\x1b[0m${release ? '\x1b[?2026l' : ''}`,
|
||||
() => {
|
||||
if (!release && service._syncOutputHandler?._timeout !== undefined) {
|
||||
window.clearTimeout(service._syncOutputHandler._timeout)
|
||||
service._syncOutputHandler._timeout = undefined
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
;(window as RevealProbeWindow).__syncRevealProbe = {
|
||||
paintFrame,
|
||||
forceRendererPresent: () => renderer.renderRows?.(0, terminal.rows - 1),
|
||||
read: () => ({
|
||||
atlasClears,
|
||||
fullViewportRenderRows,
|
||||
screen: pane.serializeAddon?.serialize?.() ?? '',
|
||||
synchronizedOutput: modes.synchronizedOutput === true
|
||||
})
|
||||
}
|
||||
return true
|
||||
}, tabId)
|
||||
}
|
||||
|
||||
async function callSynchronizedRevealProbe(
|
||||
page: Page,
|
||||
action: 'forceRendererPresent' | 'paintFrame',
|
||||
frame?: { marker: string; background: number; release: boolean }
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
async ({ action, frame }) => {
|
||||
const probe = (window as RevealProbeWindow).__syncRevealProbe
|
||||
if (!probe) {
|
||||
throw new Error('Synchronized reveal probe not installed')
|
||||
}
|
||||
if (action === 'paintFrame') {
|
||||
if (!frame) {
|
||||
throw new Error('Synchronized frame missing')
|
||||
}
|
||||
await probe.paintFrame(frame.marker, frame.background, frame.release)
|
||||
return
|
||||
}
|
||||
probe.forceRendererPresent()
|
||||
},
|
||||
{ action, frame }
|
||||
)
|
||||
}
|
||||
|
||||
async function readSynchronizedRevealProbe(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const probe = (window as RevealProbeWindow).__syncRevealProbe
|
||||
if (!probe) {
|
||||
throw new Error('Synchronized reveal probe not installed')
|
||||
}
|
||||
return probe.read()
|
||||
})
|
||||
}
|
||||
|
||||
async function captureFirstRevealedFrame(page: Page, tabId: string): Promise<Buffer> {
|
||||
const screen = page.locator(`[data-terminal-tab-id="${tabId}"] .xterm-screen`).first()
|
||||
await expect(screen).toBeVisible()
|
||||
return screen.screenshot({ animations: 'disabled' })
|
||||
}
|
||||
|
||||
test.describe('terminal reveal paused-render recovery', () => {
|
||||
test("reveal repaint forces a render through xterm's paused gate", async ({ orcaPage }) => {
|
||||
// Why: __store / __paneManagers live on the main Orca renderer window
|
||||
@@ -265,4 +424,233 @@ test.describe('terminal reveal paused-render recovery', () => {
|
||||
`recovered surface matches the revealed content (diffRatio=${diff.diffRatio})`
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('@headful atlas recovery presents a synchronized-output WebGL frame', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage)
|
||||
const tabId = (await getActiveTabId(orcaPage))!
|
||||
await forceWebglOn(orcaPage, tabId)
|
||||
const webglAttached = await orcaPage
|
||||
.waitForFunction(
|
||||
(tabId) =>
|
||||
(window.__paneManagers?.get(tabId)?.getRenderingDiagnostics?.() ?? []).some(
|
||||
(diagnostic) => diagnostic.hasWebgl
|
||||
),
|
||||
tabId,
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
test.skip(!webglAttached, 'WebGL renderer is unavailable')
|
||||
if (!webglAttached) {
|
||||
return
|
||||
}
|
||||
const installed = await installSynchronizedRevealProbe(orcaPage, tabId)
|
||||
expect(installed, 'WebGL renderer internals are available').toBe(true)
|
||||
|
||||
await callSynchronizedRevealProbe(orcaPage, 'paintFrame', {
|
||||
marker: 'BASELINE_FRAME',
|
||||
background: 17,
|
||||
release: true
|
||||
})
|
||||
await callSynchronizedRevealProbe(orcaPage, 'paintFrame', {
|
||||
marker: 'REVEALED_FRAME',
|
||||
background: 52,
|
||||
release: false
|
||||
})
|
||||
const held = await readSynchronizedRevealProbe(orcaPage)
|
||||
expect(held.synchronizedOutput).toBe(true)
|
||||
expect(held.screen).toContain('REVEALED_FRAME')
|
||||
|
||||
await orcaPage.evaluate((tabId) => {
|
||||
window.__paneManagers?.get(tabId)?.scheduleRevealRepaint?.()
|
||||
}, tabId)
|
||||
await expect
|
||||
.poll(async () => (await readSynchronizedRevealProbe(orcaPage)).atlasClears)
|
||||
.toBeGreaterThan(0)
|
||||
const afterReveal = await captureStableTabScreenshot(orcaPage, tabId)
|
||||
|
||||
await callSynchronizedRevealProbe(orcaPage, 'forceRendererPresent')
|
||||
const afterForcedPresent = await captureStableTabScreenshot(orcaPage, tabId)
|
||||
const diff = compareTerminalScreenshots(afterReveal, afterForcedPresent)
|
||||
const afterRevealPath = testInfo.outputPath('synchronized-frame-after-reveal.png')
|
||||
const afterForcedPresentPath = testInfo.outputPath(
|
||||
'synchronized-frame-after-forced-present.png'
|
||||
)
|
||||
const pixelDiffPath = testInfo.outputPath('synchronized-frame-pixel-diff.json')
|
||||
await Promise.all([
|
||||
writeFile(afterRevealPath, afterReveal),
|
||||
writeFile(afterForcedPresentPath, afterForcedPresent),
|
||||
writeFile(pixelDiffPath, JSON.stringify(diff, null, 2))
|
||||
])
|
||||
await testInfo.attach('synchronized-frame-after-reveal.png', {
|
||||
path: afterRevealPath,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
await testInfo.attach('synchronized-frame-after-forced-present.png', {
|
||||
path: afterForcedPresentPath,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
await testInfo.attach('synchronized-frame-pixel-diff.json', {
|
||||
path: pixelDiffPath,
|
||||
contentType: 'application/json'
|
||||
})
|
||||
expect(
|
||||
diff.matches,
|
||||
`revealed pixels already match a forced buffer present (diffRatio=${diff.diffRatio})`
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('@headful reveal preserves the coherent frame until synchronized output releases', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage)
|
||||
const tabId = (await getActiveTabId(orcaPage))!
|
||||
const worktreeId = (await getActiveWorktreeId(orcaPage))!
|
||||
await forceWebglOn(orcaPage, tabId)
|
||||
const webglAttached = await orcaPage
|
||||
.waitForFunction(
|
||||
(tabId) =>
|
||||
(window.__paneManagers?.get(tabId)?.getRenderingDiagnostics?.() ?? []).some(
|
||||
(diagnostic) => diagnostic.hasWebgl
|
||||
),
|
||||
tabId,
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
test.skip(!webglAttached, 'WebGL renderer is unavailable')
|
||||
if (!webglAttached) {
|
||||
return
|
||||
}
|
||||
expect(await installSynchronizedRevealProbe(orcaPage, tabId)).toBe(true)
|
||||
|
||||
await callSynchronizedRevealProbe(orcaPage, 'paintFrame', {
|
||||
marker: 'COHERENT_FRAME',
|
||||
background: 17,
|
||||
release: true
|
||||
})
|
||||
const coherent = await captureStableTabScreenshot(orcaPage, tabId)
|
||||
const beforeHide = await readSynchronizedRevealProbe(orcaPage)
|
||||
const siblingTabId = await orcaPage.evaluate((worktreeId) => {
|
||||
const state = window.__store?.getState()
|
||||
if (!state) {
|
||||
throw new Error('Renderer store unavailable')
|
||||
}
|
||||
return state.createTab(worktreeId, undefined, undefined, { activate: false }).id
|
||||
}, worktreeId)
|
||||
await orcaPage.evaluate(
|
||||
(siblingTabId) => window.__store?.getState().setActiveTab(siblingTabId),
|
||||
siblingTabId
|
||||
)
|
||||
await expect(orcaPage.locator(`[data-terminal-tab-id="${tabId}"]`)).toBeHidden()
|
||||
|
||||
await callSynchronizedRevealProbe(orcaPage, 'paintFrame', {
|
||||
marker: 'PENDING_FRAME',
|
||||
background: 52,
|
||||
release: false
|
||||
})
|
||||
await orcaPage.evaluate((tabId) => window.__store?.getState().setActiveTab(tabId), tabId)
|
||||
await expect.poll(() => getActiveTabId(orcaPage)).toBe(tabId)
|
||||
const held = await captureFirstRevealedFrame(orcaPage, tabId)
|
||||
const heldState = await readSynchronizedRevealProbe(orcaPage)
|
||||
const heldDiff = compareTerminalScreenshots(coherent, held)
|
||||
|
||||
expect(heldState.synchronizedOutput).toBe(true)
|
||||
expect(heldState.screen).toContain('PENDING_FRAME')
|
||||
expect(heldState.atlasClears - beforeHide.atlasClears, 'ordinary reveal atlas clears').toBe(0)
|
||||
expect(
|
||||
heldDiff.matches,
|
||||
`held reveal preserves the coherent frame (diffRatio=${heldDiff.diffRatio})`
|
||||
).toBe(true)
|
||||
|
||||
await callSynchronizedRevealProbe(orcaPage, 'paintFrame', {
|
||||
marker: 'PENDING_FRAME',
|
||||
background: 52,
|
||||
release: true
|
||||
})
|
||||
const released = await captureStableTabScreenshot(orcaPage, tabId)
|
||||
const releaseState = await readSynchronizedRevealProbe(orcaPage)
|
||||
await callSynchronizedRevealProbe(orcaPage, 'forceRendererPresent')
|
||||
const direct = await captureStableTabScreenshot(orcaPage, tabId)
|
||||
const releasedDiff = compareTerminalScreenshots(released, direct)
|
||||
|
||||
await callSynchronizedRevealProbe(orcaPage, 'paintFrame', {
|
||||
marker: 'WATCHDOG_FRAME',
|
||||
background: 88,
|
||||
release: false
|
||||
})
|
||||
const beforeWatchdog = await readSynchronizedRevealProbe(orcaPage)
|
||||
await orcaPage.evaluate((tabId) => {
|
||||
window.__paneManagers?.get(tabId)?.scheduleRevealPresent?.()
|
||||
}, tabId)
|
||||
await expect
|
||||
.poll(async () => (await readSynchronizedRevealProbe(orcaPage)).synchronizedOutput, {
|
||||
timeout: 2_500
|
||||
})
|
||||
.toBe(false)
|
||||
const watchdog = await captureStableTabScreenshot(orcaPage, tabId)
|
||||
const watchdogState = await readSynchronizedRevealProbe(orcaPage)
|
||||
await callSynchronizedRevealProbe(orcaPage, 'forceRendererPresent')
|
||||
const watchdogDirect = await captureStableTabScreenshot(orcaPage, tabId)
|
||||
const watchdogDiff = compareTerminalScreenshots(watchdog, watchdogDirect)
|
||||
const coherentPath = testInfo.outputPath('synchronized-coherent-before-hide.png')
|
||||
const heldPath = testInfo.outputPath('synchronized-coherent-while-held.png')
|
||||
const releasedPath = testInfo.outputPath('synchronized-new-frame-after-release.png')
|
||||
const watchdogPath = testInfo.outputPath('synchronized-new-frame-after-watchdog.png')
|
||||
const metricsPath = testInfo.outputPath('synchronized-prevention-metrics.json')
|
||||
const counts = (state: Awaited<ReturnType<typeof readSynchronizedRevealProbe>>) => ({
|
||||
atlasClears: state.atlasClears,
|
||||
fullViewportRenderRows: state.fullViewportRenderRows,
|
||||
synchronizedOutput: state.synchronizedOutput
|
||||
})
|
||||
await Promise.all([
|
||||
writeFile(coherentPath, coherent),
|
||||
writeFile(heldPath, held),
|
||||
writeFile(releasedPath, released),
|
||||
writeFile(watchdogPath, watchdog),
|
||||
writeFile(
|
||||
metricsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
beforeHide: counts(beforeHide),
|
||||
heldState: counts(heldState),
|
||||
releaseState: counts(releaseState),
|
||||
beforeWatchdog: counts(beforeWatchdog),
|
||||
watchdogState: counts(watchdogState),
|
||||
heldDiff,
|
||||
releasedDiff,
|
||||
watchdogDiff
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
])
|
||||
for (const [name, path] of [
|
||||
['synchronized-coherent-before-hide.png', coherentPath],
|
||||
['synchronized-coherent-while-held.png', heldPath],
|
||||
['synchronized-new-frame-after-release.png', releasedPath],
|
||||
['synchronized-new-frame-after-watchdog.png', watchdogPath],
|
||||
['synchronized-prevention-metrics.json', metricsPath]
|
||||
] as const) {
|
||||
await testInfo.attach(name, {
|
||||
path,
|
||||
contentType: name.endsWith('.json') ? 'application/json' : 'image/png'
|
||||
})
|
||||
}
|
||||
expect(releaseState.synchronizedOutput).toBe(false)
|
||||
expect(
|
||||
releasedDiff.matches,
|
||||
`released frame matches a direct renderer present (diffRatio=${releasedDiff.diffRatio})`
|
||||
).toBe(true)
|
||||
expect(watchdogState.atlasClears - beforeWatchdog.atlasClears).toBe(0)
|
||||
expect(
|
||||
watchdogDiff.matches,
|
||||
`watchdog frame matches a direct renderer present (diffRatio=${watchdogDiff.diffRatio})`
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user