From 6d333096ee46f46d79c0959da7733cdcda63db2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 10 Jun 2026 22:11:35 +0200 Subject: [PATCH 1/8] feat: add Android native perf profiling --- src/__tests__/cli-perf.test.ts | 67 +++ src/__tests__/client.test.ts | 34 ++ src/commands/runtime-output.ts | 64 ++- .../__tests__/session-observability.test.ts | 125 +++- src/daemon/handlers/session-native-perf.ts | 261 +++++++++ src/daemon/handlers/session-observability.ts | 26 +- src/daemon/types.ts | 4 + src/platforms/android/__tests__/perf.test.ts | 156 ++++- src/platforms/android/perf-native-report.ts | 22 + src/platforms/android/perf-native.ts | 542 ++++++++++++++++++ src/platforms/android/perf.ts | 15 + src/utils/args.ts | 1 - src/utils/cli-command-overrides.ts | 4 +- src/utils/cli-flags.ts | 2 +- src/utils/cli-help.ts | 2 +- .../suites/agent-device-smoke-suite.ts | 25 + website/docs/docs/client-api.md | 2 +- website/docs/docs/commands.md | 9 + website/docs/docs/debugging-profiling.md | 7 + 19 files changed, 1337 insertions(+), 31 deletions(-) create mode 100644 src/daemon/handlers/session-native-perf.ts create mode 100644 src/platforms/android/perf-native-report.ts create mode 100644 src/platforms/android/perf-native.ts diff --git a/src/__tests__/cli-perf.test.ts b/src/__tests__/cli-perf.test.ts index c25aab937..23524a9b5 100644 --- a/src/__tests__/cli-perf.test.ts +++ b/src/__tests__/cli-perf.test.ts @@ -335,6 +335,73 @@ 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, + }, + }), + ); + + 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)\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..eff5f9392 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -179,6 +179,40 @@ 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', + mode: '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']); + 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..a6f284848 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,56 @@ 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 +388,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-observability.test.ts b/src/daemon/handlers/__tests__/session-observability.test.ts index b366327e8..b14a9be4f 100644 --- a/src/daemon/handlers/__tests__/session-observability.test.ts +++ b/src/daemon/handlers/__tests__/session-observability.test.ts @@ -1,8 +1,10 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; +import { 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'; @@ -25,7 +27,6 @@ vi.mock('../../../platforms/ios/perf-xctrace.ts', async (importOriginal) => { }); import { handleSessionObservabilityCommands } from '../session-observability.ts'; -import type { AndroidAdbExecutor } from '../../../platforms/android/adb-executor.ts'; beforeEach(() => { vi.resetAllMocks(); @@ -681,3 +682,125 @@ 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, + }); + + assert.equal(startResponse?.ok, true); + if (!startResponse?.ok) throw new Error('Expected start response to succeed'); + assert.equal(startResponse.data?.kind, 'simpleperf'); + assert.equal(startResponse.data?.type, 'cpu-profile'); + assert.equal(startResponse.data?.state, 'running'); + assert.equal(startResponse.data?.outPath, outPath); + assert.equal(sessionStore.get('android')?.nativePerf?.android?.state, 'running'); + + const stopResponse = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'android', + command: 'perf', + positionals: ['cpu', 'profile', 'stop', 'simpleperf'], + flags: { out: outPath }, + }, + sessionName: 'android', + sessionStore, + androidAdbExecutor: adb, + }); + + assert.equal(stopResponse?.ok, true); + if (!stopResponse?.ok) throw new Error('Expected stop response to succeed'); + assert.equal(stopResponse.data?.state, 'stopped'); + assert.equal(stopResponse.data?.sizeBytes, 7); + assert.equal(sessionStore.get('android')?.nativePerf?.android?.state, 'stopped'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test('perf trace rejects non-Android sessions explicitly', 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, 'UNSUPPORTED_OPERATION'); + assert.match(response.error.message, /Android native perf collectors/); + } +}); + +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 { + return async (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: '' }; + } + if (args[0] === 'shell' && args[1]?.includes('kill -INT')) { + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (args[0] === 'pull') { + await fsPromises.writeFile(outPath, 'profile'); + return { exitCode: 0, stdout: '', stderr: '' }; + } + throw new Error(`Unexpected adb call: ${args.join(' ')}`); + }; +} diff --git a/src/daemon/handlers/session-native-perf.ts b/src/daemon/handlers/session-native-perf.ts new file mode 100644 index 000000000..0ba965d85 --- /dev/null +++ b/src/daemon/handlers/session-native-perf.ts @@ -0,0 +1,261 @@ +import path from 'node:path'; +import { + isPerfKind, + PERF_KIND_ERROR_MESSAGE, + isPerfSubject, + 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; + }; + +function resolveNativePerfRequest( + req: DaemonRequest, + area: 'cpu' | 'trace', +): NativePerfRequest | DaemonFailureResponse { + const outPath = readNativePerfOutPath(req, area); + if (area === 'cpu') { + const subject = (req.positionals?.[1] ?? '').toLowerCase(); + const action = (req.positionals?.[2] ?? '').toLowerCase(); + const kind = (req.positionals?.[3] ?? '').toLowerCase(); + if (!isPerfSubject(subject)) return errorResponse('INVALID_ARGS', PERF_SUBJECT_ERROR_MESSAGE); + if (action !== 'start' && action !== 'stop' && action !== 'report') { + 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, subject, action, kind, outPath }; + } + + const action = (req.positionals?.[1] ?? '').toLowerCase(); + const kind = (req.positionals?.[2] ?? '').toLowerCase(); + if (action !== 'start' && action !== 'stop') { + 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, action, kind, outPath }; +} + +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: { + sessionName: string; + sessionStore: SessionStore; + req: DaemonRequest; + session: SessionState; + androidAdbExecutor?: AndroidAdbExecutor; + }, + session: SessionState, + packageName: string, + request: Extract, +): Promise> { + if (request.action === 'start') { + const outPath = resolveNativePerfOutPath(params, request.outPath, 'cpu.perf.data'); + const result = await startAndroidSimpleperfProfile(session.device, packageName, outPath, { + adb: params.androidAdbExecutor, + }); + params.sessionStore.set(params.sessionName, { + ...session, + nativePerf: { android: result }, + }); + return compactNativePerfResponse(result); + } + + const active = requireAndroidNativePerfSession(session, 'cpu-profile', request.kind); + if (request.action === 'report') { + if (active.state === 'running') { + 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.', + }, + ); + } + const outPath = resolveNativePerfOutPath(params, request.outPath, 'cpu-report.json'); + return await writeAndroidSimpleperfReport(session.device, active, outPath, { + adb: params.androidAdbExecutor, + }); + } + + const outPath = resolveNativePerfOutPath(params, request.outPath, active.outPath); + const result = await stopAndroidSimpleperfProfile(session.device, active, outPath, { + adb: params.androidAdbExecutor, + }); + params.sessionStore.set(params.sessionName, { + ...session, + nativePerf: { android: result }, + }); + return compactNativePerfResponse(result); +} + +async function runAndroidTraceCommand( + params: { + sessionName: string; + sessionStore: SessionStore; + req: DaemonRequest; + session: SessionState; + androidAdbExecutor?: AndroidAdbExecutor; + }, + session: SessionState, + packageName: string, + request: Extract, +): Promise> { + if (request.action === 'start') { + const outPath = resolveNativePerfOutPath(params, request.outPath, 'app.perfetto-trace'); + const result = await startAndroidPerfettoTrace(session.device, packageName, outPath, { + adb: params.androidAdbExecutor, + }); + params.sessionStore.set(params.sessionName, { + ...session, + nativePerf: { android: result }, + }); + return compactNativePerfResponse(result); + } + + const active = requireAndroidNativePerfSession(session, 'trace', request.kind); + const outPath = resolveNativePerfOutPath(params, request.outPath, active.outPath); + const result = await stopAndroidPerfettoTrace(session.device, active, outPath, { + adb: params.androidAdbExecutor, + }); + params.sessionStore.set(params.sessionName, { + ...session, + nativePerf: { android: result }, + }); + return compactNativePerfResponse(result); +} + +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.', + }); +} + +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 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, + message: result.message, + }; +} diff --git a/src/daemon/handlers/session-observability.ts b/src/daemon/handlers/session-observability.ts index d4a27dcc5..047397902 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 { const sample = parseAndroidMemInfoSample( @@ -92,9 +89,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 +113,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 +140,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 +172,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 +332,123 @@ 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 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) => { + if (args[0] === 'shell' && args[1]?.includes('kill -INT')) { + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (args[0] === 'pull') { + await fsPromises.writeFile(args[2]!, 'profile'); + 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); +}); + +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/, + ); +}); 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..63e7d9d72 --- /dev/null +++ b/src/platforms/android/perf-native.ts @@ -0,0 +1,542 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { DeviceInfo } from '../../utils/device.ts'; +import { AppError } from '../../utils/errors.ts'; +import { resolveAndroidAdbExecutor, type AndroidAdbExecutor } from './adb-executor.ts'; +import { parseSimpleperfReportEntries } from './perf-native-report.ts'; + +export const ANDROID_SIMPLEPERF_METHOD = 'adb-shell-simpleperf'; +export 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; + +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; + }; + message: 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'); + const profilerPid = await startAndroidBackgroundTool( + adb, + buildSimpleperfStartCommand(appPid, remotePath), + 'simpleperf', + packageName, + ); + 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, + ); + const profilerPid = await startAndroidPerfettoBackgroundTool(adb, remotePath, packageName); + 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); +} + +async function stopAndroidNativePerfSession( + device: DeviceInfo, + session: AndroidNativePerfSession, + options: AndroidNativePerfOptions, +): Promise { + const adb = resolveAndroidAdbExecutor(device, options.adb); + await stopAndroidBackgroundTool(adb, session); + await pullAndroidNativeArtifact(adb, session); + const sizeBytes = await readFileSize(session.outPath); + const stoppedAt = Date.now(); + return { + ...session, + action: 'stop', + platform: 'android', + state: 'stopped', + stoppedAt, + durationMs: Math.max(0, stoppedAt - session.startedAt), + sizeBytes, + method: session.kind === 'simpleperf' ? ANDROID_SIMPLEPERF_METHOD : ANDROID_PERFETTO_METHOD, + artifact: { + path: session.outPath, + sizeBytes, + }, + message: `Stopped Android ${session.kind} ${session.type} for ${session.packageName}`, + }; +} + +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 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)], { + allowFailure: true, + 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', + ].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 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') { + if ( + text.includes('permission denied') || + text.includes('not profileable') || + text.includes('profileable') + ) { + 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.'; + } + } + if (tool === 'perfetto' && (text.includes('permission denied') || text.includes('not allowed'))) { + 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 shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} diff --git a/src/platforms/android/perf.ts b/src/platforms/android/perf.ts index 97384d291..b82e02684 100644 --- a/src/platforms/android/perf.ts +++ b/src/platforms/android/perf.ts @@ -15,6 +15,21 @@ export { type AndroidFrameDropWindow, type AndroidFramePerfSample, } from './perf-frame.ts'; +export { + ANDROID_PERFETTO_METHOD, + ANDROID_SIMPLEPERF_METHOD, + 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/args.ts b/src/utils/args.ts index 863e5fdcc..c28febebe 100644 --- a/src/utils/args.ts +++ b/src/utils/args.ts @@ -5,7 +5,6 @@ import { getCommandSchema, getFlagDefinition, type CliFlags, - type FlagDefinition, type FlagKey, } from './command-schema.ts'; import { buildCommandUsageText, buildUsageText } from './cli-help.ts'; 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..b33beb1ba 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. 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: 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..1fe4e804b 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', mode: '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..1e175aa47 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,6 +610,9 @@ 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. - `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: @@ -628,6 +636,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..1f4ab4e0e 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. +- Example native trace output: `Perf stop: perfetto trace state=stopped` plus `/tmp/app.perfetto-trace (5.1MB)`. The raw trace is the artifact, not agent context. - 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. From d20f0b9abba51bbdb6d1a94c518e1548cbc13fa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 11 Jun 2026 09:09:58 +0200 Subject: [PATCH 2/8] fix: satisfy fallow for Android perf profiling --- .../__tests__/session-observability.test.ts | 37 ++-- src/daemon/handlers/session-native-perf.ts | 185 ++++++++++-------- src/platforms/android/perf-native.ts | 41 ++-- src/platforms/android/perf.ts | 2 - 4 files changed, 150 insertions(+), 115 deletions(-) diff --git a/src/daemon/handlers/__tests__/session-observability.test.ts b/src/daemon/handlers/__tests__/session-observability.test.ts index b14a9be4f..a0e4c853a 100644 --- a/src/daemon/handlers/__tests__/session-observability.test.ts +++ b/src/daemon/handlers/__tests__/session-observability.test.ts @@ -9,6 +9,7 @@ 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(), @@ -25,7 +26,6 @@ vi.mock('../../../platforms/ios/perf-xctrace.ts', async (importOriginal) => { writeAppleXctracePerfReport: applePerfMocks.writeAppleXctracePerfReport, }; }); - import { handleSessionObservabilityCommands } from '../session-observability.ts'; beforeEach(() => { @@ -706,13 +706,12 @@ test('perf cpu profile start and stop route through Android simpleperf and prese androidAdbExecutor: adb, }); - assert.equal(startResponse?.ok, true); - if (!startResponse?.ok) throw new Error('Expected start response to succeed'); - assert.equal(startResponse.data?.kind, 'simpleperf'); - assert.equal(startResponse.data?.type, 'cpu-profile'); - assert.equal(startResponse.data?.state, 'running'); - assert.equal(startResponse.data?.outPath, outPath); - assert.equal(sessionStore.get('android')?.nativePerf?.android?.state, 'running'); + 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: { @@ -727,11 +726,10 @@ test('perf cpu profile start and stop route through Android simpleperf and prese androidAdbExecutor: adb, }); - assert.equal(stopResponse?.ok, true); - if (!stopResponse?.ok) throw new Error('Expected stop response to succeed'); - assert.equal(stopResponse.data?.state, 'stopped'); - assert.equal(stopResponse.data?.sizeBytes, 7); - assert.equal(sessionStore.get('android')?.nativePerf?.android?.state, 'stopped'); + const stopData = requireOkData(stopResponse, 'Expected stop response to succeed'); + assert.equal(stopData.state, 'stopped'); + assert.equal(stopData.sizeBytes, 7); + assert.equal(readAndroidNativePerfState(sessionStore, 'android'), 'stopped'); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } @@ -804,3 +802,16 @@ function makeNativePerfAdbExecutor(outPath: string): AndroidAdbExecutor { throw new Error(`Unexpected adb call: ${args.join(' ')}`); }; } + +function requireOkData(response: DaemonResponse | null, message: string): Record { + assert.equal(response?.ok, true); + 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; +} diff --git a/src/daemon/handlers/session-native-perf.ts b/src/daemon/handlers/session-native-perf.ts index 0ba965d85..f0ad065aa 100644 --- a/src/daemon/handlers/session-native-perf.ts +++ b/src/daemon/handlers/session-native-perf.ts @@ -1,8 +1,8 @@ import path from 'node:path'; import { isPerfKind, - PERF_KIND_ERROR_MESSAGE, isPerfSubject, + PERF_KIND_ERROR_MESSAGE, PERF_SUBJECT_ERROR_MESSAGE, } from '../../contracts/perf.ts'; import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts'; @@ -75,38 +75,87 @@ type NativePerfRequest = 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); - if (area === 'cpu') { - const subject = (req.positionals?.[1] ?? '').toLowerCase(); - const action = (req.positionals?.[2] ?? '').toLowerCase(); - const kind = (req.positionals?.[3] ?? '').toLowerCase(); - if (!isPerfSubject(subject)) return errorResponse('INVALID_ARGS', PERF_SUBJECT_ERROR_MESSAGE); - if (action !== 'start' && action !== 'stop' && action !== 'report') { - 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, subject, action, kind, outPath }; + 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 }; +} - const action = (req.positionals?.[1] ?? '').toLowerCase(); - const kind = (req.positionals?.[2] ?? '').toLowerCase(); - if (action !== 'start' && action !== 'stop') { +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, action, kind, outPath }; + 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 { @@ -118,13 +167,7 @@ function readNativePerfOutPath(req: DaemonRequest, area: 'cpu' | 'trace'): strin } async function runAndroidCpuProfileCommand( - params: { - sessionName: string; - sessionStore: SessionStore; - req: DaemonRequest; - session: SessionState; - androidAdbExecutor?: AndroidAdbExecutor; - }, + params: NativePerfHandlerParams, session: SessionState, packageName: string, request: Extract, @@ -134,24 +177,12 @@ async function runAndroidCpuProfileCommand( const result = await startAndroidSimpleperfProfile(session.device, packageName, outPath, { adb: params.androidAdbExecutor, }); - params.sessionStore.set(params.sessionName, { - ...session, - nativePerf: { android: result }, - }); - return compactNativePerfResponse(result); + return storeNativePerfSession(params, result); } const active = requireAndroidNativePerfSession(session, 'cpu-profile', request.kind); if (request.action === 'report') { - if (active.state === 'running') { - 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.', - }, - ); - } + await assertStoppedSimpleperfProfile(active); const outPath = resolveNativePerfOutPath(params, request.outPath, 'cpu-report.json'); return await writeAndroidSimpleperfReport(session.device, active, outPath, { adb: params.androidAdbExecutor, @@ -162,21 +193,11 @@ async function runAndroidCpuProfileCommand( const result = await stopAndroidSimpleperfProfile(session.device, active, outPath, { adb: params.androidAdbExecutor, }); - params.sessionStore.set(params.sessionName, { - ...session, - nativePerf: { android: result }, - }); - return compactNativePerfResponse(result); + return storeNativePerfSession(params, result); } async function runAndroidTraceCommand( - params: { - sessionName: string; - sessionStore: SessionStore; - req: DaemonRequest; - session: SessionState; - androidAdbExecutor?: AndroidAdbExecutor; - }, + params: NativePerfHandlerParams, session: SessionState, packageName: string, request: Extract, @@ -186,11 +207,7 @@ async function runAndroidTraceCommand( const result = await startAndroidPerfettoTrace(session.device, packageName, outPath, { adb: params.androidAdbExecutor, }); - params.sessionStore.set(params.sessionName, { - ...session, - nativePerf: { android: result }, - }); - return compactNativePerfResponse(result); + return storeNativePerfSession(params, result); } const active = requireAndroidNativePerfSession(session, 'trace', request.kind); @@ -198,35 +215,18 @@ async function runAndroidTraceCommand( const result = await stopAndroidPerfettoTrace(session.device, active, outPath, { adb: params.androidAdbExecutor, }); - params.sessionStore.set(params.sessionName, { - ...session, - nativePerf: { android: result }, - }); - return compactNativePerfResponse(result); + return storeNativePerfSession(params, result); } -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.', - }); -} - -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 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 pathJoinSessionArtifact( @@ -259,3 +259,18 @@ function compactNativePerfResponse(result: AndroidNativePerfSession & Record Date: Thu, 11 Jun 2026 12:15:12 +0200 Subject: [PATCH 3/8] fix: harden Android native perf lifecycle --- .../__tests__/session-observability.test.ts | 113 +++++++++++++++++- src/daemon/handlers/session-native-perf.ts | 31 ++++- src/platforms/android/__tests__/perf.test.ts | 96 +++++++++++++++ src/platforms/android/perf-native.ts | 96 +++++++++++++-- 4 files changed, 324 insertions(+), 12 deletions(-) diff --git a/src/daemon/handlers/__tests__/session-observability.test.ts b/src/daemon/handlers/__tests__/session-observability.test.ts index a0e4c853a..c7e5a5234 100644 --- a/src/daemon/handlers/__tests__/session-observability.test.ts +++ b/src/daemon/handlers/__tests__/session-observability.test.ts @@ -719,7 +719,7 @@ test('perf cpu profile start and stop route through Android simpleperf and prese session: 'android', command: 'perf', positionals: ['cpu', 'profile', 'stop', 'simpleperf'], - flags: { out: outPath }, + flags: {}, }, sessionName: 'android', sessionStore, @@ -728,6 +728,105 @@ test('perf cpu profile start and stop route through Android simpleperf and prese 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.equal(readAndroidNativePerfState(sessionStore, 'android'), 'stopped'); } finally { @@ -789,12 +888,24 @@ function makeNativePerfAdbExecutor(outPath: string): AndroidAdbExecutor { 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('command -v perfetto')) { + return { exitCode: 0, stdout: '/system/bin/perfetto\n', stderr: '' }; + } if (args[0] === 'shell' && args[1]?.includes('simpleperf')) { return { exitCode: 0, stdout: '5678\n', stderr: '' }; } + if (args[0] === 'shell' && args[1] === 'perfetto') { + return { exitCode: 0, stdout: '5678\n', stderr: '' }; + } 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] === 'shell' && args[1]?.includes('rm -f')) { + return { exitCode: 0, stdout: '', stderr: '' }; + } if (args[0] === 'pull') { await fsPromises.writeFile(outPath, 'profile'); return { exitCode: 0, stdout: '', stderr: '' }; diff --git a/src/daemon/handlers/session-native-perf.ts b/src/daemon/handlers/session-native-perf.ts index f0ad065aa..5ca64d5ea 100644 --- a/src/daemon/handlers/session-native-perf.ts +++ b/src/daemon/handlers/session-native-perf.ts @@ -173,6 +173,7 @@ async function runAndroidCpuProfileCommand( 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, @@ -189,7 +190,7 @@ async function runAndroidCpuProfileCommand( }); } - const outPath = resolveNativePerfOutPath(params, request.outPath, active.outPath); + const outPath = resolveNativePerfStopOutPath(params, request.outPath, active); const result = await stopAndroidSimpleperfProfile(session.device, active, outPath, { adb: params.androidAdbExecutor, }); @@ -203,6 +204,7 @@ async function runAndroidTraceCommand( 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, @@ -211,7 +213,7 @@ async function runAndroidTraceCommand( } const active = requireAndroidNativePerfSession(session, 'trace', request.kind); - const outPath = resolveNativePerfOutPath(params, request.outPath, active.outPath); + const outPath = resolveNativePerfStopOutPath(params, request.outPath, active); const result = await stopAndroidPerfettoTrace(session.device, active, outPath, { adb: params.androidAdbExecutor, }); @@ -229,6 +231,31 @@ function assertStoppedSimpleperfProfile(active: AndroidNativePerfSession): void ); } +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, diff --git a/src/platforms/android/__tests__/perf.test.ts b/src/platforms/android/__tests__/perf.test.ts index f9c2f56e8..b1c668f04 100644 --- a/src/platforms/android/__tests__/perf.test.ts +++ b/src/platforms/android/__tests__/perf.test.ts @@ -9,7 +9,9 @@ import { captureAndroidHeapSnapshot, parseAndroidFramePerfSample, parseAndroidMemInfoSample, + startAndroidPerfettoTrace, startAndroidSimpleperfProfile, + stopAndroidPerfettoTrace, stopAndroidSimpleperfProfile, writeAndroidSimpleperfReport, type AndroidNativePerfSession, @@ -366,6 +368,7 @@ test('startAndroidSimpleperfProfile resolves pid and starts a bounded simpleperf 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', @@ -378,13 +381,20 @@ test('stopAndroidSimpleperfProfile pulls the profile artifact and reports compac 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(' ')}`); }; @@ -393,6 +403,88 @@ test('stopAndroidSimpleperfProfile pulls the profile artifact and reports compac 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 fs.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('start and stop Android Perfetto trace use perfetto trace storage and cleanup remote artifact', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-perfetto-test-')); + const outPath = path.join(tmpDir, 'app.perfetto-trace'); + 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 perfetto')) { + return { exitCode: 0, stdout: '/system/bin/perfetto\n', stderr: '' }; + } + if (args[0] === 'shell' && args[1] === 'perfetto') { + return { exitCode: 0, stdout: '8765\n', stderr: '' }; + } + 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] === 'pull') { + await fs.writeFile(args[2]!, 'trace'); + 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 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.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 () => { @@ -452,3 +544,7 @@ test('startAndroidSimpleperfProfile fails with an actionable missing-process hin /No active Android app process/, ); }); + +function findCallIndex(calls: string[][], pattern: string): number { + return calls.findIndex((args) => args.some((arg) => arg.includes(pattern))); +} diff --git a/src/platforms/android/perf-native.ts b/src/platforms/android/perf-native.ts index 1be552e8b..71ff54654 100644 --- a/src/platforms/android/perf-native.ts +++ b/src/platforms/android/perf-native.ts @@ -1,5 +1,6 @@ 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'; @@ -13,6 +14,8 @@ 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; @@ -81,12 +84,18 @@ export async function startAndroidSimpleperfProfile( const appPid = await resolveAndroidAppPid(adb, packageName); await assertAndroidToolAvailable(adb, 'simpleperf', packageName); const remotePath = buildAndroidNativeRemotePath(packageName, 'cpu.perf.data'); - const profilerPid = await startAndroidBackgroundTool( - adb, - buildSimpleperfStartCommand(appPid, remotePath), - 'simpleperf', - packageName, - ); + 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', @@ -170,7 +179,13 @@ export async function startAndroidPerfettoTrace( 'app.perfetto-trace', ANDROID_PERFETTO_REMOTE_DIR, ); - const profilerPid = await startAndroidPerfettoBackgroundTool(adb, remotePath, packageName); + let profilerPid: string; + try { + profilerPid = await startAndroidPerfettoBackgroundTool(adb, remotePath, packageName); + } catch (error) { + await cleanupAndroidRemotePath(adb, remotePath); + throw error; + } const session = { type: 'trace', kind: 'perfetto', @@ -207,8 +222,10 @@ async function stopAndroidNativePerfSession( ): 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(); return { ...session, @@ -298,7 +315,7 @@ function buildBackgroundShellCommand(argv: string[], label: string): string { `err=${shellQuote(stderrPath)}`, `(${command}) >/dev/null 2>"$err" & pid=$!`, 'sleep 1', - 'if kill -0 "$pid" 2>/dev/null; then echo "$pid"; exit 0; fi', + 'if kill -0 "$pid" 2>/dev/null; then rm -f "$err"; echo "$pid"; exit 0; fi', 'cat "$err" >&2', 'rm -f "$err"', 'exit 1', @@ -375,7 +392,6 @@ async function stopAndroidBackgroundTool( ): Promise { try { await adb(['shell', buildStopProfilerCommand(session.profilerPid)], { - allowFailure: true, timeoutMs: ANDROID_NATIVE_PROFILE_TIMEOUT_MS, }); } catch (error) { @@ -389,6 +405,9 @@ function buildStopProfilerCommand(pid: string): string { '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('; '); } @@ -424,6 +443,65 @@ async function pullAndroidNativeArtifact( } } +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, From a26afb45aef3f513f75594dd4e25cf7f3be0dea8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 11 Jun 2026 12:24:30 +0200 Subject: [PATCH 4/8] test: reduce native perf mock complexity --- .../__tests__/session-observability.test.ts | 94 ++++++++++++------- src/platforms/android/__tests__/perf.test.ts | 93 ++++++++++++------ 2 files changed, 127 insertions(+), 60 deletions(-) diff --git a/src/daemon/handlers/__tests__/session-observability.test.ts b/src/daemon/handlers/__tests__/session-observability.test.ts index c7e5a5234..159d2ff0a 100644 --- a/src/daemon/handlers/__tests__/session-observability.test.ts +++ b/src/daemon/handlers/__tests__/session-observability.test.ts @@ -881,41 +881,22 @@ test('perf cpu profile reports a missing package with an actionable hint', async }); function makeNativePerfAdbExecutor(outPath: string): AndroidAdbExecutor { - return async (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('command -v perfetto')) { - return { exitCode: 0, stdout: '/system/bin/perfetto\n', stderr: '' }; - } - if (args[0] === 'shell' && args[1]?.includes('simpleperf')) { - return { exitCode: 0, stdout: '5678\n', stderr: '' }; - } - if (args[0] === 'shell' && args[1] === 'perfetto') { - return { exitCode: 0, stdout: '5678\n', stderr: '' }; - } - 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] === 'shell' && args[1]?.includes('rm -f')) { - return { exitCode: 0, stdout: '', stderr: '' }; - } - if (args[0] === 'pull') { - await fsPromises.writeFile(outPath, 'profile'); - return { exitCode: 0, stdout: '', stderr: '' }; - } - throw new Error(`Unexpected adb call: ${args.join(' ')}`); - }; + 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(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'), + ]; + return async (args) => dispatchAdbResponse(args, responders); } function requireOkData(response: DaemonResponse | null, message: string): Record { - assert.equal(response?.ok, true); + assert.equal(response?.ok, true, JSON.stringify(response)); if (!response?.ok) throw new Error(message); return response.data ?? {}; } @@ -926,3 +907,52 @@ function readAndroidNativePerfState( ): 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/platforms/android/__tests__/perf.test.ts b/src/platforms/android/__tests__/perf.test.ts index b1c668f04..c662786fe 100644 --- a/src/platforms/android/__tests__/perf.test.ts +++ b/src/platforms/android/__tests__/perf.test.ts @@ -408,7 +408,9 @@ test('stopAndroidSimpleperfProfile pulls the profile artifact and reports compac }); test('stopAndroidSimpleperfProfile fails before pull when remote artifact never stabilizes', async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-simpleperf-missing-test-')); + const tmpDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), 'agent-device-simpleperf-missing-test-'), + ); const session: AndroidNativePerfSession = { type: 'cpu-profile', kind: 'simpleperf', @@ -443,35 +445,10 @@ test('stopAndroidSimpleperfProfile fails before pull when remote artifact never }); test('start and stop Android Perfetto trace use perfetto trace storage and cleanup remote artifact', async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-perfetto-test-')); + 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: 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 perfetto')) { - return { exitCode: 0, stdout: '/system/bin/perfetto\n', stderr: '' }; - } - if (args[0] === 'shell' && args[1] === 'perfetto') { - return { exitCode: 0, stdout: '8765\n', stderr: '' }; - } - 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] === 'pull') { - await fs.writeFile(args[2]!, 'trace'); - 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 adb = makePerfettoTraceAdbExecutor(outPath, calls); const started = await startAndroidPerfettoTrace(ANDROID_EMULATOR, 'com.example.app', outPath, { adb, @@ -548,3 +525,63 @@ test('startAndroidSimpleperfProfile fails with an actionable missing-process hin function findCallIndex(calls: string[][], pattern: string): number { return calls.findIndex((args) => args.some((arg) => arg.includes(pattern))); } + +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(adbArgsPrefix('shell', 'perfetto'), '8765\n'), + staticAdbResponse(containsAdbArg('kill -INT')), + staticAdbResponse(containsAdbArg('stat -c %s'), '5\n'), + pullAdbResponse(outPath, 'trace'), + 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)); +} From db40fbb79935157126076ebb880f699967f2ec7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 11 Jun 2026 14:54:49 +0200 Subject: [PATCH 5/8] docs: show compact native perf evidence example --- src/utils/__tests__/args.test.ts | 5 +++++ src/utils/cli-help.ts | 10 ++++++++-- website/docs/docs/commands.md | 1 + website/docs/docs/debugging-profiling.md | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) 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-help.ts b/src/utils/cli-help.ts index b33beb1ba..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. 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. 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. + 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/website/docs/docs/commands.md b/website/docs/docs/commands.md index 1e175aa47..93c6ee568 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -614,6 +614,7 @@ agent-device perf trace stop --kind perfetto --out app.perfetto-trace - `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 diff --git a/website/docs/docs/debugging-profiling.md b/website/docs/docs/debugging-profiling.md index 1f4ab4e0e..7910b987f 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -119,7 +119,7 @@ agent-device perf trace stop --kind perfetto --out app.perfetto-trace - 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. -- Example native trace output: `Perf stop: perfetto trace state=stopped` plus `/tmp/app.perfetto-trace (5.1MB)`. The raw trace is the artifact, not agent context. +- 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. From 6a56f8d9f9f741493a790b09190248a9391bd90c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 11 Jun 2026 15:25:16 +0200 Subject: [PATCH 6/8] fix: align perf rebase validation --- .../__tests__/session-observability.test.ts | 3 +-- src/daemon/handlers/session-observability.ts | 24 ++++++++++++------- src/platforms/android/__tests__/perf.test.ts | 3 +-- src/utils/args.ts | 1 + 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/daemon/handlers/__tests__/session-observability.test.ts b/src/daemon/handlers/__tests__/session-observability.test.ts index 159d2ff0a..5b35275a5 100644 --- a/src/daemon/handlers/__tests__/session-observability.test.ts +++ b/src/daemon/handlers/__tests__/session-observability.test.ts @@ -1,6 +1,5 @@ import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import { promises as fsPromises } 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'; diff --git a/src/daemon/handlers/session-observability.ts b/src/daemon/handlers/session-observability.ts index 047397902..aa301efcb 100644 --- a/src/daemon/handlers/session-observability.ts +++ b/src/daemon/handlers/session-observability.ts @@ -196,7 +196,7 @@ async function buildPerfCommandData( const { sessionName, sessionStore, androidAdbExecutor } = params; if (request.area === 'memory') { return await buildPerfMemoryResponseData(session, { - action: request.action, + action: toPerfMemoryAction(request.action), kind: request.kind, out: request.out, cwd: params.req.meta?.cwd, @@ -233,14 +233,22 @@ function validatePerfAreaAction( area: Exclude, 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/platforms/android/__tests__/perf.test.ts b/src/platforms/android/__tests__/perf.test.ts index c662786fe..9423c1ba1 100644 --- a/src/platforms/android/__tests__/perf.test.ts +++ b/src/platforms/android/__tests__/perf.test.ts @@ -1,6 +1,5 @@ import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import { promises as fsPromises } 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'; diff --git a/src/utils/args.ts b/src/utils/args.ts index c28febebe..863e5fdcc 100644 --- a/src/utils/args.ts +++ b/src/utils/args.ts @@ -5,6 +5,7 @@ import { getCommandSchema, getFlagDefinition, type CliFlags, + type FlagDefinition, type FlagKey, } from './command-schema.ts'; import { buildCommandUsageText, buildUsageText } from './cli-help.ts'; From 5795c9404e1fffb37396c7c2d8d5666c53600e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 11 Jun 2026 17:24:35 +0200 Subject: [PATCH 7/8] fix: summarize Android perfetto artifacts --- src/__tests__/cli-perf.test.ts | 19 +++- src/__tests__/client.test.ts | 11 ++- src/commands/runtime-output.ts | 4 +- .../__tests__/session-observability.test.ts | 48 ++++++++-- src/daemon/handlers/session-native-perf.ts | 1 + src/platforms/android/__tests__/perf.test.ts | 33 +++++++ src/platforms/android/perf-native.ts | 96 ++++++++++++++++++- 7 files changed, 197 insertions(+), 15 deletions(-) diff --git a/src/__tests__/cli-perf.test.ts b/src/__tests__/cli-perf.test.ts index 23524a9b5..6dd27a4dc 100644 --- a/src/__tests__/cli-perf.test.ts +++ b/src/__tests__/cli-perf.test.ts @@ -366,7 +366,14 @@ test('perf cpu profile start forwards simpleperf kind and out path', async () => 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.deepEqual(call.positionals, [ + 'cpu', + 'profile', + 'start', + 'simpleperf', + '', + 'cpu.perf.data', + ]); assert.ok(call.flags); assert.equal(call.flags.out, 'cpu.perf.data'); }); @@ -383,6 +390,14 @@ test('perf trace stop forwards perfetto kind and prints compact artifact summary state: 'stopped', outPath: '/tmp/app.perfetto-trace', sizeBytes: 2048, + summary: { + frameHealth: { + available: true, + droppedFramePercent: 12.5, + droppedFrameCount: 3, + totalFrameCount: 24, + }, + }, }, }), ); @@ -398,7 +413,7 @@ test('perf trace stop forwards perfetto kind and prints compact artifact summary ]); assert.equal( result.stdout, - 'Perf stop: perfetto trace state=stopped\n/tmp/app.perfetto-trace (2.0KB)\n', + 'Perf stop: perfetto trace state=stopped\n/tmp/app.perfetto-trace (2.0KB)\nTrace frame health: dropped 12.5% (3/24 frames)\n', ); }); diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index eff5f9392..8cda638ad 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -198,7 +198,7 @@ test('observability.perf projects structured Android native profile input to dae await client.observability.perf({ area: 'cpu', - mode: 'profile', + subject: 'profile', action: 'start', kind: 'simpleperf', out: 'cpu.perf.data', @@ -208,7 +208,14 @@ test('observability.perf projects structured Android native profile input to dae const call = setup.calls[0]; assert.ok(call); assert.equal(call.command, 'perf'); - assert.deepEqual(call.positionals, ['cpu', 'profile', 'start', 'simpleperf']); + assert.deepEqual(call.positionals, [ + 'cpu', + 'profile', + 'start', + 'simpleperf', + '', + 'cpu.perf.data', + ]); assert.ok(call.flags); assert.equal(call.flags.out, 'cpu.perf.data'); }); diff --git a/src/commands/runtime-output.ts b/src/commands/runtime-output.ts index a6f284848..df167def7 100644 --- a/src/commands/runtime-output.ts +++ b/src/commands/runtime-output.ts @@ -224,7 +224,9 @@ function formatNativePerfLines( 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)}`; + return `Perf ${summary.action}: ${summary.kind} ${summary.type}${formatNativePerfState( + data, + )}${formatNativePerfArtifact(data)}${formatNativePerfFrameHealth(data)}`; } function readNativePerfSummary( diff --git a/src/daemon/handlers/__tests__/session-observability.test.ts b/src/daemon/handlers/__tests__/session-observability.test.ts index 5b35275a5..b48494575 100644 --- a/src/daemon/handlers/__tests__/session-observability.test.ts +++ b/src/daemon/handlers/__tests__/session-observability.test.ts @@ -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); }); @@ -827,13 +824,33 @@ test('perf trace start and stop route through Android perfetto and preserve comp 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 non-Android sessions explicitly', async () => { +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' })); @@ -851,8 +868,8 @@ test('perf trace rejects non-Android sessions explicitly', async () => { assert.equal(response?.ok, false); if (response && !response.ok) { - assert.equal(response.error.code, 'UNSUPPORTED_OPERATION'); - assert.match(response.error.message, /Android native perf collectors/); + assert.equal(response.error.code, 'INVALID_ARGS'); + assert.match(response.error.message, /supports --kind xctrace/); } }); @@ -884,12 +901,25 @@ function makeNativePerfAdbExecutor(outPath: string): AndroidAdbExecutor { 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); } diff --git a/src/daemon/handlers/session-native-perf.ts b/src/daemon/handlers/session-native-perf.ts index 5ca64d5ea..4b5972b5a 100644 --- a/src/daemon/handlers/session-native-perf.ts +++ b/src/daemon/handlers/session-native-perf.ts @@ -283,6 +283,7 @@ function compactNativePerfResponse(result: AndroidNativePerfSession & Record findCallIndex(calls, 'pull')); }); @@ -525,14 +537,35 @@ 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); diff --git a/src/platforms/android/perf-native.ts b/src/platforms/android/perf-native.ts index 71ff54654..fc233707b 100644 --- a/src/platforms/android/perf-native.ts +++ b/src/platforms/android/perf-native.ts @@ -4,6 +4,7 @@ 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'; @@ -55,9 +56,41 @@ export type AndroidNativePerfStopResult = AndroidNativePerfSession & { 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'; @@ -181,6 +214,7 @@ export async function startAndroidPerfettoTrace( ); let profilerPid: string; try { + await resetAndroidFramePerfStats(device, packageName, { adb }); profilerPid = await startAndroidPerfettoBackgroundTool(adb, remotePath, packageName); } catch (error) { await cleanupAndroidRemotePath(adb, remotePath); @@ -227,23 +261,83 @@ async function stopAndroidNativePerfSession( 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: Math.max(0, stoppedAt - session.startedAt), + 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], { From 66bb3bc804da570990e0484e345ea6edead82fad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 12 Jun 2026 09:04:25 +0200 Subject: [PATCH 8/8] fix: clean up Android native perf on session close --- .../__tests__/session-close-shutdown.test.ts | 87 +++++++++++++++++++ src/daemon/handlers/session-close.ts | 10 +++ src/platforms/android/__tests__/perf.test.ts | 41 +++++++++ src/platforms/android/perf-native.ts | 16 ++++ src/platforms/android/perf.ts | 1 + website/docs/docs/client-api.md | 2 +- 6 files changed, 156 insertions(+), 1 deletion(-) 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/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/platforms/android/__tests__/perf.test.ts b/src/platforms/android/__tests__/perf.test.ts index 5131ff37c..44d46dcc6 100644 --- a/src/platforms/android/__tests__/perf.test.ts +++ b/src/platforms/android/__tests__/perf.test.ts @@ -6,6 +6,7 @@ import { test } from 'vitest'; import type { AndroidAdbExecutor } from '../adb-executor.ts'; import { captureAndroidHeapSnapshot, + cleanupAndroidNativePerfSession, parseAndroidFramePerfSample, parseAndroidMemInfoSample, startAndroidPerfettoTrace, @@ -443,6 +444,46 @@ test('stopAndroidSimpleperfProfile fails before pull when remote artifact never ); }); +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'); diff --git a/src/platforms/android/perf-native.ts b/src/platforms/android/perf-native.ts index fc233707b..2d861823a 100644 --- a/src/platforms/android/perf-native.ts +++ b/src/platforms/android/perf-native.ts @@ -249,6 +249,22 @@ export async function stopAndroidPerfettoTrace( 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, diff --git a/src/platforms/android/perf.ts b/src/platforms/android/perf.ts index fbb1634ec..a80cb3dc9 100644 --- a/src/platforms/android/perf.ts +++ b/src/platforms/android/perf.ts @@ -16,6 +16,7 @@ export { type AndroidFramePerfSample, } from './perf-frame.ts'; export { + cleanupAndroidNativePerfSession, startAndroidPerfettoTrace, startAndroidSimpleperfProfile, stopAndroidPerfettoTrace, diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 1fe4e804b..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. Android native artifacts use `{ area: 'cpu', mode: '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. +`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.