fix(linux): disable GPU sandbox to stop terminal input freeze on Wayland (#5319)

This commit is contained in:
Neil 2026-06-28 19:43:18 -07:00 committed by GitHub
parent 4a07a1dd86
commit 0976f9427c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1435 additions and 31 deletions

View File

@ -0,0 +1,132 @@
name: Linux Wayland GPU Sandbox
on:
pull_request:
paths:
- .github/workflows/linux-wayland-gpu-sandbox.yml
- config/scripts/linux-wayland-renderer-diagnostics.mjs
- config/scripts/linux-wayland-terminal-exercise.mjs
- config/scripts/linux-wayland-validation-watchdog.mjs
- config/scripts/verify-linux-wayland-gpu-sandbox.mjs
- src/main/startup/configure-process.ts
- src/main/startup/configure-process.test.ts
- src/preload/api-types.ts
- src/preload/index.ts
- src/renderer/src/components/terminal-pane/pty-connection.ts
- src/renderer/src/lib/pane-manager/terminal-webgl-auto-policy.ts
- src/renderer/src/web/web-preload-api.ts
workflow_dispatch:
jobs:
verify:
name: wayland terminal input
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Install native build tools and Wayland compositor
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zsh weston
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
# Why: mirrors pr.yml so native module rebuilds do not use pnpm's
# non-executable bundled gyp_main.py on Linux runners.
- name: Use external node-gyp to avoid pnpm's bundled copy
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Prepare dependency install
run: |
if [ -e node_modules ]; then
ls -ld node_modules
rm -rf node_modules
fi
- name: Install dependencies
# Why: pnpm 10.24's frozen headless fast path can fail on fresh Ubuntu
# runners while creating the root node_modules. Use the normal resolver
# path, then verify package metadata stayed unchanged.
run: |
pnpm install --no-frozen-lockfile --prefer-frozen-lockfile=false
git diff --exit-code package.json pnpm-lock.yaml
- name: Start Weston
run: |
set -euo pipefail
export XDG_RUNTIME_DIR="$RUNNER_TEMP/xdg-runtime"
mkdir -p "$XDG_RUNTIME_DIR"
chmod 700 "$XDG_RUNTIME_DIR"
unset DISPLAY
weston --backend=headless-backend.so --socket=wayland-1 --idle-time=0 --log="$RUNNER_TEMP/weston.log" &
echo "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR" >> "$GITHUB_ENV"
echo "WAYLAND_DISPLAY=wayland-1" >> "$GITHUB_ENV"
echo "XDG_SESSION_TYPE=wayland" >> "$GITHUB_ENV"
echo "ELECTRON_OZONE_PLATFORM_HINT=wayland" >> "$GITHUB_ENV"
echo "ORCA_WAYLAND_GPU_VERBOSE=1" >> "$GITHUB_ENV"
for _ in $(seq 1 50); do
if [ -S "$XDG_RUNTIME_DIR/wayland-1" ]; then
break
fi
sleep 0.2
done
test -S "$XDG_RUNTIME_DIR/wayland-1"
- name: Reproduce terminal input freeze without the workaround
id: reproduce_base
if: github.event_name == 'pull_request'
# Why: still collect fixed-path Wayland evidence when the base repro
# stops reproducing; the final gate below fails the job in that case.
continue-on-error: true
timeout-minutes: 10
run: |
set -euo pipefail
git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.sha }}"
# Why: keep the new verifier scripts, but run them against the
# unfixed production terminal/GPU path instead of a hybrid checkout.
git checkout "${{ github.event.pull_request.base.sha }}" -- \
src/main/startup/configure-process.ts \
src/preload/api-types.ts \
src/preload/index.ts \
src/renderer/src/components/terminal-pane/pty-connection.ts \
src/renderer/src/lib/pane-manager/terminal-webgl-auto-policy.ts \
src/renderer/src/web/web-preload-api.ts
rm -rf out
node config/scripts/verify-linux-wayland-gpu-sandbox.mjs --mode=expect-repro
- name: Verify terminal input under Wayland GPU sandbox workaround
if: ${{ !cancelled() }}
timeout-minutes: 10
run: |
set -euo pipefail
git checkout --force "$GITHUB_SHA"
rm -rf out
node config/scripts/verify-linux-wayland-gpu-sandbox.mjs
- name: Require base reproduction
if: ${{ github.event_name == 'pull_request' && steps.reproduce_base.outcome != 'success' }}
run: |
echo "The unfixed base build did not reproduce the Wayland terminal input failure."
exit 1
- name: Upload Weston log
if: always()
uses: actions/upload-artifact@v7
with:
name: weston-log
path: ${{ runner.temp }}/weston.log
retention-days: 7
if-no-files-found: ignore

View File

@ -45,8 +45,20 @@ jobs:
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Prepare dependency install
run: |
if [ -e node_modules ]; then
ls -ld node_modules
rm -rf node_modules
fi
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why: pnpm 10.24's frozen headless fast path can fail on fresh Ubuntu
# runners while creating the root node_modules. Use the normal resolver
# path, then verify package metadata stayed unchanged.
run: |
pnpm install --no-frozen-lockfile --prefer-frozen-lockfile=false
git diff --exit-code package.json pnpm-lock.yaml
- name: Lint
run: pnpm exec oxlint --format github

View File

@ -0,0 +1,164 @@
const pollTimeoutMs = 2_500
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function pollRendererDiagnostics(read) {
const readPromise = Promise.resolve().then(read)
readPromise.catch(() => undefined)
// Why: the Wayland GPU stall can freeze renderer protocol calls, so
// diagnostics need a short deadline too.
const result = await Promise.race([
readPromise.then((value) => ({ timedOut: false, value })),
delay(pollTimeoutMs).then(() => ({ timedOut: true, value: null }))
])
if (result.timedOut) {
throw new Error(`Timed out polling renderer diagnostics after ${pollTimeoutMs}ms.`)
}
return result.value
}
export async function collectRendererDiagnostics(page) {
if (!page) {
return null
}
try {
return await pollRendererDiagnostics(() =>
page.evaluate(async () => {
const timed = (label, promise) =>
Promise.race([
Promise.resolve(promise).then(
(value) => ({ value }),
(error) => ({
error: error instanceof Error ? error.message : String(error)
})
),
new Promise((resolve) =>
setTimeout(() => resolve({ error: `Timed out collecting ${label}` }), 1_000)
)
])
const rectFor = (element) => {
if (!(element instanceof Element)) {
return null
}
const rect = element.getBoundingClientRect()
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height
}
}
const styleFor = (element) => {
if (!(element instanceof Element)) {
return null
}
const style = getComputedStyle(element)
return {
display: style.display,
visibility: style.visibility,
opacity: style.opacity
}
}
const store = window.__store
const state = store?.getState?.()
const worktreeId = state?.activeWorktreeId ?? null
const tabId = state?.activeTabId ?? null
const tabs = worktreeId ? (state?.tabsByWorktree?.[worktreeId] ?? []) : []
const activeTab = tabId ? (tabs.find((tab) => tab.id === tabId) ?? null) : null
const layout = tabId ? (state?.terminalLayoutsByTabId?.[tabId] ?? null) : null
const tabCount = worktreeId ? (state?.tabsByWorktree?.[worktreeId]?.length ?? 0) : null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const activePane = manager?.getActivePane?.() ?? null
const paneDiagnostics = (manager?.getPanes?.() ?? []).map((pane) => {
const xtermElement = pane.container?.querySelector?.('.xterm') ?? pane.terminal?.element
const viewport = pane.container?.querySelector?.('.xterm-viewport') ?? null
const buffer = pane.terminal?.buffer?.active ?? null
return {
paneId: pane.id ?? null,
leafId: pane.leafId ?? null,
isActive: activePane?.id === pane.id,
datasetPtyId: pane.container?.dataset?.ptyId ?? null,
terminalCols: pane.terminal?.cols ?? null,
terminalRows: pane.terminal?.rows ?? null,
bufferState: buffer
? {
baseY: buffer.baseY,
viewportY: buffer.viewportY,
cursorY: buffer.cursorY,
length: buffer.length
}
: null,
containerConnected: pane.container?.isConnected ?? null,
containerRect: rectFor(pane.container),
containerStyle: styleFor(pane.container),
xtermRect: rectFor(xtermElement),
viewportRect: rectFor(viewport),
viewportScroll: viewport
? {
scrollTop: viewport.scrollTop,
scrollHeight: viewport.scrollHeight,
clientHeight: viewport.clientHeight
}
: null
}
})
return {
hasStore: Boolean(store),
workspaceSessionReady: state?.workspaceSessionReady ?? null,
hydrationSucceeded: state?.hydrationSucceeded ?? null,
activeRepoId: state?.activeRepoId ?? null,
activeWorktreeId: worktreeId,
activeWorkspaceKey: state?.activeWorkspaceKey ?? null,
activeTabType: state?.activeTabType ?? null,
activeTabId: tabId,
repoIds: (state?.repos ?? []).map((repo) => repo.id),
worktreeIdsByRepo: Object.fromEntries(
Object.entries(state?.worktreesByRepo ?? {}).map(([id, worktrees]) => [
id,
worktrees.map((worktree) => worktree.id)
])
),
tabIdsByWorktree: Object.fromEntries(
Object.entries(state?.tabsByWorktree ?? {}).map(([id, worktreeTabs]) => [
id,
worktreeTabs.map((tab) => tab.id)
])
),
tabCount,
activeTab: activeTab
? {
id: activeTab.id,
ptyId: activeTab.ptyId ?? null,
title: activeTab.title ?? null,
pendingActivationSpawn: activeTab.pendingActivationSpawn ?? null
}
: null,
livePtyIdsForTab: tabId ? (state?.ptyIdsByTabId?.[tabId] ?? null) : null,
terminalLayout: layout
? {
activeLeafId: layout.activeLeafId ?? null,
expandedLeafId: layout.expandedLeafId ?? null,
ptyIdsByLeafId: layout.ptyIdsByLeafId ?? null,
root: layout.root ?? null
}
: null,
hasPaneManager: Boolean(manager),
paneDiagnostics,
renderingDiagnostics: manager?.getRenderingDiagnostics?.() ?? null,
ptyConnectDiagnostics: globalThis.__ptyConnectDiag ?? null,
ptySessions: await timed('PTY sessions', window.api?.pty?.listSessions?.()),
rendererDeliveryDebug: await timed(
'renderer delivery debug',
window.api?.pty?.getRendererDeliveryDebugSnapshot?.()
)
}
})
)
} catch (error) {
return {
error: error instanceof Error ? error.message : String(error)
}
}
}

View File

@ -0,0 +1,357 @@
import { writeFileSync } from 'node:fs'
import path from 'node:path'
import { runWithTimeout } from './linux-wayland-validation-watchdog.mjs'
const terminalWaitTimeoutMs = 45_000
const pollTimeoutMs = 2_500
const rendererActionTimeoutMs = 10_000
const rendererSetupTimeoutMs = 30_000
const typingSamples = 'abcdefghijklmnop'
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function interactivePromptScript(runId) {
return `
process.stdin.setEncoding('utf8')
if (process.stdin.isTTY) process.stdin.setRawMode(true)
process.stdin.resume()
let seq = 0
const interrupt = String.fromCharCode(3)
process.stdout.write('WAYLAND_TYPING_READY_${runId}\\n')
process.stdin.on('data', (chunk) => {
if (chunk.includes(interrupt)) {
process.exit(0)
}
for (const char of chunk) {
if (char === '\\r' || char === '\\n') continue
seq += 1
process.stdout.write('WAYLAND_TYPED_${runId}_' + seq + ':' + char + '\\n')
}
})
`
}
async function pollWithTimeout(label, read) {
const readPromise = Promise.resolve().then(read)
readPromise.catch(() => undefined)
// Why: the unfixed Wayland GPU stall can freeze renderer protocol calls, so
// each poll needs its own deadline instead of relying only on waitFor's loop.
const result = await Promise.race([
readPromise.then((value) => ({ timedOut: false, value })),
delay(pollTimeoutMs).then(() => ({ timedOut: true, value: null }))
])
if (result.timedOut) {
throw new Error(`Timed out polling ${label} after ${pollTimeoutMs}ms.`)
}
return result.value
}
async function waitFor(label, read, timeout = terminalWaitTimeoutMs) {
const startedAt = Date.now()
let lastValue
while (Date.now() - startedAt < timeout) {
lastValue = await pollWithTimeout(label, read)
if (lastValue) {
return lastValue
}
await delay(50)
}
throw new Error(`Timed out waiting for ${label}; last value: ${JSON.stringify(lastValue)}`)
}
async function getTerminalContent(page, charLimit = 12_000) {
return page.evaluate((limit) => {
const store = window.__store
if (!store || !window.__paneManagers) {
return ''
}
const state = store.getState()
const worktreeId = state.activeWorktreeId
const tabId =
state.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
return (pane?.serializeAddon?.serialize?.() ?? '').slice(-limit)
}, charLimit)
}
async function sendToTerminal(page, ptyId, text) {
await runWithTimeout(
'terminal input write',
() =>
page.evaluate(
({ ptyId: id, text: input }) => {
window.api.pty.write(id, input)
},
{ ptyId, text }
),
rendererActionTimeoutMs
)
}
async function focusActiveTerminal(page) {
await runWithTimeout(
'active terminal focus',
() =>
page.evaluate(() => {
const store = window.__store
const state = store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
throw new Error('No active terminal pane to focus.')
}
pane.terminal.focus()
pane.container.querySelector('.xterm-helper-textarea')?.focus()
}),
rendererActionTimeoutMs
)
}
export async function setupTerminal(page, repoPath, logPhase) {
logPhase('setup.wait-store')
await waitFor('renderer store exposure', () => page.evaluate(() => Boolean(window.__store)))
logPhase('setup.wait-hydration')
await waitFor('workspace session hydration', () =>
page.evaluate(() => {
const state = window.__store?.getState?.()
return Boolean(state?.workspaceSessionReady && state?.hydrationSucceeded)
})
)
logPhase('setup.add-repo')
const repoId = await runWithTimeout(
'repo registration',
() =>
page.evaluate(async (pathToAdd) => {
const result = await window.api.repos.add({ path: pathToAdd, kind: 'git' })
if ('error' in result) {
throw new Error(result.error)
}
const store = window.__store
if (!store) {
throw new Error('window.__store is not available.')
}
await store.getState().fetchRepos()
await store.getState().fetchWorktrees(result.repo.id, { requireAuthoritative: true })
return result.repo.id
}, repoPath),
rendererSetupTimeoutMs
)
// Why: startup hydration can reset activeWorktreeId; after it completes, set
// worktree, tab, and visible type in one renderer transaction for CI setup.
logPhase('setup.activate-worktree')
await waitFor('active terminal workspace setup', () =>
page.evaluate((id) => {
const store = window.__store
if (!store) {
return false
}
let state = store.getState()
const worktree = state.worktreesByRepo[id]?.[0]
if (!worktree) {
return false
}
state.setActiveWorktree(worktree.id)
state = store.getState()
const tabs = state.tabsByWorktree[worktree.id] ?? []
const tab =
tabs[0] ??
state.createTab(worktree.id, undefined, undefined, {
activate: true,
pendingActivationSpawn: true
})
state = store.getState()
state.setActiveTab(tab.id)
state.setActiveTabType('terminal')
state = store.getState()
if (
state.activeWorktreeId !== worktree.id ||
state.activeTabType !== 'terminal' ||
state.activeTabId !== tab.id
) {
return false
}
return true
}, repoId)
)
logPhase('setup.wait-pty')
const ptyId = await waitFor('active terminal PTY binding', () =>
page.evaluate(() => {
const store = window.__store
const state = store?.getState()
const tabId = state?.activeTabId
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
return pane?.container?.dataset?.ptyId ?? null
})
)
logPhase('setup.pty-bound', `ptyId=${ptyId}`)
return ptyId
}
export async function assertScrollbackBufferWorks(page, ptyId, runId, logPhase) {
logPhase('scroll.send-start')
await sendToTerminal(
page,
ptyId,
`for i in $(seq 1 160); do echo WAYLAND_SCROLL_${runId}_$i; done\r`
)
logPhase('scroll.send-done')
await waitFor('terminal scrollback marker', async () =>
(await getTerminalContent(page)).includes(`WAYLAND_SCROLL_${runId}_160`)
)
logPhase('scroll.marker-seen')
await focusActiveTerminal(page)
logPhase('scroll.focused')
const before = await waitFor('scrollable terminal buffer', () =>
page.evaluate(() => {
const store = window.__store
const state = store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
return null
}
const buffer = pane.terminal.buffer.active
if (buffer.baseY < 40) {
return null
}
pane.terminal.scrollToBottom()
if (buffer.viewportY < buffer.baseY - 1) {
return null
}
const target =
pane.container.querySelector('.xterm-screen') ??
pane.container.querySelector('.xterm-viewport') ??
pane.terminal.element ??
pane.container
if (!(target instanceof HTMLElement)) {
return null
}
const viewport = pane.container.querySelector('.xterm-viewport')
const rect = target.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) {
return null
}
return {
viewportY: buffer.viewportY,
baseY: buffer.baseY,
scrollTop: viewport instanceof HTMLElement ? viewport.scrollTop : null,
screenWidth: rect.width,
screenHeight: rect.height
}
})
)
logPhase('scroll.buffer-ready', `baseY=${before.baseY} viewportY=${before.viewportY}`)
// Why: headless Wayland does not provide a reliable native wheel path in CI,
// so verify xterm's scrollback buffer can move without bypassing the renderer.
await runWithTimeout(
'terminal scrollback API scroll',
() =>
page.evaluate(() => {
const store = window.__store
const state = store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
throw new Error('No active terminal pane for scrollback API scroll.')
}
pane.terminal.scrollLines(-10)
}),
rendererActionTimeoutMs
)
logPhase('scroll.api-scroll-sent')
const after = await waitFor('terminal scrollback API response', () =>
page.evaluate((previousViewportY) => {
const store = window.__store
const state = store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
return null
}
const buffer = pane.terminal.buffer.active
const viewport = pane.container.querySelector('.xterm-viewport')
return buffer.viewportY < previousViewportY
? {
viewportY: buffer.viewportY,
previousViewportY,
baseY: buffer.baseY,
scrollTop: viewport instanceof HTMLElement ? viewport.scrollTop : null
}
: null
}, before.viewportY)
)
logPhase('scroll.response', `viewportY=${after.viewportY} previous=${after.previousViewportY}`)
return {
beforeScrollTop: before.scrollTop,
afterScrollTop: after.scrollTop,
beforeViewportY: before.viewportY,
afterViewportY: after.viewportY,
baseY: after.baseY,
screenWidth: before.screenWidth,
screenHeight: before.screenHeight
}
}
export async function assertKeyboardInputWorks(page, ptyId, repoPath, runId, logPhase) {
const scriptPath = path.join(repoPath, `.orca-wayland-typing-${runId}.mjs`)
writeFileSync(scriptPath, interactivePromptScript(runId))
logPhase('typing.prompt-send')
await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
await waitFor('interactive prompt readiness', async () =>
(await getTerminalContent(page)).includes(`WAYLAND_TYPING_READY_${runId}`)
)
logPhase('typing.ready')
await focusActiveTerminal(page)
for (const [index, char] of [...typingSamples].entries()) {
logPhase('typing.char', `index=${index + 1}`)
await runWithTimeout(
`keyboard type ${index + 1}`,
() => page.keyboard.type(char),
rendererActionTimeoutMs
)
await waitFor(`typed marker ${index + 1}`, async () =>
(await getTerminalContent(page)).includes(`WAYLAND_TYPED_${runId}_${index + 1}:${char}`)
)
}
logPhase('typing.complete')
await sendToTerminal(page, ptyId, '\x03').catch(() => undefined)
return typingSamples.length
}

View File

@ -0,0 +1,47 @@
import process from 'node:process'
const formatError = (error) =>
error instanceof Error ? error.stack || error.message : String(error)
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export async function runWithTimeout(label, action, timeoutMs) {
const actionPromise = Promise.resolve().then(action)
actionPromise.catch(() => undefined)
// Why: Playwright protocol calls can remain pending when the Wayland GPU
// path wedges, so direct renderer actions need an independent deadline.
const result = await Promise.race([
actionPromise.then((value) => ({ timedOut: false, value })),
delay(timeoutMs).then(() => ({ timedOut: true, value: null }))
])
if (result.timedOut) {
throw new Error(`Timed out during ${label} after ${timeoutMs}ms.`)
}
return result.value
}
export function createPhaseLogger({ startedAt, onPhase }) {
return (phase, details = '') => {
onPhase(phase)
const suffix = details ? ` ${details}` : ''
console.log(`[wayland-gpu] phase=${phase} elapsedMs=${Date.now() - startedAt}${suffix}`)
}
}
export function startValidationWatchdog({ timeoutMs, onTimeout }) {
const timer = setTimeout(() => {
void Promise.resolve()
.then(onTimeout)
.then((exitCode) => {
process.exit(exitCode)
})
.catch((error) => {
console.error(`[wayland-gpu] watchdog failed: ${formatError(error)}`)
process.exit(1)
})
}, timeoutMs)
timer.unref?.()
return () => clearTimeout(timer)
}

View File

@ -0,0 +1,371 @@
#!/usr/bin/env node
import { _electron as electron } from '@playwright/test'
import { execFileSync } from 'node:child_process'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { collectRendererDiagnostics } from './linux-wayland-renderer-diagnostics.mjs'
import {
assertKeyboardInputWorks,
assertScrollbackBufferWorks,
setupTerminal
} from './linux-wayland-terminal-exercise.mjs'
import {
createPhaseLogger,
runWithTimeout,
startValidationWatchdog
} from './linux-wayland-validation-watchdog.mjs'
const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url)))
const outMain = path.join(rootDir, 'out', 'main', 'index.js')
const timeoutMs = 45_000
const rendererSetupTimeoutMs = 30_000
const appCloseTimeoutMs = 5_000
const validationWatchdogMs = 7 * 60_000
const gpuCrashPattern =
/GPU process (?:exited unexpectedly|isn't usable)|gpu_data_manager|exit[_ -]?code=8704/i
class MissingReproductionError extends Error {}
function hasBaseReproductionEvidence({ error, gpuCrashLines, phase, terminalExerciseStarted }) {
if (error instanceof MissingReproductionError) {
return false
}
return (
terminalExerciseStarted ||
gpuCrashLines.length > 0 ||
// Why: the unfixed Wayland GPU path can wedge before the terminal receives
// a PTY; reaching this boundary means the terminal pane itself is present.
phase === 'setup.wait-pty'
)
}
function parseArgs() {
const modeArg = process.argv.find((arg) => arg.startsWith('--mode='))
const mode = modeArg?.slice('--mode='.length) ?? 'verify-fix'
if (mode !== 'verify-fix' && mode !== 'expect-repro') {
throw new Error(`Unsupported --mode=${mode}`)
}
return { mode }
}
function run(command, args, options = {}) {
execFileSync(command, args, {
cwd: rootDir,
env: process.env,
stdio: 'inherit',
...options
})
}
function assertWaylandHost() {
if (process.platform !== 'linux') {
throw new Error('Wayland GPU sandbox validation must run on Linux.')
}
if (
!process.env.WAYLAND_DISPLAY &&
process.env.XDG_SESSION_TYPE !== 'wayland' &&
process.env.ELECTRON_OZONE_PLATFORM_HINT !== 'wayland'
) {
throw new Error('Wayland GPU sandbox validation requires a Wayland session.')
}
}
function ensureElectronRuntime() {
run(process.execPath, ['config/scripts/ensure-native-runtime.mjs', '--runtime=electron'])
}
function buildAppIfNeeded() {
if (process.env.SKIP_BUILD === '1' && existsSync(outMain)) {
console.log('[wayland-gpu] SKIP_BUILD=1 and out/main/index.js exists; skipping build.')
return
}
run('npx', ['electron-vite', 'build', '--mode', 'e2e'])
}
function createGitRepo() {
const repoDir = mkdtempSync(path.join(tmpdir(), 'orca-wayland-gpu-repo-'))
run('git', ['init'], { cwd: repoDir, stdio: 'pipe' })
run('git', ['config', 'user.email', 'wayland-gpu@test.local'], { cwd: repoDir, stdio: 'pipe' })
run('git', ['config', 'user.name', 'Wayland GPU Test'], { cwd: repoDir, stdio: 'pipe' })
writeFileSync(path.join(repoDir, 'README.md'), '# Wayland GPU sandbox validation\n')
writeFileSync(path.join(repoDir, 'package.json'), '{"private":true,"type":"module"}\n')
run('git', ['add', '-A'], { cwd: repoDir, stdio: 'pipe' })
run('git', ['commit', '-m', 'Initial validation fixture'], { cwd: repoDir, stdio: 'pipe' })
return repoDir
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function closeElectronApp(app) {
if (!app) {
return
}
const electronProcess = app.process()
let closeError
const didClose = await Promise.race([
app.close().then(
() => true,
(error) => {
closeError = error
return false
}
),
delay(appCloseTimeoutMs).then(() => false)
])
if (didClose) {
return
}
if (closeError) {
console.warn(
`[wayland-gpu] Electron close failed: ${closeError instanceof Error ? closeError.message : closeError}`
)
}
// Why: reproducing the Wayland GPU stall can wedge Chromium teardown after
// the evidence is collected, so CI needs a bounded close path.
if (electronProcess && electronProcess.exitCode === null && electronProcess.signalCode === null) {
console.warn('[wayland-gpu] Electron did not close cleanly; killing the app process.')
electronProcess.kill('SIGKILL')
await Promise.race([
new Promise((resolve) => electronProcess.once('exit', resolve)),
delay(appCloseTimeoutMs)
])
}
}
async function runValidation(mode) {
assertWaylandHost()
ensureElectronRuntime()
buildAppIfNeeded()
const repoPath = createGitRepo()
const userDataPath = mkdtempSync(path.join(tmpdir(), 'orca-wayland-gpu-userdata-'))
const runId = `${Date.now()}`
let app
let page
let terminalExerciseStarted = false
let commandLineSwitches = null
const stderrLines = []
const validationState = {
startedAt: Date.now(),
phase: 'initial'
}
const logPhase = createPhaseLogger({
startedAt: validationState.startedAt,
onPhase: (phase) => {
validationState.phase = phase
}
})
const stopWatchdog = startValidationWatchdog({
timeoutMs: validationWatchdogMs,
onTimeout: async () => {
const gpuCrashLines = stderrLines.filter((line) => gpuCrashPattern.test(line))
const rendererDiagnostics = await collectRendererDiagnostics(page)
const reproduced =
mode === 'expect-repro' && (terminalExerciseStarted || gpuCrashLines.length > 0)
const payload = {
mode,
watchdogTimedOut: true,
reproduced,
phase: validationState.phase,
elapsedMs: Date.now() - validationState.startedAt,
switches: commandLineSwitches,
rendererDiagnostics,
gpuCrashLines
}
const output = JSON.stringify(payload, null, 2)
if (reproduced) {
console.log(output)
return 0
}
console.error(output)
return 1
}
})
try {
const { ELECTRON_RUN_AS_NODE: _unused, DISPLAY: _display, ...env } = process.env
void _unused
void _display
logPhase('launch.start')
app = await runWithTimeout(
'Electron launch',
() =>
electron.launch({
args: ['--ozone-platform=wayland', outMain],
env: {
...env,
NODE_ENV: 'development',
ORCA_DEV_USER_DATA_PATH: userDataPath,
ELECTRON_ENABLE_LOGGING: '1',
ELECTRON_ENABLE_STACK_DUMPING: '1',
ELECTRON_OZONE_PLATFORM_HINT: 'wayland',
XDG_SESSION_TYPE: 'wayland'
}
}),
timeoutMs
)
logPhase('launch.done')
app.process().stderr?.on('data', (chunk) => {
const text = chunk.toString()
stderrLines.push(...text.split(/\r?\n/).filter(Boolean))
if (process.env.ORCA_WAYLAND_GPU_VERBOSE === '1') {
process.stderr.write(text)
}
})
logPhase('app.when-ready')
await runWithTimeout(
'Electron app readiness',
() =>
app.evaluate(async ({ app: electronApp }) => {
await electronApp.whenReady()
}),
rendererSetupTimeoutMs
)
commandLineSwitches = await runWithTimeout(
'Electron command-line switches',
() =>
app.evaluate(({ app: electronApp }) => ({
disableGpuSandbox: electronApp.commandLine.hasSwitch('disable-gpu-sandbox'),
disableGpu: electronApp.commandLine.hasSwitch('disable-gpu'),
ozonePlatform: electronApp.commandLine.getSwitchValue('ozone-platform'),
enableFeatures: electronApp.commandLine.getSwitchValue('enable-features')
})),
rendererSetupTimeoutMs
)
logPhase(
'app.switches',
`disableGpuSandbox=${commandLineSwitches.disableGpuSandbox} disableGpu=${commandLineSwitches.disableGpu}`
)
if (mode === 'expect-repro' && commandLineSwitches.disableGpuSandbox) {
throw new MissingReproductionError(
'Base run already has --disable-gpu-sandbox; cannot validate the unfixed Wayland path.'
)
}
if (mode === 'expect-repro' && commandLineSwitches.disableGpu) {
throw new MissingReproductionError(
'Base run has --disable-gpu; hardware acceleration is disabled and would mask the GPU sandbox path.'
)
}
if (mode === 'verify-fix' && !commandLineSwitches.disableGpuSandbox) {
throw new Error('Expected --disable-gpu-sandbox on Linux Wayland, but it was absent.')
}
if (mode === 'verify-fix' && commandLineSwitches.disableGpu) {
throw new Error('Expected hardware acceleration to remain enabled, but --disable-gpu is set.')
}
logPhase('window.first')
page = await runWithTimeout('first renderer window', () => app.firstWindow(), timeoutMs)
logPhase('window.load')
await runWithTimeout(
'renderer domcontentloaded',
() => page.waitForLoadState('domcontentloaded'),
rendererSetupTimeoutMs
)
logPhase('window.loaded')
const ptyId = await setupTerminal(page, repoPath, logPhase)
terminalExerciseStarted = true
logPhase('exercise.start')
const scroll = await assertScrollbackBufferWorks(page, ptyId, runId, logPhase)
const typedMarkers = await assertKeyboardInputWorks(page, ptyId, repoPath, runId, logPhase)
const gpuCrashLines = stderrLines.filter((line) => gpuCrashPattern.test(line))
if (mode === 'expect-repro') {
throw new MissingReproductionError(
'Terminal input and scroll stayed responsive without the fix on this host.'
)
}
if (gpuCrashLines.length > 0) {
throw new Error(`GPU crash evidence appeared in stderr:\n${gpuCrashLines.join('\n')}`)
}
console.log(
JSON.stringify(
{
mode,
phase: validationState.phase,
waylandDisplay: process.env.WAYLAND_DISPLAY ?? null,
xdgSessionType: process.env.XDG_SESSION_TYPE ?? null,
switches: commandLineSwitches,
scroll,
typedMarkers,
gpuCrashLines
},
null,
2
)
)
} catch (error) {
const gpuCrashLines = stderrLines.filter((line) => gpuCrashPattern.test(line))
const rendererDiagnostics = await collectRendererDiagnostics(page)
if (
mode === 'expect-repro' &&
hasBaseReproductionEvidence({
error,
gpuCrashLines,
phase: validationState.phase,
terminalExerciseStarted
})
) {
console.log(
JSON.stringify(
{
mode,
reproduced: true,
reason: error instanceof Error ? error.message : String(error),
phase: validationState.phase,
switches: commandLineSwitches,
rendererDiagnostics,
gpuCrashLines
},
null,
2
)
)
return
}
console.error(
JSON.stringify(
{
mode,
reason: error instanceof Error ? error.message : String(error),
phase: validationState.phase,
switches: commandLineSwitches,
rendererDiagnostics,
gpuCrashLines
},
null,
2
)
)
throw error
} finally {
stopWatchdog()
await closeElectronApp(app)
rmSync(repoPath, { recursive: true, force: true })
rmSync(userDataPath, { recursive: true, force: true })
}
}
const { mode } = parseArgs()
runValidation(mode).then(
() => {
// Why: a reproduced GPU stall can leave Playwright/Electron handles alive
// after cleanup; CI should finish once validation has made its decision.
process.exit(0)
},
(error) => {
console.error(error instanceof Error ? error.stack || error.message : error)
process.exit(1)
}
)

View File

@ -297,6 +297,139 @@ describe('enableMainProcessGpuFeatures', () => {
expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('enable-unsafe-webgpu')
})
it('disables the GPU sandbox on Linux Wayland without disabling acceleration', async () => {
const { app } = await import('electron')
const { enableMainProcessGpuFeatures } = await import('./configure-process')
const originalWaylandDisplay = process.env.WAYLAND_DISPLAY
try {
setPlatform('linux')
delete process.env.ORCA_E2E_USER_DATA_DIR
process.env.WAYLAND_DISPLAY = 'wayland-1'
vi.mocked(app.disableHardwareAcceleration).mockClear()
vi.mocked(app.commandLine.appendSwitch).mockClear()
enableMainProcessGpuFeatures()
} finally {
if (originalWaylandDisplay === undefined) {
delete process.env.WAYLAND_DISPLAY
} else {
process.env.WAYLAND_DISPLAY = originalWaylandDisplay
}
}
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('disable-gpu-sandbox')
expect(app.disableHardwareAcceleration).not.toHaveBeenCalled()
expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith(
'enable-features',
expect.stringContaining('EarlyEstablishGpuChannel')
)
expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith(
'enable-features',
expect.stringContaining('EstablishGpuChannelAsync')
)
})
it('uses Electron Ozone hints to recognize forced Linux Wayland launches', async () => {
const { app } = await import('electron')
const { enableMainProcessGpuFeatures } = await import('./configure-process')
setPlatform('linux')
delete process.env.ORCA_E2E_USER_DATA_DIR
vi.mocked(app.commandLine.appendSwitch).mockClear()
vi.mocked(app.commandLine.getSwitchValue).mockImplementation((switchName: string) =>
switchName === 'ozone-platform' ? 'wayland' : ''
)
enableMainProcessGpuFeatures()
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('disable-gpu-sandbox')
expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith(
'enable-features',
expect.stringContaining('EarlyEstablishGpuChannel')
)
})
it('honors explicit Linux X11 Ozone overrides even when Wayland env vars are present', async () => {
const { app } = await import('electron')
const { enableMainProcessGpuFeatures } = await import('./configure-process')
const originalWaylandDisplay = process.env.WAYLAND_DISPLAY
const originalSessionType = process.env.XDG_SESSION_TYPE
try {
setPlatform('linux')
delete process.env.ORCA_E2E_USER_DATA_DIR
process.env.WAYLAND_DISPLAY = 'wayland-1'
process.env.XDG_SESSION_TYPE = 'wayland'
vi.mocked(app.commandLine.appendSwitch).mockClear()
vi.mocked(app.commandLine.getSwitchValue).mockImplementation((switchName: string) =>
switchName === 'ozone-platform' ? 'x11' : ''
)
enableMainProcessGpuFeatures()
} finally {
if (originalWaylandDisplay === undefined) {
delete process.env.WAYLAND_DISPLAY
} else {
process.env.WAYLAND_DISPLAY = originalWaylandDisplay
}
if (originalSessionType === undefined) {
delete process.env.XDG_SESSION_TYPE
} else {
process.env.XDG_SESSION_TYPE = originalSessionType
}
}
expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('disable-gpu-sandbox')
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith(
'enable-features',
'EarlyEstablishGpuChannel,EstablishGpuChannelAsync'
)
})
it('does not disable the GPU sandbox outside Linux Wayland', async () => {
const { app } = await import('electron')
const { enableMainProcessGpuFeatures } = await import('./configure-process')
const originalWaylandDisplay = process.env.WAYLAND_DISPLAY
const originalSessionType = process.env.XDG_SESSION_TYPE
const originalOzoneHint = process.env.ELECTRON_OZONE_PLATFORM_HINT
try {
delete process.env.ORCA_E2E_USER_DATA_DIR
delete process.env.WAYLAND_DISPLAY
delete process.env.XDG_SESSION_TYPE
delete process.env.ELECTRON_OZONE_PLATFORM_HINT
for (const platform of ['linux', 'darwin', 'win32'] as const) {
setPlatform(platform)
vi.mocked(app.commandLine.appendSwitch).mockClear()
vi.mocked(app.commandLine.getSwitchValue).mockImplementation((switchName: string) =>
switchName === 'enable-features' ? '' : ''
)
enableMainProcessGpuFeatures()
expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('disable-gpu-sandbox')
}
} finally {
if (originalWaylandDisplay === undefined) {
delete process.env.WAYLAND_DISPLAY
} else {
process.env.WAYLAND_DISPLAY = originalWaylandDisplay
}
if (originalSessionType === undefined) {
delete process.env.XDG_SESSION_TYPE
} else {
process.env.XDG_SESSION_TYPE = originalSessionType
}
if (originalOzoneHint === undefined) {
delete process.env.ELECTRON_OZONE_PLATFORM_HINT
} else {
process.env.ELECTRON_OZONE_PLATFORM_HINT = originalOzoneHint
}
}
})
it('disables the GPU process for Linux E2E runs', async () => {
const { app } = await import('electron')
const { enableMainProcessGpuFeatures } = await import('./configure-process')
@ -330,4 +463,31 @@ describe('enableMainProcessGpuFeatures', () => {
'EarlyEstablishGpuChannel,EstablishGpuChannelAsync,ExistingFeature'
)
})
it('preserves existing enable-features switches on Linux Wayland without eager GPU channel flags', async () => {
const { app } = await import('electron')
const { enableMainProcessGpuFeatures } = await import('./configure-process')
const originalWaylandDisplay = process.env.WAYLAND_DISPLAY
try {
setPlatform('linux')
delete process.env.ORCA_E2E_USER_DATA_DIR
process.env.WAYLAND_DISPLAY = 'wayland-1'
vi.mocked(app.commandLine.appendSwitch).mockClear()
vi.mocked(app.commandLine.getSwitchValue).mockImplementation((switchName: string) =>
switchName === 'enable-features' ? 'ExistingFeature' : ''
)
enableMainProcessGpuFeatures()
} finally {
if (originalWaylandDisplay === undefined) {
delete process.env.WAYLAND_DISPLAY
} else {
process.env.WAYLAND_DISPLAY = originalWaylandDisplay
}
}
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('disable-gpu-sandbox')
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('enable-features', 'ExistingFeature')
})
})

View File

@ -307,16 +307,35 @@ export function enableMainProcessGpuFeatures(): void {
return
}
const ozonePlatform = (app.commandLine.getSwitchValue('ozone-platform') ?? '').toLowerCase()
const ozonePlatformHint = (process.env.ELECTRON_OZONE_PLATFORM_HINT ?? '').toLowerCase()
const isLinuxX11Override =
ozonePlatform === 'x11' || (ozonePlatform === '' && ozonePlatformHint === 'x11')
const isLinuxWaylandSession =
process.platform === 'linux' &&
!isLinuxX11Override &&
(Boolean(process.env.WAYLAND_DISPLAY) ||
process.env.XDG_SESSION_TYPE === 'wayland' ||
ozonePlatformHint === 'wayland' ||
ozonePlatform === 'wayland')
if (isLinuxWaylandSession) {
// Why: #5319 reproduces when Wayland loses the eager GPU channel. Keep
// acceleration available, but drop the GPU sandbox and let Chromium open
// the GPU channel lazily on this compositor path.
app.commandLine.appendSwitch('disable-gpu-sandbox')
}
const existingFeatures = app.commandLine.getSwitchValue('enable-features')
const features = [
// Why: mirror VS Code's conservative Electron GPU-channel startup flags
// instead of opting into Vulkan/SkiaGraphite/unsafe WebGPU globally.
// Terminal acceleration is controlled by xterm WebGL in the renderer.
'EarlyEstablishGpuChannel',
'EstablishGpuChannelAsync',
...(isLinuxWaylandSession ? [] : ['EarlyEstablishGpuChannel', 'EstablishGpuChannelAsync']),
existingFeatures
]
.filter(Boolean)
.join(',')
app.commandLine.appendSwitch('enable-features', features)
if (features) {
app.commandLine.appendSwitch('enable-features', features)
}
}

View File

@ -807,6 +807,7 @@ export type PreloadApi = {
get: () => {
platform: NodeJS.Platform
osRelease: string
displayServer: 'wayland' | 'x11' | null
}
}
e2e: {

View File

@ -198,6 +198,20 @@ type NativeFileDropCallback = (data: NativeFileDropPayload) => void
const nativeFileDropCallbacks: NativeFileDropCallback[] = []
let nativeFileDropListenerRegistered = false
function getLinuxDisplayServer(): 'wayland' | 'x11' | null {
if (process.platform !== 'linux') {
return null
}
if (
process.env.WAYLAND_DISPLAY ||
process.env.XDG_SESSION_TYPE?.toLowerCase() === 'wayland' ||
process.env.ELECTRON_OZONE_PLATFORM_HINT?.toLowerCase() === 'wayland'
) {
return 'wayland'
}
return process.env.DISPLAY ? 'x11' : null
}
type AppRestartPrepOptions = {
startedEventName: string
abortedEventName: string
@ -459,7 +473,9 @@ const api = {
get: () => ({
platform: process.platform,
osRelease:
(process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() ?? ''
(process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() ??
'',
displayServer: getLinuxDisplayServer()
})
} satisfies PreloadApi['platform'],

View File

@ -2240,7 +2240,8 @@ describe('connectPanePty', () => {
)
const remountDeps = createDeps()
connectPanePty(createPane(1) as never, createManager(2) as never, remountDeps as never)
const remountPane = createPane(1)
connectPanePty(remountPane as never, createManager(2) as never, remountDeps as never)
setupSpawn.resolve('pty-setup')
mainSpawn.resolve('pty-main')
@ -2251,10 +2252,38 @@ describe('connectPanePty', () => {
expect(remountTransport.attach).toHaveBeenCalledWith(
expect.objectContaining({ existingPtyId: 'pty-main' })
)
expect(remountPane.container.dataset.ptyId).toBe('pty-main')
expect(remountDeps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'pty-main')
expect(remountDeps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-main')
})
it('binds a fresh spawn that resolves as a daemon reattach', async () => {
const { connectPanePty } = await import('./pty-connection')
let currentPtyId: string | null = null
const transport = createMockTransport()
transport.getPtyId.mockImplementation(() => currentPtyId)
transport.connect.mockImplementation(async () => {
currentPtyId = 'pty-daemon-reattach'
return { id: currentPtyId, isReattach: true }
})
transportFactoryQueue.push(transport)
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
ptyIdsByTabId: { 'tab-1': [] }
}
const pane = createPane(1)
const deps = createDeps()
connectPanePty(pane as never, createManager(1) as never, deps as never)
await flushAsyncTicks()
expect(pane.container.dataset.ptyId).toBe('pty-daemon-reattach')
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'pty-daemon-reattach')
expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-daemon-reattach')
})
it('drops xterm onData while pane is replaying restored bytes', async () => {
// Regression: during cold-restore / snapshot replay, xterm auto-replies
// to embedded query sequences (DA1, DECRQM, OSC 10/11, focus, CPR) via
@ -3352,12 +3381,14 @@ describe('connectPanePty', () => {
restoredPtyIdByLeafId: { [LEAF_1]: eagerPtyId }
})
connectPanePty(createPane(1) as never, createManager(1) as never, deps as never)
const pane = createPane(1)
connectPanePty(pane as never, createManager(1) as never, deps as never)
await flushAsyncTicks()
expect(transport.attach).toHaveBeenCalledWith(
expect.objectContaining({ existingPtyId: eagerPtyId })
)
expect(pane.container.dataset.ptyId).toBe(eagerPtyId)
expect(transport.connect).not.toHaveBeenCalledWith(
expect.objectContaining({ sessionId: eagerPtyId })
)

View File

@ -816,6 +816,8 @@ export function connectPanePty(
exposeE2eTerminalPtyOutputDebug()
let disposed = false
let connectFrame: number | null = null
let connectFallbackTimer: ReturnType<typeof setTimeout> | null = null
let connectStarted = false
let unregisterBacklogRecovery: (() => void) | null = null
let unregisterDocumentVisibilityRecovery: (() => void) | null = null
let cleanupHiddenOutputRestoreDeferredRetry = (): void => {}
@ -1276,10 +1278,12 @@ export function connectPanePty(
bindPanePtyId(pane.id, ptyId, deps.tabId)
pane.container.dataset.ptyId = ptyId
}
let activePanePtyBinding: string | null = null
const clearPanePtyFitBinding = (): void => {
// Why: fit bindings live in a module-level map, so pane teardown must
// clear them explicitly instead of relying on DOM removal.
bindPanePtyId(pane.id, null, deps.tabId)
activePanePtyBinding = null
delete pane.container.dataset.ptyId
}
@ -1558,19 +1562,31 @@ export function connectPanePty(
onDone: scheduleCommandCodeOutputDoneStatus
})
const observeTerminalGitHubPRLink = createTerminalGitHubPRLinkDetector()
const onPtySpawn = (ptyId: string): void => {
const bindActivePanePty = (
ptyId: string,
options: { seedInitialAgentStatus?: boolean; updateTabPtyId?: 'always' | 'if-missing' } = {}
): void => {
setPanePtyFitBinding(ptyId)
activePanePtyBinding = ptyId
deps.syncPanePtyLayoutBinding(pane.id, ptyId)
deps.updateTabPtyId(deps.tabId, ptyId)
// Why: Command Code has no prompt-start hook. Seed the visible working row
// once the PTY exists, then let real hook events refine or complete it.
applyInitialAgentStatus()
// Spawn completion is when a pane gains a concrete PTY ID. The initial
const tabPtyIds = useAppStore.getState().ptyIdsByTabId?.[deps.tabId] ?? []
if (options.updateTabPtyId !== 'if-missing' || !tabPtyIds.includes(ptyId)) {
deps.updateTabPtyId(deps.tabId, ptyId)
}
if (options.seedInitialAgentStatus) {
applyInitialAgentStatus()
}
// Spawn/attach completion is when a pane gains a concrete PTY ID. The initial
// frame-level sync often runs before that async result arrives.
scheduleRuntimeGraphSync()
agentCompletionCoordinator.startProcessTracking()
}
const onPtySpawn = (ptyId: string): void => {
// Why: Command Code has no prompt-start hook. Seed the visible working row
// once the PTY exists, then let real hook events refine or complete it.
bindActivePanePty(ptyId, { seedInitialAgentStatus: true })
}
// ─── Attention signal: BEL ────────────────────────────────────────────
//
// BEL (0x07) is the attention signal. A BEL raises tab- and worktree-level
@ -2232,8 +2248,24 @@ export function connectPanePty(
// Defer PTY spawn/attach to next frame so FitAddon has time to calculate
// the correct terminal dimensions from the laid-out container.
connectFrame = requestAnimationFrame(() => {
connectFrame = null
const cancelScheduledConnectFrame = (): void => {
if (connectFrame !== null) {
if (typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(connectFrame)
}
connectFrame = null
}
}
const runDeferredConnect = (): void => {
if (connectStarted) {
return
}
connectStarted = true
cancelScheduledConnectFrame()
if (connectFallbackTimer !== null) {
clearTimeout(connectFallbackTimer)
connectFallbackTimer = null
}
if (disposed) {
return
}
@ -2610,6 +2642,18 @@ export function connectPanePty(
// viable delivery target and must not wait for a future pane.
clearRegisteredStartupLaunchConfig()
}
if (
resolvedPtyId &&
spawnedPtyId &&
typeof spawnedPtyId === 'object' &&
'id' in spawnedPtyId &&
activePanePtyBinding !== resolvedPtyId &&
transport.getPtyId() === resolvedPtyId
) {
// Why: daemon createOrAttach can turn an apparent fresh spawn into
// a reattach; the transport skips onPtySpawn there to preserve recency.
bindActivePanePty(resolvedPtyId, { updateTabPtyId: 'if-missing' })
}
if (resolvedPtyId) {
reconcilePtySizeAfterSpawn(resolvedPtyId, cols, rows)
}
@ -4304,9 +4348,7 @@ export function connectPanePty(
onError: reportError
}
})
deps.syncPanePtyLayoutBinding(pane.id, attachPtyId)
deps.updateTabPtyId(deps.tabId, attachPtyId)
agentCompletionCoordinator.startProcessTracking()
bindActivePanePty(attachPtyId, { updateTabPtyId: 'if-missing' })
if (attachPtyId === eagerLivePtyId) {
registerPaneSerializerFor(attachPtyId)
}
@ -4346,11 +4388,6 @@ export function connectPanePty(
}
return
}
// Why: this attach path reuses a PTY spawned by an earlier mount.
// Persist the binding here so tab-level PTY ownership stays correct
// even if no later spawn event or layout snapshot runs.
deps.syncPanePtyLayoutBinding(pane.id, spawnedPtyId)
deps.updateTabPtyId(deps.tabId, spawnedPtyId)
clearPaneMode2031State()
clearHiddenOutputRestoreState()
transport.attach({
@ -4363,9 +4400,9 @@ export function connectPanePty(
onError: reportError
}
})
// Why: attach sets the transport's PTY id; starting process
// tracking before this point no-ops because getPtyId() is empty.
agentCompletionCoordinator.startProcessTracking()
// Why: this path reuses a PTY spawned by an earlier mount, so no
// later spawn event will bind this remounted pane's DOM/container.
bindActivePanePty(spawnedPtyId, { updateTabPtyId: 'if-missing' })
})
.catch((err) => {
reportError(err instanceof Error ? err.message : String(err))
@ -4380,7 +4417,12 @@ export function connectPanePty(
}
}
scheduleRuntimeGraphSync()
})
}
// Why: Wayland/CI compositors can keep timers and CDP responsive while the
// next rAF never arrives; the terminal must still start its PTY once.
connectFallbackTimer = setTimeout(runDeferredConnect, 250)
connectFrame = requestAnimationFrame(runDeferredConnect)
// Why: on visibility resume a pane may still be bound to a daemon session
// reaped while hidden (the missed-exit defect). Route it through the SAME
@ -4517,8 +4559,11 @@ export function connectPanePty(
// before its deferred PTY attach/spawn work runs. Cancel that queued
// frame so stale bindings cannot reattach the PTY and steal the live
// handler wiring from the current pane.
cancelAnimationFrame(connectFrame)
connectFrame = null
cancelScheduledConnectFrame()
}
if (connectFallbackTimer !== null) {
clearTimeout(connectFallbackTimer)
connectFallbackTimer = null
}
onDataDisposable.dispose()
terminalCapabilityRepliesDisposable.dispose()

View File

@ -62,6 +62,20 @@ function stubNoDocument(): void {
vi.stubGlobal('document', undefined)
}
function stubDisplayServer(displayServer: 'wayland' | 'x11' | null): void {
vi.stubGlobal('window', {
api: {
platform: {
get: () => ({
platform: 'linux',
osRelease: '',
displayServer
})
}
}
})
}
describe('terminal WebGL auto policy', () => {
beforeEach(() => {
resetTerminalWebglAutoDecision()
@ -105,6 +119,19 @@ describe('terminal WebGL auto policy', () => {
})
})
it('keeps Linux auto panes on DOM for Wayland before probing WebGL', () => {
stubNavigator('Linux x86_64', 'Mozilla/5.0 (X11; Linux x86_64)')
stubDisplayServer('wayland')
stubNoDocument()
expect(getTerminalWebglAutoDecision()).toEqual({
allowWebgl: false,
reason: 'linux-wayland',
renderer: null,
vendor: null
})
})
it('keeps Linux auto panes on DOM when WebGL2 is unavailable', () => {
stubNavigator('Linux x86_64', 'Mozilla/5.0 (X11; Linux x86_64)')
stubWebglRendererInfo({ hasWebgl2: false })

View File

@ -2,6 +2,7 @@ export type TerminalWebglAutoDecision = {
allowWebgl: boolean
reason:
| 'non-linux'
| 'linux-wayland'
| 'linux-hardware-renderer'
| 'linux-webgl2-unavailable'
| 'linux-renderer-unavailable'
@ -29,6 +30,14 @@ export function isLinuxRendererHost(
return platform.includes('Linux') || userAgent.includes('Linux')
}
function readRendererDisplayServer(): 'wayland' | 'x11' | null {
try {
return window.api.platform.get().displayServer
} catch {
return null
}
}
function readWebglRendererInfo(): Pick<TerminalWebglAutoDecision, 'renderer' | 'vendor'> & {
hasWebgl2: boolean
hasRendererInfo: boolean
@ -77,6 +86,18 @@ export function getTerminalWebglAutoDecision(): TerminalWebglAutoDecision {
return cachedDecision
}
if (readRendererDisplayServer() === 'wayland') {
// Why: #5319 can wedge terminal input during xterm WebGL context creation
// on Linux Wayland before xterm reports a recoverable context-loss event.
cachedDecision = {
allowWebgl: false,
reason: 'linux-wayland',
renderer: null,
vendor: null
}
return cachedDecision
}
const rendererInfo = readWebglRendererInfo()
if (!rendererInfo.hasWebgl2) {
cachedDecision = {

View File

@ -488,7 +488,8 @@ function createWebPreloadApi(): Partial<PreloadApi> {
platform: {
get: () => ({
platform: getBrowserPlatform(),
osRelease: ''
osRelease: '',
displayServer: null
})
},
e2e: {