diff --git a/.playwright/scripts/__tests__/performanceDiagnostics.e2e.mjs b/.playwright/scripts/__tests__/performanceDiagnostics.e2e.mjs new file mode 100644 index 00000000..6dc33287 --- /dev/null +++ b/.playwright/scripts/__tests__/performanceDiagnostics.e2e.mjs @@ -0,0 +1,146 @@ +import { createHash } from 'node:crypto' +import fs from 'node:fs/promises' +import { createRequire } from 'node:module' +import path from 'node:path' +import { expect, test } from '../fixtures/electron.mjs' + +const { unzipSync, strFromU8 } = createRequire(new URL('../../../apps/buddy/package.json', import.meta.url))('fflate/node') + +test('performance capture stays local, identifies processes and exports bounded metadata', async ({ buddy }, testInfo) => { + const instance = await buddy.createInstance('performance-diagnostics') + const { page, app, diagnostics } = await instance.launch() + await page.evaluate(() => window.location.hash = '/settings/logs') + const header = page.locator('.desktop-settings-page__header') + const panel = page.getByTestId('performance-diagnostics') + await expect(header.getByRole('button', { name: '性能诊断' })).toBeVisible() + await expect(header.getByRole('button', { name: '导出诊断包' })).toBeVisible() + await expect(panel).toHaveCount(0) + const live = header.getByRole('switch') + await expect(live).toBeChecked() + await live.click() + await expect(live).not.toBeChecked() + await expect(page.locator('.application-logs__status')).toContainText('已暂停更新') + await live.click() + await expect(live).toBeChecked() + await testInfo.attach('logs-header', { body: await page.screenshot(), contentType: 'image/png' }) + + const extra = await app.evaluate(async ({ BrowserWindow }) => { + const window = new BrowserWindow({ show: false }) + await window.loadURL('data:text/html,

Diagnostic fixture

') + return { id: window.id, pid: window.webContents.getOSProcessId() } + }) + await header.getByRole('button', { name: '性能诊断' }).click() + await expect(panel).toBeVisible() + await expect(panel.getByText('界面 · 当前窗口', { exact: true })).toBeVisible() + const collectionDetails = page.getByText('仅记录性能摘要,不含对话、文件内容或原始堆栈。指标不含 FFmpeg 等外部子进程和桌宠。', { exact: true }) + await panel.getByRole('button', { name: '采集说明', exact: true }).click() + await expect(collectionDetails).toBeVisible() + await panel.getByRole('button', { name: '采集说明', exact: true }).click() + await expect(collectionDetails).not.toBeVisible() + const otherRenderer = panel.getByRole('row').filter({ hasText: String(extra.pid) }) + await expect(otherRenderer).toContainText('仅显示指标', { timeout: 12000 }) + await expect(otherRenderer.getByRole('button')).toHaveCount(0) + await expect(panel.getByRole('button', { name: '分析 5 秒', exact: true })).toHaveCount(3) + const wrongTargetRejected = await page.evaluate(async () => { + try { + await window.lexoraDesktop.app.performance.capture({ target: 'runtime', pid: 0 }) + return false + } + catch { return true } + }) + expect(wrongTargetRejected).toBe(true) + + const processNames = panel.locator('tbody tr td:first-child') + await expect.poll(() => processNames.allTextContents()).toEqual(['主进程', '任务运行时', '界面 · 当前窗口', '界面', '图形', '网络']) + const processPids = await panel.locator('tbody tr td:nth-child(2)').allTextContents() + const runtime = panel.getByRole('row').filter({ hasText: '任务运行时' }) + const runtimePid = (await runtime.locator('td').nth(1).textContent()).trim() + await runtime.getByRole('button', { name: '分析 5 秒' }).click() + await expect(panel.getByRole('status')).toContainText(`正在分析任务运行时(PID ${runtimePid})`) + await expect(panel.getByRole('button', { name: '关闭', exact: true })).toBeDisabled() + await expect(panel.getByRole('status')).toContainText(`已完成任务运行时(PID ${runtimePid})的分析`, { timeout: 15000 }) + expect(await panel.locator('tbody tr td:nth-child(2)').allTextContents()).toEqual(processPids) + await testInfo.attach('performance-dialog', { body: await page.screenshot(), contentType: 'image/png' }) + + const results = [] + for (const target of ['main', 'renderer']) { + const result = await page.evaluate(async (target) => { + const api = window.lexoraDesktop.app.performance + const snapshot = await api.snapshot() + const pid = target === 'renderer' ? snapshot.rendererPid : snapshot.samples.at(-1).processes.find(item => item.role === 'main').pid + return api.capture({ target, pid }) + }, target) + expect(result).toMatchObject({ target, pid: expect.any(Number) }) + expect(result.durationMs).toBeGreaterThan(4500) + expect(result.samples).toBeGreaterThan(0) + expect(result.hotspots.length).toBeLessThanOrEqual(20) + expect(JSON.stringify(result)).not.toMatch(/file:\/\/|\/home\/|functionName|scriptId|callFrame/) + results.push(result) + } + const snapshot = await page.evaluate(() => window.lexoraDesktop.app.performance.snapshot()) + expect(snapshot.coverage).toBe('electron-processes') + expect(snapshot.samples.length).toBeLessThanOrEqual(60) + expect(snapshot.samples.at(-1).logicalCpuCount).toBeGreaterThan(0) + const filename = path.join(instance.home, 'diagnostics.zip') + await app.evaluate(({ dialog }, filename) => { + dialog.showSaveDialog = async () => ({ canceled: false, filePath: filename }) + }, filename) + await panel.getByRole('button', { name: '导出诊断包' }).click() + await expect(panel).toHaveCount(0) + await page.getByRole('button', { name: '导出 ZIP', exact: true }).click() + await expect.poll(async () => fs.stat(filename).then(stat => stat.size).catch(() => 0)).toBeGreaterThan(0) + const archive = unzipSync(await fs.readFile(filename)) + const context = strFromU8(archive['context.jsonl']).trim().split('\n').map(line => JSON.parse(line)) + expect(context.filter(record => record.cpuProfile).map(record => record.cpuProfile.target).sort()).toEqual(['main', 'renderer', 'runtime']) + expect(context.find(record => record.cpuProfile?.target === 'runtime').cpuProfile.pid).toBe(Number(runtimePid)) + expect(strFromU8(archive['context.jsonl'])).not.toMatch(/functionName|scriptId|callFrame|file:\/\/|\/home\//) + const summaryPath = testInfo.outputPath('performance-summary.json') + await fs.writeFile(summaryPath, JSON.stringify({ profiles: results, snapshot }, null, 2)) + await testInfo.attach('performance-summary', { path: summaryPath, contentType: 'application/json' }) + + await header.getByRole('button', { name: '性能诊断' }).click() + await expect(panel).toBeVisible() + await page.keyboard.press('Escape') + await expect(panel).toHaveCount(0) + await app.evaluate(({ BrowserWindow }, id) => BrowserWindow.fromId(id)?.close(), extra.id) + expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) +}) + +test('renderer CPU summaries retain locations from bundled Vue code', async ({ buddy }, testInfo) => { + const instance = await buddy.createInstance('renderer-cpu-locations') + const { page } = await instance.launch() + const { filename, profile } = await page.evaluate(async () => { + const url = document.querySelector('link[rel="modulepreload"][href*="/vue.runtime.esm-bundler-"]')?.href + if (!url) + throw new Error('Bundled Vue runtime was not loaded') + const exports = await import(url) + const vue = Object.values(exports).find(value => value && typeof value === 'object' && typeof value.reactive === 'function' && typeof value.computed === 'function') + if (!vue) + throw new Error('Bundled Vue runtime exports were not found') + const state = vue.reactive({ count: 0 }) + const doubled = vue.computed(() => state.count * 2) + let observed = 0 + const timer = setInterval(() => { + const until = performance.now() + 20 + while (performance.now() < until) { + state.count++ + observed = doubled.value + } + }, 25) + try { + const api = window.lexoraDesktop.app.performance + const { rendererPid } = await api.snapshot() + const profile = await api.capture({ target: 'renderer', pid: rendererPid }) + if (!observed) + throw new Error('Reactive workload did not run') + return { filename: new URL(url).pathname.split('/').at(-1), profile } + } + finally { + clearInterval(timer) + } + }) + const code = createHash('sha256').update(filename).digest('hex').slice(0, 12) + expect(profile.hotspots.some(hotspot => hotspot.location.startsWith(`renderer:${code}:`) && hotspot.selfMs > 0)).toBe(true) + expect(JSON.stringify(profile)).not.toMatch(/vue\.runtime|functionName|scriptId|callFrame|lexora-app:|file:/) + await testInfo.attach('renderer-cpu-locations', { body: JSON.stringify({ filename, profile }, null, 2), contentType: 'application/json' }) +}) diff --git a/apps/buddy/electron.vite.config.ts b/apps/buddy/electron.vite.config.ts index 50a3bc36..cf0f0606 100644 --- a/apps/buddy/electron.vite.config.ts +++ b/apps/buddy/electron.vite.config.ts @@ -38,7 +38,7 @@ export default defineConfig({ build: { outDir: join(electronOutputRoot, 'main'), rollupOptions: { - external: ['electron', '@silvia-odwyer/photon-node'], + external: ['electron', '@silvia-odwyer/photon-node', 'fflate/node'], input: { 'index': fileURLToPath(new URL('./electron/main/index.ts', import.meta.url)), 'buddy-service': fileURLToPath(new URL('./service/src/index.ts', import.meta.url)), diff --git a/apps/buddy/electron/main/__tests__/desktopDiagnostics.spec.ts b/apps/buddy/electron/main/__tests__/desktopDiagnostics.spec.ts index 9d3f1e2a..53feaf78 100644 --- a/apps/buddy/electron/main/__tests__/desktopDiagnostics.spec.ts +++ b/apps/buddy/electron/main/__tests__/desktopDiagnostics.spec.ts @@ -162,86 +162,71 @@ describe('desktop diagnostics', () => { appVersion: '0.3.0', platform: process.platform, collectorPid: process.pid, - message: 'first\nsecond', operationId: 'startup-1', durationMs: 12, }) expect(records[0]!.launchId).toBe(records[1]!.launchId) - expect(records[1]!.error).toMatchObject({ name: 'Error', code: 'EACCES', message: 'failed /workspace token=' }) + expect(records[1]).toMatchObject({ errorType: 'Error', errorCode: 'EACCES' }) + expect(records[0]).not.toHaveProperty('message') + expect(records[1]).not.toHaveProperty('error') expect(JSON.stringify(records)).not.toContain('fixture-') - expect(records[1]!.error).not.toHaveProperty('request') if (process.platform !== 'win32') { expect((await stat(directory)).mode & 0o777).toBe(0o700) expect((await stat(join(directory, 'application.jsonl'))).mode & 0o777).toBe(0o600) } }) - it('reassembles UTF-8 and secrets across chunks before redaction', async () => { + it('counts split UTF-8 and arbitrary stderr without retaining any content', async () => { const { directory, logger } = await createLogger() const output = logger.createWritable('local-service') - const bytes = Buffer.from('中文 Authorization: Bearer fixture-secret\r\n next line\nlast line') + const bytes = Buffer.from('中文 私密用户文本 /project/customer.mov Authorization: Bearer fixture-secret') for (const byte of bytes) output.write(Buffer.from([byte])) await logger.close() const records = await readRecords(directory) - expect(records.map(record => record.message)).toEqual([ - '中文 Authorization: ', - ' next line', - 'last line', - ]) - expect(new Set(records.map(record => record.sourceId)).size).toBe(1) - expect(records.every(record => record.scope === 'local-service')).toBe(true) + expect(records).toHaveLength(1) + expect(records[0]).toMatchObject({ scope: 'local-service', output: { bytes: bytes.length, chunks: bytes.length } }) + expect(JSON.stringify(records)).not.toMatch(/私密|customer|fixture-secret/) + expect(records[0]).not.toHaveProperty('message') }) - it('does not emit partial lines during flush and continues writing after flush', async () => { + it('aggregates stderr until its fixed interval or EOF without holding output bytes', async () => { const { directory, logger } = await createLogger() const output = logger.createWritable('local-service') - output.write('Authorization: Bea') + output.write('private text') await logger.flush() expect(logger.status.accepted).toBe(0) - output.write('rer fixture-secret\n') - await logger.flush() - for (let index = 0; index < 4; index++) { - logger.record({ ...event, message: `next-${index}` }) - await logger.flush() - } + output.end('more text') await logger.close() - expect((await readRecords(directory)).map(record => record.message)) - .toEqual(['Authorization: ', 'next-0', 'next-1', 'next-2', 'next-3']) + expect((await readRecords(directory))[0]?.output).toEqual({ bytes: 21, chunks: 2 }) }) - it('discards an oversized line in full and resumes at the next newline', async () => { + it('does not serialize arbitrary messages, error stacks or nested payloads', async () => { const { directory, logger } = await createLogger() - const output = logger.createWritable('native-pet') - output.write('x'.repeat(MAX_DIAGNOSTIC_RECORD_BYTES)) - output.write('Authorization: Bea') - output.write('rer fixture-secret\nrecovered\n') - expect(logger.record({ ...event, message: 'x'.repeat(MAX_DIAGNOSTIC_RECORD_BYTES) })).toBe(false) + expect(logger.record({ ...event, message: 'private'.repeat(100000), error: new Error('unlabelled personal text') })).toBe(true) await logger.close() const records = await readRecords(directory) - expect(records.filter(record => record.scope === 'native-pet').map(record => record.message)).toEqual(['recovered']) - expect(logger.status.dropped).toBe(2) - expect(records.find(record => record.event === 'recorder.loss')?.recorderLoss).toEqual({ dropped: 2, failed: 0 }) - expect(JSON.stringify(records)).not.toContain('fixture-secret') + expect(records[0]).toMatchObject({ errorType: 'Error', errorCode: 'OPERATION_FAILED' }) + expect(JSON.stringify(records)).not.toMatch(/private|unlabelled/) }) - it('bounds the pending queue without accumulating a Writable backlog', async () => { - const { directory, logger } = await createLogger({ maxQueueBytes: MAX_DIAGNOSTIC_RECORD_BYTES }) - const output = logger.createWritable('local-service') - for (let index = 0; index < 100; index++) - output.write(`${index} ${'x'.repeat(2000)}\n`) + it('rejects a log storm before accessing payloads and bounds the pending queue', async () => { + const { logger } = await createLogger({ maxQueueBytes: MAX_DIAGNOSTIC_RECORD_BYTES }) + expect(logger.record(event)).toBe(true) + const payload = { ...event, get error() { + throw new Error('must not inspect rejected payload') + } } + for (let index = 0; index < 10000; index++) + expect(logger.record(payload)).toBe(false) expect(logger.status.pendingBytes).toBeLessThanOrEqual(MAX_DIAGNOSTIC_RECORD_BYTES) - expect(output.writableLength).toBe(0) - expect(logger.status.dropped).toBeGreaterThan(0) + expect(logger.status.dropped).toBe(10000) await logger.close() - const records = await readRecords(directory) - expect(records.filter(record => record.scope === 'local-service').length + logger.status.dropped).toBe(100) expect(logger.status.pendingBytes).toBe(0) }) it('persists a loss summary even when every input was rejected', async () => { const { directory, logger } = await createLogger() - logger.record({ ...event, message: 'x'.repeat(MAX_DIAGNOSTIC_RECORD_BYTES) }) + logger.record({ ...event, event: 'INVALID_EVENT' }) expect(await logger.close()).toMatchObject({ dropped: 1, written: 1 }) expect((await readRecords(directory))[0]).toMatchObject({ event: 'recorder.loss', @@ -251,7 +236,7 @@ describe('desktop diagnostics', () => { it('reports bounded close loss once through stderr without writing back to the closed sink', async () => { const { directory, logger } = await createLogger() - logger.record({ ...event, message: 'fixture-private'.repeat(MAX_DIAGNOSTIC_RECORD_BYTES) }) + logger.record({ ...event, event: 'INVALID_EVENT' }) const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) await closeDesktopDiagnostics(logger) await closeDesktopDiagnostics(logger) @@ -263,8 +248,8 @@ describe('desktop diagnostics', () => { it('bounds file size and count while retaining the latest complete records', async () => { const { directory, logger } = await createLogger({ maxFileBytes: MAX_DIAGNOSTIC_RECORD_BYTES, maxFiles: 3 }) - for (let index = 0; index < 8; index++) { - logger.record({ ...event, operationId: String(index), message: 'x'.repeat(10000) }) + for (let index = 0; index < 150; index++) { + logger.record({ ...event, operationId: String(index), component: 'component'.repeat(10) }) await logger.flush() } await logger.close() @@ -272,8 +257,7 @@ describe('desktop diagnostics', () => { expect(files.sort()).toEqual(['application.1.jsonl', 'application.2.jsonl', 'application.jsonl']) for (const file of files) expect((await stat(join(directory, file))).size).toBeLessThanOrEqual(MAX_DIAGNOSTIC_RECORD_BYTES) - expect((await readRecords(directory))[0]!.operationId).toBe('7') - expect((await readRecords(directory, 'application.2.jsonl'))[0]!.operationId).toBe('5') + expect((await readRecords(directory)).at(-1)!.operationId).toBe('149') }) it('starts a clean file after a previous launch ended with an incomplete line', async () => { @@ -298,7 +282,7 @@ describe('desktop diagnostics', () => { const output = logger.createWritable('local-service') const errors: Error[] = [] output.on('error', error => errors.push(error)) - output.write('first\n') + logger.record(event) expect(await logger.flush()).toMatchObject({ failed: 1, written: 0, pendingBytes: 0 }) expect(logger.status.lastError).toBeTruthy() expect(errors).toEqual([]) @@ -312,15 +296,18 @@ describe('desktop diagnostics', () => { expect(logger.status.failed).toBe(1) }) - it('isolates rotation failure and reports it without overwriting existing records', async () => { + it('isolates rotation failure and preserves already written records', async () => { const { directory, logger } = await createLogger({ maxFileBytes: MAX_DIAGNOSTIC_RECORD_BYTES, maxFiles: 2 }) - logger.record({ ...event, message: 'x'.repeat(10000) }) + logger.record({ ...event, operationId: 'first' }) await logger.flush() await mkdir(join(directory, 'application.1.jsonl')) - logger.record({ ...event, message: 'y'.repeat(10000) }) + for (let index = 0; index < 100 && !logger.status.failed; index++) { + logger.record({ ...event, operationId: String(index), component: 'component'.repeat(10) }) + await logger.flush() + } await logger.close() expect(logger.status.failed).toBe(1) - expect((await readRecords(directory))[0]!.message).toBe('x'.repeat(10000)) + expect((await readRecords(directory))[0]!.operationId).toBe('first') }) it('waits for captured stream EOF and keeps process generations separate', async () => { @@ -337,7 +324,7 @@ describe('desktop diagnostics', () => { second.end('rer fixture-secret') expect(await closing).toMatchObject({ written: 2, closeTimedOut: false }) const records = await readRecords(directory) - expect(records.map(record => record.message)).toEqual(['first tail', 'Authorization: ']) + expect(records.map(record => record.output?.bytes)).toEqual([10, 36]) expect(records[0]!.sourceId).not.toBe(records[1]!.sourceId) }) @@ -361,7 +348,8 @@ describe('desktop diagnostics', () => { expect(await logger.close()).toMatchObject({ written: 1, closeTimedOut: false }) expect((await readRecords(directory))[0]).toMatchObject({ event: 'process.stderr_failed', - error: { message: 'stderr read failed' }, + errorType: 'Error', + errorCode: 'OPERATION_FAILED', }) }) @@ -391,6 +379,6 @@ describe('desktop diagnostics', () => { const error = await new Promise(resolve => output.write('late\n', resolve)) expect(error).toBeInstanceOf(Error) expect(logger.record(event)).toBe(false) - expect((await readRecords(directory)).map(record => record.message)).toEqual(['tail']) + expect((await readRecords(directory)).map(record => record.output?.bytes)).toEqual([4]) }) }) diff --git a/apps/buddy/electron/main/app/DesktopIntegrations.ts b/apps/buddy/electron/main/app/DesktopIntegrations.ts index 9ad02f80..fee5cc10 100644 --- a/apps/buddy/electron/main/app/DesktopIntegrations.ts +++ b/apps/buddy/electron/main/app/DesktopIntegrations.ts @@ -34,6 +34,7 @@ import { createDesktopTray } from '../tray' import { registerWorkbenchIpc } from '../workbench/registerWorkbenchIpc' import { WorkbenchStateStore } from '../workbench/WorkbenchStateStore' import { registerApplicationLogIpc } from './registerApplicationLogIpc' +import { registerPerformanceIpc } from './registerPerformanceIpc' import { registerStartupIpc } from './registerStartupIpc' const browserArtifactEntrySchema = z.object({ @@ -107,7 +108,8 @@ export class DesktopIntegrations { this.#subscriptions.push(registerWorkbenchIpc(new WorkbenchStateStore(paths.buddyHome), () => windows.window)) this.#subscriptions.push(registerContextPanelIpc(runtime.contextPanel, () => windows.window)) this.#subscriptions.push(registerStartupIpc(this.#environment.startup, () => windows.window)) - this.#subscriptions.push(registerApplicationLogIpc(new ApplicationLogReader(paths.logs, diagnostics.launchId, homedir()), () => windows.window, event => diagnostics.record(event))) + this.#subscriptions.push(registerApplicationLogIpc(new ApplicationLogReader(paths.logs, diagnostics.launchId, homedir()), () => windows.window, event => diagnostics.record(event), () => diagnostics.flushWithin(1000))) + this.#subscriptions.push(registerPerformanceIpc(runtime, () => windows.window, this.#environment.events.publish)) this.#tray = createDesktopTray({ appName: paths.appName, iconPath: trayIconPath, diff --git a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts index 58efc2b9..c95d74aa 100644 --- a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts +++ b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts @@ -25,6 +25,7 @@ import { installAttachmentProtocol } from '../attachmentProtocol' import { registerBrowserHostRpc } from '../browser/registerBrowserHostRpc' import { LexoraConfigStore } from '../config/LexoraConfigStore' import { ContextPanelHost } from '../context-panel/ContextPanelHost' +import { DesktopPerformanceMonitor } from '../diagnostics/DesktopPerformanceMonitor' import { registerExtensionAuthoringRpc } from '../extensions/registerExtensionAuthoringRpc' import { DesktopNetwork } from '../network/DesktopNetwork' import { registerWebHostRpc } from '../network/registerWebHostRpc' @@ -39,6 +40,7 @@ import { createCredentialVault } from '../secrets/CredentialVault' import { registerCredentialHostRpc } from '../secrets/registerCredentialHostRpc' export class DesktopRuntimeHost { + readonly performance: DesktopPerformanceMonitor inspectExtension: ((id: string) => Promise) | null = null extensionAgent: ReturnType['agent'] | null = null readonly contextPanel: ContextPanelHost @@ -60,6 +62,7 @@ export class DesktopRuntimeHost { this.#environment = environment this.#windows = windows this.#browser = browser + this.performance = new DesktopPerformanceMonitor(() => ({ metrics: app.getAppMetrics(), proxy: this.network.activity }), environment.events.publish) this.configStore = new LexoraConfigStore({ configPath: environment.paths.configPath }) const configDiagnostics = this.configStore.onDidChange(change => environment.events.publish({ event: `settings.${change.kind.replaceAll('-', '_')}`, component: 'desktop.settings', level: change.kind.endsWith('failed') ? 'warn' : 'info', revision: change.revision, operationId: change.operationId, count: change.groups.length })) this.#subscriptions.push(() => configDiagnostics.dispose()) @@ -161,6 +164,17 @@ export class DesktopRuntimeHost { environment.diagnostics.record({ ...event, scope: 'local-service' }) }, bindPeer: (peer) => { + const sandbox = registerSandboxHostRpc(peer, { + buddyHome: environment.paths.buddyHome, + proxyUrl: this.#network!.sandboxProxyUrl, + ...this.#sandboxOptions(), + }) + sandbox.onDidChange(event => environment.events.publish({ + event: `sandbox.supervisor.${event.kind}`, + level: event.kind === 'cleanup_failed' ? 'warn' : 'info', + requestId: event.requestId, + sandboxProcess: { phase: event.kind, pid: event.pid, exitCode: event.exitCode }, + })) const disposers = [ peer.onRequest(extensionAgentRpc.list, () => this.extensionAgent?.list() ?? []), peer.onRequest(extensionAgentRpc.invoke, (input, signal) => { @@ -179,11 +193,7 @@ export class DesktopRuntimeHost { return this.contextPanel.execute({ action: 'open', target: { kind: 'browser', source } }, 'harness') }), registerWebHostRpc(peer, this.#network!.authenticateProxy, this.#network!.assertAvailable), - registerSandboxHostRpc(peer, { - buddyHome: environment.paths.buddyHome, - proxyUrl: this.#network!.sandboxProxyUrl, - ...this.#sandboxOptions(), - }), + sandbox.dispose, registerBrowserHostRpc(peer, { createAdapterLease: input => this.#browser.adapter.issueLease(input), getHost: () => this.#browser.host, @@ -241,6 +251,7 @@ export class DesktopRuntimeHost { powerMonitor.off('unlock-screen', wakeOnUnlock) }) service.start() + this.performance.start() this.#subscriptions.push(installAttachmentProtocol(service)) this.#subscriptions.push(installRendererProtocol()) } @@ -252,6 +263,7 @@ export class DesktopRuntimeHost { } async stop(): Promise { + this.performance.stop() const failures: unknown[] = [] for (const cleanup of [ () => this.contextPanel.dispose(), diff --git a/apps/buddy/electron/main/app/__tests__/registerApplicationLogIpc.spec.ts b/apps/buddy/electron/main/app/__tests__/registerApplicationLogIpc.spec.ts index e1a0167b..203a9703 100644 --- a/apps/buddy/electron/main/app/__tests__/registerApplicationLogIpc.spec.ts +++ b/apps/buddy/electron/main/app/__tests__/registerApplicationLogIpc.spec.ts @@ -36,7 +36,7 @@ async function fixture(record: (event: DesktopDiagnosticEvent) => boolean = () = const sender = { mainFrame: frame } const window = { webContents: sender, isDestroyed: () => false } as unknown as BrowserWindow const event = { sender, senderFrame: frame } as unknown as IpcMainInvokeEvent - registerApplicationLogIpc(new ApplicationLogReader(directory, 'launch-current', '/fixture'), () => window, record) + registerApplicationLogIpc(new ApplicationLogReader(directory, 'launch-current', '/fixture'), () => window, record, async () => {}) const handler = native.handlers.get(DESKTOP_IPC_CHANNELS.appLogsExportDiagnostics)! return { directory, event, handler } } diff --git a/apps/buddy/electron/main/app/registerApplicationLogIpc.ts b/apps/buddy/electron/main/app/registerApplicationLogIpc.ts index e74cae16..868604f3 100644 --- a/apps/buddy/electron/main/app/registerApplicationLogIpc.ts +++ b/apps/buddy/electron/main/app/registerApplicationLogIpc.ts @@ -8,7 +8,7 @@ import { DESKTOP_IPC_CHANNELS } from '../../shared/desktopApi' import { saveApplicationDiagnosticBundle } from '../diagnostics/saveApplicationDiagnosticBundle' import { assertTrustedSender } from '../ipc' -export function registerApplicationLogIpc(reader: ApplicationLogReader, getWindow: () => BrowserWindow | null, record: (event: DesktopDiagnosticEvent) => boolean): () => void { +export function registerApplicationLogIpc(reader: ApplicationLogReader, getWindow: () => BrowserWindow | null, record: (event: DesktopDiagnosticEvent) => boolean, flush: () => Promise): () => void { let exporting = false const producers = new WeakMap>() ipcMain.handle(DESKTOP_IPC_CHANNELS.appLogsReport, async (event, input: unknown) => { @@ -54,6 +54,7 @@ export function registerApplicationLogIpc(reader: ApplicationLogReader, getWindo return { status: 'canceled' } exporting = true try { + await flush() return await saveApplicationDiagnosticBundle(reader, request, window) } catch { diff --git a/apps/buddy/electron/main/app/registerPerformanceIpc.ts b/apps/buddy/electron/main/app/registerPerformanceIpc.ts new file mode 100644 index 00000000..3e3959b9 --- /dev/null +++ b/apps/buddy/electron/main/app/registerPerformanceIpc.ts @@ -0,0 +1,84 @@ +import type { BrowserWindow } from 'electron' +import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import type { DesktopRuntimeHost } from './DesktopRuntimeHost' +import process from 'node:process' +import { ipcMain } from 'electron' +import { captureCpuProfile, captureNodeCpuProfile } from '../../../platform/diagnostics/cpuProfile' +import { safeDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import { cpuProfileRequestSchema, cpuProfileSummarySchema } from '../../../shared/diagnostics/performanceDiagnostic' +import { DESKTOP_IPC_CHANNELS } from '../../shared/desktopApi' +import { assertTrustedSender } from '../ipc' + +export function registerPerformanceIpc(runtime: DesktopRuntimeHost, getWindow: () => BrowserWindow | null, reporter: ApplicationDiagnosticReporter): () => void { + const record = safeDiagnosticReporter(reporter) + const shutdown = new AbortController() + let busy = false + ipcMain.handle(DESKTOP_IPC_CHANNELS.appPerformanceSnapshot, (event) => { + const window = getWindow() + assertTrustedSender(event, window) + if (!window) + throw new Error('CPU_PROFILE_UNAVAILABLE') + return { ...runtime.performance.snapshot(), rendererPid: window.webContents.getOSProcessId() } + }) + ipcMain.handle(DESKTOP_IPC_CHANNELS.appPerformanceCapture, async (event, input: unknown) => { + const window = getWindow() + assertTrustedSender(event, window) + const request = cpuProfileRequestSchema.parse(input) + const { target, pid } = request + if (busy || !window || shutdown.signal.aborted) + throw new Error('CPU_PROFILE_UNAVAILABLE') + const currentPid = target === 'main' ? process.pid : target === 'runtime' ? runtime.service.state.pid : window.webContents.getOSProcessId() + if (pid !== currentPid) + throw new Error('CPU_PROFILE_PROCESS_CHANGED') + busy = true + const operationId = crypto.randomUUID() + try { + const snapshot = runtime.performance.snapshot() + for (const performanceSample of snapshot.samples) + record({ event: 'performance.sample', level: 'info', operationId, performanceSample }) + const cpuProfile = target === 'runtime' + ? cpuProfileSummarySchema.parse(await runtime.service.request('diagnostics.profile', request, { signal: shutdown.signal, timeoutMs: 10_000 })) + : target === 'main' + ? await captureNodeCpuProfile('main', shutdown.signal) + : await captureRenderer(window, shutdown.signal) + record({ event: 'performance.cpu_profile', level: 'info', operationId, cpuProfile }) + return cpuProfile + } + catch { + record({ event: 'performance.profile_failed', level: 'warn', operationId, errorCode: 'CPU_PROFILE_FAILED' }) + throw new Error('CPU_PROFILE_FAILED') + } + finally { + busy = false + } + }) + return () => { + shutdown.abort() + ipcMain.removeHandler(DESKTOP_IPC_CHANNELS.appPerformanceSnapshot) + ipcMain.removeHandler(DESKTOP_IPC_CHANNELS.appPerformanceCapture) + } +} + +async function captureRenderer(window: BrowserWindow, signal: AbortSignal) { + const contents = window.webContents + if (contents.debugger.isAttached()) + throw new Error('CPU_PROFILE_BUSY') + const pid = contents.getOSProcessId() + contents.debugger.attach('1.3') + let attached = true + const detached = () => { + attached = false + } + contents.debugger.once('detach', detached) + return captureCpuProfile({ + send: (method, params) => contents.debugger.sendCommand(method, params), + close: () => { + contents.debugger.removeListener('detach', detached) + if (attached && !contents.isDestroyed() && contents.debugger.isAttached()) + contents.debugger.detach() + }, + }, 'renderer', pid, (url) => { + const match = /^lexora-app:\/\/renderer\/assets\/([\w-]+(?:\.[\w-]+)*\.js)$/.exec(url) + return match?.[1] ?? null + }, signal) +} diff --git a/apps/buddy/electron/main/desktopDiagnostics.ts b/apps/buddy/electron/main/desktopDiagnostics.ts index ca549896..cc39659e 100644 --- a/apps/buddy/electron/main/desktopDiagnostics.ts +++ b/apps/buddy/electron/main/desktopDiagnostics.ts @@ -5,9 +5,10 @@ import { Buffer } from 'node:buffer' import { randomUUID } from 'node:crypto' import process from 'node:process' import { APPLICATION_LOG_MAX_FILE_BYTES, APPLICATION_LOG_MAX_FILES } from '../../shared/diagnostics/applicationLog' +import { DiagnosticAdmission } from '../../shared/diagnostics/DiagnosticAdmission' import { DiagnosticFile } from './diagnostics/diagnosticFile' import { captureDiagnosticOutput, createDiagnosticOutput } from './diagnostics/diagnosticOutput' -import { encodeDiagnosticRecord, MAX_DIAGNOSTIC_RECORD_BYTES, redactDiagnosticText } from './diagnostics/diagnosticRecord' +import { encodeDiagnosticRecord, MAX_DIAGNOSTIC_RECORD_BYTES } from './diagnostics/diagnosticRecord' export type { DesktopDiagnosticEvent, DesktopDiagnosticRecord, DesktopDiagnosticScope } from './diagnostics/diagnosticRecord' @@ -57,6 +58,7 @@ export class DesktopDiagnosticLogger { } #queue: Buffer[] = [] + readonly #admission = new DiagnosticAdmission() #sequence = 0 #settled = 0 #retryAt = 0 @@ -102,7 +104,7 @@ export class DesktopDiagnosticLogger { createWritable(scope: DesktopDiagnosticScope, options: Partial> = {}): Writable { const sourceId = randomUUID() const output = createDiagnosticOutput( - message => this.#enqueue({ scope, sourceId, event: 'process.stderr', level: 'warn', ...options, message }), + output => this.#enqueue({ scope, sourceId, event: 'process.stderr', level: 'warn', ...options, output }), () => this.#drop(), ) if (this.#status.state !== 'open') { @@ -193,10 +195,13 @@ export class DesktopDiagnosticLogger { return this.status } - #enqueue(input: DesktopDiagnosticEvent): boolean { + #enqueue(input: DesktopDiagnosticEvent, internal = false): boolean { if (this.#status.state === 'closed') return false - if (performance.now() < this.#retryAt) + const now = performance.now() + if (now < this.#retryAt || this.#status.pendingBytes + MAX_DIAGNOSTIC_RECORD_BYTES > this.#maxQueueBytes) + return this.#drop() + if (!internal && !this.#admission.take()) return this.#drop() const line = encodeDiagnosticRecord(input, this.#context, ++this.#sequence, performance.now() - this.#startedAt, this.#userHome) if (!line || this.#status.pendingBytes + line.length > this.#maxQueueBytes) @@ -261,7 +266,7 @@ export class DesktopDiagnosticLogger { level: 'warn', event: 'recorder.loss', recorderLoss: { dropped: this.#status.dropped, failed: this.#status.failed }, - }) + }, true) } #drop(): false { @@ -271,8 +276,7 @@ export class DesktopDiagnosticLogger { } #noteError(error: unknown): void { - const message = error instanceof Error ? error.message : 'Diagnostic I/O failure' - this.#status.lastError = redactDiagnosticText(message, this.#userHome).slice(0, 1024) + this.#status.lastError = error instanceof Error && 'code' in error && typeof error.code === 'string' && /^E[A-Z0-9_]{1,32}$/.test(error.code) ? error.code : 'DIAGNOSTIC_IO_FAILED' } #resolveFlushes(): void { diff --git a/apps/buddy/electron/main/diagnostics/DesktopPerformanceMonitor.ts b/apps/buddy/electron/main/diagnostics/DesktopPerformanceMonitor.ts new file mode 100644 index 00000000..6c7c7f84 --- /dev/null +++ b/apps/buddy/electron/main/diagnostics/DesktopPerformanceMonitor.ts @@ -0,0 +1,117 @@ +import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import type { PerformanceSample, ProcessRole } from '../../../shared/diagnostics/performanceDiagnostic' +import { cpus } from 'node:os' +import { safeDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import { performanceSampleSchema } from '../../../shared/diagnostics/performanceDiagnostic' + +export const PERFORMANCE_INTERVAL_MS = 5000 +const HISTORY_LIMIT = 60 + +export class DesktopPerformanceMonitor { + readonly #read: () => { metrics: Electron.ProcessMetric[], proxy: PerformanceSample['proxy'] } + readonly #record: ApplicationDiagnosticReporter + readonly #logicalCpuCount: number + readonly #startedAt = performance.now() + #timer: ReturnType | undefined + #history: PerformanceSample[] = [] + #previous = new Map() + #lastAt = 0 + #nextIncidentAt = 0 + #incident: { id: string, remaining: number } | undefined + + constructor(read: () => { metrics: Electron.ProcessMetric[], proxy: PerformanceSample['proxy'] }, record: ApplicationDiagnosticReporter, logicalCpuCount = cpus().length) { + this.#read = read + this.#record = safeDiagnosticReporter(record) + this.#logicalCpuCount = logicalCpuCount + } + + start(): void { + if (this.#timer) + return + this.sample() + this.#timer = setInterval(() => this.sample(), PERFORMANCE_INTERVAL_MS) + this.#timer.unref() + } + + stop(): void { + clearInterval(this.#timer) + this.#timer = undefined + this.#previous.clear() + this.#history = [] + this.#incident = undefined + } + + snapshot() { + return { samples: structuredClone(this.#history), coverage: 'electron-processes' as const, intervalMs: PERFORMANCE_INTERVAL_MS } + } + + sample(): void { + const now = performance.now() + try { + const { metrics, proxy } = this.#read() + const intervalMs = this.#previous.size ? now - this.#lastAt : 0 + const previous = this.#previous + const next = new Map() + let sustained = false + const processes = metrics.slice(0, 32).map((metric) => { + const key = `${metric.pid}:${metric.creationTime}` + const before = previous.get(key) + const cpu = metric.cpu.cumulativeCPUUsage + const occupiedCores = before?.cpu !== undefined && cpu !== undefined && cpu >= before.cpu && intervalMs > 0 + ? (cpu - before.cpu) * 1000 / intervalMs + : null + const cpuPercent = occupiedCores !== null && this.#logicalCpuCount > 0 ? occupiedCores * 100 / this.#logicalCpuCount : null + const highSince = occupiedCores !== null && occupiedCores >= 0.5 && intervalMs <= PERFORMANCE_INTERVAL_MS * 3 ? before?.highSince ?? now : undefined + if (highSince !== undefined && now - highSince >= 30_000) + sustained = true + next.set(key, { cpu, highSince }) + return { pid: metric.pid, createdAt: metric.creationTime, role: processRole(metric), cpuPercent, memoryKiB: metric.memory.workingSetSize } + }) + const sample = performanceSampleSchema.parse({ sampledAt: new Date().toISOString(), elapsedMs: now - this.#startedAt, intervalMs, collectionMs: performance.now() - now, logicalCpuCount: this.#logicalCpuCount, processes, proxy, truncated: metrics.length > 32 }) + this.#lastAt = now + this.#previous = next + this.#history.push(sample) + if (this.#history.length > HISTORY_LIMIT) + this.#history.shift() + if (sustained && now >= this.#nextIncidentAt && !this.#incident) { + this.#incident = { id: crypto.randomUUID(), remaining: 6 } + this.#nextIncidentAt = now + 300_000 + this.#record({ event: 'performance.sustained_cpu', component: 'desktop.performance', level: 'warn', operationId: this.#incident.id }) + for (const frame of this.#history) + this.#recordSample(frame, this.#incident.id) + } + else if (this.#incident) { + this.#recordSample(sample, this.#incident.id) + if (--this.#incident.remaining === 0) + this.#incident = undefined + } + } + catch { + this.#previous.clear() + if (now >= this.#nextIncidentAt) { + this.#nextIncidentAt = now + 300_000 + this.#record({ event: 'performance.sample_failed', level: 'warn', errorCode: 'PERFORMANCE_SAMPLE_FAILED' }) + } + } + } + + #recordSample(performanceSample: PerformanceSample, operationId: string): void { + this.#record({ event: 'performance.sample', component: 'desktop.performance', level: 'info', operationId, performanceSample }) + } +} + +function processRole(metric: Electron.ProcessMetric): ProcessRole { + if (metric.type === 'Browser') + return 'main' + if (metric.type === 'Tab') + return 'renderer' + if (metric.type === 'GPU') + return 'gpu' + if (metric.name === 'Buddy Local Service') + return 'runtime' + if (metric.name === 'Buddy Shell Sandbox') + return 'sandbox' + if (metric.serviceName === 'network.mojom.NetworkService') + return 'network' + return metric.type === 'Utility' ? 'utility' : 'other' +} diff --git a/apps/buddy/electron/main/diagnostics/__tests__/DesktopPerformanceMonitor.spec.ts b/apps/buddy/electron/main/diagnostics/__tests__/DesktopPerformanceMonitor.spec.ts new file mode 100644 index 00000000..90d965ee --- /dev/null +++ b/apps/buddy/electron/main/diagnostics/__tests__/DesktopPerformanceMonitor.spec.ts @@ -0,0 +1,72 @@ +import type { ApplicationDiagnostic } from '../../../../shared/diagnostics/applicationDiagnostic' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DesktopPerformanceMonitor } from '../DesktopPerformanceMonitor' + +afterEach(() => vi.useRealTimers()) +const proxy = { accepted: 1, reportedFailures: 0, opened: 1, closed: 1, active: 0 } +function metric(cpu = 0, creationTime = 1): Electron.ProcessMetric { + return { pid: 42, creationTime, type: 'Utility', name: 'Buddy Local Service', cpu: { percentCPUUsage: 999, cumulativeCPUUsage: cpu, idleWakeupsPerSecond: 0 }, memory: { workingSetSize: 1024, peakWorkingSetSize: 1024 } } +} + +describe('performance evidence', () => { + it.each([1, 8, 32])('normalizes interval CPU deltas across %i logical processors and resets reused PIDs', (logicalCpuCount) => { + vi.useFakeTimers({ toFake: ['performance', 'setInterval', 'clearInterval'] }) + let current = metric() + const records: ApplicationDiagnostic[] = [] + const monitor = new DesktopPerformanceMonitor(() => ({ metrics: [current], proxy }), event => records.push(event), logicalCpuCount) + monitor.start() + expect(monitor.snapshot().samples[0]?.processes[0]?.cpuPercent).toBeNull() + current = metric(2.5) + vi.advanceTimersByTime(5000) + expect(monitor.snapshot().samples.at(-1)?.logicalCpuCount).toBe(logicalCpuCount) + expect(monitor.snapshot().samples.at(-1)?.processes[0]).toMatchObject({ cpuPercent: 50 / logicalCpuCount, role: 'runtime', pid: 42 }) + current = metric(0, 2) + vi.advanceTimersByTime(5000) + expect(monitor.snapshot().samples.at(-1)?.processes[0]?.cpuPercent).toBeNull() + current = { ...current, cpu: { percentCPUUsage: 999, idleWakeupsPerSecond: 0 } } + vi.advanceTimersByTime(5000) + expect(monitor.snapshot().samples.at(-1)?.processes[0]?.cpuPercent).toBeNull() + expect(records).toEqual([]) + monitor.stop() + vi.advanceTimersByTime(10000) + expect(monitor.snapshot().samples).toEqual([]) + }) + + it('retains bounded history and before/after evidence without logging each routine sample', () => { + vi.useFakeTimers({ toFake: ['performance', 'setInterval', 'clearInterval'] }) + const records: ApplicationDiagnostic[] = [] + const monitor = new DesktopPerformanceMonitor(() => ({ metrics: [metric(performance.now() / 1000)], proxy }), event => records.push(event), 32) + monitor.start() + vi.advanceTimersByTime(30000) + expect(records).toEqual([]) + vi.advanceTimersByTime(5000) + expect(records[0]?.event).toBe('performance.sustained_cpu') + expect(monitor.snapshot().samples.at(-1)?.processes[0]?.cpuPercent).toBe(3.125) + expect(records.filter(record => record.performanceSample)).toHaveLength(8) + vi.advanceTimersByTime(30000) + expect(records.filter(record => record.performanceSample)).toHaveLength(14) + vi.advanceTimersByTime(240000) + expect(monitor.snapshot().samples).toHaveLength(60) + expect(records.filter(record => record.event === 'performance.sustained_cpu')).toHaveLength(1) + monitor.stop() + }) + + it('bounds process cardinality, excludes names and isolates sampler/observer failures', () => { + const monitor = new DesktopPerformanceMonitor(() => ({ metrics: Array.from({ length: 80 }, (_, pid) => ({ ...metric(), pid, name: 'private-hostname' })), proxy }), () => { + throw new Error('observer') + }) + monitor.sample() + const snapshot = monitor.snapshot() + expect(snapshot.samples[0]?.truncated).toBe(true) + expect(snapshot.samples[0]?.processes).toHaveLength(32) + expect(JSON.stringify(snapshot)).not.toContain('private-hostname') + snapshot.samples.splice(0) + expect(monitor.snapshot().samples).toHaveLength(1) + const broken = new DesktopPerformanceMonitor(() => { + throw new Error('private path') + }, () => { + throw new Error('observer') + }) + expect(() => broken.sample()).not.toThrow() + }) +}) diff --git a/apps/buddy/electron/main/diagnostics/__tests__/applicationDiagnosticBundle.spec.ts b/apps/buddy/electron/main/diagnostics/__tests__/applicationDiagnosticBundle.spec.ts index f18bc50f..fe8d5812 100644 --- a/apps/buddy/electron/main/diagnostics/__tests__/applicationDiagnosticBundle.spec.ts +++ b/apps/buddy/electron/main/diagnostics/__tests__/applicationDiagnosticBundle.spec.ts @@ -218,11 +218,40 @@ describe('application diagnostic bundles', () => { expect(context.every(record => record.launchId === 'launch-current')).toBe(true) }) + it('joins sandbox host cleanup with the originating run through request identity', async () => { + const { reader } = await fixture([ + record(1, { event: 'sandbox.requested', level: 'info', requestId: 'request-fixture', runId: 'run-fixture', errorCode: undefined }), + record(2, { event: 'sandbox.supervisor.cleanup_failed', level: 'error', requestId: 'request-fixture', runId: undefined, operationId: undefined }), + ]) + const { context } = unpack((await createApplicationDiagnosticBundle(reader, 'current', { launchId: 'launch-current', sequence: 2 }))!.bytes) + expect(context.map(record => record.sequence)).toEqual([1, 2]) + }) + + it('joins performance evidence to activity at sample time, preserving run and sandbox context without asserting causation', async () => { + const activity = { level: 'info', errorCode: undefined } as const + const performance = { ...activity, runId: undefined, operationId: 'performance-fixture', timestamp: '2026-09-24T00:10:00.000Z' } + const { reader } = await fixture([ + record(1, { ...activity, event: 'run.started', timestamp: '2026-09-24T00:01:00.000Z' }), + record(2, { ...activity, event: 'sandbox.requested', requestId: 'request-fixture', timestamp: '2026-09-24T00:02:00.000Z' }), + record(3, { ...activity, event: 'run.completed', timestamp: '2026-09-24T00:05:10.000Z' }), + record(4, { ...activity, event: 'sandbox.supervisor.released', runId: undefined, requestId: 'request-fixture', timestamp: '2026-09-24T00:05:11.000Z' }), + record(5, { ...performance, event: 'performance.sample', performanceSample: { sampledAt: '2026-09-24T00:05:00.000Z', elapsedMs: 300000, collectionMs: 1, intervalMs: 5000, logicalCpuCount: 32, truncated: false, processes: [], proxy: { accepted: 0, reportedFailures: 0, opened: 0, closed: 0, active: 0 } } }), + record(6, { ...performance, level: 'warn', event: 'performance.sustained_cpu' }), + record(7, { ...activity, event: 'run.completed', runId: 'run-earlier', timestamp: '2026-09-24T00:02:00.000Z' }), + record(8, { ...activity, event: 'run.started', runId: 'run-later', timestamp: '2026-09-24T00:14:00.000Z' }), + ]) + const { context, errors, manifest } = unpack((await createApplicationDiagnosticBundle(reader, 'current', { launchId: 'launch-current', sequence: 6 }))!.bytes) + expect(context.map(record => record.sequence)).toEqual([1, 2, 3, 4, 5, 6]) + expect(errors).toEqual([]) + expect(manifest.incidents[0]).toMatchObject({ association: 'identities-and-observation-window', runStartObserved: true, runEndObserved: true }) + }) + it('retains cumulative recorder losses per launch while keeping legacy counts unknown', async () => { const loss = { event: 'recorder.loss', level: 'warn', runId: undefined, errorCode: undefined } as const const { reader } = await fixture([ record(1, { ...loss, recorderLoss: { dropped: 7, failed: 4 } }), record(2, { ...loss, recorderLoss: { dropped: 9, failed: 4 } }), + record(3, { ...loss, producerInstanceId: 'b3827615-1bd4-4c61-8c1b-f15e6b648c73', recorderLoss: { dropped: 5, failed: 1 } }), record(1, { ...loss, launchId: 'launch-other', recorderLoss: { dropped: 3, failed: 2 } }), record(1, { ...loss, launchId: 'launch-legacy', message: 'private loss details' }), ]) @@ -230,6 +259,7 @@ describe('application diagnostic bundles', () => { expect(manifest.recorder).toMatchObject({ scope: 'scanned-records', lossObserved: true }) expect(manifest.recorder.launches).toEqual(expect.arrayContaining([ { launchId: 'launch-current', dropped: 9, failed: 4 }, + { launchId: 'launch-current', producerInstanceId: 'b3827615-1bd4-4c61-8c1b-f15e6b648c73', dropped: 5, failed: 1 }, { launchId: 'launch-other', dropped: 3, failed: 2 }, { launchId: 'launch-legacy', dropped: null, failed: null }, ])) diff --git a/apps/buddy/electron/main/diagnostics/__tests__/diagnosticOverhead.bench.ts b/apps/buddy/electron/main/diagnostics/__tests__/diagnosticOverhead.bench.ts new file mode 100644 index 00000000..6be3edbd --- /dev/null +++ b/apps/buddy/electron/main/diagnostics/__tests__/diagnosticOverhead.bench.ts @@ -0,0 +1,14 @@ +import type { ProcessMetric } from 'electron' +import { bench, describe } from 'vitest' +import { DesktopPerformanceMonitor } from '../DesktopPerformanceMonitor' + +const metrics: ProcessMetric[] = Array.from({ length: 32 }, (_, pid) => ({ pid, creationTime: 1, type: 'Utility', name: 'Buddy Local Service', cpu: { cumulativeCPUUsage: 0, percentCPUUsage: 0, idleWakeupsPerSecond: 0 }, memory: { workingSetSize: 1024, peakWorkingSetSize: 1024 } })) +const read = () => ({ metrics, proxy: { accepted: 100, reportedFailures: 0, opened: 100, closed: 100, active: 0 } }) +const monitor = new DesktopPerformanceMonitor(read, () => {}) + +describe('diagnostic bookkeeping at the 32-process bound (excludes OS reads)', () => { + bench('disabled', () => { + read() + }, { time: 300 }) + bench('enabled', () => monitor.sample(), { time: 1000 }) +}) diff --git a/apps/buddy/electron/main/diagnostics/applicationDiagnosticBundle.ts b/apps/buddy/electron/main/diagnostics/applicationDiagnosticBundle.ts index 344eeb76..c3367fbe 100644 --- a/apps/buddy/electron/main/diagnostics/applicationDiagnosticBundle.ts +++ b/apps/buddy/electron/main/diagnostics/applicationDiagnosticBundle.ts @@ -1,5 +1,5 @@ import type { ApplicationLogAnchor, ApplicationLogApi, ApplicationLogRecord } from '../../../shared/diagnostics/applicationLog' -import { strToU8, zipSync } from 'fflate/browser' +import { strToU8, zip } from 'fflate/node' import { applicationLogKey, applicationLogRecordSchema } from '../../../shared/diagnostics/applicationLog' import { isRoutineRpcRecord } from '../../../shared/diagnostics/rpcDiagnosticPolicy' @@ -47,6 +47,10 @@ const bundleRecordSchema = applicationLogRecordSchema.pick({ failure: true, providerRequest: true, recorderLoss: true, + performanceSample: true, + cpuProfile: true, + sandboxProcess: true, + output: true, processExit: true, loadFailure: true, recoveryAction: true, @@ -99,10 +103,11 @@ export async function createApplicationDiagnosticBundle(reader: Pick !isRoutineRpcRecord(record)) const selectedRecord = selected ? records.find(record => applicationLogKey(record) === applicationLogKey(selected)) : undefined const allErrors = meaningful.filter(record => record.level === 'error') + const incidents = meaningful.filter(record => record.level === 'error' || record.event === 'performance.sustained_cpu' || record.event === 'performance.cpu_profile' || record.sandboxProcess?.phase === 'cleanup_failed') const seeds: ApplicationLogRecord[] = [] const seen = new Set() - for (const record of selected ? selectedRecord ? [selectedRecord] : [] : allErrors) { - const key = `${record.launchId}:${record.runId ?? record.operationId ?? 'startup'}` + for (const record of selected ? selectedRecord ? [selectedRecord] : [] : incidents) { + const key = `${record.launchId}:${record.runId ?? record.operationId ?? record.requestId ?? 'startup'}` if (seen.has(key)) continue seen.add(key) @@ -125,16 +130,17 @@ export async function createApplicationDiagnosticBundle(reader: Pick a.timestamp.localeCompare(b.timestamp) || a.sequence - b.sequence) const scopedErrors = selected ? candidates.filter(record => record.level === 'error') : allErrors const errors = scopedErrors.slice(0, limits.errors).reverse() - const recorderLosses = new Map() + const recorderLosses = new Map() for (const record of records) { if (record.event !== 'recorder.loss') continue - const loss = recorderLosses.get(record.launchId) ?? { launchId: record.launchId, dropped: null, failed: null } + const key = `${record.launchId}:${record.producerInstanceId ?? 'collector'}` + const loss = recorderLosses.get(key) ?? { launchId: record.launchId, producerInstanceId: record.producerInstanceId, dropped: null, failed: null } if (record.recorderLoss) { loss.dropped = Math.max(loss.dropped ?? 0, record.recorderLoss.dropped) loss.failed = Math.max(loss.failed ?? 0, record.recorderLoss.failed) } - recorderLosses.set(record.launchId, loss) + recorderLosses.set(key, loss) } const coverageReasons = Object.entries({ scan_limit: records.length < first.total, @@ -162,6 +168,7 @@ export async function createApplicationDiagnosticBundle(reader: Pick record.event === 'run.started'), @@ -178,17 +185,21 @@ export async function createApplicationDiagnosticBundle(reader: Pick((resolve, reject) => zip({ 'manifest.json': strToU8(JSON.stringify(manifest, null, 2)), 'summary.txt': strToU8(summary), 'errors.jsonl': encodeRecords(errors), 'context.jsonl': encodeRecords(context), - }), + }, (error, bytes) => error ? reject(error) : resolve(bytes))), errorCount: errors.length, contextCount: context.length, filename: `lexora-diagnostics-${capturedAt.replace(/[:.]/g, '-')}.zip`, @@ -198,6 +209,25 @@ export async function createApplicationDiagnosticBundle(reader: Pick record.launchId === seed.launchId) const runs = new Set() + if (isPerformanceIncident(seed)) { + const times = [Date.parse(seed.timestamp)] + for (const record of launch) { + if (record.performanceSample && (applicationLogKey(record) === applicationLogKey(seed) || (seed.operationId && record.operationId === seed.operationId))) + times.push(Date.parse(record.performanceSample.sampledAt)) + } + const from = Math.min(...times) - 30_000 + const to = Math.max(...times) + 30_000 + for (const record of launch) { + const time = Date.parse(record.timestamp) + if (record.runId && time >= from && time <= to) + runs.add(record.runId) + } + } + const requests = new Set(seed.requestId ? [seed.requestId] : []) + for (const record of launch) { + if (record.requestId && requests.has(record.requestId) && record.runId) + runs.add(record.runId) + } if (seed.runId) runs.add(seed.runId) if (!seed.runId && seed.operationId) { @@ -213,7 +243,12 @@ function incidentRecords(records: ApplicationLogRecord[], seed: ApplicationLogRe if (record.runId && runs.has(record.runId) && record.operationId) operations.add(record.operationId) } + for (const record of launch) { + if (record.requestId && ((record.runId && runs.has(record.runId)) || (!record.runId && record.operationId && operations.has(record.operationId)))) + requests.add(record.requestId) + } const linked = (record: ApplicationLogRecord) => applicationLogKey(record) === applicationLogKey(seed) + || (record.requestId && requests.has(record.requestId)) || (record.runId && runs.has(record.runId)) || (!record.runId && ((record.operationId && operations.has(record.operationId)) || (record.parentOperationId && operations.has(record.parentOperationId)))) @@ -226,6 +261,10 @@ function incidentRecords(records: ApplicationLogRecord[], seed: ApplicationLogRe || (!record.runId && (!record.operationId || /^(?:app|startup|runtime|recorder|component)\./.test(record.event)) && Date.parse(record.timestamp) >= from && Date.parse(record.timestamp) <= to)) } +function isPerformanceIncident(record: ApplicationLogRecord): boolean { + return !!record.performanceSample || !!record.cpuProfile || record.event === 'performance.sustained_cpu' +} + function encodeRecords(records: ApplicationLogRecord[]): Uint8Array { return strToU8(records.map(record => JSON.stringify(bundleRecordSchema.parse(record))).join('\n') + (records.length ? '\n' : '')) } diff --git a/apps/buddy/electron/main/diagnostics/diagnosticOutput.ts b/apps/buddy/electron/main/diagnostics/diagnosticOutput.ts index 321eaa6e..59ec25b3 100644 --- a/apps/buddy/electron/main/diagnostics/diagnosticOutput.ts +++ b/apps/buddy/electron/main/diagnostics/diagnosticOutput.ts @@ -1,52 +1,32 @@ -import { Buffer } from 'node:buffer' +import type { Buffer } from 'node:buffer' import { Writable } from 'node:stream' -import { MAX_DIAGNOSTIC_RECORD_BYTES } from './diagnosticRecord' -export function createDiagnosticOutput(onLine: (line: string) => void, onDrop: () => void): Writable { - let pending: Buffer[] = [] +export function createDiagnosticOutput(onOutput: (output: { bytes: number, chunks: number }) => void, onDrop: () => void): Writable { let bytes = 0 - let discarding = false - - function append(chunk: Buffer) { - if (discarding || !chunk.length) - return - bytes += chunk.length - if (bytes > MAX_DIAGNOSTIC_RECORD_BYTES) { - pending = [] - bytes = 0 - discarding = true - onDrop() - return - } - pending.push(Buffer.from(chunk)) - } - - function finishLine() { - if (!discarding && bytes) - onLine(Buffer.concat(pending, bytes).toString('utf8').replace(/\r$/, '')) - pending = [] + let chunks = 0 + let timer: ReturnType | undefined + const flush = () => { + clearTimeout(timer) + timer = undefined + if (chunks) + onOutput({ bytes, chunks }) bytes = 0 - discarding = false + chunks = 0 } - const output = new Writable({ write(chunk: Buffer, _encoding, callback) { - let start = 0 - for (let end = chunk.indexOf(10); end !== -1; end = chunk.indexOf(10, start)) { - append(chunk.subarray(start, end)) - finishLine() - start = end + 1 - } - append(chunk.subarray(start)) + bytes = Math.min(Number.MAX_SAFE_INTEGER, bytes + chunk.length) + chunks = Math.min(Number.MAX_SAFE_INTEGER, chunks + 1) + timer ??= setTimeout(flush, 5000) + timer.unref() callback() }, final(callback) { - finishLine() + flush() callback() }, destroy(error, callback) { - if (bytes || discarding) - finishLine() + flush() callback(error) }, }) diff --git a/apps/buddy/electron/main/diagnostics/diagnosticRecord.ts b/apps/buddy/electron/main/diagnostics/diagnosticRecord.ts index 66b5d206..4132755e 100644 --- a/apps/buddy/electron/main/diagnostics/diagnosticRecord.ts +++ b/apps/buddy/electron/main/diagnostics/diagnosticRecord.ts @@ -1,7 +1,7 @@ import type { ApplicationDiagnostic } from '../../../shared/diagnostics/applicationDiagnostic' import type { ApplicationLogRecord } from '../../../shared/diagnostics/applicationLog' import { Buffer } from 'node:buffer' -import { applicationDiagnosticSchema } from '../../../shared/diagnostics/applicationDiagnostic' +import { applicationDiagnosticSchema, readDiagnosticError } from '../../../shared/diagnostics/applicationDiagnostic' export const MAX_DIAGNOSTIC_RECORD_BYTES = 16 * 1024 export type DesktopDiagnosticScope = 'desktop' | 'local-service' | 'native-pet' @@ -43,9 +43,10 @@ export function encodeDiagnosticRecord( try { if (!/^[a-z][a-z\d._-]{0,95}$/.test(input.event)) return null - const error = input.error instanceof Error ? input.error : undefined + const failure = input.error === undefined ? {} : readDiagnosticError(input.error) const record: DesktopDiagnosticRecord = { ...context, + ...failure, schemaVersion: 1, timestamp: new Date().toISOString(), elapsedMs: Math.round(elapsedMs), @@ -53,21 +54,11 @@ export function encodeDiagnosticRecord( scope: input.scope, level: input.level, event: input.event, - message: input.message === undefined ? undefined : clean(input.message), - sourceId: input.sourceId === undefined ? undefined : clean(input.sourceId), + sourceId: typeof input.sourceId === 'string' && /^[\w:.-]{1,192}$/.test(input.sourceId) ? input.sourceId : undefined, sourcePid: typeof input.sourcePid === 'number' && Number.isSafeInteger(input.sourcePid) && input.sourcePid > 0 ? input.sourcePid : undefined, - operationId: input.operationId === undefined ? undefined : clean(input.operationId), durationMs: typeof input.durationMs === 'number' && Number.isFinite(input.durationMs) && input.durationMs >= 0 ? input.durationMs : undefined, - error: error - ? { - name: clean(error.name), - message: clean(error.message), - stack: error.stack === undefined ? undefined : clean(error.stack), - code: 'code' in error && typeof error.code === 'string' ? clean(error.code) : undefined, - } - : input.error === undefined ? undefined : { name: 'UnknownError', message: 'Non-Error failure' }, } - for (const key of ['parentOperationId', 'producerInstanceId', 'extensionId', 'workingCopyId', 'markId', 'revision', 'contentVersion', 'savedVersion', 'dirty', 'generation', 'sessionId', 'providerId', 'connectorId', 'automationId', 'occurrenceId', 'toolCallId', 'sourceSequence', 'occurredAt', 'component', 'conversationId', 'spaceId', 'directoryId', 'branchId', 'runId', 'turnId', 'requestId', 'errorCode', 'errorType', 'failure', 'providerRequest', 'recorderLoss', 'processExit', 'loadFailure', 'recoveryAction', 'previousLaunchId', 'count', 'attempt', 'method'] as const) { + for (const key of ['operationId', 'parentOperationId', 'producerInstanceId', 'extensionId', 'workingCopyId', 'markId', 'revision', 'contentVersion', 'savedVersion', 'dirty', 'generation', 'sessionId', 'providerId', 'connectorId', 'automationId', 'occurrenceId', 'toolCallId', 'sourceSequence', 'occurredAt', 'component', 'conversationId', 'spaceId', 'directoryId', 'branchId', 'runId', 'turnId', 'requestId', 'errorCode', 'errorType', 'failure', 'providerRequest', 'recorderLoss', 'performanceSample', 'cpuProfile', 'output', 'sandboxProcess', 'processExit', 'loadFailure', 'recoveryAction', 'previousLaunchId', 'count', 'attempt', 'method'] as const) { const parsed = applicationDiagnosticSchema.shape[key].safeParse(input[key]) if (!parsed.success) return null diff --git a/apps/buddy/electron/main/network/DesktopNetwork.ts b/apps/buddy/electron/main/network/DesktopNetwork.ts index 482e0c06..c1714aee 100644 --- a/apps/buddy/electron/main/network/DesktopNetwork.ts +++ b/apps/buddy/electron/main/network/DesktopNetwork.ts @@ -37,6 +37,7 @@ export class DesktopNetwork { #startupError: NetworkStartupError | null = null get startupError(): NetworkStartupError | null { return this.#startupError } + get activity() { return this.#proxy.activity } get snapshot() { return copyEventSnapshot({ revision: this.#revision, status: this.#status, mode: this.#settings?.mode ?? null, pendingSessions: this.#sessionSetup.size, failedSessions: this.#failedSessions.size, failure: this.#startupError?.failure ?? null }) } get proxyUrl(): string { return this.#startupError ? UNAVAILABLE_PROXY_URL : this.#proxy.url } get sandboxProxyUrl(): string { return this.#startupError ? UNAVAILABLE_PROXY_URL : this.#proxy.sandboxUrl } diff --git a/apps/buddy/electron/main/network/OutboundProxy.ts b/apps/buddy/electron/main/network/OutboundProxy.ts index fab72109..0f9cd25c 100644 --- a/apps/buddy/electron/main/network/OutboundProxy.ts +++ b/apps/buddy/electron/main/network/OutboundProxy.ts @@ -16,6 +16,10 @@ export class OutboundProxy { readonly #sandboxHttpAgent = new HttpAgent({ keepAlive: false }) readonly #resolve: (url: string) => Promise #generation = 0 + #accepted = 0 + #failed = 0 + #opened = 0 + #closed = 0 readonly #sandboxGuard = createResolvedAddressGuard({ deniedResolvedAddresses: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '100.64.0.0/10', 'fc00::/7'] }) constructor(resolve: (url: string) => Promise) { @@ -31,6 +35,7 @@ export class OutboundProxy { return { requestAuthentication: true } if (port === this.port && ['localhost', '127.0.0.1', '::1', '[::1]'].includes(hostname)) throw new RequestError('Proxy loop', 502) + this.#accepted++ const generation = this.#generation const authority = `${hostname}:${port}` const protocol = username.endsWith('-http') ? 'http' : 'https' @@ -46,8 +51,13 @@ export class OutboundProxy { } }, }) + this.#server.server.on('connection', () => this.#opened++) + this.#server.on('connectionClosed', () => this.#closed++) + this.#server.on('requestFailed', () => this.#failed++) + this.#server.on('tunnelConnectFailed', () => this.#failed++) } + get activity() { return { accepted: this.#accepted, reportedFailures: this.#failed, opened: this.#opened, closed: this.#closed, active: this.#server.connections.size } } get port(): number { return this.#server.port } get url(): string { return `http://${this.username}:${this.password}@127.0.0.1:${this.port}` } get sandboxUrl(): string { return `http://lexora-sandbox:${this.#sandboxPassword}@127.0.0.1:${this.port}` } diff --git a/apps/buddy/electron/main/runtime/__tests__/buddyServiceProcess.spec.ts b/apps/buddy/electron/main/runtime/__tests__/buddyServiceProcess.spec.ts index a985485c..00aae736 100644 --- a/apps/buddy/electron/main/runtime/__tests__/buddyServiceProcess.spec.ts +++ b/apps/buddy/electron/main/runtime/__tests__/buddyServiceProcess.spec.ts @@ -52,7 +52,7 @@ describe('buddyServiceProcess', () => { process.exit(7) expect(await logger.close()).toMatchObject({ written: 1, closeTimedOut: false }) const record = JSON.parse(await readFile(join(directory, 'application.jsonl'), 'utf8')) - expect(record).toMatchObject({ scope: 'local-service', message: 'Authorization: ' }) + expect(record).toMatchObject({ scope: 'local-service', output: { bytes: 36, chunks: 2 } }) }) it('finishes the capture and persists the terminal records when Electron removes stderr listeners at exit', async () => { @@ -75,7 +75,7 @@ describe('buddyServiceProcess', () => { expect(await logger.close()).toMatchObject({ written: 2, failed: 0, unconfirmed: 0, closeTimedOut: false }) const records = (await readFile(join(directory, 'application.jsonl'), 'utf8')).trimEnd().split('\n').map(line => JSON.parse(line)) expect(records).toMatchObject([ - { scope: 'local-service', event: 'process.stderr', message: 'runtime cleanup completed' }, + { scope: 'local-service', event: 'process.stderr', output: { bytes: 25, chunks: 2 } }, { scope: 'desktop', event: 'app.stopped' }, ]) }) @@ -92,7 +92,7 @@ describe('buddyServiceProcess', () => { process.stderr.destroy(new Error('stderr read failed')) expect(await logger.close()).toMatchObject({ written: 1, closeTimedOut: false }) const record = JSON.parse(await readFile(join(directory, 'application.jsonl'), 'utf8')) - expect(record).toMatchObject({ event: 'process.stderr_failed', error: { message: 'stderr read failed' } }) + expect(record).toMatchObject({ event: 'process.stderr_failed', errorType: 'Error', errorCode: 'OPERATION_FAILED' }) process.exit(1) }) diff --git a/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts b/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts index 78db7f9d..c8f027a1 100644 --- a/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts +++ b/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts @@ -8,6 +8,7 @@ import { createSandboxDirectory } from '../../../platform/process/sandboxDirecto import { createSandboxEnvironment } from '../../../platform/process/sandboxEnvironment' import { resolveWindowsSandbox } from '../../../platform/process/windowsSandbox' import sandboxProcessPath from '../../../service/src/sandbox/sandboxProcess?modulePath' +import { Emitter } from '../../../shared/events/Emitter' import { sandboxLifecycleNotificationSchema } from '../../../shared/permissions/sandboxLifecycle' import { SANDBOX_RPC_TIMEOUT_MS, sandboxCancelSchema, sandboxCommandSchema, sandboxNetworkRequestSchema, sandboxOutputSchema } from '../../../shared/permissions/shellSandbox' import { isLinux, isWindows, OPERATING_SYSTEM, SHELL_SANDBOX_BACKEND } from '../../../shared/platform/identifiers' @@ -22,7 +23,9 @@ export interface SandboxHostOptions { windowsShell?: string } -export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: SandboxHostOptions): () => void { +export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: SandboxHostOptions) { + const changes = new Emitter>(() => console.error('SANDBOX_HOST_OBSERVER_FAILED')) + const publish = (event: Parameters[0]) => changes.fire(Object.freeze(event)) const active = new Map() let disposed = false const disposers = [ @@ -43,6 +46,7 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa active.set(input.requestId, controller) let directory: string | undefined let child: Electron.UtilityProcess | undefined + let pid: number | undefined let childPeer: BuddyServicePeer | undefined let exited: Promise | undefined let forceKill: ReturnType | undefined @@ -54,6 +58,26 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa forceKill ??= setTimeout(() => child?.kill(), 15_000) forceKill.unref() } + const release = async () => { + publish({ requestId: input.requestId, kind: 'cleanup_requested', pid }) + try { + child?.kill() + await exited + childPeer?.close(new Error('Sandbox command finished')) + if (directory) + await rm(directory, { recursive: true, force: true }) + publish({ requestId: input.requestId, kind: 'resources_released', pid }) + } + catch (error) { + publish({ requestId: input.requestId, kind: 'cleanup_failed', pid }) + throw error + } + finally { + active.delete(input.requestId) + if (disposed && !active.size) + changes.dispose() + } + } controller.signal.addEventListener('abort', cancel, { once: true }) try { const windowsSandbox = isWindows(process.platform) ? await resolveWindowsSandbox(options.windowsSandbox) : undefined @@ -73,11 +97,16 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa serviceName: 'Buddy Shell Sandbox', stdio: 'pipe', }) + child.once('spawn', () => { + pid = child?.pid + publish({ requestId: input.requestId, kind: 'spawned', pid }) + }) child.stdout?.resume() child.stderr?.resume() childPeer = new BuddyServicePeer({ process: child }) const processPeer = childPeer - exited = new Promise(resolve => child!.once('exit', () => { + exited = new Promise(resolve => child!.once('exit', (exitCode) => { + publish({ requestId: input.requestId, kind: 'exited', pid, exitCode }) processPeer.close(new Error('Sandbox supervisor exited')) resolve() })) @@ -122,19 +151,17 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa controller.signal.removeEventListener('abort', cancel) if (forceKill) clearTimeout(forceKill) - child?.kill() - await exited - childPeer?.close(new Error('Sandbox command finished')) - active.delete(input.requestId) - if (directory) - await rm(directory, { recursive: true, force: true }) + await release() } }), ] - return () => { + const dispose = () => { disposed = true disposers.forEach(dispose => dispose()) for (const controller of active.values()) controller.abort() + if (!active.size) + changes.dispose() } + return { dispose, onDidChange: changes.event } } diff --git a/apps/buddy/electron/main/sandbox/verifySandboxInstallation.ts b/apps/buddy/electron/main/sandbox/verifySandboxInstallation.ts index 58b2b393..4eb53891 100644 --- a/apps/buddy/electron/main/sandbox/verifySandboxInstallation.ts +++ b/apps/buddy/electron/main/sandbox/verifySandboxInstallation.ts @@ -108,7 +108,7 @@ export async function verifySandboxInstallation(options: SandboxHostOptions): Pr } finally { await client.dispose() - disposeHost() + disposeHost.dispose() host.close(new Error('Installation verification completed')) runtime.close(new Error('Installation verification completed')) channel.port1.close() diff --git a/apps/buddy/electron/preload/desktop.ts b/apps/buddy/electron/preload/desktop.ts index f3eaefb4..2c20405e 100644 --- a/apps/buddy/electron/preload/desktop.ts +++ b/apps/buddy/electron/preload/desktop.ts @@ -1,6 +1,7 @@ import type { ContextPanelCommand, ContextPanelState } from '../../shared/context-panel/contextPanel' import type { ApplicationLogExport, ApplicationLogQuery } from '../../shared/diagnostics/applicationLog' import type { ApplicationStartupState } from '../../shared/diagnostics/applicationStartup' +import type { CpuProfileRequest } from '../../shared/diagnostics/performanceDiagnostic' import type { RendererDiagnosticReport } from '../../shared/diagnostics/rendererDiagnostic' import type { RendererLifecycleReport } from '../../shared/lifecycle/serviceLifecycle' import type { DesktopAppInfo, DesktopOpenTarget, DesktopWindowState, LexoraConfigPatch, LexoraDesktopApi } from '../shared/desktopApi' @@ -25,9 +26,13 @@ export function createDesktopApi(): Pick void) => subscribe(DESKTOP_IPC_CHANNELS.contextPanelStateChanged, listener), }), app: Object.freeze({ + performance: Object.freeze({ + snapshot: () => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appPerformanceSnapshot), + capture: (request: CpuProfileRequest) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appPerformanceCapture, { target: request.target, pid: request.pid }), + }), logs: Object.freeze({ report: (input: RendererDiagnosticReport) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appLogsReport, input), - exportDiagnostics: (input: ApplicationLogExport) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appLogsExportDiagnostics, { launch: input.launch }), + exportDiagnostics: (input: ApplicationLogExport) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appLogsExportDiagnostics, { launch: input.launch, anchor: input.anchor ? { ...input.anchor } : undefined }), query: (input: ApplicationLogQuery) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appLogsQuery, { ...input, anchor: input.anchor ? { launchId: input.anchor.launchId, sequence: input.anchor.sequence } : undefined, diff --git a/apps/buddy/electron/shared/desktopApi.ts b/apps/buddy/electron/shared/desktopApi.ts index fc5153e4..29c2addf 100644 --- a/apps/buddy/electron/shared/desktopApi.ts +++ b/apps/buddy/electron/shared/desktopApi.ts @@ -1,6 +1,7 @@ import type { DesktopBrowserApi } from '../../shared/browser/browserDesktopApi' import type { ApplicationLogApi } from '../../shared/diagnostics/applicationLog' import type { ApplicationStartupState } from '../../shared/diagnostics/applicationStartup' +import type { PerformanceDiagnosticApi } from '../../shared/diagnostics/performanceDiagnostic' import type { RendererDiagnosticApi } from '../../shared/diagnostics/rendererDiagnostic' import type { RendererLifecycleReport } from '../../shared/lifecycle/serviceLifecycle' import type { SandboxEnvironmentStatus, SandboxSetupResult } from '../../shared/permissions/shellSandbox' @@ -16,6 +17,8 @@ export const DESKTOP_IPC_CHANNELS = { contextPanelGetState: 'lexora:context-panel:get-state', contextPanelExecute: 'lexora:context-panel:execute', contextPanelStateChanged: 'lexora:context-panel:state-changed', + appPerformanceSnapshot: 'lexora:app:performance:snapshot', + appPerformanceCapture: 'lexora:app:performance:capture', appLogsQuery: 'lexora:app:logs:query', appLogsReport: 'lexora:app:logs:report', appLogsExportDiagnostics: 'lexora:app:logs:export-diagnostics', @@ -205,6 +208,7 @@ export interface LexoraDesktopApi { workbench: import('../../shared/workbench/workbenchState').WorkbenchStateApi contextPanel: import('../../shared/context-panel/contextPanel').ContextPanelApi app: { + performance: PerformanceDiagnosticApi logs: ApplicationLogApi & RendererDiagnosticApi startup: { getState: () => Promise diff --git a/apps/buddy/platform/diagnostics/__tests__/cpuProfile.spec.ts b/apps/buddy/platform/diagnostics/__tests__/cpuProfile.spec.ts new file mode 100644 index 00000000..f4283ee4 --- /dev/null +++ b/apps/buddy/platform/diagnostics/__tests__/cpuProfile.spec.ts @@ -0,0 +1,37 @@ +import type { Profiler } from 'node:inspector' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { captureCpuProfile, summarizeCpuProfile } from '../cpuProfile' + +afterEach(() => vi.useRealTimers()) +const profile: Profiler.Profile = { + startTime: 0, + endTime: 10000, + nodes: [ + { id: 1, callFrame: { functionName: 'privateUserFunction', scriptId: '1', url: 'file:///home/private/file.js?secret=value', lineNumber: 99, columnNumber: 5 } }, + { id: 2, callFrame: { functionName: 'internalFunction', scriptId: '2', url: 'file:///app/index.js', lineNumber: 12, columnNumber: 3 } }, + ], + samples: [1, 2, 2], + timeDeltas: [2000, 3000, 5000], +} +describe('bounded CPU capture', () => { + it('keeps only build code identifiers and sampled self time, dropping untrusted paths and function names', () => { + const result = summarizeCpuProfile(profile, 'main', 42, url => url === 'file:///app/index.js' ? 'index.js' : null) + expect(result.hotspots).toEqual([{ location: expect.stringMatching(/^main:[a-f0-9]{12}:13:4$/), selfMs: 8 }, { location: 'external', selfMs: 2 }]) + expect(JSON.stringify(result)).not.toMatch(/private|secret|Function|file:|index\.js/) + expect(() => summarizeCpuProfile({ ...profile, samples: Array.from({ length: 20001 }, (_, index) => index) }, 'main', 42, () => null)).toThrow('CPU_PROFILE_LIMIT') + }) + + it('releases its inspector connection on cancellation and protocol failures', async () => { + const controller = new AbortController() + const closed: string[] = [] + const pending = captureCpuProfile({ send: async () => { + controller.abort() + return {} + }, close: () => closed.push('aborted') }, 'main', 1, () => null, controller.signal) + await expect(pending).rejects.toThrow() + await expect(captureCpuProfile({ send: async () => { + throw new Error('protocol') + }, close: () => closed.push('failed') }, 'renderer', 2, () => null)).rejects.toThrow('protocol') + expect(closed).toEqual(['aborted', 'failed']) + }) +}) diff --git a/apps/buddy/platform/diagnostics/cpuProfile.ts b/apps/buddy/platform/diagnostics/cpuProfile.ts new file mode 100644 index 00000000..a6f16f55 --- /dev/null +++ b/apps/buddy/platform/diagnostics/cpuProfile.ts @@ -0,0 +1,64 @@ +import type { Profiler } from 'node:inspector' +import type { CpuProfileSummary, CpuProfileTarget } from '../../shared/diagnostics/performanceDiagnostic' +import { createHash } from 'node:crypto' +import { Session } from 'node:inspector/promises' +import process from 'node:process' +import { setTimeout } from 'node:timers/promises' +import { cpuProfileSummarySchema } from '../../shared/diagnostics/performanceDiagnostic' + +export interface CpuProfilerConnection { + send: (method: string, params?: Record) => Promise + close: () => void +} + +export async function captureCpuProfile(connection: CpuProfilerConnection, target: CpuProfileTarget, pid: number, trustedCode: (url: string) => string | null, signal?: AbortSignal): Promise { + try { + signal?.throwIfAborted() + await connection.send('Profiler.enable') + await connection.send('Profiler.setSamplingInterval', { interval: 10_000 }) + signal?.throwIfAborted() + await connection.send('Profiler.start') + await setTimeout(5000, undefined, { signal }) + const { profile } = await connection.send('Profiler.stop') as { profile: Profiler.Profile } + return summarizeCpuProfile(profile, target, pid, trustedCode) + } + finally { + connection.close() + } +} + +export async function captureNodeCpuProfile(target: 'main' | 'runtime', signal?: AbortSignal): Promise { + const session = new Session() + session.connect() + const root = new URL(import.meta.url.includes('/chunks/') ? '../' : '.', import.meta.url).href + return captureCpuProfile({ send: (method, params) => session.post(method, params), close: () => session.disconnect() }, target, process.pid, (url) => { + if (!url.startsWith(root)) + return null + const relative = url.slice(root.length) + return /^(?:chunks\/)?[\w-]+\.js$/.test(relative) ? relative : null + }, signal) +} + +export function summarizeCpuProfile(profile: Profiler.Profile, target: CpuProfileTarget, pid: number, trustedCode: (url: string) => string | null): CpuProfileSummary { + if (profile.nodes.length > 50_000 || (profile.samples?.length ?? 0) > 20_000 || !profile.samples || !profile.timeDeltas || profile.samples.length !== profile.timeDeltas.length) + throw new Error('CPU_PROFILE_LIMIT') + const locations = new Map() + for (const node of profile.nodes) { + const frame = node.callFrame + const code = trustedCode(frame.url) + const builtin = new Map([['(idle)', 'idle'], ['(garbage collector)', 'gc'], ['(program)', 'native'], ['(root)', 'root']]) + const location = code + ? `${target}:${createHash('sha256').update(code).digest('hex').slice(0, 12)}:${Math.max(0, frame.lineNumber + 1)}:${Math.max(0, frame.columnNumber + 1)}` + : frame.url ? 'external' : builtin.get(frame.functionName) ?? 'native' + locations.set(node.id, location) + } + const totals = new Map() + for (let index = 0; index < profile.samples.length; index++) { + const location = locations.get(profile.samples[index]!) ?? 'external' + const milliseconds = profile.timeDeltas[index]! / 1000 + if (!Number.isFinite(milliseconds) || milliseconds < 0) + throw new Error('CPU_PROFILE_INVALID') + totals.set(location, (totals.get(location) ?? 0) + milliseconds) + } + return cpuProfileSummarySchema.parse({ target, pid, durationMs: (profile.endTime - profile.startTime) / 1000, samples: profile.samples.length, hotspots: [...totals].map(([location, selfMs]) => ({ location, selfMs })).sort((a, b) => b.selfMs - a.selfMs).slice(0, 20) }) +} diff --git a/apps/buddy/platform/process/windowsSandboxProcess.ts b/apps/buddy/platform/process/windowsSandboxProcess.ts index 727bbf86..540acce3 100644 --- a/apps/buddy/platform/process/windowsSandboxProcess.ts +++ b/apps/buddy/platform/process/windowsSandboxProcess.ts @@ -1,4 +1,5 @@ import type { Buffer } from 'node:buffer' +import type { SandboxProcessState } from '../../shared/permissions/sandboxLifecycle' import { spawn } from 'node:child_process' import process from 'node:process' import { createInterface } from 'node:readline' @@ -27,6 +28,7 @@ export async function runWindowsSandboxProcess(executable: string, request: Wind signal: AbortSignal onData: (data: Buffer) => void onStarted: () => void + onProcess?: (process: SandboxProcessState) => void }): Promise { options.signal.throwIfAborted() return new Promise((resolve, reject) => { @@ -35,7 +37,10 @@ export async function runWindowsSandboxProcess(executable: string, request: Wind let launchError: Error | undefined let forceKill: ReturnType | undefined const child = spawn(executable, ['run'], { cwd: request.privateRoot, env: createWindowsHostEnvironment(executable, process.env), stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }) + child.once('spawn', () => options.onProcess?.({ phase: 'spawned', pid: child.pid })) + child.once('exit', exitCode => options.onProcess?.({ phase: 'exited', pid: child.pid, exitCode })) const cancel = () => { + options.onProcess?.({ phase: 'cleanup_requested', pid: child.pid }) child.stdin.end() forceKill ??= setTimeout(() => child.kill(), 10_000) forceKill.unref() @@ -49,6 +54,8 @@ export async function runWindowsSandboxProcess(executable: string, request: Wind if (!options.signal.aborted) options.onStarted() } + if (diagnostic.type === 'cleanupError') + options.onProcess?.({ phase: 'cleanup_failed', pid: child.pid }) if (diagnostic.type === 'error' || diagnostic.type === 'cleanupError') failed = true } @@ -63,6 +70,7 @@ export async function runWindowsSandboxProcess(executable: string, request: Wind launchError = error }) child.once('close', (code) => { + options.onProcess?.({ phase: 'closed', pid: child.pid, exitCode: code }) options.signal.removeEventListener('abort', cancel) clearTimeout(forceKill) lines.close() diff --git a/apps/buddy/service/src/diagnostics/DiagnosticForwarder.ts b/apps/buddy/service/src/diagnostics/DiagnosticForwarder.ts new file mode 100644 index 00000000..0349145f --- /dev/null +++ b/apps/buddy/service/src/diagnostics/DiagnosticForwarder.ts @@ -0,0 +1,64 @@ +import type { ApplicationDiagnostic, ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import { DiagnosticAdmission } from '../../../shared/diagnostics/DiagnosticAdmission' + +export class DiagnosticForwarder { + readonly #admission = new DiagnosticAdmission() + readonly #send: ApplicationDiagnosticReporter + readonly #producerInstanceId = crypto.randomUUID() + #dropped = 0 + #failed = 0 + #reported = 0 + #timer: ReturnType | undefined + #disposed = false + + constructor(send: ApplicationDiagnosticReporter) { + this.#send = send + } + + record(event: ApplicationDiagnostic): void { + if (this.#disposed) + return + if (!this.#admission.take()) { + this.#dropped++ + this.#scheduleFlush() + return + } + this.#deliver({ ...event, producerInstanceId: this.#producerInstanceId }) + } + + dispose(): void { + if (this.#disposed) + return + this.#disposed = true + clearTimeout(this.#timer) + this.#timer = undefined + this.#flush() + } + + #deliver(event: ApplicationDiagnostic): void { + try { + this.#send(event) + } + catch { + this.#failed++ + this.#scheduleFlush() + } + } + + #scheduleFlush(): void { + if (this.#timer || this.#disposed) + return + this.#timer = setTimeout(() => { + this.#timer = undefined + this.#flush() + }, 5000) + this.#timer.unref() + } + + #flush(): void { + if (this.#reported === this.#dropped + this.#failed) + return + this.#reported = this.#dropped + this.#failed + this.#deliver({ event: 'recorder.loss', level: 'warn', producerInstanceId: this.#producerInstanceId, recorderLoss: { dropped: this.#dropped, failed: this.#failed } }) + } +} diff --git a/apps/buddy/service/src/diagnostics/__tests__/DiagnosticForwarder.spec.ts b/apps/buddy/service/src/diagnostics/__tests__/DiagnosticForwarder.spec.ts new file mode 100644 index 00000000..a1d0a63a --- /dev/null +++ b/apps/buddy/service/src/diagnostics/__tests__/DiagnosticForwarder.spec.ts @@ -0,0 +1,69 @@ +import type { ApplicationDiagnostic } from '../../../../shared/diagnostics/applicationDiagnostic' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DiagnosticForwarder } from '../DiagnosticForwarder' + +beforeEach(() => vi.useFakeTimers({ toFake: ['performance', 'setTimeout', 'clearTimeout'] })) +afterEach(() => vi.useRealTimers()) + +describe('diagnostic transport budget', () => { + it('bounds a producer storm and reports cumulative loss during idle without replaying suppressed events', () => { + const records: ApplicationDiagnostic[] = [] + const source = new DiagnosticForwarder(event => records.push(event)) + for (let index = 0; index < 10000; index++) + source.record({ event: 'tool.started', level: 'info', count: index }) + expect(records).toHaveLength(512) + expect(vi.getTimerCount()).toBe(1) + vi.advanceTimersByTime(4999) + expect(records).toHaveLength(512) + vi.advanceTimersByTime(1) + expect(records.at(-1)).toMatchObject({ event: 'recorder.loss', recorderLoss: { dropped: 9488, failed: 0 } }) + expect(vi.getTimerCount()).toBe(0) + vi.advanceTimersByTime(30000) + source.record({ event: 'tool.completed', level: 'info' }) + expect(records.at(-1)?.event).toBe('tool.completed') + expect(new Set(records.map(event => event.producerInstanceId)).size).toBe(1) + source.dispose() + expect(records).toHaveLength(514) + }) + + it('isolates a failed transport and exposes its loss after recovery', () => { + let failed = true + const records: ApplicationDiagnostic[] = [] + const source = new DiagnosticForwarder((event) => { + if (failed) + throw new Error('private transport context') + records.push(event) + }) + expect(() => source.record({ event: 'tool.started', level: 'info' })).not.toThrow() + vi.advanceTimersByTime(5000) + expect(records).toEqual([]) + expect(vi.getTimerCount()).toBe(1) + failed = false + vi.advanceTimersByTime(5000) + expect(records[0]?.recorderLoss).toEqual({ dropped: 0, failed: 2 }) + expect(vi.getTimerCount()).toBe(0) + expect(JSON.stringify(records)).not.toContain('private') + source.dispose() + }) + + it('flushes remaining losses once on shutdown and stops subsequent delivery and retries', () => { + const records: ApplicationDiagnostic[] = [] + const source = new DiagnosticForwarder(event => records.push(event)) + for (let index = 0; index < 513; index++) + source.record({ event: 'tool.started', level: 'info' }) + source.dispose() + expect(records.at(-1)?.recorderLoss).toEqual({ dropped: 1, failed: 0 }) + expect(vi.getTimerCount()).toBe(0) + source.dispose() + source.record({ event: 'tool.completed', level: 'info' }) + vi.advanceTimersByTime(10000) + expect(records).toHaveLength(513) + + const broken = new DiagnosticForwarder(() => { + throw new Error('transport closed') + }) + broken.record({ event: 'tool.started', level: 'info' }) + broken.dispose() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/apps/buddy/service/src/index.ts b/apps/buddy/service/src/index.ts index 9eca9c9e..cf226665 100644 --- a/apps/buddy/service/src/index.ts +++ b/apps/buddy/service/src/index.ts @@ -14,6 +14,7 @@ import { toPublicRunEvent } from '../../shared/runs/publicRunEvent' import { runNotifications } from '../../shared/runs/runApi' import { buddyServiceFailureCodeSchema } from '../../shared/runtime/runtimeProtocol' import { startBuddyService } from './BuddyService' +import { DiagnosticForwarder } from './diagnostics/DiagnosticForwarder' import { createRunEventLog } from './events/createRunEventLog' import { RunEventLogFatalError } from './events/RunEventFailure' import { startRuntimeNetwork } from './network/runtimeNetwork' @@ -50,7 +51,8 @@ async function runBuddyService(): Promise { let database: ReturnType | null = null let serviceServer: ReturnType | null = null const events = new ApplicationEvents() - events.subscribe(event => serviceServer?.notify(APPLICATION_DIAGNOSTIC_METHOD, event)) + const diagnostics = new DiagnosticForwarder(event => serviceServer?.notify(APPLICATION_DIAGNOSTIC_METHOD, event)) + events.subscribe(event => diagnostics.record(event)) const record = events.publish const lifecycle = new ServiceLifecycleSource(() => record({ event: 'observer.failed', component: 'runtime.lifecycle', level: 'warn' })) const host = new ServiceHost(lifecycle) @@ -91,6 +93,7 @@ async function runBuddyService(): Promise { record({ event: failed ? 'service.stop_failed' : 'service.stopped', level: failed ? 'error' : 'info', component: 'runtime.service' }) lifecycleDelivery.dispose() stopLifecycleDiagnostics() + diagnostics.dispose() serviceServer?.close(new Error('Buddy Local Service is shutting down')) process.exit(failed ? 1 : exitCode) } @@ -174,6 +177,7 @@ async function runBuddyService(): Promise { await host.stop().catch(() => {}) lifecycleDelivery.dispose() stopLifecycleDiagnostics() + diagnostics.dispose() serviceServer.close(new Error('Buddy Local Service startup failed')) closeDatabase() throw error diff --git a/apps/buddy/service/src/rpc/BuddyServiceRpcServer.ts b/apps/buddy/service/src/rpc/BuddyServiceRpcServer.ts index 6497b8ae..a16cf54c 100644 --- a/apps/buddy/service/src/rpc/BuddyServiceRpcServer.ts +++ b/apps/buddy/service/src/rpc/BuddyServiceRpcServer.ts @@ -2,8 +2,10 @@ import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/ import type { RuntimeRequestHandler, RuntimeRpcPeerContract } from '../../../shared/runtime/rpcPeer' import type { BuddyServiceFailureCode } from '../../../shared/runtime/runtimeProtocol' import process from 'node:process' +import { captureNodeCpuProfile } from '../../../platform/diagnostics/cpuProfile' import { RuntimeRpcPeer } from '../../../platform/ipc/runtimeRpcPeer' import { readDiagnosticErrorCode, safeDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import { cpuProfileRequestSchema } from '../../../shared/diagnostics/performanceDiagnostic' import { isRoutineRpc } from '../../../shared/diagnostics/rpcDiagnosticPolicy' import { BUDDY_SERVICE_PROTOCOL_VERSION, @@ -103,6 +105,19 @@ export function createBuddyService(options: CreateBuddyServiceOptions): BuddySer port: options.port, onFatalError: options.onFatalError, }) + let profiling = false + server.onRequest('diagnostics.profile', async (input, signal) => { + const request = cpuProfileRequestSchema.parse(input) + if (request.target !== 'runtime' || request.pid !== process.pid) + throw new Error('CPU_PROFILE_PROCESS_CHANGED') + if (profiling) + throw new Error('CPU_PROFILE_BUSY') + profiling = true + try { + return await captureNodeCpuProfile('runtime', signal) + } + finally { profiling = false } + }) server.onRequest('runtime.status', () => ({ name: SERVICE_NAME, protocolVersion: BUDDY_SERVICE_PROTOCOL_VERSION, diff --git a/apps/buddy/service/src/sandbox/__tests__/ShellSandboxClient.spec.ts b/apps/buddy/service/src/sandbox/__tests__/ShellSandboxClient.spec.ts index 86dba5f0..d0a6d4e3 100644 --- a/apps/buddy/service/src/sandbox/__tests__/ShellSandboxClient.spec.ts +++ b/apps/buddy/service/src/sandbox/__tests__/ShellSandboxClient.spec.ts @@ -3,6 +3,8 @@ import type { RuntimeRpcPeerContract } from '../../../../shared/runtime/rpcPeer' import type { SandboxClientEvent } from '../ShellSandboxClient' import { deferred } from '@buddy-tests/deferred' import { describe, expect, it } from 'vitest' +import { diagnosticContext } from '../../diagnostics/diagnosticContext' +import { observeSandboxClient } from '../observeSandboxClient' import { ShellSandboxClient } from '../ShellSandboxClient' function fixture() { @@ -36,6 +38,20 @@ function fixture() { } describe('sandbox client facts', () => { + it('retains tool correlation across incoming backend notifications outside the original async scope', async () => { + const f = fixture() + const records: import('../../../../shared/diagnostics/applicationDiagnostic').ApplicationDiagnostic[] = [] + const subscription = observeSandboxClient(f.client, event => records.push(event)) + const pending = diagnosticContext.run({ runId: 'run-fixture', toolCallId: 'tool-fixture' }, f.execute) + f.backend(1, 'started') + f.response.resolve({ ok: true, exitCode: 0 }) + await pending + expect(records).toHaveLength(3) + expect(records.every(event => event.runId === 'run-fixture' && event.toolCallId === 'tool-fixture')).toBe(true) + subscription.dispose() + await f.client.dispose() + }) + it('keeps successful settlement distinct from cleanup and ignores stale backend messages', async () => { const f = fixture() const pending = f.execute() diff --git a/apps/buddy/service/src/sandbox/__tests__/shellSandbox.spec.ts b/apps/buddy/service/src/sandbox/__tests__/shellSandbox.spec.ts index ea869cd3..4ba8a34f 100644 --- a/apps/buddy/service/src/sandbox/__tests__/shellSandbox.spec.ts +++ b/apps/buddy/service/src/sandbox/__tests__/shellSandbox.spec.ts @@ -71,6 +71,18 @@ describe.skipIf(process.platform !== 'linux')('linux shell enforcement', () => { return { result, output: Buffer.concat(output).toString('utf8') } } + it('records command process exit, pipe close and resource release without command contents', async () => { + const events: import('../../../../shared/permissions/sandboxLifecycle').SandboxLifecycleEvent[] = [] + const { result } = await execute('printf private-fixture-content', { onLifecycle: event => events.push(event) }) + expect(result).toEqual({ ok: true, exitCode: 0 }) + const processes = events.filter(event => event.kind === 'process').map(event => event.snapshot.process!) + expect(processes.map(process => process.phase)).toEqual(['spawned', 'exited', 'closed', 'cleanup_requested', 'resources_released']) + expect(processes[0]?.pid).toBeGreaterThan(0) + expect(new Set(processes.map(process => process.pid)).size).toBe(1) + expect(processes.find(process => process.phase === 'exited')?.exitCode).toBe(0) + expect(JSON.stringify(events)).not.toMatch(/private-fixture|printf/) + }) + it('executes compound commands and workspace edits while masking secrets, symlink escapes and Git metadata', async () => { await mkdir(join(workspace, '.git')) await writeFile(join(workspace, '.git/config'), 'git-preserved') diff --git a/apps/buddy/service/src/sandbox/backends/srt/runSrtSandbox.ts b/apps/buddy/service/src/sandbox/backends/srt/runSrtSandbox.ts index 07ec08f4..7b5b2c6a 100644 --- a/apps/buddy/service/src/sandbox/backends/srt/runSrtSandbox.ts +++ b/apps/buddy/service/src/sandbox/backends/srt/runSrtSandbox.ts @@ -10,6 +10,18 @@ export async function runSrtSandbox(input: SrtSandboxInput, options: SandboxExec let child: ChildProcess | undefined const kill = () => killProcessGroup(child?.pid) options.signal.addEventListener('abort', kill, { once: true }) + const release = async () => { + options.onProcess?.({ phase: 'cleanup_requested', pid: child?.pid }) + try { + kill() + await SandboxManager.reset() + options.onProcess?.({ phase: 'resources_released', pid: child?.pid }) + } + catch (error) { + options.onProcess?.({ phase: 'cleanup_failed', pid: child?.pid }) + throw error + } + } try { const { config, path } = await createSandboxPolicy(input, options.signal) await SandboxManager.initialize(config, ({ host, port }) => options.approveNetwork({ host, port: port ?? 443 }), false) @@ -32,16 +44,22 @@ export async function runSrtSandbox(input: SrtSandboxInput, options: SandboxExec child.stdout!.on('data', options.onData) child.stderr!.on('data', options.onData) child.once('error', reject) - child.once('close', resolve) - child.once('spawn', options.onStarted) + child.once('exit', exitCode => options.onProcess?.({ phase: 'exited', pid: child?.pid, exitCode })) + child.once('close', (exitCode) => { + options.onProcess?.({ phase: 'closed', pid: child?.pid, exitCode }) + resolve(exitCode) + }) + child.once('spawn', () => { + options.onProcess?.({ phase: 'spawned', pid: child?.pid }) + options.onStarted() + }) if (options.signal.aborted) kill() }) } finally { options.signal.removeEventListener('abort', kill) - kill() - await SandboxManager.reset() + await release() } } diff --git a/apps/buddy/service/src/sandbox/observeSandboxClient.ts b/apps/buddy/service/src/sandbox/observeSandboxClient.ts index 5e05b150..321c4403 100644 --- a/apps/buddy/service/src/sandbox/observeSandboxClient.ts +++ b/apps/buddy/service/src/sandbox/observeSandboxClient.ts @@ -1,20 +1,34 @@ import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import type { EventContext } from '../../../shared/observability/ApplicationEvents' import type { ShellSandboxClient } from './ShellSandboxClient' import { safeDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import { diagnosticContext } from '../diagnostics/diagnosticContext' export function observeSandboxClient(source: Pick, report: ApplicationDiagnosticReporter) { const record = safeDiagnosticReporter(report) - return source.onDidChange((event) => { + const contexts = new Map() + const subscription = source.onDidChange((event) => { + if (event.kind === 'requested' && contexts.size < 64) + contexts.set(event.requestId, { ...diagnosticContext.getStore() }) const kind = event.kind === 'backend' ? `backend.${event.event.kind}` : event.kind + const cleanupFailed = event.kind === 'backend' && event.event.snapshot.process?.phase === 'cleanup_failed' const result = event.kind === 'returned' ? event.result : event.kind === 'backend' ? event.event.snapshot.result : undefined record({ + ...contexts.get(event.requestId), event: `sandbox.${kind.replaceAll('-', '_')}`, - level: event.kind === 'transport-failed' || result?.ok === false ? 'warn' : 'info', + level: event.kind === 'transport-failed' || result?.ok === false || cleanupFailed ? 'warn' : 'info', requestId: event.requestId, revision: event.revision, - ...(event.kind === 'backend' ? { count: event.event.snapshot.waiting, sourceSequence: event.event.revision } : {}), + ...(event.kind === 'backend' ? { count: event.event.snapshot.waiting, sourceSequence: event.event.revision, sandboxProcess: event.event.snapshot.process } : {}), + ...(cleanupFailed ? { errorCode: 'SANDBOX_CLEANUP_FAILED' } : {}), ...(result?.ok === false ? { errorCode: result.code } : {}), ...(event.kind === 'transport-failed' ? { errorCode: 'SANDBOX_TRANSPORT_FAILED' } : {}), }) + if (event.kind === 'returned' || event.kind === 'transport-failed') + contexts.delete(event.requestId) }) + return { dispose: () => { + subscription.dispose() + contexts.clear() + } } } diff --git a/apps/buddy/service/src/sandbox/runSandboxCommand.ts b/apps/buddy/service/src/sandbox/runSandboxCommand.ts index b1af016b..011541b4 100644 --- a/apps/buddy/service/src/sandbox/runSandboxCommand.ts +++ b/apps/buddy/service/src/sandbox/runSandboxCommand.ts @@ -7,7 +7,7 @@ import { runSrtSandbox } from './backends/srt/runSrtSandbox' import { runWindowsSandbox } from './backends/windows/runWindowsSandbox' import { SandboxExecutionLifecycle } from './sandboxExecutionLifecycle' -export async function runSandboxCommand(input: SandboxProcessInput, options: Omit & { onLifecycle?: (event: Readonly) => void }): Promise { +export async function runSandboxCommand(input: SandboxProcessInput, options: Omit & { onLifecycle?: (event: Readonly) => void }): Promise { const lifecycle = new SandboxExecutionLifecycle(options.signal, input.timeout) const subscription = options.onLifecycle ? lifecycle.onDidChange(options.onLifecycle) : undefined lifecycle.begin() @@ -31,6 +31,7 @@ export async function runSandboxCommand(input: SandboxProcessInput, options: Omi ...options, signal: lifecycle.signal, approveNetwork: requestNetwork, + onProcess: process => lifecycle.processChanged(process), onStarted: () => { if (!lifecycle.signal.aborted) lifecycle.start() diff --git a/apps/buddy/service/src/sandbox/sandboxExecutionLifecycle.ts b/apps/buddy/service/src/sandbox/sandboxExecutionLifecycle.ts index 4f3f1e58..b937b655 100644 --- a/apps/buddy/service/src/sandbox/sandboxExecutionLifecycle.ts +++ b/apps/buddy/service/src/sandbox/sandboxExecutionLifecycle.ts @@ -1,6 +1,6 @@ import type { Buffer } from 'node:buffer' import type { EventSnapshot } from '../../../shared/events/eventTypes' -import type { SandboxLifecycleEvent, SandboxLifecycleSnapshot } from '../../../shared/permissions/sandboxLifecycle' +import type { SandboxLifecycleEvent, SandboxLifecycleSnapshot, SandboxProcessState } from '../../../shared/permissions/sandboxLifecycle' import type { SandboxNetworkTarget, SandboxResult } from '../../../shared/permissions/shellSandbox' import { Emitter } from '../../../shared/events/Emitter' import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' @@ -10,6 +10,7 @@ export interface SandboxExecutionOptions { approveNetwork: (target: SandboxNetworkTarget) => Promise onData: (data: Buffer) => void onStarted: () => void + onProcess?: (process: SandboxProcessState) => void } export class SandboxExecutionLifecycle { @@ -36,6 +37,7 @@ export class SandboxExecutionLifecycle { #phase: SandboxLifecycleSnapshot['phase'] = 'preparing' #cancellation: SandboxLifecycleSnapshot['cancellation'] = 'none' #result: SandboxResult | undefined + #process: SandboxProcessState | undefined constructor(signal: AbortSignal, timeoutSeconds = 30 * 60, preparationTimeoutMs = 60_000) { this.#externalSignal = signal @@ -53,7 +55,14 @@ export class SandboxExecutionLifecycle { get started(): boolean { return this.#started } get timedOut(): boolean { return this.#cancellation === 'timed-out' } get snapshot(): EventSnapshot { - return copyEventSnapshot({ phase: this.#phase, started: this.#started, waiting: this.#waiting, cancellation: this.#cancellation, ...this.#result ? { result: this.#result } : {} }) + return copyEventSnapshot({ phase: this.#phase, started: this.#started, waiting: this.#waiting, cancellation: this.#cancellation, process: this.#process, ...this.#result ? { result: this.#result } : {} }) + } + + processChanged(process: SandboxProcessState): void { + if (this.#phase === 'finished') + return + this.#process = { ...process } + this.#publish('process') } begin(): void { diff --git a/apps/buddy/shared/diagnostics/DiagnosticAdmission.ts b/apps/buddy/shared/diagnostics/DiagnosticAdmission.ts new file mode 100644 index 00000000..f425d434 --- /dev/null +++ b/apps/buddy/shared/diagnostics/DiagnosticAdmission.ts @@ -0,0 +1,14 @@ +export class DiagnosticAdmission { + #tokens = 512 + #updatedAt = performance.now() + + take(): boolean { + const now = performance.now() + this.#tokens = Math.min(512, this.#tokens + Math.max(0, now - this.#updatedAt) * 0.1) + this.#updatedAt = now + if (this.#tokens < 1) + return false + this.#tokens-- + return true + } +} diff --git a/apps/buddy/shared/diagnostics/applicationDiagnostic.ts b/apps/buddy/shared/diagnostics/applicationDiagnostic.ts index 4daee862..c9b53dc2 100644 --- a/apps/buddy/shared/diagnostics/applicationDiagnostic.ts +++ b/apps/buddy/shared/diagnostics/applicationDiagnostic.ts @@ -2,8 +2,10 @@ import type { LifecycleFailure } from '../lifecycle/lifecycleFailure' import { z } from 'zod' import { extensionIdSchema } from '../extensions/extensionManifest' import { readLifecycleFailure } from '../lifecycle/lifecycleFailure' +import { sandboxProcessStateSchema } from '../permissions/sandboxLifecycle' import { desktopBootstrapFailureSchema, processExitSchema, rendererLoadFailureSchema } from './desktopStartupDiagnostic' import { networkStartupFailureSchema } from './networkStartupFailure' +import { cpuProfileSummarySchema, performanceSampleSchema } from './performanceDiagnostic' import { privateDirectoryFailureSchema } from './privateDirectoryFailure' import { providerRequestDiagnosticSchema } from './providerRequestDiagnostic' @@ -44,6 +46,10 @@ export const applicationDiagnosticSchema = z.object({ errorType: z.string().regex(/^[a-z]\w{0,95}$/i).optional(), failure: z.union([privateDirectoryFailureSchema, desktopBootstrapFailureSchema, networkStartupFailureSchema]).optional(), providerRequest: providerRequestDiagnosticSchema.optional(), + performanceSample: performanceSampleSchema.optional(), + cpuProfile: cpuProfileSummarySchema.optional(), + output: z.object({ bytes: z.number().int().nonnegative(), chunks: z.number().int().nonnegative() }).strict().optional(), + sandboxProcess: sandboxProcessStateSchema.optional(), recorderLoss: z.object({ dropped: z.number().int().nonnegative(), failed: z.number().int().nonnegative() }).strict().optional(), processExit: processExitSchema.optional(), loadFailure: rendererLoadFailureSchema.optional(), diff --git a/apps/buddy/shared/diagnostics/applicationLog.ts b/apps/buddy/shared/diagnostics/applicationLog.ts index b352c163..a3d83ca3 100644 --- a/apps/buddy/shared/diagnostics/applicationLog.ts +++ b/apps/buddy/shared/diagnostics/applicationLog.ts @@ -70,7 +70,7 @@ export interface ApplicationLogApi { const domains: ReadonlyArray = [ ['models', ['provider', 'providers', 'models']], ['automations', ['automation', 'automations', 'scheduler']], - ['execution', ['run', 'runs', 'turn', 'tool', 'session', 'sessions', 'session_factory', 'execution', 'chat', 'approvals', 'usage']], + ['execution', ['sandbox', 'run', 'runs', 'turn', 'tool', 'session', 'sessions', 'session_factory', 'execution', 'chat', 'approvals', 'usage']], ['storage', ['database', 'event_log', 'event_replay', 'recovery', 'artifacts', 'attachments', 'filesystem', 'spaces']], ['capabilities', ['connector', 'connectors', 'mcp', 'skills', 'web', 'browser', 'browser_adapter', 'pet', 'guard']], ['recorder', ['recorder', 'observer']], @@ -92,7 +92,7 @@ export function applicationLogCategory(record: Pick +export type ProcessRole = z.infer + +export const cpuProfileTargetSchema = z.enum(['main', 'runtime', 'renderer']) +export type CpuProfileTarget = z.infer +export const cpuProfileRequestSchema = z.object({ target: cpuProfileTargetSchema, pid: count }).strict() +export type CpuProfileRequest = z.infer +export const cpuProfileSummarySchema = z.object({ + target: cpuProfileTargetSchema, + pid: count, + durationMs: measurement, + samples: count, + hotspots: z.array(z.object({ + location: z.string().regex(/^(?:main|runtime|renderer|dependency|external|idle|gc|native|root)(?::[a-f0-9]{12}:\d+:\d+)?$/), + selfMs: measurement, + }).strict()).max(20), +}).strict() +export type CpuProfileSummary = z.infer + +export interface PerformanceDiagnosticApi { + snapshot: () => Promise<{ samples: PerformanceSample[], coverage: 'electron-processes', intervalMs: number, rendererPid: number }> + capture: (request: CpuProfileRequest) => Promise +} diff --git a/apps/buddy/shared/permissions/sandboxLifecycle.ts b/apps/buddy/shared/permissions/sandboxLifecycle.ts index 04d39ae6..2458c258 100644 --- a/apps/buddy/shared/permissions/sandboxLifecycle.ts +++ b/apps/buddy/shared/permissions/sandboxLifecycle.ts @@ -1,18 +1,26 @@ import { z } from 'zod' import { sandboxResultSchema } from './shellSandbox' +export const sandboxProcessStateSchema = z.object({ + pid: z.number().int().positive().optional(), + exitCode: z.number().int().nullable().optional(), + phase: z.enum(['spawned', 'exited', 'closed', 'cleanup_requested', 'resources_released', 'cleanup_failed']), +}).strict() +export type SandboxProcessState = z.infer + export const sandboxLifecycleSnapshotSchema = z.object({ phase: z.enum(['preparing', 'running', 'finished']), started: z.boolean(), waiting: z.number().int().nonnegative().max(1024), cancellation: z.enum(['none', 'requested', 'timed-out']), result: sandboxResultSchema.optional(), + process: sandboxProcessStateSchema.optional(), }).strict() export type SandboxLifecycleSnapshot = z.infer export const sandboxLifecycleEventSchema = z.object({ revision: z.number().int().positive(), - kind: z.enum(['preparing', 'started', 'approval-wait', 'approval-settled', 'approval-resumed', 'cancel-requested', 'timed-out', 'settled', 'released']), + kind: z.enum(['preparing', 'started', 'approval-wait', 'approval-settled', 'approval-resumed', 'cancel-requested', 'timed-out', 'settled', 'released', 'process']), snapshot: sandboxLifecycleSnapshotSchema, }).strict() export type SandboxLifecycleEvent = z.infer diff --git a/apps/buddy/src/i18n/locales/en-US/applicationLogs.ts b/apps/buddy/src/i18n/locales/en-US/applicationLogs.ts index c5f97db0..c9815be3 100644 --- a/apps/buddy/src/i18n/locales/en-US/applicationLogs.ts +++ b/apps/buddy/src/i18n/locales/en-US/applicationLogs.ts @@ -1,6 +1,37 @@ import type zhCN from '../zh-CN/applicationLogs' export default { + 'applicationLogs.performance.sample': 'Process load snapshot', + 'applicationLogs.performance.profile': 'CPU profile summary', + 'applicationLogs.performance.sustained': 'Sustained CPU load', + + 'applicationLogs.performance.title': 'Performance diagnostics', + 'applicationLogs.performance.unavailable': 'Process metrics are temporarily unavailable.', + 'applicationLogs.performance.wait': 'This takes about 5 seconds. Please wait.', + 'applicationLogs.performance.completed': 'Analysis complete for {process} (PID {pid})', + 'applicationLogs.performance.progress': 'Analyzing {process} (PID {pid})', + 'applicationLogs.performance.metricsOnly': 'Metrics only', + 'applicationLogs.performance.analysis': 'CPU analysis', + 'applicationLogs.performance.memory': 'Memory', + 'applicationLogs.performance.currentWindow': 'Interface · Current window', + 'applicationLogs.performance.scope': 'Collection details', + 'applicationLogs.performance.metrics': 'Updates every 5 seconds · CPU normalized to the whole machine', + 'applicationLogs.performance.privacy': 'Analysis adds brief overhead. Results stay on this device; no automatic uploads.', + 'applicationLogs.performance.coverage': 'Only performance summaries are recorded, without conversations, file contents or raw stacks. Metrics exclude external children such as FFmpeg and the desktop pet.', + 'applicationLogs.performance.process': 'Process', + 'applicationLogs.performance.main': 'Main', + 'applicationLogs.performance.runtime': 'Task runtime', + 'applicationLogs.performance.renderer': 'Interface', + 'applicationLogs.performance.gpu': 'Graphics', + 'applicationLogs.performance.network': 'Network', + 'applicationLogs.performance.sandbox': 'Sandbox host', + 'applicationLogs.performance.utility': 'Utility', + 'applicationLogs.performance.other': 'Other', + 'applicationLogs.performance.capture': 'Analyze 5 seconds', + 'applicationLogs.performance.capturing': 'Analyzing…', + 'applicationLogs.performance.failed': 'Analysis could not finish. The process may have restarted or be under debugging. Wait for the list to update and try again.', + 'applicationLogs.performance.saved': 'Export a diagnostic bundle for further investigation.', + 'desktop.settings.category.logs': 'Logs', 'applicationLogs.description': 'Explore application activity, state changes and errors.', 'applicationLogs.open': 'View logs', @@ -30,7 +61,7 @@ export default { 'applicationLogs.refresh': 'Refresh logs', 'applicationLogs.exportDiagnostics': 'Export diagnostics', 'applicationLogs.exportScope': 'Collection scope', - 'applicationLogs.exportErrors': 'Recent errors and related incidents; recent activity when no errors exist', + 'applicationLogs.exportErrors': 'Includes recent errors, performance diagnostics and related operations; falls back to recent activity when none are retained', 'applicationLogs.exportContext': 'Includes related starts, retries and outcomes. Limits and gaps are noted in the bundle.', 'applicationLogs.exportRelated': 'Export related diagnostics', 'applicationLogs.exportSelected': 'Events from the same run or operation as the selected record', @@ -39,7 +70,7 @@ export default { 'applicationLogs.exportPrivacyDescription': 'Structured activity only. No conversation or file contents, raw requests or responses, credentials or personal paths.', 'applicationLogs.exportLocalOnly': 'Saved locally, never uploaded automatically. Review before sharing.', 'applicationLogs.exportSave': 'Export ZIP', - 'applicationLogs.exportSucceeded': 'Saved {count} error records and {context} context events', + 'applicationLogs.exportSucceeded': 'Diagnostic bundle saved with {context} related activity records', 'applicationLogs.exportEmpty': 'No logs were recorded in the selected launch scope', 'applicationLogs.exportFailed': 'Could not export diagnostics. Please try again.', 'applicationLogs.live': 'Live', diff --git a/apps/buddy/src/i18n/locales/zh-CN/applicationLogs.ts b/apps/buddy/src/i18n/locales/zh-CN/applicationLogs.ts index 37f0851e..38a08476 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/applicationLogs.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/applicationLogs.ts @@ -1,4 +1,35 @@ export default { + 'applicationLogs.performance.sample': '进程负载快照', + 'applicationLogs.performance.profile': 'CPU 采样摘要', + 'applicationLogs.performance.sustained': '持续高占用记录', + + 'applicationLogs.performance.title': '性能诊断', + 'applicationLogs.performance.unavailable': '暂时无法获取进程指标。', + 'applicationLogs.performance.wait': '约 5 秒后完成,请稍候。', + 'applicationLogs.performance.completed': '已完成{process}(PID {pid})的分析', + 'applicationLogs.performance.progress': '正在分析{process}(PID {pid})', + 'applicationLogs.performance.metricsOnly': '仅显示指标', + 'applicationLogs.performance.analysis': 'CPU 分析', + 'applicationLogs.performance.memory': '内存', + 'applicationLogs.performance.currentWindow': '界面 · 当前窗口', + 'applicationLogs.performance.scope': '采集说明', + 'applicationLogs.performance.metrics': '每 5 秒更新 · CPU 以整机 100% 计', + 'applicationLogs.performance.privacy': '分析会短暂增加开销;结果仅存本机,不自动上传。', + 'applicationLogs.performance.coverage': '仅记录性能摘要,不含对话、文件内容或原始堆栈。指标不含 FFmpeg 等外部子进程和桌宠。', + 'applicationLogs.performance.process': '进程', + 'applicationLogs.performance.main': '主进程', + 'applicationLogs.performance.runtime': '任务运行时', + 'applicationLogs.performance.renderer': '界面', + 'applicationLogs.performance.gpu': '图形', + 'applicationLogs.performance.network': '网络', + 'applicationLogs.performance.sandbox': '沙箱宿主', + 'applicationLogs.performance.utility': '辅助进程', + 'applicationLogs.performance.other': '其他', + 'applicationLogs.performance.capture': '分析 5 秒', + 'applicationLogs.performance.capturing': '分析中…', + 'applicationLogs.performance.failed': '分析未完成,进程可能已重启或正在被调试。请确认列表更新后重试。', + 'applicationLogs.performance.saved': '导出诊断包可用于后续排查。', + 'desktop.settings.category.logs': '日志', 'applicationLogs.description': '查看应用各部分的运行记录、状态变化与异常。', 'applicationLogs.open': '查看日志', @@ -28,7 +59,7 @@ export default { 'applicationLogs.refresh': '刷新日志', 'applicationLogs.exportDiagnostics': '导出诊断包', 'applicationLogs.exportScope': '采集范围', - 'applicationLogs.exportErrors': '采集最近错误及相关操作;没有错误时采集最近活动', + 'applicationLogs.exportErrors': '采集最近错误、性能诊断及相关操作;没有此类记录时采集最近活动', 'applicationLogs.exportContext': '保留关联运行的开始、重试和结果;截断或缺失会在包内标明', 'applicationLogs.exportRelated': '导出相关诊断', 'applicationLogs.exportSelected': '围绕当前记录,采集同一次运行或操作的相关事件', @@ -37,7 +68,7 @@ export default { 'applicationLogs.exportPrivacyDescription': '仅包含结构化运行信息,不含对话或文件正文、原始请求与响应、凭据或个人路径。', 'applicationLogs.exportLocalOnly': '文件仅保存在本机,不会自动上传。分享前请检查内容。', 'applicationLogs.exportSave': '导出 ZIP', - 'applicationLogs.exportSucceeded': '已保存 {count} 条错误和 {context} 条上下文记录', + 'applicationLogs.exportSucceeded': '诊断包已保存,包含 {context} 条相关运行记录', 'applicationLogs.exportEmpty': '所选启动范围暂无日志记录', 'applicationLogs.exportFailed': '诊断包导出失败,请稍后重试', 'applicationLogs.live': '实时更新', diff --git a/apps/buddy/src/modules/settings/model/applicationLogPresentation.ts b/apps/buddy/src/modules/settings/model/applicationLogPresentation.ts index 892fed1b..2b7b2b84 100644 --- a/apps/buddy/src/modules/settings/model/applicationLogPresentation.ts +++ b/apps/buddy/src/modules/settings/model/applicationLogPresentation.ts @@ -2,6 +2,9 @@ import type { ApplicationLogRecord } from '@buddy-shared/diagnostics/application import type { BuddyI18nKey, BuddyLocale, BuddyTranslate } from '@/i18n/buddyI18n' const eventTitles: Record = { + 'performance.sample': 'applicationLogs.performance.sample', + 'performance.cpu_profile': 'applicationLogs.performance.profile', + 'performance.sustained_cpu': 'applicationLogs.performance.sustained', 'app.starting': 'applicationLogs.event.appStarting', 'app.ready': 'applicationLogs.event.appReady', 'app.stopping': 'applicationLogs.event.appStopping', diff --git a/apps/buddy/src/modules/settings/pages/DesktopLogsSettingsView.vue b/apps/buddy/src/modules/settings/pages/DesktopLogsSettingsView.vue index cd2aeedb..718fbee6 100644 --- a/apps/buddy/src/modules/settings/pages/DesktopLogsSettingsView.vue +++ b/apps/buddy/src/modules/settings/pages/DesktopLogsSettingsView.vue @@ -1,16 +1,65 @@ diff --git a/apps/buddy/src/modules/settings/state/__tests__/usePerformanceDiagnostics.spec.ts b/apps/buddy/src/modules/settings/state/__tests__/usePerformanceDiagnostics.spec.ts new file mode 100644 index 00000000..2bb70eec --- /dev/null +++ b/apps/buddy/src/modules/settings/state/__tests__/usePerformanceDiagnostics.spec.ts @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import type { CpuProfileSummary, PerformanceDiagnosticApi } from '@buddy-shared/diagnostics/performanceDiagnostic' +import { deferred } from '@buddy-tests/deferred' +import { afterEach, expect, it, vi } from 'vitest' +import { createApp } from 'vue' +import { usePerformanceDiagnostics } from '../usePerformanceDiagnostics' + +const cleanups: (() => void)[] = [] +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup() + vi.restoreAllMocks() + vi.useRealTimers() +}) + +function mount(api: PerformanceDiagnosticApi) { + const root = document.createElement('div') + document.body.append(root) + let state!: ReturnType + const app = createApp({ setup() { + state = usePerformanceDiagnostics(api) + return () => null + } }) + app.mount(root) + let disposed = false + const dispose = () => { + if (disposed) + return + disposed = true + app.unmount() + root.remove() + } + cleanups.push(dispose) + return { state, dispose } +} + +it('stops polling and ignores pending snapshots and profiles when the dialog closes', async () => { + vi.useFakeTimers() + vi.spyOn(document, 'hidden', 'get').mockReturnValue(false) + const snapshot = deferred>>() + const profile = deferred() + const { state, dispose } = mount({ snapshot: () => snapshot.promise, capture: () => profile.promise }) + const analyzing = state.capture({ target: 'runtime', pid: 42 }) + expect(state.capturing.value).toEqual({ target: 'runtime', pid: 42 }) + expect(state.reading.value).toBe(true) + dispose() + snapshot.resolve({ samples: [], coverage: 'electron-processes', intervalMs: 5000, rendererPid: 17 }) + profile.resolve({ target: 'runtime', pid: 42, durationMs: 5000, samples: 1, hotspots: [] }) + await analyzing + await vi.advanceTimersByTimeAsync(15000) + expect(state.rendererPid.value).toBeNull() + expect(state.profile.value).toBeNull() + expect(vi.getTimerCount()).toBe(0) +}) + +it('keeps snapshot availability separate from manual analysis failures and allows retry', async () => { + vi.useFakeTimers() + vi.spyOn(document, 'hidden', 'get').mockReturnValue(false) + const api: PerformanceDiagnosticApi = { + snapshot: async () => ({ samples: [], coverage: 'electron-processes', intervalMs: 5000, rendererPid: 17 }), + capture: async () => { throw new Error('CPU_PROFILE_PROCESS_CHANGED') }, + } + const { state } = mount(api) + await expect(state.capture({ target: 'runtime', pid: 42 })).rejects.toThrow('CPU_PROFILE_PROCESS_CHANGED') + expect(state.capturing.value).toBeNull() + expect(state.unavailable.value).toBe(false) + api.capture = async request => ({ ...request, durationMs: 5000, samples: 1, hotspots: [] }) + await state.capture({ target: 'runtime', pid: 43 }) + expect(state.profile.value).toMatchObject({ target: 'runtime', pid: 43, samples: 1 }) +}) diff --git a/apps/buddy/src/modules/settings/state/usePerformanceDiagnostics.ts b/apps/buddy/src/modules/settings/state/usePerformanceDiagnostics.ts new file mode 100644 index 00000000..fcd93b3d --- /dev/null +++ b/apps/buddy/src/modules/settings/state/usePerformanceDiagnostics.ts @@ -0,0 +1,65 @@ +import type { CpuProfileRequest, CpuProfileSummary, PerformanceDiagnosticApi, PerformanceSample } from '@buddy-shared/diagnostics/performanceDiagnostic' +import { onMounted, onScopeDispose, shallowRef } from 'vue' + +export function usePerformanceDiagnostics(api: PerformanceDiagnosticApi) { + const sample = shallowRef(null) + const rendererPid = shallowRef(null) + const profile = shallowRef(null) + const capturing = shallowRef(null) + const unavailable = shallowRef(false) + const reading = shallowRef(false) + let disposed = false + let timer: ReturnType | undefined + + async function refresh(): Promise { + if (reading.value || disposed || document.hidden) + return + reading.value = true + try { + const snapshot = await api.snapshot() + if (!disposed) { + sample.value = snapshot.samples.at(-1) ?? null + rendererPid.value = snapshot.rendererPid + unavailable.value = false + } + } + catch { + if (!disposed) + unavailable.value = true + } + finally { + if (!disposed) + reading.value = false + } + } + + async function capture(request: CpuProfileRequest): Promise { + if (capturing.value || disposed) + return + capturing.value = request + profile.value = null + try { + const result = await api.capture(request) + if (!disposed) + profile.value = result + } + catch (error) { + if (!disposed) + throw error + } + finally { + if (!disposed) + capturing.value = null + } + } + + onMounted(() => { + void refresh() + timer = setInterval(() => void refresh(), 5000) + }) + onScopeDispose(() => { + disposed = true + clearInterval(timer) + }) + return { sample, rendererPid, profile, capturing, unavailable, reading, refresh, capture } +} diff --git a/apps/buddy/src/modules/settings/widgets/application-logs/DesktopApplicationLogActions.vue b/apps/buddy/src/modules/settings/widgets/application-logs/DesktopApplicationLogActions.vue new file mode 100644 index 00000000..578cc965 --- /dev/null +++ b/apps/buddy/src/modules/settings/widgets/application-logs/DesktopApplicationLogActions.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/apps/buddy/src/modules/settings/widgets/application-logs/DesktopApplicationLogFilters.vue b/apps/buddy/src/modules/settings/widgets/application-logs/DesktopApplicationLogFilters.vue index 59f9ff3d..b781f400 100644 --- a/apps/buddy/src/modules/settings/widgets/application-logs/DesktopApplicationLogFilters.vue +++ b/apps/buddy/src/modules/settings/widgets/application-logs/DesktopApplicationLogFilters.vue @@ -2,15 +2,14 @@ import type { ApplicationLogLaunch, ApplicationLogQuery } from '@buddy-shared/diagnostics/applicationLog' import type { BuddyLocale } from '@/i18n/buddyI18n' import { APPLICATION_LOG_CATEGORIES } from '@buddy-shared/diagnostics/applicationLog' -import { ArrowClockwise20Regular, ArrowDownload20Regular, Pause20Regular, Play20Regular, Search20Regular } from '@vicons/fluent' -import { NButton, NInput, NSelect } from 'naive-ui' +import { Search20Regular } from '@vicons/fluent' +import { NInput, NSelect } from 'naive-ui' import { computed } from 'vue' import { useBuddyI18n } from '@/i18n/buddyI18n' import DesktopIcon from '@/shared/ui/icon/DesktopIcon.vue' import { formatLogTime } from '../../model/applicationLogPresentation' -const props = defineProps<{ language: BuddyLocale, launches: ApplicationLogLaunch[], currentLaunchId?: string, live: boolean, loading: boolean, exporting: boolean }>() -const emit = defineEmits<{ refresh: [], toggleLive: [], exportDiagnostics: [] }>() +const props = defineProps<{ language: BuddyLocale, launches: ApplicationLogLaunch[], currentLaunchId?: string }>() const launch = defineModel('launch', { required: true }) const category = defineModel>('category', { required: true }) const level = defineModel>('level', { required: true }) @@ -36,23 +35,6 @@ const levelOptions = computed(() => (['all', 'error', 'warn', 'info', 'debug'] a - - - {{ t(live ? 'applicationLogs.pause' : 'applicationLogs.follow') }} - - - - {{ t('applicationLogs.exportDiagnostics') }} - - - -
diff --git a/apps/buddy/src/modules/settings/widgets/application-logs/DesktopApplicationLogs.vue b/apps/buddy/src/modules/settings/widgets/application-logs/DesktopApplicationLogs.vue index e1fd795d..cc8fe2ab 100644 --- a/apps/buddy/src/modules/settings/widgets/application-logs/DesktopApplicationLogs.vue +++ b/apps/buddy/src/modules/settings/widgets/application-logs/DesktopApplicationLogs.vue @@ -1,77 +1,54 @@ diff --git a/apps/buddy/src/modules/settings/widgets/application-logs/DesktopPerformanceDiagnostics.vue b/apps/buddy/src/modules/settings/widgets/application-logs/DesktopPerformanceDiagnostics.vue new file mode 100644 index 00000000..589e9d5c --- /dev/null +++ b/apps/buddy/src/modules/settings/widgets/application-logs/DesktopPerformanceDiagnostics.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/apps/buddy/src/platform/runtime/RendererDiagnostics.ts b/apps/buddy/src/platform/runtime/RendererDiagnostics.ts index 80fd6980..848b9c59 100644 --- a/apps/buddy/src/platform/runtime/RendererDiagnostics.ts +++ b/apps/buddy/src/platform/runtime/RendererDiagnostics.ts @@ -1,4 +1,5 @@ import type { RendererDiagnosticApi } from '@buddy-shared/diagnostics/rendererDiagnostic' +import { DiagnosticAdmission } from '@buddy-shared/diagnostics/DiagnosticAdmission' import { rendererDiagnosticSchema } from '@buddy-shared/diagnostics/rendererDiagnostic' import { ApplicationEvents } from '@buddy-shared/observability/ApplicationEvents' @@ -12,18 +13,19 @@ export function createRendererDiagnostics(api: RendererDiagnosticApi) { const events = new ApplicationEvents() const sourceId = crypto.randomUUID() const pending = new Set>() + const admission = new DiagnosticAdmission() let dropped = 0 let failed = 0 const unsubscribe = events.subscribe((event) => { + if (pending.size >= 128 || !admission.take()) { + dropped++ + return + } const parsed = rendererDiagnosticSchema.safeParse(event) if (!parsed.success) { failed++ return } - if (pending.size >= 128) { - dropped++ - return - } const delivery = Promise.resolve() .then(() => api.report({ sourceId, diagnostic: parsed.data })) .then((accepted) => {