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

This commit is contained in:
Jinwoo Hong 2026-07-02 15:41:23 -07:00 committed by GitHub
parent ce997b001d
commit 2ce9314acb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 7103 additions and 53 deletions

View File

@ -0,0 +1,196 @@
#!/usr/bin/env node
import { mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import {
createWslFixture,
listWslDistros,
removeWslFixture
} from './windows-apphang-repro/wsl-workspace-fixture.mjs'
import { runGpuMode } from './windows-apphang-repro/terminal-activation-scenario.mjs'
import { summarizeResult } from './windows-apphang-repro/apphang-report-summary.mjs'
const defaultCycles = 14
const defaultOutputLines = 1_600
function parseArgs() {
const args = {
cycles: defaultCycles,
distro: null,
expect: 'none',
gpuModes: ['on'],
keep: false,
outputLines: defaultOutputLines,
reportPath: null,
sourceControl: true,
deadPtyReactivate: true
}
for (const arg of process.argv.slice(2)) {
if (arg === '--help' || arg === '-h') {
printHelp()
process.exit(0)
}
if (arg === '--keep') {
args.keep = true
continue
}
if (arg === '--no-source-control') {
args.sourceControl = false
continue
}
if (arg === '--no-dead-pty-reactivate') {
args.deadPtyReactivate = false
continue
}
const [name, value] = arg.split('=', 2)
if (name === '--cycles') {
args.cycles = parsePositiveInt(name, value)
continue
}
if (name === '--distro') {
args.distro = value?.trim() || null
continue
}
if (name === '--expect') {
if (!['none', 'repro', 'pass'].includes(value)) {
throw new Error(`Unsupported --expect=${value}. Use none, repro, or pass.`)
}
args.expect = value
continue
}
if (name === '--gpu') {
const modes = (value ?? '')
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
if (modes.length === 0 || modes.some((mode) => !['on', 'off', 'auto'].includes(mode))) {
throw new Error(`Unsupported --gpu=${value}. Use on, off, auto, or a comma-list.`)
}
args.gpuModes = modes
continue
}
if (name === '--output-lines') {
args.outputLines = parsePositiveInt(name, value)
continue
}
if (name === '--report') {
args.reportPath = value?.trim() || null
if (!args.reportPath) {
throw new Error('--report requires a file path.')
}
continue
}
throw new Error(`Unknown argument: ${arg}`)
}
return args
}
function printHelp() {
console.log(`Usage:
node config/scripts/repro-windows-apphang-terminal-activation.mjs [options]
Options:
--expect=none|repro|pass none prints measurements, repro exits 0 only when hang evidence is observed,
pass exits 1 if hang evidence is observed. Default: none.
--gpu=on|off|auto[,mode...] Terminal GPU setting(s) to run. Default: on.
--cycles=N Activation/output cycles per GPU mode. Default: ${defaultCycles}.
--output-lines=N Lines emitted by each terminal stress command. Default: ${defaultOutputLines}.
--report=PATH Write full JSON evidence to PATH and print a compact summary to stdout.
--distro=NAME WSL distro to use. Default: first non docker-desktop distro.
--no-source-control Do not open Source Control during the stress loop.
--no-dead-pty-reactivate Do not kill PTYs and revisit workspaces after initial activation.
--keep Keep disposable WSL/userData fixtures after the run.`)
}
function parsePositiveInt(name, value) {
const parsed = Number.parseInt(value ?? '', 10)
if (!Number.isInteger(parsed) || parsed <= 0 || String(parsed) !== value) {
throw new Error(`${name} requires a positive integer.`)
}
return parsed
}
async function main() {
if (process.platform !== 'win32') {
throw new Error('This repro harness is intentionally Windows-only.')
}
const args = parseArgs()
const distros = listWslDistros()
const distro = args.distro ?? distros[0]
if (!distro) {
throw new Error('No user WSL distro found. Install/enable WSL or pass --distro=NAME.')
}
console.log(
`[apphang-repro] issue=https://github.com/stablyai/orca/issues/6874 distro=${distro} gpuModes=${args.gpuModes.join(',')} cycles=${args.cycles}`
)
const fixture = createWslFixture(distro)
console.log(
`[apphang-repro] fixture repo=${fixture.repoUncPath} plain=${fixture.plainUncPath} base=${fixture.baseLinuxPath}`
)
const results = []
try {
for (const gpuMode of args.gpuModes) {
results.push(await runGpuMode(gpuMode, args, fixture))
if (args.expect === 'repro' && results.at(-1)?.reproduced) {
break
}
}
} finally {
if (!args.keep) {
removeWslFixture(fixture)
}
}
const payload = {
issue: 'https://github.com/stablyai/orca/issues/6874',
expect: args.expect,
distro,
fixture: args.keep ? fixture : { removed: true, baseLinuxPath: fixture.baseLinuxPath },
summary: results.map(summarizeResult),
results
}
if (args.reportPath) {
const reportPath = path.resolve(args.reportPath)
mkdirSync(path.dirname(reportPath), { recursive: true })
writeFileSync(reportPath, `${JSON.stringify(payload, null, 2)}\n`)
console.log(JSON.stringify({ reportPath, summary: payload.summary }, null, 2))
} else {
console.log(JSON.stringify(payload, null, 2))
}
// Why: a broken harness run (CDP/setup/selector failure) proves nothing —
// it must fail both --expect=repro and --expect=pass rather than being
// miscounted as hang evidence or as a clean pass.
const harnessErrors = results.filter((result) => result.harnessError)
if (harnessErrors.length > 0) {
const harnessErrorLines = harnessErrors
.map((result) => ` gpu=${result.gpuMode}: ${result.harnessError}`)
.join('\n')
console.error(
`[apphang-repro] Harness failed in ${harnessErrors.length} run(s); result inconclusive:\n${harnessErrorLines}`
)
if (args.expect !== 'none') {
process.exit(1)
}
}
const reproduced = results.some((result) => result.reproduced)
if (args.expect === 'repro' && !reproduced) {
console.error(
'[apphang-repro] Expected to reproduce the hang, but no hang evidence was observed.'
)
process.exit(1)
}
if (args.expect === 'pass' && reproduced) {
console.error('[apphang-repro] Expected a clean pass, but hang evidence was observed.')
process.exit(1)
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error))
process.exit(1)
})

View File

@ -0,0 +1,77 @@
import { existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
export function summarizeTrace(userDataDir) {
const tracePath = path.join(userDataDir, 'logs', 'main.trace.ndjson')
if (!existsSync(tracePath)) {
return { tracePath, exists: false }
}
const lines = readFileSync(tracePath, 'utf8').trim().split(/\r?\n/).filter(Boolean)
const parsed = []
for (const line of lines.slice(-400)) {
try {
parsed.push(JSON.parse(line))
} catch {
parsed.push({ raw: line })
}
}
const interesting = parsed.filter((entry) => {
const name = String(entry.name ?? entry.event ?? entry.type ?? '')
const text = JSON.stringify(entry)
return (
name.includes('git.exec') ||
name.includes('renderer_memory') ||
name.includes('sidebar_worktree_activate') ||
text.includes('git.exec remote') ||
text.includes('git.exec worktree') ||
text.includes('sidebar_worktree_activate')
)
})
return {
tracePath,
exists: true,
totalLines: lines.length,
interestingTail: interesting.slice(-40)
}
}
export function summarizeAppLogs(logs) {
const webglContextWarnings = logs.filter((entry) =>
entry.line.includes('Too many active WebGL contexts')
)
const webglContextLosses = logs.filter((entry) =>
entry.line.toLowerCase().includes('webgl context lost')
)
const gpuProcessEvents = logs.filter((entry) => /gpu|d3d|angle/i.test(entry.line))
return {
lineCount: logs.length,
webglContextWarningCount: webglContextWarnings.length,
webglContextWarningsTail: webglContextWarnings.slice(-10),
webglContextLossCount: webglContextLosses.length,
webglContextLossesTail: webglContextLosses.slice(-10),
gpuProcessEventCount: gpuProcessEvents.length,
gpuProcessEventsTail: gpuProcessEvents.slice(-20)
}
}
export function summarizeResult(result) {
const samples = result.samples.filter((sample) => sample && typeof sample === 'object')
const maxActivationMs = Math.max(0, ...samples.map((sample) => sample.activationMs ?? 0))
const maxPtyWaitMs = Math.max(0, ...samples.map((sample) => sample.ptyWaitMs ?? 0))
const maxMainIpcMs = Math.max(0, ...samples.map((sample) => sample.mainIpcMs ?? 0))
const maxRendererDriftMs = Math.max(0, ...samples.map((sample) => sample.rendererMaxDriftMs ?? 0))
return {
gpuMode: result.gpuMode,
reproduced: result.reproduced,
reproductionReason: result.reproductionReason,
harnessError: result.harnessError ?? null,
elapsedMs: result.elapsedMs,
sampleCount: samples.length,
maxActivationMs,
maxPtyWaitMs,
maxMainIpcMs,
maxRendererDriftMs,
finalWebglContextCounts: result.finalDiagnostics?.webglContextCounts ?? null,
appLogSummary: result.appLogSummary
}
}

View File

@ -0,0 +1,331 @@
import { chromium } from '@playwright/test'
import { spawn } from 'node:child_process'
import { mkdtempSync } from 'node:fs'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import {
appShutdownTimeoutMs,
cdpPollTimeoutMs,
delay,
pollUntil,
rendererActionTimeoutMs,
runWithTimeout
} from './repro-timing.mjs'
import { createCompletedOnboardingProfile } from './wsl-workspace-fixture.mjs'
const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url)))
function isPortFree(port) {
return new Promise((resolve) => {
const server = net.createServer()
server.once('error', () => {
try {
server.close()
} catch {}
resolve(false)
})
server.once('listening', () => server.close(() => resolve(true)))
server.listen(port, '127.0.0.1')
})
}
export async function pickFreePort() {
for (let port = 9533; port < 9633; port += 1) {
if (await isPortFree(port)) {
return port
}
}
throw new Error('Could not find a free CDP port in 9533..9632.')
}
export function createGpuUserDataDirectory(gpuMode) {
const userDataDir = mkdtempSync(path.join(os.tmpdir(), `orca-apphang-${gpuMode}-userdata-`))
createCompletedOnboardingProfile(userDataDir)
return userDataDir
}
export function launchDevApp({ cdpPort, userDataDir }) {
const env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE
Object.assign(env, {
ELECTRON_ENABLE_LOGGING: '1',
ELECTRON_ENABLE_STACK_DUMPING: '1',
NODE_ENV: 'development',
ORCA_DEV_USER_DATA_PATH: userDataDir,
ORCA_SKIP_DEV_WEB_PREPARE: '1',
ORCA_STARTUP_DIAGNOSTICS: '1',
REMOTE_DEBUGGING_PORT: String(cdpPort),
VITE_EXPOSE_STORE: 'true'
})
const child = spawn(
process.execPath,
[path.join('config', 'scripts', 'run-electron-vite-dev.mjs')],
{
cwd: rootDir,
env,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
}
)
const logs = []
const collect = (source) => (chunk) => {
const text = chunk.toString()
for (const line of text.split(/\r?\n/).filter(Boolean)) {
logs.push({ source, line, at: Date.now() })
console.log(`[apphang-repro:${source}] ${line}`)
}
}
child.stdout?.on('data', collect('stdout'))
child.stderr?.on('data', collect('stderr'))
return { child, logs }
}
export async function stopDevApp(child) {
if (!child?.pid || child.exitCode !== null || child.signalCode !== null) {
return
}
await new Promise((resolve) => {
const timer = setTimeout(resolve, appShutdownTimeoutMs)
child.once('exit', () => {
clearTimeout(timer)
resolve()
})
if (process.platform === 'win32') {
const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true
})
killer.once('exit', () => undefined)
return
}
child.kill('SIGTERM')
})
}
async function waitForCdp(port) {
const url = `http://127.0.0.1:${port}/json`
const startedAt = Date.now()
while (Date.now() - startedAt < cdpPollTimeoutMs) {
try {
const response = await fetch(url)
if (response.ok) {
const targets = await response.json()
if (Array.isArray(targets) && targets.some((target) => target.type)) {
return targets
}
}
} catch {}
await delay(500)
}
throw new Error(`Timed out waiting for CDP targets on ${url}`)
}
async function getMainPage(browser) {
const startedAt = Date.now()
while (Date.now() - startedAt < cdpPollTimeoutMs) {
for (const context of browser.contexts()) {
const pages = context.pages()
const page =
pages.find((candidate) =>
/^https?:\/\/127\.0\.0\.1:|^https?:\/\/localhost:/.test(candidate.url())
) ?? pages[0]
if (page) {
return page
}
}
await delay(250)
}
throw new Error('Timed out waiting for the Electron renderer page.')
}
export async function connectToApp(cdpPort) {
await waitForCdp(cdpPort)
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`)
const page = await getMainPage(browser)
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 })
return { browser, page }
}
export async function installRendererProbe(page) {
await page.evaluate(() => {
if (globalThis.__orcaApphangProbe) {
return
}
const probe = {
intervalMs: 50,
last: performance.now(),
maxDriftMs: 0,
samples: 0,
startedAt: performance.now(),
lastTickAt: performance.now()
}
const timer = setInterval(() => {
const now = performance.now()
const drift = Math.max(0, now - probe.last - probe.intervalMs)
probe.maxDriftMs = Math.max(probe.maxDriftMs, drift)
probe.last = now
probe.lastTickAt = now
probe.samples += 1
}, probe.intervalMs)
globalThis.__orcaApphangProbe = { probe, timer }
})
}
export async function waitForStoreReady(page) {
await pollUntil(
'renderer store exposure',
() => page.evaluate(() => Boolean(window.__store && window.api)),
Boolean,
30_000
)
await pollUntil(
'workspace session hydration',
() =>
page.evaluate(() => {
const state = window.__store?.getState?.()
return Boolean(state?.workspaceSessionReady && state?.hydrationSucceeded)
}),
Boolean,
45_000
)
}
export async function collectRendererDiagnostics(page) {
if (!page) {
return null
}
try {
return await runWithTimeout(
'renderer diagnostics',
() =>
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 readWebglIdentity = () => {
const canvas = document.createElement('canvas')
let gl = null
try {
gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl')
if (!gl) {
return { available: false, vendor: null, renderer: null }
}
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info')
if (!debugInfo) {
return { available: true, vendor: null, renderer: null }
}
return {
available: true,
vendor: String(gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) ?? '') || null,
renderer: String(gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) ?? '') || null
}
} catch (error) {
return {
available: false,
error: error instanceof Error ? error.message : String(error)
}
} finally {
try {
gl?.getExtension('WEBGL_lose_context')?.loseContext()
} catch {}
canvas.width = 0
canvas.height = 0
}
}
const allPaneManagersDiagnostics = Array.from(
window.__paneManagers?.entries?.() ?? []
).map(([managerTabId, paneManager]) => ({
tabId: managerTabId,
diagnostics: paneManager?.getRenderingDiagnostics?.() ?? []
}))
const webglContextCounts = allPaneManagersDiagnostics.reduce(
(acc, entry) => {
acc.managerCount += 1
acc.paneCount += entry.diagnostics.length
for (const diagnostic of entry.diagnostics) {
if (diagnostic.hasWebgl) {
acc.attachedWebglCount += 1
}
if (diagnostic.webglAttachmentDeferred) {
acc.deferredWebglCount += 1
}
}
return acc
},
{ attachedWebglCount: 0, deferredWebglCount: 0, managerCount: 0, paneCount: 0 }
)
const state = window.__store?.getState?.()
const worktreeId = state?.activeWorktreeId ?? null
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const activePane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null
const buffer = activePane?.terminal?.buffer?.active ?? null
return {
hasStore: Boolean(window.__store),
workspaceSessionReady: state?.workspaceSessionReady ?? null,
hydrationSucceeded: state?.hydrationSucceeded ?? null,
activeView: state?.activeView ?? null,
activeRepoId: state?.activeRepoId ?? null,
activeWorktreeId: worktreeId,
activeTabType: state?.activeTabType ?? null,
activeTabId: tabId,
terminalGpuAcceleration: state?.settings?.terminalGpuAcceleration ?? null,
repoCount: state?.repos?.length ?? null,
worktreeCountsByRepo: Object.fromEntries(
Object.entries(state?.worktreesByRepo ?? {}).map(([repoId, worktrees]) => [
repoId,
worktrees.length
])
),
ptyIdsByTabId: tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : [],
paneManagerCount: window.__paneManagers?.size ?? null,
activePane: activePane
? {
id: activePane.id ?? null,
leafId: activePane.leafId ?? null,
ptyId: activePane.container?.dataset?.ptyId ?? null,
cols: activePane.terminal?.cols ?? null,
rows: activePane.terminal?.rows ?? null,
buffer: buffer
? {
baseY: buffer.baseY,
viewportY: buffer.viewportY,
cursorY: buffer.cursorY,
length: buffer.length
}
: null
}
: null,
renderingDiagnostics: manager?.getRenderingDiagnostics?.() ?? null,
allPaneManagersDiagnostics,
webglContextCounts,
webglIdentity: readWebglIdentity(),
rendererProbe: globalThis.__orcaApphangProbe?.probe ?? null,
ptySessions: await timed('PTY sessions', window.api?.pty?.listSessions?.()),
rendererDeliveryDebug: await timed(
'renderer delivery debug',
window.api?.pty?.getRendererDeliveryDebugSnapshot?.()
)
}
}),
rendererActionTimeoutMs
)
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) }
}
}

View File

@ -0,0 +1,47 @@
export const cdpPollTimeoutMs = 90_000
export const rendererActionTimeoutMs = 5_000
export const activationTimeoutMs = 15_000
export const ptyWaitTimeoutMs = 20_000
export const terminalMarkerTimeoutMs = 45_000
export const setupTimeoutMs = 90_000
export const appShutdownTimeoutMs = 8_000
export const severeRendererDriftMs = 2_000
export const severeActivationMs = 5_000
export const severeMainIpcMs = 2_000
export const severePtyWaitMs = 10_000
export 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)
const result = await Promise.race([
actionPromise.then(
(value) => ({ timedOut: false, value }),
(error) => ({ timedOut: false, error })
),
delay(timeoutMs).then(() => ({ timedOut: true }))
])
if (result.timedOut) {
throw new Error(`Timed out during ${label} after ${timeoutMs}ms.`)
}
if ('error' in result) {
throw result.error
}
return result.value
}
export async function pollUntil(label, read, predicate, timeoutMs, intervalMs = 100) {
const startedAt = Date.now()
let lastValue = null
while (Date.now() - startedAt < timeoutMs) {
lastValue = await runWithTimeout(label, read, rendererActionTimeoutMs)
if (predicate(lastValue)) {
return lastValue
}
await delay(intervalMs)
}
throw new Error(`Timed out waiting for ${label}; last value: ${JSON.stringify(lastValue)}`)
}

View File

@ -0,0 +1,486 @@
import {
collectRendererDiagnostics,
connectToApp,
createGpuUserDataDirectory,
installRendererProbe,
launchDevApp,
pickFreePort,
stopDevApp,
waitForStoreReady
} from './electron-dev-session.mjs'
import { summarizeAppLogs, summarizeTrace } from './apphang-report-summary.mjs'
import {
activationTimeoutMs,
pollUntil,
ptyWaitTimeoutMs,
rendererActionTimeoutMs,
runWithTimeout,
setupTimeoutMs,
severeActivationMs,
severeMainIpcMs,
severePtyWaitMs,
severeRendererDriftMs,
terminalMarkerTimeoutMs
} from './repro-timing.mjs'
import { safeRemoveLocalDirectory } from './wsl-workspace-fixture.mjs'
class ReproductionObservedError extends Error {
constructor(message, evidence) {
super(message)
this.name = 'ReproductionObservedError'
this.evidence = evidence
}
}
async function setupAppFixture(page, fixture, gpuMode, sourceControl) {
return await runWithTimeout(
'fixture registration in Orca',
() =>
page.evaluate(
async ({ repoPath, plainPath, importedWorktreePaths, mode, openSourceControl }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is unavailable.')
}
const state = store.getState()
await state.fetchSettings?.()
await store.getState().updateSettings({ terminalGpuAcceleration: mode })
const addResult = await window.api.repos.add({ path: repoPath, kind: 'git' })
if ('error' in addResult) {
throw new Error(addResult.error)
}
await store.getState().fetchRepos()
let nextState = store.getState()
const repo =
nextState.repos.find((candidate) => candidate.path === repoPath) ?? addResult.repo
await nextState.updateRepo(repo.id, {
externalWorktreeVisibility: 'show',
externalWorktreeVisibilityPromptDismissedAt: Date.now(),
importedExternalWorktreePaths: importedWorktreePaths,
externalWorktreeInboxBaselinePaths: importedWorktreePaths
})
await store.getState().fetchWorktrees(repo.id, { requireAuthoritative: true })
const plainRepo = await store.getState().addNonGitFolder(plainPath)
if (!plainRepo) {
throw new Error('addNonGitFolder returned null.')
}
await store.getState().fetchWorktrees(plainRepo.id, { requireAuthoritative: true })
nextState = store.getState()
nextState.setSidebarOpen(true)
nextState.setGroupBy('none')
nextState.setSortBy('recent')
nextState.setShowActiveOnly(false)
nextState.setActiveView('terminal')
if (openSourceControl) {
nextState.setRightSidebarOpen(true)
nextState.setRightSidebarTab('source-control')
}
const gitWorktrees = nextState.worktreesByRepo[repo.id] ?? []
const plainWorktrees = nextState.worktreesByRepo[plainRepo.id] ?? []
return {
repoId: repo.id,
repoPath: repo.path,
plainRepoId: plainRepo.id,
gitWorktrees: gitWorktrees.map((worktree) => ({
id: worktree.id,
path: worktree.path,
displayName: worktree.displayName,
branch: worktree.branch,
isMainWorktree: worktree.isMainWorktree
})),
plainWorktrees: plainWorktrees.map((worktree) => ({
id: worktree.id,
path: worktree.path,
displayName: worktree.displayName,
branch: worktree.branch,
isMainWorktree: worktree.isMainWorktree
}))
}
},
{
repoPath: fixture.repoUncPath,
plainPath: fixture.plainUncPath,
importedWorktreePaths: fixture.worktreeUncPaths,
mode: gpuMode,
openSourceControl: sourceControl
}
),
setupTimeoutMs
)
}
async function clickWorktreeCard(page, worktreeId) {
const rect = await runWithTimeout(
`locate worktree card ${worktreeId}`,
() =>
page.evaluate((id) => {
const rows = Array.from(document.querySelectorAll('[data-worktree-id]'))
const row = rows.find((candidate) => candidate.getAttribute('data-worktree-id') === id)
if (!row) {
return null
}
row.scrollIntoView({ block: 'center', inline: 'nearest' })
const surface = row.querySelector('[data-worktree-card-surface="true"]') ?? row
const bounds = surface.getBoundingClientRect()
if (bounds.width <= 0 || bounds.height <= 0) {
return null
}
return {
x: bounds.left + bounds.width / 2,
y: bounds.top + bounds.height / 2,
width: bounds.width,
height: bounds.height
}
}, worktreeId),
rendererActionTimeoutMs
)
if (!rect) {
throw new Error(`Could not find rendered worktree card for ${worktreeId}`)
}
await runWithTimeout(
`click worktree card ${worktreeId}`,
() => page.mouse.click(rect.x, rect.y),
rendererActionTimeoutMs
)
}
async function waitForActiveWorktree(page, worktreeId) {
return await pollUntil(
`active worktree ${worktreeId}`,
() =>
page.evaluate((id) => {
const state = window.__store?.getState?.()
return {
activeWorktreeId: state?.activeWorktreeId ?? null,
activeTabId: state?.activeTabId ?? null,
activeTabType: state?.activeTabType ?? null,
tabs: state?.tabsByWorktree?.[id]?.map((tab) => tab.id) ?? []
}
}, worktreeId),
(value) => value?.activeWorktreeId === worktreeId && value.activeTabType === 'terminal',
activationTimeoutMs
)
}
async function waitForActivePty(page, worktreeId) {
const startedAt = Date.now()
const value = await pollUntil(
`active PTY for ${worktreeId}`,
() =>
page.evaluate((id) => {
const state = window.__store?.getState?.()
const tabId =
state?.activeWorktreeId === id && state.activeTabType === 'terminal'
? state.activeTabId
: (state?.activeTabIdByWorktree?.[id] ?? null)
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null
const ptyId = pane?.container?.dataset?.ptyId ?? null
return {
tabId,
ptyId,
hasManager: Boolean(manager),
paneCount: manager?.getPanes?.()?.length ?? 0,
ptyIdsByTab: tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : []
}
}, worktreeId),
(value) => Boolean(value?.ptyId),
ptyWaitTimeoutMs
)
return { ...value, waitMs: Date.now() - startedAt }
}
function makeOutputCommand(marker, outputLines) {
const payload = 'x'.repeat(180)
return `printf '${marker}_START\\n'; i=1; while [ "$i" -le ${outputLines} ]; do printf '${marker}_%05d ${payload}\\n' "$i"; i=$((i+1)); done; printf '${marker}_DONE\\n'\r`
}
async function getTerminalContent(page, charLimit = 20_000) {
return await runWithTimeout(
'terminal content',
() =>
page.evaluate((limit) => {
const state = window.__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),
rendererActionTimeoutMs
)
}
async function sendOutputStress(page, ptyId, marker, outputLines) {
await runWithTimeout(
`write terminal stress ${marker}`,
() =>
page.evaluate(
({ id, command }) => {
window.api.pty.write(id, command)
},
{ id: ptyId, command: makeOutputCommand(marker, outputLines) }
),
rendererActionTimeoutMs
)
await pollUntil(
`terminal stress marker ${marker}`,
async () => (await getTerminalContent(page)).includes(`${marker}_DONE`),
Boolean,
terminalMarkerTimeoutMs,
200
)
}
async function pingMainIpc(page) {
const startedAt = Date.now()
await runWithTimeout(
'main IPC ping',
() => page.evaluate(() => window.api.pty.listSessions()),
rendererActionTimeoutMs
)
return Date.now() - startedAt
}
async function readRendererProbe(page) {
return await runWithTimeout(
'renderer probe read',
() => page.evaluate(() => globalThis.__orcaApphangProbe?.probe ?? null),
rendererActionTimeoutMs
)
}
async function killActivePty(page) {
return await runWithTimeout(
'kill active PTY',
() =>
page.evaluate(async () => {
const state = window.__store?.getState?.()
const tabId = state?.activeTabId ?? null
const ptyIds = tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : []
const ptyId = ptyIds[0] ?? null
if (!ptyId) {
return null
}
await window.api.pty.kill(ptyId)
return ptyId
}),
rendererActionTimeoutMs
)
}
async function resetRendererProbe(page) {
// Why: probe drift is cumulative since installation; without a per-cycle
// reset, one startup/setup stall gets re-reported as a hang in every
// later cycle's rendererMaxDriftMs check.
await runWithTimeout(
'renderer probe reset',
() =>
page.evaluate(() => {
const probe = globalThis.__orcaApphangProbe?.probe
if (!probe) {
return
}
const now = performance.now()
probe.last = now
probe.lastTickAt = now
probe.startedAt = now
probe.maxDriftMs = 0
probe.samples = 0
}),
rendererActionTimeoutMs
)
}
async function runActivationCycle(page, target, args) {
await resetRendererProbe(page)
const marker = `ORCA_APPHANG_${target.index}_${Date.now()}`
const cycleStartedAt = Date.now()
const activationStartedAt = Date.now()
await clickWorktreeCard(page, target.id)
const activation = await waitForActiveWorktree(page, target.id)
const activationMs = Date.now() - activationStartedAt
const activePty = await waitForActivePty(page, target.id)
const diagnosticsBeforeOutput = await collectRendererDiagnostics(page)
const outputStartedAt = Date.now()
await sendOutputStress(page, activePty.ptyId, marker, args.outputLines)
const outputMs = Date.now() - outputStartedAt
const mainIpcMs = await pingMainIpc(page)
const rendererProbe = await readRendererProbe(page)
const diagnosticsAfterOutput = await collectRendererDiagnostics(page)
const elapsedMs = Date.now() - cycleStartedAt
const sample = {
index: target.index,
kind: target.kind,
worktreeId: target.id,
displayName: target.displayName,
elapsedMs,
activationMs,
outputMs,
activation,
ptyWaitMs: activePty.waitMs,
ptyId: activePty.ptyId,
tabId: activePty.tabId,
mainIpcMs,
rendererMaxDriftMs: rendererProbe?.maxDriftMs ?? null,
renderingDiagnostics: diagnosticsAfterOutput?.renderingDiagnostics ?? null,
webglIdentity: diagnosticsAfterOutput?.webglIdentity ?? null,
diagnosticsBeforeOutput,
diagnosticsAfterOutput
}
const reproducedReasons = []
if (activationMs > severeActivationMs) {
reproducedReasons.push(`worktree activation took ${activationMs}ms`)
}
if (activePty.waitMs > severePtyWaitMs) {
reproducedReasons.push(`PTY binding took ${activePty.waitMs}ms`)
}
if (mainIpcMs > severeMainIpcMs) {
reproducedReasons.push(`main IPC ping took ${mainIpcMs}ms`)
}
if ((rendererProbe?.maxDriftMs ?? 0) > severeRendererDriftMs) {
reproducedReasons.push(`renderer timer drift reached ${Math.round(rendererProbe.maxDriftMs)}ms`)
}
if (reproducedReasons.length > 0) {
throw new ReproductionObservedError(reproducedReasons.join('; '), sample)
}
return sample
}
async function runDeadPtyReactivation(page, targets, args) {
const samples = []
for (const target of targets) {
await clickWorktreeCard(page, target.id)
await waitForActiveWorktree(page, target.id)
await waitForActivePty(page, target.id)
const killedPtyId = await killActivePty(page)
samples.push({ worktreeId: target.id, killedPtyId })
}
for (const target of targets) {
samples.push(
await runActivationCycle(page, { ...target, kind: `${target.kind}:dead-pty` }, args)
)
}
return samples
}
function selectTargets(setupResult, cycles) {
const gitWorktrees = setupResult.gitWorktrees
.filter((worktree) => worktree.path)
.sort((a, b) => Number(b.isMainWorktree) - Number(a.isMainWorktree))
.slice(0, 5)
.map((worktree) => ({ ...worktree, kind: 'git-wsl' }))
const plainWorktrees = setupResult.plainWorktrees.map((worktree) => ({
...worktree,
kind: 'plain-folder-wsl'
}))
const baseTargets = [...gitWorktrees, ...plainWorktrees]
if (baseTargets.length === 0) {
throw new Error('No worktrees were discovered for the repro fixture.')
}
return Array.from({ length: cycles }, (_, index) => ({
...baseTargets[index % baseTargets.length],
index: index + 1
}))
}
export async function runGpuMode(gpuMode, args, fixture) {
const cdpPort = await pickFreePort()
const userDataDir = createGpuUserDataDirectory(gpuMode)
const launched = launchDevApp({ cdpPort, userDataDir })
let browser = null
let page = null
const startedAt = Date.now()
const result = {
gpuMode,
reproduced: false,
reproductionReason: null,
harnessError: null,
startedAt,
elapsedMs: null,
cdpPort,
userDataDir,
appPid: launched.child.pid ?? null,
setup: null,
samples: [],
finalDiagnostics: null,
trace: null,
appLogSummary: null,
appLogsTail: null,
cleanupErrors: []
}
try {
const connected = await connectToApp(cdpPort)
browser = connected.browser
page = connected.page
await waitForStoreReady(page)
await installRendererProbe(page)
const identity = await runWithTimeout(
'app identity',
() => page.evaluate(() => window.api.app.getIdentity?.()),
rendererActionTimeoutMs
).catch(() => null)
console.log(
`[apphang-repro] connected gpu=${gpuMode} pid=${result.appPid} identity=${JSON.stringify(identity)}`
)
result.setup = await setupAppFixture(page, fixture, gpuMode, args.sourceControl)
const targets = selectTargets(result.setup, args.cycles)
console.log(
`[apphang-repro] targets gpu=${gpuMode}: ${targets
.map((target) => `${target.kind}:${target.displayName}`)
.join(', ')}`
)
for (const target of targets) {
console.log(`[apphang-repro] cycle=${target.index} gpu=${gpuMode} kind=${target.kind}`)
result.samples.push(await runActivationCycle(page, target, args))
}
if (args.deadPtyReactivate) {
const uniqueTargets = []
const seen = new Set()
for (const target of targets) {
if (!seen.has(target.id)) {
seen.add(target.id)
uniqueTargets.push(target)
}
}
console.log(`[apphang-repro] dead-pty-reactivation gpu=${gpuMode}`)
result.samples.push(...(await runDeadPtyReactivation(page, uniqueTargets, args)))
}
} catch (error) {
if (error instanceof ReproductionObservedError) {
result.reproduced = true
result.reproductionReason = error.message
result.samples.push(error.evidence)
} else {
// Why: setup/CDP/selector failures are inconclusive, not hang evidence.
// Conflating them with `reproduced` lets --expect=repro pass on a broken
// harness; callers treat harnessError as a hard failure instead.
result.harnessError = error instanceof Error ? error.stack || error.message : String(error)
}
} finally {
result.elapsedMs = Date.now() - startedAt
result.finalDiagnostics = await collectRendererDiagnostics(page)
result.trace = summarizeTrace(userDataDir)
result.appLogSummary = summarizeAppLogs(launched.logs)
result.appLogsTail = launched.logs.slice(-120)
if (browser) {
await browser.close().catch(() => undefined)
}
await stopDevApp(launched.child)
if (!args.keep) {
safeRemoveLocalDirectory(userDataDir, result.cleanupErrors)
}
}
return result
}

View File

@ -0,0 +1,135 @@
import { execFileSync } from 'node:child_process'
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url)))
function runWsl(distro, script, options = {}) {
return execFileSync('wsl.exe', ['-d', distro, '--', 'bash', '-se'], {
cwd: rootDir,
encoding: 'utf8',
input: script,
timeout: options.timeoutMs ?? 120_000,
stdio: ['pipe', 'pipe', 'pipe']
})
}
export function listWslDistros() {
const output = execFileSync('wsl.exe', ['--list', '--quiet'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 10_000
})
return output
.replaceAll(String.fromCharCode(0), '')
.split(/\r?\n/)
.map((line) => line.trim().replace(/^\*\s*/, ''))
.filter((line) => line && !line.toLowerCase().startsWith('docker-desktop'))
}
function linuxPathToWslUnc(distro, linuxPath) {
if (!linuxPath.startsWith('/')) {
throw new Error(`Expected absolute Linux path, got ${linuxPath}`)
}
return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}`
}
export function createWslFixture(distro) {
const script = String.raw`
set -euo pipefail
base="$(mktemp -d /tmp/orca-apphang-repro.XXXXXX)"
repo="$base/repo"
mkdir -p "$repo"
cd "$repo"
git init -q
git config user.email apphang-repro@test.local
git config user.name "AppHang Repro"
mkdir -p src docs
printf '# Orca Windows AppHang repro\n' > README.md
for n in $(seq 1 25); do
printf 'line %03d %s\n' "$n" "abcdefghijklmnopqrstuvwxyz0123456789" >> src/payload.txt
done
git add -A
git commit -q -m "Initial repro fixture"
for n in 1 2 3 4; do
git branch "repro-wt-$n"
git worktree add -q "$base/wt-$n" "repro-wt-$n"
mkdir -p "$base/wt-$n/docs"
printf 'worktree %s\n' "$n" > "$base/wt-$n/docs/wt.txt"
done
plain="$base/plain-folder"
mkdir -p "$plain/subdir"
printf 'plain folder for Orca AppHang repro\n' > "$plain/README.txt"
printf '%s\n' "$base" "$repo" "$base/wt-1" "$base/wt-2" "$base/wt-3" "$base/wt-4" "$plain"
`
const lines = runWsl(distro, script, { timeoutMs: 120_000 }).trim().split(/\r?\n/).filter(Boolean)
if (lines.length < 7) {
throw new Error(`WSL fixture creation returned unexpected output: ${JSON.stringify(lines)}`)
}
const [base, repo, ...rest] = lines
const plain = rest.at(-1)
const worktrees = rest.slice(0, -1)
return {
distro,
baseLinuxPath: base,
repoLinuxPath: repo,
plainLinuxPath: plain,
worktreeLinuxPaths: worktrees,
repoUncPath: linuxPathToWslUnc(distro, repo),
plainUncPath: linuxPathToWslUnc(distro, plain),
worktreeUncPaths: worktrees.map((entry) => linuxPathToWslUnc(distro, entry))
}
}
export function removeWslFixture(fixture) {
if (!fixture?.baseLinuxPath) {
return
}
const quoted = fixture.baseLinuxPath.replaceAll("'", "'\\''")
runWsl(fixture.distro, `rm -rf '${quoted}'`, { timeoutMs: 30_000 })
}
export function createCompletedOnboardingProfile(userDataDir) {
mkdirSync(userDataDir, { recursive: true })
const profile = {
settings: {
telemetry: {
// Why: synthetic harness/benchmark activity must not send telemetry.
// Explicit false with existedBeforeTelemetryRelease=false also keeps
// the first-launch consent surface from blocking automation.
optedIn: false,
installId: '00000000-0000-4000-8000-000000000000',
existedBeforeTelemetryRelease: false
}
},
onboarding: {
flowVersion: 4,
closedAt: 1,
outcome: 'completed',
lastCompletedStep: 5
},
ui: {
contextualToursAutoEligible: false,
contextualToursSeenIds: [
'workspace-board',
'browser',
'tasks',
'automations',
'workspace-creation'
],
featureTipsSeenIds: [],
featureInteractions: {},
projectOrderManualDefaultNoticeDismissed: true
}
}
writeFileSync(path.join(userDataDir, 'orca-data.json'), `${JSON.stringify(profile, null, 2)}\n`)
}
export function safeRemoveLocalDirectory(dir, cleanupErrors) {
try {
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
} catch (error) {
cleanupErrors.push(error instanceof Error ? error.message : String(error))
}
}

View File

@ -4,6 +4,69 @@ Goal: (1) significantly improve Windows startup time (~1 min cold start reported
(2) fix OpenCode-driven UI freezes, (3) improve overall Windows performance.
All changes must be proven with before/after benchmark numbers.
## Phase 2 (2026-07-02, branch Jinwoo-H/windows-performance-improvement) — terminal interaction latency
Complaints: slow workspace switching, slow tab create/switch (terminal-related), occasional crashes.
Harness: `tools/benchmarks/terminal-perf-bench.mjs` (CDP-driven dev app, renderer-clock phase
timings; scenarios tab-create / tab-switch / workspace-switch; local git fixture).
Main-process spawn attribution: `ORCA_PTY_SPAWN_TIMING=1``[pty-spawn-timing]` lines
(pty.ts handler phases: preflight/auth/host_env/options/provider_spawn).
Findings (baseline, this machine):
- Workspace switch: every hide disposed each pane's WebGL context; resume recreated it —
~5ms macOS, 100-500ms/pane Windows ANGLE (the comment in terminal-visibility-resume.ts
admitted this). Premise (16-context budget) stale since #7064 raised budget to 128.
- Tab create: ~550ms steady state; main handler only ~115ms (host_env≈50ms, daemon
provider_spawn≈68ms). Remainder is renderer-side (xterm open + WebGL context for the new
pane + React mount). First-ever spawn paid +2.7s inside provider_spawn = daemon's first
ConPTY (native module + conpty.dll + OpenConsole + Defender), lazily on the user's first terminal.
- Tab switch: paint settle median 80-99ms; longtasks 64-151ms — every light tab resume runs
scheduleTerminalWebglAtlasRecovery: 3× (frame/120ms/500ms) global shared-atlas clear +
refresh of EVERY pane in EVERY manager. Parse-time recovery (pty-connection.ts
recoverWebglAtlasAfterParse / hiddenOutputNeedsAtlasRecoveryAfterParse) already covers
risky output including hidden. CAUTION: #7058 changed this area and was reverted (#7073) —
left as follow-up.
- LocalPtyProvider spawned without useConptyDll while the daemon path used it (legacy system
ConPTY corruption + perf differences on degraded-mode/fresh-local spawns).
Fixes on this branch (PR #7080 merged in — WebGL release on dispose + stale pty:exit synthesis):
- A: WebGL context retention across hide/show (pane-webgl-context-retention.ts, LRU cap 32 —
sized for ~10-16 worktrees × ~2 terminals hot sets; suspend keeps live contexts; resume
repaints instead of recreating). Gated to Windows (getRendererAppPlatform() === 'win32'):
macOS/Linux context creation is cheap, so they keep dispose-on-hide and pay no retention
GPU-memory cost. Peer note: at least one comparable product raises the context budget to
256 and retains unbounded with a session-wide DOM latch on loss; we bound + recover instead.
- B: useConptyDll for LocalPtyProvider spawns (local-pty-utils.ts) — parity with daemon.
- D: atlas recovery bursts skip suspended panes (scoped to visible); retained panes repaint
on resume (shared-atlas invariant preserved).
- F: daemon boots a throwaway `cmd.exe /c exit` ConPTY (windows-conpty-warmup.ts) so the
first user terminal doesn't pay the ~2.7s first-ConPTY cost.
Follow-ups (documented, not in this branch):
- Gate/scope the light-tab-switch atlas burst (see #7058/#7073 history first). Residual
tab-switch cost besides the burst: debounced ResizeObserver re-fit can reflow scrollback
when column count changed while hidden.
- Renderer-side tab-create cost (~400ms): mount chain runs new Terminal() + 5 eager addons
+ synchronous attachWebgl (pane-lifecycle.ts:108) before the spawn IPC (deferred one rAF,
pty-connection.ts:5158). Candidate: defer WebGL attach for brand-new panes.
- Cold-restore respawn fan-out: reconnectPersistedTerminals does NOT spawn; the fan-out is
Terminal.tsx mounting a TerminalPane per restored tab at once — each fires connectPanePty
→ rAF-deferred spawn IPC (pty-connection.ts:5158) with no concurrency cap. Cap belongs at
that renderer connect layer, not in reconnectPersistedTerminals.
- First terminal opened immediately after launch also waits on the one-time daemon-init
barrier (pty:spawn awaits getLocalPtyStartupPromise, ipc/pty.ts:2518; measured
preflight=0 in the bench because hydration had finished first, but an early Ctrl+T pays
it). In-daemon Windows shell resolution (pwsh -Version probe, PowerShell exe-chain
existsSync/statSync scan) is uncached per spawn; the conpty warm-up spawns cmd.exe so it
does not warm PowerShell resolution. Note the warm-up and an early first spawn serialize
on the daemon's single thread — the 1255ms post-fix first-spawn number is mostly queueing
behind the in-flight warm-up, not unwarmed cost.
- node-pty ≥1.2.0-beta defers conpty connect (spawn returns pid=0 fast) — would stop spawn
storms serializing the daemon loop.
Pre-existing Windows-only test failures (also on main, CI is ubuntu-only): 5 attribution-shim
PATH assertions in src/main/ipc/pty.test.ts (path-separator artifacts).
## Status
- [x] Benchmark harness for startup time (`tools/benchmarks/startup-time-bench.mjs`)

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)

View File

@ -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()
})
})

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 */
}
})
}

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}`
)
}
}
}

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 () => {

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(

View File

@ -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 })
)
})

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) {

View File

@ -237,11 +237,7 @@ export function disposePane(
} catch {
/* ignore */
}
try {
pane.webglAddon?.dispose()
} catch {
/* ignore */
}
disposeWebgl(pane)
try {
pane.searchAddon.dispose()
} catch {

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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)
})
})

View File

@ -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()
}

View File

@ -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)

View File

@ -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 {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,621 @@
#!/usr/bin/env node
// Terminal interaction latency benchmark: tab creation, tab switching, and
// workspace switching against the real Electron dev app over CDP. Produces
// median/p95 phase timings so Windows terminal slowness can be attributed to
// store work, PTY spawn, shell startup, or renderer paint — not guessed at.
import { execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import {
collectRendererDiagnostics,
connectToApp,
createGpuUserDataDirectory,
installRendererProbe,
launchDevApp,
pickFreePort,
stopDevApp,
waitForStoreReady
} from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs'
import {
pollUntil,
rendererActionTimeoutMs,
runWithTimeout,
setupTimeoutMs
} from '../../config/scripts/windows-apphang-repro/repro-timing.mjs'
import { safeRemoveLocalDirectory } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs'
const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url)))
const scenarioTimeoutMs = 300_000
const defaultIterations = 8
const defaultSwitches = 24
const defaultCycles = 12
function parseArgs() {
const args = {
label: 'run',
iterations: defaultIterations,
switches: defaultSwitches,
cycles: defaultCycles,
shell: null,
scenarios: ['tab-create', 'tab-switch', 'workspace-switch'],
reportPath: null,
keep: false
}
for (const arg of process.argv.slice(2)) {
if (arg === '--help' || arg === '-h') {
printHelp()
process.exit(0)
}
if (arg === '--keep') {
args.keep = true
continue
}
const [name, value] = arg.split('=', 2)
if (name === '--label') {
args.label = value?.trim() || 'run'
continue
}
if (name === '--iterations') {
args.iterations = parsePositiveInt(name, value)
continue
}
if (name === '--switches') {
args.switches = parsePositiveInt(name, value)
continue
}
if (name === '--cycles') {
args.cycles = parsePositiveInt(name, value)
continue
}
if (name === '--shell') {
args.shell = value?.trim() || null
continue
}
if (name === '--scenarios') {
const scenarios = (value ?? '')
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
const known = new Set(['tab-create', 'tab-switch', 'workspace-switch'])
if (scenarios.length === 0 || scenarios.some((scenario) => !known.has(scenario))) {
throw new Error(
`Unsupported --scenarios=${value}. Use tab-create,tab-switch,workspace-switch.`
)
}
args.scenarios = scenarios
continue
}
if (name === '--report') {
args.reportPath = value?.trim() || null
continue
}
throw new Error(`Unknown argument: ${arg}`)
}
return args
}
function printHelp() {
console.log(`Usage:
node tools/benchmarks/terminal-perf-bench.mjs [options]
Options:
--label=NAME Label recorded in the report filename/JSON. Default: run.
--iterations=N Terminal tab create/close iterations. Default: ${defaultIterations}.
--switches=N Tab switch alternations. Default: ${defaultSwitches}.
--cycles=N Workspace switch cycles. Default: ${defaultCycles}.
--shell=PATH Shell override for created tabs (e.g. cmd.exe). Default: app default.
--scenarios=a,b Subset of tab-create,tab-switch,workspace-switch.
--report=PATH Report JSON path. Default: tools/benchmarks/results/terminal-perf-<label>-<ts>.json.
--keep Keep the temp fixture and user data dir.`)
}
function parsePositiveInt(name, value) {
const parsed = Number.parseInt(value ?? '', 10)
if (!Number.isInteger(parsed) || parsed <= 0 || String(parsed) !== value) {
throw new Error(`${name} requires a positive integer.`)
}
return parsed
}
function git(cwd, ...cmd) {
execFileSync('git', cmd, { cwd, stdio: 'pipe' })
}
/** Local (native-path) git repo with two external worktrees three workspace
* cards total. Native paths keep the benchmark portable and representative of
* the default local workflow, unlike the WSL-specific apphang fixture. */
function createLocalRepoFixture() {
const baseDir = mkdtempSync(path.join(os.tmpdir(), 'orca-termperf-'))
const repoPath = path.join(baseDir, 'repo')
mkdirSync(repoPath, { recursive: true })
git(repoPath, 'init', '--initial-branch=main')
git(repoPath, 'config', 'user.email', 'bench@orca.local')
git(repoPath, 'config', 'user.name', 'Orca Bench')
writeFileSync(path.join(repoPath, 'README.md'), '# terminal perf fixture\n')
git(repoPath, 'add', '.')
git(repoPath, 'commit', '-m', 'init', '--no-gpg-sign')
const worktreePaths = []
for (const name of ['wt-one', 'wt-two']) {
const worktreePath = path.join(baseDir, name)
git(repoPath, 'worktree', 'add', worktreePath, '-b', name)
worktreePaths.push(worktreePath)
}
return { baseDir, repoPath, worktreePaths }
}
async function setupWorkspaces(page, fixture) {
return await runWithTimeout(
'fixture registration in Orca',
() =>
page.evaluate(
async ({ repoPath, importedWorktreePaths }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is unavailable.')
}
await store.getState().fetchSettings?.()
const addResult = await window.api.repos.add({ path: repoPath, kind: 'git' })
if ('error' in addResult) {
throw new Error(addResult.error)
}
await store.getState().fetchRepos()
const state = store.getState()
const repo =
state.repos.find((candidate) => candidate.path === repoPath) ?? addResult.repo
await state.updateRepo(repo.id, {
externalWorktreeVisibility: 'show',
externalWorktreeVisibilityPromptDismissedAt: Date.now(),
importedExternalWorktreePaths: importedWorktreePaths,
externalWorktreeInboxBaselinePaths: importedWorktreePaths
})
await store.getState().fetchWorktrees(repo.id, { requireAuthoritative: true })
const nextState = store.getState()
nextState.setSidebarOpen(true)
nextState.setGroupBy('none')
nextState.setSortBy('recent')
nextState.setShowActiveOnly(false)
nextState.setActiveView('terminal')
const worktrees = nextState.worktreesByRepo[repo.id] ?? []
return {
repoId: repo.id,
worktrees: worktrees.map((worktree) => ({
id: worktree.id,
path: worktree.path,
displayName: worktree.displayName,
isMainWorktree: worktree.isMainWorktree
}))
}
},
{ repoPath: fixture.repoPath, importedWorktreePaths: fixture.worktreePaths }
),
setupTimeoutMs
)
}
async function clickWorktreeCard(page, worktreeId) {
const rect = await runWithTimeout(
`locate worktree card ${worktreeId}`,
() =>
page.evaluate((id) => {
const rows = Array.from(document.querySelectorAll('[data-worktree-id]'))
const row = rows.find((candidate) => candidate.getAttribute('data-worktree-id') === id)
if (!row) {
return null
}
row.scrollIntoView({ block: 'center', inline: 'nearest' })
const surface = row.querySelector('[data-worktree-card-surface="true"]') ?? row
const bounds = surface.getBoundingClientRect()
if (bounds.width <= 0 || bounds.height <= 0) {
return null
}
return { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 }
}, worktreeId),
rendererActionTimeoutMs
)
if (!rect) {
throw new Error(`Could not find rendered worktree card for ${worktreeId}`)
}
await runWithTimeout(
`click worktree card ${worktreeId}`,
() => page.mouse.click(rect.x, rect.y),
rendererActionTimeoutMs
)
}
async function activateWorktree(page, worktreeId) {
await clickWorktreeCard(page, worktreeId)
await pollUntil(
`active worktree ${worktreeId}`,
() =>
page.evaluate((id) => {
const state = window.__store?.getState?.()
return state?.activeWorktreeId === id && state.activeTabType === 'terminal'
}, worktreeId),
Boolean,
30_000
)
}
/** Runs entirely in the renderer so phase timestamps come from one
* performance.now() clock with no CDP round-trip skew. */
async function runTabCreateScenario(page, worktreeId, iterations, shell) {
return await runWithTimeout(
'tab-create scenario',
() =>
page.evaluate(
async ({ worktreeId, iterations, shell }) => {
const store = window.__store
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const raf2 = () =>
new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
const longtasks = []
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
longtasks.push(entry.duration)
}
})
observer.observe({ entryTypes: ['longtask'] })
const samples = []
for (let index = 0; index < iterations; index++) {
const t0 = performance.now()
const tab = store.getState().createTab(worktreeId, undefined, shell ?? undefined)
const tCreated = performance.now()
const readPane = () => {
const manager = window.__paneManagers?.get(tab.id)
return manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null
}
let ptyId = null
while (!ptyId && performance.now() - t0 < 60_000) {
ptyId = readPane()?.container?.dataset?.ptyId ?? null
if (!ptyId) {
await sleep(5)
}
}
const tPty = performance.now()
let sawOutput = false
while (!sawOutput && performance.now() - t0 < 60_000) {
const content = readPane()?.serializeAddon?.serialize?.() ?? ''
sawOutput = content.trim().length > 0
if (!sawOutput) {
await sleep(10)
}
}
const tOutput = performance.now()
await raf2()
const tPainted = performance.now()
samples.push({
index,
storeCreateMs: tCreated - t0,
ptyBindMs: tPty - t0,
firstOutputMs: tOutput - t0,
paintSettleMs: tPainted - t0,
timedOut: !ptyId || !sawOutput
})
store.getState().closeTab(tab.id)
await sleep(400)
}
observer.disconnect()
return { samples, longtasks }
},
{ worktreeId, iterations, shell }
),
scenarioTimeoutMs
)
}
/** K tabs are created up-front (PTY bound), then activation alternates across
* them. switchMs = setActiveTab call active pane manager/pane for the new
* tab present two RAFs (paint settled). */
async function runTabSwitchScenario(page, worktreeId, switches, shell) {
return await runWithTimeout(
'tab-switch scenario',
() =>
page.evaluate(
async ({ worktreeId, switches, shell }) => {
const store = window.__store
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const raf2 = () =>
new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
const tabCount = 4
const tabs = []
for (let index = 0; index < tabCount; index++) {
const tab = store.getState().createTab(worktreeId, undefined, shell ?? undefined)
tabs.push(tab.id)
const started = performance.now()
let bound = false
while (!bound && performance.now() - started < 60_000) {
const manager = window.__paneManagers?.get(tab.id)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null
bound = Boolean(pane?.container?.dataset?.ptyId)
if (!bound) {
await sleep(10)
}
}
}
await sleep(500)
const longtasks = []
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
longtasks.push(entry.duration)
}
})
observer.observe({ entryTypes: ['longtask'] })
const samples = []
for (let index = 0; index < switches; index++) {
const targetTabId = tabs[index % tabs.length]
if (store.getState().activeTabId === targetTabId) {
continue
}
const t0 = performance.now()
store.getState().setActiveTab(targetTabId)
const tStore = performance.now()
let visible = false
while (!visible && performance.now() - t0 < 10_000) {
const manager = window.__paneManagers?.get(targetTabId)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null
visible = Boolean(pane?.container?.isConnected)
if (!visible) {
await sleep(2)
}
}
const tVisible = performance.now()
await raf2()
const tPainted = performance.now()
samples.push({
index,
targetTabId,
storeMs: tStore - t0,
paneVisibleMs: tVisible - t0,
paintSettleMs: tPainted - t0,
timedOut: !visible
})
await sleep(120)
}
observer.disconnect()
for (const tabId of tabs) {
store.getState().closeTab(tabId)
}
return { samples, longtasks }
},
{ worktreeId, switches, shell }
),
scenarioTimeoutMs
)
}
async function runWorkspaceSwitchScenario(page, worktreeIds, cycles) {
const samples = []
for (let index = 0; index < cycles; index++) {
const worktreeId = worktreeIds[index % worktreeIds.length]
const t0 = Date.now()
await clickWorktreeCard(page, worktreeId)
await pollUntil(
`workspace activation ${worktreeId}`,
() =>
page.evaluate((id) => {
const state = window.__store?.getState?.()
return state?.activeWorktreeId === id && state.activeTabType === 'terminal'
}, worktreeId),
Boolean,
30_000,
10
)
const activationMs = Date.now() - t0
const pty = await pollUntil(
`workspace pty ${worktreeId}`,
() =>
page.evaluate((id) => {
const state = window.__store?.getState?.()
const tabId =
state?.activeWorktreeId === id && state.activeTabType === 'terminal'
? state.activeTabId
: (state?.activeTabIdByWorktree?.[id] ?? null)
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null
return pane?.container?.dataset?.ptyId ?? null
}, worktreeId),
Boolean,
30_000,
10
)
const ptyBindMs = Date.now() - t0
await runWithTimeout(
'paint settle',
() =>
page.evaluate(
() =>
new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
),
rendererActionTimeoutMs
)
const paintSettleMs = Date.now() - t0
samples.push({ index, worktreeId, ptyId: pty, activationMs, ptyBindMs, paintSettleMs })
}
return { samples }
}
function summarize(values) {
const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b)
if (sorted.length === 0) {
return null
}
const at = (fraction) =>
sorted[Math.min(sorted.length - 1, Math.round(fraction * (sorted.length - 1)))]
return {
count: sorted.length,
median: Math.round(at(0.5)),
p95: Math.round(at(0.95)),
max: Math.round(sorted.at(-1))
}
}
function summarizeScenario(name, result) {
if (!result) {
return null
}
const fields = {}
const keys = new Set()
// Why: a timed-out sample carries its timeout ceiling as the measurement,
// which would masquerade as a giant latency in median/p95/max. Timeouts are
// reported through the separate timedOut count instead.
const completedSamples = result.samples.filter((sample) => !sample.timedOut)
for (const sample of completedSamples) {
for (const [key, value] of Object.entries(sample)) {
if (typeof value === 'number' && key !== 'index') {
keys.add(key)
}
}
}
for (const key of keys) {
fields[key] = summarize(completedSamples.map((sample) => sample[key]))
}
const longtasks = result.longtasks ?? []
return {
name,
samples: result.samples.length,
timedOut: result.samples.filter((sample) => sample.timedOut).length,
fields,
longtaskCount: longtasks.length,
longtaskMaxMs: longtasks.length ? Math.round(Math.max(...longtasks)) : 0
}
}
async function main() {
const args = parseArgs()
const startedAt = Date.now()
// Why: the report and clocks exist before any setup so a launch/setup
// failure still produces a report and reaches the cleanup below.
const report = {
label: args.label,
startedAt: new Date(startedAt).toISOString(),
platform: `${process.platform} ${os.release()}`,
shell: args.shell ?? 'default',
args: { iterations: args.iterations, switches: args.switches, cycles: args.cycles },
scenarios: {},
summaries: [],
finalDiagnostics: null,
cleanupErrors: []
}
let fixture = null
let userDataDir = null
let launched = null
let browser = null
let page = null
try {
fixture = createLocalRepoFixture()
const cdpPort = await pickFreePort()
userDataDir = createGpuUserDataDirectory('bench')
console.log(`[terminal-perf] fixture=${fixture.baseDir} userData=${userDataDir} cdp=${cdpPort}`)
launched = launchDevApp({ cdpPort, userDataDir })
const connected = await connectToApp(cdpPort)
browser = connected.browser
page = connected.page
await waitForStoreReady(page)
await installRendererProbe(page)
const setup = await setupWorkspaces(page, fixture)
const worktrees = setup.worktrees
if (worktrees.length < 2) {
throw new Error(`Expected >=2 worktrees, got ${worktrees.length}`)
}
const primary = worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0]
await activateWorktree(page, primary.id)
if (args.scenarios.includes('tab-create')) {
console.log(`[terminal-perf] scenario=tab-create iterations=${args.iterations}`)
report.scenarios['tab-create'] = await runTabCreateScenario(
page,
primary.id,
args.iterations,
args.shell
)
}
if (args.scenarios.includes('tab-switch')) {
console.log(`[terminal-perf] scenario=tab-switch switches=${args.switches}`)
report.scenarios['tab-switch'] = await runTabSwitchScenario(
page,
primary.id,
args.switches,
args.shell
)
}
if (args.scenarios.includes('workspace-switch')) {
console.log(`[terminal-perf] scenario=workspace-switch cycles=${args.cycles}`)
const targets = worktrees.slice(0, 3).map((worktree) => worktree.id)
report.scenarios['workspace-switch'] = await runWorkspaceSwitchScenario(
page,
targets,
args.cycles
)
}
report.finalDiagnostics = await collectRendererDiagnostics(page)
} finally {
report.elapsedMs = Date.now() - startedAt
for (const [name, result] of Object.entries(report.scenarios)) {
const summary = summarizeScenario(name, result)
if (summary) {
report.summaries.push(summary)
}
}
if (browser) {
await browser.close().catch(() => undefined)
}
if (launched) {
// Why: a shutdown failure must not abort the rest of teardown — the
// report still gets written and temp dirs still get removed.
try {
await stopDevApp(launched.child)
} catch (error) {
report.cleanupErrors.push(error instanceof Error ? error.message : String(error))
}
report.appLogsTail = launched.logs.slice(-80)
// Main-process phase attribution (requires ORCA_PTY_SPAWN_TIMING=1 in the
// benchmark's environment; launchDevApp inherits it into the app).
report.ptySpawnTimings = launched.logs
.filter((entry) => entry.line.includes('[pty-spawn-timing]'))
.map((entry) => entry.line.slice(entry.line.indexOf('[pty-spawn-timing]')))
}
if (!args.keep) {
if (fixture) {
safeRemoveLocalDirectory(fixture.baseDir, report.cleanupErrors)
}
if (userDataDir) {
safeRemoveLocalDirectory(userDataDir, report.cleanupErrors)
}
}
const stamp = new Date(startedAt).toISOString().replace(/[:.]/g, '-')
const reportPath = path.resolve(
args.reportPath ??
path.join(
rootDir,
'tools',
'benchmarks',
'results',
`terminal-perf-${args.label}-${stamp}.json`
)
)
mkdirSync(path.dirname(reportPath), { recursive: true })
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`)
console.log(`[terminal-perf] report=${reportPath}`)
for (const summary of report.summaries) {
console.log(
`[terminal-perf] ${summary.name}: samples=${summary.samples} timedOut=${summary.timedOut} longtasks=${summary.longtaskCount} (max ${summary.longtaskMaxMs}ms)`
)
for (const [field, stats] of Object.entries(summary.fields)) {
if (stats) {
console.log(
`[terminal-perf] ${field}: median=${stats.median}ms p95=${stats.p95}ms max=${stats.max}ms`
)
}
}
}
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error))
process.exit(1)
})