mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 16:03:03 +00:00
fix(terminal): dispose and re-create WebGL addon across split reparent
The previous fix (forcing fit+refresh during pending split) did not resolve the intermittent dead terminal. The terminal.refresh() call paints to the existing WebGL context, but Chromium can silently invalidate a WebGL context when its canvas is moved in the DOM without firing the contextlost event — leaving the renderer painting to a dead surface. The fix: dispose the WebGL addon BEFORE wrapInSplit() reparents the DOM, then re-attach a fresh WebGL addon after layout has settled (200ms timer in scheduleSplitScrollRestore). During the 200ms window the pane falls back to the DOM renderer, which is immune to the reparent issue. The same pattern is applied to insertPaneNextTo (drag-drop reorder) which performs analogous DOM reparenting. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -122,6 +122,13 @@ export class PaneManager {
|
||||
// the authoritative restore to the timeout below.
|
||||
existing.pendingSplitScrollState = scrollState
|
||||
|
||||
// Why: Chromium can silently invalidate a WebGL context when its canvas
|
||||
// moves in the DOM without firing contextlost. Dispose before reparent,
|
||||
// re-attach after layout settles (scheduleSplitScrollRestore 200ms timer).
|
||||
const hadWebgl = !!existing.webglAddon
|
||||
if (hadWebgl) {
|
||||
disposeWebgl(existing)
|
||||
}
|
||||
wrapInSplit(existing.container, newPane.container, isVertical, divider, opts)
|
||||
|
||||
openTerminal(newPane)
|
||||
@@ -144,7 +151,8 @@ export class PaneManager {
|
||||
(id) => this.panes.get(id),
|
||||
existing.id,
|
||||
scrollState,
|
||||
() => this.destroyed
|
||||
() => this.destroyed,
|
||||
hadWebgl
|
||||
)
|
||||
|
||||
return toPublicPane(newPane)
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import type { ManagedPaneInternal, ScrollState } from './pane-manager-types'
|
||||
import { restoreScrollState } from './pane-scroll'
|
||||
import { attachWebgl } from './pane-lifecycle'
|
||||
|
||||
// Why: wrapInSplit reparents the existing pane's container, which briefly
|
||||
// detaches the WebGL canvas from the DOM. The WebGL renderer's internal
|
||||
// render state can become stale after the re-attachment, leaving the canvas
|
||||
// blank even though the terminal buffer has data. This mirrors the explicit
|
||||
// refresh in resumeRendering() (pane-manager.ts) and the onContextLoss
|
||||
// handler (pane-lifecycle.ts) which address the same "frozen terminal"
|
||||
// symptom for analogous WebGL state transitions.
|
||||
function refreshAfterReparent(pane: ManagedPaneInternal): void {
|
||||
try {
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
@@ -20,11 +14,17 @@ function refreshAfterReparent(pane: ManagedPaneInternal): void {
|
||||
// scroll position (browser clears scrollTop on DOM move). This schedules a
|
||||
// two-phase restore: an early double-rAF (~32ms) to minimise the visible
|
||||
// flash, plus a 200ms authoritative restore that also clears the scroll lock.
|
||||
//
|
||||
// When `reattachWebgl` is true, the 200ms timer re-creates the WebGL addon
|
||||
// that splitPane disposed before wrapInSplit. Waiting 200ms ensures the DOM
|
||||
// has fully settled — attaching WebGL to a canvas that's mid-reparent can
|
||||
// silently produce a dead context.
|
||||
export function scheduleSplitScrollRestore(
|
||||
getPaneById: (id: number) => ManagedPaneInternal | undefined,
|
||||
paneId: number,
|
||||
scrollState: ScrollState,
|
||||
isDestroyed: () => boolean
|
||||
isDestroyed: () => boolean,
|
||||
reattachWebgl?: boolean
|
||||
): void {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -49,6 +49,16 @@ export function scheduleSplitScrollRestore(
|
||||
}
|
||||
live.pendingSplitScrollState = null
|
||||
restoreScrollState(live.terminal, scrollState)
|
||||
|
||||
// Why: splitPane() disposed WebGL before wrapInSplit to prevent Chromium
|
||||
// from silently invalidating the context during the DOM reparent. Now that
|
||||
// layout has settled, re-attach a fresh WebGL addon so the pane returns to
|
||||
// GPU rendering. The refresh after attachment paints the buffer content
|
||||
// that accumulated while the pane was on the DOM fallback renderer.
|
||||
if (reattachWebgl && live.gpuRenderingEnabled && !live.webglDisabledAfterContextLoss) {
|
||||
attachWebgl(live)
|
||||
}
|
||||
|
||||
refreshAfterReparent(live)
|
||||
}, 200)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DropZone, ManagedPaneInternal, PaneStyleOptions } from './pane-manager-types'
|
||||
import { createDivider } from './pane-divider'
|
||||
import { disposeWebgl, attachWebgl } from './pane-lifecycle'
|
||||
|
||||
export { findLineByContent, captureScrollState, restoreScrollState } from './pane-scroll'
|
||||
|
||||
@@ -27,12 +28,6 @@ function getProposedDimensions(pane: ManagedPaneInternal): { cols: number; rows:
|
||||
// viewportY across reflows — see scroll-reflow.test.ts "reference: undisturbed".
|
||||
// A plain fit() is all we need during sidebar drags, divider drags, and window
|
||||
// resizes. This matches how Superset and VSCode handle the same cases.
|
||||
//
|
||||
// pendingSplitScrollState is the one case where fit() alone isn't enough:
|
||||
// wrapInSplit() reparents the container, which makes the browser reset
|
||||
// scrollTop to 0 asynchronously. splitPane captures the pre-split state and
|
||||
// scheduleSplitScrollRestore owns the authoritative restore on a timer, so
|
||||
// safeFit here just fits and lets the scheduled restore do its job.
|
||||
export function safeFit(pane: ManagedPaneInternal): void {
|
||||
try {
|
||||
const dims = getProposedDimensions(pane)
|
||||
@@ -40,29 +35,6 @@ export function safeFit(pane: ManagedPaneInternal): void {
|
||||
// Why: divider drags fire refits every frame, but most frames do not
|
||||
// cross a cell boundary. Skipping those avoids FitAddon.clear()+refresh()
|
||||
// churn, which was causing visible terminal blinking while resizing.
|
||||
//
|
||||
// Why: wrapInSplit() reparents the pane's container, which can leave
|
||||
// the WebGL canvas stale even when proposed dimensions match current
|
||||
// (the browser detaches and reattaches the canvas during the DOM move).
|
||||
// When pendingSplitScrollState is set we must force a fit + refresh so
|
||||
// the WebGL renderer repaints. Without this, the pane appears blank
|
||||
// until something forces a dimension change.
|
||||
if (pane.pendingSplitScrollState) {
|
||||
console.warn(
|
||||
'[terminal] safeFit forcing fit+refresh during pending split for pane',
|
||||
pane.id,
|
||||
`— dims ${dims.cols}×${dims.rows} match current, webgl:`,
|
||||
!!pane.webglAddon,
|
||||
pane.debugLabel ? `(${pane.debugLabel})` : ''
|
||||
)
|
||||
pane.fitAddon.fit()
|
||||
try {
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
} catch {
|
||||
/* ignore — terminal may not be fully initialised */
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
pane.fitAddon.fit()
|
||||
@@ -183,6 +155,17 @@ export function insertPaneNextTo(
|
||||
applyPaneFlexStyle(source.container)
|
||||
applyPaneFlexStyle(targetContainer)
|
||||
|
||||
// Why: dispose WebGL before reparenting so Chromium cannot silently
|
||||
// invalidate the context during the DOM move (same as splitPane).
|
||||
const sourceHadWebgl = !!source.webglAddon
|
||||
const targetHadWebgl = !!target.webglAddon
|
||||
if (sourceHadWebgl) {
|
||||
disposeWebgl(source)
|
||||
}
|
||||
if (targetHadWebgl) {
|
||||
disposeWebgl(target)
|
||||
}
|
||||
|
||||
// Replace target with the split in the DOM
|
||||
parent.replaceChild(split, targetContainer)
|
||||
|
||||
@@ -197,11 +180,14 @@ export function insertPaneNextTo(
|
||||
split.appendChild(source.container)
|
||||
}
|
||||
|
||||
// Refit both and refresh rendering surfaces — both panes were reparented
|
||||
// into the new split wrapper, which can leave the WebGL canvas in a stale
|
||||
// state (same mechanism as wrapInSplit; see refreshAfterReparent in
|
||||
// pane-split-scroll.ts).
|
||||
// Re-attach WebGL after the DOM has settled, then refit both panes.
|
||||
requestAnimationFrame(() => {
|
||||
if (sourceHadWebgl && source.gpuRenderingEnabled && !source.webglDisabledAfterContextLoss) {
|
||||
attachWebgl(source)
|
||||
}
|
||||
if (targetHadWebgl && target.gpuRenderingEnabled && !target.webglDisabledAfterContextLoss) {
|
||||
attachWebgl(target)
|
||||
}
|
||||
callbacks.safeFit(source)
|
||||
callbacks.safeFit(target)
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user