Improve Windows terminal performance: retain WebGL contexts, warm first ConPTY (#7085)

This commit is contained in:
Jinwoo Hong
2026-07-02 18:41:23 -04:00
committed by GitHub
parent ce997b001d
commit 2ce9314acb
27 changed files with 7103 additions and 53 deletions
+3
View File
@@ -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()
})
})
+45
View File
@@ -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 */
}
})
}
+41
View File
@@ -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
View File
@@ -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
View File
@@ -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 })
)
})
+18 -2
View File
@@ -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 {