mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
Add renderer perf metrics to diagnostics and a one-click performance debug dump
Diagnostic bundles (Settings > Privacy and crash reports) now embed a redacted, aggregate renderer-perf record: DOM node/listener counts, JS heap, per-worktree mounted terminal pane and scrollback counts, browser pane counts, and registry sizes, collected only at report time with timeout-guarded degradation. A new Settings > Privacy action captures a 10s Chromium trace plus a renderer heap snapshot into one local tar.gz behind an explicit consent dialog. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -647,10 +647,10 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
it('does not register a PDF stream when the client disconnects mid-print', async () => {
|
||||
let resolvePrint: (buf: Buffer) => void = () => {}
|
||||
let resolvePrint: (buf: Buffer<ArrayBuffer>) => void = () => {}
|
||||
mock.webContents.printToPDF.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Buffer>((resolve) => {
|
||||
new Promise<Buffer<ArrayBuffer>>((resolve) => {
|
||||
resolvePrint = resolve
|
||||
})
|
||||
)
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
} from '../crash-reporting/crash-breadcrumb-store'
|
||||
import { collectDiagnosticBundle, getDiagnosticsStatus } from '../observability'
|
||||
import { resolveDiagnosticOrcaChannel } from '../observability/diagnostic-upload-endpoint'
|
||||
import { collectRendererPerfMetrics } from '../observability/renderer-perf'
|
||||
import { getDiagnosticsRendererWebContents } from './diagnostics'
|
||||
import { startSpan } from '../observability/tracer'
|
||||
import type { FeedbackDiagnosticBundleAttachment } from './feedback'
|
||||
import {
|
||||
@@ -334,7 +336,7 @@ function skippedCrashDiagnosticBundle(): CrashDiagnosticBundleAttachment {
|
||||
}
|
||||
}
|
||||
|
||||
function collectCrashDiagnosticBundleAttachment(): CrashDiagnosticBundleAttachment {
|
||||
async function collectCrashDiagnosticBundleAttachment(): Promise<CrashDiagnosticBundleAttachment> {
|
||||
const status = getDiagnosticsStatus()
|
||||
if (!status.bundleEnabled) {
|
||||
return {
|
||||
@@ -345,6 +347,9 @@ function collectCrashDiagnosticBundleAttachment(): CrashDiagnosticBundleAttachme
|
||||
}
|
||||
}
|
||||
|
||||
const perfRecord = await collectRendererPerfMetrics(getDiagnosticsRendererWebContents, {
|
||||
timeoutMs: 750
|
||||
})
|
||||
let bundle: ReturnType<typeof collectDiagnosticBundle>
|
||||
try {
|
||||
bundle = collectDiagnosticBundle({
|
||||
@@ -355,7 +360,8 @@ function collectCrashDiagnosticBundleAttachment(): CrashDiagnosticBundleAttachme
|
||||
orcaChannel: resolveDiagnosticOrcaChannel(),
|
||||
// Why: Help > Report Crash is often used after relaunch, long after the
|
||||
// default 30 minute support bundle window would miss the failure context.
|
||||
lookbackMinutes: CRASH_REPORT_LOG_LOOKBACK_MINUTES
|
||||
lookbackMinutes: CRASH_REPORT_LOG_LOOKBACK_MINUTES,
|
||||
extraRecords: [perfRecord]
|
||||
})
|
||||
} catch (error) {
|
||||
return { diagnosticBundle: { status: 'not_uploaded', reason: formatUnknownError(error) } }
|
||||
@@ -451,7 +457,7 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
|
||||
const diagnosticUpload =
|
||||
args.includeDiagnosticLogs === false
|
||||
? skippedCrashDiagnosticBundle()
|
||||
: collectCrashDiagnosticBundleAttachment()
|
||||
: await collectCrashDiagnosticBundleAttachment()
|
||||
const diagnosticBundle = diagnosticUpload.diagnosticBundle
|
||||
const result = await submitFeedback({
|
||||
feedback: buildUncapturedCrashReportText(args.notes, diagnosticBundle),
|
||||
@@ -494,7 +500,7 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
|
||||
const diagnosticUpload =
|
||||
args.includeDiagnosticLogs === false
|
||||
? skippedCrashDiagnosticBundle()
|
||||
: collectCrashDiagnosticBundleAttachment()
|
||||
: await collectCrashDiagnosticBundleAttachment()
|
||||
const diagnosticBundle = diagnosticUpload.diagnosticBundle
|
||||
const result = await submitFeedback({
|
||||
feedback: formatCrashReportText(report, args.notes, diagnosticBundle),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// IPC surface for the error-tracking lane (telemetry-error-tracking.md
|
||||
// §User controls). Six renderer-facing channels:
|
||||
// §User controls). Renderer-facing channels:
|
||||
//
|
||||
// diagnostics:getStatus — read-only snapshot for the Privacy pane.
|
||||
// diagnostics:collectBundle — assemble and retain a redacted payload.
|
||||
@@ -7,6 +7,7 @@
|
||||
// diagnostics:discardBundlePreview — delete a retained, unuploaded payload.
|
||||
// diagnostics:uploadBundle — POST the main-retained payload.
|
||||
// diagnostics:deleteBundle — delete an uploaded bundle by ticket ID.
|
||||
// diagnostics:capturePerfDump — local renderer perf trace + heap dump.
|
||||
//
|
||||
// Same threat model as the product-telemetry IPC (`ipc/telemetry.ts`):
|
||||
// renderer can pass anything over the wire, type-narrow here. Everything
|
||||
@@ -18,11 +19,19 @@
|
||||
// renderer triggers the flow; main reads the URL from a build-time
|
||||
// constant or env var and does the POST itself.
|
||||
|
||||
import { app, dialog, ipcMain, shell } from 'electron'
|
||||
import {
|
||||
app,
|
||||
dialog,
|
||||
ipcMain,
|
||||
shell,
|
||||
webContents as electronWebContents,
|
||||
type WebContents
|
||||
} from 'electron'
|
||||
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { arch as osArch, platform as osPlatform, release as osRelease } from 'node:os'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { captureRendererPerfDump, type PerfDumpProgressStage } from '../observability/perf-dump'
|
||||
import {
|
||||
collectDiagnosticBundle,
|
||||
deleteDiagnosticBundle,
|
||||
@@ -36,12 +45,14 @@ import {
|
||||
resolveDiagnosticOrcaChannel,
|
||||
resolveDiagnosticTokenEndpoint
|
||||
} from '../observability/diagnostic-upload-endpoint'
|
||||
import { collectRendererPerfMetrics } from '../observability/renderer-perf'
|
||||
|
||||
export type DiagnosticsBundlePreview = Omit<CollectedBundle, 'payload'>
|
||||
type UploadBundleIpcResult = UploadBundleResult | { canceled: true }
|
||||
|
||||
const PENDING_BUNDLE_TTL_MS = 15 * 60 * 1000
|
||||
const MAX_PENDING_BUNDLES = 8
|
||||
let diagnosticsRendererWebContentsId: number | null = null
|
||||
|
||||
type PendingBundle = {
|
||||
bundle: CollectedBundle
|
||||
@@ -53,6 +64,10 @@ type PendingBundle = {
|
||||
|
||||
const pendingBundles = new Map<string, PendingBundle>()
|
||||
|
||||
export function setDiagnosticsRendererWebContentsId(webContentsId: number | null): void {
|
||||
diagnosticsRendererWebContentsId = webContentsId
|
||||
}
|
||||
|
||||
function prunePendingBundles(now = Date.now()): void {
|
||||
for (const [id, pending] of pendingBundles) {
|
||||
if (now - pending.createdAtMs > PENDING_BUNDLE_TTL_MS) {
|
||||
@@ -190,6 +205,13 @@ function isTicketId(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^[A-Za-z0-9_-]{16,64}$/.test(value)
|
||||
}
|
||||
|
||||
export function getDiagnosticsRendererWebContents(): WebContents | null {
|
||||
if (diagnosticsRendererWebContentsId == null) {
|
||||
return null
|
||||
}
|
||||
return electronWebContents.fromId(diagnosticsRendererWebContentsId) ?? null
|
||||
}
|
||||
|
||||
async function confirmBundleUpload(bundle: CollectedBundle): Promise<boolean> {
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'question',
|
||||
@@ -212,7 +234,7 @@ export function registerDiagnosticsHandlers(): void {
|
||||
|
||||
ipcMain.handle(
|
||||
'diagnostics:collectBundle',
|
||||
(_event, lookbackMinutesIn: unknown): DiagnosticsBundlePreview => {
|
||||
async (_event, lookbackMinutesIn: unknown): Promise<DiagnosticsBundlePreview> => {
|
||||
// Consent gate: main is the consent enforcement boundary; the
|
||||
// renderer-side button-hide is UX, not security. A compromised or
|
||||
// malicious renderer must not be able to assemble a bundle when the
|
||||
@@ -228,12 +250,14 @@ export function registerDiagnosticsHandlers(): void {
|
||||
typeof lookbackMinutesIn === 'number' && Number.isFinite(lookbackMinutesIn)
|
||||
? Math.max(1, Math.min(30 * 24 * 60, Math.floor(lookbackMinutesIn)))
|
||||
: undefined
|
||||
const perfRecord = await collectRendererPerfMetrics(getDiagnosticsRendererWebContents)
|
||||
const bundle = collectDiagnosticBundle({
|
||||
appVersion: app.getVersion(),
|
||||
platform: osPlatform(),
|
||||
arch: osArch(),
|
||||
osRelease: osRelease(),
|
||||
orcaChannel: resolveDiagnosticOrcaChannel(),
|
||||
extraRecords: [perfRecord],
|
||||
...(lookbackMinutes !== undefined ? { lookbackMinutes } : {})
|
||||
})
|
||||
rememberBundle(bundle)
|
||||
@@ -241,6 +265,22 @@ export function registerDiagnosticsHandlers(): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('diagnostics:capturePerfDump', async (event): Promise<unknown> => {
|
||||
const status = getDiagnosticsStatus()
|
||||
if (!status.perfDumpEnabled) {
|
||||
throw new Error('performance debug dumps are disabled')
|
||||
}
|
||||
const sender = event.sender
|
||||
return captureRendererPerfDump({
|
||||
getRendererWebContents: getDiagnosticsRendererWebContents,
|
||||
onProgress: (stage: PerfDumpProgressStage) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send('diagnostics:perfDump:progress', { stage })
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'diagnostics:uploadBundle',
|
||||
async (_event, bundleSubmissionId: unknown): Promise<UploadBundleIpcResult> => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -40,6 +40,7 @@ export type CollectBundleOptions = {
|
||||
readonly traceFilePath: string
|
||||
readonly maxFiles: number
|
||||
readonly lookbackMinutes?: number
|
||||
readonly extraRecords?: readonly Record<string, unknown>[]
|
||||
readonly appVersion: string
|
||||
readonly platform: string
|
||||
readonly arch: string
|
||||
@@ -70,6 +71,8 @@ type BundleHeader = {
|
||||
readonly schema_version: 1
|
||||
}
|
||||
|
||||
const MAX_EXTRA_RECORD_BYTES = 64 * 1024
|
||||
|
||||
function* readLinesNewestFirst(text: string): Iterable<string> {
|
||||
let end = text.length
|
||||
while (end > 0) {
|
||||
@@ -116,6 +119,15 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle {
|
||||
// Avoids re-running `lines.join('\n').length` every iteration — that's
|
||||
// O(N²) in span count and dominates collection time for large backlogs.
|
||||
let currentBytes = Buffer.byteLength(`${headerLine}\n`)
|
||||
|
||||
for (const extraRecord of opts.extraRecords ?? []) {
|
||||
const line = serializeExtraRecord(extraRecord, MAX_BUNDLE_BYTES - currentBytes)
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
lines.push(line)
|
||||
currentBytes += Buffer.byteLength(line) + 1
|
||||
}
|
||||
const maxRecordBytes = MAX_BUNDLE_BYTES - currentBytes
|
||||
|
||||
// Files from listRotatedFiles are newest → oldest. Reading newest first
|
||||
@@ -219,5 +231,56 @@ export function generateBundleSubmissionId(): string {
|
||||
|
||||
// Test-only export.
|
||||
export const _internalsForTests = {
|
||||
MAX_BUNDLE_BYTES
|
||||
MAX_BUNDLE_BYTES,
|
||||
MAX_EXTRA_RECORD_BYTES
|
||||
}
|
||||
|
||||
function serializeExtraRecord(
|
||||
record: Record<string, unknown>,
|
||||
remainingBytes: number
|
||||
): string | null {
|
||||
const budget = Math.min(MAX_EXTRA_RECORD_BYTES, Math.max(0, remainingBytes))
|
||||
if (budget <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const redacted = redactValue(record, 'server')
|
||||
const serialized = JSON.stringify(redacted)
|
||||
if (Buffer.byteLength(serialized) + 1 <= budget) {
|
||||
return serialized
|
||||
}
|
||||
|
||||
const truncated = JSON.stringify(redactValue(markExtraRecordTruncated(record), 'server'))
|
||||
if (Buffer.byteLength(truncated) + 1 <= budget) {
|
||||
return truncated
|
||||
}
|
||||
|
||||
const minimal = JSON.stringify({
|
||||
type: typeof record.type === 'string' ? record.type : 'extra-record',
|
||||
unavailableReason: 'record-too-large'
|
||||
})
|
||||
return Buffer.byteLength(minimal) + 1 <= budget ? minimal : null
|
||||
}
|
||||
|
||||
function markExtraRecordTruncated(value: unknown, depth = 0): unknown {
|
||||
// Why: extra records come from trusted main-side callers, but a depth cap
|
||||
// keeps a mis-shaped record from recursing unboundedly here.
|
||||
if (depth >= 8) {
|
||||
return '[truncated]'
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.slice(0, 10).map((child) => markExtraRecordTruncated(child, depth + 1))
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.length > 1024 ? value.slice(0, 1024) : value
|
||||
}
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return value
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = { truncated: true }
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
result[key] = markExtraRecordTruncated(child, depth + 1)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { gunzipSync } from 'node:zlib'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { showMessageBoxMock, startRecordingMock, stopRecordingMock, getPathMock } = vi.hoisted(
|
||||
() => ({
|
||||
showMessageBoxMock: vi.fn(),
|
||||
startRecordingMock: vi.fn(),
|
||||
stopRecordingMock: vi.fn(),
|
||||
getPathMock: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: getPathMock, getVersion: () => '1.2.3-test' },
|
||||
dialog: { showMessageBox: showMessageBoxMock },
|
||||
contentTracing: { startRecording: startRecordingMock, stopRecording: stopRecordingMock }
|
||||
}))
|
||||
|
||||
// Why: the trace stage waits 10 s in production; tests must not.
|
||||
vi.mock('node:timers/promises', () => ({ setTimeout: async () => {} }))
|
||||
|
||||
vi.mock('./renderer-perf', () => ({
|
||||
collectRendererPerfMetrics: vi.fn(async () => ({
|
||||
type: 'renderer-perf',
|
||||
schema_version: 1,
|
||||
collected_at: '2026-01-01T00:00:00.000Z'
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('../i18n/main-i18n', () => ({
|
||||
translateMain: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
import { captureRendererPerfDump } from './perf-dump'
|
||||
|
||||
let tempRoot: string
|
||||
let downloadsDir: string
|
||||
|
||||
function makeRenderer(overrides: Record<string, unknown> = {}): unknown {
|
||||
return {
|
||||
isDestroyed: () => false,
|
||||
takeHeapSnapshot: vi.fn(async (filePath: string) => {
|
||||
await writeFile(filePath, '{"snapshot":true}', 'utf8')
|
||||
}),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function readTarEntries(archive: Buffer): Map<string, string> {
|
||||
const tar = gunzipSync(archive)
|
||||
const entries = new Map<string, string>()
|
||||
let offset = 0
|
||||
while (offset + 512 <= tar.length) {
|
||||
const name = tar
|
||||
.subarray(offset, offset + 100)
|
||||
.toString('utf8')
|
||||
.split('\0')[0]
|
||||
if (!name) {
|
||||
break
|
||||
}
|
||||
const size = Number.parseInt(tar.subarray(offset + 124, offset + 136).toString('ascii'), 8)
|
||||
entries.set(name, tar.subarray(offset + 512, offset + 512 + size).toString('utf8'))
|
||||
offset += 512 + Math.ceil(size / 512) * 512
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
describe('captureRendererPerfDump', () => {
|
||||
beforeEach(async () => {
|
||||
tempRoot = mkdtempSync(join(tmpdir(), 'orca-perf-dump-test-'))
|
||||
downloadsDir = join(tempRoot, 'downloads')
|
||||
await mkdir(downloadsDir, { recursive: true })
|
||||
getPathMock.mockImplementation((name: string) =>
|
||||
name === 'downloads' ? downloadsDir : join(tempRoot, name)
|
||||
)
|
||||
showMessageBoxMock.mockResolvedValue({ response: 0 })
|
||||
startRecordingMock.mockResolvedValue(undefined)
|
||||
stopRecordingMock.mockImplementation(async (filePath: string) => {
|
||||
await writeFile(filePath, '{"traceEvents":[]}', 'utf8')
|
||||
return filePath
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempRoot, { recursive: true, force: true })
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('produces a tar.gz containing metadata, metrics, trace, and heap snapshot', async () => {
|
||||
const result = await captureRendererPerfDump({
|
||||
getRendererWebContents: () => makeRenderer() as never
|
||||
})
|
||||
|
||||
expect(result).not.toHaveProperty('canceled')
|
||||
const { filePath, bytes } = result as { filePath: string; bytes: number }
|
||||
expect(bytes).toBeGreaterThan(0)
|
||||
const names = [...readTarEntries(await readFile(filePath)).keys()]
|
||||
expect(names).toEqual([
|
||||
'metadata.json',
|
||||
'renderer-perf-metrics.json',
|
||||
'trace.json',
|
||||
'renderer-heap.heapsnapshot'
|
||||
])
|
||||
// Temp capture directory is removed after packaging.
|
||||
expect(await readdir(join(tempRoot, 'temp', 'orca-perf-dumps'))).toEqual([])
|
||||
})
|
||||
|
||||
it('returns canceled without touching tracing when the consent dialog is declined', async () => {
|
||||
showMessageBoxMock.mockResolvedValue({ response: 1 })
|
||||
|
||||
const result = await captureRendererPerfDump({
|
||||
getRendererWebContents: () => makeRenderer() as never
|
||||
})
|
||||
|
||||
expect(result).toEqual({ canceled: true })
|
||||
expect(startRecordingMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('coalesces concurrent captures onto one consent dialog and one capture', async () => {
|
||||
const renderer = makeRenderer()
|
||||
const [first, second] = await Promise.all([
|
||||
captureRendererPerfDump({ getRendererWebContents: () => renderer as never }),
|
||||
captureRendererPerfDump({ getRendererWebContents: () => renderer as never })
|
||||
])
|
||||
|
||||
expect(showMessageBoxMock).toHaveBeenCalledTimes(1)
|
||||
expect(startRecordingMock).toHaveBeenCalledTimes(1)
|
||||
expect(first).toEqual(second)
|
||||
})
|
||||
|
||||
it('still produces a dump with failure notes when trace and heap capture fail', async () => {
|
||||
startRecordingMock.mockRejectedValue(new Error('tracing busy'))
|
||||
const renderer = makeRenderer({
|
||||
takeHeapSnapshot: vi.fn(async () => {
|
||||
throw new Error('snapshot failed')
|
||||
})
|
||||
})
|
||||
|
||||
const result = await captureRendererPerfDump({
|
||||
getRendererWebContents: () => renderer as never
|
||||
})
|
||||
|
||||
const { filePath } = result as { filePath: string }
|
||||
const entries = readTarEntries(await readFile(filePath))
|
||||
expect([...entries.keys()]).toEqual(['metadata.json', 'renderer-perf-metrics.json'])
|
||||
const metadata = JSON.parse(entries.get('metadata.json')!) as {
|
||||
artifacts: Record<string, { status: string; reason?: string }>
|
||||
}
|
||||
expect(metadata.artifacts.trace.status).toBe('failed')
|
||||
expect(metadata.artifacts.trace.reason).toContain('tracing busy')
|
||||
expect(metadata.artifacts.heap.status).toBe('failed')
|
||||
expect(metadata.artifacts.heap.reason).toContain('snapshot failed')
|
||||
})
|
||||
|
||||
it('reports progress stages in order', async () => {
|
||||
const stages: string[] = []
|
||||
await captureRendererPerfDump({
|
||||
getRendererWebContents: () => makeRenderer() as never,
|
||||
onProgress: (stage) => stages.push(stage)
|
||||
})
|
||||
expect(stages).toEqual(['metrics', 'trace', 'heap', 'compressing'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,321 @@
|
||||
import { app, contentTracing, dialog, type WebContents } from 'electron'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { arch, platform, release } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import { collectRendererPerfMetrics } from './renderer-perf'
|
||||
import { createTarGzipArchive, type TarArchiveEntry } from './tar-archive'
|
||||
import { resolveDiagnosticOrcaChannel } from './diagnostic-upload-endpoint'
|
||||
import { translateMain } from '../i18n/main-i18n'
|
||||
|
||||
export type PerfDumpProgressStage = 'metrics' | 'trace' | 'heap' | 'compressing'
|
||||
|
||||
export type CapturePerfDumpResult =
|
||||
| { readonly canceled: true }
|
||||
| { readonly filePath: string; readonly bytes: number }
|
||||
|
||||
type CapturePerfDumpOptions = {
|
||||
readonly getRendererWebContents: () => WebContents | null
|
||||
readonly onProgress?: (stage: PerfDumpProgressStage) => void
|
||||
}
|
||||
|
||||
type ArtifactStatus = {
|
||||
readonly status: 'included' | 'omitted' | 'failed'
|
||||
readonly fileName?: string
|
||||
readonly bytes?: number
|
||||
readonly reason?: string
|
||||
}
|
||||
|
||||
const TRACE_DURATION_MS = 10_000
|
||||
const HEAP_SNAPSHOT_TIMEOUT_MS = 120_000
|
||||
const MAX_TAR_ENTRY_BYTES = 0o77777777777
|
||||
const TRACE_CATEGORIES = [
|
||||
'devtools.timeline',
|
||||
'disabled-by-default-devtools.timeline',
|
||||
'disabled-by-default-devtools.timeline.frame',
|
||||
'disabled-by-default-v8.cpu_profiler',
|
||||
'toplevel',
|
||||
'v8.execute',
|
||||
'blink.user_timing',
|
||||
'latencyInfo'
|
||||
] as const
|
||||
|
||||
let inFlightCapture: Promise<CapturePerfDumpResult> | null = null
|
||||
|
||||
export async function captureRendererPerfDump(
|
||||
opts: CapturePerfDumpOptions
|
||||
): Promise<CapturePerfDumpResult> {
|
||||
if (inFlightCapture) {
|
||||
return inFlightCapture
|
||||
}
|
||||
// Why: the consent dialog is part of the single-flight window — otherwise a
|
||||
// second trigger while the dialog is open would stack a second dialog.
|
||||
inFlightCapture = (async () => {
|
||||
const confirmed = await confirmPerfDumpCapture()
|
||||
if (!confirmed) {
|
||||
return { canceled: true } as const
|
||||
}
|
||||
return captureRendererPerfDumpInternal(opts)
|
||||
})().finally(() => {
|
||||
inFlightCapture = null
|
||||
})
|
||||
return inFlightCapture
|
||||
}
|
||||
|
||||
async function confirmPerfDumpCapture(): Promise<boolean> {
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'warning',
|
||||
buttons: [
|
||||
translateMain('auto.main.observability.perfDump.e6735719a0', 'Capture'),
|
||||
translateMain('auto.main.observability.perfDump.8d1d1d5725', 'Cancel')
|
||||
],
|
||||
defaultId: 1,
|
||||
cancelId: 1,
|
||||
title: translateMain(
|
||||
'auto.main.observability.perfDump.71ec14a301',
|
||||
'Capture performance debug dump?'
|
||||
),
|
||||
message: translateMain(
|
||||
'auto.main.observability.perfDump.6d969f0773',
|
||||
'This saves a local performance debug dump for support.'
|
||||
),
|
||||
detail: translateMain(
|
||||
'auto.main.observability.perfDump.2551f622fd',
|
||||
'Orca will record about 10 seconds of app activity and capture a memory snapshot of the interface. The dump may include terminal text, file paths, and page titles. It is saved to your computer only; nothing is uploaded.'
|
||||
)
|
||||
})
|
||||
return result.response === 0
|
||||
}
|
||||
|
||||
async function captureRendererPerfDumpInternal({
|
||||
getRendererWebContents,
|
||||
onProgress
|
||||
}: CapturePerfDumpOptions): Promise<CapturePerfDumpResult> {
|
||||
const captureId = randomUUID()
|
||||
const tempRoot = join(app.getPath('temp'), 'orca-perf-dumps')
|
||||
const captureDir = join(tempRoot, captureId)
|
||||
const outputPath = await chooseOutputPath()
|
||||
const startedAt = new Date().toISOString()
|
||||
const artifacts: Record<string, ArtifactStatus> = {}
|
||||
const entries: TarArchiveEntry[] = []
|
||||
|
||||
try {
|
||||
await mkdir(captureDir, { recursive: true, mode: 0o700 })
|
||||
onProgress?.('metrics')
|
||||
await captureMetricsArtifact(captureDir, getRendererWebContents, artifacts, entries)
|
||||
|
||||
onProgress?.('trace')
|
||||
await captureTraceArtifact(captureDir, artifacts, entries)
|
||||
|
||||
onProgress?.('heap')
|
||||
await captureHeapArtifact(captureDir, getRendererWebContents, artifacts, entries)
|
||||
|
||||
onProgress?.('compressing')
|
||||
// Why: filter before writing metadata so oversized-artifact skip notes
|
||||
// land inside the archived metadata.json.
|
||||
const packableEntries = await filterPackableEntries(entries, artifacts)
|
||||
|
||||
const metadataPath = join(captureDir, 'metadata.json')
|
||||
await writeFile(
|
||||
metadataPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schema_version: 1,
|
||||
capture_id: captureId,
|
||||
app_version: app.getVersion(),
|
||||
platform: platform(),
|
||||
arch: arch(),
|
||||
os_release: release(),
|
||||
orca_channel: resolveDiagnosticOrcaChannel(),
|
||||
started_at: startedAt,
|
||||
finished_at: new Date().toISOString(),
|
||||
artifacts
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
{ encoding: 'utf8', mode: 0o600 }
|
||||
)
|
||||
packableEntries.unshift({ name: 'metadata.json', filePath: metadataPath })
|
||||
|
||||
await createTarGzipArchive(outputPath, packableEntries)
|
||||
const outputInfo = await stat(outputPath)
|
||||
return { filePath: outputPath, bytes: outputInfo.size }
|
||||
} catch (error) {
|
||||
try {
|
||||
await rm(outputPath, { force: true })
|
||||
} catch {
|
||||
// Why: cleanup failure must not mask the original capture error.
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
await cleanupCaptureDir(captureDir)
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupCaptureDir(captureDir: string): Promise<void> {
|
||||
try {
|
||||
await rm(captureDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Why: on Windows a timed-out heap snapshot can still hold files in the
|
||||
// capture dir; retry once after it settles instead of failing the dump.
|
||||
void heapSnapshotInFlight?.then(async () => {
|
||||
try {
|
||||
await rm(captureDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function captureMetricsArtifact(
|
||||
captureDir: string,
|
||||
getRendererWebContents: () => WebContents | null,
|
||||
artifacts: Record<string, ArtifactStatus>,
|
||||
entries: TarArchiveEntry[]
|
||||
): Promise<void> {
|
||||
const fileName = 'renderer-perf-metrics.json'
|
||||
const filePath = join(captureDir, fileName)
|
||||
try {
|
||||
// Why: the dump stays local behind explicit consent, so it may keep the
|
||||
// folder-basename labels that the uploadable bundle anonymizes.
|
||||
const metrics = await collectRendererPerfMetrics(getRendererWebContents, {
|
||||
labelMode: 'named'
|
||||
})
|
||||
await writeFile(filePath, `${JSON.stringify(metrics, null, 2)}\n`, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600
|
||||
})
|
||||
const info = await stat(filePath)
|
||||
artifacts.metrics = { status: 'included', fileName, bytes: info.size }
|
||||
entries.push({ name: fileName, filePath })
|
||||
} catch (error) {
|
||||
artifacts.metrics = { status: 'failed', reason: formatReason(error) }
|
||||
}
|
||||
}
|
||||
|
||||
async function captureTraceArtifact(
|
||||
captureDir: string,
|
||||
artifacts: Record<string, ArtifactStatus>,
|
||||
entries: TarArchiveEntry[]
|
||||
): Promise<void> {
|
||||
const fileName = 'trace.json'
|
||||
const tracePath = join(captureDir, fileName)
|
||||
let started = false
|
||||
try {
|
||||
await contentTracing.startRecording({
|
||||
recording_mode: 'record-until-full',
|
||||
included_categories: [...TRACE_CATEGORIES]
|
||||
})
|
||||
started = true
|
||||
await delay(TRACE_DURATION_MS)
|
||||
const returnedTracePath = await contentTracing.stopRecording(tracePath)
|
||||
const artifactPath = returnedTracePath || tracePath
|
||||
const info = await stat(artifactPath)
|
||||
artifacts.trace = { status: 'included', fileName, bytes: info.size }
|
||||
entries.push({ name: fileName, filePath: artifactPath })
|
||||
} catch (error) {
|
||||
if (started) {
|
||||
try {
|
||||
await contentTracing.stopRecording()
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
artifacts.trace = { status: 'failed', reason: formatReason(error) }
|
||||
}
|
||||
}
|
||||
|
||||
let heapSnapshotInFlight: Promise<void> | null = null
|
||||
|
||||
async function captureHeapArtifact(
|
||||
captureDir: string,
|
||||
getRendererWebContents: () => WebContents | null,
|
||||
artifacts: Record<string, ArtifactStatus>,
|
||||
entries: TarArchiveEntry[]
|
||||
): Promise<void> {
|
||||
const fileName = 'renderer-heap.heapsnapshot'
|
||||
const filePath = join(captureDir, fileName)
|
||||
const renderer = getRendererWebContents()
|
||||
if (!renderer || renderer.isDestroyed()) {
|
||||
artifacts.heap = { status: 'omitted', reason: 'renderer unavailable' }
|
||||
return
|
||||
}
|
||||
// Why: a timed-out takeHeapSnapshot keeps running in the renderer; starting
|
||||
// a second one on top of it would stack heap-serialization pauses.
|
||||
if (heapSnapshotInFlight) {
|
||||
artifacts.heap = { status: 'omitted', reason: 'previous heap snapshot still running' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const snapshot = renderer.takeHeapSnapshot(filePath)
|
||||
heapSnapshotInFlight = snapshot
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
heapSnapshotInFlight = null
|
||||
})
|
||||
await withTimeout(snapshot, HEAP_SNAPSHOT_TIMEOUT_MS)
|
||||
const info = await stat(filePath)
|
||||
artifacts.heap = { status: 'included', fileName, bytes: info.size }
|
||||
entries.push({ name: fileName, filePath })
|
||||
} catch (error) {
|
||||
artifacts.heap = { status: 'failed', reason: formatReason(error) }
|
||||
}
|
||||
}
|
||||
|
||||
async function filterPackableEntries(
|
||||
entries: readonly TarArchiveEntry[],
|
||||
artifacts: Record<string, ArtifactStatus>
|
||||
): Promise<TarArchiveEntry[]> {
|
||||
const kept: TarArchiveEntry[] = []
|
||||
for (const entry of entries) {
|
||||
const info = await stat(entry.filePath)
|
||||
if (info.size > MAX_TAR_ENTRY_BYTES) {
|
||||
artifacts[`skipped:${entry.name}`] = {
|
||||
status: 'omitted',
|
||||
fileName: entry.name,
|
||||
reason: 'file exceeds tar size limit'
|
||||
}
|
||||
continue
|
||||
}
|
||||
kept.push(entry)
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
async function chooseOutputPath(): Promise<string> {
|
||||
const downloads = app.getPath('downloads')
|
||||
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+$/, '').replace('T', '-')
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
const suffix = index === 0 ? '' : `-${index + 1}`
|
||||
const candidate = join(downloads, `orca-perf-dump-${stamp}${suffix}.tar.gz`)
|
||||
try {
|
||||
await stat(candidate)
|
||||
} catch {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
throw new Error('Could not choose a unique file name in Downloads.')
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
let timeout: NodeJS.Timeout | null = null
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error('timed out')), timeoutMs)
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatReason(error: unknown): string {
|
||||
return error instanceof Error && error.message ? error.message.slice(0, 200) : 'unavailable'
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type IpcListener = (event: { sender: { id: number } }, args: unknown) => void
|
||||
|
||||
const { ipcOnMock, appMetricsMock } = vi.hoisted(() => ({
|
||||
ipcOnMock: vi.fn(),
|
||||
appMetricsMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: { on: ipcOnMock },
|
||||
app: { getAppMetrics: appMetricsMock }
|
||||
}))
|
||||
|
||||
import { collectRendererPerfMetrics } from './renderer-perf'
|
||||
|
||||
type FakeWebContents = {
|
||||
id: number
|
||||
isDestroyed: () => boolean
|
||||
getOSProcessId: () => number
|
||||
send: ReturnType<typeof vi.fn>
|
||||
debugger: {
|
||||
isAttached: () => boolean
|
||||
attach: (version: string) => void
|
||||
detach: () => void
|
||||
sendCommand: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
|
||||
function makeWebContents(overrides: Partial<FakeWebContents> = {}): FakeWebContents {
|
||||
let attached = false
|
||||
return {
|
||||
id: 7,
|
||||
isDestroyed: () => false,
|
||||
getOSProcessId: () => 4242,
|
||||
send: vi.fn(),
|
||||
debugger: {
|
||||
isAttached: () => attached,
|
||||
attach: vi.fn(() => {
|
||||
attached = true
|
||||
}),
|
||||
detach: vi.fn(() => {
|
||||
attached = false
|
||||
}),
|
||||
sendCommand: vi.fn().mockResolvedValue({ documents: 2, nodes: 1500, jsEventListeners: 320 })
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function getResponseListener(): IpcListener {
|
||||
const call = ipcOnMock.mock.calls.find(
|
||||
([channel]) => channel === 'diagnostics:rendererPerf:response'
|
||||
)
|
||||
expect(call).toBeDefined()
|
||||
return call![1] as IpcListener
|
||||
}
|
||||
|
||||
function extractRequestId(webContents: FakeWebContents): string {
|
||||
const call = webContents.send.mock.calls.find(
|
||||
([channel]) => channel === 'diagnostics:rendererPerf:request'
|
||||
)
|
||||
expect(call).toBeDefined()
|
||||
return (call![1] as { requestId: string }).requestId
|
||||
}
|
||||
|
||||
const minimalRendererMetrics = {
|
||||
domNodeCount: 12,
|
||||
terminalPanes: {
|
||||
totalMountedPaneCount: 1,
|
||||
worktrees: [
|
||||
{
|
||||
worktreeLabel: 'my-secret-project',
|
||||
worktreeKind: 'local',
|
||||
paneCount: 1,
|
||||
scrollbackRowsTotal: 42,
|
||||
panes: [{ cols: 80, rows: 24, normalBufferRows: 42, altBufferRows: 0, hasWebgl: false }]
|
||||
}
|
||||
]
|
||||
},
|
||||
browserPanes: { browserWebviewCount: 0, registeredBrowserGuestCount: 0 },
|
||||
registries: {
|
||||
ptySerializerCount: 0,
|
||||
livePaneManagerCount: 0,
|
||||
browserWebviewCount: 0,
|
||||
registeredBrowserGuestCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
describe('collectRendererPerfMetrics', () => {
|
||||
beforeEach(() => {
|
||||
appMetricsMock.mockReturnValue([{ pid: 4242, memory: { workingSetSize: 2048 } }])
|
||||
})
|
||||
|
||||
it('returns an unavailable record when no renderer webContents exists', async () => {
|
||||
const record = await collectRendererPerfMetrics(() => null)
|
||||
expect(record.type).toBe('renderer-perf')
|
||||
expect(record.unavailableReason).toBe('no-renderer')
|
||||
})
|
||||
|
||||
it('merges renderer metrics with main-side CDP and process-memory counters', async () => {
|
||||
const webContents = makeWebContents()
|
||||
const pending = collectRendererPerfMetrics(() => webContents as never, { timeoutMs: 2000 })
|
||||
await vi.waitFor(() => expect(webContents.send).toHaveBeenCalled())
|
||||
getResponseListener()(
|
||||
{ sender: { id: webContents.id } },
|
||||
{ requestId: extractRequestId(webContents), metrics: minimalRendererMetrics }
|
||||
)
|
||||
|
||||
const record = await pending
|
||||
expect(record.renderer?.domNodeCount).toBe(12)
|
||||
expect(record.main?.domCounters).toEqual({ documents: 2, nodes: 1500, jsEventListeners: 320 })
|
||||
// Electron reports MemoryInfo in KiB; the record must be in bytes.
|
||||
expect(record.main?.processMemoryBytes?.workingSetSizeBytes).toBe(2048 * 1024)
|
||||
expect(record.unavailableReason).toBeUndefined()
|
||||
// Default (bundle) records must not carry user-chosen folder names.
|
||||
expect(record.renderer?.terminalPanes.worktrees[0]?.worktreeLabel).toBe('worktree-1')
|
||||
expect(record.renderer?.terminalPanes.worktrees[0]?.paneCount).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps folder-basename labels only in named mode (local perf dump)', async () => {
|
||||
const webContents = makeWebContents()
|
||||
const pending = collectRendererPerfMetrics(() => webContents as never, {
|
||||
timeoutMs: 2000,
|
||||
labelMode: 'named'
|
||||
})
|
||||
await vi.waitFor(() => expect(webContents.send).toHaveBeenCalled())
|
||||
getResponseListener()(
|
||||
{ sender: { id: webContents.id } },
|
||||
{ requestId: extractRequestId(webContents), metrics: minimalRendererMetrics }
|
||||
)
|
||||
|
||||
const record = await pending
|
||||
expect(record.renderer?.terminalPanes.worktrees[0]?.worktreeLabel).toBe('my-secret-project')
|
||||
})
|
||||
|
||||
it('degrades when the CDP command hangs instead of hanging collection', async () => {
|
||||
const webContents = makeWebContents()
|
||||
webContents.debugger.sendCommand = vi.fn(() => new Promise(() => {}))
|
||||
const pending = collectRendererPerfMetrics(() => webContents as never, { timeoutMs: 25 })
|
||||
await vi.waitFor(() => expect(webContents.send).toHaveBeenCalled())
|
||||
|
||||
const record = await pending
|
||||
expect(record.main?.domCounters).toBeUndefined()
|
||||
expect(record.unavailable?.domCounters).toBe('timeout')
|
||||
expect(webContents.debugger.detach).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores responses from an untrusted sender and degrades to timeout', async () => {
|
||||
const webContents = makeWebContents()
|
||||
const pending = collectRendererPerfMetrics(() => webContents as never, { timeoutMs: 25 })
|
||||
await vi.waitFor(() => expect(webContents.send).toHaveBeenCalled())
|
||||
getResponseListener()(
|
||||
{ sender: { id: webContents.id + 1 } },
|
||||
{ requestId: extractRequestId(webContents), metrics: minimalRendererMetrics }
|
||||
)
|
||||
|
||||
const record = await pending
|
||||
expect(record.renderer).toBeUndefined()
|
||||
expect(record.unavailable?.renderer).toBe('timeout')
|
||||
// Main-side counters still ride along when only the renderer times out.
|
||||
expect(record.main?.domCounters?.nodes).toBe(1500)
|
||||
})
|
||||
|
||||
it('degrades to invalid-response when the renderer payload fails narrowing', async () => {
|
||||
const webContents = makeWebContents()
|
||||
const pending = collectRendererPerfMetrics(() => webContents as never, { timeoutMs: 2000 })
|
||||
await vi.waitFor(() => expect(webContents.send).toHaveBeenCalled())
|
||||
getResponseListener()(
|
||||
{ sender: { id: webContents.id } },
|
||||
{ requestId: extractRequestId(webContents), metrics: 'not-an-object' }
|
||||
)
|
||||
|
||||
const record = await pending
|
||||
expect(record.renderer).toBeUndefined()
|
||||
expect(record.unavailable?.renderer).toBe('invalid-response')
|
||||
})
|
||||
|
||||
it('omits CDP counters when the debugger attach fails and releases nothing it does not own', async () => {
|
||||
const webContents = makeWebContents()
|
||||
webContents.debugger.attach = vi.fn(() => {
|
||||
throw new Error('Another debugger is already attached')
|
||||
})
|
||||
const pending = collectRendererPerfMetrics(() => webContents as never, { timeoutMs: 25 })
|
||||
await vi.waitFor(() => expect(webContents.send).toHaveBeenCalled())
|
||||
|
||||
const record = await pending
|
||||
expect(record.main?.domCounters).toBeUndefined()
|
||||
expect(record.unavailable?.domCounters).toContain('debugger')
|
||||
expect(webContents.debugger.detach).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports process memory as unavailable when the renderer pid is missing from app metrics', async () => {
|
||||
appMetricsMock.mockReturnValue([{ pid: 1, memory: { workingSetSize: 10 } }])
|
||||
const webContents = makeWebContents()
|
||||
const pending = collectRendererPerfMetrics(() => webContents as never, { timeoutMs: 25 })
|
||||
await vi.waitFor(() => expect(webContents.send).toHaveBeenCalled())
|
||||
|
||||
const record = await pending
|
||||
expect(record.main?.processMemoryBytes).toBeUndefined()
|
||||
expect(record.unavailable?.processMemory).toBe('renderer process metric unavailable')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,287 @@
|
||||
import { app, ipcMain, type WebContents } from 'electron'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { acquireElectronDebugger } from '../browser/electron-debugger-lease'
|
||||
import {
|
||||
narrowRendererPerfMetrics,
|
||||
type RendererPerfDomCounters,
|
||||
type RendererPerfMetrics,
|
||||
type RendererPerfProcessMemory,
|
||||
type RendererPerfRecord,
|
||||
type RendererPerfUnavailableReason
|
||||
} from '../../shared/renderer-perf-metrics'
|
||||
|
||||
type PendingRendererPerfRequest = {
|
||||
readonly trustedWebContentsId: number
|
||||
readonly timeout: NodeJS.Timeout
|
||||
readonly resolve: (result: RendererPerfResponseResult) => void
|
||||
}
|
||||
|
||||
type CollectRendererPerfMetricsOptions = {
|
||||
readonly timeoutMs?: number
|
||||
/** 'anonymous' (default) replaces worktree labels with stable `worktree-N`
|
||||
* placeholders for records that ride the uploadable diagnostics bundle;
|
||||
* 'named' keeps folder basenames and is reserved for the local-only
|
||||
* consent-gated perf dump. */
|
||||
readonly labelMode?: 'anonymous' | 'named'
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 1500
|
||||
const pendingRequests = new Map<string, PendingRendererPerfRequest>()
|
||||
let responseListenerInstalled = false
|
||||
|
||||
type RendererPerfResponseResult =
|
||||
| { readonly status: 'ok'; readonly metrics: RendererPerfMetrics }
|
||||
| { readonly status: 'invalid-response' }
|
||||
| { readonly status: 'send-failed' }
|
||||
| { readonly status: 'timeout' }
|
||||
|
||||
export async function collectRendererPerfMetrics(
|
||||
getRendererWebContents: () => WebContents | null,
|
||||
opts: CollectRendererPerfMetricsOptions = {}
|
||||
): Promise<RendererPerfRecord> {
|
||||
const collectedAt = new Date().toISOString()
|
||||
const webContents = getRendererWebContents()
|
||||
const unavailable: Record<string, string> = {}
|
||||
|
||||
if (!webContents) {
|
||||
return unavailableRecord(collectedAt, 'no-renderer')
|
||||
}
|
||||
if (webContents.isDestroyed()) {
|
||||
return unavailableRecord(collectedAt, 'renderer-destroyed')
|
||||
}
|
||||
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||
const [renderer, main] = await Promise.all([
|
||||
requestRendererMetrics(webContents, timeoutMs),
|
||||
collectMainMetrics(webContents, timeoutMs)
|
||||
])
|
||||
const rendererMetrics = renderer.metrics
|
||||
? withLabelMode(renderer.metrics, opts.labelMode ?? 'anonymous')
|
||||
: null
|
||||
|
||||
if (renderer.reason) {
|
||||
unavailable.renderer = renderer.reason
|
||||
}
|
||||
if (main.domCountersReason) {
|
||||
unavailable.domCounters = main.domCountersReason
|
||||
}
|
||||
if (main.processMemoryReason) {
|
||||
unavailable.processMemory = main.processMemoryReason
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'renderer-perf',
|
||||
schema_version: 1,
|
||||
collected_at: collectedAt,
|
||||
...(rendererMetrics ? { renderer: rendererMetrics } : {}),
|
||||
...(main.metrics ? { main: main.metrics } : {}),
|
||||
...(renderer.reason && !rendererMetrics && !main.metrics
|
||||
? { unavailableReason: renderer.reason }
|
||||
: {}),
|
||||
...(Object.keys(unavailable).length > 0 ? { unavailable } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Anonymous mode keeps per-worktree/per-pane counts (the ticket's cheap
|
||||
* metrics) but strips user-chosen folder names from uploadable records. */
|
||||
function withLabelMode(
|
||||
metrics: RendererPerfMetrics,
|
||||
labelMode: 'anonymous' | 'named'
|
||||
): RendererPerfMetrics {
|
||||
if (labelMode === 'named') {
|
||||
return metrics
|
||||
}
|
||||
return {
|
||||
...metrics,
|
||||
terminalPanes: {
|
||||
...metrics.terminalPanes,
|
||||
worktrees: metrics.terminalPanes.worktrees.map((worktree, index) => ({
|
||||
...worktree,
|
||||
worktreeLabel: `worktree-${index + 1}`
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function unavailableRecord(
|
||||
collectedAt: string,
|
||||
reason: RendererPerfUnavailableReason
|
||||
): RendererPerfRecord {
|
||||
return {
|
||||
type: 'renderer-perf',
|
||||
schema_version: 1,
|
||||
collected_at: collectedAt,
|
||||
unavailableReason: reason,
|
||||
unavailable: { renderer: reason }
|
||||
}
|
||||
}
|
||||
|
||||
function ensureResponseListener(): void {
|
||||
if (responseListenerInstalled) {
|
||||
return
|
||||
}
|
||||
responseListenerInstalled = true
|
||||
ipcMain.on('diagnostics:rendererPerf:response', (event, args?: unknown) => {
|
||||
if (!args || typeof args !== 'object') {
|
||||
return
|
||||
}
|
||||
const payload = args as { requestId?: unknown; metrics?: unknown }
|
||||
if (typeof payload.requestId !== 'string') {
|
||||
return
|
||||
}
|
||||
const pending = pendingRequests.get(payload.requestId)
|
||||
if (!pending || event.sender.id !== pending.trustedWebContentsId) {
|
||||
return
|
||||
}
|
||||
const metrics = narrowRendererPerfMetrics(payload.metrics)
|
||||
settleRendererPerfRequest(
|
||||
payload.requestId,
|
||||
metrics ? { status: 'ok', metrics } : { status: 'invalid-response' }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function settleRendererPerfRequest(requestId: string, result: RendererPerfResponseResult): void {
|
||||
const pending = pendingRequests.get(requestId)
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
clearTimeout(pending.timeout)
|
||||
pendingRequests.delete(requestId)
|
||||
pending.resolve(result)
|
||||
}
|
||||
|
||||
async function requestRendererMetrics(
|
||||
webContents: WebContents,
|
||||
timeoutMs: number
|
||||
): Promise<{ metrics: RendererPerfMetrics | null; reason?: RendererPerfUnavailableReason }> {
|
||||
ensureResponseListener()
|
||||
const requestId = randomUUID()
|
||||
const result = await new Promise<RendererPerfResponseResult>((resolve) => {
|
||||
const timeout = setTimeout(
|
||||
() => {
|
||||
settleRendererPerfRequest(requestId, { status: 'timeout' })
|
||||
},
|
||||
Math.max(1, timeoutMs)
|
||||
)
|
||||
pendingRequests.set(requestId, {
|
||||
trustedWebContentsId: webContents.id,
|
||||
timeout,
|
||||
resolve
|
||||
})
|
||||
try {
|
||||
webContents.send('diagnostics:rendererPerf:request', { requestId })
|
||||
} catch {
|
||||
settleRendererPerfRequest(requestId, { status: 'send-failed' })
|
||||
}
|
||||
})
|
||||
|
||||
if (result.status === 'ok') {
|
||||
return { metrics: result.metrics }
|
||||
}
|
||||
return {
|
||||
metrics: null,
|
||||
reason: webContents.isDestroyed() ? 'renderer-destroyed' : result.status
|
||||
}
|
||||
}
|
||||
|
||||
async function collectMainMetrics(
|
||||
webContents: WebContents,
|
||||
timeoutMs: number
|
||||
): Promise<{
|
||||
metrics?: NonNullable<RendererPerfRecord['main']>
|
||||
domCountersReason?: string
|
||||
processMemoryReason?: string
|
||||
}> {
|
||||
const [domCounters, processMemory] = await Promise.all([
|
||||
collectDomCounters(webContents, timeoutMs),
|
||||
collectProcessMemory(webContents)
|
||||
])
|
||||
const metrics: NonNullable<RendererPerfRecord['main']> = {
|
||||
...(domCounters.value ? { domCounters: domCounters.value } : {}),
|
||||
...(processMemory.value ? { processMemoryBytes: processMemory.value } : {})
|
||||
}
|
||||
return {
|
||||
...(Object.keys(metrics).length > 0 ? { metrics } : {}),
|
||||
...(domCounters.reason ? { domCountersReason: domCounters.reason } : {}),
|
||||
...(processMemory.reason ? { processMemoryReason: processMemory.reason } : {})
|
||||
}
|
||||
}
|
||||
|
||||
async function collectDomCounters(
|
||||
webContents: WebContents,
|
||||
timeoutMs: number
|
||||
): Promise<{ value?: RendererPerfDomCounters; reason?: string }> {
|
||||
let lease: { release: () => void } | null = null
|
||||
try {
|
||||
lease = acquireElectronDebugger(webContents)
|
||||
// Why: a hung renderer can leave the CDP command pending forever; bound
|
||||
// it so bundle collection degrades instead of hanging with it.
|
||||
const result = (await withTimeout(
|
||||
webContents.debugger.sendCommand('Memory.getDOMCounters'),
|
||||
timeoutMs
|
||||
)) as Partial<RendererPerfDomCounters>
|
||||
return {
|
||||
value: {
|
||||
documents: clampCount(result.documents),
|
||||
nodes: clampCount(result.nodes),
|
||||
jsEventListeners: clampCount(result.jsEventListeners)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return { reason: formatReason(error) }
|
||||
} finally {
|
||||
lease?.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
let timeout: NodeJS.Timeout | null = null
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error('timeout')), Math.max(1, timeoutMs))
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function collectProcessMemory(
|
||||
webContents: WebContents
|
||||
): Promise<{ value?: RendererPerfProcessMemory; reason?: string }> {
|
||||
try {
|
||||
// Why: webContents.getProcessMemoryInfo() was removed from Electron;
|
||||
// app.getAppMetrics() is the supported source for per-process memory.
|
||||
const osPid = webContents.getOSProcessId()
|
||||
const memory = app.getAppMetrics().find((metric) => metric.pid === osPid)?.memory
|
||||
if (!memory) {
|
||||
return { reason: 'renderer process metric unavailable' }
|
||||
}
|
||||
const normalized: RendererPerfProcessMemory = {}
|
||||
for (const [key, value] of Object.entries(memory)) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
// Electron reports MemoryInfo fields in KiB.
|
||||
normalized[`${key}Bytes`] = clampCount(value * 1024)
|
||||
}
|
||||
}
|
||||
return Object.keys(normalized).length > 0 ? { value: normalized } : {}
|
||||
} catch (error) {
|
||||
return { reason: formatReason(error) }
|
||||
}
|
||||
}
|
||||
|
||||
function clampCount(value: unknown): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return 0
|
||||
}
|
||||
return Math.max(0, Math.floor(value))
|
||||
}
|
||||
|
||||
function formatReason(error: unknown): string {
|
||||
return error instanceof Error && error.message ? error.message.slice(0, 160) : 'unavailable'
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { gunzipSync } from 'node:zlib'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createTarGzipArchive } from './tar-archive'
|
||||
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'orca-tar-archive-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('createTarGzipArchive', () => {
|
||||
it('round-trips small files through gzip-compressed ustar', async () => {
|
||||
const first = join(dir, 'first.txt')
|
||||
const second = join(dir, 'second.json')
|
||||
const archive = join(dir, 'bundle.tar.gz')
|
||||
writeFileSync(first, 'hello tar\n')
|
||||
writeFileSync(second, '{"ok":true}\n')
|
||||
|
||||
await createTarGzipArchive(archive, [
|
||||
{ name: 'first.txt', filePath: first },
|
||||
{ name: 'nested/second.json', filePath: second }
|
||||
])
|
||||
|
||||
const entries = parseTarEntries(gunzipSync(readFileSync(archive)))
|
||||
expect(entries).toEqual([
|
||||
{ name: 'first.txt', content: 'hello tar\n' },
|
||||
{ name: 'nested/second.json', content: '{"ok":true}\n' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function parseTarEntries(buffer: Buffer): { name: string; content: string }[] {
|
||||
const entries: { name: string; content: string }[] = []
|
||||
let offset = 0
|
||||
while (offset + 512 <= buffer.length) {
|
||||
const header = buffer.subarray(offset, offset + 512)
|
||||
if (header.every((byte) => byte === 0)) {
|
||||
break
|
||||
}
|
||||
const name = trimAtNull(header.toString('utf8', 0, 100))
|
||||
const sizeText = trimAtNull(header.toString('ascii', 124, 136)).trim()
|
||||
const size = Number.parseInt(sizeText, 8)
|
||||
const contentStart = offset + 512
|
||||
const contentEnd = contentStart + size
|
||||
entries.push({
|
||||
name,
|
||||
content: buffer.toString('utf8', contentStart, contentEnd)
|
||||
})
|
||||
offset = contentStart + Math.ceil(size / 512) * 512
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function trimAtNull(value: string): string {
|
||||
const index = value.indexOf('\u0000')
|
||||
return index === -1 ? value : value.slice(0, index)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { createReadStream, createWriteStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { basename } from 'node:path'
|
||||
import { Readable } from 'node:stream'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { createGzip } from 'node:zlib'
|
||||
|
||||
export type TarArchiveEntry = {
|
||||
readonly name: string
|
||||
readonly filePath: string
|
||||
readonly mode?: number
|
||||
}
|
||||
|
||||
export type TarArchiveResult = {
|
||||
readonly skipped: readonly string[]
|
||||
}
|
||||
|
||||
const BLOCK_SIZE = 512
|
||||
const MAX_TAR_FILE_SIZE = 0o77777777777
|
||||
|
||||
export async function createTarGzipArchive(
|
||||
outputPath: string,
|
||||
entries: readonly TarArchiveEntry[]
|
||||
): Promise<TarArchiveResult> {
|
||||
const skipped: string[] = []
|
||||
const stream = Readable.from(tarBlocks(entries, skipped))
|
||||
await pipeline(stream, createGzip(), createWriteStream(outputPath, { mode: 0o600 }))
|
||||
return { skipped }
|
||||
}
|
||||
|
||||
async function* tarBlocks(
|
||||
entries: readonly TarArchiveEntry[],
|
||||
skipped: string[]
|
||||
): AsyncGenerator<Buffer> {
|
||||
for (const entry of entries) {
|
||||
const info = await stat(entry.filePath)
|
||||
const name = normalizeEntryName(entry.name)
|
||||
if (info.size > MAX_TAR_FILE_SIZE || Buffer.byteLength(name) > 100) {
|
||||
skipped.push(entry.name)
|
||||
continue
|
||||
}
|
||||
yield createHeader({
|
||||
name,
|
||||
size: info.size,
|
||||
mode: entry.mode ?? 0o600,
|
||||
mtime: Math.floor(info.mtimeMs / 1000)
|
||||
})
|
||||
for await (const chunk of createReadStream(entry.filePath)) {
|
||||
yield Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
}
|
||||
const remainder = info.size % BLOCK_SIZE
|
||||
if (remainder > 0) {
|
||||
yield Buffer.alloc(BLOCK_SIZE - remainder)
|
||||
}
|
||||
}
|
||||
yield Buffer.alloc(BLOCK_SIZE * 2)
|
||||
}
|
||||
|
||||
function normalizeEntryName(name: string): string {
|
||||
const normalized = name.replace(/\\/g, '/').replace(/^\/+/, '')
|
||||
return normalized || basename(name)
|
||||
}
|
||||
|
||||
function createHeader({
|
||||
name,
|
||||
size,
|
||||
mode,
|
||||
mtime
|
||||
}: {
|
||||
readonly name: string
|
||||
readonly size: number
|
||||
readonly mode: number
|
||||
readonly mtime: number
|
||||
}): Buffer {
|
||||
const header = Buffer.alloc(BLOCK_SIZE)
|
||||
writeString(header, name, 0, 100)
|
||||
writeOctal(header, mode, 100, 8)
|
||||
writeOctal(header, 0, 108, 8)
|
||||
writeOctal(header, 0, 116, 8)
|
||||
writeOctal(header, size, 124, 12)
|
||||
writeOctal(header, mtime, 136, 12)
|
||||
header.fill(0x20, 148, 156)
|
||||
header[156] = '0'.charCodeAt(0)
|
||||
writeString(header, 'ustar', 257, 6)
|
||||
writeString(header, '00', 263, 2)
|
||||
const checksum = header.reduce((sum, byte) => sum + byte, 0)
|
||||
writeChecksum(header, checksum)
|
||||
return header
|
||||
}
|
||||
|
||||
function writeString(header: Buffer, value: string, offset: number, length: number): void {
|
||||
header.write(value, offset, Math.min(Buffer.byteLength(value), length), 'utf8')
|
||||
}
|
||||
|
||||
function writeOctal(header: Buffer, value: number, offset: number, length: number): void {
|
||||
const encoded = Math.floor(value)
|
||||
.toString(8)
|
||||
.padStart(length - 1, '0')
|
||||
.slice(-(length - 1))
|
||||
header.write(`${encoded}\0`, offset, length, 'ascii')
|
||||
}
|
||||
|
||||
function writeChecksum(header: Buffer, checksum: number): void {
|
||||
const encoded = checksum.toString(8).padStart(6, '0').slice(-6)
|
||||
header.write(`${encoded}\0 `, 148, 8, 'ascii')
|
||||
}
|
||||
@@ -257,6 +257,7 @@ import type { BrowserSetAnnotationViewportBridgeArgs } from '../shared/browser-a
|
||||
import type { CliInstallStatus } from '../shared/cli-install-types'
|
||||
import type { E2EConfig } from '../shared/e2e-config'
|
||||
import type { AgentHookInstallStatus } from '../shared/agent-hook-types'
|
||||
import type { RendererPerfMetrics } from '../shared/renderer-perf-metrics'
|
||||
import type {
|
||||
AgentStatusIpcPayload,
|
||||
MigrationUnsupportedPtyEntry
|
||||
@@ -637,6 +638,7 @@ export type StatsApi = {
|
||||
export type DiagnosticsStatusPayload = {
|
||||
readonly localFileEnabled: boolean
|
||||
readonly bundleEnabled: boolean
|
||||
readonly perfDumpEnabled: boolean
|
||||
readonly traceFilePath: string
|
||||
readonly traceFamilySize: number
|
||||
readonly disabledReason?:
|
||||
@@ -657,6 +659,17 @@ export type DiagnosticsUploadPayload =
|
||||
| {
|
||||
readonly canceled: true
|
||||
}
|
||||
export type PerfDumpProgressPayload = {
|
||||
readonly stage: 'metrics' | 'trace' | 'heap' | 'compressing'
|
||||
}
|
||||
export type PerfDumpCapturePayload =
|
||||
| {
|
||||
readonly canceled: true
|
||||
}
|
||||
| {
|
||||
readonly filePath: string
|
||||
readonly bytes: number
|
||||
}
|
||||
|
||||
export type MemoryApi = {
|
||||
getSnapshot: () => Promise<MemorySnapshot>
|
||||
@@ -1877,6 +1890,10 @@ export type PreloadApi = {
|
||||
discardBundlePreview: (bundleSubmissionId: string) => Promise<void>
|
||||
uploadBundle: (bundleSubmissionId: string) => Promise<DiagnosticsUploadPayload>
|
||||
deleteBundle: (ticketId: string) => Promise<void>
|
||||
capturePerfDump: () => Promise<PerfDumpCapturePayload>
|
||||
onPerfDumpProgress: (callback: (payload: PerfDumpProgressPayload) => void) => () => void
|
||||
onPerfMetricsRequest: (callback: (payload: { requestId: string }) => void) => () => void
|
||||
sendPerfMetrics: (requestId: string, metrics: RendererPerfMetrics) => void
|
||||
}
|
||||
/** Read-only view of effective consent state, including the reason if
|
||||
* disabled (env var / user opt-out / CI / pending banner). Used by the
|
||||
|
||||
+68
-1
@@ -437,6 +437,45 @@ document.addEventListener(
|
||||
|
||||
const startupDiagnosticsEnabled = process.env.ORCA_STARTUP_DIAGNOSTICS === '1'
|
||||
|
||||
function getV8HeapStatisticsBytes(): Record<string, number> | undefined {
|
||||
const heapStats = (
|
||||
process as NodeJS.Process & { getHeapStatistics?: () => Record<string, number> }
|
||||
).getHeapStatistics?.()
|
||||
if (!heapStats) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
totalHeapSizeBytes: toBytes(readHeapStat(heapStats, 'total_heap_size', 'totalHeapSize')),
|
||||
totalHeapSizeExecutableBytes: toBytes(
|
||||
readHeapStat(heapStats, 'total_heap_size_executable', 'totalHeapSizeExecutable')
|
||||
),
|
||||
totalPhysicalSizeBytes: toBytes(
|
||||
readHeapStat(heapStats, 'total_physical_size', 'totalPhysicalSize')
|
||||
),
|
||||
totalAvailableSizeBytes: toBytes(
|
||||
readHeapStat(heapStats, 'total_available_size', 'totalAvailableSize')
|
||||
),
|
||||
usedHeapSizeBytes: toBytes(readHeapStat(heapStats, 'used_heap_size', 'usedHeapSize')),
|
||||
heapSizeLimitBytes: toBytes(readHeapStat(heapStats, 'heap_size_limit', 'heapSizeLimit')),
|
||||
mallocedMemoryBytes: toBytes(readHeapStat(heapStats, 'malloced_memory', 'mallocedMemory')),
|
||||
peakMallocedMemoryBytes: toBytes(
|
||||
readHeapStat(heapStats, 'peak_malloced_memory', 'peakMallocedMemory')
|
||||
),
|
||||
externalMemoryBytes: toBytes(readHeapStat(heapStats, 'external_memory', 'externalMemory'))
|
||||
}
|
||||
}
|
||||
|
||||
function readHeapStat(stats: object, snakeKey: string, camelKey: string): unknown {
|
||||
const record = stats as Record<string, unknown>
|
||||
return record[snakeKey] ?? record[camelKey]
|
||||
}
|
||||
|
||||
function toBytes(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? Math.max(0, Math.floor(value * 1024))
|
||||
: 0
|
||||
}
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {
|
||||
app: {
|
||||
@@ -1681,7 +1720,35 @@ const api = {
|
||||
uploadBundle: (bundleSubmissionId: string): Promise<unknown> =>
|
||||
ipcRenderer.invoke('diagnostics:uploadBundle', bundleSubmissionId),
|
||||
deleteBundle: (ticketId: string): Promise<void> =>
|
||||
ipcRenderer.invoke('diagnostics:deleteBundle', ticketId)
|
||||
ipcRenderer.invoke('diagnostics:deleteBundle', ticketId),
|
||||
capturePerfDump: (): Promise<unknown> => ipcRenderer.invoke('diagnostics:capturePerfDump'),
|
||||
onPerfDumpProgress: (callback: (payload: { stage?: unknown }) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload?: { stage?: unknown }): void =>
|
||||
callback(payload ?? {})
|
||||
ipcRenderer.on('diagnostics:perfDump:progress', listener)
|
||||
return () => ipcRenderer.removeListener('diagnostics:perfDump:progress', listener)
|
||||
},
|
||||
onPerfMetricsRequest: (callback: (payload: { requestId: string }) => void): (() => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
payload?: { requestId?: unknown }
|
||||
): void => {
|
||||
if (typeof payload?.requestId === 'string') {
|
||||
callback({ requestId: payload.requestId })
|
||||
}
|
||||
}
|
||||
ipcRenderer.on('diagnostics:rendererPerf:request', listener)
|
||||
return () => ipcRenderer.removeListener('diagnostics:rendererPerf:request', listener)
|
||||
},
|
||||
sendPerfMetrics: (requestId: string, metrics: Record<string, unknown>): void => {
|
||||
ipcRenderer.send('diagnostics:rendererPerf:response', {
|
||||
requestId,
|
||||
metrics: {
|
||||
...metrics,
|
||||
v8HeapStatistics: getV8HeapStatisticsBytes()
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
settings: {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { FileText } from 'lucide-react'
|
||||
import { Gauge, FileText } from 'lucide-react'
|
||||
import type {
|
||||
DiagnosticsBundlePayload,
|
||||
DiagnosticsStatusPayload
|
||||
DiagnosticsStatusPayload,
|
||||
PerfDumpProgressPayload
|
||||
} from '../../../../preload/api-types'
|
||||
import { Label } from '../ui/label'
|
||||
import { Separator } from '../ui/separator'
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
getDiagnosticBundleDescription,
|
||||
PrivacyDiagnosticBundleControls
|
||||
} from './PrivacyDiagnosticBundleControls'
|
||||
import { getPerfDumpDescription, PrivacyPerfDumpControls } from './PrivacyPerfDumpControls'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function PrivacyDiagnosticsSection(): React.JSX.Element {
|
||||
@@ -24,6 +26,8 @@ export function PrivacyDiagnosticsSection(): React.JSX.Element {
|
||||
const [discarding, setDiscarding] = useState(false)
|
||||
const [copyingTicket, setCopyingTicket] = useState(false)
|
||||
const [deletingTicket, setDeletingTicket] = useState(false)
|
||||
const [capturingPerfDump, setCapturingPerfDump] = useState(false)
|
||||
const [perfDumpStage, setPerfDumpStage] = useState<PerfDumpProgressPayload['stage'] | null>(null)
|
||||
const mountedRef = useRef(true)
|
||||
const activeBundleSubmissionIdRef = useRef<string | null>(null)
|
||||
|
||||
@@ -52,6 +56,14 @@ export function PrivacyDiagnosticsSection(): React.JSX.Element {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return window.api.diagnostics.onPerfDumpProgress((payload) => {
|
||||
if (mountedRef.current) {
|
||||
setPerfDumpStage(payload.stage)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleCollectBundle = useCallback(async (): Promise<void> => {
|
||||
setCollecting(true)
|
||||
try {
|
||||
@@ -235,6 +247,43 @@ export function PrivacyDiagnosticsSection(): React.JSX.Element {
|
||||
}
|
||||
}, [ticketId])
|
||||
|
||||
const handleCapturePerfDump = useCallback(async (): Promise<void> => {
|
||||
setCapturingPerfDump(true)
|
||||
setPerfDumpStage(null)
|
||||
try {
|
||||
const result = await window.api.diagnostics.capturePerfDump()
|
||||
if (!mountedRef.current || 'canceled' in result) {
|
||||
return
|
||||
}
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.settings.PrivacyDiagnosticsSection.c4cd74809e',
|
||||
'Performance dump saved'
|
||||
)
|
||||
)
|
||||
void window.api.shell.openInFileManager(result.filePath)
|
||||
} catch (error) {
|
||||
if (mountedRef.current) {
|
||||
toast.error(
|
||||
getDiagnosticsErrorMessage(
|
||||
error,
|
||||
translate(
|
||||
'auto.components.settings.PrivacyDiagnosticsSection.f73f536350',
|
||||
'Could not capture performance dump'
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setCapturingPerfDump(false)
|
||||
setPerfDumpStage(null)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const showPerfDumpRow = status?.perfDumpEnabled !== false
|
||||
|
||||
return (
|
||||
<>
|
||||
{status?.disabledReason ? (
|
||||
@@ -269,6 +318,22 @@ export function PrivacyDiagnosticsSection(): React.JSX.Element {
|
||||
onDismissTicket={() => setTicketId(null)}
|
||||
/>
|
||||
</PrivacyDiagnosticsRow>
|
||||
{showPerfDumpRow ? (
|
||||
<PrivacyDiagnosticsRow
|
||||
icon={<Gauge className="size-4" />}
|
||||
title={translate(
|
||||
'auto.components.settings.PrivacyDiagnosticsSection.4c7eb24bdd',
|
||||
'Capture performance debug dump'
|
||||
)}
|
||||
description={getPerfDumpDescription(capturingPerfDump ? perfDumpStage : null)}
|
||||
>
|
||||
<PrivacyPerfDumpControls
|
||||
status={status}
|
||||
capturing={capturingPerfDump}
|
||||
onCapture={handleCapturePerfDump}
|
||||
/>
|
||||
</PrivacyDiagnosticsRow>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Activity, Loader2 } from 'lucide-react'
|
||||
import type {
|
||||
DiagnosticsStatusPayload,
|
||||
PerfDumpProgressPayload
|
||||
} from '../../../../preload/api-types'
|
||||
import { Button } from '../ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type PerfDumpStage = PerfDumpProgressPayload['stage'] | null
|
||||
|
||||
export function PrivacyPerfDumpControls({
|
||||
status,
|
||||
capturing,
|
||||
onCapture
|
||||
}: {
|
||||
readonly status: DiagnosticsStatusPayload | null
|
||||
readonly capturing: boolean
|
||||
readonly onCapture: () => Promise<void>
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!status?.perfDumpEnabled || capturing}
|
||||
title={
|
||||
status && !status.perfDumpEnabled
|
||||
? translate(
|
||||
'auto.components.settings.PrivacyPerfDumpControls.a90900c24d',
|
||||
'Performance debug dumps are disabled.'
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
onClick={() => void onCapture()}
|
||||
>
|
||||
{capturing ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Activity className="size-3.5" />
|
||||
)}
|
||||
{translate(
|
||||
'auto.components.settings.PrivacyPerfDumpControls.18f7cad8ee',
|
||||
'Capture performance dump'
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function getPerfDumpDescription(stage: PerfDumpStage): string {
|
||||
if (stage === 'metrics') {
|
||||
return translate(
|
||||
'auto.components.settings.PrivacyPerfDumpControls.ed819b5b0a',
|
||||
'Collecting renderer metrics…'
|
||||
)
|
||||
}
|
||||
if (stage === 'trace') {
|
||||
return translate(
|
||||
'auto.components.settings.PrivacyPerfDumpControls.7f6c7cc90f',
|
||||
'Recording activity trace (10s)…'
|
||||
)
|
||||
}
|
||||
if (stage === 'heap') {
|
||||
return translate(
|
||||
'auto.components.settings.PrivacyPerfDumpControls.20f8c48f16',
|
||||
'Capturing memory snapshot…'
|
||||
)
|
||||
}
|
||||
if (stage === 'compressing') {
|
||||
return translate('auto.components.settings.PrivacyPerfDumpControls.05d8e75876', 'Compressing…')
|
||||
}
|
||||
return translate(
|
||||
'auto.components.settings.PrivacyPerfDumpControls.581d264c8c',
|
||||
'Records about 10 seconds of app activity and a memory snapshot of the interface. May include terminal text, file paths, and page titles; saved to your computer only — nothing is uploaded.'
|
||||
)
|
||||
}
|
||||
@@ -54,7 +54,16 @@ export const getPrivacyPaneSearchEntries = createLocalizedCatalog(() => [
|
||||
'auto.components.settings.privacy.search.c0494ff48a',
|
||||
'diagnostics'
|
||||
),
|
||||
...translateSearchKeyword('auto.components.settings.privacy.search.1686c07fee', 'support')
|
||||
...translateSearchKeyword('auto.components.settings.privacy.search.1686c07fee', 'support'),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.privacy.search.68d450145b',
|
||||
'performance'
|
||||
),
|
||||
...translateSearchKeyword('auto.components.settings.privacy.search.e8e43779e8', 'perf dump'),
|
||||
...translateSearchKeyword('auto.components.settings.privacy.search.048b49260d', 'heap'),
|
||||
...translateSearchKeyword('auto.components.settings.privacy.search.c1d94c8566', 'trace'),
|
||||
...translateSearchKeyword('auto.components.settings.privacy.search.ec083fb9e1', 'slow'),
|
||||
...translateSearchKeyword('auto.components.settings.privacy.search.07241d4d1', 'lag')
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -114,6 +114,10 @@ export function hasPtySerializer(ptyId: string): boolean {
|
||||
return serializersByPtyId.has(ptyId)
|
||||
}
|
||||
|
||||
export function getRegisteredPtySerializerCount(): number {
|
||||
return serializersByPtyId.size
|
||||
}
|
||||
|
||||
function ensureSerializerListener(): void {
|
||||
if (listenerAttached) {
|
||||
return
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -154,7 +154,8 @@
|
||||
"36761d9604": "未知的键绑定操作“{{value0}}”被忽略。",
|
||||
"d2e43e426a": "{{value0}} 必须是一个对象。",
|
||||
"fb290366b2": "无法在网络上使用。",
|
||||
"76122208ca": "“{{value0}}”的快捷方式被忽略:{{value1}}"
|
||||
"76122208ca": "“{{value0}}”的快捷方式被忽略:{{value1}}",
|
||||
"afa470528f": "网页版不支持性能调试转储。"
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
@@ -5673,7 +5674,10 @@
|
||||
"860bca9ec9": "已丢弃查看文件",
|
||||
"49fc6c80e8": "诊断信息已发送",
|
||||
"db3228e01a": "已打开查看文件",
|
||||
"a2b3505c77": "已创建查看文件"
|
||||
"a2b3505c77": "已创建查看文件",
|
||||
"c4cd74809e": "性能转储已保存",
|
||||
"f73f536350": "无法捕获性能转储",
|
||||
"4c7eb24bdd": "捕获性能调试转储"
|
||||
},
|
||||
"PrivacyPane": {
|
||||
"36e0e2e63b": "环境变量。取消设置并重新启动以重新启用。",
|
||||
@@ -8707,6 +8711,15 @@
|
||||
"ephemeralVmsExperimentalSetting": {
|
||||
"description": "显示仓库自带按需环境的设置控件和工作区运行目标。",
|
||||
"toggleLabel": "切换工作区专属环境"
|
||||
},
|
||||
"PrivacyPerfDumpControls": {
|
||||
"a90900c24d": "性能调试转储已被禁用。",
|
||||
"18f7cad8ee": "捕获性能转储",
|
||||
"ed819b5b0a": "正在收集渲染器指标…",
|
||||
"7f6c7cc90f": "正在录制活动跟踪(10 秒)…",
|
||||
"20f8c48f16": "正在捕获内存快照…",
|
||||
"05d8e75876": "正在压缩…",
|
||||
"581d264c8c": "录制约 10 秒的应用活动并捕获界面的内存快照。可能包含终端文本、文件路径和页面标题;仅保存在您的计算机上——不会上传任何内容。"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
@@ -12310,6 +12323,17 @@
|
||||
"91b5c8d7e6": "GitLab"
|
||||
}
|
||||
}
|
||||
},
|
||||
"main": {
|
||||
"observability": {
|
||||
"perfDump": {
|
||||
"e6735719a0": "捕获",
|
||||
"8d1d1d5725": "取消",
|
||||
"71ec14a301": "要捕获性能调试转储吗?",
|
||||
"6d969f0773": "这会在本地保存一个用于支持的性能调试转储。",
|
||||
"2551f622fd": "Orca 将录制约 10 秒的应用活动并捕获界面的内存快照。转储可能包含终端文本、文件路径和页面标题。它仅保存在您的计算机上;不会上传任何内容。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
|
||||
@@ -14,6 +14,10 @@ export function unregisterLivePaneManager(manager: RegisteredPaneManager): void
|
||||
liveManagers.delete(manager)
|
||||
}
|
||||
|
||||
export function getLivePaneManagerCount(): number {
|
||||
return liveManagers.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the WebGL glyph atlases of every live pane manager, not just one.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { collectRendererPerfSnapshot } from './renderer-perf-snapshot'
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/sync-runtime-graph', () => ({
|
||||
listRegisteredTerminalTabPerfSources: () => []
|
||||
}))
|
||||
|
||||
vi.mock('@/components/browser-pane/webview-registry', () => ({
|
||||
getBrowserWebviewMemoryProfile: () => ({
|
||||
browserWebviewCount: 0,
|
||||
registeredBrowserGuestCount: 0
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/terminal-pane/pty-buffer-serializer', () => ({
|
||||
getRegisteredPtySerializerCount: () => 0
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/pane-manager/pane-manager-registry', () => ({
|
||||
getLivePaneManagerCount: () => 0
|
||||
}))
|
||||
|
||||
describe('collectRendererPerfSnapshot', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<main><section><button>Run</button></section></main>'
|
||||
Object.defineProperty(window.performance, 'memory', {
|
||||
configurable: true,
|
||||
value: {
|
||||
usedJSHeapSize: 1024,
|
||||
totalJSHeapSize: 2048,
|
||||
jsHeapSizeLimit: 4096
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('collects DOM, browser, registry, and terminal pane metrics on demand', () => {
|
||||
const metrics = collectRendererPerfSnapshot({
|
||||
getAppState: () => localState(),
|
||||
getBrowserProfile: () => ({
|
||||
browserWebviewCount: 2,
|
||||
registeredBrowserGuestCount: 1
|
||||
}),
|
||||
getPtySerializerCount: () => 3,
|
||||
getLiveManagerCount: () => 4,
|
||||
listTabSources: () => [
|
||||
{
|
||||
worktreeId: 'repo-1::/Users/orca/project-alpha',
|
||||
getManager: () => managerWithPane()
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(metrics.domNodeCount).toBe(document.getElementsByTagName('*').length)
|
||||
expect(metrics.jsHeapUsedBytes).toBe(1024)
|
||||
expect(metrics.browserPanes).toEqual({
|
||||
browserWebviewCount: 2,
|
||||
registeredBrowserGuestCount: 1
|
||||
})
|
||||
expect(metrics.registries).toEqual({
|
||||
ptySerializerCount: 3,
|
||||
livePaneManagerCount: 4,
|
||||
browserWebviewCount: 2,
|
||||
registeredBrowserGuestCount: 1
|
||||
})
|
||||
expect(metrics.terminalPanes).toEqual({
|
||||
totalMountedPaneCount: 1,
|
||||
worktrees: [
|
||||
{
|
||||
worktreeLabel: 'project-alpha',
|
||||
worktreeKind: 'local',
|
||||
paneCount: 1,
|
||||
scrollbackRowsTotal: 124,
|
||||
panes: [
|
||||
{
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
normalBufferRows: 100,
|
||||
altBufferRows: 24,
|
||||
hasWebgl: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('skips throwing and disposed tab sources without failing the snapshot', () => {
|
||||
const metrics = collectRendererPerfSnapshot({
|
||||
getAppState: () => localState(),
|
||||
listTabSources: () => [
|
||||
{
|
||||
worktreeId: 'repo-1::/Users/orca/project-alpha',
|
||||
getManager: () => {
|
||||
throw new Error('unmounting')
|
||||
}
|
||||
},
|
||||
{
|
||||
worktreeId: 'repo-1::/Users/orca/project-alpha',
|
||||
getManager: () => null
|
||||
},
|
||||
{
|
||||
worktreeId: 'global-floating-terminal',
|
||||
getManager: () => managerWithPane()
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(metrics.terminalPanes.totalMountedPaneCount).toBe(1)
|
||||
expect(metrics.terminalPanes.worktrees[0]?.worktreeLabel).toBe('floating')
|
||||
expect(metrics.terminalPanes.worktrees[0]?.worktreeKind).toBe('floating')
|
||||
})
|
||||
|
||||
it('caps per-worktree pane detail before IPC while keeping totals accurate', () => {
|
||||
const manyPanes = Array.from({ length: 60 }, (_, index) => ({
|
||||
id: index,
|
||||
terminal: {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
buffer: { normal: { length: 10 }, alternate: { length: 0 } }
|
||||
}
|
||||
}))
|
||||
const manager = {
|
||||
getPanes: () => manyPanes,
|
||||
getRenderingDiagnostics: () => []
|
||||
} as never
|
||||
const source = { worktreeId: 'repo-1::/Users/orca/project-alpha', getManager: () => manager }
|
||||
|
||||
const metrics = collectRendererPerfSnapshot({
|
||||
getAppState: () => localState(),
|
||||
listTabSources: () => [source, source, source]
|
||||
})
|
||||
|
||||
const worktree = metrics.terminalPanes.worktrees[0]
|
||||
expect(metrics.terminalPanes.totalMountedPaneCount).toBe(180)
|
||||
expect(worktree?.paneCount).toBe(180)
|
||||
expect(worktree?.scrollbackRowsTotal).toBe(1800)
|
||||
expect(worktree?.panes).toHaveLength(100)
|
||||
})
|
||||
})
|
||||
|
||||
function managerWithPane() {
|
||||
return {
|
||||
getPanes: () => [
|
||||
{
|
||||
id: 7,
|
||||
terminal: {
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
buffer: {
|
||||
normal: { length: 100 },
|
||||
alternate: { length: 24 }
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
getRenderingDiagnostics: () => [
|
||||
{
|
||||
paneId: 7,
|
||||
hasWebgl: true
|
||||
}
|
||||
]
|
||||
} as never
|
||||
}
|
||||
|
||||
function localState() {
|
||||
return {
|
||||
repos: [{ id: 'repo-1', connectionId: null }],
|
||||
worktreesByRepo: {
|
||||
'repo-1': [{ id: 'repo-1::/Users/orca/project-alpha', repoId: 'repo-1' }]
|
||||
},
|
||||
folderWorkspaces: [],
|
||||
projectGroups: []
|
||||
} as never
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { getBrowserWebviewMemoryProfile } from '@/components/browser-pane/webview-registry'
|
||||
import { getRegisteredPtySerializerCount } from '@/components/terminal-pane/pty-buffer-serializer'
|
||||
import { getLivePaneManagerCount } from '@/lib/pane-manager/pane-manager-registry'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { listRegisteredTerminalTabPerfSources } from '@/runtime/sync-runtime-graph'
|
||||
import { getConnectionIdFromState } from '@/lib/connection-context'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
|
||||
import { getWorktreePathBasenameFromId } from '../../../shared/worktree-id'
|
||||
import {
|
||||
MAX_PANES_PER_WORKTREE,
|
||||
MAX_WORKTREES,
|
||||
type RendererPerfBrowserPaneMetrics,
|
||||
type RendererPerfMetrics,
|
||||
type RendererPerfPaneMetrics,
|
||||
type RendererPerfWorktreeKind
|
||||
} from '../../../shared/renderer-perf-metrics'
|
||||
|
||||
export type RendererPerfTabSource = {
|
||||
readonly worktreeId: string
|
||||
readonly getManager: () => PaneManager | null
|
||||
}
|
||||
|
||||
type BrowserPerformanceMemory = {
|
||||
readonly usedJSHeapSize?: number
|
||||
readonly totalJSHeapSize?: number
|
||||
readonly jsHeapSizeLimit?: number
|
||||
}
|
||||
|
||||
type RendererPerfSnapshotOptions = {
|
||||
readonly listTabSources?: () => readonly RendererPerfTabSource[]
|
||||
readonly getAppState?: () => AppState
|
||||
readonly getBrowserProfile?: () => RendererPerfBrowserPaneMetrics
|
||||
readonly getPtySerializerCount?: () => number
|
||||
readonly getLiveManagerCount?: () => number
|
||||
}
|
||||
|
||||
let disposePerfMetricsRequestListener: (() => void) | null = null
|
||||
|
||||
export function collectRendererPerfSnapshot(
|
||||
opts: RendererPerfSnapshotOptions = {}
|
||||
): RendererPerfMetrics {
|
||||
const browserPanes = opts.getBrowserProfile?.() ?? getBrowserWebviewMemoryProfile()
|
||||
const terminalPanes = collectTerminalPaneMetrics({
|
||||
sources: opts.listTabSources?.() ?? listRegisteredTerminalTabPerfSources(),
|
||||
getAppState: opts.getAppState ?? useAppStore.getState
|
||||
})
|
||||
const memory = getPerformanceMemory()
|
||||
|
||||
return {
|
||||
domNodeCount: getDomNodeCount(),
|
||||
...(memory?.usedJSHeapSize !== undefined ? { jsHeapUsedBytes: memory.usedJSHeapSize } : {}),
|
||||
...(memory?.totalJSHeapSize !== undefined ? { jsHeapTotalBytes: memory.totalJSHeapSize } : {}),
|
||||
...(memory?.jsHeapSizeLimit !== undefined ? { jsHeapLimitBytes: memory.jsHeapSizeLimit } : {}),
|
||||
terminalPanes,
|
||||
browserPanes,
|
||||
registries: {
|
||||
ptySerializerCount: opts.getPtySerializerCount?.() ?? getRegisteredPtySerializerCount(),
|
||||
livePaneManagerCount: opts.getLiveManagerCount?.() ?? getLivePaneManagerCount(),
|
||||
browserWebviewCount: browserPanes.browserWebviewCount,
|
||||
registeredBrowserGuestCount: browserPanes.registeredBrowserGuestCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function installRendererPerfSnapshotDiagnostics(): void {
|
||||
if (disposePerfMetricsRequestListener || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
disposePerfMetricsRequestListener = window.api.diagnostics.onPerfMetricsRequest(
|
||||
({ requestId }) => {
|
||||
try {
|
||||
window.api.diagnostics.sendPerfMetrics(requestId, collectRendererPerfSnapshot())
|
||||
} catch {
|
||||
window.api.diagnostics.sendPerfMetrics(requestId, emptyRendererPerfSnapshot())
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function _disposeRendererPerfSnapshotDiagnosticsForTests(): void {
|
||||
disposePerfMetricsRequestListener?.()
|
||||
disposePerfMetricsRequestListener = null
|
||||
}
|
||||
|
||||
if (typeof import.meta !== 'undefined' && import.meta.hot) {
|
||||
import.meta.hot.dispose(() => {
|
||||
_disposeRendererPerfSnapshotDiagnosticsForTests()
|
||||
})
|
||||
}
|
||||
|
||||
type MutableWorktreeMetrics = {
|
||||
worktreeLabel: string
|
||||
worktreeKind: RendererPerfWorktreeKind
|
||||
paneCount: number
|
||||
scrollbackRowsTotal: number
|
||||
panes: RendererPerfPaneMetrics[]
|
||||
}
|
||||
|
||||
function collectTerminalPaneMetrics({
|
||||
sources,
|
||||
getAppState
|
||||
}: {
|
||||
readonly sources: readonly RendererPerfTabSource[]
|
||||
readonly getAppState: () => AppState
|
||||
}): RendererPerfMetrics['terminalPanes'] {
|
||||
const groups = new Map<string, MutableWorktreeMetrics>()
|
||||
const fallbackLabels = new Map<string, string>()
|
||||
const state = getAppState()
|
||||
let totalMountedPaneCount = 0
|
||||
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const manager = source.getManager()
|
||||
if (!manager) {
|
||||
continue
|
||||
}
|
||||
const panes = manager.getPanes()
|
||||
const renderingByPaneId = new Map(
|
||||
manager.getRenderingDiagnostics().map((diagnostic) => [diagnostic.paneId, diagnostic])
|
||||
)
|
||||
let worktree = groups.get(source.worktreeId)
|
||||
if (!worktree) {
|
||||
// Why: bound the per-worktree detail before it is allocated and
|
||||
// serialized over IPC; totals stay accurate past the cap.
|
||||
if (groups.size >= MAX_WORKTREES) {
|
||||
totalMountedPaneCount += panes.length
|
||||
continue
|
||||
}
|
||||
worktree = createWorktreeMetrics(source.worktreeId, state, fallbackLabels)
|
||||
groups.set(source.worktreeId, worktree)
|
||||
}
|
||||
for (const pane of panes) {
|
||||
const paneMetrics = collectPaneMetrics(
|
||||
pane.terminal,
|
||||
renderingByPaneId.get(pane.id)?.hasWebgl === true
|
||||
)
|
||||
totalMountedPaneCount += 1
|
||||
worktree.paneCount += 1
|
||||
worktree.scrollbackRowsTotal += paneMetrics.normalBufferRows + paneMetrics.altBufferRows
|
||||
if (worktree.panes.length < MAX_PANES_PER_WORKTREE) {
|
||||
worktree.panes.push(paneMetrics)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Why: terminal panes can unmount while diagnostics are reading them.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalMountedPaneCount,
|
||||
worktrees: [...groups.values()]
|
||||
}
|
||||
}
|
||||
|
||||
function collectPaneMetrics(
|
||||
terminal: {
|
||||
readonly cols?: number
|
||||
readonly rows?: number
|
||||
readonly buffer?: {
|
||||
readonly normal?: { readonly length?: number }
|
||||
readonly alternate?: { readonly length?: number }
|
||||
}
|
||||
},
|
||||
hasWebgl: boolean
|
||||
): RendererPerfPaneMetrics {
|
||||
return {
|
||||
cols: nonNegativeCount(terminal.cols),
|
||||
rows: nonNegativeCount(terminal.rows),
|
||||
normalBufferRows: nonNegativeCount(terminal.buffer?.normal?.length),
|
||||
altBufferRows: nonNegativeCount(terminal.buffer?.alternate?.length),
|
||||
hasWebgl
|
||||
}
|
||||
}
|
||||
|
||||
function createWorktreeMetrics(
|
||||
worktreeId: string,
|
||||
state: AppState,
|
||||
fallbackLabels: Map<string, string>
|
||||
): MutableWorktreeMetrics {
|
||||
return {
|
||||
worktreeLabel: resolveWorktreeLabel(worktreeId, fallbackLabels),
|
||||
worktreeKind: resolveWorktreeKind(worktreeId, state),
|
||||
paneCount: 0,
|
||||
scrollbackRowsTotal: 0,
|
||||
panes: []
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWorktreeKind(worktreeId: string, state: AppState): RendererPerfWorktreeKind {
|
||||
if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
|
||||
return 'floating'
|
||||
}
|
||||
try {
|
||||
const connectionId = getConnectionIdFromState(state, worktreeId)
|
||||
if (typeof connectionId === 'string' && connectionId.trim()) {
|
||||
return 'ssh'
|
||||
}
|
||||
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId)
|
||||
if (typeof runtimeEnvironmentId === 'string' && runtimeEnvironmentId.trim()) {
|
||||
return 'runtime'
|
||||
}
|
||||
if (connectionId === null && runtimeEnvironmentId === null) {
|
||||
return 'local'
|
||||
}
|
||||
} catch {
|
||||
return 'other'
|
||||
}
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function resolveWorktreeLabel(worktreeId: string, fallbackLabels: Map<string, string>): string {
|
||||
if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
|
||||
return 'floating'
|
||||
}
|
||||
const basename = getWorktreePathBasenameFromId(worktreeId)
|
||||
if (basename) {
|
||||
return basename
|
||||
}
|
||||
const existing = fallbackLabels.get(worktreeId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const label = `worktree-${fallbackLabels.size + 1}`
|
||||
fallbackLabels.set(worktreeId, label)
|
||||
return label
|
||||
}
|
||||
|
||||
function getDomNodeCount(): number {
|
||||
if (typeof document === 'undefined') {
|
||||
return 0
|
||||
}
|
||||
return document.getElementsByTagName('*').length
|
||||
}
|
||||
|
||||
function getPerformanceMemory(): BrowserPerformanceMemory | undefined {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
return (window.performance as Performance & { memory?: BrowserPerformanceMemory }).memory
|
||||
}
|
||||
|
||||
function emptyRendererPerfSnapshot(): RendererPerfMetrics {
|
||||
return {
|
||||
terminalPanes: { totalMountedPaneCount: 0, worktrees: [] },
|
||||
browserPanes: { browserWebviewCount: 0, registeredBrowserGuestCount: 0 },
|
||||
registries: {
|
||||
ptySerializerCount: 0,
|
||||
livePaneManagerCount: 0,
|
||||
browserWebviewCount: 0,
|
||||
registeredBrowserGuestCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function nonNegativeCount(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
installRendererCrashDiagnostics,
|
||||
recordRendererCrashBreadcrumb
|
||||
} from './lib/crash-diagnostics'
|
||||
import { installRendererPerfSnapshotDiagnostics } from './lib/renderer-perf-snapshot'
|
||||
import { applyDocumentTheme } from './lib/document-theme'
|
||||
import { shouldEnableReactGrab } from './lib/react-grab-dev-gate'
|
||||
import { I18nProvider } from './i18n/I18nProvider'
|
||||
@@ -16,6 +17,7 @@ import { translate } from './i18n/i18n'
|
||||
|
||||
recordRendererCrashBreadcrumb('renderer_bootstrap_started', { dev: import.meta.env.DEV })
|
||||
installRendererCrashDiagnostics()
|
||||
installRendererPerfSnapshotDiagnostics()
|
||||
|
||||
if (
|
||||
import.meta.env.DEV &&
|
||||
|
||||
@@ -113,6 +113,16 @@ export function registerRuntimeTerminalTab(tab: RegisteredTerminalTab): () => vo
|
||||
}
|
||||
}
|
||||
|
||||
export function listRegisteredTerminalTabPerfSources(): readonly {
|
||||
readonly worktreeId: string
|
||||
readonly getManager: () => PaneManager | null
|
||||
}[] {
|
||||
return Array.from(registeredTabs.values(), ({ worktreeId, getManager }) => ({
|
||||
worktreeId,
|
||||
getManager
|
||||
}))
|
||||
}
|
||||
|
||||
export function focusRuntimeTerminalSurface(tabId: string, leafId?: string | null): boolean {
|
||||
const registered = registeredTabs.get(tabId)
|
||||
const manager = registered?.getManager()
|
||||
|
||||
@@ -540,6 +540,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
|
||||
Promise.resolve({
|
||||
localFileEnabled: false,
|
||||
bundleEnabled: false,
|
||||
perfDumpEnabled: false,
|
||||
traceFilePath: '',
|
||||
traceFamilySize: 0
|
||||
}),
|
||||
@@ -547,7 +548,19 @@ function createWebPreloadApi(): Partial<PreloadApi> {
|
||||
openBundlePreview: () => Promise.reject(new Error('Review files are unavailable on web.')),
|
||||
discardBundlePreview: () => Promise.resolve(),
|
||||
uploadBundle: () => Promise.reject(new Error('Sending diagnostics is unavailable on web.')),
|
||||
deleteBundle: () => Promise.reject(new Error('Sent diagnostics are unavailable on web.'))
|
||||
deleteBundle: () => Promise.reject(new Error('Sent diagnostics are unavailable on web.')),
|
||||
capturePerfDump: () =>
|
||||
Promise.reject(
|
||||
new Error(
|
||||
translate(
|
||||
'auto.web.web.preload.api.afa470528f',
|
||||
'Performance debug dumps are unavailable on web.'
|
||||
)
|
||||
)
|
||||
),
|
||||
onPerfDumpProgress: () => () => {},
|
||||
onPerfMetricsRequest: () => () => {},
|
||||
sendPerfMetrics: () => {}
|
||||
},
|
||||
session: {
|
||||
// hostId mirrors the desktop bridge: omitted/'local' targets the existing
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,302 @@
|
||||
export type RendererPerfWorktreeKind = 'local' | 'ssh' | 'runtime' | 'floating' | 'other'
|
||||
|
||||
export type RendererPerfPaneMetrics = {
|
||||
readonly cols: number
|
||||
readonly rows: number
|
||||
readonly normalBufferRows: number
|
||||
readonly altBufferRows: number
|
||||
readonly hasWebgl: boolean
|
||||
}
|
||||
|
||||
export type RendererPerfWorktreeMetrics = {
|
||||
readonly worktreeLabel: string
|
||||
readonly worktreeKind: RendererPerfWorktreeKind
|
||||
readonly paneCount: number
|
||||
readonly scrollbackRowsTotal: number
|
||||
readonly panes: readonly RendererPerfPaneMetrics[]
|
||||
}
|
||||
|
||||
export type RendererPerfBrowserPaneMetrics = {
|
||||
readonly browserWebviewCount: number
|
||||
readonly registeredBrowserGuestCount: number
|
||||
}
|
||||
|
||||
export type RendererPerfRegistryMetrics = {
|
||||
readonly ptySerializerCount: number
|
||||
readonly livePaneManagerCount: number
|
||||
readonly browserWebviewCount: number
|
||||
readonly registeredBrowserGuestCount: number
|
||||
}
|
||||
|
||||
export type RendererPerfV8HeapStatistics = {
|
||||
readonly totalHeapSizeBytes?: number
|
||||
readonly totalHeapSizeExecutableBytes?: number
|
||||
readonly totalPhysicalSizeBytes?: number
|
||||
readonly totalAvailableSizeBytes?: number
|
||||
readonly usedHeapSizeBytes?: number
|
||||
readonly heapSizeLimitBytes?: number
|
||||
readonly mallocedMemoryBytes?: number
|
||||
readonly peakMallocedMemoryBytes?: number
|
||||
readonly externalMemoryBytes?: number
|
||||
}
|
||||
|
||||
export type RendererPerfMetrics = {
|
||||
readonly domNodeCount?: number
|
||||
readonly jsHeapUsedBytes?: number
|
||||
readonly jsHeapTotalBytes?: number
|
||||
readonly jsHeapLimitBytes?: number
|
||||
readonly v8HeapStatistics?: RendererPerfV8HeapStatistics
|
||||
readonly terminalPanes: {
|
||||
readonly totalMountedPaneCount: number
|
||||
readonly worktrees: readonly RendererPerfWorktreeMetrics[]
|
||||
}
|
||||
readonly browserPanes: RendererPerfBrowserPaneMetrics
|
||||
readonly registries: RendererPerfRegistryMetrics
|
||||
readonly truncated?: boolean
|
||||
}
|
||||
|
||||
export type RendererPerfDomCounters = {
|
||||
readonly documents: number
|
||||
readonly nodes: number
|
||||
readonly jsEventListeners: number
|
||||
}
|
||||
|
||||
export type RendererPerfProcessMemory = Record<string, number>
|
||||
|
||||
export type RendererPerfUnavailableReason =
|
||||
| 'no-renderer'
|
||||
| 'renderer-destroyed'
|
||||
| 'send-failed'
|
||||
| 'timeout'
|
||||
| 'invalid-response'
|
||||
| 'collection-disabled'
|
||||
| 'record-too-large'
|
||||
|
||||
export type RendererPerfRecord = {
|
||||
readonly type: 'renderer-perf'
|
||||
readonly schema_version: 1
|
||||
readonly collected_at: string
|
||||
readonly renderer?: RendererPerfMetrics
|
||||
readonly main?: {
|
||||
readonly domCounters?: RendererPerfDomCounters
|
||||
readonly processMemoryBytes?: RendererPerfProcessMemory
|
||||
}
|
||||
readonly unavailableReason?: RendererPerfUnavailableReason
|
||||
readonly unavailable?: Record<string, string>
|
||||
readonly truncated?: boolean
|
||||
}
|
||||
|
||||
export const MAX_WORKTREES = 200
|
||||
export const MAX_PANES_PER_WORKTREE = 100
|
||||
const MAX_LABEL_LENGTH = 80
|
||||
// Why: fields carry byte values, and a leaky renderer heap past 1 GB is the
|
||||
// exact signal diagnostics must not clip — bound at 1 PB, not 1e9.
|
||||
const MAX_SAFE_COUNT = 1_000_000_000_000_000
|
||||
const WORKTREE_KINDS = new Set<RendererPerfWorktreeKind>([
|
||||
'local',
|
||||
'ssh',
|
||||
'runtime',
|
||||
'floating',
|
||||
'other'
|
||||
])
|
||||
|
||||
export function narrowRendererPerfMetrics(value: unknown): RendererPerfMetrics | null {
|
||||
if (!isRecord(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let truncated = value.truncated === true
|
||||
const browserPanes = narrowBrowserPanes(value.browserPanes)
|
||||
const registries = narrowRegistries(value.registries, browserPanes)
|
||||
const terminalPanes = narrowTerminalPanes(value.terminalPanes)
|
||||
truncated = truncated || terminalPanes.truncated
|
||||
|
||||
const metrics: RendererPerfMetrics = {
|
||||
terminalPanes: terminalPanes.value,
|
||||
browserPanes,
|
||||
registries,
|
||||
...(optionalCount(value.domNodeCount) !== undefined
|
||||
? { domNodeCount: optionalCount(value.domNodeCount) }
|
||||
: {}),
|
||||
...(optionalCount(value.jsHeapUsedBytes) !== undefined
|
||||
? { jsHeapUsedBytes: optionalCount(value.jsHeapUsedBytes) }
|
||||
: {}),
|
||||
...(optionalCount(value.jsHeapTotalBytes) !== undefined
|
||||
? { jsHeapTotalBytes: optionalCount(value.jsHeapTotalBytes) }
|
||||
: {}),
|
||||
...(optionalCount(value.jsHeapLimitBytes) !== undefined
|
||||
? { jsHeapLimitBytes: optionalCount(value.jsHeapLimitBytes) }
|
||||
: {}),
|
||||
...(narrowV8HeapStatistics(value.v8HeapStatistics)
|
||||
? { v8HeapStatistics: narrowV8HeapStatistics(value.v8HeapStatistics) }
|
||||
: {}),
|
||||
...(truncated ? { truncated: true } : {})
|
||||
}
|
||||
|
||||
return metrics
|
||||
}
|
||||
|
||||
export function clampRendererPerfCount(value: unknown): number {
|
||||
return clampNonNegativeNumber(value, 0)
|
||||
}
|
||||
|
||||
function narrowTerminalPanes(value: unknown): {
|
||||
value: RendererPerfMetrics['terminalPanes']
|
||||
truncated: boolean
|
||||
} {
|
||||
if (!isRecord(value)) {
|
||||
return { value: { totalMountedPaneCount: 0, worktrees: [] }, truncated: false }
|
||||
}
|
||||
|
||||
const rawWorktrees = Array.isArray(value.worktrees) ? value.worktrees : []
|
||||
const narrowedWorktrees = rawWorktrees.slice(0, MAX_WORKTREES).map(narrowWorktree)
|
||||
const worktrees = narrowedWorktrees.map(([worktree]) => worktree)
|
||||
return {
|
||||
value: {
|
||||
totalMountedPaneCount: clampRendererPerfCount(value.totalMountedPaneCount),
|
||||
worktrees
|
||||
},
|
||||
truncated:
|
||||
rawWorktrees.length > worktrees.length ||
|
||||
narrowedWorktrees.some(([, wasTruncated]) => wasTruncated)
|
||||
}
|
||||
}
|
||||
|
||||
function narrowWorktree(value: unknown): [RendererPerfWorktreeMetrics, boolean] {
|
||||
if (!isRecord(value)) {
|
||||
return [
|
||||
{
|
||||
worktreeLabel: 'worktree',
|
||||
worktreeKind: 'other',
|
||||
paneCount: 0,
|
||||
scrollbackRowsTotal: 0,
|
||||
panes: []
|
||||
},
|
||||
false
|
||||
]
|
||||
}
|
||||
|
||||
const rawPanes = Array.isArray(value.panes) ? value.panes : []
|
||||
const panes = rawPanes.slice(0, MAX_PANES_PER_WORKTREE).map(narrowPane)
|
||||
const label = stringField(value.worktreeLabel, MAX_LABEL_LENGTH) ?? 'worktree'
|
||||
const worktreeKind = WORKTREE_KINDS.has(value.worktreeKind as RendererPerfWorktreeKind)
|
||||
? (value.worktreeKind as RendererPerfWorktreeKind)
|
||||
: 'other'
|
||||
|
||||
return [
|
||||
{
|
||||
worktreeLabel: label,
|
||||
worktreeKind,
|
||||
paneCount: clampRendererPerfCount(value.paneCount),
|
||||
scrollbackRowsTotal: clampRendererPerfCount(value.scrollbackRowsTotal),
|
||||
panes
|
||||
},
|
||||
rawPanes.length > panes.length || label.length < String(value.worktreeLabel ?? '').length
|
||||
]
|
||||
}
|
||||
|
||||
function narrowPane(value: unknown): RendererPerfPaneMetrics {
|
||||
if (!isRecord(value)) {
|
||||
return {
|
||||
cols: 0,
|
||||
rows: 0,
|
||||
normalBufferRows: 0,
|
||||
altBufferRows: 0,
|
||||
hasWebgl: false
|
||||
}
|
||||
}
|
||||
return {
|
||||
cols: clampRendererPerfCount(value.cols),
|
||||
rows: clampRendererPerfCount(value.rows),
|
||||
normalBufferRows: clampRendererPerfCount(value.normalBufferRows),
|
||||
altBufferRows: clampRendererPerfCount(value.altBufferRows),
|
||||
hasWebgl: value.hasWebgl === true
|
||||
}
|
||||
}
|
||||
|
||||
function narrowBrowserPanes(value: unknown): RendererPerfBrowserPaneMetrics {
|
||||
if (!isRecord(value)) {
|
||||
return { browserWebviewCount: 0, registeredBrowserGuestCount: 0 }
|
||||
}
|
||||
return {
|
||||
browserWebviewCount: clampRendererPerfCount(value.browserWebviewCount),
|
||||
registeredBrowserGuestCount: clampRendererPerfCount(value.registeredBrowserGuestCount)
|
||||
}
|
||||
}
|
||||
|
||||
function narrowRegistries(
|
||||
value: unknown,
|
||||
browserPanes: RendererPerfBrowserPaneMetrics
|
||||
): RendererPerfRegistryMetrics {
|
||||
if (!isRecord(value)) {
|
||||
return {
|
||||
ptySerializerCount: 0,
|
||||
livePaneManagerCount: 0,
|
||||
browserWebviewCount: browserPanes.browserWebviewCount,
|
||||
registeredBrowserGuestCount: browserPanes.registeredBrowserGuestCount
|
||||
}
|
||||
}
|
||||
return {
|
||||
ptySerializerCount: clampRendererPerfCount(value.ptySerializerCount),
|
||||
livePaneManagerCount: clampRendererPerfCount(value.livePaneManagerCount),
|
||||
browserWebviewCount: clampRendererPerfCount(value.browserWebviewCount),
|
||||
registeredBrowserGuestCount: clampRendererPerfCount(value.registeredBrowserGuestCount)
|
||||
}
|
||||
}
|
||||
|
||||
function narrowV8HeapStatistics(value: unknown): RendererPerfV8HeapStatistics | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined
|
||||
}
|
||||
const stats: RendererPerfV8HeapStatistics = {}
|
||||
copyOptionalCount(value, stats, 'totalHeapSizeBytes')
|
||||
copyOptionalCount(value, stats, 'totalHeapSizeExecutableBytes')
|
||||
copyOptionalCount(value, stats, 'totalPhysicalSizeBytes')
|
||||
copyOptionalCount(value, stats, 'totalAvailableSizeBytes')
|
||||
copyOptionalCount(value, stats, 'usedHeapSizeBytes')
|
||||
copyOptionalCount(value, stats, 'heapSizeLimitBytes')
|
||||
copyOptionalCount(value, stats, 'mallocedMemoryBytes')
|
||||
copyOptionalCount(value, stats, 'peakMallocedMemoryBytes')
|
||||
copyOptionalCount(value, stats, 'externalMemoryBytes')
|
||||
return Object.keys(stats).length > 0 ? stats : undefined
|
||||
}
|
||||
|
||||
function copyOptionalCount(
|
||||
source: Record<string, unknown>,
|
||||
target: Record<string, number>,
|
||||
key: string
|
||||
): void {
|
||||
const value = optionalCount(source[key])
|
||||
if (value !== undefined) {
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
function optionalCount(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return undefined
|
||||
}
|
||||
return clampNonNegativeNumber(value, 0)
|
||||
}
|
||||
|
||||
function clampNonNegativeNumber(value: unknown, fallback: number): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return fallback
|
||||
}
|
||||
return Math.min(MAX_SAFE_COUNT, Math.max(0, Math.floor(value)))
|
||||
}
|
||||
|
||||
function stringField(value: unknown, maxLength: number): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return undefined
|
||||
}
|
||||
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
Reference in New Issue
Block a user