diff --git a/src/__tests__/cli-perf.test.ts b/src/__tests__/cli-perf.test.ts index c25aab937..6dd27a4dc 100644 --- a/src/__tests__/cli-perf.test.ts +++ b/src/__tests__/cli-perf.test.ts @@ -335,6 +335,88 @@ test('perf rejects incomplete native CLI area before daemon dispatch', async () assert.match(payload.error.message, /perf cpu requires profile/i); }); +test('perf rejects unknown CLI area before daemon dispatch', async () => { + const result = await runCliCapture(['perf', 'gpu', '--json'], async () => ({ + ok: true, + data: {}, + })); + + assert.equal(result.code, 1); + assert.equal(result.calls.length, 0); + const payload = JSON.parse(result.stdout); + assert.equal(payload.error.code, 'INVALID_ARGS'); + assert.match(payload.error.message, /perf area must be metrics, frames, memory, cpu, or trace/i); +}); + +test('perf cpu profile start forwards simpleperf kind and out path', async () => { + const result = await runCliCapture( + ['perf', 'cpu', 'profile', 'start', '--kind', 'simpleperf', '--out', 'cpu.perf.data', '--json'], + async () => ({ + ok: true, + data: { + action: 'start', + type: 'cpu-profile', + kind: 'simpleperf', + state: 'running', + }, + }), + ); + + assert.equal(result.code, null); + const call = result.calls[0]; + assert.ok(call); + assert.equal(call.command, 'perf'); + assert.deepEqual(call.positionals, [ + 'cpu', + 'profile', + 'start', + 'simpleperf', + '', + 'cpu.perf.data', + ]); + assert.ok(call.flags); + assert.equal(call.flags.out, 'cpu.perf.data'); +}); + +test('perf trace stop forwards perfetto kind and prints compact artifact summary', async () => { + const result = await runCliCapture( + ['perf', 'trace', 'stop', '--kind', 'perfetto', '--out', 'app.perfetto-trace'], + async () => ({ + ok: true, + data: { + action: 'stop', + type: 'trace', + kind: 'perfetto', + state: 'stopped', + outPath: '/tmp/app.perfetto-trace', + sizeBytes: 2048, + summary: { + frameHealth: { + available: true, + droppedFramePercent: 12.5, + droppedFrameCount: 3, + totalFrameCount: 24, + }, + }, + }, + }), + ); + + assert.equal(result.code, null); + assert.equal(result.calls[0]?.command, 'perf'); + assert.deepEqual(result.calls[0]?.positionals, [ + 'trace', + 'stop', + 'perfetto', + '', + 'app.perfetto-trace', + ]); + assert.equal( + result.stdout, + 'Perf stop: perfetto trace state=stopped\n/tmp/app.perfetto-trace (2.0KB)\nTrace frame health: dropped 12.5% (3/24 frames)\n', + ); +}); + test('perf prints unavailable frame health reason by default', async () => { const result = await runCliCapture(['perf'], async () => ({ ok: true, diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 3b03f6ae0..8cda638ad 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -179,6 +179,47 @@ test('observability.perf projects memory snapshot options to daemon flags', asyn assert.equal(setup.calls[0]?.flags?.out, 'app.memgraph'); }); +test('observability.perf projects structured Android native profile input to daemon positionals', async () => { + const setup = createTransport(async (req) => { + if (req.command === 'perf') { + return { + ok: true, + data: { + action: 'start', + type: 'cpu-profile', + kind: 'simpleperf', + state: 'running', + }, + }; + } + throw new Error(`Unexpected command: ${req.command}`); + }); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + await client.observability.perf({ + area: 'cpu', + subject: 'profile', + action: 'start', + kind: 'simpleperf', + out: 'cpu.perf.data', + }); + + assert.equal(setup.calls.length, 1); + const call = setup.calls[0]; + assert.ok(call); + assert.equal(call.command, 'perf'); + assert.deepEqual(call.positionals, [ + 'cpu', + 'profile', + 'start', + 'simpleperf', + '', + 'cpu.perf.data', + ]); + assert.ok(call.flags); + assert.equal(call.flags.out, 'cpu.perf.data'); +}); + test('structured command input accepts target as deviceTarget alias when no UI target exists', async () => { const setup = createTransport(async (req) => { if (req.command === 'open') { diff --git a/src/commands/runtime-output.ts b/src/commands/runtime-output.ts index 02ff7fafc..df167def7 100644 --- a/src/commands/runtime-output.ts +++ b/src/commands/runtime-output.ts @@ -193,6 +193,11 @@ function formatMemoryArtifactSummary(artifact: Record): string } function formatNativePerfOutput(data: Record): string | undefined { + if (data.kind === 'xctrace') return formatAppleNativePerfOutput(data); + return formatAndroidNativePerfOutput(data); +} + +function formatAppleNativePerfOutput(data: Record): string | undefined { const state = typeof data.perf === 'string' ? data.perf : undefined; const outPath = readNativePerfArtifactPath(data); if (!state || !outPath || data.kind !== 'xctrace') return undefined; @@ -216,6 +221,58 @@ function formatNativePerfLines( return lines.join('\n'); } +function formatAndroidNativePerfOutput(data: Record): string | undefined { + const summary = readNativePerfSummary(data); + if (!summary) return undefined; + return `Perf ${summary.action}: ${summary.kind} ${summary.type}${formatNativePerfState( + data, + )}${formatNativePerfArtifact(data)}${formatNativePerfFrameHealth(data)}`; +} + +function readNativePerfSummary( + data: Record, +): { action: string; kind: string; type: string } | undefined { + const action = readString(data.action); + const kind = readString(data.kind); + const type = readString(data.type); + return action && kind && type ? { action, kind, type } : undefined; +} + +function formatNativePerfState(data: Record): string { + const state = readString(data.state); + return state ? ` state=${state}` : ''; +} + +function formatNativePerfArtifact(data: Record): string { + const outPath = readString(data.outPath); + if (!outPath) return ''; + const sizeBytes = readFiniteNumber(data.sizeBytes); + return `\n${outPath}${sizeBytes !== undefined ? ` (${formatBytes(sizeBytes)})` : ''}`; +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function formatNativePerfFrameHealth(data: Record): string { + const summary = readRecord(data.summary); + const frameHealth = readRecord(summary?.frameHealth); + if (!frameHealth || frameHealth.available !== true) return ''; + const droppedFramePercent = readFiniteNumber(frameHealth.droppedFramePercent); + const droppedFrameCount = readFiniteNumber(frameHealth.droppedFrameCount); + const totalFrameCount = readFiniteNumber(frameHealth.totalFrameCount); + if ( + droppedFramePercent === undefined || + droppedFrameCount === undefined || + totalFrameCount === undefined + ) { + return ''; + } + return `\nTrace frame health: dropped ${formatPercent(droppedFramePercent)} (${Math.round( + droppedFrameCount, + )}/${Math.round(totalFrameCount)} frames)`; +} + function formatPerfUnavailable(resourceSummary: string | undefined, reason: string): string { return resourceSummary ? `Performance: ${resourceSummary}` @@ -333,8 +390,9 @@ function formatMemoryKb(value: number): string { } function formatBytes(value: number): string { - const megabytes = value / 1024 / 1024; - if (megabytes >= 10) return `${Math.round(megabytes)}MB`; - if (megabytes >= 1) return `${megabytes.toFixed(1)}MB`; - return `${Math.max(1, Math.round(value / 1024))}KB`; + if (value < 1024) return `${Math.round(value)}B`; + const kib = value / 1024; + if (kib < 1024) return `${kib >= 10 ? Math.round(kib) : kib.toFixed(1)}KB`; + const mib = kib / 1024; + return `${mib >= 10 ? Math.round(mib) : mib.toFixed(1)}MB`; } diff --git a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts index a932f50e1..dd259bdf3 100644 --- a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts +++ b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts @@ -22,6 +22,10 @@ vi.mock('../../../platforms/ios/perf-xctrace.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, cleanupAppleXctracePerfCapture: vi.fn(async () => ({})) }; }); +vi.mock('../../../platforms/android/perf.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, cleanupAndroidNativePerfSession: vi.fn(async () => {}) }; +}); vi.mock('../../../platforms/ios/macos-helper.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, runMacOsAlertAction: vi.fn(async () => {}) }; @@ -44,10 +48,12 @@ import { teardownSessionResources } from '../session-close.ts'; import { shutdownSimulator } from '../../../platforms/ios/simulator.ts'; import { runCmd } from '../../../utils/exec.ts'; import { cleanupAppleXctracePerfCapture } from '../../../platforms/ios/perf-xctrace.ts'; +import { cleanupAndroidNativePerfSession } from '../../../platforms/android/perf.ts'; const mockShutdownSimulator = vi.mocked(shutdownSimulator); const mockRunCmd = vi.mocked(runCmd); const mockCleanupAppleXctracePerfCapture = vi.mocked(cleanupAppleXctracePerfCapture); +const mockCleanupAndroidNativePerfSession = vi.mocked(cleanupAndroidNativePerfSession); const noopInvoke = async (_req: DaemonRequest): Promise => ({ ok: true, data: {} }); @@ -291,6 +297,87 @@ test('daemon session teardown stops active Apple xctrace perf capture', async () expect(session.applePerf?.active).toBeUndefined(); }); +test('close stops active Android native perf capture before deleting session', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-active-native-perf-session'; + const activeCapture = { + type: 'trace', + kind: 'perfetto', + packageName: 'com.example.app', + appPid: '1234', + profilerPid: '5678', + remotePath: '/data/misc/perfetto-traces/app.perfetto-trace', + outPath: '/tmp/app.perfetto-trace', + startedAt: Date.now(), + state: 'running', + }; + const session = { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }), + appBundleId: 'com.example.app', + nativePerf: { + android: activeCapture, + }, + } as unknown as SessionState; + sessionStore.set(sessionName, session); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + expect(mockCleanupAndroidNativePerfSession).toHaveBeenCalledWith(session.device, activeCapture); + expect(sessionStore.get(sessionName)).toBeUndefined(); +}); + +test('daemon session teardown stops active Android native perf capture', async () => { + const sessionName = 'android-active-native-perf-teardown-session'; + const activeCapture = { + type: 'cpu-profile', + kind: 'simpleperf', + packageName: 'com.example.app', + appPid: '1234', + profilerPid: '5678', + remotePath: '/data/local/tmp/cpu.perf.data', + outPath: '/tmp/cpu.perf.data', + startedAt: Date.now(), + state: 'running', + }; + const session = { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }), + appBundleId: 'com.example.app', + nativePerf: { + android: activeCapture, + }, + } as unknown as SessionState; + + await teardownSessionResources(session, sessionName); + + expect(mockCleanupAndroidNativePerfSession).toHaveBeenCalledWith(session.device, activeCapture); + expect(session.nativePerf?.android).toBeUndefined(); +}); + test('close --shutdown is ignored for Android devices', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-device-shutdown-session'; diff --git a/src/daemon/handlers/__tests__/session-observability.test.ts b/src/daemon/handlers/__tests__/session-observability.test.ts index b366327e8..b48494575 100644 --- a/src/daemon/handlers/__tests__/session-observability.test.ts +++ b/src/daemon/handlers/__tests__/session-observability.test.ts @@ -1,12 +1,14 @@ import assert from 'node:assert/strict'; -import fs from 'node:fs'; +import fs, { promises as fsPromises } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { beforeEach, test, vi } from 'vitest'; +import type { AndroidAdbExecutor } from '../../../platforms/android/adb-executor.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import { makeAndroidSession, makeIosSession } from '../../../__tests__/test-utils/index.ts'; import { AppError } from '../../../utils/errors.ts'; import type { AppleXctracePerfCapture } from '../../../platforms/ios/perf-xctrace.ts'; +import type { DaemonResponse } from '../../types.ts'; const applePerfMocks = vi.hoisted(() => ({ startAppleXctracePerfCapture: vi.fn(), @@ -23,9 +25,7 @@ vi.mock('../../../platforms/ios/perf-xctrace.ts', async (importOriginal) => { writeAppleXctracePerfReport: applePerfMocks.writeAppleXctracePerfReport, }; }); - import { handleSessionObservabilityCommands } from '../session-observability.ts'; -import type { AndroidAdbExecutor } from '../../../platforms/android/adb-executor.ts'; beforeEach(() => { vi.resetAllMocks(); @@ -328,7 +328,7 @@ test('perf cpu profile report uses last profile trace and writes compact JSON re ); }); -test('perf native xctrace reports Android support as out of scope', async () => { +test('perf cpu profile rejects xctrace on Android sessions', async () => { const sessionStore = makeSessionStore('agent-device-session-observability-perf-'); sessionStore.set( 'android', @@ -351,11 +351,8 @@ test('perf native xctrace reports Android support as out of scope', async () => assert.equal(response?.ok, false); if (response && !response.ok) { - assert.equal(response.error.code, 'UNSUPPORTED_OPERATION'); - assert.match( - response.error.message, - /Android native profiling belongs to the Android perf rollout/i, - ); + assert.equal(response.error.code, 'INVALID_ARGS'); + assert.match(response.error.message, /perf cpu profile requires --kind simpleperf/i); } assert.equal(applePerfMocks.startAppleXctracePerfCapture.mock.calls.length, 0); }); @@ -681,3 +678,310 @@ test('perf memory snapshot returns artifact-shaped unsupported payload on unsupp const support = response.data?.support as Record; assert.equal(support.memgraph, false); }); + +test('perf cpu profile start and stop route through Android simpleperf and preserve compact artifact state', async () => { + const tmpDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), 'agent-device-daemon-simpleperf-'), + ); + const outPath = path.join(tmpDir, 'cpu.perf.data'); + const sessionStore = makeSessionStore('agent-device-session-observability-'); + sessionStore.set('android', makeAndroidSession('android', { appBundleId: 'com.example.app' })); + const adb = makeNativePerfAdbExecutor(outPath); + + try { + const startResponse = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'android', + command: 'perf', + positionals: ['cpu', 'profile', 'start', 'simpleperf'], + flags: { out: outPath }, + }, + sessionName: 'android', + sessionStore, + androidAdbExecutor: adb, + }); + + const startData = requireOkData(startResponse, 'Expected start response to succeed'); + assert.equal(startData.kind, 'simpleperf'); + assert.equal(startData.type, 'cpu-profile'); + assert.equal(startData.state, 'running'); + assert.equal(startData.outPath, outPath); + assert.equal(readAndroidNativePerfState(sessionStore, 'android'), 'running'); + + const stopResponse = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'android', + command: 'perf', + positionals: ['cpu', 'profile', 'stop', 'simpleperf'], + flags: {}, + }, + sessionName: 'android', + sessionStore, + androidAdbExecutor: adb, + }); + + const stopData = requireOkData(stopResponse, 'Expected stop response to succeed'); + assert.equal(stopData.state, 'stopped'); + assert.equal(stopData.outPath, outPath); + assert.equal(stopData.sizeBytes, 7); + assert.equal(readAndroidNativePerfState(sessionStore, 'android'), 'stopped'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test('perf rejects starting a second Android native capture while one is active', async () => { + const tmpDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), 'agent-device-daemon-double-start-'), + ); + const outPath = path.join(tmpDir, 'cpu.perf.data'); + const sessionStore = makeSessionStore('agent-device-session-observability-'); + sessionStore.set('android', makeAndroidSession('android', { appBundleId: 'com.example.app' })); + const adb = makeNativePerfAdbExecutor(outPath); + + try { + const startResponse = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'android', + command: 'perf', + positionals: ['cpu', 'profile', 'start', 'simpleperf'], + flags: { out: outPath }, + }, + sessionName: 'android', + sessionStore, + androidAdbExecutor: adb, + }); + requireOkData(startResponse, 'Expected first start response to succeed'); + + const secondStartResponse = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'android', + command: 'perf', + positionals: ['trace', 'start', 'perfetto'], + flags: { out: path.join(tmpDir, 'app.perfetto-trace') }, + }, + sessionName: 'android', + sessionStore, + androidAdbExecutor: adb, + }); + + assert.equal(secondStartResponse?.ok, false); + if (secondStartResponse && !secondStartResponse.ok) { + assert.equal(secondStartResponse.error.code, 'COMMAND_FAILED'); + assert.match(secondStartResponse.error.message, /already running/); + assert.match(JSON.stringify(secondStartResponse.error), /Run perf cpu profile stop/); + } + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test('perf trace start and stop route through Android perfetto and preserve compact artifact state', async () => { + const tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'agent-device-daemon-perfetto-')); + const outPath = path.join(tmpDir, 'app.perfetto-trace'); + const sessionStore = makeSessionStore('agent-device-session-observability-'); + sessionStore.set('android', makeAndroidSession('android', { appBundleId: 'com.example.app' })); + const adb = makeNativePerfAdbExecutor(outPath); + + try { + const startResponse = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'android', + command: 'perf', + positionals: ['trace', 'start', 'perfetto'], + flags: { out: outPath }, + }, + sessionName: 'android', + sessionStore, + androidAdbExecutor: adb, + }); + + const startData = requireOkData(startResponse, 'Expected perfetto start response to succeed'); + assert.equal(startData.kind, 'perfetto'); + assert.equal(startData.type, 'trace'); + assert.equal(startData.state, 'running'); + assert.equal(readAndroidNativePerfState(sessionStore, 'android'), 'running'); + + const stopResponse = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'android', + command: 'perf', + positionals: ['trace', 'stop', 'perfetto'], + flags: {}, + }, + sessionName: 'android', + sessionStore, + androidAdbExecutor: adb, + }); + + const stopData = requireOkData(stopResponse, 'Expected perfetto stop response to succeed'); + assert.equal(stopData.state, 'stopped'); + assert.equal(stopData.outPath, outPath); + assert.equal(stopData.sizeBytes, 7); + assert.deepEqual(stopData.summary, { + capture: { + durationMs: stopData.durationMs, + packageName: 'com.example.app', + appPid: '1234', + artifactPath: outPath, + sizeBytes: 7, + }, + frameHealth: { + available: true, + droppedFramePercent: 20, + droppedFrameCount: 2, + totalFrameCount: 10, + method: 'adb-shell-dumpsys-gfxinfo-framestats', + worstWindows: undefined, + }, + notes: [ + 'Frame health is sampled from Android gfxinfo around the trace window; open the Perfetto artifact for timeline root cause.', + ], + }); + assert.equal(readAndroidNativePerfState(sessionStore, 'android'), 'stopped'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test('perf trace rejects perfetto on Apple sessions', async () => { + const sessionStore = makeSessionStore('agent-device-session-observability-'); + sessionStore.set('ios', makeIosSession('ios', { appBundleId: 'com.example.app' })); + + const response = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'ios', + command: 'perf', + positionals: ['trace', 'start', 'perfetto'], + flags: {}, + }, + sessionName: 'ios', + sessionStore, + }); + + assert.equal(response?.ok, false); + if (response && !response.ok) { + assert.equal(response.error.code, 'INVALID_ARGS'); + assert.match(response.error.message, /supports --kind xctrace/); + } +}); + +test('perf cpu profile reports a missing package with an actionable hint', async () => { + const sessionStore = makeSessionStore('agent-device-session-observability-'); + sessionStore.set('android', makeAndroidSession('android')); + + const response = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'android', + command: 'perf', + positionals: ['cpu', 'profile', 'start', 'simpleperf'], + flags: {}, + }, + sessionName: 'android', + sessionStore, + }); + + assert.equal(response?.ok, false); + if (response && !response.ok) { + assert.equal(response.error.code, 'COMMAND_FAILED'); + assert.match(JSON.stringify(response.error), /Run open first/); + } +}); + +function makeNativePerfAdbExecutor(outPath: string): AndroidAdbExecutor { + const responders = [ + staticAdbResponse(exactAdbArgs('shell', 'pidof', 'com.example.app'), '1234\n'), + staticAdbResponse(containsAdbArg('command -v simpleperf'), '/system/bin/simpleperf\n'), + staticAdbResponse(containsAdbArg('command -v perfetto'), '/system/bin/perfetto\n'), + staticAdbResponse(exactAdbArgs('shell', 'dumpsys', 'gfxinfo', 'com.example.app', 'reset')), + staticAdbResponse(shellCommandContains('simpleperf'), '5678\n'), + staticAdbResponse(adbArgsPrefix('shell', 'perfetto'), '5678\n'), + staticAdbResponse(containsAdbArg('kill -INT')), + staticAdbResponse(containsAdbArg('stat -c %s'), '7\n'), + staticAdbResponse(containsAdbArg('rm -f')), + pullAdbResponse(outPath, 'profile'), + staticAdbResponse( + exactAdbArgs('shell', 'dumpsys', 'gfxinfo', 'com.example.app', 'framestats'), + [ + 'Applications Graphics Acceleration Info:', + 'Uptime: 11000 Realtime: 11000', + '** Graphics info for pid 1234 [com.example.app] **', + 'Stats since: 10000000000ns', + 'Total frames rendered: 10', + 'Janky frames: 2 (20.00%)', + 'Number Frame deadline missed: 2', + ].join('\n'), + ), + ]; + return async (args) => dispatchAdbResponse(args, responders); +} + +function requireOkData(response: DaemonResponse | null, message: string): Record { + assert.equal(response?.ok, true, JSON.stringify(response)); + if (!response?.ok) throw new Error(message); + return response.data ?? {}; +} + +function readAndroidNativePerfState( + sessionStore: ReturnType, + sessionName: string, +): string | undefined { + return sessionStore.get(sessionName)?.nativePerf?.android?.state; +} + +type MockAdbResult = Awaited>; + +type MockAdbResponder = { + matches: (args: string[]) => boolean; + run: (args: string[]) => Promise; +}; + +async function dispatchAdbResponse( + args: string[], + responders: MockAdbResponder[], +): Promise { + const responder = responders.find((candidate) => candidate.matches(args)); + if (!responder) throw new Error(`Unexpected adb call: ${args.join(' ')}`); + return await responder.run(args); +} + +function staticAdbResponse(matches: MockAdbResponder['matches'], stdout = ''): MockAdbResponder { + return { + matches, + run: async () => ({ exitCode: 0, stdout, stderr: '' }), + }; +} + +function pullAdbResponse(outPath: string, contents: string): MockAdbResponder { + return { + matches: (args) => args[0] === 'pull', + run: async (args) => { + await fsPromises.writeFile(args[2] ?? outPath, contents); + return { exitCode: 0, stdout: '', stderr: '' }; + }, + }; +} + +function exactAdbArgs(...expected: string[]): MockAdbResponder['matches'] { + return (args) => args.join('\0') === expected.join('\0'); +} + +function adbArgsPrefix(...expected: string[]): MockAdbResponder['matches'] { + return (args) => expected.every((value, index) => args[index] === value); +} + +function containsAdbArg(pattern: string): MockAdbResponder['matches'] { + return (args) => args.some((arg) => arg.includes(pattern)); +} + +function shellCommandContains(pattern: string): MockAdbResponder['matches'] { + return (args) => args[0] === 'shell' && args.slice(1).some((arg) => arg.includes(pattern)); +} diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index ac3bfba9d..871f26854 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -8,6 +8,7 @@ import { SessionStore } from '../session-store.ts'; import { stopAppLog } from '../app-log.ts'; import { stopIosRunnerSession } from '../../platforms/ios/runner-client.ts'; import { cleanupAppleXctracePerfCapture } from '../../platforms/ios/perf-xctrace.ts'; +import { cleanupAndroidNativePerfSession } from '../../platforms/android/perf.ts'; import { clearRuntimeHintsFromApp, hasRuntimeTransportHints } from '../runtime-hints.ts'; import { cleanupRetainedMaterializedPathsForSession } from '../materialized-path-registry.ts'; import { @@ -72,6 +73,13 @@ async function stopSessionApplePerfCapture(session: SessionState): Promise session.applePerf = { ...(session.applePerf ?? {}), active: undefined }; } +async function stopSessionAndroidNativePerfCapture(session: SessionState): Promise { + const active = session.nativePerf?.android; + if (!active) return; + await cleanupAndroidNativePerfSession(session.device, active); + session.nativePerf = { ...(session.nativePerf ?? {}), android: undefined }; +} + export async function teardownSessionResources( session: SessionState, sessionName: string, @@ -80,6 +88,7 @@ export async function teardownSessionResources( await stopAppLog(session.appLog); } await stopSessionApplePerfCapture(session); + await stopSessionAndroidNativePerfCapture(session); if (isApplePlatform(session.device.platform)) { await stopAppleRunnerForClose(session); } @@ -101,6 +110,7 @@ export async function handleCloseCommand(params: { await stopAppLog(session.appLog); } await stopSessionApplePerfCapture(session); + await stopSessionAndroidNativePerfCapture(session); if (req.positionals && req.positionals.length > 0) { if (shouldStopAppleRunnerBeforeTargetedClose(session)) { await stopAppleRunnerForClose(session); diff --git a/src/daemon/handlers/session-native-perf.ts b/src/daemon/handlers/session-native-perf.ts new file mode 100644 index 000000000..4b5972b5a --- /dev/null +++ b/src/daemon/handlers/session-native-perf.ts @@ -0,0 +1,304 @@ +import path from 'node:path'; +import { + isPerfKind, + isPerfSubject, + PERF_KIND_ERROR_MESSAGE, + PERF_SUBJECT_ERROR_MESSAGE, +} from '../../contracts/perf.ts'; +import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts'; +import { + startAndroidPerfettoTrace, + startAndroidSimpleperfProfile, + stopAndroidPerfettoTrace, + stopAndroidSimpleperfProfile, + writeAndroidSimpleperfReport, + type AndroidNativePerfKind, + type AndroidNativePerfSession, +} from '../../platforms/android/perf.ts'; +import { AppError, normalizeError } from '../../utils/errors.ts'; +import { SessionStore } from '../session-store.ts'; +import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; +import { errorResponse, type DaemonFailureResponse } from './response.ts'; + +export async function handleNativePerfCommand(params: { + req: DaemonRequest; + sessionName: string; + sessionStore: SessionStore; + session: SessionState; + androidAdbExecutor?: AndroidAdbExecutor; + area: 'cpu' | 'trace'; +}): Promise { + const request = resolveNativePerfRequest(params.req, params.area); + if (!request.ok) return request; + const { session } = params; + if (session.device.platform !== 'android') { + return errorResponse( + 'UNSUPPORTED_OPERATION', + 'Android native perf collectors are supported only on Android sessions.', + ); + } + if (!session.appBundleId) { + return errorResponse( + 'COMMAND_FAILED', + 'No Android app package is associated with this session.', + { + hint: 'Run open first so perf can resolve the package and process identity.', + }, + ); + } + + try { + const data = + request.area === 'cpu' + ? await runAndroidCpuProfileCommand(params, session, session.appBundleId, request) + : await runAndroidTraceCommand(params, session, session.appBundleId, request); + return { ok: true, data }; + } catch (error) { + return { ok: false, error: normalizeError(error) }; + } +} + +type NativePerfRequest = + | { + ok: true; + area: 'cpu'; + subject: 'profile'; + action: 'start' | 'stop' | 'report'; + kind: 'simpleperf'; + outPath?: string; + } + | { + ok: true; + area: 'trace'; + action: 'start' | 'stop'; + kind: 'perfetto'; + outPath?: string; + }; + +type NativePerfHandlerParams = { + sessionName: string; + sessionStore: SessionStore; + req: DaemonRequest; + session: SessionState; + androidAdbExecutor?: AndroidAdbExecutor; +}; + +function resolveNativePerfRequest( + req: DaemonRequest, + area: 'cpu' | 'trace', +): NativePerfRequest | DaemonFailureResponse { + const outPath = readNativePerfOutPath(req, area); + return area === 'cpu' + ? resolveCpuProfileRequest(req, outPath) + : resolveTraceRequest(req, outPath); +} + +function resolveCpuProfileRequest( + req: DaemonRequest, + outPath: string | undefined, +): NativePerfRequest | DaemonFailureResponse { + const subject = readPositional(req, 1); + const action = readPositional(req, 2); + const kind = readPositional(req, 3); + if (!isPerfSubject(subject)) return errorResponse('INVALID_ARGS', PERF_SUBJECT_ERROR_MESSAGE); + if (!isCpuProfileAction(action)) { + return errorResponse('INVALID_ARGS', 'perf cpu profile action must be start, stop, or report'); + } + if (kind !== 'simpleperf') { + return errorResponse('INVALID_ARGS', 'perf cpu profile requires --kind simpleperf'); + } + return { ok: true, area: 'cpu', subject, action, kind, outPath }; +} + +function resolveTraceRequest( + req: DaemonRequest, + outPath: string | undefined, +): NativePerfRequest | DaemonFailureResponse { + const action = readPositional(req, 1); + const kind = readPositional(req, 2); + if (!isTraceAction(action)) { + return errorResponse('INVALID_ARGS', 'perf trace action must be start or stop'); + } + if (!isPerfKind(kind)) return errorResponse('INVALID_ARGS', PERF_KIND_ERROR_MESSAGE); + if (kind !== 'perfetto') { + return errorResponse('INVALID_ARGS', 'perf trace requires --kind perfetto'); + } + return { ok: true, area: 'trace', action, kind, outPath }; +} + +function readPositional(req: DaemonRequest, index: number): string { + return (req.positionals?.[index] ?? '').toLowerCase(); +} + +function isCpuProfileAction(action: string): action is 'start' | 'stop' | 'report' { + return action === 'start' || action === 'stop' || action === 'report'; +} + +function isTraceAction(action: string): action is 'start' | 'stop' { + return action === 'start' || action === 'stop'; +} + +function storeNativePerfSession( + params: NativePerfHandlerParams, + result: AndroidNativePerfSession & Record, +): Record { + params.sessionStore.set(params.sessionName, { + ...params.session, + nativePerf: { android: result }, + }); + return compactNativePerfResponse(result); +} + +function resolveNativePerfOutPath( + params: { sessionName: string; sessionStore: SessionStore; req: DaemonRequest }, + requestedPath: string | undefined, + fallbackFileName: string, +): string { + if (requestedPath) return SessionStore.expandHome(requestedPath, params.req.meta?.cwd); + return pathJoinSessionArtifact(params.sessionStore, params.sessionName, fallbackFileName); +} + +function readNativePerfOutPath(req: DaemonRequest, area: 'cpu' | 'trace'): string | undefined { + if (typeof req.flags?.out === 'string' && req.flags.out.length > 0) return req.flags.out; + const positionals = req.positionals ?? []; + const outIndex = area === 'cpu' ? 5 : 4; + const outPath = positionals[outIndex]; + return outPath ? outPath : undefined; +} + +async function runAndroidCpuProfileCommand( + params: NativePerfHandlerParams, + session: SessionState, + packageName: string, + request: Extract, +): Promise> { + if (request.action === 'start') { + assertNoActiveAndroidNativePerfSession(session); + const outPath = resolveNativePerfOutPath(params, request.outPath, 'cpu.perf.data'); + const result = await startAndroidSimpleperfProfile(session.device, packageName, outPath, { + adb: params.androidAdbExecutor, + }); + return storeNativePerfSession(params, result); + } + + const active = requireAndroidNativePerfSession(session, 'cpu-profile', request.kind); + if (request.action === 'report') { + await assertStoppedSimpleperfProfile(active); + const outPath = resolveNativePerfOutPath(params, request.outPath, 'cpu-report.json'); + return await writeAndroidSimpleperfReport(session.device, active, outPath, { + adb: params.androidAdbExecutor, + }); + } + + const outPath = resolveNativePerfStopOutPath(params, request.outPath, active); + const result = await stopAndroidSimpleperfProfile(session.device, active, outPath, { + adb: params.androidAdbExecutor, + }); + return storeNativePerfSession(params, result); +} + +async function runAndroidTraceCommand( + params: NativePerfHandlerParams, + session: SessionState, + packageName: string, + request: Extract, +): Promise> { + if (request.action === 'start') { + assertNoActiveAndroidNativePerfSession(session); + const outPath = resolveNativePerfOutPath(params, request.outPath, 'app.perfetto-trace'); + const result = await startAndroidPerfettoTrace(session.device, packageName, outPath, { + adb: params.androidAdbExecutor, + }); + return storeNativePerfSession(params, result); + } + + const active = requireAndroidNativePerfSession(session, 'trace', request.kind); + const outPath = resolveNativePerfStopOutPath(params, request.outPath, active); + const result = await stopAndroidPerfettoTrace(session.device, active, outPath, { + adb: params.androidAdbExecutor, + }); + return storeNativePerfSession(params, result); +} + +function assertStoppedSimpleperfProfile(active: AndroidNativePerfSession): void { + if (active.state !== 'running') return; + throw new AppError( + 'COMMAND_FAILED', + 'Stop the Android Simpleperf CPU profile before generating a report.', + { + hint: 'Run perf cpu profile stop --kind simpleperf, then retry perf cpu profile report --kind simpleperf.', + }, + ); +} + +function assertNoActiveAndroidNativePerfSession(session: SessionState): void { + const active = session.nativePerf?.android; + if (active?.state !== 'running') return; + throw new AppError( + 'COMMAND_FAILED', + `Android ${active.kind} ${active.type} is already running for this session.`, + { + hint: + active.type === 'cpu-profile' + ? 'Run perf cpu profile stop --kind simpleperf before starting another Android native perf capture.' + : 'Run perf trace stop --kind perfetto before starting another Android native perf capture.', + }, + ); +} + +function resolveNativePerfStopOutPath( + params: { sessionName: string; sessionStore: SessionStore; req: DaemonRequest }, + requestedPath: string | undefined, + active: AndroidNativePerfSession, +): string { + return requestedPath + ? resolveNativePerfOutPath(params, requestedPath, active.outPath) + : active.outPath; +} + +function pathJoinSessionArtifact( + sessionStore: SessionStore, + sessionName: string, + fallbackFileName: string, +): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return path.join(sessionStore.ensureSessionDir(sessionName), `${timestamp}-${fallbackFileName}`); +} + +function compactNativePerfResponse(result: AndroidNativePerfSession & Record) { + return { + action: result.action, + platform: 'android', + type: result.type, + kind: result.kind, + packageName: result.packageName, + appPid: result.appPid, + profilerPid: result.profilerPid, + state: result.state, + startedAt: new Date(result.startedAt).toISOString(), + stoppedAt: + typeof result.stoppedAt === 'number' ? new Date(result.stoppedAt).toISOString() : undefined, + durationMs: result.durationMs, + outPath: result.outPath, + sizeBytes: result.sizeBytes, + remotePath: result.remotePath, + method: result.method, + summary: result.summary, + message: result.message, + }; +} + +function requireAndroidNativePerfSession( + session: SessionState, + type: AndroidNativePerfSession['type'], + kind: AndroidNativePerfKind, +): AndroidNativePerfSession { + const active = session.nativePerf?.android; + if (active?.type === type && active.kind === kind) return active; + throw new AppError('COMMAND_FAILED', `No Android ${kind} ${type} is active for this session.`, { + hint: + type === 'cpu-profile' + ? 'Run perf cpu profile start --kind simpleperf first, then stop or report in the same session.' + : 'Run perf trace start --kind perfetto first, then stop in the same session.', + }); +} diff --git a/src/daemon/handlers/session-observability.ts b/src/daemon/handlers/session-observability.ts index d4a27dcc5..aa301efcb 100644 --- a/src/daemon/handlers/session-observability.ts +++ b/src/daemon/handlers/session-observability.ts @@ -31,8 +31,9 @@ import { buildPerfMemoryResponseData, buildPerfResponseData, } from './session-perf.ts'; +import { handleNativePerfCommand as handleAndroidNativePerfCommand } from './session-native-perf.ts'; import { errorResponse, type DaemonFailureResponse } from './response.ts'; -import { handleNativePerfCommand } from './session-perf-xctrace.ts'; +import { handleNativePerfCommand as handleAppleNativePerfCommand } from './session-perf-xctrace.ts'; import { NETWORK_INCLUDE_MODES, type NetworkIncludeMode } from '../../contracts.ts'; import type { LogBackend } from '../network-log.ts'; import { @@ -100,16 +101,31 @@ export async function handleSessionObservabilityCommands( // --------------------------------------------------------------------------- async function handlePerfCommand(params: ObservabilityParams): Promise { - const { req, sessionName, sessionStore } = params; + const { req, sessionName, sessionStore, androidAdbExecutor } = params; const session = sessionStore.get(sessionName); if (!session) { return errorResponse('SESSION_NOT_FOUND', 'perf requires an active session. Run open first.'); } + const area = (req.positionals?.[0] ?? 'metrics').toLowerCase(); + if (isNativePerfArea(area)) { + if (session.device.platform === 'android') { + return await handleAndroidNativePerfCommand({ + req, + sessionName, + sessionStore, + session, + androidAdbExecutor, + area, + }); + } + return await handleAppleNativePerfCommand(params, session); + } + const request = resolvePerfCommandRequest(req); if (!request.ok) return request; if (request.native) { - return await handleNativePerfCommand(params, session); + return await handleAppleNativePerfCommand(params, session); } try { @@ -122,6 +138,10 @@ async function handlePerfCommand(params: ObservabilityParams): Promise, action: PerfAction, ): DaemonFailureResponse | undefined { + if (area === 'memory') { + return isPerfMemoryAction(action) + ? undefined + : errorResponse('INVALID_ARGS', 'perf memory requires sample or snapshot'); + } if (action === 'sample') return undefined; - if (area === 'memory' && action === 'snapshot') return undefined; - return errorResponse( - 'INVALID_ARGS', - area === 'memory' - ? 'perf memory only supports snapshot' - : 'perf metrics and perf frames only support sample', - ); + return errorResponse('INVALID_ARGS', 'perf metrics and perf frames only support sample'); +} + +function isPerfMemoryAction(action: PerfAction): action is 'sample' | 'snapshot' { + return action === 'sample' || action === 'snapshot'; +} + +function toPerfMemoryAction(action: PerfAction): 'sample' | 'snapshot' { + if (isPerfMemoryAction(action)) return action; + throw new AppError('INVALID_ARGS', 'perf memory requires sample or snapshot'); } function validatePerfFlags( diff --git a/src/daemon/types.ts b/src/daemon/types.ts index cd7daf84d..4c3a9b0f9 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -18,6 +18,7 @@ import type { DeviceInfo, Platform, PlatformSelector } from '../utils/device.ts' import type { ExecBackgroundResult, ExecResult } from '../utils/exec.ts'; import type { SnapshotState } from '../utils/snapshot.ts'; import type { AppLogState } from './app-log-process.ts'; +import type { AndroidNativePerfSession } from '../platforms/android/perf.ts'; import type { AppleXctracePerfCapture, AppleXctracePerfMode, @@ -241,6 +242,9 @@ export type SessionState = { lastTracePath?: string; lastMode?: AppleXctracePerfMode; }; + nativePerf?: { + android?: AndroidNativePerfSession; + }; /** Session was created by record start and should be released when recording stops. */ recordOnlySession?: boolean; recordSession?: boolean; diff --git a/src/platforms/android/__tests__/perf.test.ts b/src/platforms/android/__tests__/perf.test.ts index 2f5dbe050..44d46dcc6 100644 --- a/src/platforms/android/__tests__/perf.test.ts +++ b/src/platforms/android/__tests__/perf.test.ts @@ -1,23 +1,22 @@ import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import fs from 'node:fs'; +import fs, { promises as fsPromises } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { test } from 'vitest'; +import type { AndroidAdbExecutor } from '../adb-executor.ts'; import { captureAndroidHeapSnapshot, + cleanupAndroidNativePerfSession, parseAndroidFramePerfSample, parseAndroidMemInfoSample, + startAndroidPerfettoTrace, + startAndroidSimpleperfProfile, + stopAndroidPerfettoTrace, + stopAndroidSimpleperfProfile, + writeAndroidSimpleperfReport, + type AndroidNativePerfSession, } from '../perf.ts'; -import type { AndroidAdbExecutor } from '../adb-executor.ts'; -import type { DeviceInfo } from '../../../utils/device.ts'; - -const ANDROID_DEVICE: DeviceInfo = { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel API', - kind: 'emulator', - booted: true, -}; +import { ANDROID_EMULATOR } from '../../../__tests__/test-utils/index.ts'; test('parseAndroidMemInfoSample supports legacy total row layout', () => { const sample = parseAndroidMemInfoSample( @@ -92,9 +91,12 @@ test('captureAndroidHeapSnapshot resolves pid, dumps heap, pulls artifact, and c }; try { - const snapshot = await captureAndroidHeapSnapshot(ANDROID_DEVICE, 'com.example.app', outPath, { - adb, - }); + const snapshot = await captureAndroidHeapSnapshot( + ANDROID_EMULATOR, + 'com.example.app', + outPath, + { adb }, + ); assert.equal(snapshot.kind, 'android-hprof'); assert.equal(snapshot.path, outPath); @@ -113,7 +115,9 @@ test('captureAndroidHeapSnapshot explains missing process failures', async () => const adb: AndroidAdbExecutor = async () => ({ stdout: '', stderr: '', exitCode: 1 }); await assert.rejects( () => - captureAndroidHeapSnapshot(ANDROID_DEVICE, 'com.example.missing', '/tmp/app.hprof', { adb }), + captureAndroidHeapSnapshot(ANDROID_EMULATOR, 'com.example.missing', '/tmp/app.hprof', { + adb, + }), /No running Android process found/, ); }); @@ -138,7 +142,7 @@ test('captureAndroidHeapSnapshot cleans remote path when dumpheap fails', async try { await assert.rejects( - () => captureAndroidHeapSnapshot(ANDROID_DEVICE, 'com.example.app', outPath, { adb }), + () => captureAndroidHeapSnapshot(ANDROID_EMULATOR, 'com.example.app', outPath, { adb }), /Failed to capture Android heap dump/, ); assert.equal(calls.at(-1)?.slice(0, 3).join(' '), 'shell rm -f'); @@ -170,7 +174,7 @@ test('captureAndroidHeapSnapshot removes partial local artifact when pull fails' try { await assert.rejects( - () => captureAndroidHeapSnapshot(ANDROID_DEVICE, 'com.example.app', outPath, { adb }), + () => captureAndroidHeapSnapshot(ANDROID_EMULATOR, 'com.example.app', outPath, { adb }), /Failed to pull Android heap dump/, ); assert.equal(fs.existsSync(outPath), false); @@ -330,3 +334,327 @@ test('parseAndroidFramePerfSample treats a reset idle window as an available zer assert.equal(sample.droppedFramePercent, 0); assert.equal(sample.source, 'android-gfxinfo-summary'); }); + +test('startAndroidSimpleperfProfile resolves pid and starts a bounded simpleperf recorder', async () => { + const calls: string[][] = []; + const adb: AndroidAdbExecutor = async (args) => { + calls.push(args); + if (args.join('\0') === ['shell', 'pidof', 'com.example.app'].join('\0')) { + return { exitCode: 0, stdout: '1234\n', stderr: '' }; + } + if (args[0] === 'shell' && args[1]?.includes('command -v simpleperf')) { + return { exitCode: 0, stdout: '/system/bin/simpleperf\n', stderr: '' }; + } + if (args[0] === 'shell' && args[1]?.includes('simpleperf')) { + return { exitCode: 0, stdout: '5678\n', stderr: '' }; + } + throw new Error(`Unexpected adb call: ${args.join(' ')}`); + }; + + const result = await startAndroidSimpleperfProfile( + ANDROID_EMULATOR, + 'com.example.app', + '/tmp/cpu.perf.data', + { adb }, + ); + + assert.equal(result.kind, 'simpleperf'); + assert.equal(result.type, 'cpu-profile'); + assert.equal(result.appPid, '1234'); + assert.equal(result.profilerPid, '5678'); + assert.match(calls[2]?.[1] ?? '', /simpleperf.+record.+-p.+1234/); +}); + +test('stopAndroidSimpleperfProfile pulls the profile artifact and reports compact metadata', async () => { + const tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'agent-device-simpleperf-test-')); + const outPath = path.join(tmpDir, 'cpu.perf.data'); + const calls: string[][] = []; + const session: AndroidNativePerfSession = { + type: 'cpu-profile', + kind: 'simpleperf', + packageName: 'com.example.app', + appPid: '1234', + profilerPid: '5678', + remotePath: '/data/local/tmp/cpu.perf.data', + outPath, + startedAt: Date.now() - 2000, + state: 'running', + }; + const adb: AndroidAdbExecutor = async (args) => { + calls.push(args); + if (args[0] === 'shell' && args[1]?.includes('kill -INT')) { + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (args[0] === 'shell' && args[1]?.includes('stat -c %s')) { + return { exitCode: 0, stdout: '7\n', stderr: '' }; + } + if (args[0] === 'pull') { + await fsPromises.writeFile(args[2]!, 'profile'); + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (args[0] === 'shell' && args[1]?.includes('rm -f')) { + return { exitCode: 0, stdout: '', stderr: '' }; + } + throw new Error(`Unexpected adb call: ${args.join(' ')}`); + }; + + const result = await stopAndroidSimpleperfProfile(ANDROID_EMULATOR, session, outPath, { adb }); + + assert.equal(result.state, 'stopped'); + assert.equal(result.artifact.path, outPath); + assert.equal(result.artifact.sizeBytes, 7); + assert.ok(findCallIndex(calls, 'stat -c %s') < findCallIndex(calls, 'pull')); + assert.ok(findCallIndex(calls, 'rm -f') > findCallIndex(calls, 'pull')); +}); + +test('stopAndroidSimpleperfProfile fails before pull when remote artifact never stabilizes', async () => { + const tmpDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), 'agent-device-simpleperf-missing-test-'), + ); + const session: AndroidNativePerfSession = { + type: 'cpu-profile', + kind: 'simpleperf', + packageName: 'com.example.app', + appPid: '1234', + profilerPid: '5678', + remotePath: '/data/local/tmp/cpu.perf.data', + outPath: path.join(tmpDir, 'cpu.perf.data'), + startedAt: Date.now() - 2000, + state: 'running', + }; + const calls: string[][] = []; + const adb: AndroidAdbExecutor = async (args) => { + calls.push(args); + if (args[0] === 'shell' && args[1]?.includes('kill -INT')) { + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (args[0] === 'shell' && args[1]?.includes('stat -c %s')) { + return { exitCode: 0, stdout: '', stderr: '' }; + } + throw new Error(`Unexpected adb call: ${args.join(' ')}`); + }; + + await assert.rejects( + stopAndroidSimpleperfProfile(ANDROID_EMULATOR, session, session.outPath, { adb }), + /artifact is not ready/, + ); + assert.equal( + calls.some((args) => args[0] === 'pull'), + false, + ); +}); + +test('cleanupAndroidNativePerfSession stops profiler and removes remote artifact without pulling', async () => { + const session: AndroidNativePerfSession = { + type: 'trace', + kind: 'perfetto', + packageName: 'com.example.app', + appPid: '1234', + profilerPid: '8765', + remotePath: '/data/misc/perfetto-traces/app.perfetto-trace', + outPath: '/tmp/app.perfetto-trace', + startedAt: Date.now() - 1000, + state: 'running', + }; + const calls: string[][] = []; + const adb: AndroidAdbExecutor = async (args) => { + calls.push(args); + if (args[0] === 'shell' && args[1]?.includes('kill -INT')) { + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (args[0] === 'shell' && args[1]?.includes('stat -c %s')) { + return { exitCode: 0, stdout: '5\n', stderr: '' }; + } + if (args[0] === 'shell' && args[1]?.includes('rm -f')) { + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (args[0] === 'pull') { + throw new Error('cleanup must not pull artifacts'); + } + throw new Error(`Unexpected adb call: ${args.join(' ')}`); + }; + + await cleanupAndroidNativePerfSession(ANDROID_EMULATOR, session, { adb }); + + assert.ok(findCallIndex(calls, 'kill -INT') < findCallIndex(calls, 'stat -c %s')); + assert.ok(findCallIndex(calls, 'rm -f') > findCallIndex(calls, 'stat -c %s')); + assert.equal( + calls.some((args) => args[0] === 'pull'), + false, + ); +}); + +test('start and stop Android Perfetto trace use perfetto trace storage and cleanup remote artifact', async () => { + const tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'agent-device-perfetto-test-')); + const outPath = path.join(tmpDir, 'app.perfetto-trace'); + const calls: string[][] = []; + const adb = makePerfettoTraceAdbExecutor(outPath, calls); + + const started = await startAndroidPerfettoTrace(ANDROID_EMULATOR, 'com.example.app', outPath, { + adb, + }); + const stopped = await stopAndroidPerfettoTrace(ANDROID_EMULATOR, started, outPath, { adb }); + + assert.equal(started.kind, 'perfetto'); + assert.equal(started.type, 'trace'); + assert.match(started.remotePath, /^\/data\/misc\/perfetto-traces\//); + assert.equal(stopped.artifact.path, outPath); + assert.equal(stopped.artifact.sizeBytes, 5); + assert.deepEqual(stopped.summary.frameHealth, { + available: true, + droppedFramePercent: 20, + droppedFrameCount: 2, + totalFrameCount: 10, + method: 'adb-shell-dumpsys-gfxinfo-framestats', + worstWindows: undefined, + }); + assert.ok( + findExactCallIndex(calls, 'shell', 'dumpsys', 'gfxinfo', 'com.example.app', 'reset') < + findCallPrefixIndex(calls, 'shell', 'perfetto'), + ); + assert.ok(findCallIndex(calls, 'stat -c %s') < findCallIndex(calls, 'pull')); + assert.ok(findCallIndex(calls, 'rm -f') > findCallIndex(calls, 'pull')); +}); + +test('writeAndroidSimpleperfReport writes JSON report artifact without returning report contents', async () => { + const tmpDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), 'agent-device-simpleperf-report-test-'), + ); + const outPath = path.join(tmpDir, 'cpu-report.json'); + const session: AndroidNativePerfSession = { + type: 'cpu-profile', + kind: 'simpleperf', + packageName: 'com.example.app', + appPid: '1234', + profilerPid: '5678', + remotePath: '/data/local/tmp/cpu.perf.data', + outPath: path.join(tmpDir, 'cpu.perf.data'), + startedAt: Date.now() - 2000, + state: 'stopped', + }; + const adb: AndroidAdbExecutor = async (args) => { + if (args[0] === 'shell' && args[1]?.includes('command -v simpleperf')) { + return { exitCode: 0, stdout: '/system/bin/simpleperf\n', stderr: '' }; + } + if (args[0] === 'shell' && args[1] === 'simpleperf') { + return { + exitCode: 0, + stdout: '12.34% com.example.app /data/app/libapp.so Java_com_example_Foo\n', + stderr: '', + }; + } + throw new Error(`Unexpected adb call: ${args.join(' ')}`); + }; + + const result = await writeAndroidSimpleperfReport(ANDROID_EMULATOR, session, outPath, { adb }); + const report = JSON.parse(await fsPromises.readFile(outPath, 'utf8')) as { + entries: Array<{ percentage: number; symbol: string }>; + }; + + assert.equal(result.outPath, outPath); + assert.equal(result.entryCount, 1); + assert.equal(report.entries[0]?.percentage, 12.3); + assert.equal(report.entries[0]?.symbol, 'Java_com_example_Foo'); + assert.equal('entries' in result, false); +}); + +test('startAndroidSimpleperfProfile fails with an actionable missing-process hint', async () => { + const adb: AndroidAdbExecutor = async (args) => { + if (args[0] === 'shell' && args[1] === 'pidof') { + return { exitCode: 1, stdout: '', stderr: '' }; + } + throw new Error(`Unexpected adb call: ${args.join(' ')}`); + }; + + await assert.rejects( + startAndroidSimpleperfProfile(ANDROID_EMULATOR, 'com.example.app', '/tmp/cpu.perf.data', { + adb, + }), + /No active Android app process/, + ); +}); + +function findCallIndex(calls: string[][], pattern: string): number { + return calls.findIndex((args) => args.some((arg) => arg.includes(pattern))); +} + +function findExactCallIndex(calls: string[][], ...expected: string[]): number { + return calls.findIndex((args) => args.join('\0') === expected.join('\0')); +} + +function findCallPrefixIndex(calls: string[][], ...expected: string[]): number { + return calls.findIndex((args) => expected.every((value, index) => args[index] === value)); +} + +function makePerfettoTraceAdbExecutor(outPath: string, calls: string[][]): AndroidAdbExecutor { + const responders = [ + staticAdbResponse(exactAdbArgs('shell', 'pidof', 'com.example.app'), '1234\n'), + staticAdbResponse(containsAdbArg('command -v perfetto'), '/system/bin/perfetto\n'), + staticAdbResponse(exactAdbArgs('shell', 'dumpsys', 'gfxinfo', 'com.example.app', 'reset')), + staticAdbResponse(adbArgsPrefix('shell', 'perfetto'), '8765\n'), + staticAdbResponse(containsAdbArg('kill -INT')), + staticAdbResponse(containsAdbArg('stat -c %s'), '5\n'), + pullAdbResponse(outPath, 'trace'), + staticAdbResponse( + exactAdbArgs('shell', 'dumpsys', 'gfxinfo', 'com.example.app', 'framestats'), + [ + 'Applications Graphics Acceleration Info:', + 'Uptime: 11000 Realtime: 11000', + '** Graphics info for pid 1234 [com.example.app] **', + 'Stats since: 10000000000ns', + 'Total frames rendered: 10', + 'Janky frames: 2 (20.00%)', + 'Number Frame deadline missed: 2', + ].join('\n'), + ), + staticAdbResponse(containsAdbArg('rm -f')), + ]; + return async (args) => dispatchAdbResponse(args, calls, responders); +} + +type MockAdbResult = Awaited>; + +type MockAdbResponder = { + matches: (args: string[]) => boolean; + run: (args: string[]) => Promise; +}; + +async function dispatchAdbResponse( + args: string[], + calls: string[][], + responders: MockAdbResponder[], +): Promise { + calls.push(args); + const responder = responders.find((candidate) => candidate.matches(args)); + if (!responder) throw new Error(`Unexpected adb call: ${args.join(' ')}`); + return await responder.run(args); +} + +function staticAdbResponse(matches: MockAdbResponder['matches'], stdout = ''): MockAdbResponder { + return { + matches, + run: async () => ({ exitCode: 0, stdout, stderr: '' }), + }; +} + +function pullAdbResponse(outPath: string, contents: string): MockAdbResponder { + return { + matches: (args) => args[0] === 'pull', + run: async () => { + await fsPromises.writeFile(outPath, contents); + return { exitCode: 0, stdout: '', stderr: '' }; + }, + }; +} + +function exactAdbArgs(...expected: string[]): MockAdbResponder['matches'] { + return (args) => args.join('\0') === expected.join('\0'); +} + +function adbArgsPrefix(...expected: string[]): MockAdbResponder['matches'] { + return (args) => expected.every((value, index) => args[index] === value); +} + +function containsAdbArg(pattern: string): MockAdbResponder['matches'] { + return (args) => args.some((arg) => arg.includes(pattern)); +} diff --git a/src/platforms/android/perf-native-report.ts b/src/platforms/android/perf-native-report.ts new file mode 100644 index 000000000..9c168e8ba --- /dev/null +++ b/src/platforms/android/perf-native-report.ts @@ -0,0 +1,22 @@ +import { splitNonEmptyTrimmedLines } from '../../utils/parsing.ts'; +import { roundPercent } from '../perf-utils.ts'; + +export function parseSimpleperfReportEntries(stdout: string): Array> { + const entries: Array> = []; + for (const line of splitNonEmptyTrimmedLines(stdout)) { + const match = line.match(/^([0-9]+(?:\.[0-9]+)?)%\s+(.+)$/); + if (!match) continue; + const percentage = Number(match[1]); + const rest = match[2]?.trim(); + if (!Number.isFinite(percentage) || !rest) continue; + const columns = rest.split(/\s{2,}/).filter(Boolean); + entries.push({ + percentage: roundPercent(percentage), + command: columns[0], + dso: columns[1], + symbol: columns.slice(2).join(' ') || undefined, + }); + if (entries.length >= 50) break; + } + return entries; +} diff --git a/src/platforms/android/perf-native.ts b/src/platforms/android/perf-native.ts new file mode 100644 index 000000000..2d861823a --- /dev/null +++ b/src/platforms/android/perf-native.ts @@ -0,0 +1,741 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import type { DeviceInfo } from '../../utils/device.ts'; +import { AppError } from '../../utils/errors.ts'; +import { resolveAndroidAdbExecutor, type AndroidAdbExecutor } from './adb-executor.ts'; +import { resetAndroidFramePerfStats, sampleAndroidFramePerf } from './perf-frame.ts'; +import { parseSimpleperfReportEntries } from './perf-native-report.ts'; + +const ANDROID_SIMPLEPERF_METHOD = 'adb-shell-simpleperf'; +const ANDROID_PERFETTO_METHOD = 'adb-shell-perfetto'; + +const ANDROID_PERF_TIMEOUT_MS = 15_000; +const ANDROID_NATIVE_PROFILE_TIMEOUT_MS = 30_000; +const ANDROID_NATIVE_REMOTE_DIR = '/data/local/tmp'; +const ANDROID_PERFETTO_REMOTE_DIR = '/data/misc/perfetto-traces'; +const ANDROID_NATIVE_MAX_SECONDS = 60 * 60; +const ANDROID_NATIVE_ARTIFACT_POLL_INTERVAL_MS = 250; +const ANDROID_NATIVE_ARTIFACT_POLL_ATTEMPTS = 12; + +export type AndroidNativePerfOptions = { + adb?: AndroidAdbExecutor; +}; + +export type AndroidNativePerfKind = 'simpleperf' | 'perfetto'; + +export type AndroidNativePerfType = 'cpu-profile' | 'trace'; + +export type AndroidNativePerfSession = { + type: AndroidNativePerfType; + kind: AndroidNativePerfKind; + packageName: string; + appPid: string; + profilerPid: string; + remotePath: string; + outPath: string; + startedAt: number; + state: 'running' | 'stopped'; + stoppedAt?: number; + sizeBytes?: number; +}; + +export type AndroidNativePerfStartResult = AndroidNativePerfSession & { + action: 'start'; + platform: 'android'; + method: typeof ANDROID_SIMPLEPERF_METHOD | typeof ANDROID_PERFETTO_METHOD; + message: string; +}; + +export type AndroidNativePerfStopResult = AndroidNativePerfSession & { + action: 'stop'; + platform: 'android'; + durationMs: number; + method: typeof ANDROID_SIMPLEPERF_METHOD | typeof ANDROID_PERFETTO_METHOD; + artifact: { + path: string; + sizeBytes: number; + }; + summary: AndroidNativePerfStopSummary; + message: string; +}; + +export type AndroidNativePerfStopSummary = { + capture: { + durationMs: number; + packageName: string; + appPid: string; + artifactPath: string; + sizeBytes: number; + }; + frameHealth?: AndroidNativePerfFrameHealthSummary; + notes: string[]; +}; + +export type AndroidNativePerfFrameHealthSummary = + | { + available: true; + droppedFramePercent: number; + droppedFrameCount: number; + totalFrameCount: number; + method: string; + worstWindows?: Array<{ + startOffsetMs?: number; + endOffsetMs?: number; + missedDeadlineFrameCount: number; + worstFrameMs?: number; + }>; + } + | { + available: false; + reason: string; + }; + +export type AndroidSimpleperfReportResult = { + action: 'report'; + platform: 'android'; + type: 'cpu-profile-report'; + kind: 'simpleperf'; + packageName: string; + appPid: string; + sourceProfilePath: string; + outPath: string; + sizeBytes: number; + generatedAt: string; + entryCount: number; + method: typeof ANDROID_SIMPLEPERF_METHOD; + message: string; +}; + +export async function startAndroidSimpleperfProfile( + device: DeviceInfo, + packageName: string, + outPath: string, + options: AndroidNativePerfOptions = {}, +): Promise { + const adb = resolveAndroidAdbExecutor(device, options.adb); + const appPid = await resolveAndroidAppPid(adb, packageName); + await assertAndroidToolAvailable(adb, 'simpleperf', packageName); + const remotePath = buildAndroidNativeRemotePath(packageName, 'cpu.perf.data'); + let profilerPid: string; + try { + profilerPid = await startAndroidBackgroundTool( + adb, + buildSimpleperfStartCommand(appPid, remotePath), + 'simpleperf', + packageName, + ); + } catch (error) { + await cleanupAndroidRemotePath(adb, remotePath); + throw error; + } + const session = { + type: 'cpu-profile', + kind: 'simpleperf', + packageName, + appPid, + profilerPid, + remotePath, + outPath, + startedAt: Date.now(), + state: 'running', + } satisfies AndroidNativePerfSession; + return { + ...session, + action: 'start', + platform: 'android', + method: ANDROID_SIMPLEPERF_METHOD, + message: `Started Android Simpleperf CPU profile for ${packageName}`, + }; +} + +export async function stopAndroidSimpleperfProfile( + device: DeviceInfo, + session: AndroidNativePerfSession, + outPath: string, + options: AndroidNativePerfOptions = {}, +): Promise { + return await stopAndroidNativePerfSession(device, { ...session, outPath }, options); +} + +export async function writeAndroidSimpleperfReport( + device: DeviceInfo, + session: AndroidNativePerfSession, + outPath: string, + options: AndroidNativePerfOptions = {}, +): Promise { + const adb = resolveAndroidAdbExecutor(device, options.adb); + await assertAndroidToolAvailable(adb, 'simpleperf', session.packageName); + const report = await runAndroidSimpleperfReport(adb, session); + const generatedAt = new Date().toISOString(); + const entries = parseSimpleperfReportEntries(report.stdout); + const payload = { + kind: 'simpleperf-report', + generatedAt, + packageName: session.packageName, + appPid: session.appPid, + sourceProfilePath: session.outPath, + sourceRemotePath: session.remotePath, + entryCount: entries.length, + entries, + }; + await writeJsonArtifact(outPath, payload); + const sizeBytes = await readFileSize(outPath); + return { + action: 'report', + platform: 'android', + type: 'cpu-profile-report', + kind: 'simpleperf', + packageName: session.packageName, + appPid: session.appPid, + sourceProfilePath: session.outPath, + outPath, + sizeBytes, + generatedAt, + entryCount: entries.length, + method: ANDROID_SIMPLEPERF_METHOD, + message: `Wrote Android Simpleperf report for ${session.packageName}`, + }; +} + +export async function startAndroidPerfettoTrace( + device: DeviceInfo, + packageName: string, + outPath: string, + options: AndroidNativePerfOptions = {}, +): Promise { + const adb = resolveAndroidAdbExecutor(device, options.adb); + const appPid = await resolveAndroidAppPid(adb, packageName); + await assertAndroidToolAvailable(adb, 'perfetto', packageName); + const remotePath = buildAndroidNativeRemotePath( + packageName, + 'app.perfetto-trace', + ANDROID_PERFETTO_REMOTE_DIR, + ); + let profilerPid: string; + try { + await resetAndroidFramePerfStats(device, packageName, { adb }); + profilerPid = await startAndroidPerfettoBackgroundTool(adb, remotePath, packageName); + } catch (error) { + await cleanupAndroidRemotePath(adb, remotePath); + throw error; + } + const session = { + type: 'trace', + kind: 'perfetto', + packageName, + appPid, + profilerPid, + remotePath, + outPath, + startedAt: Date.now(), + state: 'running', + } satisfies AndroidNativePerfSession; + return { + ...session, + action: 'start', + platform: 'android', + method: ANDROID_PERFETTO_METHOD, + message: `Started Android Perfetto trace for ${packageName}`, + }; +} + +export async function stopAndroidPerfettoTrace( + device: DeviceInfo, + session: AndroidNativePerfSession, + outPath: string, + options: AndroidNativePerfOptions = {}, +): Promise { + return await stopAndroidNativePerfSession(device, { ...session, outPath }, options); +} + +export async function cleanupAndroidNativePerfSession( + device: DeviceInfo, + session: AndroidNativePerfSession, + options: AndroidNativePerfOptions = {}, +): Promise { + const adb = resolveAndroidAdbExecutor(device, options.adb); + try { + if (session.state === 'running') { + await stopAndroidBackgroundTool(adb, session); + await waitForAndroidNativeArtifact(adb, session).catch(() => {}); + } + } finally { + await cleanupAndroidRemotePath(adb, session.remotePath); + } +} + +async function stopAndroidNativePerfSession( + device: DeviceInfo, + session: AndroidNativePerfSession, + options: AndroidNativePerfOptions, +): Promise { + const adb = resolveAndroidAdbExecutor(device, options.adb); + await stopAndroidBackgroundTool(adb, session); + await waitForAndroidNativeArtifact(adb, session); + await pullAndroidNativeArtifact(adb, session); + const sizeBytes = await readFileSize(session.outPath); + await cleanupAndroidRemotePath(adb, session.remotePath); + const stoppedAt = Date.now(); + const durationMs = Math.max(0, stoppedAt - session.startedAt); + const summary = await buildAndroidNativePerfStopSummary(device, session, sizeBytes, durationMs, { + adb, + }); + return { + ...session, + action: 'stop', + platform: 'android', + state: 'stopped', + stoppedAt, + durationMs, + sizeBytes, + method: session.kind === 'simpleperf' ? ANDROID_SIMPLEPERF_METHOD : ANDROID_PERFETTO_METHOD, + artifact: { + path: session.outPath, + sizeBytes, + }, + summary, + message: `Stopped Android ${session.kind} ${session.type} for ${session.packageName}`, + }; +} + +async function buildAndroidNativePerfStopSummary( + device: DeviceInfo, + session: AndroidNativePerfSession, + sizeBytes: number, + durationMs: number, + options: AndroidNativePerfOptions, +): Promise { + return { + capture: { + durationMs, + packageName: session.packageName, + appPid: session.appPid, + artifactPath: session.outPath, + sizeBytes, + }, + frameHealth: + session.kind === 'perfetto' + ? await sampleAndroidNativePerfFrameHealth(device, session.packageName, options) + : undefined, + notes: [ + session.kind === 'perfetto' + ? 'Frame health is sampled from Android gfxinfo around the trace window; open the Perfetto artifact for timeline root cause.' + : 'Open the Simpleperf report artifact for symbol-level CPU attribution.', + ], + }; +} + +async function sampleAndroidNativePerfFrameHealth( + device: DeviceInfo, + packageName: string, + options: AndroidNativePerfOptions, +): Promise { + try { + const sample = await sampleAndroidFramePerf(device, packageName, options); + return { + available: true, + droppedFramePercent: sample.droppedFramePercent, + droppedFrameCount: sample.droppedFrameCount, + totalFrameCount: sample.totalFrameCount, + method: sample.method, + worstWindows: sample.worstWindows?.slice(0, 3).map((window) => ({ + startOffsetMs: window.startOffsetMs, + endOffsetMs: window.endOffsetMs, + missedDeadlineFrameCount: window.missedDeadlineFrameCount, + worstFrameMs: window.worstFrameMs, + })), + }; + } catch (error) { + return { + available: false, + reason: error instanceof Error ? error.message : 'Android frame health was not available', + }; + } +} + +async function resolveAndroidAppPid(adb: AndroidAdbExecutor, packageName: string): Promise { + try { + const result = await adb(['shell', 'pidof', packageName], { + allowFailure: true, + timeoutMs: ANDROID_PERF_TIMEOUT_MS, + }); + const pid = findPidToken(result.stdout); + if (result.exitCode === 0 && pid) return pid; + } catch { + // Fall through to the actionable error below. + } + throw new AppError('COMMAND_FAILED', `No active Android app process found for ${packageName}`, { + package: packageName, + hint: 'Run open for this session again, wait for the app UI to appear, then retry perf.', + }); +} + +async function assertAndroidToolAvailable( + adb: AndroidAdbExecutor, + tool: 'simpleperf' | 'perfetto', + packageName: string, +): Promise { + const result = await adb(['shell', `command -v ${tool} || which ${tool}`], { + allowFailure: true, + timeoutMs: ANDROID_PERF_TIMEOUT_MS, + }); + if (result.exitCode === 0 && result.stdout.trim()) return; + throw new AppError('UNSUPPORTED_OPERATION', `Android device does not expose ${tool}`, { + package: packageName, + tool, + hint: + tool === 'simpleperf' + ? 'Use an emulator/system image with simpleperf available, or install the Android NDK simpleperf binary for this device.' + : 'Use Android 10+ or a system image that exposes the perfetto command-line binary.', + }); +} + +function buildAndroidNativeRemotePath( + packageName: string, + fileName: string, + remoteDir = ANDROID_NATIVE_REMOTE_DIR, +): string { + const safePackage = packageName.replace(/[^A-Za-z0-9_.-]/g, '_'); + return `${remoteDir}/agent-device-${safePackage}-${Date.now()}-${fileName}`; +} + +function buildSimpleperfStartCommand(appPid: string, remotePath: string): string { + return buildBackgroundShellCommand( + [ + 'simpleperf', + 'record', + '-e', + 'cpu-clock:u', + '-p', + appPid, + '-o', + remotePath, + '--duration', + String(ANDROID_NATIVE_MAX_SECONDS), + ], + 'simpleperf', + ); +} + +function buildBackgroundShellCommand(argv: string[], label: string): string { + const command = argv.map(shellQuote).join(' '); + const stderrPath = `${ANDROID_NATIVE_REMOTE_DIR}/agent-device-${label}-${Date.now()}.err`; + return [ + `err=${shellQuote(stderrPath)}`, + `(${command}) >/dev/null 2>"$err" & pid=$!`, + 'sleep 1', + 'if kill -0 "$pid" 2>/dev/null; then rm -f "$err"; echo "$pid"; exit 0; fi', + 'cat "$err" >&2', + 'rm -f "$err"', + 'exit 1', + ].join('; '); +} + +async function startAndroidPerfettoBackgroundTool( + adb: AndroidAdbExecutor, + remotePath: string, + packageName: string, +): Promise { + try { + const result = await adb( + [ + 'shell', + 'perfetto', + '--background-wait', + '-o', + remotePath, + '-t', + `${ANDROID_NATIVE_MAX_SECONDS}s`, + 'sched', + 'freq', + 'idle', + 'am', + 'wm', + 'gfx', + 'view', + 'binder_driver', + 'hal', + 'dalvik', + ], + { + timeoutMs: ANDROID_NATIVE_PROFILE_TIMEOUT_MS, + }, + ); + const pid = findPidToken(result.stdout); + if (pid) return pid; + throw new AppError('COMMAND_FAILED', 'Android perfetto did not return a profiler pid', { + package: packageName, + tool: 'perfetto', + hint: 'Retry perf trace start. If perfetto exits immediately, verify the device permits trace capture.', + }); + } catch (error) { + throw annotateAndroidNativePerfError('start', 'perfetto', packageName, error); + } +} + +async function startAndroidBackgroundTool( + adb: AndroidAdbExecutor, + shellCommand: string, + tool: AndroidNativePerfKind, + packageName: string, +): Promise { + try { + const result = await adb(['shell', shellCommand], { + timeoutMs: ANDROID_NATIVE_PROFILE_TIMEOUT_MS, + }); + const pid = findPidToken(result.stdout); + if (pid) return pid; + throw new AppError('COMMAND_FAILED', `Android ${tool} did not return a profiler pid`, { + package: packageName, + tool, + hint: `Retry perf. If ${tool} exits immediately, verify the app is profileable and the device permits native profiling.`, + }); + } catch (error) { + throw annotateAndroidNativePerfError('start', tool, packageName, error); + } +} + +async function stopAndroidBackgroundTool( + adb: AndroidAdbExecutor, + session: AndroidNativePerfSession, +): Promise { + try { + await adb(['shell', buildStopProfilerCommand(session.profilerPid)], { + timeoutMs: ANDROID_NATIVE_PROFILE_TIMEOUT_MS, + }); + } catch (error) { + throw annotateAndroidNativePerfError('stop', session.kind, session.packageName, error); + } +} + +function buildStopProfilerCommand(pid: string): string { + return [ + `pid=${shellQuote(pid)}`, + 'kill -INT "$pid" 2>/dev/null || true', + 'for i in 1 2 3 4 5 6 7 8 9 10; do kill -0 "$pid" 2>/dev/null || exit 0; sleep 0.2; done', + 'kill -TERM "$pid" 2>/dev/null || true', + 'for i in 1 2 3 4 5 6 7 8 9 10; do kill -0 "$pid" 2>/dev/null || exit 0; sleep 0.2; done', + 'echo "profiler process did not stop after SIGTERM" >&2', + 'exit 1', + ].join('; '); +} + +function findPidToken(stdout: string): string | undefined { + return stdout + .trim() + .split(/\s+/) + .find((token) => /^\d+$/.test(token)); +} + +async function pullAndroidNativeArtifact( + adb: AndroidAdbExecutor, + session: AndroidNativePerfSession, +): Promise { + await fs.mkdir(path.dirname(session.outPath), { recursive: true }); + try { + await adb(['pull', session.remotePath, session.outPath], { + timeoutMs: ANDROID_NATIVE_PROFILE_TIMEOUT_MS, + }); + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + `Failed to pull Android ${session.kind} artifact for ${session.packageName}`, + { + package: session.packageName, + tool: session.kind, + remotePath: session.remotePath, + outPath: session.outPath, + hint: 'Check that the profiling command ran long enough to create an artifact, then retry stop with the same session.', + }, + error, + ); + } +} + +async function waitForAndroidNativeArtifact( + adb: AndroidAdbExecutor, + session: AndroidNativePerfSession, +): Promise { + let previousSize: number | undefined; + let stableSamples = 0; + for (let attempt = 0; attempt < ANDROID_NATIVE_ARTIFACT_POLL_ATTEMPTS; attempt += 1) { + const size = await readAndroidRemoteFileSize(adb, session.remotePath); + if (size !== undefined && size > 0 && size === previousSize) { + stableSamples += 1; + if (stableSamples >= 1) return; + } else { + stableSamples = 0; + } + previousSize = size; + await delay(ANDROID_NATIVE_ARTIFACT_POLL_INTERVAL_MS); + } + throw new AppError('COMMAND_FAILED', `Android ${session.kind} artifact is not ready to pull`, { + package: session.packageName, + tool: session.kind, + remotePath: session.remotePath, + hint: 'The profiler stopped, but the remote artifact was missing, empty, or still changing. Retry stop with the same session or inspect the device-side artifact.', + }); +} + +async function readAndroidRemoteFileSize( + adb: AndroidAdbExecutor, + remotePath: string, +): Promise { + const quotedPath = shellQuote(remotePath); + const result = await adb( + [ + 'shell', + `if [ -f ${quotedPath} ]; then stat -c %s ${quotedPath} 2>/dev/null || wc -c < ${quotedPath}; fi`, + ], + { + allowFailure: true, + timeoutMs: ANDROID_PERF_TIMEOUT_MS, + }, + ); + if (result.exitCode !== 0) return undefined; + const value = Number(result.stdout.trim().split(/\s+/)[0]); + return Number.isFinite(value) ? value : undefined; +} + +async function cleanupAndroidRemotePath( + adb: AndroidAdbExecutor, + remotePath: string, +): Promise { + try { + await adb(['shell', `rm -f ${shellQuote(remotePath)}`], { + allowFailure: true, + timeoutMs: ANDROID_PERF_TIMEOUT_MS, + }); + } catch { + // Best-effort cleanup must not hide the primary profiling result. + } +} + +async function runAndroidSimpleperfReport( + adb: AndroidAdbExecutor, + session: AndroidNativePerfSession, +): Promise<{ stdout: string }> { + try { + return await adb( + [ + 'shell', + 'simpleperf', + 'report', + '-i', + session.remotePath, + '--stdio', + '--sort', + 'comm,dso,symbol', + ], + { + timeoutMs: ANDROID_NATIVE_PROFILE_TIMEOUT_MS, + }, + ); + } catch (error) { + throw annotateAndroidNativePerfError('report', 'simpleperf', session.packageName, error); + } +} + +async function writeJsonArtifact(outPath: string, value: unknown): Promise { + await fs.mkdir(path.dirname(outPath), { recursive: true }); + await fs.writeFile(outPath, `${JSON.stringify(value, null, 2)}\n`); +} + +async function readFileSize(filePath: string): Promise { + try { + return (await fs.stat(filePath)).size; + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + `Profiler artifact was not written: ${filePath}`, + { + outPath: filePath, + hint: 'Retry the profiling command and check daemon logs if the artifact path is still missing.', + }, + error, + ); + } +} + +function annotateAndroidNativePerfError( + action: 'start' | 'stop' | 'report', + tool: AndroidNativePerfKind, + packageName: string, + error: unknown, +): AppError { + if (error instanceof AppError) { + const details = error.details ?? {}; + return new AppError( + error.code, + error.message, + { + ...details, + action, + package: packageName, + tool, + hint: + typeof details.hint === 'string' + ? details.hint + : classifyAndroidNativePerfHint(tool, details), + }, + error, + ); + } + return new AppError( + 'COMMAND_FAILED', + `Failed to ${action} Android ${tool} for ${packageName}`, + { + action, + package: packageName, + tool, + hint: buildAndroidNativePerfHint(tool), + }, + error, + ); +} + +function buildAndroidNativePerfHint(tool: AndroidNativePerfKind): string { + return tool === 'simpleperf' + ? 'Verify simpleperf is available, the app process is running, and the app/device permits native CPU profiling.' + : 'Verify perfetto is available, the app process is running, and the device permits trace capture.'; +} + +function classifyAndroidNativePerfHint( + tool: AndroidNativePerfKind, + details: Record, +): string { + const stderr = typeof details.stderr === 'string' ? details.stderr : ''; + const text = stderr.toLowerCase(); + if (tool === 'simpleperf') return classifySimpleperfHint(text); + if (hasPerfettoPermissionError(text)) { + return 'Use a device image that permits perfetto trace capture for shell, keep the app running, then retry perf trace start.'; + } + return buildAndroidNativePerfHint(tool); +} + +function classifySimpleperfHint(text: string): string { + if (hasSimpleperfProfileabilityError(text)) { + return 'Use a debuggable/profileable Android app or a device image that permits simpleperf for the target process, then retry perf cpu profile start.'; + } + if (text.includes('not supported') || text.includes('failed to open perf event')) { + return 'This device image does not expose the requested simpleperf event for the app process. Try a different emulator/system image or a profileable app.'; + } + return buildAndroidNativePerfHint('simpleperf'); +} + +function hasSimpleperfProfileabilityError(text: string): boolean { + return ( + text.includes('permission denied') || + text.includes('not profileable') || + text.includes('profileable') + ); +} + +function hasPerfettoPermissionError(text: string): boolean { + return text.includes('permission denied') || text.includes('not allowed'); +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} diff --git a/src/platforms/android/perf.ts b/src/platforms/android/perf.ts index 97384d291..a80cb3dc9 100644 --- a/src/platforms/android/perf.ts +++ b/src/platforms/android/perf.ts @@ -15,6 +15,20 @@ export { type AndroidFrameDropWindow, type AndroidFramePerfSample, } from './perf-frame.ts'; +export { + cleanupAndroidNativePerfSession, + startAndroidPerfettoTrace, + startAndroidSimpleperfProfile, + stopAndroidPerfettoTrace, + stopAndroidSimpleperfProfile, + writeAndroidSimpleperfReport, + type AndroidNativePerfKind, + type AndroidNativePerfSession, + type AndroidNativePerfStartResult, + type AndroidNativePerfStopResult, + type AndroidNativePerfType, + type AndroidSimpleperfReportResult, +} from './perf-native.ts'; export const ANDROID_CPU_SAMPLE_METHOD = 'adb-shell-dumpsys-cpuinfo'; export const ANDROID_CPU_SAMPLE_DESCRIPTION = diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index baa797654..921b666c2 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -909,6 +909,8 @@ test('usageForCommand supports metrics alias', () => { const help = usageForCommand('metrics'); assert.equal(help === null, false); assert.match(help ?? '', /agent-device perf/); + assert.match(help ?? '', /Native perf output is agent evidence/); + assert.match(help ?? '', /raw profiles\/traces stay on disk/); }); test('parseArgs rejects invalid swipe pattern', () => { @@ -1192,6 +1194,9 @@ test('usageForCommand resolves debugging help topic', () => { assert.match(help, /Prefer perf memory sample over raw dumpsys\/leaks output/); assert.match(help, /Unsupported platforms return artifact\.available=false with reason\/hint/); assert.match(help, /Do not use settings permission to answer a dialog already on screen/); + assert.match(help, /Treat native perf output as the agent evidence/); + assert.match(help, /sizeBytes=5392410/); + assert.match(help, /5\.3 MB raw trace stays in the artifact/); }); test('usageForCommand resolves remote help topic', () => { diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index 57ca78d5c..d3b90e774 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -151,11 +151,11 @@ const CLI_COMMAND_OVERRIDES = { }, perf: { usageOverride: - 'perf [metrics|frames|memory] [sample|snapshot]\n agent-device perf memory sample --json\n agent-device perf memory snapshot [--kind android-hprof|memgraph] [--out ]\n agent-device perf cpu profile start|stop|report --kind xctrace [--template ] --out \n agent-device perf trace start|stop --kind xctrace [--template ] --out ', + 'perf [metrics|frames|memory] [sample|snapshot]\n agent-device perf memory sample --json\n agent-device perf memory snapshot [--kind android-hprof|memgraph] [--out ]\n agent-device perf cpu profile start|stop|report --kind xctrace [--template ] --out \n agent-device perf trace start|stop --kind xctrace [--template ] --out \n agent-device perf cpu profile start|stop|report --kind simpleperf [--out ]\n agent-device perf trace start|stop --kind perfetto [--out ]', listUsageOverride: 'perf [metrics|frames|memory] | perf cpu profile start|stop|report | perf trace start|stop', helpDescription: - 'Show session performance metrics, focused frame/jank health, memory diagnostics artifacts, or Apple xctrace artifacts. Bare perf and metrics are aliases for perf metrics.', + 'Show session performance metrics, focused frame/jank health, memory diagnostics artifacts, Apple xctrace artifacts, or Android native Simpleperf/Perfetto artifacts. Bare perf and metrics are aliases for perf metrics. Native perf output is agent evidence: compact state, artifact path, and size only; raw profiles/traces stay on disk.', summary: 'Show performance metrics or collect native perf artifacts', positionalArgs: ['area?', 'subjectOrAction?', 'action?'], allowedFlags: ['kind', 'perfTemplate', 'out'], diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index 21c978c27..0e94e3079 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -395,7 +395,7 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ enumValues: ['auto', 'react-native', 'expo', ...PERF_KIND_VALUES], usageLabel: '--kind ', usageDescription: - 'Kind selector for commands that support it, such as metro prepare or perf memory snapshot', + 'Kind selector for commands that support it, such as metro prepare or perf artifact collectors', }, { key: 'perfTemplate', diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 5d1268c4c..5879c31cc 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -209,7 +209,7 @@ Validation and evidence: If task says snapshot, use snapshot. If it asks visual evidence, use screenshot. Icon/tappable visual proof: screenshot --overlay-refs. Flag is --overlay-refs. If snapshot returns a sparse/AX-unavailable state, refs are not reliable. Use plain screenshot, not screenshot --overlay-refs, navigate with coordinates if needed, then retry snapshot -i after reaching another screen; the AX failure may be screen-specific. - Startup/CPU/memory/frame first pass: perf metrics --json (bare perf and metrics are aliases). Focused frame/jank health: perf frames --json. Memory-only sample: perf memory sample --json returns compact JSON with bounded top offenders. Heap/memgraph artifact escalation: perf memory snapshot --out heap.artifact; use --kind android-hprof on Android or --kind memgraph on supported Apple simulator/macOS app sessions. Large memory artifacts stay on disk and responses return paths/compact metadata only. This is better than raw memory dumps for agents because it is stable, bounded, and keeps large artifacts out of context. heapprofd is deferred until Perfetto plumbing is available. Replay maintenance: replay -u ./flow.ad. + Startup/CPU/memory/frame first pass: perf metrics --json (bare perf and metrics are aliases). Focused frame/jank health: perf frames --json. Memory-only sample: perf memory sample --json returns compact JSON with bounded top offenders. Heap/memgraph artifact escalation: perf memory snapshot --out heap.artifact; use --kind android-hprof on Android or --kind memgraph on supported Apple simulator/macOS app sessions. Android native profiling: perf cpu profile start|stop|report --kind simpleperf --out ; Android native traces: perf trace start|stop --kind perfetto --out . Artifact collectors return compact state/path/size metadata only; raw heap/profile/trace files stay on disk. Treat native perf output as the agent evidence: for example, a Perfetto stop can return state=stopped, outPath=/tmp/app.perfetto-trace, sizeBytes=5392410, and method=adb-shell-perfetto while the 5.3 MB raw trace stays in the artifact. This is better than raw dumps for agents because it is stable, bounded, and keeps large artifacts out of context. heapprofd is deferred until Perfetto plumbing is available. Replay maintenance: replay -u ./flow.ad. Recording: record start/stop. By default, stop burns touch overlays into the video; use record start --hide-touches for the fastest raw recording. Android adb screenrecord has a 180s platform limit, so longer Android recordings are returned as multiple MP4 chunks. For gesture-heavy iOS simulator proof videos, prefer --hide-touches because overlay timing depends on a stable runner session while gestures are executing. Tracing: trace start ./trace.log, trace stop ./trace.log. Paths are positional. Stable known flow: batch ./steps.json, not workflow batch. Inline batch JSON example: @@ -303,7 +303,13 @@ Diagnostics and traces: agent-device perf trace start --kind xctrace --template "Animation Hitches" --out ./artifacts/hitches.trace agent-device perf trace stop --kind xctrace --out ./artifacts/hitches.trace perf xctrace returns artifact paths and compact metadata only. Do not dump .trace contents into context. - Android native profiling is out of scope for Apple xctrace perf; use the Android perf rollout when available. + For Android native CPU/trace evidence, use perf artifacts instead of raw adb/simpleperf/perfetto output: + agent-device perf cpu profile start --kind simpleperf --out /tmp/cpu.perf.data + agent-device perf cpu profile stop --kind simpleperf + agent-device perf cpu profile report --kind simpleperf --out /tmp/cpu-report.json + agent-device perf trace start --kind perfetto --out /tmp/app.perfetto-trace + agent-device perf trace stop --kind perfetto + Treat native perf output as the agent evidence: for example, state=stopped, outPath=/tmp/app.perfetto-trace, sizeBytes=5392410, method=adb-shell-perfetto. The 5.3 MB raw trace stays in the artifact. Memory diagnostics: Use perf memory when the symptom is leak/growth/OOM suspicion and you need agent-readable evidence. diff --git a/test/skillgym/suites/agent-device-smoke-suite.ts b/test/skillgym/suites/agent-device-smoke-suite.ts index 1119cccad..c23c38013 100644 --- a/test/skillgym/suites/agent-device-smoke-suite.ts +++ b/test/skillgym/suites/agent-device-smoke-suite.ts @@ -1385,6 +1385,31 @@ const SKILL_GUIDANCE_CASES: Case[] = [ ], forbiddenOutputs: [plannedCommand('debug'), plannedCommand('react-devtools')], }), + makeCase({ + id: 'perf-android-native-profiling', + contract: [ + 'App package: com.example.app', + 'Platform: Android emulator', + 'Need native CPU profile and system trace artifacts for PR evidence', + 'Collect lightweight perf metrics and focused frame health first', + 'Do not claim iOS native Simpleperf or Perfetto support', + ], + task: 'Plan commands to open the Android app, collect perf metrics and frames, then capture Android native Simpleperf CPU and Perfetto trace artifacts.', + outputs: [ + plannedCommand('open'), + plannedCommand('perf metrics'), + plannedCommand('perf frames'), + plannedCommand('perf cpu profile start'), + /--kind\s+simpleperf/i, + plannedCommand('perf cpu profile stop'), + plannedCommand('perf cpu profile report'), + plannedCommand('perf trace start'), + /--kind\s+perfetto/i, + plannedCommand('perf trace stop'), + /--out/i, + ], + forbiddenOutputs: [/ios/i, plannedCommand('debug'), plannedCommand('react-devtools')], + }), makeCase({ id: 'react-devtools-profile-search', contract: [ diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 98f065a74..5d52bd4c0 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -263,7 +263,7 @@ Additional CLI-backed methods are exposed on their domain groups with typed opti - `client.recording.record()` and `client.recording.trace()` - `client.settings.update()` -`client.observability.perf()` returns daemon-shaped JSON so local and remote transports expose the same metrics payload. Pass `{ area: 'metrics' }` for the broad startup/CPU/memory/frame first pass, `{ area: 'frames' }` for a focused frame/jank-health payload, or `{ area: 'memory', action: 'sample' }` for a compact memory-only sample. Use `{ area: 'memory', action: 'snapshot', kind: 'android-hprof', out: 'app.hprof' }` on Android or `{ area: 'memory', action: 'snapshot', kind: 'memgraph', out: 'app.memgraph' }` on supported Apple simulator/macOS app sessions to write large memory artifacts to disk; responses return artifact paths and compact metadata, not artifact bytes. Physical iOS device memgraph capture reports unavailable with a reason/hint. heapprofd allocation tracing is deferred until Perfetto plumbing is available. On Android and supported Apple targets, `data.metrics.fps.droppedFramePercent` is the primary frame-smoothness value. Android derives it from the current `adb shell dumpsys gfxinfo framestats` window; connected iOS devices derive it from `xcrun xctrace` Animation Hitches for the active app process. Frame samples include `windowStartedAt`, `windowEndedAt`, and `worstWindows` so agents can correlate dropped-frame clusters with logs, network entries, and their own session actions. A successful Android read resets Android frame stats; `open ` resets the Android frame window too, so agents can call `perf({ area: 'frames' })`, perform a transition or gesture, then call it again to inspect that focused window. iOS simulator and macOS app sessions report frame health as unavailable rather than inventing FPS or dropped-frame values. +`client.observability.perf()` returns daemon-shaped JSON so local and remote transports expose the same metrics payload. Pass `{ area: 'metrics' }` for the broad startup/CPU/memory/frame first pass, `{ area: 'frames' }` for a focused frame/jank-health payload, or `{ area: 'memory', action: 'sample' }` for a compact memory-only sample. Use `{ area: 'memory', action: 'snapshot', kind: 'android-hprof', out: 'app.hprof' }` on Android or `{ area: 'memory', action: 'snapshot', kind: 'memgraph', out: 'app.memgraph' }` on supported Apple simulator/macOS app sessions to write large memory artifacts to disk. Android native artifacts use `{ area: 'cpu', subject: 'profile', action: 'start' | 'stop' | 'report', kind: 'simpleperf', out }` and `{ area: 'trace', action: 'start' | 'stop', kind: 'perfetto', out }`; these Android-only commands return artifact paths and compact summaries, not trace/profile contents. Physical iOS device memgraph capture reports unavailable with a reason/hint. heapprofd allocation tracing is deferred until Perfetto plumbing is available. On Android and supported Apple targets, `data.metrics.fps.droppedFramePercent` is the primary frame-smoothness value. Android derives it from the current `adb shell dumpsys gfxinfo framestats` window; connected iOS devices derive it from `xcrun xctrace` Animation Hitches for the active app process. Frame samples include `windowStartedAt`, `windowEndedAt`, and `worstWindows` so agents can correlate dropped-frame clusters with logs, network entries, and their own session actions. A successful Android read resets Android frame stats; `open ` resets the Android frame window too, so agents can call `perf({ area: 'frames' })`, perform a transition or gesture, then call it again to inspect that focused window. iOS simulator and macOS app sessions report frame health as unavailable rather than inventing FPS or dropped-frame values. For Apple native profiling, call `perf({ area: 'cpu', subject: 'profile', action: 'start', kind: 'xctrace', template: 'Time Profiler', out: 'app.trace' })`, then stop with the same trace path and write a compact report with `action: 'report'`. `area: 'trace'` supports xctrace templates such as `Animation Hitches`. Responses include artifact paths and compact metadata only. diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index a2b9caf69..93c6ee568 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -594,6 +594,11 @@ agent-device perf cpu profile stop --kind xctrace --out app.trace agent-device perf cpu profile report --kind xctrace --out app-profile.json agent-device perf trace start --kind xctrace --template "Animation Hitches" --out hitches.trace agent-device perf trace stop --kind xctrace --out hitches.trace +agent-device perf cpu profile start --kind simpleperf --out cpu.perf.data +agent-device perf cpu profile stop --kind simpleperf --out cpu.perf.data +agent-device perf cpu profile report --kind simpleperf --out cpu-report.json +agent-device perf trace start --kind perfetto --out app.perfetto-trace +agent-device perf trace stop --kind perfetto --out app.perfetto-trace ``` - `perf metrics` returns a session-scoped metrics JSON blob. Bare `perf` and `metrics` remain aliases for `perf metrics`. @@ -605,7 +610,11 @@ agent-device perf trace stop --kind xctrace --out hitches.trace - `perf cpu profile ... --kind xctrace` records an Apple `.trace` with the requested xctrace template and writes a compact JSON report from the most recent CPU profile trace. - `perf trace ... --kind xctrace` records an Apple `.trace` such as Animation Hitches for native diagnosis. - xctrace perf commands return artifact paths and compact metadata only; inspect `.trace` files in Instruments/Xcode instead of dumping trace contents into agent context. +- `perf cpu profile ... --kind simpleperf` starts/stops Android native CPU profiling for the active session package and can generate a compact JSON report artifact from the captured profile. +- `perf trace ... --kind perfetto` starts/stops Android Perfetto trace capture for the active session package. +- Native profile/trace outputs are compact agent evidence: state, artifact path, size, and method. Raw `.perf.data` and `.perfetto-trace` contents stay on disk. - Without `--json`, `perf` prints a compact summary: frame health when reliable frame data is available, otherwise CPU/memory when those samples are available. +- Use native perf stop/report results as compact agent evidence, not raw profiler output. A successful Perfetto stop can return `state: "stopped"`, `outPath: "/tmp/app.perfetto-trace"`, `sizeBytes: 5392410`, and `method: "adb-shell-perfetto"` while the 5.3 MB raw trace stays on disk as the artifact. - `startup` is sampled from `open-command-roundtrip`: elapsed wall-clock time around each `open` command dispatch for the active session app target. - Android app sessions with an active package also sample: - `fps` frame health from `adb shell dumpsys gfxinfo framestats`, with `droppedFramePercent` as the primary value and `worstWindows` for dropped-frame clusters @@ -628,6 +637,7 @@ agent-device perf trace stop --kind xctrace --out hitches.trace - If no startup sample exists yet for the session, run `open ` first and retry `perf metrics`. - Android URL/deep-link opens infer the foreground package after launch when possible, including Expo Go/dev-client shells. If the session still has no app package/bundle ID, package-bound metrics remain unavailable until you `open `. - Android frame health is reset after each successful `perf metrics` or `perf frames` read and after `open `, so run `perf frames`, perform the interaction, then run `perf frames` again for a focused window. +- Android Simpleperf and Perfetto collectors require an active Android app session with a running package process. They return artifact paths, sizes, and compact state summaries; they do not print profile or trace contents into the agent context. iOS native Simpleperf/Perfetto support is not provided by these commands. - On physical iOS devices, `perf metrics` and `perf frames` record short `xcrun xctrace` samples. Keep the device unlocked, connected, and the app active in the foreground while sampling. - Interpretation note: this startup metric is command round-trip timing and does not represent true first frame / first interactive app instrumentation. - CPU data is a lightweight process snapshot, so an idle app may legitimately read as `0`. diff --git a/website/docs/docs/debugging-profiling.md b/website/docs/docs/debugging-profiling.md index 50862999c..7910b987f 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -104,6 +104,11 @@ agent-device perf memory snapshot --kind memgraph --out app.memgraph agent-device perf cpu profile start --kind xctrace --template "Time Profiler" --out app.trace agent-device perf cpu profile stop --kind xctrace --out app.trace agent-device perf cpu profile report --kind xctrace --out app-profile.json +agent-device perf cpu profile start --kind simpleperf --out cpu.perf.data +agent-device perf cpu profile stop --kind simpleperf --out cpu.perf.data +agent-device perf cpu profile report --kind simpleperf --out cpu-report.json +agent-device perf trace start --kind perfetto --out app.perfetto-trace +agent-device perf trace stop --kind perfetto --out app.perfetto-trace ``` - `perf metrics` returns session-scoped startup and, where supported, CPU, memory, and frame-health samples. Bare `perf` and `metrics` remain aliases. @@ -113,6 +118,8 @@ agent-device perf cpu profile report --kind xctrace --out app-profile.json - `perf memory snapshot` escalates to file artifacts. Android supports Java HPROF capture for active app processes when the build/device allows heap dumping. iOS simulator and macOS app sessions support memgraph capture through host-visible process tooling; physical iOS device memgraph capture reports unavailable with a hint instead of pretending support. - Heap and memgraph artifacts are returned as paths plus compact metadata. Example default output: `Memory artifact (android-hprof): /tmp/app.hprof (42MB)`. They are not printed or embedded in JSON by default. heapprofd/native allocation tracing is deferred until Perfetto plumbing is available. - `perf cpu profile ... --kind xctrace` and `perf trace ... --kind xctrace` collect Apple native `.trace` artifacts for iOS/macOS app sessions and return only artifact paths plus compact metadata. +- Android native profiling uses `perf cpu profile ... --kind simpleperf`; Android native trace capture uses `perf trace ... --kind perfetto`. These commands require an active Android app session and return artifact paths/summaries instead of dumping profile or trace contents. +- Use the compact native perf result as agent evidence. For example, a successful Perfetto stop may return `state: "stopped"`, `outPath: "/tmp/app.perfetto-trace"`, `sizeBytes: 5392410`, and `method: "adb-shell-perfetto"` while the 5.3 MB raw trace remains on disk as the artifact. - Startup is measured around the `open` command; it is not first-frame instrumentation. - CPU, memory, and Android frame-health availability depend on platform and whether the active session is bound to an app/package. - On Android and supported Apple targets, use `metrics.fps.droppedFramePercent` for the health check and `metrics.fps.worstWindows` to line up jank clusters with logs, network activity, or recent actions.