fix(linux): disable GPU sandbox to stop terminal input freeze on Wayland (#5319)
This commit is contained in:
parent
4a07a1dd86
commit
0976f9427c
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -807,6 +807,7 @@ export type PreloadApi = {
|
|||
get: () => {
|
||||
platform: NodeJS.Platform
|
||||
osRelease: string
|
||||
displayServer: 'wayland' | 'x11' | null
|
||||
}
|
||||
}
|
||||
e2e: {
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -488,7 +488,8 @@ function createWebPreloadApi(): Partial<PreloadApi> {
|
|||
platform: {
|
||||
get: () => ({
|
||||
platform: getBrowserPlatform(),
|
||||
osRelease: ''
|
||||
osRelease: '',
|
||||
displayServer: null
|
||||
})
|
||||
},
|
||||
e2e: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue