Add renderer perf metrics to diagnostics and a one-click performance debug dump

Diagnostic bundles (Settings > Privacy and crash reports) now embed a
redacted, aggregate renderer-perf record: DOM node/listener counts, JS
heap, per-worktree mounted terminal pane and scrollback counts, browser
pane counts, and registry sizes, collected only at report time with
timeout-guarded degradation. A new Settings > Privacy action captures a
10s Chromium trace plus a renderer heap snapshot into one local tar.gz
behind an explicit consent dialog.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-07-03 17:29:50 -07:00
co-authored by Orca
parent 02dfb4b334
commit 9fadc8deeb
34 changed files with 2596 additions and 29 deletions
+2 -2
View File
@@ -647,10 +647,10 @@ describe('CdpWsProxy', () => {
})
it('does not register a PDF stream when the client disconnects mid-print', async () => {
let resolvePrint: (buf: Buffer) => void = () => {}
let resolvePrint: (buf: Buffer<ArrayBuffer>) => void = () => {}
mock.webContents.printToPDF.mockImplementationOnce(
() =>
new Promise<Buffer>((resolve) => {
new Promise<Buffer<ArrayBuffer>>((resolve) => {
resolvePrint = resolve
})
)
+10 -4
View File
@@ -22,6 +22,8 @@ import {
} from '../crash-reporting/crash-breadcrumb-store'
import { collectDiagnosticBundle, getDiagnosticsStatus } from '../observability'
import { resolveDiagnosticOrcaChannel } from '../observability/diagnostic-upload-endpoint'
import { collectRendererPerfMetrics } from '../observability/renderer-perf'
import { getDiagnosticsRendererWebContents } from './diagnostics'
import { startSpan } from '../observability/tracer'
import type { FeedbackDiagnosticBundleAttachment } from './feedback'
import {
@@ -334,7 +336,7 @@ function skippedCrashDiagnosticBundle(): CrashDiagnosticBundleAttachment {
}
}
function collectCrashDiagnosticBundleAttachment(): CrashDiagnosticBundleAttachment {
async function collectCrashDiagnosticBundleAttachment(): Promise<CrashDiagnosticBundleAttachment> {
const status = getDiagnosticsStatus()
if (!status.bundleEnabled) {
return {
@@ -345,6 +347,9 @@ function collectCrashDiagnosticBundleAttachment(): CrashDiagnosticBundleAttachme
}
}
const perfRecord = await collectRendererPerfMetrics(getDiagnosticsRendererWebContents, {
timeoutMs: 750
})
let bundle: ReturnType<typeof collectDiagnosticBundle>
try {
bundle = collectDiagnosticBundle({
@@ -355,7 +360,8 @@ function collectCrashDiagnosticBundleAttachment(): CrashDiagnosticBundleAttachme
orcaChannel: resolveDiagnosticOrcaChannel(),
// Why: Help > Report Crash is often used after relaunch, long after the
// default 30 minute support bundle window would miss the failure context.
lookbackMinutes: CRASH_REPORT_LOG_LOOKBACK_MINUTES
lookbackMinutes: CRASH_REPORT_LOG_LOOKBACK_MINUTES,
extraRecords: [perfRecord]
})
} catch (error) {
return { diagnosticBundle: { status: 'not_uploaded', reason: formatUnknownError(error) } }
@@ -451,7 +457,7 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
const diagnosticUpload =
args.includeDiagnosticLogs === false
? skippedCrashDiagnosticBundle()
: collectCrashDiagnosticBundleAttachment()
: await collectCrashDiagnosticBundleAttachment()
const diagnosticBundle = diagnosticUpload.diagnosticBundle
const result = await submitFeedback({
feedback: buildUncapturedCrashReportText(args.notes, diagnosticBundle),
@@ -494,7 +500,7 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
const diagnosticUpload =
args.includeDiagnosticLogs === false
? skippedCrashDiagnosticBundle()
: collectCrashDiagnosticBundleAttachment()
: await collectCrashDiagnosticBundleAttachment()
const diagnosticBundle = diagnosticUpload.diagnosticBundle
const result = await submitFeedback({
feedback: formatCrashReportText(report, args.notes, diagnosticBundle),
+1 -1
View File
@@ -303,7 +303,7 @@ describe('diagnostics IPC handlers', () => {
collectDiagnosticBundleMock.mockReturnValue(bundle)
const collect = handlers.get('diagnostics:collectBundle')!
expect(collect({}, 30)).toEqual({
expect(await collect({}, 30)).toEqual({
bundleSubmissionId: bundle.bundleSubmissionId,
bytes: bundle.bytes,
spanCount: bundle.spanCount
+43 -3
View File
@@ -1,5 +1,5 @@
// IPC surface for the error-tracking lane (telemetry-error-tracking.md
// §User controls). Six renderer-facing channels:
// §User controls). Renderer-facing channels:
//
// diagnostics:getStatus — read-only snapshot for the Privacy pane.
// diagnostics:collectBundle — assemble and retain a redacted payload.
@@ -7,6 +7,7 @@
// diagnostics:discardBundlePreview — delete a retained, unuploaded payload.
// diagnostics:uploadBundle — POST the main-retained payload.
// diagnostics:deleteBundle — delete an uploaded bundle by ticket ID.
// diagnostics:capturePerfDump — local renderer perf trace + heap dump.
//
// Same threat model as the product-telemetry IPC (`ipc/telemetry.ts`):
// renderer can pass anything over the wire, type-narrow here. Everything
@@ -18,11 +19,19 @@
// renderer triggers the flow; main reads the URL from a build-time
// constant or env var and does the POST itself.
import { app, dialog, ipcMain, shell } from 'electron'
import {
app,
dialog,
ipcMain,
shell,
webContents as electronWebContents,
type WebContents
} from 'electron'
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'
import { arch as osArch, platform as osPlatform, release as osRelease } from 'node:os'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { captureRendererPerfDump, type PerfDumpProgressStage } from '../observability/perf-dump'
import {
collectDiagnosticBundle,
deleteDiagnosticBundle,
@@ -36,12 +45,14 @@ import {
resolveDiagnosticOrcaChannel,
resolveDiagnosticTokenEndpoint
} from '../observability/diagnostic-upload-endpoint'
import { collectRendererPerfMetrics } from '../observability/renderer-perf'
export type DiagnosticsBundlePreview = Omit<CollectedBundle, 'payload'>
type UploadBundleIpcResult = UploadBundleResult | { canceled: true }
const PENDING_BUNDLE_TTL_MS = 15 * 60 * 1000
const MAX_PENDING_BUNDLES = 8
let diagnosticsRendererWebContentsId: number | null = null
type PendingBundle = {
bundle: CollectedBundle
@@ -53,6 +64,10 @@ type PendingBundle = {
const pendingBundles = new Map<string, PendingBundle>()
export function setDiagnosticsRendererWebContentsId(webContentsId: number | null): void {
diagnosticsRendererWebContentsId = webContentsId
}
function prunePendingBundles(now = Date.now()): void {
for (const [id, pending] of pendingBundles) {
if (now - pending.createdAtMs > PENDING_BUNDLE_TTL_MS) {
@@ -190,6 +205,13 @@ function isTicketId(value: unknown): value is string {
return typeof value === 'string' && /^[A-Za-z0-9_-]{16,64}$/.test(value)
}
export function getDiagnosticsRendererWebContents(): WebContents | null {
if (diagnosticsRendererWebContentsId == null) {
return null
}
return electronWebContents.fromId(diagnosticsRendererWebContentsId) ?? null
}
async function confirmBundleUpload(bundle: CollectedBundle): Promise<boolean> {
const result = await dialog.showMessageBox({
type: 'question',
@@ -212,7 +234,7 @@ export function registerDiagnosticsHandlers(): void {
ipcMain.handle(
'diagnostics:collectBundle',
(_event, lookbackMinutesIn: unknown): DiagnosticsBundlePreview => {
async (_event, lookbackMinutesIn: unknown): Promise<DiagnosticsBundlePreview> => {
// Consent gate: main is the consent enforcement boundary; the
// renderer-side button-hide is UX, not security. A compromised or
// malicious renderer must not be able to assemble a bundle when the
@@ -228,12 +250,14 @@ export function registerDiagnosticsHandlers(): void {
typeof lookbackMinutesIn === 'number' && Number.isFinite(lookbackMinutesIn)
? Math.max(1, Math.min(30 * 24 * 60, Math.floor(lookbackMinutesIn)))
: undefined
const perfRecord = await collectRendererPerfMetrics(getDiagnosticsRendererWebContents)
const bundle = collectDiagnosticBundle({
appVersion: app.getVersion(),
platform: osPlatform(),
arch: osArch(),
osRelease: osRelease(),
orcaChannel: resolveDiagnosticOrcaChannel(),
extraRecords: [perfRecord],
...(lookbackMinutes !== undefined ? { lookbackMinutes } : {})
})
rememberBundle(bundle)
@@ -241,6 +265,22 @@ export function registerDiagnosticsHandlers(): void {
}
)
ipcMain.handle('diagnostics:capturePerfDump', async (event): Promise<unknown> => {
const status = getDiagnosticsStatus()
if (!status.perfDumpEnabled) {
throw new Error('performance debug dumps are disabled')
}
const sender = event.sender
return captureRendererPerfDump({
getRendererWebContents: getDiagnosticsRendererWebContents,
onProgress: (stage: PerfDumpProgressStage) => {
if (!sender.isDestroyed()) {
sender.send('diagnostics:perfDump:progress', { stage })
}
}
})
})
ipcMain.handle(
'diagnostics:uploadBundle',
async (_event, bundleSubmissionId: unknown): Promise<UploadBundleIpcResult> => {
+5 -1
View File
@@ -20,6 +20,7 @@ const {
registerKeybindingHandlersMock,
registerTelemetryHandlersMock,
registerDiagnosticsHandlersMock,
setDiagnosticsRendererWebContentsIdMock,
registerShellHandlersMock,
registerPetHandlersMock,
registerSessionHandlersMock,
@@ -75,6 +76,7 @@ const {
registerKeybindingHandlersMock: vi.fn(),
registerTelemetryHandlersMock: vi.fn(),
registerDiagnosticsHandlersMock: vi.fn(),
setDiagnosticsRendererWebContentsIdMock: vi.fn(),
registerShellHandlersMock: vi.fn(),
registerPetHandlersMock: vi.fn(),
registerSessionHandlersMock: vi.fn(),
@@ -207,7 +209,8 @@ vi.mock('./telemetry', () => ({
}))
vi.mock('./diagnostics', () => ({
registerDiagnosticsHandlers: registerDiagnosticsHandlersMock
registerDiagnosticsHandlers: registerDiagnosticsHandlersMock,
setDiagnosticsRendererWebContentsId: setDiagnosticsRendererWebContentsIdMock
}))
vi.mock('./shell', () => ({
@@ -339,6 +342,7 @@ describe('registerCoreHandlers', () => {
registerKeybindingHandlersMock.mockReset()
registerTelemetryHandlersMock.mockReset()
registerDiagnosticsHandlersMock.mockReset()
setDiagnosticsRendererWebContentsIdMock.mockReset()
registerShellHandlersMock.mockReset()
registerPetHandlersMock.mockReset()
registerSessionHandlersMock.mockReset()
+2 -1
View File
@@ -34,7 +34,7 @@ import { registerComputerUsePermissionHandlers } from './computer-use-permission
import { setTrustedBrowserRendererWebContentsId, setAgentBrowserBridgeRef } from './browser'
import { registerSessionHandlers } from './session'
import { registerSettingsHandlers } from './settings'
import { registerDiagnosticsHandlers } from './diagnostics'
import { registerDiagnosticsHandlers, setDiagnosticsRendererWebContentsId } from './diagnostics'
import { registerSkillsHandlers } from './skills'
import { registerWorkspaceSpaceHandlers } from './workspace-space'
import { registerWorkspacePortHandlers } from './workspace-ports'
@@ -101,6 +101,7 @@ export function registerCoreHandlers(
setTrustedBrowserRendererWebContentsId(mainWindowWebContentsId)
setTrustedClipboardRendererWebContentsId(mainWindowWebContentsId)
setTrustedUIRendererWebContentsId(mainWindowWebContentsId)
setDiagnosticsRendererWebContentsId(mainWindowWebContentsId)
setAgentBrowserBridgeRef(runtime.getAgentBrowserBridge())
if (registered) {
return
+56
View File
@@ -199,6 +199,62 @@ describe('bundle — collection', () => {
expect(bundle.payload).toContain('"keep":"ok"')
})
it('emits redacted extra records immediately after the header', () => {
writeFileSync(traceFile, makeNDJSON([makeSpan({ name: 'span-after-extra' })]))
const bundle = collectBundle({
traceFilePath: traceFile,
maxFiles: 10,
appVersion: '1',
platform: 'darwin',
arch: 'arm64',
osRelease: '24',
orcaChannel: 'dev',
extraRecords: [
{
type: 'renderer-perf',
leaked: `sk-ant-api03-${'a'.repeat(50)}`
}
]
})
const lines = bundle.payload
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line))
expect(lines[0].type).toBe('bundle-header')
expect(lines[1].type).toBe('renderer-perf')
expect(JSON.stringify(lines[1])).not.toContain('sk-ant-api03-')
expect(JSON.stringify(lines[1])).toContain('[redacted:anthropic-key]')
expect(lines[2].name).toBe('span-after-extra')
})
it('truncates oversized extra records inside the fixed extra-record budget', () => {
writeFileSync(traceFile, makeNDJSON([makeSpan({ name: 'kept' })]))
const bundle = collectBundle({
traceFilePath: traceFile,
maxFiles: 10,
appVersion: '1',
platform: 'darwin',
arch: 'arm64',
osRelease: '24',
orcaChannel: 'dev',
extraRecords: [
{
type: 'renderer-perf',
payload: 'x'.repeat(_internalsForTests.MAX_EXTRA_RECORD_BYTES * 2)
}
]
})
const lines = bundle.payload
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line))
expect(lines[1].type).toBe('renderer-perf')
expect(lines[1].truncated).toBe(true)
expect(lines[1].payload.length).toBeLessThan(_internalsForTests.MAX_EXTRA_RECORD_BYTES)
expect(bundle.bytes).toBeLessThanOrEqual(_internalsForTests.MAX_BUNDLE_BYTES)
expect(bundle.payload).toContain('"name":"kept"')
})
it('does not append a span that would push the payload over the upload cap', () => {
const giantSpan = makeSpan({
attributes: {
+64 -1
View File
@@ -40,6 +40,7 @@ export type CollectBundleOptions = {
readonly traceFilePath: string
readonly maxFiles: number
readonly lookbackMinutes?: number
readonly extraRecords?: readonly Record<string, unknown>[]
readonly appVersion: string
readonly platform: string
readonly arch: string
@@ -70,6 +71,8 @@ type BundleHeader = {
readonly schema_version: 1
}
const MAX_EXTRA_RECORD_BYTES = 64 * 1024
function* readLinesNewestFirst(text: string): Iterable<string> {
let end = text.length
while (end > 0) {
@@ -116,6 +119,15 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle {
// Avoids re-running `lines.join('\n').length` every iteration — that's
// O(N²) in span count and dominates collection time for large backlogs.
let currentBytes = Buffer.byteLength(`${headerLine}\n`)
for (const extraRecord of opts.extraRecords ?? []) {
const line = serializeExtraRecord(extraRecord, MAX_BUNDLE_BYTES - currentBytes)
if (!line) {
continue
}
lines.push(line)
currentBytes += Buffer.byteLength(line) + 1
}
const maxRecordBytes = MAX_BUNDLE_BYTES - currentBytes
// Files from listRotatedFiles are newest → oldest. Reading newest first
@@ -219,5 +231,56 @@ export function generateBundleSubmissionId(): string {
// Test-only export.
export const _internalsForTests = {
MAX_BUNDLE_BYTES
MAX_BUNDLE_BYTES,
MAX_EXTRA_RECORD_BYTES
}
function serializeExtraRecord(
record: Record<string, unknown>,
remainingBytes: number
): string | null {
const budget = Math.min(MAX_EXTRA_RECORD_BYTES, Math.max(0, remainingBytes))
if (budget <= 0) {
return null
}
const redacted = redactValue(record, 'server')
const serialized = JSON.stringify(redacted)
if (Buffer.byteLength(serialized) + 1 <= budget) {
return serialized
}
const truncated = JSON.stringify(redactValue(markExtraRecordTruncated(record), 'server'))
if (Buffer.byteLength(truncated) + 1 <= budget) {
return truncated
}
const minimal = JSON.stringify({
type: typeof record.type === 'string' ? record.type : 'extra-record',
unavailableReason: 'record-too-large'
})
return Buffer.byteLength(minimal) + 1 <= budget ? minimal : null
}
function markExtraRecordTruncated(value: unknown, depth = 0): unknown {
// Why: extra records come from trusted main-side callers, but a depth cap
// keeps a mis-shaped record from recursing unboundedly here.
if (depth >= 8) {
return '[truncated]'
}
if (Array.isArray(value)) {
return value.slice(0, 10).map((child) => markExtraRecordTruncated(child, depth + 1))
}
if (typeof value === 'string') {
return value.length > 1024 ? value.slice(0, 1024) : value
}
if (typeof value !== 'object' || value === null) {
return value
}
const result: Record<string, unknown> = { truncated: true }
for (const [key, child] of Object.entries(value)) {
result[key] = markExtraRecordTruncated(child, depth + 1)
}
return result
}
+9 -1
View File
@@ -198,6 +198,7 @@ export function getObservabilityConsent(): ObservabilityConsent | null {
export type DiagnosticsStatus = {
readonly localFileEnabled: boolean
readonly bundleEnabled: boolean
readonly perfDumpEnabled: boolean
readonly traceFilePath: string
readonly traceFamilySize: number
readonly disabledReason?: ObservabilityConsent['disabledReason']
@@ -210,6 +211,7 @@ export function getDiagnosticsStatus(): DiagnosticsStatus {
return {
localFileEnabled: c.localFileEnabled,
bundleEnabled: c.bundleEnabled,
perfDumpEnabled: c.disabledReason !== 'ci' && c.disabledReason !== 'orca_diagnostics_disabled',
traceFilePath,
traceFamilySize,
...(c.disabledReason ? { disabledReason: c.disabledReason } : {})
@@ -224,7 +226,13 @@ export function getDiagnosticsStatus(): DiagnosticsStatus {
export function collectDiagnosticBundle(
meta: Pick<
CollectBundleOptions,
'appVersion' | 'platform' | 'arch' | 'osRelease' | 'orcaChannel' | 'lookbackMinutes'
| 'appVersion'
| 'platform'
| 'arch'
| 'osRelease'
| 'orcaChannel'
| 'lookbackMinutes'
| 'extraRecords'
>
): CollectedBundle {
// Flush the active sink first so the very latest spans are present in the
+167
View File
@@ -0,0 +1,167 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { gunzipSync } from 'node:zlib'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { showMessageBoxMock, startRecordingMock, stopRecordingMock, getPathMock } = vi.hoisted(
() => ({
showMessageBoxMock: vi.fn(),
startRecordingMock: vi.fn(),
stopRecordingMock: vi.fn(),
getPathMock: vi.fn()
})
)
vi.mock('electron', () => ({
app: { getPath: getPathMock, getVersion: () => '1.2.3-test' },
dialog: { showMessageBox: showMessageBoxMock },
contentTracing: { startRecording: startRecordingMock, stopRecording: stopRecordingMock }
}))
// Why: the trace stage waits 10 s in production; tests must not.
vi.mock('node:timers/promises', () => ({ setTimeout: async () => {} }))
vi.mock('./renderer-perf', () => ({
collectRendererPerfMetrics: vi.fn(async () => ({
type: 'renderer-perf',
schema_version: 1,
collected_at: '2026-01-01T00:00:00.000Z'
}))
}))
vi.mock('../i18n/main-i18n', () => ({
translateMain: (_key: string, fallback: string) => fallback
}))
import { captureRendererPerfDump } from './perf-dump'
let tempRoot: string
let downloadsDir: string
function makeRenderer(overrides: Record<string, unknown> = {}): unknown {
return {
isDestroyed: () => false,
takeHeapSnapshot: vi.fn(async (filePath: string) => {
await writeFile(filePath, '{"snapshot":true}', 'utf8')
}),
...overrides
}
}
function readTarEntries(archive: Buffer): Map<string, string> {
const tar = gunzipSync(archive)
const entries = new Map<string, string>()
let offset = 0
while (offset + 512 <= tar.length) {
const name = tar
.subarray(offset, offset + 100)
.toString('utf8')
.split('\0')[0]
if (!name) {
break
}
const size = Number.parseInt(tar.subarray(offset + 124, offset + 136).toString('ascii'), 8)
entries.set(name, tar.subarray(offset + 512, offset + 512 + size).toString('utf8'))
offset += 512 + Math.ceil(size / 512) * 512
}
return entries
}
describe('captureRendererPerfDump', () => {
beforeEach(async () => {
tempRoot = mkdtempSync(join(tmpdir(), 'orca-perf-dump-test-'))
downloadsDir = join(tempRoot, 'downloads')
await mkdir(downloadsDir, { recursive: true })
getPathMock.mockImplementation((name: string) =>
name === 'downloads' ? downloadsDir : join(tempRoot, name)
)
showMessageBoxMock.mockResolvedValue({ response: 0 })
startRecordingMock.mockResolvedValue(undefined)
stopRecordingMock.mockImplementation(async (filePath: string) => {
await writeFile(filePath, '{"traceEvents":[]}', 'utf8')
return filePath
})
})
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true })
vi.clearAllMocks()
})
it('produces a tar.gz containing metadata, metrics, trace, and heap snapshot', async () => {
const result = await captureRendererPerfDump({
getRendererWebContents: () => makeRenderer() as never
})
expect(result).not.toHaveProperty('canceled')
const { filePath, bytes } = result as { filePath: string; bytes: number }
expect(bytes).toBeGreaterThan(0)
const names = [...readTarEntries(await readFile(filePath)).keys()]
expect(names).toEqual([
'metadata.json',
'renderer-perf-metrics.json',
'trace.json',
'renderer-heap.heapsnapshot'
])
// Temp capture directory is removed after packaging.
expect(await readdir(join(tempRoot, 'temp', 'orca-perf-dumps'))).toEqual([])
})
it('returns canceled without touching tracing when the consent dialog is declined', async () => {
showMessageBoxMock.mockResolvedValue({ response: 1 })
const result = await captureRendererPerfDump({
getRendererWebContents: () => makeRenderer() as never
})
expect(result).toEqual({ canceled: true })
expect(startRecordingMock).not.toHaveBeenCalled()
})
it('coalesces concurrent captures onto one consent dialog and one capture', async () => {
const renderer = makeRenderer()
const [first, second] = await Promise.all([
captureRendererPerfDump({ getRendererWebContents: () => renderer as never }),
captureRendererPerfDump({ getRendererWebContents: () => renderer as never })
])
expect(showMessageBoxMock).toHaveBeenCalledTimes(1)
expect(startRecordingMock).toHaveBeenCalledTimes(1)
expect(first).toEqual(second)
})
it('still produces a dump with failure notes when trace and heap capture fail', async () => {
startRecordingMock.mockRejectedValue(new Error('tracing busy'))
const renderer = makeRenderer({
takeHeapSnapshot: vi.fn(async () => {
throw new Error('snapshot failed')
})
})
const result = await captureRendererPerfDump({
getRendererWebContents: () => renderer as never
})
const { filePath } = result as { filePath: string }
const entries = readTarEntries(await readFile(filePath))
expect([...entries.keys()]).toEqual(['metadata.json', 'renderer-perf-metrics.json'])
const metadata = JSON.parse(entries.get('metadata.json')!) as {
artifacts: Record<string, { status: string; reason?: string }>
}
expect(metadata.artifacts.trace.status).toBe('failed')
expect(metadata.artifacts.trace.reason).toContain('tracing busy')
expect(metadata.artifacts.heap.status).toBe('failed')
expect(metadata.artifacts.heap.reason).toContain('snapshot failed')
})
it('reports progress stages in order', async () => {
const stages: string[] = []
await captureRendererPerfDump({
getRendererWebContents: () => makeRenderer() as never,
onProgress: (stage) => stages.push(stage)
})
expect(stages).toEqual(['metrics', 'trace', 'heap', 'compressing'])
})
})
+321
View File
@@ -0,0 +1,321 @@
import { app, contentTracing, dialog, type WebContents } from 'electron'
import { randomUUID } from 'node:crypto'
import { mkdir, rm, stat, writeFile } from 'node:fs/promises'
import { arch, platform, release } from 'node:os'
import { join } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
import { collectRendererPerfMetrics } from './renderer-perf'
import { createTarGzipArchive, type TarArchiveEntry } from './tar-archive'
import { resolveDiagnosticOrcaChannel } from './diagnostic-upload-endpoint'
import { translateMain } from '../i18n/main-i18n'
export type PerfDumpProgressStage = 'metrics' | 'trace' | 'heap' | 'compressing'
export type CapturePerfDumpResult =
| { readonly canceled: true }
| { readonly filePath: string; readonly bytes: number }
type CapturePerfDumpOptions = {
readonly getRendererWebContents: () => WebContents | null
readonly onProgress?: (stage: PerfDumpProgressStage) => void
}
type ArtifactStatus = {
readonly status: 'included' | 'omitted' | 'failed'
readonly fileName?: string
readonly bytes?: number
readonly reason?: string
}
const TRACE_DURATION_MS = 10_000
const HEAP_SNAPSHOT_TIMEOUT_MS = 120_000
const MAX_TAR_ENTRY_BYTES = 0o77777777777
const TRACE_CATEGORIES = [
'devtools.timeline',
'disabled-by-default-devtools.timeline',
'disabled-by-default-devtools.timeline.frame',
'disabled-by-default-v8.cpu_profiler',
'toplevel',
'v8.execute',
'blink.user_timing',
'latencyInfo'
] as const
let inFlightCapture: Promise<CapturePerfDumpResult> | null = null
export async function captureRendererPerfDump(
opts: CapturePerfDumpOptions
): Promise<CapturePerfDumpResult> {
if (inFlightCapture) {
return inFlightCapture
}
// Why: the consent dialog is part of the single-flight window — otherwise a
// second trigger while the dialog is open would stack a second dialog.
inFlightCapture = (async () => {
const confirmed = await confirmPerfDumpCapture()
if (!confirmed) {
return { canceled: true } as const
}
return captureRendererPerfDumpInternal(opts)
})().finally(() => {
inFlightCapture = null
})
return inFlightCapture
}
async function confirmPerfDumpCapture(): Promise<boolean> {
const result = await dialog.showMessageBox({
type: 'warning',
buttons: [
translateMain('auto.main.observability.perfDump.e6735719a0', 'Capture'),
translateMain('auto.main.observability.perfDump.8d1d1d5725', 'Cancel')
],
defaultId: 1,
cancelId: 1,
title: translateMain(
'auto.main.observability.perfDump.71ec14a301',
'Capture performance debug dump?'
),
message: translateMain(
'auto.main.observability.perfDump.6d969f0773',
'This saves a local performance debug dump for support.'
),
detail: translateMain(
'auto.main.observability.perfDump.2551f622fd',
'Orca will record about 10 seconds of app activity and capture a memory snapshot of the interface. The dump may include terminal text, file paths, and page titles. It is saved to your computer only; nothing is uploaded.'
)
})
return result.response === 0
}
async function captureRendererPerfDumpInternal({
getRendererWebContents,
onProgress
}: CapturePerfDumpOptions): Promise<CapturePerfDumpResult> {
const captureId = randomUUID()
const tempRoot = join(app.getPath('temp'), 'orca-perf-dumps')
const captureDir = join(tempRoot, captureId)
const outputPath = await chooseOutputPath()
const startedAt = new Date().toISOString()
const artifacts: Record<string, ArtifactStatus> = {}
const entries: TarArchiveEntry[] = []
try {
await mkdir(captureDir, { recursive: true, mode: 0o700 })
onProgress?.('metrics')
await captureMetricsArtifact(captureDir, getRendererWebContents, artifacts, entries)
onProgress?.('trace')
await captureTraceArtifact(captureDir, artifacts, entries)
onProgress?.('heap')
await captureHeapArtifact(captureDir, getRendererWebContents, artifacts, entries)
onProgress?.('compressing')
// Why: filter before writing metadata so oversized-artifact skip notes
// land inside the archived metadata.json.
const packableEntries = await filterPackableEntries(entries, artifacts)
const metadataPath = join(captureDir, 'metadata.json')
await writeFile(
metadataPath,
`${JSON.stringify(
{
schema_version: 1,
capture_id: captureId,
app_version: app.getVersion(),
platform: platform(),
arch: arch(),
os_release: release(),
orca_channel: resolveDiagnosticOrcaChannel(),
started_at: startedAt,
finished_at: new Date().toISOString(),
artifacts
},
null,
2
)}\n`,
{ encoding: 'utf8', mode: 0o600 }
)
packableEntries.unshift({ name: 'metadata.json', filePath: metadataPath })
await createTarGzipArchive(outputPath, packableEntries)
const outputInfo = await stat(outputPath)
return { filePath: outputPath, bytes: outputInfo.size }
} catch (error) {
try {
await rm(outputPath, { force: true })
} catch {
// Why: cleanup failure must not mask the original capture error.
}
throw error
} finally {
await cleanupCaptureDir(captureDir)
}
}
async function cleanupCaptureDir(captureDir: string): Promise<void> {
try {
await rm(captureDir, { recursive: true, force: true })
} catch {
// Why: on Windows a timed-out heap snapshot can still hold files in the
// capture dir; retry once after it settles instead of failing the dump.
void heapSnapshotInFlight?.then(async () => {
try {
await rm(captureDir, { recursive: true, force: true })
} catch {
/* best effort */
}
})
}
}
async function captureMetricsArtifact(
captureDir: string,
getRendererWebContents: () => WebContents | null,
artifacts: Record<string, ArtifactStatus>,
entries: TarArchiveEntry[]
): Promise<void> {
const fileName = 'renderer-perf-metrics.json'
const filePath = join(captureDir, fileName)
try {
// Why: the dump stays local behind explicit consent, so it may keep the
// folder-basename labels that the uploadable bundle anonymizes.
const metrics = await collectRendererPerfMetrics(getRendererWebContents, {
labelMode: 'named'
})
await writeFile(filePath, `${JSON.stringify(metrics, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600
})
const info = await stat(filePath)
artifacts.metrics = { status: 'included', fileName, bytes: info.size }
entries.push({ name: fileName, filePath })
} catch (error) {
artifacts.metrics = { status: 'failed', reason: formatReason(error) }
}
}
async function captureTraceArtifact(
captureDir: string,
artifacts: Record<string, ArtifactStatus>,
entries: TarArchiveEntry[]
): Promise<void> {
const fileName = 'trace.json'
const tracePath = join(captureDir, fileName)
let started = false
try {
await contentTracing.startRecording({
recording_mode: 'record-until-full',
included_categories: [...TRACE_CATEGORIES]
})
started = true
await delay(TRACE_DURATION_MS)
const returnedTracePath = await contentTracing.stopRecording(tracePath)
const artifactPath = returnedTracePath || tracePath
const info = await stat(artifactPath)
artifacts.trace = { status: 'included', fileName, bytes: info.size }
entries.push({ name: fileName, filePath: artifactPath })
} catch (error) {
if (started) {
try {
await contentTracing.stopRecording()
} catch {
/* best effort */
}
}
artifacts.trace = { status: 'failed', reason: formatReason(error) }
}
}
let heapSnapshotInFlight: Promise<void> | null = null
async function captureHeapArtifact(
captureDir: string,
getRendererWebContents: () => WebContents | null,
artifacts: Record<string, ArtifactStatus>,
entries: TarArchiveEntry[]
): Promise<void> {
const fileName = 'renderer-heap.heapsnapshot'
const filePath = join(captureDir, fileName)
const renderer = getRendererWebContents()
if (!renderer || renderer.isDestroyed()) {
artifacts.heap = { status: 'omitted', reason: 'renderer unavailable' }
return
}
// Why: a timed-out takeHeapSnapshot keeps running in the renderer; starting
// a second one on top of it would stack heap-serialization pauses.
if (heapSnapshotInFlight) {
artifacts.heap = { status: 'omitted', reason: 'previous heap snapshot still running' }
return
}
try {
const snapshot = renderer.takeHeapSnapshot(filePath)
heapSnapshotInFlight = snapshot
.catch(() => {})
.then(() => {
heapSnapshotInFlight = null
})
await withTimeout(snapshot, HEAP_SNAPSHOT_TIMEOUT_MS)
const info = await stat(filePath)
artifacts.heap = { status: 'included', fileName, bytes: info.size }
entries.push({ name: fileName, filePath })
} catch (error) {
artifacts.heap = { status: 'failed', reason: formatReason(error) }
}
}
async function filterPackableEntries(
entries: readonly TarArchiveEntry[],
artifacts: Record<string, ArtifactStatus>
): Promise<TarArchiveEntry[]> {
const kept: TarArchiveEntry[] = []
for (const entry of entries) {
const info = await stat(entry.filePath)
if (info.size > MAX_TAR_ENTRY_BYTES) {
artifacts[`skipped:${entry.name}`] = {
status: 'omitted',
fileName: entry.name,
reason: 'file exceeds tar size limit'
}
continue
}
kept.push(entry)
}
return kept
}
async function chooseOutputPath(): Promise<string> {
const downloads = app.getPath('downloads')
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+$/, '').replace('T', '-')
for (let index = 0; index < 100; index += 1) {
const suffix = index === 0 ? '' : `-${index + 1}`
const candidate = join(downloads, `orca-perf-dump-${stamp}${suffix}.tar.gz`)
try {
await stat(candidate)
} catch {
return candidate
}
}
throw new Error('Could not choose a unique file name in Downloads.')
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timeout: NodeJS.Timeout | null = null
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timeout = setTimeout(() => reject(new Error('timed out')), timeoutMs)
})
])
} finally {
if (timeout) {
clearTimeout(timeout)
}
}
}
function formatReason(error: unknown): string {
return error instanceof Error && error.message ? error.message.slice(0, 200) : 'unavailable'
}
@@ -0,0 +1,203 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
type IpcListener = (event: { sender: { id: number } }, args: unknown) => void
const { ipcOnMock, appMetricsMock } = vi.hoisted(() => ({
ipcOnMock: vi.fn(),
appMetricsMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: { on: ipcOnMock },
app: { getAppMetrics: appMetricsMock }
}))
import { collectRendererPerfMetrics } from './renderer-perf'
type FakeWebContents = {
id: number
isDestroyed: () => boolean
getOSProcessId: () => number
send: ReturnType<typeof vi.fn>
debugger: {
isAttached: () => boolean
attach: (version: string) => void
detach: () => void
sendCommand: ReturnType<typeof vi.fn>
}
}
function makeWebContents(overrides: Partial<FakeWebContents> = {}): FakeWebContents {
let attached = false
return {
id: 7,
isDestroyed: () => false,
getOSProcessId: () => 4242,
send: vi.fn(),
debugger: {
isAttached: () => attached,
attach: vi.fn(() => {
attached = true
}),
detach: vi.fn(() => {
attached = false
}),
sendCommand: vi.fn().mockResolvedValue({ documents: 2, nodes: 1500, jsEventListeners: 320 })
},
...overrides
}
}
function getResponseListener(): IpcListener {
const call = ipcOnMock.mock.calls.find(
([channel]) => channel === 'diagnostics:rendererPerf:response'
)
expect(call).toBeDefined()
return call![1] as IpcListener
}
function extractRequestId(webContents: FakeWebContents): string {
const call = webContents.send.mock.calls.find(
([channel]) => channel === 'diagnostics:rendererPerf:request'
)
expect(call).toBeDefined()
return (call![1] as { requestId: string }).requestId
}
const minimalRendererMetrics = {
domNodeCount: 12,
terminalPanes: {
totalMountedPaneCount: 1,
worktrees: [
{
worktreeLabel: 'my-secret-project',
worktreeKind: 'local',
paneCount: 1,
scrollbackRowsTotal: 42,
panes: [{ cols: 80, rows: 24, normalBufferRows: 42, altBufferRows: 0, hasWebgl: false }]
}
]
},
browserPanes: { browserWebviewCount: 0, registeredBrowserGuestCount: 0 },
registries: {
ptySerializerCount: 0,
livePaneManagerCount: 0,
browserWebviewCount: 0,
registeredBrowserGuestCount: 0
}
}
describe('collectRendererPerfMetrics', () => {
beforeEach(() => {
appMetricsMock.mockReturnValue([{ pid: 4242, memory: { workingSetSize: 2048 } }])
})
it('returns an unavailable record when no renderer webContents exists', async () => {
const record = await collectRendererPerfMetrics(() => null)
expect(record.type).toBe('renderer-perf')
expect(record.unavailableReason).toBe('no-renderer')
})
it('merges renderer metrics with main-side CDP and process-memory counters', async () => {
const webContents = makeWebContents()
const pending = collectRendererPerfMetrics(() => webContents as never, { timeoutMs: 2000 })
await vi.waitFor(() => expect(webContents.send).toHaveBeenCalled())
getResponseListener()(
{ sender: { id: webContents.id } },
{ requestId: extractRequestId(webContents), metrics: minimalRendererMetrics }
)
const record = await pending
expect(record.renderer?.domNodeCount).toBe(12)
expect(record.main?.domCounters).toEqual({ documents: 2, nodes: 1500, jsEventListeners: 320 })
// Electron reports MemoryInfo in KiB; the record must be in bytes.
expect(record.main?.processMemoryBytes?.workingSetSizeBytes).toBe(2048 * 1024)
expect(record.unavailableReason).toBeUndefined()
// Default (bundle) records must not carry user-chosen folder names.
expect(record.renderer?.terminalPanes.worktrees[0]?.worktreeLabel).toBe('worktree-1')
expect(record.renderer?.terminalPanes.worktrees[0]?.paneCount).toBe(1)
})
it('keeps folder-basename labels only in named mode (local perf dump)', async () => {
const webContents = makeWebContents()
const pending = collectRendererPerfMetrics(() => webContents as never, {
timeoutMs: 2000,
labelMode: 'named'
})
await vi.waitFor(() => expect(webContents.send).toHaveBeenCalled())
getResponseListener()(
{ sender: { id: webContents.id } },
{ requestId: extractRequestId(webContents), metrics: minimalRendererMetrics }
)
const record = await pending
expect(record.renderer?.terminalPanes.worktrees[0]?.worktreeLabel).toBe('my-secret-project')
})
it('degrades when the CDP command hangs instead of hanging collection', async () => {
const webContents = makeWebContents()
webContents.debugger.sendCommand = vi.fn(() => new Promise(() => {}))
const pending = collectRendererPerfMetrics(() => webContents as never, { timeoutMs: 25 })
await vi.waitFor(() => expect(webContents.send).toHaveBeenCalled())
const record = await pending
expect(record.main?.domCounters).toBeUndefined()
expect(record.unavailable?.domCounters).toBe('timeout')
expect(webContents.debugger.detach).toHaveBeenCalled()
})
it('ignores responses from an untrusted sender and degrades to timeout', async () => {
const webContents = makeWebContents()
const pending = collectRendererPerfMetrics(() => webContents as never, { timeoutMs: 25 })
await vi.waitFor(() => expect(webContents.send).toHaveBeenCalled())
getResponseListener()(
{ sender: { id: webContents.id + 1 } },
{ requestId: extractRequestId(webContents), metrics: minimalRendererMetrics }
)
const record = await pending
expect(record.renderer).toBeUndefined()
expect(record.unavailable?.renderer).toBe('timeout')
// Main-side counters still ride along when only the renderer times out.
expect(record.main?.domCounters?.nodes).toBe(1500)
})
it('degrades to invalid-response when the renderer payload fails narrowing', async () => {
const webContents = makeWebContents()
const pending = collectRendererPerfMetrics(() => webContents as never, { timeoutMs: 2000 })
await vi.waitFor(() => expect(webContents.send).toHaveBeenCalled())
getResponseListener()(
{ sender: { id: webContents.id } },
{ requestId: extractRequestId(webContents), metrics: 'not-an-object' }
)
const record = await pending
expect(record.renderer).toBeUndefined()
expect(record.unavailable?.renderer).toBe('invalid-response')
})
it('omits CDP counters when the debugger attach fails and releases nothing it does not own', async () => {
const webContents = makeWebContents()
webContents.debugger.attach = vi.fn(() => {
throw new Error('Another debugger is already attached')
})
const pending = collectRendererPerfMetrics(() => webContents as never, { timeoutMs: 25 })
await vi.waitFor(() => expect(webContents.send).toHaveBeenCalled())
const record = await pending
expect(record.main?.domCounters).toBeUndefined()
expect(record.unavailable?.domCounters).toContain('debugger')
expect(webContents.debugger.detach).not.toHaveBeenCalled()
})
it('reports process memory as unavailable when the renderer pid is missing from app metrics', async () => {
appMetricsMock.mockReturnValue([{ pid: 1, memory: { workingSetSize: 10 } }])
const webContents = makeWebContents()
const pending = collectRendererPerfMetrics(() => webContents as never, { timeoutMs: 25 })
await vi.waitFor(() => expect(webContents.send).toHaveBeenCalled())
const record = await pending
expect(record.main?.processMemoryBytes).toBeUndefined()
expect(record.unavailable?.processMemory).toBe('renderer process metric unavailable')
})
})
+287
View File
@@ -0,0 +1,287 @@
import { app, ipcMain, type WebContents } from 'electron'
import { randomUUID } from 'node:crypto'
import { acquireElectronDebugger } from '../browser/electron-debugger-lease'
import {
narrowRendererPerfMetrics,
type RendererPerfDomCounters,
type RendererPerfMetrics,
type RendererPerfProcessMemory,
type RendererPerfRecord,
type RendererPerfUnavailableReason
} from '../../shared/renderer-perf-metrics'
type PendingRendererPerfRequest = {
readonly trustedWebContentsId: number
readonly timeout: NodeJS.Timeout
readonly resolve: (result: RendererPerfResponseResult) => void
}
type CollectRendererPerfMetricsOptions = {
readonly timeoutMs?: number
/** 'anonymous' (default) replaces worktree labels with stable `worktree-N`
* placeholders for records that ride the uploadable diagnostics bundle;
* 'named' keeps folder basenames and is reserved for the local-only
* consent-gated perf dump. */
readonly labelMode?: 'anonymous' | 'named'
}
const DEFAULT_TIMEOUT_MS = 1500
const pendingRequests = new Map<string, PendingRendererPerfRequest>()
let responseListenerInstalled = false
type RendererPerfResponseResult =
| { readonly status: 'ok'; readonly metrics: RendererPerfMetrics }
| { readonly status: 'invalid-response' }
| { readonly status: 'send-failed' }
| { readonly status: 'timeout' }
export async function collectRendererPerfMetrics(
getRendererWebContents: () => WebContents | null,
opts: CollectRendererPerfMetricsOptions = {}
): Promise<RendererPerfRecord> {
const collectedAt = new Date().toISOString()
const webContents = getRendererWebContents()
const unavailable: Record<string, string> = {}
if (!webContents) {
return unavailableRecord(collectedAt, 'no-renderer')
}
if (webContents.isDestroyed()) {
return unavailableRecord(collectedAt, 'renderer-destroyed')
}
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
const [renderer, main] = await Promise.all([
requestRendererMetrics(webContents, timeoutMs),
collectMainMetrics(webContents, timeoutMs)
])
const rendererMetrics = renderer.metrics
? withLabelMode(renderer.metrics, opts.labelMode ?? 'anonymous')
: null
if (renderer.reason) {
unavailable.renderer = renderer.reason
}
if (main.domCountersReason) {
unavailable.domCounters = main.domCountersReason
}
if (main.processMemoryReason) {
unavailable.processMemory = main.processMemoryReason
}
return {
type: 'renderer-perf',
schema_version: 1,
collected_at: collectedAt,
...(rendererMetrics ? { renderer: rendererMetrics } : {}),
...(main.metrics ? { main: main.metrics } : {}),
...(renderer.reason && !rendererMetrics && !main.metrics
? { unavailableReason: renderer.reason }
: {}),
...(Object.keys(unavailable).length > 0 ? { unavailable } : {})
}
}
/** Anonymous mode keeps per-worktree/per-pane counts (the ticket's cheap
* metrics) but strips user-chosen folder names from uploadable records. */
function withLabelMode(
metrics: RendererPerfMetrics,
labelMode: 'anonymous' | 'named'
): RendererPerfMetrics {
if (labelMode === 'named') {
return metrics
}
return {
...metrics,
terminalPanes: {
...metrics.terminalPanes,
worktrees: metrics.terminalPanes.worktrees.map((worktree, index) => ({
...worktree,
worktreeLabel: `worktree-${index + 1}`
}))
}
}
}
function unavailableRecord(
collectedAt: string,
reason: RendererPerfUnavailableReason
): RendererPerfRecord {
return {
type: 'renderer-perf',
schema_version: 1,
collected_at: collectedAt,
unavailableReason: reason,
unavailable: { renderer: reason }
}
}
function ensureResponseListener(): void {
if (responseListenerInstalled) {
return
}
responseListenerInstalled = true
ipcMain.on('diagnostics:rendererPerf:response', (event, args?: unknown) => {
if (!args || typeof args !== 'object') {
return
}
const payload = args as { requestId?: unknown; metrics?: unknown }
if (typeof payload.requestId !== 'string') {
return
}
const pending = pendingRequests.get(payload.requestId)
if (!pending || event.sender.id !== pending.trustedWebContentsId) {
return
}
const metrics = narrowRendererPerfMetrics(payload.metrics)
settleRendererPerfRequest(
payload.requestId,
metrics ? { status: 'ok', metrics } : { status: 'invalid-response' }
)
})
}
function settleRendererPerfRequest(requestId: string, result: RendererPerfResponseResult): void {
const pending = pendingRequests.get(requestId)
if (!pending) {
return
}
clearTimeout(pending.timeout)
pendingRequests.delete(requestId)
pending.resolve(result)
}
async function requestRendererMetrics(
webContents: WebContents,
timeoutMs: number
): Promise<{ metrics: RendererPerfMetrics | null; reason?: RendererPerfUnavailableReason }> {
ensureResponseListener()
const requestId = randomUUID()
const result = await new Promise<RendererPerfResponseResult>((resolve) => {
const timeout = setTimeout(
() => {
settleRendererPerfRequest(requestId, { status: 'timeout' })
},
Math.max(1, timeoutMs)
)
pendingRequests.set(requestId, {
trustedWebContentsId: webContents.id,
timeout,
resolve
})
try {
webContents.send('diagnostics:rendererPerf:request', { requestId })
} catch {
settleRendererPerfRequest(requestId, { status: 'send-failed' })
}
})
if (result.status === 'ok') {
return { metrics: result.metrics }
}
return {
metrics: null,
reason: webContents.isDestroyed() ? 'renderer-destroyed' : result.status
}
}
async function collectMainMetrics(
webContents: WebContents,
timeoutMs: number
): Promise<{
metrics?: NonNullable<RendererPerfRecord['main']>
domCountersReason?: string
processMemoryReason?: string
}> {
const [domCounters, processMemory] = await Promise.all([
collectDomCounters(webContents, timeoutMs),
collectProcessMemory(webContents)
])
const metrics: NonNullable<RendererPerfRecord['main']> = {
...(domCounters.value ? { domCounters: domCounters.value } : {}),
...(processMemory.value ? { processMemoryBytes: processMemory.value } : {})
}
return {
...(Object.keys(metrics).length > 0 ? { metrics } : {}),
...(domCounters.reason ? { domCountersReason: domCounters.reason } : {}),
...(processMemory.reason ? { processMemoryReason: processMemory.reason } : {})
}
}
async function collectDomCounters(
webContents: WebContents,
timeoutMs: number
): Promise<{ value?: RendererPerfDomCounters; reason?: string }> {
let lease: { release: () => void } | null = null
try {
lease = acquireElectronDebugger(webContents)
// Why: a hung renderer can leave the CDP command pending forever; bound
// it so bundle collection degrades instead of hanging with it.
const result = (await withTimeout(
webContents.debugger.sendCommand('Memory.getDOMCounters'),
timeoutMs
)) as Partial<RendererPerfDomCounters>
return {
value: {
documents: clampCount(result.documents),
nodes: clampCount(result.nodes),
jsEventListeners: clampCount(result.jsEventListeners)
}
}
} catch (error) {
return { reason: formatReason(error) }
} finally {
lease?.release()
}
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timeout: NodeJS.Timeout | null = null
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timeout = setTimeout(() => reject(new Error('timeout')), Math.max(1, timeoutMs))
})
])
} finally {
if (timeout) {
clearTimeout(timeout)
}
}
}
async function collectProcessMemory(
webContents: WebContents
): Promise<{ value?: RendererPerfProcessMemory; reason?: string }> {
try {
// Why: webContents.getProcessMemoryInfo() was removed from Electron;
// app.getAppMetrics() is the supported source for per-process memory.
const osPid = webContents.getOSProcessId()
const memory = app.getAppMetrics().find((metric) => metric.pid === osPid)?.memory
if (!memory) {
return { reason: 'renderer process metric unavailable' }
}
const normalized: RendererPerfProcessMemory = {}
for (const [key, value] of Object.entries(memory)) {
if (typeof value === 'number' && Number.isFinite(value)) {
// Electron reports MemoryInfo fields in KiB.
normalized[`${key}Bytes`] = clampCount(value * 1024)
}
}
return Object.keys(normalized).length > 0 ? { value: normalized } : {}
} catch (error) {
return { reason: formatReason(error) }
}
}
function clampCount(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return 0
}
return Math.max(0, Math.floor(value))
}
function formatReason(error: unknown): string {
return error instanceof Error && error.message ? error.message.slice(0, 160) : 'unavailable'
}
@@ -0,0 +1,64 @@
import { gunzipSync } from 'node:zlib'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createTarGzipArchive } from './tar-archive'
let dir: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'orca-tar-archive-'))
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
describe('createTarGzipArchive', () => {
it('round-trips small files through gzip-compressed ustar', async () => {
const first = join(dir, 'first.txt')
const second = join(dir, 'second.json')
const archive = join(dir, 'bundle.tar.gz')
writeFileSync(first, 'hello tar\n')
writeFileSync(second, '{"ok":true}\n')
await createTarGzipArchive(archive, [
{ name: 'first.txt', filePath: first },
{ name: 'nested/second.json', filePath: second }
])
const entries = parseTarEntries(gunzipSync(readFileSync(archive)))
expect(entries).toEqual([
{ name: 'first.txt', content: 'hello tar\n' },
{ name: 'nested/second.json', content: '{"ok":true}\n' }
])
})
})
function parseTarEntries(buffer: Buffer): { name: string; content: string }[] {
const entries: { name: string; content: string }[] = []
let offset = 0
while (offset + 512 <= buffer.length) {
const header = buffer.subarray(offset, offset + 512)
if (header.every((byte) => byte === 0)) {
break
}
const name = trimAtNull(header.toString('utf8', 0, 100))
const sizeText = trimAtNull(header.toString('ascii', 124, 136)).trim()
const size = Number.parseInt(sizeText, 8)
const contentStart = offset + 512
const contentEnd = contentStart + size
entries.push({
name,
content: buffer.toString('utf8', contentStart, contentEnd)
})
offset = contentStart + Math.ceil(size / 512) * 512
}
return entries
}
function trimAtNull(value: string): string {
const index = value.indexOf('\u0000')
return index === -1 ? value : value.slice(0, index)
}
+106
View File
@@ -0,0 +1,106 @@
import { createReadStream, createWriteStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { basename } from 'node:path'
import { Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { createGzip } from 'node:zlib'
export type TarArchiveEntry = {
readonly name: string
readonly filePath: string
readonly mode?: number
}
export type TarArchiveResult = {
readonly skipped: readonly string[]
}
const BLOCK_SIZE = 512
const MAX_TAR_FILE_SIZE = 0o77777777777
export async function createTarGzipArchive(
outputPath: string,
entries: readonly TarArchiveEntry[]
): Promise<TarArchiveResult> {
const skipped: string[] = []
const stream = Readable.from(tarBlocks(entries, skipped))
await pipeline(stream, createGzip(), createWriteStream(outputPath, { mode: 0o600 }))
return { skipped }
}
async function* tarBlocks(
entries: readonly TarArchiveEntry[],
skipped: string[]
): AsyncGenerator<Buffer> {
for (const entry of entries) {
const info = await stat(entry.filePath)
const name = normalizeEntryName(entry.name)
if (info.size > MAX_TAR_FILE_SIZE || Buffer.byteLength(name) > 100) {
skipped.push(entry.name)
continue
}
yield createHeader({
name,
size: info.size,
mode: entry.mode ?? 0o600,
mtime: Math.floor(info.mtimeMs / 1000)
})
for await (const chunk of createReadStream(entry.filePath)) {
yield Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
}
const remainder = info.size % BLOCK_SIZE
if (remainder > 0) {
yield Buffer.alloc(BLOCK_SIZE - remainder)
}
}
yield Buffer.alloc(BLOCK_SIZE * 2)
}
function normalizeEntryName(name: string): string {
const normalized = name.replace(/\\/g, '/').replace(/^\/+/, '')
return normalized || basename(name)
}
function createHeader({
name,
size,
mode,
mtime
}: {
readonly name: string
readonly size: number
readonly mode: number
readonly mtime: number
}): Buffer {
const header = Buffer.alloc(BLOCK_SIZE)
writeString(header, name, 0, 100)
writeOctal(header, mode, 100, 8)
writeOctal(header, 0, 108, 8)
writeOctal(header, 0, 116, 8)
writeOctal(header, size, 124, 12)
writeOctal(header, mtime, 136, 12)
header.fill(0x20, 148, 156)
header[156] = '0'.charCodeAt(0)
writeString(header, 'ustar', 257, 6)
writeString(header, '00', 263, 2)
const checksum = header.reduce((sum, byte) => sum + byte, 0)
writeChecksum(header, checksum)
return header
}
function writeString(header: Buffer, value: string, offset: number, length: number): void {
header.write(value, offset, Math.min(Buffer.byteLength(value), length), 'utf8')
}
function writeOctal(header: Buffer, value: number, offset: number, length: number): void {
const encoded = Math.floor(value)
.toString(8)
.padStart(length - 1, '0')
.slice(-(length - 1))
header.write(`${encoded}\0`, offset, length, 'ascii')
}
function writeChecksum(header: Buffer, checksum: number): void {
const encoded = checksum.toString(8).padStart(6, '0').slice(-6)
header.write(`${encoded}\0 `, 148, 8, 'ascii')
}
+17
View File
@@ -257,6 +257,7 @@ import type { BrowserSetAnnotationViewportBridgeArgs } from '../shared/browser-a
import type { CliInstallStatus } from '../shared/cli-install-types'
import type { E2EConfig } from '../shared/e2e-config'
import type { AgentHookInstallStatus } from '../shared/agent-hook-types'
import type { RendererPerfMetrics } from '../shared/renderer-perf-metrics'
import type {
AgentStatusIpcPayload,
MigrationUnsupportedPtyEntry
@@ -637,6 +638,7 @@ export type StatsApi = {
export type DiagnosticsStatusPayload = {
readonly localFileEnabled: boolean
readonly bundleEnabled: boolean
readonly perfDumpEnabled: boolean
readonly traceFilePath: string
readonly traceFamilySize: number
readonly disabledReason?:
@@ -657,6 +659,17 @@ export type DiagnosticsUploadPayload =
| {
readonly canceled: true
}
export type PerfDumpProgressPayload = {
readonly stage: 'metrics' | 'trace' | 'heap' | 'compressing'
}
export type PerfDumpCapturePayload =
| {
readonly canceled: true
}
| {
readonly filePath: string
readonly bytes: number
}
export type MemoryApi = {
getSnapshot: () => Promise<MemorySnapshot>
@@ -1877,6 +1890,10 @@ export type PreloadApi = {
discardBundlePreview: (bundleSubmissionId: string) => Promise<void>
uploadBundle: (bundleSubmissionId: string) => Promise<DiagnosticsUploadPayload>
deleteBundle: (ticketId: string) => Promise<void>
capturePerfDump: () => Promise<PerfDumpCapturePayload>
onPerfDumpProgress: (callback: (payload: PerfDumpProgressPayload) => void) => () => void
onPerfMetricsRequest: (callback: (payload: { requestId: string }) => void) => () => void
sendPerfMetrics: (requestId: string, metrics: RendererPerfMetrics) => void
}
/** Read-only view of effective consent state, including the reason if
* disabled (env var / user opt-out / CI / pending banner). Used by the
+68 -1
View File
@@ -437,6 +437,45 @@ document.addEventListener(
const startupDiagnosticsEnabled = process.env.ORCA_STARTUP_DIAGNOSTICS === '1'
function getV8HeapStatisticsBytes(): Record<string, number> | undefined {
const heapStats = (
process as NodeJS.Process & { getHeapStatistics?: () => Record<string, number> }
).getHeapStatistics?.()
if (!heapStats) {
return undefined
}
return {
totalHeapSizeBytes: toBytes(readHeapStat(heapStats, 'total_heap_size', 'totalHeapSize')),
totalHeapSizeExecutableBytes: toBytes(
readHeapStat(heapStats, 'total_heap_size_executable', 'totalHeapSizeExecutable')
),
totalPhysicalSizeBytes: toBytes(
readHeapStat(heapStats, 'total_physical_size', 'totalPhysicalSize')
),
totalAvailableSizeBytes: toBytes(
readHeapStat(heapStats, 'total_available_size', 'totalAvailableSize')
),
usedHeapSizeBytes: toBytes(readHeapStat(heapStats, 'used_heap_size', 'usedHeapSize')),
heapSizeLimitBytes: toBytes(readHeapStat(heapStats, 'heap_size_limit', 'heapSizeLimit')),
mallocedMemoryBytes: toBytes(readHeapStat(heapStats, 'malloced_memory', 'mallocedMemory')),
peakMallocedMemoryBytes: toBytes(
readHeapStat(heapStats, 'peak_malloced_memory', 'peakMallocedMemory')
),
externalMemoryBytes: toBytes(readHeapStat(heapStats, 'external_memory', 'externalMemory'))
}
}
function readHeapStat(stats: object, snakeKey: string, camelKey: string): unknown {
const record = stats as Record<string, unknown>
return record[snakeKey] ?? record[camelKey]
}
function toBytes(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value)
? Math.max(0, Math.floor(value * 1024))
: 0
}
// Custom APIs for renderer
const api = {
app: {
@@ -1681,7 +1720,35 @@ const api = {
uploadBundle: (bundleSubmissionId: string): Promise<unknown> =>
ipcRenderer.invoke('diagnostics:uploadBundle', bundleSubmissionId),
deleteBundle: (ticketId: string): Promise<void> =>
ipcRenderer.invoke('diagnostics:deleteBundle', ticketId)
ipcRenderer.invoke('diagnostics:deleteBundle', ticketId),
capturePerfDump: (): Promise<unknown> => ipcRenderer.invoke('diagnostics:capturePerfDump'),
onPerfDumpProgress: (callback: (payload: { stage?: unknown }) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload?: { stage?: unknown }): void =>
callback(payload ?? {})
ipcRenderer.on('diagnostics:perfDump:progress', listener)
return () => ipcRenderer.removeListener('diagnostics:perfDump:progress', listener)
},
onPerfMetricsRequest: (callback: (payload: { requestId: string }) => void): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
payload?: { requestId?: unknown }
): void => {
if (typeof payload?.requestId === 'string') {
callback({ requestId: payload.requestId })
}
}
ipcRenderer.on('diagnostics:rendererPerf:request', listener)
return () => ipcRenderer.removeListener('diagnostics:rendererPerf:request', listener)
},
sendPerfMetrics: (requestId: string, metrics: Record<string, unknown>): void => {
ipcRenderer.send('diagnostics:rendererPerf:response', {
requestId,
metrics: {
...metrics,
v8HeapStatistics: getV8HeapStatisticsBytes()
}
})
}
},
settings: {
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { FileText } from 'lucide-react'
import { Gauge, FileText } from 'lucide-react'
import type {
DiagnosticsBundlePayload,
DiagnosticsStatusPayload
DiagnosticsStatusPayload,
PerfDumpProgressPayload
} from '../../../../preload/api-types'
import { Label } from '../ui/label'
import { Separator } from '../ui/separator'
@@ -11,6 +12,7 @@ import {
getDiagnosticBundleDescription,
PrivacyDiagnosticBundleControls
} from './PrivacyDiagnosticBundleControls'
import { getPerfDumpDescription, PrivacyPerfDumpControls } from './PrivacyPerfDumpControls'
import { translate } from '@/i18n/i18n'
export function PrivacyDiagnosticsSection(): React.JSX.Element {
@@ -24,6 +26,8 @@ export function PrivacyDiagnosticsSection(): React.JSX.Element {
const [discarding, setDiscarding] = useState(false)
const [copyingTicket, setCopyingTicket] = useState(false)
const [deletingTicket, setDeletingTicket] = useState(false)
const [capturingPerfDump, setCapturingPerfDump] = useState(false)
const [perfDumpStage, setPerfDumpStage] = useState<PerfDumpProgressPayload['stage'] | null>(null)
const mountedRef = useRef(true)
const activeBundleSubmissionIdRef = useRef<string | null>(null)
@@ -52,6 +56,14 @@ export function PrivacyDiagnosticsSection(): React.JSX.Element {
}
}, [])
useEffect(() => {
return window.api.diagnostics.onPerfDumpProgress((payload) => {
if (mountedRef.current) {
setPerfDumpStage(payload.stage)
}
})
}, [])
const handleCollectBundle = useCallback(async (): Promise<void> => {
setCollecting(true)
try {
@@ -235,6 +247,43 @@ export function PrivacyDiagnosticsSection(): React.JSX.Element {
}
}, [ticketId])
const handleCapturePerfDump = useCallback(async (): Promise<void> => {
setCapturingPerfDump(true)
setPerfDumpStage(null)
try {
const result = await window.api.diagnostics.capturePerfDump()
if (!mountedRef.current || 'canceled' in result) {
return
}
toast.success(
translate(
'auto.components.settings.PrivacyDiagnosticsSection.c4cd74809e',
'Performance dump saved'
)
)
void window.api.shell.openInFileManager(result.filePath)
} catch (error) {
if (mountedRef.current) {
toast.error(
getDiagnosticsErrorMessage(
error,
translate(
'auto.components.settings.PrivacyDiagnosticsSection.f73f536350',
'Could not capture performance dump'
)
)
)
}
} finally {
if (mountedRef.current) {
setCapturingPerfDump(false)
setPerfDumpStage(null)
}
}
}, [])
const showPerfDumpRow = status?.perfDumpEnabled !== false
return (
<>
{status?.disabledReason ? (
@@ -269,6 +318,22 @@ export function PrivacyDiagnosticsSection(): React.JSX.Element {
onDismissTicket={() => setTicketId(null)}
/>
</PrivacyDiagnosticsRow>
{showPerfDumpRow ? (
<PrivacyDiagnosticsRow
icon={<Gauge className="size-4" />}
title={translate(
'auto.components.settings.PrivacyDiagnosticsSection.4c7eb24bdd',
'Capture performance debug dump'
)}
description={getPerfDumpDescription(capturingPerfDump ? perfDumpStage : null)}
>
<PrivacyPerfDumpControls
status={status}
capturing={capturingPerfDump}
onCapture={handleCapturePerfDump}
/>
</PrivacyDiagnosticsRow>
) : null}
</>
)
}
@@ -0,0 +1,74 @@
import { Activity, Loader2 } from 'lucide-react'
import type {
DiagnosticsStatusPayload,
PerfDumpProgressPayload
} from '../../../../preload/api-types'
import { Button } from '../ui/button'
import { translate } from '@/i18n/i18n'
type PerfDumpStage = PerfDumpProgressPayload['stage'] | null
export function PrivacyPerfDumpControls({
status,
capturing,
onCapture
}: {
readonly status: DiagnosticsStatusPayload | null
readonly capturing: boolean
readonly onCapture: () => Promise<void>
}): React.JSX.Element {
return (
<Button
variant="outline"
size="sm"
disabled={!status?.perfDumpEnabled || capturing}
title={
status && !status.perfDumpEnabled
? translate(
'auto.components.settings.PrivacyPerfDumpControls.a90900c24d',
'Performance debug dumps are disabled.'
)
: undefined
}
onClick={() => void onCapture()}
>
{capturing ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Activity className="size-3.5" />
)}
{translate(
'auto.components.settings.PrivacyPerfDumpControls.18f7cad8ee',
'Capture performance dump'
)}
</Button>
)
}
export function getPerfDumpDescription(stage: PerfDumpStage): string {
if (stage === 'metrics') {
return translate(
'auto.components.settings.PrivacyPerfDumpControls.ed819b5b0a',
'Collecting renderer metrics…'
)
}
if (stage === 'trace') {
return translate(
'auto.components.settings.PrivacyPerfDumpControls.7f6c7cc90f',
'Recording activity trace (10s)…'
)
}
if (stage === 'heap') {
return translate(
'auto.components.settings.PrivacyPerfDumpControls.20f8c48f16',
'Capturing memory snapshot…'
)
}
if (stage === 'compressing') {
return translate('auto.components.settings.PrivacyPerfDumpControls.05d8e75876', 'Compressing…')
}
return translate(
'auto.components.settings.PrivacyPerfDumpControls.581d264c8c',
'Records about 10 seconds of app activity and a memory snapshot of the interface. May include terminal text, file paths, and page titles; saved to your computer only — nothing is uploaded.'
)
}
@@ -54,7 +54,16 @@ export const getPrivacyPaneSearchEntries = createLocalizedCatalog(() => [
'auto.components.settings.privacy.search.c0494ff48a',
'diagnostics'
),
...translateSearchKeyword('auto.components.settings.privacy.search.1686c07fee', 'support')
...translateSearchKeyword('auto.components.settings.privacy.search.1686c07fee', 'support'),
...translateSearchKeyword(
'auto.components.settings.privacy.search.68d450145b',
'performance'
),
...translateSearchKeyword('auto.components.settings.privacy.search.e8e43779e8', 'perf dump'),
...translateSearchKeyword('auto.components.settings.privacy.search.048b49260d', 'heap'),
...translateSearchKeyword('auto.components.settings.privacy.search.c1d94c8566', 'trace'),
...translateSearchKeyword('auto.components.settings.privacy.search.ec083fb9e1', 'slow'),
...translateSearchKeyword('auto.components.settings.privacy.search.07241d4d1', 'lag')
]
},
{
@@ -114,6 +114,10 @@ export function hasPtySerializer(ptyId: string): boolean {
return serializersByPtyId.has(ptyId)
}
export function getRegisteredPtySerializerCount(): number {
return serializersByPtyId.size
}
function ensureSerializerListener(): void {
if (listenerAttached) {
return
+26 -2
View File
@@ -154,7 +154,8 @@
"36761d9604": "Unknown keybinding action \"{{value0}}\" was ignored.",
"d2e43e426a": "{{value0}} must be an object.",
"fb290366b2": "Unavailable on web.",
"76122208ca": "Shortcut for \"{{value0}}\" was ignored: {{value1}}"
"76122208ca": "Shortcut for \"{{value0}}\" was ignored: {{value1}}",
"afa470528f": "Performance debug dumps are unavailable on web."
}
},
"runtime": {
@@ -5725,7 +5726,10 @@
"860bca9ec9": "Review file discarded",
"49fc6c80e8": "Diagnostics sent",
"db3228e01a": "Review file opened",
"a2b3505c77": "Review file created"
"a2b3505c77": "Review file created",
"c4cd74809e": "Performance dump saved",
"f73f536350": "Could not capture performance dump",
"4c7eb24bdd": "Capture performance debug dump"
},
"PrivacyPane": {
"36e0e2e63b": "environment variable. Unset it and restart to re-enable.",
@@ -8707,6 +8711,15 @@
"ephemeralVmsExperimentalSetting": {
"description": "Shows setup controls and workspace run targets for repo-owned, on-demand environments.",
"toggleLabel": "Toggle per-workspace environments"
},
"PrivacyPerfDumpControls": {
"a90900c24d": "Performance debug dumps are disabled.",
"18f7cad8ee": "Capture performance dump",
"ed819b5b0a": "Collecting renderer metrics…",
"7f6c7cc90f": "Recording activity trace (10s)…",
"20f8c48f16": "Capturing memory snapshot…",
"05d8e75876": "Compressing…",
"581d264c8c": "Records about 10 seconds of app activity and a memory snapshot of the interface. May include terminal text, file paths, and page titles; saved to your computer only — nothing is uploaded."
}
},
"right": {
@@ -12310,6 +12323,17 @@
"91b5c8d7e6": "GitLab"
}
}
},
"main": {
"observability": {
"perfDump": {
"e6735719a0": "Capture",
"8d1d1d5725": "Cancel",
"71ec14a301": "Capture performance debug dump?",
"6d969f0773": "This saves a local performance debug dump for support.",
"2551f622fd": "Orca will record about 10 seconds of app activity and capture a memory snapshot of the interface. The dump may include terminal text, file paths, and page titles. It is saved to your computer only; nothing is uploaded."
}
}
}
},
"components": {
+26 -2
View File
@@ -154,7 +154,8 @@
"36761d9604": "Se ignoró la acción de combinación de teclas desconocida \"{{value0}}\".",
"d2e43e426a": "{{value0}} debe ser un objeto.",
"fb290366b2": "No disponible en la web.",
"76122208ca": "Se ignoró el acceso directo para \"{{value0}}\": {{value1}}"
"76122208ca": "Se ignoró el acceso directo para \"{{value0}}\": {{value1}}",
"afa470528f": "Los volcados de depuración de rendimiento no están disponibles en la web."
}
},
"runtime": {
@@ -5688,7 +5689,10 @@
"860bca9ec9": "Archivo para revisar descartado",
"49fc6c80e8": "Diagnósticos enviados",
"db3228e01a": "Archivo para revisar abierto",
"a2b3505c77": "Archivo para revisar creado"
"a2b3505c77": "Archivo para revisar creado",
"c4cd74809e": "Volcado de rendimiento guardado",
"f73f536350": "No se pudo capturar el volcado de rendimiento",
"4c7eb24bdd": "Capturar volcado de depuración de rendimiento"
},
"PrivacyPane": {
"36e0e2e63b": "variable de entorno. Desconfigúrelo y reinicie para volver a habilitarlo.",
@@ -8707,6 +8711,15 @@
"ephemeralVmsExperimentalSetting": {
"description": "Muestra controles de configuración y objetivos de ejecución del espacio de trabajo para entornos bajo demanda de propiedad de repos.",
"toggleLabel": "Alternar entornos por espacio de trabajo"
},
"PrivacyPerfDumpControls": {
"a90900c24d": "Los volcados de depuración de rendimiento están desactivados.",
"18f7cad8ee": "Capturar volcado de rendimiento",
"ed819b5b0a": "Recopilando métricas del renderizador…",
"7f6c7cc90f": "Grabando traza de actividad (10 s)…",
"20f8c48f16": "Capturando instantánea de memoria…",
"05d8e75876": "Comprimiendo…",
"581d264c8c": "Graba unos 10 segundos de actividad de la aplicación y una instantánea de memoria de la interfaz. Puede incluir texto del terminal, rutas de archivos y títulos de páginas; se guarda solo en tu equipo — no se sube nada."
}
},
"right": {
@@ -12310,6 +12323,17 @@
"91b5c8d7e6": "GitLab"
}
}
},
"main": {
"observability": {
"perfDump": {
"e6735719a0": "Capturar",
"8d1d1d5725": "Cancelar",
"71ec14a301": "¿Capturar volcado de depuración de rendimiento?",
"6d969f0773": "Esto guarda un volcado local de depuración de rendimiento para soporte.",
"2551f622fd": "Orca grabará unos 10 segundos de actividad de la aplicación y capturará una instantánea de memoria de la interfaz. El volcado puede incluir texto del terminal, rutas de archivos y títulos de páginas. Se guarda solo en tu equipo; no se sube nada."
}
}
}
},
"components": {
+26 -2
View File
@@ -154,7 +154,8 @@
"36761d9604": "不明なキーバインド 操作「{{value0}}」は無視されました。",
"d2e43e426a": "{{value0}} はオブジェクトでなければなりません。",
"fb290366b2": "ウェブでは利用できません。",
"76122208ca": "「{{value0}}」のショートカットは無視されました: {{value1}}"
"76122208ca": "「{{value0}}」のショートカットは無視されました: {{value1}}",
"afa470528f": "パフォーマンスデバッグダンプはウェブでは利用できません。"
}
},
"runtime": {
@@ -5710,7 +5711,10 @@
"860bca9ec9": "確認用ファイルを破棄しました",
"49fc6c80e8": "診断情報を送信しました",
"db3228e01a": "確認用ファイルを開きました",
"a2b3505c77": "確認用ファイルを作成しました"
"a2b3505c77": "確認用ファイルを作成しました",
"c4cd74809e": "パフォーマンスダンプを保存しました",
"f73f536350": "パフォーマンスダンプをキャプチャできませんでした",
"4c7eb24bdd": "パフォーマンスデバッグダンプをキャプチャ"
},
"PrivacyPane": {
"36e0e2e63b": "環境変数。設定を解除し、再起動して再度有効にします。",
@@ -8707,6 +8711,15 @@
"ephemeralVmsExperimentalSetting": {
"description": "repo が所有するオンデマンド環境のセットアップ コントロールとワークスペース実行ターゲットを示します。",
"toggleLabel": "ワークスペースごとの環境を切り替える"
},
"PrivacyPerfDumpControls": {
"a90900c24d": "パフォーマンスデバッグダンプは無効になっています。",
"18f7cad8ee": "パフォーマンスダンプをキャプチャ",
"ed819b5b0a": "レンダラーのメトリクスを収集中…",
"7f6c7cc90f": "アクティビティトレースを記録中(10秒)…",
"20f8c48f16": "メモリスナップショットをキャプチャ中…",
"05d8e75876": "圧縮中…",
"581d264c8c": "アプリの動作を約10秒間記録し、インターフェイスのメモリスナップショットを取得します。ターミナルのテキスト、ファイルパス、ページタイトルが含まれる場合があります。お使いのコンピューターにのみ保存され、何もアップロードされません。"
}
},
"right": {
@@ -12310,6 +12323,17 @@
"91b5c8d7e6": "GitLab"
}
}
},
"main": {
"observability": {
"perfDump": {
"e6735719a0": "キャプチャ",
"8d1d1d5725": "キャンセル",
"71ec14a301": "パフォーマンスデバッグダンプをキャプチャしますか?",
"6d969f0773": "サポート用にローカルのパフォーマンスデバッグダンプを保存します。",
"2551f622fd": "Orca はアプリの動作を約10秒間記録し、インターフェイスのメモリスナップショットを取得します。ダンプにはターミナルのテキスト、ファイルパス、ページタイトルが含まれる場合があります。お使いのコンピューターにのみ保存され、何もアップロードされません。"
}
}
}
},
"components": {
+26 -2
View File
@@ -154,7 +154,8 @@
"36761d9604": "알 수 없는 키 바인딩 작업 \"{{value0}}\"이(가) 무시되었습니다.",
"d2e43e426a": "{{value0}}은(는) 객체여야 합니다.",
"fb290366b2": "웹에서는 사용할 수 없습니다.",
"76122208ca": "\"{{value0}}\"에 대한 단축키가 무시되었습니다: {{value1}}"
"76122208ca": "\"{{value0}}\"에 대한 단축키가 무시되었습니다: {{value1}}",
"afa470528f": "성능 디버그 덤프는 웹에서 사용할 수 없습니다."
}
},
"runtime": {
@@ -5673,7 +5674,10 @@
"860bca9ec9": "검토 파일을 버렸습니다",
"49fc6c80e8": "진단 정보를 보냈습니다",
"db3228e01a": "검토 파일을 열었습니다",
"a2b3505c77": "검토 파일을 만들었습니다"
"a2b3505c77": "검토 파일을 만들었습니다",
"c4cd74809e": "성능 덤프가 저장되었습니다",
"f73f536350": "성능 덤프를 캡처하지 못했습니다",
"4c7eb24bdd": "성능 디버그 덤프 캡처"
},
"PrivacyPane": {
"36e0e2e63b": "환경 변수. 다시 활성화하려면 설정을 해제하고 다시 시작하세요.",
@@ -8707,6 +8711,15 @@
"ephemeralVmsExperimentalSetting": {
"description": "repo 소유 온디맨드 환경에 대한 설정 제어 및 워크스페이스 실행 대상을 표시합니다.",
"toggleLabel": "워크스페이스별 환경 전환"
},
"PrivacyPerfDumpControls": {
"a90900c24d": "성능 디버그 덤프가 비활성화되어 있습니다.",
"18f7cad8ee": "성능 덤프 캡처",
"ed819b5b0a": "렌더러 지표 수집 중…",
"7f6c7cc90f": "활동 추적 기록 중(10초)…",
"20f8c48f16": "메모리 스냅샷 캡처 중…",
"05d8e75876": "압축 중…",
"581d264c8c": "약 10초간의 앱 활동과 인터페이스의 메모리 스냅샷을 기록합니다. 터미널 텍스트, 파일 경로, 페이지 제목이 포함될 수 있으며, 컴퓨터에만 저장되고 아무것도 업로드되지 않습니다."
}
},
"right": {
@@ -12310,6 +12323,17 @@
"91b5c8d7e6": "GitLab"
}
}
},
"main": {
"observability": {
"perfDump": {
"e6735719a0": "캡처",
"8d1d1d5725": "취소",
"71ec14a301": "성능 디버그 덤프를 캡처하시겠습니까?",
"6d969f0773": "지원을 위해 로컬 성능 디버그 덤프를 저장합니다.",
"2551f622fd": "Orca가 약 10초간의 앱 활동을 기록하고 인터페이스의 메모리 스냅샷을 캡처합니다. 덤프에는 터미널 텍스트, 파일 경로, 페이지 제목이 포함될 수 있습니다. 컴퓨터에만 저장되며 아무것도 업로드되지 않습니다."
}
}
}
},
"components": {
+26 -2
View File
@@ -154,7 +154,8 @@
"36761d9604": "未知的键绑定操作“{{value0}}”被忽略。",
"d2e43e426a": "{{value0}} 必须是一个对象。",
"fb290366b2": "无法在网络上使用。",
"76122208ca": "“{{value0}}”的快捷方式被忽略:{{value1}}"
"76122208ca": "“{{value0}}”的快捷方式被忽略:{{value1}}",
"afa470528f": "网页版不支持性能调试转储。"
}
},
"runtime": {
@@ -5673,7 +5674,10 @@
"860bca9ec9": "已丢弃查看文件",
"49fc6c80e8": "诊断信息已发送",
"db3228e01a": "已打开查看文件",
"a2b3505c77": "已创建查看文件"
"a2b3505c77": "已创建查看文件",
"c4cd74809e": "性能转储已保存",
"f73f536350": "无法捕获性能转储",
"4c7eb24bdd": "捕获性能调试转储"
},
"PrivacyPane": {
"36e0e2e63b": "环境变量。取消设置并重新启动以重新启用。",
@@ -8707,6 +8711,15 @@
"ephemeralVmsExperimentalSetting": {
"description": "显示仓库自带按需环境的设置控件和工作区运行目标。",
"toggleLabel": "切换工作区专属环境"
},
"PrivacyPerfDumpControls": {
"a90900c24d": "性能调试转储已被禁用。",
"18f7cad8ee": "捕获性能转储",
"ed819b5b0a": "正在收集渲染器指标…",
"7f6c7cc90f": "正在录制活动跟踪(10 秒)…",
"20f8c48f16": "正在捕获内存快照…",
"05d8e75876": "正在压缩…",
"581d264c8c": "录制约 10 秒的应用活动并捕获界面的内存快照。可能包含终端文本、文件路径和页面标题;仅保存在您的计算机上——不会上传任何内容。"
}
},
"right": {
@@ -12310,6 +12323,17 @@
"91b5c8d7e6": "GitLab"
}
}
},
"main": {
"observability": {
"perfDump": {
"e6735719a0": "捕获",
"8d1d1d5725": "取消",
"71ec14a301": "要捕获性能调试转储吗?",
"6d969f0773": "这会在本地保存一个用于支持的性能调试转储。",
"2551f622fd": "Orca 将录制约 10 秒的应用活动并捕获界面的内存快照。转储可能包含终端文本、文件路径和页面标题。它仅保存在您的计算机上;不会上传任何内容。"
}
}
}
},
"components": {
@@ -14,6 +14,10 @@ export function unregisterLivePaneManager(manager: RegisteredPaneManager): void
liveManagers.delete(manager)
}
export function getLivePaneManagerCount(): number {
return liveManagers.size
}
/**
* Resets the WebGL glyph atlases of every live pane manager, not just one.
*
@@ -0,0 +1,182 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { collectRendererPerfSnapshot } from './renderer-perf-snapshot'
vi.mock('@/store', () => ({
useAppStore: {
getState: vi.fn()
}
}))
vi.mock('@/runtime/sync-runtime-graph', () => ({
listRegisteredTerminalTabPerfSources: () => []
}))
vi.mock('@/components/browser-pane/webview-registry', () => ({
getBrowserWebviewMemoryProfile: () => ({
browserWebviewCount: 0,
registeredBrowserGuestCount: 0
})
}))
vi.mock('@/components/terminal-pane/pty-buffer-serializer', () => ({
getRegisteredPtySerializerCount: () => 0
}))
vi.mock('@/lib/pane-manager/pane-manager-registry', () => ({
getLivePaneManagerCount: () => 0
}))
describe('collectRendererPerfSnapshot', () => {
beforeEach(() => {
document.body.innerHTML = '<main><section><button>Run</button></section></main>'
Object.defineProperty(window.performance, 'memory', {
configurable: true,
value: {
usedJSHeapSize: 1024,
totalJSHeapSize: 2048,
jsHeapSizeLimit: 4096
}
})
})
it('collects DOM, browser, registry, and terminal pane metrics on demand', () => {
const metrics = collectRendererPerfSnapshot({
getAppState: () => localState(),
getBrowserProfile: () => ({
browserWebviewCount: 2,
registeredBrowserGuestCount: 1
}),
getPtySerializerCount: () => 3,
getLiveManagerCount: () => 4,
listTabSources: () => [
{
worktreeId: 'repo-1::/Users/orca/project-alpha',
getManager: () => managerWithPane()
}
]
})
expect(metrics.domNodeCount).toBe(document.getElementsByTagName('*').length)
expect(metrics.jsHeapUsedBytes).toBe(1024)
expect(metrics.browserPanes).toEqual({
browserWebviewCount: 2,
registeredBrowserGuestCount: 1
})
expect(metrics.registries).toEqual({
ptySerializerCount: 3,
livePaneManagerCount: 4,
browserWebviewCount: 2,
registeredBrowserGuestCount: 1
})
expect(metrics.terminalPanes).toEqual({
totalMountedPaneCount: 1,
worktrees: [
{
worktreeLabel: 'project-alpha',
worktreeKind: 'local',
paneCount: 1,
scrollbackRowsTotal: 124,
panes: [
{
cols: 120,
rows: 30,
normalBufferRows: 100,
altBufferRows: 24,
hasWebgl: true
}
]
}
]
})
})
it('skips throwing and disposed tab sources without failing the snapshot', () => {
const metrics = collectRendererPerfSnapshot({
getAppState: () => localState(),
listTabSources: () => [
{
worktreeId: 'repo-1::/Users/orca/project-alpha',
getManager: () => {
throw new Error('unmounting')
}
},
{
worktreeId: 'repo-1::/Users/orca/project-alpha',
getManager: () => null
},
{
worktreeId: 'global-floating-terminal',
getManager: () => managerWithPane()
}
]
})
expect(metrics.terminalPanes.totalMountedPaneCount).toBe(1)
expect(metrics.terminalPanes.worktrees[0]?.worktreeLabel).toBe('floating')
expect(metrics.terminalPanes.worktrees[0]?.worktreeKind).toBe('floating')
})
it('caps per-worktree pane detail before IPC while keeping totals accurate', () => {
const manyPanes = Array.from({ length: 60 }, (_, index) => ({
id: index,
terminal: {
cols: 80,
rows: 24,
buffer: { normal: { length: 10 }, alternate: { length: 0 } }
}
}))
const manager = {
getPanes: () => manyPanes,
getRenderingDiagnostics: () => []
} as never
const source = { worktreeId: 'repo-1::/Users/orca/project-alpha', getManager: () => manager }
const metrics = collectRendererPerfSnapshot({
getAppState: () => localState(),
listTabSources: () => [source, source, source]
})
const worktree = metrics.terminalPanes.worktrees[0]
expect(metrics.terminalPanes.totalMountedPaneCount).toBe(180)
expect(worktree?.paneCount).toBe(180)
expect(worktree?.scrollbackRowsTotal).toBe(1800)
expect(worktree?.panes).toHaveLength(100)
})
})
function managerWithPane() {
return {
getPanes: () => [
{
id: 7,
terminal: {
cols: 120,
rows: 30,
buffer: {
normal: { length: 100 },
alternate: { length: 24 }
}
}
}
],
getRenderingDiagnostics: () => [
{
paneId: 7,
hasWebgl: true
}
]
} as never
}
function localState() {
return {
repos: [{ id: 'repo-1', connectionId: null }],
worktreesByRepo: {
'repo-1': [{ id: 'repo-1::/Users/orca/project-alpha', repoId: 'repo-1' }]
},
folderWorkspaces: [],
projectGroups: []
} as never
}
@@ -0,0 +1,260 @@
import { getBrowserWebviewMemoryProfile } from '@/components/browser-pane/webview-registry'
import { getRegisteredPtySerializerCount } from '@/components/terminal-pane/pty-buffer-serializer'
import { getLivePaneManagerCount } from '@/lib/pane-manager/pane-manager-registry'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { listRegisteredTerminalTabPerfSources } from '@/runtime/sync-runtime-graph'
import { getConnectionIdFromState } from '@/lib/connection-context'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import { getWorktreePathBasenameFromId } from '../../../shared/worktree-id'
import {
MAX_PANES_PER_WORKTREE,
MAX_WORKTREES,
type RendererPerfBrowserPaneMetrics,
type RendererPerfMetrics,
type RendererPerfPaneMetrics,
type RendererPerfWorktreeKind
} from '../../../shared/renderer-perf-metrics'
export type RendererPerfTabSource = {
readonly worktreeId: string
readonly getManager: () => PaneManager | null
}
type BrowserPerformanceMemory = {
readonly usedJSHeapSize?: number
readonly totalJSHeapSize?: number
readonly jsHeapSizeLimit?: number
}
type RendererPerfSnapshotOptions = {
readonly listTabSources?: () => readonly RendererPerfTabSource[]
readonly getAppState?: () => AppState
readonly getBrowserProfile?: () => RendererPerfBrowserPaneMetrics
readonly getPtySerializerCount?: () => number
readonly getLiveManagerCount?: () => number
}
let disposePerfMetricsRequestListener: (() => void) | null = null
export function collectRendererPerfSnapshot(
opts: RendererPerfSnapshotOptions = {}
): RendererPerfMetrics {
const browserPanes = opts.getBrowserProfile?.() ?? getBrowserWebviewMemoryProfile()
const terminalPanes = collectTerminalPaneMetrics({
sources: opts.listTabSources?.() ?? listRegisteredTerminalTabPerfSources(),
getAppState: opts.getAppState ?? useAppStore.getState
})
const memory = getPerformanceMemory()
return {
domNodeCount: getDomNodeCount(),
...(memory?.usedJSHeapSize !== undefined ? { jsHeapUsedBytes: memory.usedJSHeapSize } : {}),
...(memory?.totalJSHeapSize !== undefined ? { jsHeapTotalBytes: memory.totalJSHeapSize } : {}),
...(memory?.jsHeapSizeLimit !== undefined ? { jsHeapLimitBytes: memory.jsHeapSizeLimit } : {}),
terminalPanes,
browserPanes,
registries: {
ptySerializerCount: opts.getPtySerializerCount?.() ?? getRegisteredPtySerializerCount(),
livePaneManagerCount: opts.getLiveManagerCount?.() ?? getLivePaneManagerCount(),
browserWebviewCount: browserPanes.browserWebviewCount,
registeredBrowserGuestCount: browserPanes.registeredBrowserGuestCount
}
}
}
export function installRendererPerfSnapshotDiagnostics(): void {
if (disposePerfMetricsRequestListener || typeof window === 'undefined') {
return
}
disposePerfMetricsRequestListener = window.api.diagnostics.onPerfMetricsRequest(
({ requestId }) => {
try {
window.api.diagnostics.sendPerfMetrics(requestId, collectRendererPerfSnapshot())
} catch {
window.api.diagnostics.sendPerfMetrics(requestId, emptyRendererPerfSnapshot())
}
}
)
}
export function _disposeRendererPerfSnapshotDiagnosticsForTests(): void {
disposePerfMetricsRequestListener?.()
disposePerfMetricsRequestListener = null
}
if (typeof import.meta !== 'undefined' && import.meta.hot) {
import.meta.hot.dispose(() => {
_disposeRendererPerfSnapshotDiagnosticsForTests()
})
}
type MutableWorktreeMetrics = {
worktreeLabel: string
worktreeKind: RendererPerfWorktreeKind
paneCount: number
scrollbackRowsTotal: number
panes: RendererPerfPaneMetrics[]
}
function collectTerminalPaneMetrics({
sources,
getAppState
}: {
readonly sources: readonly RendererPerfTabSource[]
readonly getAppState: () => AppState
}): RendererPerfMetrics['terminalPanes'] {
const groups = new Map<string, MutableWorktreeMetrics>()
const fallbackLabels = new Map<string, string>()
const state = getAppState()
let totalMountedPaneCount = 0
for (const source of sources) {
try {
const manager = source.getManager()
if (!manager) {
continue
}
const panes = manager.getPanes()
const renderingByPaneId = new Map(
manager.getRenderingDiagnostics().map((diagnostic) => [diagnostic.paneId, diagnostic])
)
let worktree = groups.get(source.worktreeId)
if (!worktree) {
// Why: bound the per-worktree detail before it is allocated and
// serialized over IPC; totals stay accurate past the cap.
if (groups.size >= MAX_WORKTREES) {
totalMountedPaneCount += panes.length
continue
}
worktree = createWorktreeMetrics(source.worktreeId, state, fallbackLabels)
groups.set(source.worktreeId, worktree)
}
for (const pane of panes) {
const paneMetrics = collectPaneMetrics(
pane.terminal,
renderingByPaneId.get(pane.id)?.hasWebgl === true
)
totalMountedPaneCount += 1
worktree.paneCount += 1
worktree.scrollbackRowsTotal += paneMetrics.normalBufferRows + paneMetrics.altBufferRows
if (worktree.panes.length < MAX_PANES_PER_WORKTREE) {
worktree.panes.push(paneMetrics)
}
}
} catch {
// Why: terminal panes can unmount while diagnostics are reading them.
}
}
return {
totalMountedPaneCount,
worktrees: [...groups.values()]
}
}
function collectPaneMetrics(
terminal: {
readonly cols?: number
readonly rows?: number
readonly buffer?: {
readonly normal?: { readonly length?: number }
readonly alternate?: { readonly length?: number }
}
},
hasWebgl: boolean
): RendererPerfPaneMetrics {
return {
cols: nonNegativeCount(terminal.cols),
rows: nonNegativeCount(terminal.rows),
normalBufferRows: nonNegativeCount(terminal.buffer?.normal?.length),
altBufferRows: nonNegativeCount(terminal.buffer?.alternate?.length),
hasWebgl
}
}
function createWorktreeMetrics(
worktreeId: string,
state: AppState,
fallbackLabels: Map<string, string>
): MutableWorktreeMetrics {
return {
worktreeLabel: resolveWorktreeLabel(worktreeId, fallbackLabels),
worktreeKind: resolveWorktreeKind(worktreeId, state),
paneCount: 0,
scrollbackRowsTotal: 0,
panes: []
}
}
function resolveWorktreeKind(worktreeId: string, state: AppState): RendererPerfWorktreeKind {
if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
return 'floating'
}
try {
const connectionId = getConnectionIdFromState(state, worktreeId)
if (typeof connectionId === 'string' && connectionId.trim()) {
return 'ssh'
}
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId)
if (typeof runtimeEnvironmentId === 'string' && runtimeEnvironmentId.trim()) {
return 'runtime'
}
if (connectionId === null && runtimeEnvironmentId === null) {
return 'local'
}
} catch {
return 'other'
}
return 'other'
}
function resolveWorktreeLabel(worktreeId: string, fallbackLabels: Map<string, string>): string {
if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
return 'floating'
}
const basename = getWorktreePathBasenameFromId(worktreeId)
if (basename) {
return basename
}
const existing = fallbackLabels.get(worktreeId)
if (existing) {
return existing
}
const label = `worktree-${fallbackLabels.size + 1}`
fallbackLabels.set(worktreeId, label)
return label
}
function getDomNodeCount(): number {
if (typeof document === 'undefined') {
return 0
}
return document.getElementsByTagName('*').length
}
function getPerformanceMemory(): BrowserPerformanceMemory | undefined {
if (typeof window === 'undefined') {
return undefined
}
return (window.performance as Performance & { memory?: BrowserPerformanceMemory }).memory
}
function emptyRendererPerfSnapshot(): RendererPerfMetrics {
return {
terminalPanes: { totalMountedPaneCount: 0, worktrees: [] },
browserPanes: { browserWebviewCount: 0, registeredBrowserGuestCount: 0 },
registries: {
ptySerializerCount: 0,
livePaneManagerCount: 0,
browserWebviewCount: 0,
registeredBrowserGuestCount: 0
}
}
}
function nonNegativeCount(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0
}
+2
View File
@@ -9,6 +9,7 @@ import {
installRendererCrashDiagnostics,
recordRendererCrashBreadcrumb
} from './lib/crash-diagnostics'
import { installRendererPerfSnapshotDiagnostics } from './lib/renderer-perf-snapshot'
import { applyDocumentTheme } from './lib/document-theme'
import { shouldEnableReactGrab } from './lib/react-grab-dev-gate'
import { I18nProvider } from './i18n/I18nProvider'
@@ -16,6 +17,7 @@ import { translate } from './i18n/i18n'
recordRendererCrashBreadcrumb('renderer_bootstrap_started', { dev: import.meta.env.DEV })
installRendererCrashDiagnostics()
installRendererPerfSnapshotDiagnostics()
if (
import.meta.env.DEV &&
@@ -113,6 +113,16 @@ export function registerRuntimeTerminalTab(tab: RegisteredTerminalTab): () => vo
}
}
export function listRegisteredTerminalTabPerfSources(): readonly {
readonly worktreeId: string
readonly getManager: () => PaneManager | null
}[] {
return Array.from(registeredTabs.values(), ({ worktreeId, getManager }) => ({
worktreeId,
getManager
}))
}
export function focusRuntimeTerminalSurface(tabId: string, leafId?: string | null): boolean {
const registered = registeredTabs.get(tabId)
const manager = registered?.getManager()
+14 -1
View File
@@ -540,6 +540,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
Promise.resolve({
localFileEnabled: false,
bundleEnabled: false,
perfDumpEnabled: false,
traceFilePath: '',
traceFamilySize: 0
}),
@@ -547,7 +548,19 @@ function createWebPreloadApi(): Partial<PreloadApi> {
openBundlePreview: () => Promise.reject(new Error('Review files are unavailable on web.')),
discardBundlePreview: () => Promise.resolve(),
uploadBundle: () => Promise.reject(new Error('Sending diagnostics is unavailable on web.')),
deleteBundle: () => Promise.reject(new Error('Sent diagnostics are unavailable on web.'))
deleteBundle: () => Promise.reject(new Error('Sent diagnostics are unavailable on web.')),
capturePerfDump: () =>
Promise.reject(
new Error(
translate(
'auto.web.web.preload.api.afa470528f',
'Performance debug dumps are unavailable on web.'
)
)
),
onPerfDumpProgress: () => () => {},
onPerfMetricsRequest: () => () => {},
sendPerfMetrics: () => {}
},
session: {
// hostId mirrors the desktop bridge: omitted/'local' targets the existing
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import { narrowRendererPerfMetrics } from './renderer-perf-metrics'
describe('renderer perf metrics narrowing', () => {
it('rejects non-object payloads', () => {
expect(narrowRendererPerfMetrics(null)).toBeNull()
expect(narrowRendererPerfMetrics([])).toBeNull()
expect(narrowRendererPerfMetrics('nope')).toBeNull()
})
it('clamps hostile numeric fields and defaults missing nested objects', () => {
const metrics = narrowRendererPerfMetrics({
domNodeCount: Number.POSITIVE_INFINITY,
jsHeapUsedBytes: -42,
// Why: a 4.3 GB heap limit is a legitimate value the leak diagnostics
// must report un-clipped, not a hostile number to clamp.
jsHeapLimitBytes: 4_300_000_000,
terminalPanes: {
totalMountedPaneCount: 1.9,
worktrees: [
{
worktreeLabel: 'repo',
worktreeKind: 'ssh',
paneCount: Number.NaN,
scrollbackRowsTotal: -1,
panes: [
{
cols: 120.8,
rows: -30,
normalBufferRows: 10,
altBufferRows: Number.POSITIVE_INFINITY,
hasWebgl: true
}
]
}
]
},
browserPanes: {
browserWebviewCount: 2,
registeredBrowserGuestCount: -10
},
registries: {
ptySerializerCount: 3,
livePaneManagerCount: 4,
browserWebviewCount: 5,
registeredBrowserGuestCount: 6
}
})
expect(metrics).toMatchObject({
jsHeapUsedBytes: 0,
jsHeapLimitBytes: 4_300_000_000,
terminalPanes: {
totalMountedPaneCount: 1,
worktrees: [
{
worktreeLabel: 'repo',
worktreeKind: 'ssh',
paneCount: 0,
scrollbackRowsTotal: 0,
panes: [
{
cols: 120,
rows: 0,
normalBufferRows: 10,
altBufferRows: 0,
hasWebgl: true
}
]
}
]
},
browserPanes: {
browserWebviewCount: 2,
registeredBrowserGuestCount: 0
}
})
expect(metrics).not.toHaveProperty('domNodeCount')
})
it('caps worktree and pane arrays and marks the snapshot truncated', () => {
const panes = Array.from({ length: 150 }, () => ({
cols: 80,
rows: 24,
normalBufferRows: 1000,
altBufferRows: 24,
hasWebgl: false
}))
const worktrees = Array.from({ length: 250 }, (_, index) => ({
worktreeLabel: `repo-${index}-${'x'.repeat(120)}`,
worktreeKind: index % 2 === 0 ? 'runtime' : 'unknown',
paneCount: panes.length,
scrollbackRowsTotal: 1,
panes
}))
const metrics = narrowRendererPerfMetrics({
terminalPanes: {
totalMountedPaneCount: 999,
worktrees
},
browserPanes: {},
registries: {}
})
expect(metrics?.truncated).toBe(true)
expect(metrics?.terminalPanes.worktrees).toHaveLength(200)
expect(metrics?.terminalPanes.worktrees[0]?.panes).toHaveLength(100)
expect(metrics?.terminalPanes.worktrees[0]?.worktreeLabel.length).toBeLessThanOrEqual(80)
expect(metrics?.terminalPanes.worktrees[1]?.worktreeKind).toBe('other')
})
})
+302
View File
@@ -0,0 +1,302 @@
export type RendererPerfWorktreeKind = 'local' | 'ssh' | 'runtime' | 'floating' | 'other'
export type RendererPerfPaneMetrics = {
readonly cols: number
readonly rows: number
readonly normalBufferRows: number
readonly altBufferRows: number
readonly hasWebgl: boolean
}
export type RendererPerfWorktreeMetrics = {
readonly worktreeLabel: string
readonly worktreeKind: RendererPerfWorktreeKind
readonly paneCount: number
readonly scrollbackRowsTotal: number
readonly panes: readonly RendererPerfPaneMetrics[]
}
export type RendererPerfBrowserPaneMetrics = {
readonly browserWebviewCount: number
readonly registeredBrowserGuestCount: number
}
export type RendererPerfRegistryMetrics = {
readonly ptySerializerCount: number
readonly livePaneManagerCount: number
readonly browserWebviewCount: number
readonly registeredBrowserGuestCount: number
}
export type RendererPerfV8HeapStatistics = {
readonly totalHeapSizeBytes?: number
readonly totalHeapSizeExecutableBytes?: number
readonly totalPhysicalSizeBytes?: number
readonly totalAvailableSizeBytes?: number
readonly usedHeapSizeBytes?: number
readonly heapSizeLimitBytes?: number
readonly mallocedMemoryBytes?: number
readonly peakMallocedMemoryBytes?: number
readonly externalMemoryBytes?: number
}
export type RendererPerfMetrics = {
readonly domNodeCount?: number
readonly jsHeapUsedBytes?: number
readonly jsHeapTotalBytes?: number
readonly jsHeapLimitBytes?: number
readonly v8HeapStatistics?: RendererPerfV8HeapStatistics
readonly terminalPanes: {
readonly totalMountedPaneCount: number
readonly worktrees: readonly RendererPerfWorktreeMetrics[]
}
readonly browserPanes: RendererPerfBrowserPaneMetrics
readonly registries: RendererPerfRegistryMetrics
readonly truncated?: boolean
}
export type RendererPerfDomCounters = {
readonly documents: number
readonly nodes: number
readonly jsEventListeners: number
}
export type RendererPerfProcessMemory = Record<string, number>
export type RendererPerfUnavailableReason =
| 'no-renderer'
| 'renderer-destroyed'
| 'send-failed'
| 'timeout'
| 'invalid-response'
| 'collection-disabled'
| 'record-too-large'
export type RendererPerfRecord = {
readonly type: 'renderer-perf'
readonly schema_version: 1
readonly collected_at: string
readonly renderer?: RendererPerfMetrics
readonly main?: {
readonly domCounters?: RendererPerfDomCounters
readonly processMemoryBytes?: RendererPerfProcessMemory
}
readonly unavailableReason?: RendererPerfUnavailableReason
readonly unavailable?: Record<string, string>
readonly truncated?: boolean
}
export const MAX_WORKTREES = 200
export const MAX_PANES_PER_WORKTREE = 100
const MAX_LABEL_LENGTH = 80
// Why: fields carry byte values, and a leaky renderer heap past 1 GB is the
// exact signal diagnostics must not clip — bound at 1 PB, not 1e9.
const MAX_SAFE_COUNT = 1_000_000_000_000_000
const WORKTREE_KINDS = new Set<RendererPerfWorktreeKind>([
'local',
'ssh',
'runtime',
'floating',
'other'
])
export function narrowRendererPerfMetrics(value: unknown): RendererPerfMetrics | null {
if (!isRecord(value)) {
return null
}
let truncated = value.truncated === true
const browserPanes = narrowBrowserPanes(value.browserPanes)
const registries = narrowRegistries(value.registries, browserPanes)
const terminalPanes = narrowTerminalPanes(value.terminalPanes)
truncated = truncated || terminalPanes.truncated
const metrics: RendererPerfMetrics = {
terminalPanes: terminalPanes.value,
browserPanes,
registries,
...(optionalCount(value.domNodeCount) !== undefined
? { domNodeCount: optionalCount(value.domNodeCount) }
: {}),
...(optionalCount(value.jsHeapUsedBytes) !== undefined
? { jsHeapUsedBytes: optionalCount(value.jsHeapUsedBytes) }
: {}),
...(optionalCount(value.jsHeapTotalBytes) !== undefined
? { jsHeapTotalBytes: optionalCount(value.jsHeapTotalBytes) }
: {}),
...(optionalCount(value.jsHeapLimitBytes) !== undefined
? { jsHeapLimitBytes: optionalCount(value.jsHeapLimitBytes) }
: {}),
...(narrowV8HeapStatistics(value.v8HeapStatistics)
? { v8HeapStatistics: narrowV8HeapStatistics(value.v8HeapStatistics) }
: {}),
...(truncated ? { truncated: true } : {})
}
return metrics
}
export function clampRendererPerfCount(value: unknown): number {
return clampNonNegativeNumber(value, 0)
}
function narrowTerminalPanes(value: unknown): {
value: RendererPerfMetrics['terminalPanes']
truncated: boolean
} {
if (!isRecord(value)) {
return { value: { totalMountedPaneCount: 0, worktrees: [] }, truncated: false }
}
const rawWorktrees = Array.isArray(value.worktrees) ? value.worktrees : []
const narrowedWorktrees = rawWorktrees.slice(0, MAX_WORKTREES).map(narrowWorktree)
const worktrees = narrowedWorktrees.map(([worktree]) => worktree)
return {
value: {
totalMountedPaneCount: clampRendererPerfCount(value.totalMountedPaneCount),
worktrees
},
truncated:
rawWorktrees.length > worktrees.length ||
narrowedWorktrees.some(([, wasTruncated]) => wasTruncated)
}
}
function narrowWorktree(value: unknown): [RendererPerfWorktreeMetrics, boolean] {
if (!isRecord(value)) {
return [
{
worktreeLabel: 'worktree',
worktreeKind: 'other',
paneCount: 0,
scrollbackRowsTotal: 0,
panes: []
},
false
]
}
const rawPanes = Array.isArray(value.panes) ? value.panes : []
const panes = rawPanes.slice(0, MAX_PANES_PER_WORKTREE).map(narrowPane)
const label = stringField(value.worktreeLabel, MAX_LABEL_LENGTH) ?? 'worktree'
const worktreeKind = WORKTREE_KINDS.has(value.worktreeKind as RendererPerfWorktreeKind)
? (value.worktreeKind as RendererPerfWorktreeKind)
: 'other'
return [
{
worktreeLabel: label,
worktreeKind,
paneCount: clampRendererPerfCount(value.paneCount),
scrollbackRowsTotal: clampRendererPerfCount(value.scrollbackRowsTotal),
panes
},
rawPanes.length > panes.length || label.length < String(value.worktreeLabel ?? '').length
]
}
function narrowPane(value: unknown): RendererPerfPaneMetrics {
if (!isRecord(value)) {
return {
cols: 0,
rows: 0,
normalBufferRows: 0,
altBufferRows: 0,
hasWebgl: false
}
}
return {
cols: clampRendererPerfCount(value.cols),
rows: clampRendererPerfCount(value.rows),
normalBufferRows: clampRendererPerfCount(value.normalBufferRows),
altBufferRows: clampRendererPerfCount(value.altBufferRows),
hasWebgl: value.hasWebgl === true
}
}
function narrowBrowserPanes(value: unknown): RendererPerfBrowserPaneMetrics {
if (!isRecord(value)) {
return { browserWebviewCount: 0, registeredBrowserGuestCount: 0 }
}
return {
browserWebviewCount: clampRendererPerfCount(value.browserWebviewCount),
registeredBrowserGuestCount: clampRendererPerfCount(value.registeredBrowserGuestCount)
}
}
function narrowRegistries(
value: unknown,
browserPanes: RendererPerfBrowserPaneMetrics
): RendererPerfRegistryMetrics {
if (!isRecord(value)) {
return {
ptySerializerCount: 0,
livePaneManagerCount: 0,
browserWebviewCount: browserPanes.browserWebviewCount,
registeredBrowserGuestCount: browserPanes.registeredBrowserGuestCount
}
}
return {
ptySerializerCount: clampRendererPerfCount(value.ptySerializerCount),
livePaneManagerCount: clampRendererPerfCount(value.livePaneManagerCount),
browserWebviewCount: clampRendererPerfCount(value.browserWebviewCount),
registeredBrowserGuestCount: clampRendererPerfCount(value.registeredBrowserGuestCount)
}
}
function narrowV8HeapStatistics(value: unknown): RendererPerfV8HeapStatistics | undefined {
if (!isRecord(value)) {
return undefined
}
const stats: RendererPerfV8HeapStatistics = {}
copyOptionalCount(value, stats, 'totalHeapSizeBytes')
copyOptionalCount(value, stats, 'totalHeapSizeExecutableBytes')
copyOptionalCount(value, stats, 'totalPhysicalSizeBytes')
copyOptionalCount(value, stats, 'totalAvailableSizeBytes')
copyOptionalCount(value, stats, 'usedHeapSizeBytes')
copyOptionalCount(value, stats, 'heapSizeLimitBytes')
copyOptionalCount(value, stats, 'mallocedMemoryBytes')
copyOptionalCount(value, stats, 'peakMallocedMemoryBytes')
copyOptionalCount(value, stats, 'externalMemoryBytes')
return Object.keys(stats).length > 0 ? stats : undefined
}
function copyOptionalCount(
source: Record<string, unknown>,
target: Record<string, number>,
key: string
): void {
const value = optionalCount(source[key])
if (value !== undefined) {
target[key] = value
}
}
function optionalCount(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return undefined
}
return clampNonNegativeNumber(value, 0)
}
function clampNonNegativeNumber(value: unknown, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return fallback
}
return Math.min(MAX_SAFE_COUNT, Math.max(0, Math.floor(value)))
}
function stringField(value: unknown, maxLength: number): string | undefined {
if (typeof value !== 'string') {
return undefined
}
const trimmed = value.trim()
if (!trimmed) {
return undefined
}
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}