From 9fadc8deeb7c27d63acad56351f1882559bb09d3 Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Fri, 3 Jul 2026 17:29:50 -0700 Subject: [PATCH] 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 --- src/main/browser/cdp-ws-proxy.test.ts | 4 +- src/main/ipc/crash-reporting.ts | 14 +- src/main/ipc/diagnostics.test.ts | 2 +- src/main/ipc/diagnostics.ts | 46 ++- src/main/ipc/register-core-handlers.test.ts | 6 +- src/main/ipc/register-core-handlers.ts | 3 +- src/main/observability/bundle.test.ts | 56 +++ src/main/observability/bundle.ts | 65 +++- src/main/observability/index.ts | 10 +- src/main/observability/perf-dump.test.ts | 167 +++++++++ src/main/observability/perf-dump.ts | 321 ++++++++++++++++++ src/main/observability/renderer-perf.test.ts | 203 +++++++++++ src/main/observability/renderer-perf.ts | 287 ++++++++++++++++ src/main/observability/tar-archive.test.ts | 64 ++++ src/main/observability/tar-archive.ts | 106 ++++++ src/preload/api-types.ts | 17 + src/preload/index.ts | 69 +++- .../settings/PrivacyDiagnosticsSection.tsx | 69 +++- .../settings/PrivacyPerfDumpControls.tsx | 74 ++++ .../src/components/settings/privacy-search.ts | 11 +- .../terminal-pane/pty-buffer-serializer.ts | 4 + src/renderer/src/i18n/locales/en.json | 28 +- src/renderer/src/i18n/locales/es.json | 28 +- src/renderer/src/i18n/locales/ja.json | 28 +- src/renderer/src/i18n/locales/ko.json | 28 +- src/renderer/src/i18n/locales/zh.json | 28 +- .../lib/pane-manager/pane-manager-registry.ts | 4 + .../src/lib/renderer-perf-snapshot.test.ts | 182 ++++++++++ .../src/lib/renderer-perf-snapshot.ts | 260 ++++++++++++++ src/renderer/src/main.tsx | 2 + .../src/runtime/sync-runtime-graph.ts | 10 + src/renderer/src/web/web-preload-api.ts | 15 +- src/shared/renderer-perf-metrics.test.ts | 112 ++++++ src/shared/renderer-perf-metrics.ts | 302 ++++++++++++++++ 34 files changed, 2596 insertions(+), 29 deletions(-) create mode 100644 src/main/observability/perf-dump.test.ts create mode 100644 src/main/observability/perf-dump.ts create mode 100644 src/main/observability/renderer-perf.test.ts create mode 100644 src/main/observability/renderer-perf.ts create mode 100644 src/main/observability/tar-archive.test.ts create mode 100644 src/main/observability/tar-archive.ts create mode 100644 src/renderer/src/components/settings/PrivacyPerfDumpControls.tsx create mode 100644 src/renderer/src/lib/renderer-perf-snapshot.test.ts create mode 100644 src/renderer/src/lib/renderer-perf-snapshot.ts create mode 100644 src/shared/renderer-perf-metrics.test.ts create mode 100644 src/shared/renderer-perf-metrics.ts diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts index 15976e9c729..c5fa3361d96 100644 --- a/src/main/browser/cdp-ws-proxy.test.ts +++ b/src/main/browser/cdp-ws-proxy.test.ts @@ -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) => void = () => {} mock.webContents.printToPDF.mockImplementationOnce( () => - new Promise((resolve) => { + new Promise>((resolve) => { resolvePrint = resolve }) ) diff --git a/src/main/ipc/crash-reporting.ts b/src/main/ipc/crash-reporting.ts index 7d60da1799f..d1b0d0ae286 100644 --- a/src/main/ipc/crash-reporting.ts +++ b/src/main/ipc/crash-reporting.ts @@ -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 { const status = getDiagnosticsStatus() if (!status.bundleEnabled) { return { @@ -345,6 +347,9 @@ function collectCrashDiagnosticBundleAttachment(): CrashDiagnosticBundleAttachme } } + const perfRecord = await collectRendererPerfMetrics(getDiagnosticsRendererWebContents, { + timeoutMs: 750 + }) let bundle: ReturnType 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), diff --git a/src/main/ipc/diagnostics.test.ts b/src/main/ipc/diagnostics.test.ts index b30d013f6ea..d19482e3a06 100644 --- a/src/main/ipc/diagnostics.test.ts +++ b/src/main/ipc/diagnostics.test.ts @@ -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 diff --git a/src/main/ipc/diagnostics.ts b/src/main/ipc/diagnostics.ts index 53d8d2f6ef3..841109ee91d 100644 --- a/src/main/ipc/diagnostics.ts +++ b/src/main/ipc/diagnostics.ts @@ -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 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() +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 { 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 => { // 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 => { + 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 => { diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index f1fea8f7765..d3687df40d2 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -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() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index b54a54493ec..71fad6b8a13 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -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 diff --git a/src/main/observability/bundle.test.ts b/src/main/observability/bundle.test.ts index bee1a2c312f..e814acdb6ea 100644 --- a/src/main/observability/bundle.test.ts +++ b/src/main/observability/bundle.test.ts @@ -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: { diff --git a/src/main/observability/bundle.ts b/src/main/observability/bundle.ts index 812e354d51f..f3246c428c5 100644 --- a/src/main/observability/bundle.ts +++ b/src/main/observability/bundle.ts @@ -40,6 +40,7 @@ export type CollectBundleOptions = { readonly traceFilePath: string readonly maxFiles: number readonly lookbackMinutes?: number + readonly extraRecords?: readonly Record[] 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 { 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, + 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 = { truncated: true } + for (const [key, child] of Object.entries(value)) { + result[key] = markExtraRecordTruncated(child, depth + 1) + } + return result } diff --git a/src/main/observability/index.ts b/src/main/observability/index.ts index 9ab216d50e1..142772bd9b5 100644 --- a/src/main/observability/index.ts +++ b/src/main/observability/index.ts @@ -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 diff --git a/src/main/observability/perf-dump.test.ts b/src/main/observability/perf-dump.test.ts new file mode 100644 index 00000000000..8f349dc3715 --- /dev/null +++ b/src/main/observability/perf-dump.test.ts @@ -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 = {}): unknown { + return { + isDestroyed: () => false, + takeHeapSnapshot: vi.fn(async (filePath: string) => { + await writeFile(filePath, '{"snapshot":true}', 'utf8') + }), + ...overrides + } +} + +function readTarEntries(archive: Buffer): Map { + const tar = gunzipSync(archive) + const entries = new Map() + 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 + } + 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']) + }) +}) diff --git a/src/main/observability/perf-dump.ts b/src/main/observability/perf-dump.ts new file mode 100644 index 00000000000..d7f880b31da --- /dev/null +++ b/src/main/observability/perf-dump.ts @@ -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 | null = null + +export async function captureRendererPerfDump( + opts: CapturePerfDumpOptions +): Promise { + 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 { + 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 { + 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 = {} + 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 { + 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, + entries: TarArchiveEntry[] +): Promise { + 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, + entries: TarArchiveEntry[] +): Promise { + 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 | null = null + +async function captureHeapArtifact( + captureDir: string, + getRendererWebContents: () => WebContents | null, + artifacts: Record, + entries: TarArchiveEntry[] +): Promise { + 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 +): Promise { + 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 { + 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(promise: Promise, timeoutMs: number): Promise { + let timeout: NodeJS.Timeout | null = null + try { + return await Promise.race([ + promise, + new Promise((_, 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' +} diff --git a/src/main/observability/renderer-perf.test.ts b/src/main/observability/renderer-perf.test.ts new file mode 100644 index 00000000000..7962b385924 --- /dev/null +++ b/src/main/observability/renderer-perf.test.ts @@ -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 + debugger: { + isAttached: () => boolean + attach: (version: string) => void + detach: () => void + sendCommand: ReturnType + } +} + +function makeWebContents(overrides: Partial = {}): 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') + }) +}) diff --git a/src/main/observability/renderer-perf.ts b/src/main/observability/renderer-perf.ts new file mode 100644 index 00000000000..185e77a7570 --- /dev/null +++ b/src/main/observability/renderer-perf.ts @@ -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() +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 { + const collectedAt = new Date().toISOString() + const webContents = getRendererWebContents() + const unavailable: Record = {} + + 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((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 + domCountersReason?: string + processMemoryReason?: string +}> { + const [domCounters, processMemory] = await Promise.all([ + collectDomCounters(webContents, timeoutMs), + collectProcessMemory(webContents) + ]) + const metrics: NonNullable = { + ...(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 + 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(promise: Promise, timeoutMs: number): Promise { + let timeout: NodeJS.Timeout | null = null + try { + return await Promise.race([ + promise, + new Promise((_, 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' +} diff --git a/src/main/observability/tar-archive.test.ts b/src/main/observability/tar-archive.test.ts new file mode 100644 index 00000000000..cc64c806ecd --- /dev/null +++ b/src/main/observability/tar-archive.test.ts @@ -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) +} diff --git a/src/main/observability/tar-archive.ts b/src/main/observability/tar-archive.ts new file mode 100644 index 00000000000..1f730511e78 --- /dev/null +++ b/src/main/observability/tar-archive.ts @@ -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 { + 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 { + 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') +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 35bf793840a..d904eb6afa7 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 @@ -1877,6 +1890,10 @@ export type PreloadApi = { discardBundlePreview: (bundleSubmissionId: string) => Promise uploadBundle: (bundleSubmissionId: string) => Promise deleteBundle: (ticketId: string) => Promise + capturePerfDump: () => Promise + 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 diff --git a/src/preload/index.ts b/src/preload/index.ts index 333e298eabe..11388b01312 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -437,6 +437,45 @@ document.addEventListener( const startupDiagnosticsEnabled = process.env.ORCA_STARTUP_DIAGNOSTICS === '1' +function getV8HeapStatisticsBytes(): Record | undefined { + const heapStats = ( + process as NodeJS.Process & { getHeapStatistics?: () => Record } + ).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 + 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 => ipcRenderer.invoke('diagnostics:uploadBundle', bundleSubmissionId), deleteBundle: (ticketId: string): Promise => - ipcRenderer.invoke('diagnostics:deleteBundle', ticketId) + ipcRenderer.invoke('diagnostics:deleteBundle', ticketId), + capturePerfDump: (): Promise => 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): void => { + ipcRenderer.send('diagnostics:rendererPerf:response', { + requestId, + metrics: { + ...metrics, + v8HeapStatistics: getV8HeapStatisticsBytes() + } + }) + } }, settings: { diff --git a/src/renderer/src/components/settings/PrivacyDiagnosticsSection.tsx b/src/renderer/src/components/settings/PrivacyDiagnosticsSection.tsx index 78d664988ec..15f9069e45b 100644 --- a/src/renderer/src/components/settings/PrivacyDiagnosticsSection.tsx +++ b/src/renderer/src/components/settings/PrivacyDiagnosticsSection.tsx @@ -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(null) const mountedRef = useRef(true) const activeBundleSubmissionIdRef = useRef(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 => { setCollecting(true) try { @@ -235,6 +247,43 @@ export function PrivacyDiagnosticsSection(): React.JSX.Element { } }, [ticketId]) + const handleCapturePerfDump = useCallback(async (): Promise => { + 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)} /> + {showPerfDumpRow ? ( + } + title={translate( + 'auto.components.settings.PrivacyDiagnosticsSection.4c7eb24bdd', + 'Capture performance debug dump' + )} + description={getPerfDumpDescription(capturingPerfDump ? perfDumpStage : null)} + > + + + ) : null} ) } diff --git a/src/renderer/src/components/settings/PrivacyPerfDumpControls.tsx b/src/renderer/src/components/settings/PrivacyPerfDumpControls.tsx new file mode 100644 index 00000000000..0b538f78fa5 --- /dev/null +++ b/src/renderer/src/components/settings/PrivacyPerfDumpControls.tsx @@ -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 +}): React.JSX.Element { + return ( + + ) +} + +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.' + ) +} diff --git a/src/renderer/src/components/settings/privacy-search.ts b/src/renderer/src/components/settings/privacy-search.ts index 6e0c744f581..1a2585bb1b5 100644 --- a/src/renderer/src/components/settings/privacy-search.ts +++ b/src/renderer/src/components/settings/privacy-search.ts @@ -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') ] }, { diff --git a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts index 46d3451c633..416258849b6 100644 --- a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts +++ b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts @@ -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 diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 32395a56253..7ad1eda00b1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ea305d3abb7..249912665fa 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index fc9795ca8c0..241ffa7cac6 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5ab1eade73a..b37c17867eb 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 5f9fff50376..6610a0352c1 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": { diff --git a/src/renderer/src/lib/pane-manager/pane-manager-registry.ts b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts index 5fa2be90a20..3f031680916 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-registry.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts @@ -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. * diff --git a/src/renderer/src/lib/renderer-perf-snapshot.test.ts b/src/renderer/src/lib/renderer-perf-snapshot.test.ts new file mode 100644 index 00000000000..4bbbcaa8214 --- /dev/null +++ b/src/renderer/src/lib/renderer-perf-snapshot.test.ts @@ -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 = '
' + 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 +} diff --git a/src/renderer/src/lib/renderer-perf-snapshot.ts b/src/renderer/src/lib/renderer-perf-snapshot.ts new file mode 100644 index 00000000000..5b29ce78729 --- /dev/null +++ b/src/renderer/src/lib/renderer-perf-snapshot.ts @@ -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() + const fallbackLabels = new Map() + 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 +): 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 { + 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 +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 424e838c234..07aec23c6e5 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -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 && diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index bfe9d86ef0c..ed7d66fd375 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -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() diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 1da70b789e5..92e6f251812 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -540,6 +540,7 @@ function createWebPreloadApi(): Partial { Promise.resolve({ localFileEnabled: false, bundleEnabled: false, + perfDumpEnabled: false, traceFilePath: '', traceFamilySize: 0 }), @@ -547,7 +548,19 @@ function createWebPreloadApi(): Partial { 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 diff --git a/src/shared/renderer-perf-metrics.test.ts b/src/shared/renderer-perf-metrics.test.ts new file mode 100644 index 00000000000..5a370d194dd --- /dev/null +++ b/src/shared/renderer-perf-metrics.test.ts @@ -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') + }) +}) diff --git a/src/shared/renderer-perf-metrics.ts b/src/shared/renderer-perf-metrics.ts new file mode 100644 index 00000000000..ab7718647df --- /dev/null +++ b/src/shared/renderer-perf-metrics.ts @@ -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 + +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 + 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([ + '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, + target: Record, + 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 { + return typeof value === 'object' && value !== null && !Array.isArray(value) +}