mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Improve Windows terminal performance: retain WebGL contexts, warm first ConPTY (#7085)
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
import { startDaemon, type DaemonHandle } from './daemon-main'
|
||||
import { createPtySubprocess } from './pty-subprocess'
|
||||
import { warmWindowsConptyOnce } from './windows-conpty-warmup'
|
||||
import { warmPwshAvailabilityCache } from '../pwsh'
|
||||
|
||||
export function parseArgs(argv: string[]): { socketPath: string; tokenPath: string } {
|
||||
@@ -84,6 +85,8 @@ async function main(): Promise<void> {
|
||||
if (process.send) {
|
||||
process.send({ type: 'ready' })
|
||||
}
|
||||
|
||||
warmWindowsConptyOnce()
|
||||
}
|
||||
|
||||
// Only auto-run when executed directly (not imported for testing)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as pty from 'node-pty'
|
||||
import { warmWindowsConptyOnce } from './windows-conpty-warmup'
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): () => void {
|
||||
const original = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
return () => Object.defineProperty(process, 'platform', { configurable: true, value: original })
|
||||
}
|
||||
|
||||
function flushImmediates(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
|
||||
let restorePlatform: (() => void) | null = null
|
||||
afterEach(() => {
|
||||
restorePlatform?.()
|
||||
restorePlatform = null
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function makeFakePty(): { proc: pty.IPty; fireExit: () => void } {
|
||||
let exitListener: (() => void) | null = null
|
||||
const proc = {
|
||||
pid: 4321,
|
||||
kill: vi.fn(),
|
||||
onExit: vi.fn((listener: () => void) => {
|
||||
exitListener = listener
|
||||
return { dispose: () => undefined }
|
||||
})
|
||||
} as unknown as pty.IPty
|
||||
return { proc, fireExit: () => exitListener?.() }
|
||||
}
|
||||
|
||||
describe('warmWindowsConptyOnce', () => {
|
||||
it('is a no-op off Windows', async () => {
|
||||
restorePlatform = setPlatform('darwin')
|
||||
const spawnPty = vi.fn() as unknown as typeof pty.spawn
|
||||
|
||||
warmWindowsConptyOnce(spawnPty)
|
||||
await flushImmediates()
|
||||
|
||||
expect(spawnPty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('spawns a short-lived cmd.exe with the bundled ConPTY on Windows', async () => {
|
||||
restorePlatform = setPlatform('win32')
|
||||
const { proc, fireExit } = makeFakePty()
|
||||
const spawnPty = vi.fn(() => proc) as unknown as typeof pty.spawn
|
||||
|
||||
warmWindowsConptyOnce(spawnPty)
|
||||
await flushImmediates()
|
||||
|
||||
expect(spawnPty).toHaveBeenCalledTimes(1)
|
||||
const [file, args, options] = vi.mocked(spawnPty).mock.calls[0]
|
||||
expect(String(file).toLowerCase()).toContain('cmd')
|
||||
expect(args).toEqual(['/c', 'exit'])
|
||||
expect(options).toMatchObject({ useConptyDll: true, cols: 2, rows: 1 })
|
||||
|
||||
// A clean exit must not leave the kill timer to fire later.
|
||||
fireExit()
|
||||
expect(proc.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('kills the warm-up shell if it never exits', async () => {
|
||||
restorePlatform = setPlatform('win32')
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { proc } = makeFakePty()
|
||||
const spawnPty = vi.fn(() => proc) as unknown as typeof pty.spawn
|
||||
|
||||
warmWindowsConptyOnce(spawnPty)
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
expect(proc.kill).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('swallows spawn failures', async () => {
|
||||
restorePlatform = setPlatform('win32')
|
||||
const spawnPty = vi.fn(() => {
|
||||
throw new Error('conpty unavailable')
|
||||
}) as unknown as typeof pty.spawn
|
||||
|
||||
expect(() => warmWindowsConptyOnce(spawnPty)).not.toThrow()
|
||||
await flushImmediates()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import os from 'node:os'
|
||||
import * as pty from 'node-pty'
|
||||
|
||||
const WARMUP_KILL_TIMEOUT_MS = 10_000
|
||||
|
||||
/**
|
||||
* Pays the one-time cost of the first ConPTY spawn (conpty native module
|
||||
* load, bundled conpty.dll + OpenConsole.exe first launch, Defender scans of
|
||||
* those binaries) at daemon boot instead of on the user's first terminal.
|
||||
* Measured ~2.7s on a Windows dev profile for the first spawn vs ~70ms after.
|
||||
*/
|
||||
export function warmWindowsConptyOnce(spawnPty: typeof pty.spawn = pty.spawn): void {
|
||||
if (process.platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
// Why: setImmediate keeps the ready/handshake path ahead of the warm-up; a
|
||||
// real spawn arriving first simply does the warming itself.
|
||||
setImmediate(() => {
|
||||
try {
|
||||
const proc = spawnPty(process.env.COMSPEC || 'cmd.exe', ['/c', 'exit'], {
|
||||
name: 'xterm-256color',
|
||||
cols: 2,
|
||||
rows: 1,
|
||||
cwd: os.homedir(),
|
||||
env: process.env as Record<string, string>,
|
||||
// Match real terminal spawns so the bundled ConPTY binaries are the
|
||||
// ones warmed, not the legacy system ConPTY.
|
||||
useConptyDll: true
|
||||
})
|
||||
const killTimer = setTimeout(() => {
|
||||
try {
|
||||
proc.kill()
|
||||
} catch {
|
||||
/* best-effort cleanup of a stuck warm-up shell */
|
||||
}
|
||||
}, WARMUP_KILL_TIMEOUT_MS)
|
||||
killTimer.unref?.()
|
||||
proc.onExit(() => {
|
||||
clearTimeout(killTimer)
|
||||
})
|
||||
} catch {
|
||||
/* warm-up is best-effort; real spawns surface their own errors */
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Why: pty:spawn latency has four very different suspects (startup barrier,
|
||||
// Claude auth prep, buildPtyHostEnv filesystem work, provider/daemon spawn).
|
||||
// A single opt-in log line per spawn lets benchmarks attribute the cost
|
||||
// without a tracing dependency. Enabled via ORCA_PTY_SPAWN_TIMING=1.
|
||||
|
||||
export type PtySpawnTiming = {
|
||||
mark(phase: string): void
|
||||
log(id: string, extra?: Record<string, string | number | boolean>): void
|
||||
}
|
||||
|
||||
const noopTiming: PtySpawnTiming = {
|
||||
mark: () => undefined,
|
||||
log: () => undefined
|
||||
}
|
||||
|
||||
export function createPtySpawnTiming(): PtySpawnTiming {
|
||||
const flag = process.env.ORCA_PTY_SPAWN_TIMING
|
||||
if (!flag || flag === '0' || flag.toLowerCase() === 'false') {
|
||||
return noopTiming
|
||||
}
|
||||
const startedAt = Date.now()
|
||||
let lastAt = startedAt
|
||||
const phases: string[] = []
|
||||
return {
|
||||
mark(phase: string): void {
|
||||
const now = Date.now()
|
||||
phases.push(`${phase}=${now - lastAt}ms`)
|
||||
lastAt = now
|
||||
},
|
||||
log(id: string, extra?: Record<string, string | number | boolean>): void {
|
||||
const extras = extra
|
||||
? ` ${Object.entries(extra)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(' ')}`
|
||||
: ''
|
||||
console.log(
|
||||
`[pty-spawn-timing] id=${id} total=${Date.now() - startedAt}ms ${phases.join(' ')}${extras}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+210
-4
@@ -2331,7 +2331,9 @@ describe('registerPtyHandlers', () => {
|
||||
}
|
||||
|
||||
expect(controller.kill('remote-pty')).toBe(true)
|
||||
await Promise.resolve()
|
||||
// Why: kill's shutdown now runs through the exit-detection wrapper,
|
||||
// which adds async hops; a single microtask flush is no longer enough.
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(shutdown).toHaveBeenCalledWith('remote-pty', { immediate: false })
|
||||
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith(
|
||||
@@ -2342,6 +2344,113 @@ describe('registerPtyHandlers', () => {
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1)
|
||||
})
|
||||
|
||||
it('controller kill does not duplicate exits when the provider emits exit during shutdown', async () => {
|
||||
const exitListeners = new Set<(payload: { id: string; code: number }) => void>()
|
||||
const shutdown = vi.fn(async (id: string) => {
|
||||
for (const listener of exitListeners) {
|
||||
listener({ id, code: 0 })
|
||||
}
|
||||
})
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
onPtyExit: vi.fn()
|
||||
}
|
||||
setLocalPtyProvider({
|
||||
spawn: vi.fn(),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
shutdown,
|
||||
sendSignal: vi.fn(),
|
||||
getCwd: vi.fn(),
|
||||
getInitialCwd: vi.fn(),
|
||||
clearBuffer: vi.fn(),
|
||||
acknowledgeDataEvent: vi.fn(),
|
||||
hasChildProcesses: vi.fn(),
|
||||
getForegroundProcess: vi.fn(),
|
||||
serialize: vi.fn(),
|
||||
revive: vi.fn(),
|
||||
onData: vi.fn(() => () => {}),
|
||||
onReplay: vi.fn(() => () => {}),
|
||||
onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => {
|
||||
exitListeners.add(listener)
|
||||
return () => exitListeners.delete(listener)
|
||||
}),
|
||||
listProcesses: vi.fn(async () => []),
|
||||
attach: vi.fn(),
|
||||
getDefaultShell: vi.fn(),
|
||||
getProfiles: vi.fn()
|
||||
} as never)
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
|
||||
kill: (ptyId: string) => boolean
|
||||
}
|
||||
|
||||
expect(controller.kill('local-pty')).toBe(true)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0)
|
||||
expect(
|
||||
mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')
|
||||
).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]])
|
||||
})
|
||||
|
||||
it('controller stopAndWait skips the synthetic exit when the provider emitted one', async () => {
|
||||
vi.useFakeTimers()
|
||||
const exitListeners = new Set<(payload: { id: string; code: number }) => void>()
|
||||
const shutdown = vi.fn(async (id: string) => {
|
||||
for (const listener of exitListeners) {
|
||||
listener({ id, code: 0 })
|
||||
}
|
||||
})
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
onPtyExit: vi.fn()
|
||||
}
|
||||
setLocalPtyProvider({
|
||||
spawn: vi.fn(),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
shutdown,
|
||||
sendSignal: vi.fn(),
|
||||
getCwd: vi.fn(),
|
||||
getInitialCwd: vi.fn(),
|
||||
clearBuffer: vi.fn(),
|
||||
acknowledgeDataEvent: vi.fn(),
|
||||
hasChildProcesses: vi.fn(),
|
||||
getForegroundProcess: vi.fn(),
|
||||
serialize: vi.fn(),
|
||||
revive: vi.fn(),
|
||||
onData: vi.fn(() => () => {}),
|
||||
onReplay: vi.fn(() => () => {}),
|
||||
onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => {
|
||||
exitListeners.add(listener)
|
||||
return () => exitListeners.delete(listener)
|
||||
}),
|
||||
listProcesses: vi.fn(async () => []),
|
||||
attach: vi.fn(),
|
||||
getDefaultShell: vi.fn(),
|
||||
getProfiles: vi.fn()
|
||||
} as never)
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
|
||||
stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise<boolean>
|
||||
}
|
||||
|
||||
const stopPromise = controller.stopAndWait('local-pty')
|
||||
await vi.advanceTimersByTimeAsync(1_200)
|
||||
await expect(stopPromise).resolves.toBe(true)
|
||||
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0)
|
||||
expect(
|
||||
mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')
|
||||
).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]])
|
||||
})
|
||||
|
||||
it('passes keepHistory through runtime controller stopAndWait', async () => {
|
||||
vi.useFakeTimers()
|
||||
const shutdown = vi.fn(async () => undefined)
|
||||
@@ -2566,7 +2675,7 @@ describe('registerPtyHandlers', () => {
|
||||
}
|
||||
|
||||
expect(controller.kill('ssh:ssh-1@@relay-pty')).toBe(true)
|
||||
await Promise.resolve()
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(shutdown).toHaveBeenCalledWith('ssh:ssh-1@@relay-pty', { immediate: false })
|
||||
expect(localShutdown).not.toHaveBeenCalled()
|
||||
@@ -2701,8 +2810,7 @@ describe('registerPtyHandlers', () => {
|
||||
|
||||
try {
|
||||
expect(controller.kill('remote-pty')).toBe(true)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
} finally {
|
||||
warnSpy.mockRestore()
|
||||
deletePtyOwnership('remote-pty')
|
||||
@@ -2911,6 +3019,104 @@ describe('registerPtyHandlers', () => {
|
||||
keepHistory: true
|
||||
})
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', -1)
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:exit', {
|
||||
id: 'local-pty',
|
||||
code: -1
|
||||
})
|
||||
})
|
||||
|
||||
it('does not synthesize a duplicate renderer exit when kill emits provider exit', async () => {
|
||||
const exitListeners = new Set<(payload: { id: string; code: number }) => void>()
|
||||
const shutdown = vi.fn(async (id: string) => {
|
||||
for (const listener of exitListeners) {
|
||||
listener({ id, code: 0 })
|
||||
}
|
||||
})
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
onPtyExit: vi.fn()
|
||||
}
|
||||
setLocalPtyProvider({
|
||||
spawn: vi.fn(),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
shutdown,
|
||||
sendSignal: vi.fn(),
|
||||
getCwd: vi.fn(),
|
||||
getInitialCwd: vi.fn(),
|
||||
clearBuffer: vi.fn(),
|
||||
acknowledgeDataEvent: vi.fn(),
|
||||
hasChildProcesses: vi.fn(),
|
||||
getForegroundProcess: vi.fn(),
|
||||
serialize: vi.fn(),
|
||||
revive: vi.fn(),
|
||||
onData: vi.fn(() => () => {}),
|
||||
onReplay: vi.fn(() => () => {}),
|
||||
onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => {
|
||||
exitListeners.add(listener)
|
||||
return () => exitListeners.delete(listener)
|
||||
}),
|
||||
listProcesses: vi.fn(async () => []),
|
||||
attach: vi.fn(),
|
||||
getDefaultShell: vi.fn(),
|
||||
getProfiles: vi.fn()
|
||||
} as never)
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
|
||||
await handlers.get('pty:kill')!(null, { id: 'local-pty' })
|
||||
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0)
|
||||
expect(mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')).toEqual(
|
||||
[['pty:exit', { id: 'local-pty', code: 0 }]]
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores a late provider exit after synthesizing kill exit', async () => {
|
||||
const exitListeners = new Set<(payload: { id: string; code: number }) => void>()
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
onPtyExit: vi.fn()
|
||||
}
|
||||
setLocalPtyProvider({
|
||||
spawn: vi.fn(),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
shutdown: vi.fn(async () => undefined),
|
||||
sendSignal: vi.fn(),
|
||||
getCwd: vi.fn(),
|
||||
getInitialCwd: vi.fn(),
|
||||
clearBuffer: vi.fn(),
|
||||
acknowledgeDataEvent: vi.fn(),
|
||||
hasChildProcesses: vi.fn(),
|
||||
getForegroundProcess: vi.fn(),
|
||||
serialize: vi.fn(),
|
||||
revive: vi.fn(),
|
||||
onData: vi.fn(() => () => {}),
|
||||
onReplay: vi.fn(() => () => {}),
|
||||
onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => {
|
||||
exitListeners.add(listener)
|
||||
return () => exitListeners.delete(listener)
|
||||
}),
|
||||
listProcesses: vi.fn(async () => []),
|
||||
attach: vi.fn(),
|
||||
getDefaultShell: vi.fn(),
|
||||
getProfiles: vi.fn()
|
||||
} as never)
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
|
||||
await handlers.get('pty:kill')!(null, { id: 'local-pty' })
|
||||
for (const listener of exitListeners) {
|
||||
listener({ id: 'local-pty', code: 0 })
|
||||
}
|
||||
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', -1)
|
||||
expect(mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')).toEqual(
|
||||
[['pty:exit', { id: 'local-pty', code: -1 }]]
|
||||
)
|
||||
})
|
||||
|
||||
it('waits for the desktop startup barrier before renderer local spawns resolve the provider', async () => {
|
||||
|
||||
+128
-38
@@ -39,6 +39,7 @@ import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers
|
||||
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
|
||||
import { SSH_SESSION_EXPIRED_ERROR, isSshPtyNotFoundError } from '../providers/ssh-pty-provider'
|
||||
import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
|
||||
import { createPtySpawnTiming } from './pty-spawn-timing'
|
||||
import { mintPtySessionId, isSafePtySessionId } from '../daemon/pty-session-id'
|
||||
import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints'
|
||||
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
|
||||
@@ -111,6 +112,7 @@ type FreshLocalFallbackProvider = IPtyProvider & {
|
||||
routesFreshSpawnsToLocalProvider?: true
|
||||
}
|
||||
const sshProviders = new Map<string, IPtyProvider>()
|
||||
const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000
|
||||
// Why: PTY IDs are assigned at spawn time with a connectionId, but subsequent
|
||||
// write/resize/kill calls only carry the PTY ID. This map lets us route
|
||||
// post-spawn operations to the correct provider without the renderer needing
|
||||
@@ -1656,6 +1658,82 @@ export function registerPtyHandlers(
|
||||
flushTimer = null
|
||||
}
|
||||
|
||||
const syntheticKillExitPtyIds = new Map<string, NodeJS.Timeout>()
|
||||
|
||||
function rememberSyntheticKillExit(id: string): void {
|
||||
const existing = syntheticKillExitPtyIds.get(id)
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
}
|
||||
// Why: some providers can report the real exit after kill has already
|
||||
// completed; skip only that late duplicate, not a future reused id forever.
|
||||
const cleanupTimer = setTimeout(() => {
|
||||
syntheticKillExitPtyIds.delete(id)
|
||||
}, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS)
|
||||
cleanupTimer.unref?.()
|
||||
syntheticKillExitPtyIds.set(id, cleanupTimer)
|
||||
}
|
||||
|
||||
function consumeSyntheticKillExit(id: string): boolean {
|
||||
const cleanupTimer = syntheticKillExitPtyIds.get(id)
|
||||
if (!cleanupTimer) {
|
||||
return false
|
||||
}
|
||||
clearTimeout(cleanupTimer)
|
||||
syntheticKillExitPtyIds.delete(id)
|
||||
return true
|
||||
}
|
||||
|
||||
function sendPtyExitToRenderer(payload: { id: string; code: number }): void {
|
||||
if (mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
// Why: flush any batched data for this PTY before sending the exit event,
|
||||
// otherwise the last <=8ms of output is silently lost because the renderer
|
||||
// tears down the terminal on pty:exit before the batch timer fires.
|
||||
const remaining = pendingData.get(payload.id)
|
||||
if (remaining) {
|
||||
sendPtyDataToRenderer(
|
||||
payload.id,
|
||||
makePtyDataPayload(
|
||||
payload.id,
|
||||
remaining.data,
|
||||
remaining.startSeq,
|
||||
remaining.containsBackgroundOutput
|
||||
)
|
||||
)
|
||||
pendingData.delete(payload.id)
|
||||
}
|
||||
lastInputAtByPty.delete(payload.id)
|
||||
interactiveOutputCharsByPty.delete(payload.id)
|
||||
rendererInFlightTotalChars = Math.max(
|
||||
0,
|
||||
rendererInFlightTotalChars - (rendererInFlightCharsByPty.get(payload.id) ?? 0)
|
||||
)
|
||||
rendererInFlightCharsByPty.delete(payload.id)
|
||||
recordPtyRendererDeliveryPressure()
|
||||
mainWindow.webContents.send('pty:exit', payload)
|
||||
}
|
||||
|
||||
async function shutdownProviderAndDetectExit(
|
||||
provider: IPtyProvider,
|
||||
id: string,
|
||||
opts: { immediate?: boolean; keepHistory?: boolean }
|
||||
): Promise<boolean> {
|
||||
let providerExitObserved = false
|
||||
const unsubscribe = provider.onExit((payload) => {
|
||||
if (payload.id === id) {
|
||||
providerExitObserved = true
|
||||
}
|
||||
})
|
||||
try {
|
||||
await provider.shutdown(id, opts)
|
||||
} finally {
|
||||
unsubscribe()
|
||||
}
|
||||
return providerExitObserved
|
||||
}
|
||||
|
||||
// Why: extracted so the "Restart daemon" flow can rebind against the fresh
|
||||
// adapter after replaceDaemonProvider runs. Both the startup registration
|
||||
// and the post-restart rebind go through the same code path — no risk of
|
||||
@@ -1745,39 +1823,16 @@ export function registerPtyHandlers(
|
||||
}
|
||||
})
|
||||
localExitUnsub = localProvider.onExit((payload) => {
|
||||
if (consumeSyntheticKillExit(payload.id)) {
|
||||
return
|
||||
}
|
||||
if (!isLocalProvider) {
|
||||
clearProviderPtyState(payload.id)
|
||||
ptyOwnership.delete(payload.id)
|
||||
markClaudePtyExited(payload.id)
|
||||
runtime?.onPtyExit(payload.id, payload.code)
|
||||
}
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
// Why: flush any batched data for this PTY before sending the exit event,
|
||||
// otherwise the last ≤8ms of output is silently lost because the renderer
|
||||
// tears down the terminal on pty:exit before the batch timer fires.
|
||||
const remaining = pendingData.get(payload.id)
|
||||
if (remaining) {
|
||||
sendPtyDataToRenderer(
|
||||
payload.id,
|
||||
makePtyDataPayload(
|
||||
payload.id,
|
||||
remaining.data,
|
||||
remaining.startSeq,
|
||||
remaining.containsBackgroundOutput
|
||||
)
|
||||
)
|
||||
pendingData.delete(payload.id)
|
||||
}
|
||||
lastInputAtByPty.delete(payload.id)
|
||||
interactiveOutputCharsByPty.delete(payload.id)
|
||||
rendererInFlightTotalChars = Math.max(
|
||||
0,
|
||||
rendererInFlightTotalChars - (rendererInFlightCharsByPty.get(payload.id) ?? 0)
|
||||
)
|
||||
rendererInFlightCharsByPty.delete(payload.id)
|
||||
recordPtyRendererDeliveryPressure()
|
||||
mainWindow.webContents.send('pty:exit', payload)
|
||||
}
|
||||
sendPtyExitToRenderer(payload)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2240,6 +2295,8 @@ export function registerPtyHandlers(
|
||||
// not revive a terminal the user explicitly closed.
|
||||
finishPtyShutdown(ptyId, connectionId, store)
|
||||
runtime?.onPtyExit(ptyId, -1)
|
||||
rememberSyntheticKillExit(ptyId)
|
||||
sendPtyExitToRenderer({ id: ptyId, code: -1 })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -2247,16 +2304,25 @@ export function registerPtyHandlers(
|
||||
// Why: shutdown() is async but the PtyController interface is sync. Defer
|
||||
// cleanup until shutdown resolves so transient SSH/daemon failures don't
|
||||
// hide a still-running remote process or local daemon session.
|
||||
void provider
|
||||
.shutdown(ptyId, { immediate: false })
|
||||
.then(() => {
|
||||
//
|
||||
// Same synthetic-exit contract as the renderer pty:kill handler: when the
|
||||
// provider emitted its own exit during shutdown, the exit listener already
|
||||
// delivered runtime + renderer exits — synthesizing again would double-fire.
|
||||
void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false })
|
||||
.then((providerExitObserved) => {
|
||||
finishPtyShutdown(ptyId, connectionId, store)
|
||||
runtime?.onPtyExit(ptyId, -1)
|
||||
if (!providerExitObserved) {
|
||||
runtime?.onPtyExit(ptyId, -1)
|
||||
rememberSyntheticKillExit(ptyId)
|
||||
sendPtyExitToRenderer({ id: ptyId, code: -1 })
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (isPtyAlreadyGoneError(err)) {
|
||||
finishPtyShutdown(ptyId, connectionId, store)
|
||||
runtime?.onPtyExit(ptyId, -1)
|
||||
rememberSyntheticKillExit(ptyId)
|
||||
sendPtyExitToRenderer({ id: ptyId, code: -1 })
|
||||
return
|
||||
}
|
||||
console.warn(
|
||||
@@ -2283,12 +2349,15 @@ export function registerPtyHandlers(
|
||||
// await, but the relay lease must still be tombstoned.
|
||||
finishPtyShutdown(ptyId, connectionId, store)
|
||||
runtime?.onPtyExit(ptyId, -1)
|
||||
rememberSyntheticKillExit(ptyId)
|
||||
sendPtyExitToRenderer({ id: ptyId, code: -1 })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
let providerExitObserved = false
|
||||
try {
|
||||
await provider.shutdown(ptyId, {
|
||||
providerExitObserved = await shutdownProviderAndDetectExit(provider, ptyId, {
|
||||
immediate: true,
|
||||
keepHistory: opts?.keepHistory ?? false
|
||||
})
|
||||
@@ -2313,7 +2382,11 @@ export function registerPtyHandlers(
|
||||
return false
|
||||
}
|
||||
finishPtyShutdown(ptyId, connectionId, store)
|
||||
runtime?.onPtyExit(ptyId, -1)
|
||||
if (!providerExitObserved) {
|
||||
runtime?.onPtyExit(ptyId, -1)
|
||||
rememberSyntheticKillExit(ptyId)
|
||||
sendPtyExitToRenderer({ id: ptyId, code: -1 })
|
||||
}
|
||||
return true
|
||||
},
|
||||
getForegroundProcess: async (ptyId) => {
|
||||
@@ -2461,11 +2534,13 @@ export function registerPtyHandlers(
|
||||
}
|
||||
}
|
||||
) => {
|
||||
const spawnTiming = createPtySpawnTiming()
|
||||
const startupPromise = getLocalPtyStartupPromise(args.connectionId)
|
||||
if (startupPromise) {
|
||||
await startupPromise
|
||||
}
|
||||
await assertFolderWorkspacePtyPathUsable(args.worktreeId)
|
||||
spawnTiming.mark('preflight')
|
||||
const provider = getProvider(args.connectionId)
|
||||
const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command)
|
||||
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
||||
@@ -2488,6 +2563,7 @@ export function registerPtyHandlers(
|
||||
)
|
||||
const claudeAuth =
|
||||
isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(initialSelectionTarget) : null
|
||||
spawnTiming.mark('auth')
|
||||
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
||||
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
|
||||
}
|
||||
@@ -2713,6 +2789,7 @@ export function registerPtyHandlers(
|
||||
throw err
|
||||
}
|
||||
}
|
||||
spawnTiming.mark('host_env')
|
||||
const spawnEnv = preAllocatedHandle
|
||||
? { ...env, ORCA_TERMINAL_HANDLE: preAllocatedHandle }
|
||||
: env
|
||||
@@ -2807,7 +2884,9 @@ export function registerPtyHandlers(
|
||||
startupTerminalColorQueryReplyColors
|
||||
)
|
||||
}
|
||||
spawnTiming.mark('options')
|
||||
result = await provider.spawn(spawnOptions)
|
||||
spawnTiming.mark('provider_spawn')
|
||||
} catch (err) {
|
||||
const rawMessage = err instanceof Error ? err.message : String(err)
|
||||
const spawnError = normalizeNodePtySpawnError(err)
|
||||
@@ -2868,6 +2947,10 @@ export function registerPtyHandlers(
|
||||
trustedTerminalHandleEnv.delete(preAllocatedHandle)
|
||||
}
|
||||
}
|
||||
spawnTiming.log(result.id, {
|
||||
daemon: isDaemonHostSpawn,
|
||||
reattach: result.isReattach ?? false
|
||||
})
|
||||
ptyOwnership.set(result.id, args.connectionId ?? null)
|
||||
if (startupTerminalColorQueryReplyColors) {
|
||||
if (result.isReattach) {
|
||||
@@ -3396,10 +3479,14 @@ export function registerPtyHandlers(
|
||||
// before ownership is rebuilt. Tombstone instead of falling back local.
|
||||
finishPtyShutdown(args.id, connectionId, store)
|
||||
runtime?.onPtyExit(args.id, -1)
|
||||
rememberSyntheticKillExit(args.id)
|
||||
sendPtyExitToRenderer({ id: args.id, code: -1 })
|
||||
return
|
||||
}
|
||||
const shutdownProvider = provider ?? getProviderForPty(args.id)
|
||||
let providerExitObserved = false
|
||||
try {
|
||||
await (provider ?? getProviderForPty(args.id)).shutdown(args.id, {
|
||||
providerExitObserved = await shutdownProviderAndDetectExit(shutdownProvider, args.id, {
|
||||
immediate: true,
|
||||
keepHistory: args.keepHistory ?? false
|
||||
})
|
||||
@@ -3412,11 +3499,14 @@ export function registerPtyHandlers(
|
||||
}
|
||||
/* session already dead — cleanup below handles the rest */
|
||||
}
|
||||
// Why: onExit clears provider state for LocalPtyProvider, but remote SSH
|
||||
// and daemon shutdown paths do not emit onExit through the local provider's
|
||||
// listener. Explicit cleanup is idempotent and covers already-dead PTYs.
|
||||
// Why: some shutdown paths do not emit onExit through the provider listener.
|
||||
// Explicit cleanup is idempotent and covers already-dead PTYs.
|
||||
finishPtyShutdown(args.id, connectionId, store)
|
||||
runtime?.onPtyExit(args.id, -1)
|
||||
if (!providerExitObserved) {
|
||||
runtime?.onPtyExit(args.id, -1)
|
||||
rememberSyntheticKillExit(args.id)
|
||||
sendPtyExitToRenderer({ id: args.id, code: -1 })
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
|
||||
@@ -76,13 +76,13 @@ describe('spawnShellWithFallback on Windows', () => {
|
||||
1,
|
||||
PWSH7,
|
||||
attempts[0].shellArgs,
|
||||
expect.objectContaining({ cwd: 'C:\\repo' })
|
||||
expect.objectContaining({ cwd: 'C:\\repo', useConptyDll: true })
|
||||
)
|
||||
expect(ptySpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
WINDOWS_POWERSHELL,
|
||||
attempts[1].shellArgs,
|
||||
expect.objectContaining({ cwd: 'C:\\repo' })
|
||||
expect.objectContaining({ cwd: 'C:\\repo', useConptyDll: true })
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -160,6 +160,14 @@ export type ShellSpawnResult = {
|
||||
* executables with per-shell args, so when the primary fails we retry with the
|
||||
* next safe shell instead of leaving the user with no terminal.
|
||||
*/
|
||||
// Why: match the daemon spawn path (pty-subprocess.ts) — the bundled ConPTY
|
||||
// has the modern wrap-marker behavior xterm expects; legacy system ConPTY can
|
||||
// corrupt full-width TUI rows in scrollback. Without this, degraded-mode and
|
||||
// fresh-local spawns silently behave differently from daemon terminals.
|
||||
function windowsConptyDllOptions(): { useConptyDll: true } | Record<string, never> {
|
||||
return process.platform === 'win32' ? { useConptyDll: true } : {}
|
||||
}
|
||||
|
||||
function spawnWindowsFallbackChain(
|
||||
params: ShellSpawnParams,
|
||||
primaryError: string
|
||||
@@ -174,7 +182,8 @@ function spawnWindowsFallbackChain(
|
||||
cols,
|
||||
rows,
|
||||
cwd: attempt.effectiveCwd,
|
||||
env
|
||||
env,
|
||||
...windowsConptyDllOptions()
|
||||
})
|
||||
console.warn(
|
||||
`[pty] Primary shell "${params.shellPath}" failed (${primaryError}), fell back to "${attempt.shellPath}"`
|
||||
@@ -217,7 +226,14 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu
|
||||
if (!primaryError) {
|
||||
try {
|
||||
return {
|
||||
process: ptySpawn(shellPath, shellArgs, { name: termName, cols, rows, cwd, env }),
|
||||
process: ptySpawn(shellPath, shellArgs, {
|
||||
name: termName,
|
||||
cols,
|
||||
rows,
|
||||
cwd,
|
||||
env,
|
||||
...windowsConptyDllOptions()
|
||||
}),
|
||||
shellPath
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -237,11 +237,7 @@ export function disposePane(
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
pane.webglAddon?.dispose()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
disposeWebgl(pane)
|
||||
try {
|
||||
pane.searchAddon.dispose()
|
||||
} catch {
|
||||
|
||||
@@ -168,6 +168,11 @@ export class PaneManager {
|
||||
|
||||
refreshAllPanes(): void {
|
||||
for (const pane of this.panes.values()) {
|
||||
// Why: suspended panes are invisible and repaint on rendering resume;
|
||||
// recovery repaints must not scale with hidden-workspace pane count.
|
||||
if (pane.webglAttachmentDeferred) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
if (pane.terminal.rows > 0) {
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
markComplexScriptOutput,
|
||||
resetWebglTextureAtlas
|
||||
} from './pane-webgl-renderer'
|
||||
import {
|
||||
retainSuspendedWebglPane,
|
||||
shouldRetainSuspendedWebglContexts,
|
||||
unretainWebglPane
|
||||
} from './pane-webgl-context-retention'
|
||||
import { reattachWebglIfNeeded } from './pane-webgl-reattach'
|
||||
|
||||
export function setPaneGpuRenderingState(
|
||||
@@ -43,9 +48,20 @@ export function markPaneComplexScriptOutput(
|
||||
}
|
||||
|
||||
export function suspendPaneRendering(panes: Iterable<ManagedPaneInternal>): void {
|
||||
const retainContexts = shouldRetainSuspendedWebglContexts()
|
||||
for (const pane of panes) {
|
||||
// Why: deferred blocks NEW context creation while hidden; on Windows,
|
||||
// live contexts are retained (not disposed) so the return switch repaints
|
||||
// instantly instead of paying ANGLE context re-creation. The LRU cap
|
||||
// bounds how many hidden panes may keep one.
|
||||
pane.webglAttachmentDeferred = true
|
||||
disposeWebgl(pane)
|
||||
if (!retainContexts) {
|
||||
disposeWebgl(pane)
|
||||
continue
|
||||
}
|
||||
for (const evicted of retainSuspendedWebglPane(pane)) {
|
||||
disposeWebgl(evicted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,8 +71,23 @@ export function resumePaneRendering(panes: Iterable<ManagedPaneInternal>): void
|
||||
// loss, and bounding retries to resume events cannot loop on live loss.
|
||||
clearTerminalWebglAttachBackoff()
|
||||
for (const pane of panes) {
|
||||
unretainWebglPane(pane)
|
||||
pane.webglAttachmentDeferred = false
|
||||
pane.webglDisabledAfterContextLoss = false
|
||||
if (pane.webglAddon) {
|
||||
// Why: recovery bursts skip suspended panes, so the shared glyph atlas
|
||||
// may have been cleared/rebuilt while this pane sat hidden with its
|
||||
// retained context. Repaint from the buffer so stale glyph coordinates
|
||||
// never reach the screen.
|
||||
try {
|
||||
if (pane.terminal.rows > 0) {
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
}
|
||||
} catch {
|
||||
/* ignore — pane may be tearing down during resume */
|
||||
}
|
||||
continue
|
||||
}
|
||||
reattachWebglIfNeeded(pane)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ManagedPaneInternal } from './pane-manager-types'
|
||||
import {
|
||||
clearRetainedWebglPanes,
|
||||
RETAINED_WEBGL_PANE_CAP,
|
||||
retainedWebglPaneCount
|
||||
} from './pane-webgl-context-retention'
|
||||
import { resumePaneRendering, suspendPaneRendering } from './pane-rendering-control'
|
||||
import {
|
||||
attachWebgl,
|
||||
disposeWebgl,
|
||||
resetTerminalWebglSuggestion,
|
||||
resetWebglTextureAtlas
|
||||
} from './pane-webgl-renderer'
|
||||
|
||||
let nextPaneId = 1
|
||||
|
||||
function createPane(): ManagedPaneInternal {
|
||||
const leafId = '11111111-1111-4111-8111-111111111111' as never
|
||||
return {
|
||||
id: nextPaneId++,
|
||||
leafId,
|
||||
stablePaneId: leafId,
|
||||
terminal: {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
refresh: vi.fn(),
|
||||
loadAddon: vi.fn()
|
||||
} as never,
|
||||
container: {} as never,
|
||||
xtermContainer: {} as never,
|
||||
linkTooltip: {} as never,
|
||||
terminalGpuAcceleration: 'on',
|
||||
gpuRenderingEnabled: true,
|
||||
webglAttachmentDeferred: false,
|
||||
webglDisabledAfterContextLoss: false,
|
||||
hasComplexScriptOutput: false,
|
||||
webglAddon: null,
|
||||
ligaturesAddon: null,
|
||||
fitResizeObserver: null,
|
||||
pendingObservedFitRafId: null,
|
||||
pendingWebglRefreshRafId: null,
|
||||
fitAddon: {
|
||||
proposeDimensions: vi.fn(() => ({ cols: 80, rows: 23 })),
|
||||
fit: vi.fn()
|
||||
} as never,
|
||||
searchAddon: {} as never,
|
||||
serializeAddon: {} as never,
|
||||
unicode11Addon: {} as never,
|
||||
webLinksAddon: {} as never,
|
||||
compositionHandler: null,
|
||||
pendingSplitScrollState: null,
|
||||
debugLabel: null
|
||||
}
|
||||
}
|
||||
|
||||
function createAttachedPane(): ManagedPaneInternal {
|
||||
const pane = createPane()
|
||||
attachWebgl(pane)
|
||||
expect(pane.webglAddon).not.toBeNull()
|
||||
return pane
|
||||
}
|
||||
|
||||
function fireContextLoss(pane: ManagedPaneInternal): void {
|
||||
const addon = pane.webglAddon as unknown as { _onContextLoss: { fire: () => void } }
|
||||
addon._onContextLoss.fire()
|
||||
}
|
||||
|
||||
function stubCommonGlobals(userAgent: string): void {
|
||||
vi.stubGlobal('navigator', { userAgent })
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callback(16)
|
||||
return 1
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
}
|
||||
|
||||
describe('terminal WebGL context retention across hide/show (Windows)', () => {
|
||||
beforeEach(() => {
|
||||
resetTerminalWebglSuggestion()
|
||||
clearRetainedWebglPanes()
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
stubCommonGlobals('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearRetainedWebglPanes()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('keeps the live WebGL addon when rendering is suspended', () => {
|
||||
const pane = createAttachedPane()
|
||||
const addon = pane.webglAddon
|
||||
|
||||
suspendPaneRendering([pane])
|
||||
|
||||
expect(pane.webglAttachmentDeferred).toBe(true)
|
||||
expect(pane.webglAddon).toBe(addon)
|
||||
expect(retainedWebglPaneCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('does not create a new context when resuming a retained pane', () => {
|
||||
const pane = createAttachedPane()
|
||||
const addon = pane.webglAddon
|
||||
expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1)
|
||||
|
||||
suspendPaneRendering([pane])
|
||||
resumePaneRendering([pane])
|
||||
|
||||
expect(pane.webglAddon).toBe(addon)
|
||||
expect(pane.webglAttachmentDeferred).toBe(false)
|
||||
expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1)
|
||||
expect(retainedWebglPaneCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('still blocks new context creation while suspended', () => {
|
||||
const pane = createPane()
|
||||
|
||||
suspendPaneRendering([pane])
|
||||
attachWebgl(pane)
|
||||
|
||||
expect(pane.webglAddon).toBeNull()
|
||||
expect(pane.terminal.loadAddon).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('evicts and disposes the least-recently-suspended pane past the cap', () => {
|
||||
const panes = Array.from({ length: RETAINED_WEBGL_PANE_CAP + 2 }, () => createAttachedPane())
|
||||
|
||||
suspendPaneRendering(panes)
|
||||
|
||||
expect(retainedWebglPaneCount()).toBe(RETAINED_WEBGL_PANE_CAP)
|
||||
expect(panes[0].webglAddon).toBeNull()
|
||||
expect(panes[1].webglAddon).toBeNull()
|
||||
expect(panes[2].webglAddon).not.toBeNull()
|
||||
expect(panes.at(-1)?.webglAddon).not.toBeNull()
|
||||
})
|
||||
|
||||
it('re-attaches an evicted pane on resume', () => {
|
||||
const panes = Array.from({ length: RETAINED_WEBGL_PANE_CAP + 1 }, () => createAttachedPane())
|
||||
suspendPaneRendering(panes)
|
||||
expect(panes[0].webglAddon).toBeNull()
|
||||
|
||||
resumePaneRendering([panes[0]])
|
||||
|
||||
expect(panes[0].webglAddon).not.toBeNull()
|
||||
expect(panes[0].terminal.loadAddon).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('drops the retention entry when a suspended pane is disposed', () => {
|
||||
const pane = createAttachedPane()
|
||||
suspendPaneRendering([pane])
|
||||
expect(retainedWebglPaneCount()).toBe(1)
|
||||
|
||||
disposeWebgl(pane)
|
||||
|
||||
expect(retainedWebglPaneCount()).toBe(0)
|
||||
expect(pane.webglAddon).toBeNull()
|
||||
})
|
||||
|
||||
it('repaints a retained pane on resume so a cleared shared atlas cannot leave stale glyphs', () => {
|
||||
const pane = createAttachedPane()
|
||||
suspendPaneRendering([pane])
|
||||
vi.mocked(pane.terminal.refresh).mockClear()
|
||||
|
||||
resumePaneRendering([pane])
|
||||
|
||||
expect(pane.terminal.refresh).toHaveBeenCalledWith(0, pane.terminal.rows - 1)
|
||||
})
|
||||
|
||||
it('skips suspended panes during atlas recovery resets', () => {
|
||||
const pane = createAttachedPane()
|
||||
const clearTextureAtlas = vi.fn()
|
||||
;(pane.webglAddon as unknown as { clearTextureAtlas: () => void }).clearTextureAtlas =
|
||||
clearTextureAtlas
|
||||
suspendPaneRendering([pane])
|
||||
vi.mocked(pane.terminal.refresh).mockClear()
|
||||
|
||||
resetWebglTextureAtlas(pane)
|
||||
|
||||
expect(clearTextureAtlas).not.toHaveBeenCalled()
|
||||
expect(pane.terminal.refresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops the retention entry on context loss while hidden and recovers on resume', () => {
|
||||
const pane = createAttachedPane()
|
||||
suspendPaneRendering([pane])
|
||||
|
||||
fireContextLoss(pane)
|
||||
|
||||
expect(pane.webglAddon).toBeNull()
|
||||
expect(pane.webglDisabledAfterContextLoss).toBe(true)
|
||||
expect(retainedWebglPaneCount()).toBe(0)
|
||||
|
||||
resumePaneRendering([pane])
|
||||
|
||||
expect(pane.webglDisabledAfterContextLoss).toBe(false)
|
||||
expect(pane.webglAddon).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal WebGL context retention is Windows-only', () => {
|
||||
beforeEach(() => {
|
||||
resetTerminalWebglSuggestion()
|
||||
clearRetainedWebglPanes()
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
stubCommonGlobals('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearRetainedWebglPanes()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('keeps the dispose-on-hide behavior off Windows', () => {
|
||||
const pane = createAttachedPane()
|
||||
|
||||
suspendPaneRendering([pane])
|
||||
|
||||
expect(pane.webglAttachmentDeferred).toBe(true)
|
||||
expect(pane.webglAddon).toBeNull()
|
||||
expect(retainedWebglPaneCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('re-attaches on resume off Windows as before', () => {
|
||||
const pane = createAttachedPane()
|
||||
suspendPaneRendering([pane])
|
||||
|
||||
resumePaneRendering([pane])
|
||||
|
||||
expect(pane.webglAddon).not.toBeNull()
|
||||
expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { getRendererAppPlatform } from '@/lib/renderer-app-platform'
|
||||
import type { ManagedPaneInternal } from './pane-manager-types'
|
||||
|
||||
// Why: workspace switches used to dispose every hidden pane's WebGL context
|
||||
// and recreate it on return — ~5ms on macOS but 100-500ms per pane on Windows
|
||||
// (ANGLE → D3D11), paid synchronously on the switch path. With the app-level
|
||||
// context budget raised to 128 (#7064), hidden panes can keep their contexts;
|
||||
// this LRU cap only bounds hidden-pane GPU memory. Sized for the reported hot
|
||||
// set (~10-16 worktrees × ~2 terminals) while staying far under the budget.
|
||||
export const RETAINED_WEBGL_PANE_CAP = 32
|
||||
|
||||
/** Windows-only: context re-creation is cheap on macOS/Linux GL, so hidden
|
||||
* panes there keep the long-proven dispose-on-hide behavior instead of
|
||||
* paying retention's GPU-memory cost for no perceptible switch latency win. */
|
||||
export function shouldRetainSuspendedWebglContexts(): boolean {
|
||||
return getRendererAppPlatform() === 'win32'
|
||||
}
|
||||
|
||||
// Insertion order doubles as LRU order: re-retaining deletes + re-adds.
|
||||
const retainedPanes = new Set<ManagedPaneInternal>()
|
||||
|
||||
/**
|
||||
* Registers a suspended pane's live WebGL context for retention across
|
||||
* hide/show. Returns panes evicted by the cap — the caller disposes them
|
||||
* (dependency points that way to avoid a cycle with pane-webgl-renderer).
|
||||
*/
|
||||
export function retainSuspendedWebglPane(pane: ManagedPaneInternal): ManagedPaneInternal[] {
|
||||
if (!pane.webglAddon) {
|
||||
return []
|
||||
}
|
||||
retainedPanes.delete(pane)
|
||||
retainedPanes.add(pane)
|
||||
const evicted: ManagedPaneInternal[] = []
|
||||
while (retainedPanes.size > RETAINED_WEBGL_PANE_CAP) {
|
||||
const oldest = retainedPanes.values().next().value
|
||||
if (!oldest) {
|
||||
break
|
||||
}
|
||||
retainedPanes.delete(oldest)
|
||||
evicted.push(oldest)
|
||||
}
|
||||
return evicted
|
||||
}
|
||||
|
||||
export function unretainWebglPane(pane: ManagedPaneInternal): void {
|
||||
retainedPanes.delete(pane)
|
||||
}
|
||||
|
||||
export function retainedWebglPaneCount(): number {
|
||||
return retainedPanes.size
|
||||
}
|
||||
|
||||
/** Drops all retention entries without disposing anything (test isolation). */
|
||||
export function clearRetainedWebglPanes(): void {
|
||||
retainedPanes.clear()
|
||||
}
|
||||
@@ -65,6 +65,30 @@ describe('pane WebGL refresh lifecycle', () => {
|
||||
expect(pane.pendingWebglRefreshRafId).toBe(29)
|
||||
})
|
||||
|
||||
it('actively releases the xterm WebGL context before disposing the addon', () => {
|
||||
const loseContext = vi.fn()
|
||||
const canvas = { width: 120, height: 40 }
|
||||
const dispose = vi.fn()
|
||||
const pane = createPane({
|
||||
webglAddon: {
|
||||
dispose,
|
||||
_renderer: {
|
||||
_gl: {
|
||||
getExtension: vi.fn(() => ({ loseContext }))
|
||||
},
|
||||
_canvas: canvas
|
||||
}
|
||||
} as never
|
||||
})
|
||||
|
||||
disposeWebgl(pane)
|
||||
|
||||
expect(loseContext).toHaveBeenCalledTimes(1)
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
expect(canvas).toEqual({ width: 0, height: 0 })
|
||||
expect(pane.webglAddon).toBeNull()
|
||||
})
|
||||
|
||||
it('cancels a pending WebGL refresh when the pane is disposed', () => {
|
||||
const cancelAnimationFrame = vi.fn()
|
||||
vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { WebglAddon } from '@xterm/addon-webgl'
|
||||
import type { ManagedPaneInternal } from './pane-manager-types'
|
||||
import { unretainWebglPane } from './pane-webgl-context-retention'
|
||||
import {
|
||||
getTerminalWebglAutoDecision,
|
||||
resetTerminalWebglAutoDecision
|
||||
@@ -14,6 +15,17 @@ let suggestedRendererType: 'dom' | undefined
|
||||
// until the next recovery boundary (rendering resume or GPU-setting change).
|
||||
let webglAttachFailedSinceRecovery = false
|
||||
|
||||
type ReleasableWebglContext = {
|
||||
getExtension(name: 'WEBGL_lose_context'): WEBGL_lose_context | null
|
||||
}
|
||||
|
||||
type XtermWebglAddonInternals = {
|
||||
_renderer?: {
|
||||
_gl?: ReleasableWebglContext
|
||||
_canvas?: HTMLCanvasElement
|
||||
}
|
||||
}
|
||||
|
||||
export function resetTerminalWebglSuggestion(): void {
|
||||
// Why: toggling GPU settings should let "auto" retry WebGL after an earlier
|
||||
// attach failure suggested DOM rendering for this app session.
|
||||
@@ -61,9 +73,14 @@ export function disposeWebgl(
|
||||
options?: { refreshDimensions?: boolean }
|
||||
): void {
|
||||
cancelPendingWebglRefresh(pane)
|
||||
// Why: every dispose path (pane close, context loss, GPU-setting off) must
|
||||
// drop the retention entry, or the LRU would later evict-dispose a pane
|
||||
// whose addon was already gone.
|
||||
unretainWebglPane(pane)
|
||||
if (!pane.webglAddon) {
|
||||
return
|
||||
}
|
||||
releaseXtermWebglContext(pane.webglAddon)
|
||||
try {
|
||||
pane.webglAddon.dispose()
|
||||
} catch {
|
||||
@@ -85,12 +102,32 @@ export function disposeWebgl(
|
||||
}
|
||||
}
|
||||
|
||||
function releaseXtermWebglContext(webglAddon: ManagedPaneInternal['webglAddon']): void {
|
||||
try {
|
||||
// Why: xterm removes the canvas on dispose, but Windows/ANGLE can keep the
|
||||
// driver context alive long enough for rapid terminal activation to hit
|
||||
// Chromium's active WebGL context budget (#6874).
|
||||
const renderer = (webglAddon as unknown as XtermWebglAddonInternals | null)?._renderer
|
||||
renderer?._gl?.getExtension('WEBGL_lose_context')?.loseContext()
|
||||
if (renderer?._canvas) {
|
||||
renderer._canvas.width = 0
|
||||
renderer._canvas.height = 0
|
||||
}
|
||||
} catch {
|
||||
/* ignore - WebGL teardown must not block fallback to the DOM renderer */
|
||||
}
|
||||
}
|
||||
|
||||
export function markComplexScriptOutput(pane: ManagedPaneInternal): void {
|
||||
pane.hasComplexScriptOutput = true
|
||||
}
|
||||
|
||||
export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void {
|
||||
if (pane.webglDisabledAfterContextLoss) {
|
||||
// Why: suspended panes keep retained contexts but are invisible; their
|
||||
// rebuild is deferred to resumePaneRendering. Clearing + repainting them
|
||||
// here would make every recovery burst scale with total pane count across
|
||||
// all workspaces instead of visible panes.
|
||||
if (pane.webglDisabledAfterContextLoss || pane.webglAttachmentDeferred) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user