diff --git a/docs/development/CANVAS_WINDOW_PROFILING.md b/docs/development/CANVAS_WINDOW_PROFILING.md new file mode 100644 index 00000000..16fb4dbf --- /dev/null +++ b/docs/development/CANVAS_WINDOW_PROFILING.md @@ -0,0 +1,173 @@ +# Canvas and Window Performance Diagnosis + +Use this workflow when canvas panning or desktop app switching stalls. The +profiler is an opt-in development tool, with no product runtime changes. + +## References and scope + +- [Electron performance](https://www.electronjs.org/docs/latest/tutorial/performance): + measure the running application and correlate multiple processes before + assigning a bottleneck. Main owns native windows; renderer JavaScript is + only one possible source of a visible delay. +- [Electron contentTracing](https://www.electronjs.org/docs/latest/api/content-tracing): + Main coordinates one recording across child processes after app readiness. + Stop flushes asynchronously to a local file; capture/flush failures invalidate + evidence. See the [TraceConfig contract](https://www.electronjs.org/docs/latest/api/structures/trace-config) + for category selection and bounded buffers. +- [Chromium CPU profiler](https://chromedevtools.github.io/devtools-protocol/tot/Profiler/): + sampled stacks attribute renderer JavaScript costs; they do not measure + WindowServer, native blocking, or actual presentation of another application. + +The transferable rule is to correlate input, frame production, sampled stacks, +and native process activity. OpenCove's normal E2E environment disables background +throttling, so it cannot establish production app-switch performance. The +diagnostic runner restores background throttling on its window, records that +setting, and retains the test-fixture limitation in every report. It does not +automatically disable GPU acceleration, change React memoization, or tune input. + +## State owners and invariants + +| State | Owner and writes | Restart truth | +| --- | --- | --- | +| Fixture workspace and PTYs | Existing seed helper and runtime APIs, isolated temporary userData | Disposable fixture only | +| Canvas viewport | Existing renderer interaction owner; the probe sends real mouse events | Existing persistence owner | +| Observations | Injected renderer/Main probes, bounded arrays and paired cleanup | None; exported local report | +| Trace and CPU profile | Electron contentTracing and Chromium Profiler | Local artifact files | + +1. Instrumentation never writes canvas positions, changes IPC/security contracts, + or accesses installed application data. The intentional input changes only + the disposable fixture through the existing interaction path. +2. Frame intervals spanning blur/hidden periods remain recorded separately. + Foreground intervals above one second must not be discarded. Empty samples + are unavailable, not zero cost. +3. A capture with errors, full buffers, dropped samples, or a pan with unchanged + viewport is incomplete. A frame gap alone does not prove a CPU or GPU bottleneck. + +## Preparation and capture + +Use an isolated worktree and finish builds/tests before measurement. Coordinate +with other agents so no build or E2E runs overlap a capture. Record other host +load and stop only processes owned by the current experiment. + +```bash +pnpm install --frozen-lockfile +pnpm build +OPENCOVE_PROFILE_SCENARIO=idle OPENCOVE_PROFILE_TERMINAL_COUNT=0 pnpm profile:canvas:window-stall +OPENCOVE_PROFILE_SCENARIO=pan OPENCOVE_PROFILE_TERMINAL_COUNT=1 pnpm profile:canvas:window-stall +OPENCOVE_PROFILE_SCENARIO=pan OPENCOVE_PROFILE_TERMINAL_COUNT=10 pnpm profile:canvas:window-stall +``` + +PowerShell uses the same runner with environment variables set beforehand: + +```powershell +$env:OPENCOVE_PROFILE_SCENARIO = 'pan' +$env:OPENCOVE_PROFILE_TERMINAL_COUNT = '10' +pnpm profile:canvas:window-stall +``` + +The window is visible, focused, and set to the display's work area (not native +fullscreen). Capture takes 10 seconds after setup and a 3-second settle period. +The terminal stub is synthetic output, not a real agent workload. Each run has +an isolated profile that is removed on normal exit; interruption can leave +`opencove-canvas-profile-*` temporary directories for manual inspection. + +Each report's `environmentIsolation` records redirected directory keys and +inherited host-variable names without dumping environment values. On Windows, +`USERPROFILE`, `APPDATA`, `LOCALAPPDATA`, and `PSModuleAnalysisCachePath` point +into the temporary fixture. This changes shell profiles, module-analysis cache +warmth, and user configuration relative to an installed application. `PATH`, +`SystemRoot`, `COMSPEC`, `HOMEDRIVE`/`HOMEPATH`, and `PSModulePath` remain inherited +when present; OS-account/registry/system-module/machine-cache state is not +isolated. Do not claim a hermetic Windows shell or real-user startup parity. + +Controls: + +| Environment variable prefix `OPENCOVE_PROFILE_` | Values / default | +| --- | --- | +| `SCENARIO` | `idle`, `pan`, `manual`; default `pan` | +| `TERMINAL_COUNT` | 0-30; default 1 | +| `SAMPLE_DURATION_MS` | 1000-120000; default 10000 | +| `OUTPUT_INTERVAL_MS` | 10-10000; default 100 | +| `OUTPUT_PAYLOAD_BYTES` | 1-10000; default 160 | +| `TRACE` | `0` disables trace/CPU profile for overhead comparison; default enabled | + +Run idle and pan at 0, 1, and 10 terminals, at least three runs per cell, +interleaving cells to expose warmup or thermal drift. Repeat relevant cells +with `TRACE=0` to quantify profiler overhead. Keep resolution, DPR, display, +power state and other foreground apps fixed. Do not use a cross-machine absolute +FPS threshold as a merge gate. + +## Real macOS app switching + +```bash +OPENCOVE_PROFILE_SCENARIO=manual OPENCOVE_PROFILE_SAMPLE_DURATION_MS=60000 pnpm profile:canvas:window-stall +``` + +Wait for `[canvas-profile] manual` in the terminal. Alternate between OpenCove +and the affected application, first without panning and then with rapid panning. +Record the action count and perceived stall times alongside the artifacts. +Use an OS screen recording when actual presentation latency must be measured. +BrowserWindow focus events prove focus transitions, not another app's first +presented frame. Automated canvas pan and `window.blur()` cannot substitute for +this reproduction. Repeat with the installed release before concluding parity +with the fixture-based development build. + +When native attribution is needed, the runner prints Main PID and records GPU +and renderer PIDs. During the capture, macOS can collect a bounded sample: + +```bash +sample 10 -file /tmp/opencove-main.sample.txt +sample 10 -file /tmp/opencove-gpu.sample.txt +xcrun xctrace list templates +``` + +In Instruments, use Time Profiler/System Trace for the involved processes and +WindowServer, if permitted. Save this as a separate higher-overhead run. +Windows uses WPR/WPA CPU Usage/UI Delays; Linux uses `perf`/its compositor's +profiler subject to local permissions. Electron trace/CPU capture is shared +across all three OSes; native desktop latency claims require that OS's evidence. + +## Artifacts and interpretation + +Every run writes under ignored `artifacts/canvas-window-stall-profile//`: + +- `report.json`: revision, OS/CPU/Electron versions, display/bounds/GPU details, + existing cross-platform process snapshot, raw frames/long tasks/focus events, + autonomous 250ms Main timer and Electron process CPU samples, summaries/errors. +- `electron-trace.json`: Chromium trace, open locally in Chrome tracing or + Perfetto. Category list and buffer utilization are in the report. +- `renderer.cpuprofile`: open in DevTools' JavaScript profiler for sampled stacks. +- `before.png`, `after.png`, `electron.log`: fixture and bounded startup output. + +Main and renderer each use their own monotonic `startedAt`. Compare wall timestamps +as `timeOrigin + startedAt + at`, not raw offsets across processes. Chromium +trace timestamps have their own origin; align renderer `opencove-profile:*` +User Timing markers with matching report events. Timing alignment across clocks +is approximate; do not infer sub-millisecond causality from it. + +Main timer delay is event-loop scheduling evidence, not pure CPU time. Electron +CPU metrics are interval process utilization, not per-thread stalls; the first +snapshot primes CPU sampling. Report GPU process metrics separately. The existing +process-tree snapshot captures Worker/PTY topology but is not a live Worker CPU +profile. A renderer CPU profile attributes JS; trace slices distinguish layout, +paint, raster, compositor and native task activity. Nested trace slice durations +must not be summed as exclusive time. + +Correlate repeated foreground gaps with overlapping tasks/stacks. High GPU CPU or +WindowServer activity alone is insufficient to blame a function. If local runs +do not reproduce the reported >1-second cross-app delay, report that limitation +and retain the manual/native follow-up instead of presenting an optimization. + +Raw traces, command lines and screenshots can contain local paths and content; +keep them local and review before sharing. Do not commit run snapshots. Commit +the reusable script/tests/workflow and place dated findings in the PR or local +report. This tool is separate from the sanitized in-app issue-report bundle. + +## Verification and risk + +Unit tests cover configuration bounds, missing data, retained >1-second frames, +background separation, and probe serialization in fresh Main/renderer VM +contexts without runner closure dependencies. Runtime smoke must show a changed pan viewport, +nonempty Main/renderer samples, parseable nonempty trace/profile, and paired +probe/process cleanup. Full repository gates follow DEVELOPMENT.md before delivery. +No product behavior changes are part of this diagnosis workflow. diff --git a/docs/development/DEBUGGING.md b/docs/development/DEBUGGING.md index ae17830e..9edfc3d0 100644 --- a/docs/development/DEBUGGING.md +++ b/docs/development/DEBUGGING.md @@ -7,6 +7,7 @@ - 若 UI 表现与代码不一致,先怀疑是否跑到了旧构建产物。 - Web UI(Worker Web Canvas / Debug Shell)常见问题速查见:`docs/runtime/WEB_UI_TROUBLESHOOTING.md`。 - Issue Report 诊断包的 owner、脱敏、预算和扩展规则见:`docs/development/ISSUE_REPORT_DIAGNOSTICS.md`。 +- 画布拖动 / 应用切换卡顿的跨平台 trace、CPU profile 与原生采样方法见:`docs/development/CANVAS_WINDOW_PROFILING.md`。 ## 失败后的首轮动作 diff --git a/package.json b/package.json index b30528e8..9a40a07d 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "test:terminal:presentation": "node scripts/test-terminal-presentation-contract.mjs", "recover:opencove-db": "node scripts/recover-opencove-db-from-sqlite-recover.mjs", "profile:terminal:display-parity": "node scripts/profile-terminal-display-parity.mjs", + "profile:canvas:window-stall": "node scripts/profile-canvas-window-stall.mjs", "lint": "oxlint .", "lint:fix": "oxlint --fix .", "harness:check": "node harness/check.mjs", diff --git a/scripts/lib/canvas-window-profile-metrics.mjs b/scripts/lib/canvas-window-profile-metrics.mjs new file mode 100644 index 00000000..8f9fc4fd --- /dev/null +++ b/scripts/lib/canvas-window-profile-metrics.mjs @@ -0,0 +1,60 @@ +export function readProfileConfig(env = process.env) { + function integer(key, fallback, min, max) { + const raw = env[`OPENCOVE_PROFILE_${key}`] + const value = raw === undefined ? fallback : Number(raw) + if (!Number.isInteger(value) || value < min || value > max) { + throw new Error(`OPENCOVE_PROFILE_${key} must be an integer in [${min}, ${max}]`) + } + return value + } + const scenario = env.OPENCOVE_PROFILE_SCENARIO ?? 'pan' + if (!['idle', 'pan', 'manual'].includes(scenario)) { + throw new Error('OPENCOVE_PROFILE_SCENARIO must be idle, pan, or manual') + } + return { + scenario, + terminalCount: integer('TERMINAL_COUNT', 1, 0, 30), + sampleDurationMs: integer('SAMPLE_DURATION_MS', 10_000, 1_000, 120_000), + outputIntervalMs: integer('OUTPUT_INTERVAL_MS', 100, 10, 10_000), + outputPayloadBytes: integer('OUTPUT_PAYLOAD_BYTES', 160, 1, 10_000), + trace: env.OPENCOVE_PROFILE_TRACE !== '0', + } +} + +export function distribution(values) { + if (values.length === 0) { + return { count: 0, p50: null, p95: null, max: null } + } + const sorted = [...values].sort((a, b) => a - b) + const percentile = ratio => sorted[Math.ceil(sorted.length * ratio) - 1] + return { count: values.length, p50: percentile(0.5), p95: percentile(0.95), max: sorted.at(-1) } +} + +export function summarizeCapture(renderer, main) { + const groups = new Map() + for (const sample of main.samples) { + for (const metric of sample.metrics) { + const key = `${metric.pid}:${metric.type}` + const group = groups.get(key) ?? { pid: metric.pid, type: metric.type, cpu: [] } + group.cpu.push(metric.cpu.percentCPUUsage) + groups.set(key, group) + } + } + return { + // Background/suspended frame gaps are retained separately, never classified as CPU stalls. + visibleFocusedFramesMs: distribution( + renderer.frames.filter(frame => frame.visible && frame.focused).map(frame => frame.deltaMs), + ), + otherFramesMs: distribution( + renderer.frames.filter(frame => !frame.visible || !frame.focused).map(frame => frame.deltaMs), + ), + longTasksMs: distribution(renderer.longTasks.map(task => task.duration)), + mainTimerDelayMs: distribution(main.samples.map(sample => sample.timerDelayMs)), + processCpuPercent: [...groups.values()].map(({ cpu, ...identity }) => ({ + ...identity, + ...distribution(cpu), + })), + focusTransitions: renderer.events.filter(event => ['focus', 'blur'].includes(event.name)), + lostSamples: { renderer: renderer.dropped, main: main.dropped }, + } +} diff --git a/scripts/lib/canvas-window-profile-probes.mjs b/scripts/lib/canvas-window-profile-probes.mjs new file mode 100644 index 00000000..f6c1c498 --- /dev/null +++ b/scripts/lib/canvas-window-profile-probes.mjs @@ -0,0 +1,168 @@ +/* eslint-disable no-await-in-loop -- real mouse events must remain ordered */ +import { setTimeout as delay } from 'node:timers/promises' + +export async function installProbes(electronApp, page) { + await electronApp.evaluate(({ app, BrowserWindow }) => { + const samples = [] + const events = [] + const startedAt = performance.now() + const timeOrigin = performance.timeOrigin + let previousAt = startedAt + let dropped = 0 + const window = BrowserWindow.getAllWindows()[0] + const listeners = ['focus', 'blur', 'resize', 'unresponsive', 'responsive'].map(name => { + const listener = () => { + if (events.length < 2_000) { + events.push({ name, at: performance.now() - startedAt }) + } else { + dropped += 1 + } + } + window.on(name, listener) + return [name, listener] + }) + app.getAppMetrics() + const timer = setInterval(() => { + const now = performance.now() + if (samples.length < 1_000) { + samples.push({ + at: now - startedAt, + timerDelayMs: Math.max(0, now - previousAt - 250), + metrics: app.getAppMetrics(), + }) + } else { + dropped += 1 + } + previousAt = now + }, 250) + globalThis.__opencoveStallMain = { + stop() { + clearInterval(timer) + for (const [name, listener] of listeners) { + window.removeListener(name, listener) + } + return { startedAt, timeOrigin, samples, events, dropped } + }, + } + }) + await page.evaluate(() => { + const startedAt = performance.now() + const data = { + startedAt, + timeOrigin: performance.timeOrigin, + frames: [], + longTasks: [], + events: [], + dropped: 0, + } + const append = (list, entry) => { + if (list.length < 20_000) { + list.push(entry) + } else { + data.dropped += 1 + } + } + let previousAt = startedAt + let wasVisible = document.visibilityState === 'visible' + let wasFocused = document.hasFocus() + let frameId + const frame = now => { + const visible = document.visibilityState === 'visible' + const focused = document.hasFocus() + append(data.frames, { + at: now - startedAt, + deltaMs: now - previousAt, + visible: wasVisible && visible, + focused: wasFocused && focused, + }) + previousAt = now + wasVisible = visible + wasFocused = focused + frameId = requestAnimationFrame(frame) + } + frameId = requestAnimationFrame(frame) + const events = ['focus', 'blur', 'visibilitychange', 'pointerdown', 'pointerup'] + const mark = name => { + performance.mark(`opencove-profile:${name}`) + append(data.events, { + name, + at: performance.now() - startedAt, + visible: document.visibilityState, + focused: document.hasFocus(), + }) + // A focus round-trip between frames must not count as a foreground frame. + if (name === 'blur' || document.visibilityState !== 'visible') { + wasFocused = false + wasVisible = false + } + } + const listener = event => mark(event.type) + for (const name of events) { + window.addEventListener(name, listener, true) + } + const longTasksSupported = PerformanceObserver.supportedEntryTypes.includes('longtask') + const observer = longTasksSupported + ? new PerformanceObserver(list => { + for (const entry of list.getEntries()) { + append(data.longTasks, { at: entry.startTime - startedAt, duration: entry.duration }) + } + }) + : null + observer?.observe({ type: 'longtask' }) + mark('capture-start') + window.__opencoveStallRenderer = { + mark, + stop() { + mark('capture-stop') + cancelAnimationFrame(frameId) + if (observer) { + for (const entry of observer.takeRecords()) { + append(data.longTasks, { at: entry.startTime - startedAt, duration: entry.duration }) + } + observer.disconnect() + } + for (const name of events) { + window.removeEventListener(name, listener, true) + } + return { ...data, longTasksSupported } + }, + } + }) +} + +export async function runPan(page, durationMs) { + const point = await page.locator('.workspace-canvas .react-flow__pane').evaluate(pane => { + const box = pane.getBoundingClientRect() + for (let y = box.top + 40; y < box.bottom - 60; y += 60) { + for (let x = box.left + 60; x < box.right - 200; x += 60) { + if (document.elementFromPoint(x, y) === pane) { + return { x, y } + } + } + } + throw new Error('No exposed canvas pane available for pan') + }) + const viewport = page.locator('.react-flow__viewport') + const before = await viewport.getAttribute('style') + await page.mouse.move(point.x, point.y) + await page.mouse.down() + try { + const start = performance.now() + let step = 0 + while (performance.now() - start < durationMs) { + step += 1 + await page.mouse.move( + point.x + 100 + Math.sin(step / 8) * 100, + point.y + Math.sin(step / 12) * 30, + ) + await delay(8) + } + } finally { + await page.mouse.up() + } + const after = await viewport.getAttribute('style') + if (before === after) { + throw new Error('Pan probe did not change the canvas viewport') + } + return { before, after } +} diff --git a/scripts/profile-canvas-window-stall.mjs b/scripts/profile-canvas-window-stall.mjs new file mode 100644 index 00000000..11a24a5b --- /dev/null +++ b/scripts/profile-canvas-window-stall.mjs @@ -0,0 +1,268 @@ +#!/usr/bin/env node +import { _electron as electron } from '@playwright/test' +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { cpus, release, tmpdir } from 'node:os' +import path from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { fileURLToPath } from 'node:url' +import { + createNodes, + seedProfileUserData, + seedWorkspace, + spawnTerminalSessions, + waitForWorkspace, +} from './lib/terminal-load-profile-workspace.mjs' +import { installProbes, runPan } from './lib/canvas-window-profile-probes.mjs' +import { readProfileConfig, summarizeCapture } from './lib/canvas-window-profile-metrics.mjs' + +const repoPath = path.resolve(fileURLToPath(new URL('..', import.meta.url))) +const config = readProfileConfig() +const categories = [ + 'toplevel', + 'electron', + 'devtools.timeline', + 'v8.execute', + 'blink.user_timing', + 'latencyInfo', + 'input', + 'cc', + 'viz', + 'gpu', + 'disabled-by-default-devtools.timeline', + 'disabled-by-default-devtools.timeline.frame', + 'disabled-by-default-v8.cpu_profiler', +] + +async function main() { + const artifactDir = path.join( + repoPath, + 'artifacts', + 'canvas-window-stall-profile', + new Date().toISOString().replace(/[:.]/g, '-'), + ) + await mkdir(artifactDir, { recursive: true }) + const userDataDir = await mkdtemp(path.join(tmpdir(), 'opencove-canvas-profile-')) + const report = { + config, + categories, + revision: execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoPath, + encoding: 'utf8', + }).trim(), + host: { + platform: process.platform, + arch: process.arch, + release: release(), + cpu: cpus()[0]?.model, + cpuCount: cpus().length, + }, + error: null, + } + let electronApp + let page + let cdp + let traceStarted = false + let cpuStarted = false + let probesStarted = false + const logs = [] + const save = (name, value) => + writeFile(path.join(artifactDir, name), `${JSON.stringify(value, null, 2)}\n`) + try { + await seedProfileUserData({ userDataDir, repoPath }) + const env = { ...process.env } + delete env.ELECTRON_RUN_AS_NODE + delete env.__CFBundleIdentifier + const directories = new Set() + const isolatedDirectories = { + HOME: 'home', + USERPROFILE: 'home', + APPDATA: 'app-data', + LOCALAPPDATA: 'local-app-data', + XDG_CONFIG_HOME: 'config', + XDG_CACHE_HOME: 'cache', + XDG_RUNTIME_DIR: 'runtime', + } + for (const [key, folder] of Object.entries(isolatedDirectories)) { + env[key] = path.join(userDataDir, folder) + directories.add(env[key]) + } + await Promise.all([...directories].map(directory => mkdir(directory, { recursive: true }))) + env.PSModuleAnalysisCachePath = path.join(env.LOCALAPPDATA, 'ModuleAnalysisCache') + report.environmentIsolation = { + redirectedDirectories: isolatedDirectories, + powerShellModuleCache: 'local-app-data/ModuleAnalysisCache', + inheritedHostVariableNames: [ + 'PATH', + 'SystemRoot', + 'COMSPEC', + 'HOMEDRIVE', + 'HOMEPATH', + 'PSModulePath', + ].filter(name => env[name] !== undefined), + limitations: [ + 'Disposable userData and empty home/config/cache directories differ from an installed user profile.', + 'Inherited executable discovery, system shell modules, OS account, registry and machine caches are not isolated.', + 'Windows USERPROFILE/APPDATA/LOCALAPPDATA and PowerShell analysis cache redirection can alter startup and shell configuration costs.', + 'NODE_ENV=test uses fixture behavior and synthetic terminal output; this is not packaged-release or real-agent parity.', + ], + } + electronApp = await electron.launch({ + args: [repoPath], + timeout: 60_000, + env: { + ...env, + NODE_ENV: 'test', + OPENCOVE_TEST_USER_DATA_DIR: userDataDir, + OPENCOVE_TEST_WORKSPACE: repoPath, + OPENCOVE_TEST_NODE_EXECUTABLE: process.execPath, + OPENCOVE_E2E_WINDOW_MODE: 'inactive', + OPENCOVE_E2E_FORCE_RENDERER_SANDBOX: '1', + OPENCOVE_TERMINAL_DIAGNOSTICS: '0', + OPENCOVE_TERMINAL_INPUT_DIAGNOSTICS: '0', + }, + }) + for (const stream of [electronApp.process().stdout, electronApp.process().stderr]) { + stream?.on('data', chunk => { + if (logs.length < 2_000) { + logs.push(chunk.toString().slice(0, 8_192)) + } + }) + } + page = await electronApp.firstWindow() + page.setDefaultTimeout(30_000) + await seedWorkspace(page, { repoPath, nodes: [] }) + await waitForWorkspace(page) + if (config.terminalCount > 0) { + const sessions = await spawnTerminalSessions(page, { + repoPath, + ...config, + sampleDurationMs: config.sampleDurationMs + 60_000, + }) + await seedWorkspace(page, { repoPath, nodes: createNodes(sessions, { repoPath }) }) + await waitForWorkspace(page) + await page.waitForFunction( + count => document.querySelectorAll('.terminal-node .xterm').length === count, + config.terminalCount, + ) + } + report.runtime = await electronApp.evaluate(async ({ app, BrowserWindow, screen }) => { + const window = BrowserWindow.getAllWindows()[0] + const display = screen.getDisplayMatching(window.getBounds()) + window.setFullScreen(false) + window.setBounds(display.workArea) + window.webContents.setBackgroundThrottling(true) + window.show() + window.focus() + return { + mainPid: process.pid, + rendererPid: window.webContents.getOSProcessId(), + versions: process.versions, + display: { + size: display.size, + workArea: display.workArea, + scaleFactor: display.scaleFactor, + }, + bounds: window.getBounds(), + fullScreen: window.isFullScreen(), + backgroundThrottling: window.webContents.getBackgroundThrottling(), + gpu: await app.getGPUInfo('basic'), + gpuFeatures: app.getGPUFeatureStatus(), + testMode: true, + } + }) + await delay(3_000) + report.processSnapshot = await page.evaluate(() => + window.opencoveApi.performanceDiagnostics.getSnapshot(), + ) + await page.screenshot({ path: path.join(artifactDir, 'before.png') }) + cdp = await page.context().newCDPSession(page) + if (config.trace) { + await electronApp.evaluate(async ({ contentTracing }, included_categories) => { + await contentTracing.startRecording({ + included_categories, + excluded_categories: ['*'], + recording_mode: 'record-until-full', + trace_buffer_size_in_kb: 102_400, + }) + }, categories) + traceStarted = true + await cdp.send('Profiler.enable') + await cdp.send('Profiler.start') + cpuStarted = true + } + probesStarted = true + await installProbes(electronApp, page) + process.stdout.write( + `[canvas-profile] ${config.scenario} ${config.sampleDurationMs}ms; main PID ${report.runtime.mainPid}; ${artifactDir}\n`, + ) + await page.evaluate(name => window.__opencoveStallRenderer.mark(name), config.scenario) + if (config.scenario === 'pan') { + report.pan = await runPan(page, config.sampleDurationMs) + } else { + await delay(config.sampleDurationMs) + } + report.renderer = await page.evaluate(() => window.__opencoveStallRenderer.stop()) + report.main = await electronApp.evaluate(() => globalThis.__opencoveStallMain.stop()) + probesStarted = false + report.summary = summarizeCapture(report.renderer, report.main) + if (report.renderer.frames.length === 0 || report.main.samples.length === 0) { + throw new Error('Capture is missing renderer or main-process samples') + } + } catch (error) { + report.error = { message: error.message, stack: error.stack } + process.exitCode = 1 + } finally { + if (probesStarted) { + report.renderer ??= await page + ?.evaluate(() => window.__opencoveStallRenderer?.stop()) + .catch(() => null) + report.main ??= await electronApp + ?.evaluate(() => globalThis.__opencoveStallMain?.stop()) + .catch(() => null) + } + const cleanupErrors = [] + const collect = async action => { + try { + await action() + } catch (error) { + cleanupErrors.push(error.message) + process.exitCode = 1 + } + } + if (cpuStarted) { + await collect(async () => + save('renderer.cpuprofile', (await cdp.send('Profiler.stop')).profile), + ) + } + if (traceStarted) { + await collect(async () => { + report.traceBuffer = await electronApp.evaluate(({ contentTracing }) => + contentTracing.getTraceBufferUsage(), + ) + await electronApp.evaluate( + ({ contentTracing }, outputPath) => contentTracing.stopRecording(outputPath), + path.join(artifactDir, 'electron-trace.json'), + ) + }) + } + if (page) { + await collect(() => page.screenshot({ path: path.join(artifactDir, 'after.png') })) + } + if (cdp) { + await collect(() => cdp.detach()) + } + if (electronApp) { + await collect(() => electronApp.close()) + } + await collect(() => rm(userDataDir, { recursive: true, force: true })) + report.cleanupErrors = cleanupErrors + await save('report.json', report) + await writeFile(path.join(artifactDir, 'electron.log'), logs.join('')) + process.stdout.write( + `${JSON.stringify({ summary: report.summary, error: report.error, cleanupErrors })}\nArtifacts: ${artifactDir}\n`, + ) + } +} + +await main() diff --git a/tests/unit/scripts/canvas-window-profile-serialization.spec.ts b/tests/unit/scripts/canvas-window-profile-serialization.spec.ts new file mode 100644 index 00000000..3127d256 --- /dev/null +++ b/tests/unit/scripts/canvas-window-profile-serialization.spec.ts @@ -0,0 +1,62 @@ +import { EventEmitter } from 'node:events' +import { runInNewContext } from 'node:vm' +import { describe, expect, it, vi } from 'vitest' +import { installProbes } from '../../../scripts/lib/canvas-window-profile-probes.mjs' + +describe('serialized canvas performance probes', () => { + it('installs, samples and cleans up in fresh Main/renderer contexts without runner closures', async () => { + let now = 0 + let mainTick: () => void = () => {} + let rendererFrame: (time: number) => void = () => {} + const nativeWindow = new EventEmitter() + const clearInterval = vi.fn() + const cancelAnimationFrame = vi.fn() + const removeEventListener = vi.fn() + const mainContext = { + performance: { now: () => now, timeOrigin: 1_000 }, + setInterval: (callback: () => void) => { + mainTick = callback + return 1 + }, + clearInterval, + electron: { + app: { getAppMetrics: () => [{ pid: 5, type: 'Browser', cpu: { percentCPUUsage: 2 } }] }, + BrowserWindow: { getAllWindows: () => [nativeWindow] }, + }, + } + const rendererContext = { + performance: { now: () => now, timeOrigin: 2_000, mark: vi.fn() }, + document: { visibilityState: 'visible', hasFocus: () => true }, + window: { addEventListener: vi.fn(), removeEventListener }, + requestAnimationFrame: (callback: (time: number) => void) => { + rendererFrame = callback + return 2 + }, + cancelAnimationFrame, + PerformanceObserver: { supportedEntryTypes: [] }, + } + await installProbes( + { + evaluate: (callback: Function) => + runInNewContext(`(${callback.toString()})(electron)`, mainContext), + }, + { + evaluate: (callback: Function) => + runInNewContext(`(${callback.toString()})()`, rendererContext), + }, + ) + now = 300 + mainTick() + rendererFrame(300) + nativeWindow.emit('blur') + const main = runInNewContext('globalThis.__opencoveStallMain.stop()', mainContext) + const renderer = runInNewContext('window.__opencoveStallRenderer.stop()', rendererContext) + expect(main.samples[0]).toMatchObject({ at: 300, timerDelayMs: 50 }) + expect(main.events[0]).toMatchObject({ name: 'blur' }) + expect(renderer.frames[0]).toMatchObject({ deltaMs: 300, focused: true }) + expect(clearInterval).toHaveBeenCalledWith(1) + expect(cancelAnimationFrame).toHaveBeenCalledWith(2) + expect(nativeWindow.eventNames()).toEqual([]) + expect(removeEventListener).toHaveBeenCalledTimes(5) + }) +}) diff --git a/tests/unit/scripts/canvas-window-profile.spec.ts b/tests/unit/scripts/canvas-window-profile.spec.ts new file mode 100644 index 00000000..b1af5964 --- /dev/null +++ b/tests/unit/scripts/canvas-window-profile.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { + distribution, + readProfileConfig, + summarizeCapture, +} from '../../../scripts/lib/canvas-window-profile-metrics.mjs' + +describe('canvas window profiling evidence', () => { + it('allows an empty-canvas control and rejects ambiguous or unbounded configurations', () => { + expect(readProfileConfig({ OPENCOVE_PROFILE_TERMINAL_COUNT: '0' }).terminalCount).toBe(0) + for (const value of ['-1', '10junk', '1.5', '31']) { + expect(() => readProfileConfig({ OPENCOVE_PROFILE_TERMINAL_COUNT: value })).toThrow() + } + expect(() => readProfileConfig({ OPENCOVE_PROFILE_SAMPLE_DURATION_MS: '999999' })).toThrow() + expect(() => readProfileConfig({ OPENCOVE_PROFILE_SCENARIO: 'fake-focus' })).toThrow() + }) + + it('keeps a one-second foreground stall while separating background frame suspension', () => { + const report = summarizeCapture( + { + frames: [ + { deltaMs: 16, visible: true, focused: true }, + { deltaMs: 1_500, visible: true, focused: true }, + { deltaMs: 8_000, visible: false, focused: false }, + { deltaMs: 2_000, visible: true, focused: false }, + ], + longTasks: [{ duration: 1_400 }], + events: [{ name: 'blur', at: 123 }], + dropped: 0, + }, + { + samples: [ + { + timerDelayMs: 20, + metrics: [{ pid: 1, type: 'Browser', cpu: { percentCPUUsage: 12 } }], + }, + ], + dropped: 0, + }, + ) + expect(report.visibleFocusedFramesMs).toMatchObject({ count: 2, max: 1_500 }) + expect(report.otherFramesMs).toMatchObject({ count: 2, max: 8_000 }) + expect(report.longTasksMs.max).toBe(1_400) + expect(report.processCpuPercent[0]).toMatchObject({ pid: 1, type: 'Browser', max: 12 }) + }) + + it('reports missing observations as null rather than healthy zeroes and preserves loss counts', () => { + expect(distribution([])).toEqual({ count: 0, p50: null, p95: null, max: null }) + const report = summarizeCapture( + { frames: [], longTasks: [], events: [], dropped: 2 }, + { samples: [], dropped: 3 }, + ) + expect(report.visibleFocusedFramesMs.max).toBeNull() + expect(report.lostSamples).toEqual({ renderer: 2, main: 3 }) + }) +})