From 77c1336f82328da93b018cb0f2a495a56c620997 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/2] feat: add Android native perf profiling --- src/__tests__/cli-perf.test.ts | 52 +- src/__tests__/client.test.ts | 34 ++ src/client-types.ts | 7 +- src/commands/cli-grammar/metro.ts | 10 +- src/commands/cli-grammar/observability.ts | 78 ++- src/commands/client-command-metadata.ts | 13 +- src/commands/runtime-output.ts | 24 + src/contracts/perf.ts | 28 +- .../__tests__/session-observability.test.ts | 121 ++++ src/daemon/handlers/session-native-perf.ts | 256 +++++++++ src/daemon/handlers/session-observability.ts | 16 +- src/daemon/types.ts | 4 + src/platforms/android/__tests__/perf.test.ts | 132 ++++- 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 | 14 +- src/utils/cli-command-overrides.ts | 13 +- src/utils/cli-flags.ts | 9 + 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 | 8 + website/docs/docs/debugging-profiling.md | 6 + 24 files changed, 1406 insertions(+), 27 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 362bf2c3a..495af0a9e 100644 --- a/src/__tests__/cli-perf.test.ts +++ b/src/__tests__/cli-perf.test.ts @@ -143,7 +143,7 @@ test('perf area and action positionals are case-insensitive', async () => { }); test('perf rejects unknown CLI area before daemon dispatch', async () => { - const result = await runCliCapture(['perf', 'cpu', '--json'], async () => ({ + const result = await runCliCapture(['perf', 'gpu', '--json'], async () => ({ ok: true, data: {}, })); @@ -152,7 +152,55 @@ test('perf rejects unknown CLI area before daemon dispatch', async () => { 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 or frames/i); + assert.match(payload.error.message, /perf area must be metrics, frames, 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']); + 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']); + 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 () => { diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 2fe4857b8..fc384b4ec 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -147,6 +147,40 @@ test('observability.perf projects structured frame area to daemon positionals', assert.deepEqual(setup.calls[0]?.positionals, ['frames', 'sample']); }); +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/client-types.ts b/src/client-types.ts index fbeff5646..ad02a9976 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -40,7 +40,7 @@ import type { import type { MetroBridgeScope } from './client-companion-tunnel-contract.ts'; import type { AppsFilter } from './contracts/app-inventory.ts'; import type { ScreenshotRequestFlags } from './contracts/screenshot.ts'; -import type { PerfAction, PerfArea } from './contracts/perf.ts'; +import type { PerfAction, PerfCommandArea, PerfCpuMode, PerfKind } from './contracts/perf.ts'; import type { DaemonBatchStep } from './core/batch.ts'; import type { AlertAction, AlertInfo } from './alert-contract.ts'; @@ -740,8 +740,11 @@ export type BatchRunOptions = AgentDeviceRequestOverrides & { }; export type PerfOptions = DeviceCommandBaseOptions & { - area?: PerfArea; + area?: PerfCommandArea; + mode?: PerfCpuMode; action?: PerfAction; + kind?: PerfKind; + out?: string; }; export type LogsOptions = AgentDeviceRequestOverrides & { diff --git a/src/commands/cli-grammar/metro.ts b/src/commands/cli-grammar/metro.ts index e4982c462..60cd2735c 100644 --- a/src/commands/cli-grammar/metro.ts +++ b/src/commands/cli-grammar/metro.ts @@ -1,6 +1,8 @@ import { AppError } from '../../utils/errors.ts'; import type { CliReader } from './types.ts'; +const METRO_PREPARE_KINDS = ['auto', 'react-native', 'expo'] as const; + export const metroCliReaders = { metro: metroInputFromCli, } satisfies Record; @@ -29,7 +31,7 @@ function metroInputFromCli(positionals: string[], flags: Parameters[1 return { action, projectRoot: flags.metroProjectRoot, - kind: flags.metroKind, + kind: readMetroPrepareKind(flags.metroKind), port: flags.metroPreparePort, listenHost: flags.metroListenHost, statusHost: flags.metroStatusHost, @@ -51,3 +53,9 @@ function metroInputFromCli(positionals: string[], flags: Parameters[1 runtimeFilePath: flags.metroRuntimeFile, }; } + +function readMetroPrepareKind(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + if ((METRO_PREPARE_KINDS as readonly string[]).includes(value)) return value; + throw new AppError('INVALID_ARGS', 'metro prepare --kind must be auto, react-native, or expo'); +} diff --git a/src/commands/cli-grammar/observability.ts b/src/commands/cli-grammar/observability.ts index ae274608c..eb08598dc 100644 --- a/src/commands/cli-grammar/observability.ts +++ b/src/commands/cli-grammar/observability.ts @@ -11,11 +11,17 @@ import { parseStringMember } from '../../utils/string-enum.ts'; import { LOG_ACTION_VALUES, type LogAction } from '../log-command-contract.ts'; import { isPerfAction, - isPerfArea, + isPerfCommandArea, + isPerfCpuMode, + isPerfKind, PERF_ACTION_ERROR_MESSAGE, PERF_AREA_ERROR_MESSAGE, + PERF_CPU_MODE_ERROR_MESSAGE, + PERF_KIND_ERROR_MESSAGE, type PerfAction, - type PerfArea, + type PerfCommandArea, + type PerfCpuMode, + type PerfKind, } from '../perf-command-contract.ts'; import { commonInputFromFlags, @@ -31,6 +37,8 @@ export const observabilityCliReaders = { perf: (positionals, flags) => ({ ...commonInputFromFlags(flags), ...readPerfPositionals(positionals), + kind: readPerfKind(flags.perfKind), + out: flags.out, }), logs: (positionals, flags) => ({ ...commonInputFromFlags(flags), @@ -73,16 +81,45 @@ export const observabilityDaemonWriters = { function perfPositionals(input: PerfOptions): string[] { const area = input.area ?? (input.action ? 'metrics' : undefined); + if (area === 'cpu') { + return [ + 'cpu', + requiredPerfCpuMode(input.mode), + requiredPerfAction(input.action, 'perf cpu profile'), + requiredPerfKind(input.kind, 'perf cpu profile'), + ]; + } + if (area === 'trace') { + return [ + 'trace', + requiredPerfAction(input.action, 'perf trace'), + requiredPerfKind(input.kind, 'perf trace'), + ]; + } return [...optionalString(area), ...optionalString(input.action)]; } -function readPerfPositionals(positionals: string[]): Pick { +function readPerfPositionals(positionals: string[]): Pick { if (positionals[0] !== undefined && positionals[1] === undefined) { const action = readPerfAction(positionals[0], { allowUndefined: true }); if (action) return { action }; } + const area = readPerfArea(positionals[0]); + if (area === 'cpu') { + return { + area, + mode: readPerfCpuMode(positionals[1]), + action: readPerfAction(positionals[2]), + }; + } + if (area === 'trace') { + return { + area, + action: readPerfAction(positionals[1]), + }; + } return { - area: readPerfArea(positionals[0]), + area, action: readPerfAction(positionals[1]), }; } @@ -104,10 +141,10 @@ function readStartStop(value: string | undefined, command: string): 'start' | 's throw new AppError('INVALID_ARGS', `${command} requires start|stop`); } -function readPerfArea(value: string | undefined): PerfArea | undefined { +function readPerfArea(value: string | undefined): PerfCommandArea | undefined { if (value === undefined) return undefined; const normalized = value.toLowerCase(); - if (isPerfArea(normalized)) return normalized; + if (isPerfCommandArea(normalized)) return normalized; throw new AppError('INVALID_ARGS', PERF_AREA_ERROR_MESSAGE); } @@ -122,6 +159,35 @@ function readPerfAction( throw new AppError('INVALID_ARGS', PERF_ACTION_ERROR_MESSAGE); } +function readPerfCpuMode(value: string | undefined): PerfCpuMode | undefined { + if (value === undefined) return undefined; + const normalized = value.toLowerCase(); + if (isPerfCpuMode(normalized)) return normalized; + throw new AppError('INVALID_ARGS', PERF_CPU_MODE_ERROR_MESSAGE); +} + +function readPerfKind(value: string | undefined): PerfKind | undefined { + if (value === undefined) return undefined; + const normalized = value.toLowerCase(); + if (isPerfKind(normalized)) return normalized; + throw new AppError('INVALID_ARGS', PERF_KIND_ERROR_MESSAGE); +} + +function requiredPerfCpuMode(value: PerfOptions['mode'], command: string = 'perf cpu'): string { + if (value === 'profile') return value; + throw new AppError('INVALID_ARGS', `${command} requires profile`); +} + +function requiredPerfAction(value: PerfOptions['action'], command: string): string { + if (value) return value; + throw new AppError('INVALID_ARGS', `${command} requires an action`); +} + +function requiredPerfKind(value: PerfOptions['kind'], command: string): string { + if (value) return value; + throw new AppError('INVALID_ARGS', `${command} requires --kind`); +} + function readLogsAction(value: string | undefined): LogAction | undefined { if (value === undefined) return undefined; return parseStringMember(LOG_ACTION_VALUES, value, { diff --git a/src/commands/client-command-metadata.ts b/src/commands/client-command-metadata.ts index 35c213080..31b92da4c 100644 --- a/src/commands/client-command-metadata.ts +++ b/src/commands/client-command-metadata.ts @@ -23,7 +23,13 @@ import { type CommandFieldMap, } from './command-input.ts'; import { defineFieldCommandMetadata } from './field-command-contract.ts'; -import { PERF_ACTION_VALUES, PERF_AREA_VALUES } from './perf-command-contract.ts'; +import { + PERF_ACTION_VALUES, + PERF_AREA_VALUES, + PERF_CPU_MODE_VALUES, + PERF_KIND_VALUES, + PERF_NATIVE_AREA_VALUES, +} from './perf-command-contract.ts'; import { WAIT_KIND_VALUES } from './wait-command-contract.ts'; const CLIPBOARD_ACTION_VALUES = ['read', 'write'] as const; @@ -180,8 +186,11 @@ export const clientCommandMetadata = [ shardSplit: integerField(), }), defineClientCommandMetadata('perf', { - area: enumField(PERF_AREA_VALUES), + area: enumField([...PERF_AREA_VALUES, ...PERF_NATIVE_AREA_VALUES]), + mode: enumField(PERF_CPU_MODE_VALUES), action: enumField(PERF_ACTION_VALUES), + kind: enumField(PERF_KIND_VALUES), + out: stringField(), }), defineClientCommandMetadata('logs', { action: enumField(LOG_ACTION_VALUES), diff --git a/src/commands/runtime-output.ts b/src/commands/runtime-output.ts index e33800f75..24286a66c 100644 --- a/src/commands/runtime-output.ts +++ b/src/commands/runtime-output.ts @@ -147,6 +147,8 @@ function joinDefinedLines(lines: Array): string | undefined } function formatPerfCliOutput(data: Record): string { + const nativeSummary = formatNativePerfOutput(data); + if (nativeSummary) return nativeSummary; const metrics = readRecord(data.metrics); const fps = readRecord(metrics?.fps); const resourceSummary = buildResourcePerfSummary(metrics); @@ -169,6 +171,20 @@ function formatPerfCliOutput(data: Record): string { return lines.join('\n'); } +function formatNativePerfOutput(data: Record): string | undefined { + const kind = typeof data.kind === 'string' ? data.kind : undefined; + const action = typeof data.action === 'string' ? data.action : undefined; + const type = typeof data.type === 'string' ? data.type : undefined; + if (!kind || !action || !type) return undefined; + const outPath = typeof data.outPath === 'string' ? data.outPath : undefined; + const sizeBytes = readFiniteNumber(data.sizeBytes); + const state = typeof data.state === 'string' ? ` state=${data.state}` : ''; + const artifact = outPath + ? `\n${outPath}${sizeBytes !== undefined ? ` (${formatBytes(sizeBytes)})` : ''}` + : ''; + return `Perf ${action}: ${kind} ${type}${state}${artifact}`; +} + function formatPerfUnavailable(resourceSummary: string | undefined, reason: string): string { return resourceSummary ? `Performance: ${resourceSummary}` @@ -284,3 +300,11 @@ function formatMemoryKb(value: number): string { const megabytes = value / 1024; return `${megabytes >= 10 ? Math.round(megabytes) : megabytes.toFixed(1)}MB`; } + +function formatBytes(value: number): string { + 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/contracts/perf.ts b/src/contracts/perf.ts index eaa6ea386..05e1fc430 100644 --- a/src/contracts/perf.ts +++ b/src/contracts/perf.ts @@ -1,16 +1,38 @@ import { defineStringEnum } from '../utils/string-enum.ts'; export const PERF_AREA_VALUES = ['metrics', 'frames'] as const; -export const PERF_ACTION_VALUES = ['sample'] as const; +export const PERF_NATIVE_AREA_VALUES = ['cpu', 'trace'] as const; +export const PERF_ACTION_VALUES = ['sample', 'start', 'stop', 'report'] as const; +export const PERF_CPU_MODE_VALUES = ['profile'] as const; +export const PERF_KIND_VALUES = ['simpleperf', 'perfetto'] as const; const PERF_AREAS = defineStringEnum(PERF_AREA_VALUES); +const PERF_NATIVE_AREAS = defineStringEnum(PERF_NATIVE_AREA_VALUES); const PERF_ACTIONS = defineStringEnum(PERF_ACTION_VALUES); +const PERF_CPU_MODES = defineStringEnum(PERF_CPU_MODE_VALUES); +const PERF_KINDS = defineStringEnum(PERF_KIND_VALUES); export type PerfArea = (typeof PERF_AREA_VALUES)[number]; +export type PerfNativeArea = (typeof PERF_NATIVE_AREA_VALUES)[number]; +export type PerfCommandArea = PerfArea | PerfNativeArea; export type PerfAction = (typeof PERF_ACTION_VALUES)[number]; +export type PerfCpuMode = (typeof PERF_CPU_MODE_VALUES)[number]; +export type PerfKind = (typeof PERF_KIND_VALUES)[number]; -export const PERF_AREA_ERROR_MESSAGE = 'perf area must be metrics or frames'; -export const PERF_ACTION_ERROR_MESSAGE = 'perf action must be sample'; +export const PERF_AREA_ERROR_MESSAGE = 'perf area must be metrics, frames, cpu, or trace'; +export const PERF_ACTION_ERROR_MESSAGE = 'perf action must be sample, start, stop, or report'; +export const PERF_CPU_MODE_ERROR_MESSAGE = 'perf cpu requires profile'; +export const PERF_KIND_ERROR_MESSAGE = 'perf --kind must be simpleperf or perfetto'; export const isPerfArea = PERF_AREAS.is; +export const isPerfNativeArea = PERF_NATIVE_AREAS.is; + +export function isPerfCommandArea(value: string): value is PerfCommandArea { + return isPerfArea(value) || isPerfNativeArea(value); +} + export const isPerfAction = PERF_ACTIONS.is; + +export const isPerfCpuMode = PERF_CPU_MODES.is; + +export const isPerfKind = PERF_KINDS.is; diff --git a/src/daemon/handlers/__tests__/session-observability.test.ts b/src/daemon/handlers/__tests__/session-observability.test.ts index 2007b004b..cb94787ff 100644 --- a/src/daemon/handlers/__tests__/session-observability.test.ts +++ b/src/daemon/handlers/__tests__/session-observability.test.ts @@ -1,5 +1,10 @@ import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { test } from 'vitest'; +import type { AndroidAdbExecutor } from '../../../platforms/android/adb-executor.ts'; +import { makeAndroidSession, makeIosSession } from '../../../__tests__/test-utils/index.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import { handleSessionObservabilityCommands } from '../session-observability.ts'; @@ -92,3 +97,119 @@ test('network dump accepts explicit include flag and rejects conflicting values' assert.match(conflictResponse.error.message, /both positionally and via --include/i); } }); + +test('perf cpu profile start and stop route through Android simpleperf and preserve compact artifact state', async () => { + const tmpDir = await fs.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); + + 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'); +}); + +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 fs.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..442b9cff1 --- /dev/null +++ b/src/daemon/handlers/session-native-perf.ts @@ -0,0 +1,256 @@ +import path from 'node:path'; +import { + isPerfAction, + isPerfCpuMode, + isPerfKind, + PERF_ACTION_ERROR_MESSAGE, + PERF_CPU_MODE_ERROR_MESSAGE, + PERF_KIND_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'; + mode: '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 = typeof req.flags?.out === 'string' ? req.flags.out : undefined; + if (area === 'cpu') { + const mode = (req.positionals?.[1] ?? '').toLowerCase(); + const action = (req.positionals?.[2] ?? '').toLowerCase(); + const kind = (req.positionals?.[3] ?? '').toLowerCase(); + if (!isPerfCpuMode(mode)) return errorResponse('INVALID_ARGS', PERF_CPU_MODE_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, mode, 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 (!isPerfAction(action)) return errorResponse('INVALID_ARGS', PERF_ACTION_ERROR_MESSAGE); + 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 }; +} + +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 4d1225427..4516aac14 100644 --- a/src/daemon/handlers/session-observability.ts +++ b/src/daemon/handlers/session-observability.ts @@ -1,7 +1,7 @@ import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; import { - isPerfAction, isPerfArea, + isPerfNativeArea, PERF_ACTION_ERROR_MESSAGE, PERF_AREA_ERROR_MESSAGE, } from '../../contracts/perf.ts'; @@ -20,6 +20,7 @@ import { stopAppLog, } from '../app-log.ts'; import { buildPerfFramesResponseData, buildPerfResponseData } from './session-perf.ts'; +import { handleNativePerfCommand } from './session-native-perf.ts'; import { errorResponse, type DaemonFailureResponse } from './response.ts'; import { NETWORK_INCLUDE_MODES, type NetworkIncludeMode } from '../../contracts.ts'; import type { LogBackend } from '../network-log.ts'; @@ -95,11 +96,22 @@ async function handlePerfCommand(params: ObservabilityParams): Promise { const sample = parseAndroidMemInfoSample( @@ -172,3 +184,121 @@ 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 fs.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 fs.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 fs.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 fs.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 a21231616..4ffee45d7 100644 --- a/src/platforms/android/perf.ts +++ b/src/platforms/android/perf.ts @@ -13,6 +13,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 25b03f6e8..fd1298397 100644 --- a/src/utils/args.ts +++ b/src/utils/args.ts @@ -4,6 +4,7 @@ import { applyCommandDefaults, getCommandSchema, getFlagDefinition, + getFlagDefinitions, type CliFlags, type FlagDefinition, type FlagKey, @@ -67,7 +68,8 @@ export function parseRawArgs(argv: string[]): RawParsedArgs { } const [token, inlineValue] = isLongFlag ? splitLongFlag(arg) : [arg, undefined]; - const definition = getFlagDefinition(token); + const definition = + resolveCommandScopedFlagDefinition(command, token) ?? getFlagDefinition(token); if (shouldPassThroughReactDevtoolsFlag(command, definition)) { positionals.push(arg); continue; @@ -100,6 +102,16 @@ export function parseRawArgs(argv: string[]): RawParsedArgs { return { command, positionals, flags, warnings, providedFlags }; } +function resolveCommandScopedFlagDefinition( + command: string | null, + token: string, +): FlagDefinition | undefined { + if (command === 'perf' && token === '--kind') { + return getFlagDefinitions().find((definition) => definition.key === 'perfKind'); + } + return undefined; +} + function shouldPassThroughReactDevtoolsFlag( command: string | null, definition: FlagDefinition | undefined, diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index 5f474f25f..3cdbbb896 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -150,12 +150,15 @@ const CLI_COMMAND_OVERRIDES = { helpDescription: 'Show foreground app/activity', }, perf: { - usageOverride: 'perf [metrics|frames] [sample]', - listUsageOverride: 'perf [metrics|frames]', + usageOverride: + 'perf [metrics|frames] [sample]\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]; perf cpu profile start|stop|report; perf trace start|stop', helpDescription: - 'Show session performance metrics or focused frame/jank health. Bare perf and metrics are aliases for perf metrics.', - summary: 'Show session performance and frame health', - positionalArgs: ['area?', 'action?'], + 'Show session performance metrics, focused frame/jank health, or Android native Simpleperf/Perfetto artifacts. Bare perf and metrics are aliases for perf metrics.', + summary: 'Show session performance, frame health, and Android native perf artifacts', + positionalArgs: ['area?', 'mode?', 'action?'], + allowedFlags: ['perfKind', 'out'], }, metro: { usageOverride: diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index 049d8e825..ff82f87ce 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -52,6 +52,7 @@ export type CliFlags = RemoteConfigMetroOptions & session?: string; metroHost?: string; metroPort?: number; + perfKind?: 'simpleperf' | 'perfetto'; bundleUrl?: string; launchUrl?: string; verbose?: boolean; @@ -392,6 +393,14 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--kind auto|react-native|expo', usageDescription: 'metro prepare: detect or force the Metro launcher kind', }, + { + key: 'perfKind', + names: [], + type: 'enum', + enumValues: ['simpleperf', 'perfetto'], + usageLabel: '--kind simpleperf|perfetto', + usageDescription: 'Perf: native Android collector kind', + }, { key: 'metroPublicBaseUrl', names: ['--public-base-url'], diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 614aad5ab..937b7d355 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. 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. Android native profiling: perf cpu profile start|stop|report --kind simpleperf --out ; Android native traces: perf trace start|stop --kind perfetto --out . These are Android-only and return artifact paths/summaries, not trace/profile contents. 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 6979830b2..f86d4abd8 100644 --- a/test/skillgym/suites/agent-device-smoke-suite.ts +++ b/test/skillgym/suites/agent-device-smoke-suite.ts @@ -1329,6 +1329,31 @@ const SKILL_GUIDANCE_CASES: Case[] = [ outputs: [plannedCommand('perf frames'), /--json/i], forbiddenOutputs: [plannedCommand('react-devtools'), plannedCommand('network')], }), + 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 f3b046ae1..4ada4a02d 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -260,7 +260,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, or `{ area: 'frames' }` for a focused frame/jank-health payload. 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, or `{ area: 'frames' }` for a focused frame/jank-health payload. 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. 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.recording.record({ action: 'start', path, quality: 5 })` starts a smaller 50% resolution video; omit `quality` to keep native/current resolution. diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index bec930e24..29041183a 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -586,10 +586,17 @@ agent-device perf --json agent-device metrics --json agent-device perf metrics --json agent-device perf frames --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 a session-scoped metrics JSON blob. Bare `perf` and `metrics` remain aliases for `perf metrics`. - `perf frames` returns a focused frame/jank-health JSON blob from the same frame sampling source used by `perf metrics`. +- `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. - 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: @@ -607,6 +614,7 @@ agent-device perf frames --json - 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 f9acceb76..f921ec726 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -98,10 +98,16 @@ agent-device perf --json agent-device metrics --json agent-device perf metrics --json agent-device perf frames --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. - `perf frames` returns a focused frame/jank-health payload. +- 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. - 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 ec93baf655b19b81715a5e7d98ee6bc35054c907 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/2] fix: satisfy fallow for Android perf profiling --- src/commands/runtime-output.ts | 39 +++- .../__tests__/session-observability.test.ts | 36 ++-- src/daemon/handlers/session-native-perf.ts | 194 +++++++++--------- src/platforms/android/perf-native.ts | 41 ++-- src/platforms/android/perf.ts | 2 - 5 files changed, 179 insertions(+), 133 deletions(-) diff --git a/src/commands/runtime-output.ts b/src/commands/runtime-output.ts index 24286a66c..362a7d832 100644 --- a/src/commands/runtime-output.ts +++ b/src/commands/runtime-output.ts @@ -172,17 +172,36 @@ function formatPerfCliOutput(data: Record): string { } function formatNativePerfOutput(data: Record): string | undefined { - const kind = typeof data.kind === 'string' ? data.kind : undefined; - const action = typeof data.action === 'string' ? data.action : undefined; - const type = typeof data.type === 'string' ? data.type : undefined; - if (!kind || !action || !type) return undefined; - const outPath = typeof data.outPath === 'string' ? data.outPath : undefined; + const summary = readNativePerfSummary(data); + if (!summary) return undefined; + return `Perf ${summary.action}: ${summary.kind} ${summary.type}${formatNativePerfState( + data, + )}${formatNativePerfArtifact(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); - const state = typeof data.state === 'string' ? ` state=${data.state}` : ''; - const artifact = outPath - ? `\n${outPath}${sizeBytes !== undefined ? ` (${formatBytes(sizeBytes)})` : ''}` - : ''; - return `Perf ${action}: ${kind} ${type}${state}${artifact}`; + return `\n${outPath}${sizeBytes !== undefined ? ` (${formatBytes(sizeBytes)})` : ''}`; +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; } function formatPerfUnavailable(resourceSummary: string | undefined, reason: string): string { diff --git a/src/daemon/handlers/__tests__/session-observability.test.ts b/src/daemon/handlers/__tests__/session-observability.test.ts index cb94787ff..753a3f11f 100644 --- a/src/daemon/handlers/__tests__/session-observability.test.ts +++ b/src/daemon/handlers/__tests__/session-observability.test.ts @@ -6,6 +6,7 @@ import { test } from 'vitest'; import type { AndroidAdbExecutor } from '../../../platforms/android/adb-executor.ts'; import { makeAndroidSession, makeIosSession } from '../../../__tests__/test-utils/index.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; +import type { DaemonResponse } from '../../types.ts'; import { handleSessionObservabilityCommands } from '../session-observability.ts'; test('network dump validates include mode directly', async () => { @@ -118,13 +119,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: { @@ -139,11 +139,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'); }); test('perf trace rejects non-Android sessions explicitly', async () => { @@ -213,3 +212,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 442b9cff1..ae6e35dc1 100644 --- a/src/daemon/handlers/session-native-perf.ts +++ b/src/daemon/handlers/session-native-perf.ts @@ -1,12 +1,5 @@ import path from 'node:path'; -import { - isPerfAction, - isPerfCpuMode, - isPerfKind, - PERF_ACTION_ERROR_MESSAGE, - PERF_CPU_MODE_ERROR_MESSAGE, - PERF_KIND_ERROR_MESSAGE, -} from '../../contracts/perf.ts'; +import { isPerfCpuMode, PERF_CPU_MODE_ERROR_MESSAGE } from '../../contracts/perf.ts'; import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts'; import { startAndroidPerfettoTrace, @@ -77,49 +70,90 @@ 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 = typeof req.flags?.out === 'string' ? req.flags.out : undefined; - if (area === 'cpu') { - const mode = (req.positionals?.[1] ?? '').toLowerCase(); - const action = (req.positionals?.[2] ?? '').toLowerCase(); - const kind = (req.positionals?.[3] ?? '').toLowerCase(); - if (!isPerfCpuMode(mode)) return errorResponse('INVALID_ARGS', PERF_CPU_MODE_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, mode, action, kind, outPath }; + return area === 'cpu' + ? resolveCpuProfileRequest(req, outPath) + : resolveTraceRequest(req, outPath); +} + +function resolveCpuProfileRequest( + req: DaemonRequest, + outPath: string | undefined, +): NativePerfRequest | DaemonFailureResponse { + const mode = readPositional(req, 1); + const action = readPositional(req, 2); + const kind = readPositional(req, 3); + if (!isPerfCpuMode(mode)) return errorResponse('INVALID_ARGS', PERF_CPU_MODE_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', mode, 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 (!isPerfAction(action)) return errorResponse('INVALID_ARGS', PERF_ACTION_ERROR_MESSAGE); - 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); } async function runAndroidCpuProfileCommand( - params: { - sessionName: string; - sessionStore: SessionStore; - req: DaemonRequest; - session: SessionState; - androidAdbExecutor?: AndroidAdbExecutor; - }, + params: NativePerfHandlerParams, session: SessionState, packageName: string, request: Extract, @@ -129,24 +163,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, @@ -157,21 +179,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, @@ -181,11 +193,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); @@ -193,35 +201,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( @@ -254,3 +245,18 @@ function compactNativePerfResponse(result: AndroidNativePerfSession & Record