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