diff --git a/src/__tests__/cli-perf.test.ts b/src/__tests__/cli-perf.test.ts index 362bf2c3a..67b81da4a 100644 --- a/src/__tests__/cli-perf.test.ts +++ b/src/__tests__/cli-perf.test.ts @@ -106,6 +106,70 @@ test('perf frames sample forwards explicit sample action to daemon', async () => assert.deepEqual(result.calls[0]?.positionals, ['frames', 'sample']); }); +test('perf memory sample forwards memory area and prints compact memory summary', async () => { + const result = await runCliCapture(['perf', 'memory', 'sample'], async () => ({ + ok: true, + data: { + metrics: { + memory: { + available: true, + totalPssKb: 216524, + topConsumers: [{ name: 'Dalvik Heap', pssKb: 120000 }], + }, + }, + }, + })); + + assert.equal(result.code, null); + assert.equal(result.calls[0]?.command, 'perf'); + assert.deepEqual(result.calls[0]?.positionals, ['memory', 'sample']); + assert.equal(result.stdout, 'Performance: memory 211MB\n'); +}); + +test('perf memory snapshot forwards kind and output path and prints artifact summary', async () => { + const result = await runCliCapture( + ['perf', 'memory', 'snapshot', '--kind', 'android-hprof', '--out', 'heap.hprof'], + async () => ({ + ok: true, + data: { + artifact: { + available: true, + kind: 'android-hprof', + path: '/tmp/heap.hprof', + sizeBytes: 2_500_000, + }, + }, + }), + ); + + assert.equal(result.code, null); + assert.equal(result.calls[0]?.command, 'perf'); + assert.deepEqual(result.calls[0]?.positionals, ['memory', 'snapshot']); + assert.equal(result.calls[0]?.flags?.kind, 'android-hprof'); + assert.equal(result.calls[0]?.flags?.out, 'heap.hprof'); + assert.equal(result.stdout, 'Memory artifact (android-hprof): /tmp/heap.hprof (2.4MB)\n'); +}); + +test('perf forwards shared perf kind values through CLI parsing', async () => { + const result = await runCliCapture( + ['perf', 'memory', 'snapshot', '--kind', 'perfetto', '--json'], + async () => ({ + ok: false, + error: { + code: 'INVALID_ARGS', + message: 'perf memory snapshot --kind must be android-hprof or memgraph', + }, + }), + ); + + assert.equal(result.code, 1); + assert.equal(result.calls[0]?.command, 'perf'); + assert.deepEqual(result.calls[0]?.positionals, ['memory', 'snapshot']); + assert.equal(result.calls[0]?.flags?.kind, 'perfetto'); + const payload = JSON.parse(result.stdout); + assert.equal(payload.error.code, 'INVALID_ARGS'); +}); + test('perf sample defaults to metrics sample', async () => { const result = await runCliCapture(['perf', 'sample', '--json'], async () => ({ ok: true, @@ -152,7 +216,7 @@ 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, or memory/i); }); 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 a6f6fd7dc..c263f94f4 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -147,6 +147,38 @@ test('observability.perf projects structured frame area to daemon positionals', assert.deepEqual(setup.calls[0]?.positionals, ['frames', 'sample']); }); +test('observability.perf projects memory snapshot options to daemon flags', async () => { + const setup = createTransport(async (req) => { + if (req.command === 'perf') { + return { + ok: true, + data: { + artifact: { + available: true, + kind: 'memgraph', + path: '/tmp/app.memgraph', + }, + }, + }; + } + throw new Error(`Unexpected command: ${req.command}`); + }); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + await client.observability.perf({ + area: 'memory', + action: 'snapshot', + kind: 'memgraph', + out: 'app.memgraph', + }); + + assert.equal(setup.calls.length, 1); + assert.equal(setup.calls[0]?.command, 'perf'); + assert.deepEqual(setup.calls[0]?.positionals, ['memory', 'snapshot']); + assert.equal(setup.calls[0]?.flags?.kind, 'memgraph'); + assert.equal(setup.calls[0]?.flags?.out, 'app.memgraph'); +}); + 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/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 225b24afd..725ef5125 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -17,7 +17,10 @@ import { connectionCommand, disconnectCommand, } from '../cli/commands/connection.ts'; -import { materializeRemoteConnectionForCommand } from '../cli/commands/connection-runtime.ts'; +import { + hasDeferredMetroConfig, + materializeRemoteConnectionForCommand, +} from '../cli/commands/connection-runtime.ts'; import { stopMetroCompanion } from '../client-metro-companion.ts'; import { AppError } from '../utils/errors.ts'; import { @@ -37,6 +40,27 @@ const unexpectedCommandCall = async (): Promise => { throw new Error('unexpected call'); }; +test('deferred Metro config ignores perf-style kind values', () => { + assert.equal( + hasDeferredMetroConfig({ + json: true, + help: false, + version: false, + kind: 'memgraph', + }), + false, + ); + assert.equal( + hasDeferredMetroConfig({ + json: true, + help: false, + version: false, + metroKind: 'expo', + }), + true, + ); +}); + function createThrowingMethodGroup(methods: Partial = {}): T { return new Proxy(methods, { get: (target, property) => target[property as keyof T] ?? unexpectedCommandCall, diff --git a/src/cli.ts b/src/cli.ts index e2110d2fa..0252c673c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -41,6 +41,7 @@ const DEFAULT_CLI_DEPS: CliDeps = { const METRO_RUNTIME_OVERRIDE_FLAG_KEYS = new Set([ 'launchUrl', + 'kind', 'metroBearerToken', 'metroKind', 'metroListenHost', diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 844105c4a..285a382dc 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -16,6 +16,7 @@ import { AppError } from '../../utils/errors.ts'; import type { LeaseBackend, SessionRuntimeHints } from '../../contracts.ts'; import type { CliFlags } from '../../utils/cli-flags.ts'; import type { AgentDeviceClient, Lease } from '../../client.ts'; +import type { MetroPrepareKind } from '../../client-metro.ts'; const leaseDeferredCommands = new Set([ 'connect', @@ -195,7 +196,7 @@ async function prepareConnectedMetro( } const prepared = await client.metro.prepare({ projectRoot: flags.metroProjectRoot, - kind: flags.metroKind, + kind: readDeferredMetroKind(flags.metroKind), publicBaseUrl: flags.metroPublicBaseUrl, proxyBaseUrl: flags.metroProxyBaseUrl, bearerToken: flags.metroBearerToken, @@ -304,11 +305,9 @@ function shouldPrepareRuntimeForCommand(command: string, batchSteps?: BatchStep[ } export function hasDeferredMetroConfig(flags: CliFlags): boolean { + const metroKind = flags.metroKind; return Boolean( - flags.metroPublicBaseUrl || - flags.metroProxyBaseUrl || - flags.metroProjectRoot || - flags.metroKind, + flags.metroPublicBaseUrl || flags.metroProxyBaseUrl || flags.metroProjectRoot || metroKind, ); } @@ -322,6 +321,12 @@ function isRuntimeCompatibleWithPlatform( return runtime.platform === platform; } +function readDeferredMetroKind(value: string | undefined): MetroPrepareKind | undefined { + if (value === undefined) return undefined; + if (value === 'auto' || value === 'react-native' || value === 'expo') return value; + throw new AppError('INVALID_ARGS', 'metro prepare --kind must be auto, react-native, or expo'); +} + function isSameMetroCleanup( left: RemoteConnectionState['metro'] | undefined, right: RemoteConnectionState['metro'] | undefined, diff --git a/src/client-normalizers.ts b/src/client-normalizers.ts index a4d16ebde..6462765f8 100644 --- a/src/client-normalizers.ts +++ b/src/client-normalizers.ts @@ -297,6 +297,7 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags { snapshotForceFull: options.forceFull, ...screenshotFlagsFromOptions(options), appsFilter: options.appsFilter, + kind: options.kind, out: options.out, count: options.count, fps: options.fps, diff --git a/src/client-types.ts b/src/client-types.ts index 1d4a8c25f..a2d78c917 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, PerfArea, PerfKind } from './contracts/perf.ts'; import type { DaemonBatchStep } from './core/batch.ts'; import type { AlertAction, AlertInfo } from './alert-contract.ts'; @@ -742,6 +742,8 @@ export type BatchRunOptions = AgentDeviceRequestOverrides & { export type PerfOptions = DeviceCommandBaseOptions & { area?: PerfArea; action?: PerfAction; + kind?: PerfKind; + out?: string; }; export type LogsOptions = AgentDeviceRequestOverrides & { @@ -830,6 +832,7 @@ export type SettingsUpdateOptions = type CommandExecutionOptions = Partial & { positionals?: string[]; + kind?: string; out?: string; interactiveOnly?: boolean; compact?: boolean; diff --git a/src/commands/cli-grammar/metro.ts b/src/commands/cli-grammar/metro.ts index e4982c462..cd2c6f740 100644 --- a/src/commands/cli-grammar/metro.ts +++ b/src/commands/cli-grammar/metro.ts @@ -1,4 +1,5 @@ import { AppError } from '../../utils/errors.ts'; +import type { MetroPrepareKind } from '../../client-metro.ts'; import type { CliReader } from './types.ts'; export const metroCliReaders = { @@ -29,7 +30,7 @@ function metroInputFromCli(positionals: string[], flags: Parameters[1 return { action, projectRoot: flags.metroProjectRoot, - kind: flags.metroKind, + kind: readMetroPrepareKind(flags.kind ?? flags.metroKind), port: flags.metroPreparePort, listenHost: flags.metroListenHost, statusHost: flags.metroStatusHost, @@ -51,3 +52,9 @@ function metroInputFromCli(positionals: string[], flags: Parameters[1 runtimeFilePath: flags.metroRuntimeFile, }; } + +function readMetroPrepareKind(value: string | undefined): MetroPrepareKind | undefined { + if (value === undefined) return undefined; + if (value === 'auto' || value === 'react-native' || value === 'expo') 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..f5501812a 100644 --- a/src/commands/cli-grammar/observability.ts +++ b/src/commands/cli-grammar/observability.ts @@ -12,10 +12,13 @@ import { LOG_ACTION_VALUES, type LogAction } from '../log-command-contract.ts'; import { isPerfAction, isPerfArea, + isPerfKind, PERF_ACTION_ERROR_MESSAGE, PERF_AREA_ERROR_MESSAGE, + PERF_KIND_ERROR_MESSAGE, type PerfAction, type PerfArea, + type PerfKind, } from '../perf-command-contract.ts'; import { commonInputFromFlags, @@ -31,6 +34,8 @@ export const observabilityCliReaders = { perf: (positionals, flags) => ({ ...commonInputFromFlags(flags), ...readPerfPositionals(positionals), + kind: readPerfKind(flags.kind), + out: flags.out, }), logs: (positionals, flags) => ({ ...commonInputFromFlags(flags), @@ -87,6 +92,12 @@ function readPerfPositionals(positionals: string[]): Pick): string | undefined } function formatPerfCliOutput(data: Record): string { + const artifact = readRecord(data.artifact); + if (artifact) { + return formatMemoryArtifactSummary(artifact); + } const metrics = readRecord(data.metrics); const fps = readRecord(metrics?.fps); const resourceSummary = buildResourcePerfSummary(metrics); @@ -169,6 +173,23 @@ function formatPerfCliOutput(data: Record): string { return lines.join('\n'); } +function formatMemoryArtifactSummary(artifact: Record): string { + const kind = typeof artifact.kind === 'string' ? artifact.kind : 'memory'; + if (artifact.available === false) { + const reason = + typeof artifact.reason === 'string' && artifact.reason.length > 0 + ? artifact.reason + : 'not available'; + return `Memory artifact (${kind}): unavailable - ${reason}`; + } + const artifactPath = typeof artifact.path === 'string' ? artifact.path : undefined; + const sizeBytes = readFiniteNumber(artifact.sizeBytes); + const sizeText = sizeBytes === undefined ? '' : ` (${formatBytes(sizeBytes)})`; + return artifactPath + ? `Memory artifact (${kind}): ${artifactPath}${sizeText}` + : `Memory artifact (${kind}): captured${sizeText}`; +} + function formatPerfUnavailable(resourceSummary: string | undefined, reason: string): string { return resourceSummary ? `Performance: ${resourceSummary}` @@ -284,3 +305,10 @@ function formatMemoryKb(value: number): string { const megabytes = value / 1024; return `${megabytes >= 10 ? Math.round(megabytes) : megabytes.toFixed(1)}MB`; } + +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`; +} diff --git a/src/contracts/perf.ts b/src/contracts/perf.ts index eaa6ea386..5cd35b327 100644 --- a/src/contracts/perf.ts +++ b/src/contracts/perf.ts @@ -1,16 +1,36 @@ 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_AREA_VALUES = ['metrics', 'frames', 'memory'] as const; +export const PERF_ACTION_VALUES = ['sample', 'snapshot'] as const; +export const PERF_KIND_VALUES = [ + 'xctrace', + 'simpleperf', + 'perfetto', + 'android-hprof', + 'memgraph', +] as const; +export const PERF_MEMORY_KIND_VALUES = ['android-hprof', 'memgraph'] as const; const PERF_AREAS = defineStringEnum(PERF_AREA_VALUES); const PERF_ACTIONS = defineStringEnum(PERF_ACTION_VALUES); +const PERF_KINDS = defineStringEnum(PERF_KIND_VALUES); +const PERF_MEMORY_KINDS = defineStringEnum(PERF_MEMORY_KIND_VALUES); export type PerfArea = (typeof PERF_AREA_VALUES)[number]; export type PerfAction = (typeof PERF_ACTION_VALUES)[number]; +export type PerfKind = (typeof PERF_KIND_VALUES)[number]; +export type PerfMemoryKind = (typeof PERF_MEMORY_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, or memory'; +export const PERF_ACTION_ERROR_MESSAGE = 'perf action must be sample or snapshot'; +export const PERF_KIND_ERROR_MESSAGE = + 'perf --kind must be xctrace, simpleperf, perfetto, android-hprof, or memgraph'; +export const PERF_MEMORY_KIND_ERROR_MESSAGE = + 'perf memory snapshot --kind must be android-hprof or memgraph'; export const isPerfArea = PERF_AREAS.is; export const isPerfAction = PERF_ACTIONS.is; + +export const isPerfKind = PERF_KINDS.is; + +export const isPerfMemoryKind = PERF_MEMORY_KINDS.is; diff --git a/src/core/dispatch-context.ts b/src/core/dispatch-context.ts index 6ff1d58b5..0826435a8 100644 --- a/src/core/dispatch-context.ts +++ b/src/core/dispatch-context.ts @@ -22,6 +22,7 @@ export type CommandFlags = Omit & { retryOnNoChange?: boolean; }; launchArgs?: string[]; + kind?: string; maestro?: MaestroRuntimeFlags; postGestureStabilization?: boolean; replayBackend?: string; diff --git a/src/daemon/handlers/__tests__/session-observability.test.ts b/src/daemon/handlers/__tests__/session-observability.test.ts index 2007b004b..f45de7837 100644 --- a/src/daemon/handlers/__tests__/session-observability.test.ts +++ b/src/daemon/handlers/__tests__/session-observability.test.ts @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { test } from 'vitest'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import { handleSessionObservabilityCommands } from '../session-observability.ts'; +import type { AndroidAdbExecutor } from '../../../platforms/android/adb-executor.ts'; test('network dump validates include mode directly', async () => { const sessionStore = makeSessionStore('agent-device-session-observability-'); @@ -92,3 +96,271 @@ test('network dump accepts explicit include flag and rejects conflicting values' assert.match(conflictResponse.error.message, /both positionally and via --include/i); } }); + +test('perf memory sample routes to memory-only Android sampler', async () => { + const sessionStore = makeSessionStore('agent-device-session-observability-'); + sessionStore.set('android', { + name: 'android', + createdAt: Date.now(), + actions: [], + appBundleId: 'com.example.app', + device: { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }, + }); + const adbCalls: string[][] = []; + const androidAdbExecutor: AndroidAdbExecutor = async (args) => { + adbCalls.push([...args]); + assert.deepEqual(args, ['shell', 'dumpsys', 'meminfo', 'com.example.app']); + return { + stdout: [ + '** MEMINFO in pid 18227 [com.example.app] **', + ' TOTAL 216524 208232 4384 0 82916 68345 14570', + 'App Summary', + ' TOTAL PSS: 216,524 TOTAL RSS: 340,112 TOTAL SWAP PSS: 0', + ].join('\n'), + stderr: '', + exitCode: 0, + }; + }; + + const response = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'android', + command: 'perf', + positionals: ['memory', 'sample'], + flags: {}, + }, + sessionName: 'android', + sessionStore, + androidAdbExecutor, + }); + + assert.ok(response?.ok); + if (!response?.ok) assert.fail(JSON.stringify(response)); + assert.deepEqual(adbCalls, [['shell', 'dumpsys', 'meminfo', 'com.example.app']]); + const metrics = response.data?.metrics as Record; + assert.equal(metrics.memory.available, true); + assert.equal(metrics.memory.totalPssKb, 216524); + assert.deepEqual(Object.keys(metrics), ['memory']); +}); + +test('perf memory snapshot resolves relative output and returns Android artifact metadata', async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-perf-memory-cwd-')); + const sessionStore = makeSessionStore('agent-device-session-observability-'); + sessionStore.set('android', { + name: 'android', + createdAt: Date.now(), + actions: [], + appBundleId: 'com.example.app', + device: { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }, + }); + const adbCalls: string[][] = []; + const androidAdbExecutor: AndroidAdbExecutor = async (args) => { + adbCalls.push([...args]); + if (args.join(' ') === 'shell pidof com.example.app') { + return { stdout: '4242\n', stderr: '', exitCode: 0 }; + } + if (args.slice(0, 4).join(' ') === 'shell am dumpheap com.example.app') { + return { stdout: '', stderr: '', exitCode: 0 }; + } + if (args[0] === 'pull') { + fs.writeFileSync(String(args[2]), 'hprof-bytes'); + return { stdout: '', stderr: '', exitCode: 0 }; + } + if (args.slice(0, 3).join(' ') === 'shell rm -f') { + return { stdout: '', stderr: '', exitCode: 0 }; + } + throw new Error(`unexpected adb call: ${args.join(' ')}`); + }; + + try { + const response = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'android', + command: 'perf', + positionals: ['memory', 'snapshot'], + flags: { kind: 'android-hprof', out: 'heap.hprof' }, + meta: { cwd }, + }, + sessionName: 'android', + sessionStore, + androidAdbExecutor, + }); + + assert.ok(response?.ok); + if (!response?.ok) assert.fail(JSON.stringify(response)); + const artifact = response.data?.artifact as Record; + assert.equal(artifact.available, true); + assert.equal(artifact.kind, 'android-hprof'); + assert.equal(artifact.path, path.join(cwd, 'heap.hprof')); + assert.equal(artifact.sizeBytes, 'hprof-bytes'.length); + assert.equal(fs.existsSync(path.join(cwd, 'heap.hprof')), true); + assert.equal(adbCalls.at(-1)?.slice(0, 3).join(' '), 'shell rm -f'); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test('perf memory snapshot reports physical iOS memgraph as unavailable', async () => { + const sessionStore = makeSessionStore('agent-device-session-observability-'); + sessionStore.set('ios-device', { + name: 'ios-device', + createdAt: Date.now(), + actions: [], + appBundleId: 'com.example.app', + device: { + platform: 'ios', + id: 'ios-device-1', + name: 'iPhone', + kind: 'device', + booted: true, + }, + }); + + const response = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'ios-device', + command: 'perf', + positionals: ['memory', 'snapshot'], + flags: { kind: 'memgraph' }, + }, + sessionName: 'ios-device', + sessionStore, + }); + + assert.ok(response?.ok); + if (!response?.ok) assert.fail(JSON.stringify(response)); + const artifact = response.data?.artifact as Record; + assert.equal(artifact.available, false); + assert.match(String(artifact.reason), /Physical iOS device memgraph capture/i); + const support = response.data?.support as Record; + assert.equal(support.memgraph, false); +}); + +test('perf rejects kind outside memory snapshot at daemon layer', async () => { + const sessionStore = makeSessionStore('agent-device-session-observability-'); + sessionStore.set('android', { + name: 'android', + createdAt: Date.now(), + actions: [], + appBundleId: 'com.example.app', + device: { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }, + }); + + const response = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'android', + command: 'perf', + positionals: ['frames', 'sample'], + flags: { kind: 'xctrace' }, + }, + sessionName: 'android', + sessionStore, + }); + + assert.ok(response); + assert.equal(response?.ok, false); + if (response && !response.ok) { + assert.equal(response.error.code, 'INVALID_ARGS'); + assert.match(response.error.message, /--kind is only supported with perf memory snapshot/i); + } +}); + +test('perf memory snapshot rejects non-memory perf kind at daemon layer', async () => { + const sessionStore = makeSessionStore('agent-device-session-observability-'); + sessionStore.set('android', { + name: 'android', + createdAt: Date.now(), + actions: [], + appBundleId: 'com.example.app', + device: { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }, + }); + + const response = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'android', + command: 'perf', + positionals: ['memory', 'snapshot'], + flags: { kind: 'perfetto' }, + }, + sessionName: 'android', + sessionStore, + }); + + assert.ok(response); + assert.equal(response?.ok, false); + if (response && !response.ok) { + assert.equal(response.error.code, 'INVALID_ARGS'); + assert.match( + response.error.message, + /perf memory snapshot --kind must be android-hprof or memgraph/i, + ); + } +}); + +test('perf memory snapshot returns artifact-shaped unsupported payload on unsupported platforms', async () => { + const sessionStore = makeSessionStore('agent-device-session-observability-'); + sessionStore.set('linux', { + name: 'linux', + createdAt: Date.now(), + actions: [], + appBundleId: 'com.example.app', + device: { + platform: 'linux', + id: 'linux-host', + name: 'Linux', + kind: 'device', + booted: true, + }, + }); + + const response = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'linux', + command: 'perf', + positionals: ['memory', 'snapshot'], + flags: {}, + }, + sessionName: 'linux', + sessionStore, + }); + + assert.ok(response?.ok); + if (!response?.ok) assert.fail(JSON.stringify(response)); + assert.equal(response.data?.metrics, undefined); + const artifact = response.data?.artifact as Record; + assert.equal(artifact.available, false); + assert.equal(artifact.kind, 'memgraph'); + assert.match(String(artifact.reason), /not supported on linux/i); + const support = response.data?.support as Record; + assert.equal(support.memgraph, false); +}); diff --git a/src/daemon/handlers/session-observability.ts b/src/daemon/handlers/session-observability.ts index 4d1225427..8caebc1ca 100644 --- a/src/daemon/handlers/session-observability.ts +++ b/src/daemon/handlers/session-observability.ts @@ -2,12 +2,19 @@ import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; import { isPerfAction, isPerfArea, + isPerfKind, + isPerfMemoryKind, PERF_ACTION_ERROR_MESSAGE, PERF_AREA_ERROR_MESSAGE, + PERF_KIND_ERROR_MESSAGE, + PERF_MEMORY_KIND_ERROR_MESSAGE, + type PerfAction, + type PerfArea, + type PerfKind, } from '../../contracts/perf.ts'; -import { normalizeError } from '../../utils/errors.ts'; +import { AppError, normalizeError } from '../../utils/errors.ts'; import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts'; -import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; +import type { DaemonRequest, DaemonResponse, DaemonResponseData, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { appendAppLogMarker, @@ -19,7 +26,11 @@ import { startAppLog, stopAppLog, } from '../app-log.ts'; -import { buildPerfFramesResponseData, buildPerfResponseData } from './session-perf.ts'; +import { + buildPerfFramesResponseData, + buildPerfMemoryResponseData, + buildPerfResponseData, +} from './session-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'; @@ -88,34 +99,142 @@ export async function handleSessionObservabilityCommands( // --------------------------------------------------------------------------- async function handlePerfCommand(params: ObservabilityParams): Promise { - const { req, sessionName, sessionStore, androidAdbExecutor } = params; + const { req, sessionName, sessionStore } = 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(); - const action = (req.positionals?.[1] ?? 'sample').toLowerCase(); - if (!isPerfArea(area)) { - return errorResponse('INVALID_ARGS', PERF_AREA_ERROR_MESSAGE); - } - if (!isPerfAction(action)) { - return errorResponse('INVALID_ARGS', PERF_ACTION_ERROR_MESSAGE); - } + const request = resolvePerfCommandRequest(req); + if (!request.ok) return request; try { return { ok: true, - data: - area === 'frames' - ? await buildPerfFramesResponseData(session, { androidAdb: androidAdbExecutor }) - : await buildPerfResponseData(session, { androidAdb: androidAdbExecutor }), + data: await buildPerfCommandData(params, session, request), }; } catch (error) { return { ok: false, error: normalizeError(error) }; } } +type PerfCommandRequest = { + ok: true; + area: PerfArea; + action: PerfAction; + kind?: PerfKind; + out?: string; +}; + +function resolvePerfCommandRequest(req: DaemonRequest): PerfCommandRequest | DaemonFailureResponse { + const area = readPerfArea(req.positionals?.[0]); + if (!area) { + return errorResponse('INVALID_ARGS', PERF_AREA_ERROR_MESSAGE); + } + + const action = readPerfAction(req.positionals?.[1]); + if (!action) { + return errorResponse('INVALID_ARGS', PERF_ACTION_ERROR_MESSAGE); + } + + const kindResult = readPerfKind(req.flags?.kind); + if (kindResult instanceof AppError) { + return { ok: false, error: normalizeError(kindResult) }; + } + const kind = kindResult; + const validationError = + validatePerfAreaAction(area, action) ?? validatePerfFlags(req, area, action, kind); + if (validationError) return validationError; + + return { + ok: true, + area, + action, + kind, + out: readOptionalStringFlag(req.flags?.out), + }; +} + +async function buildPerfCommandData( + params: ObservabilityParams, + session: SessionState, + request: PerfCommandRequest, +): Promise { + const { sessionName, sessionStore, androidAdbExecutor } = params; + if (request.area === 'memory') { + return await buildPerfMemoryResponseData(session, { + action: request.action, + kind: request.kind, + out: request.out, + cwd: params.req.meta?.cwd, + sessionName, + sessionStore, + androidAdb: androidAdbExecutor, + }); + } + if (request.area === 'frames') { + return await buildPerfFramesResponseData(session, { androidAdb: androidAdbExecutor }); + } + return await buildPerfResponseData(session, { androidAdb: androidAdbExecutor }); +} + +function readPerfArea(value: unknown): PerfArea | undefined { + const area = (value ?? 'metrics').toString().toLowerCase(); + return isPerfArea(area) ? area : undefined; +} + +function readPerfAction(value: unknown): PerfAction | undefined { + const action = (value ?? 'sample').toString().toLowerCase(); + return isPerfAction(action) ? action : undefined; +} + +function readPerfKind(value: unknown): PerfKind | undefined | AppError { + if (value === undefined) return undefined; + if (typeof value !== 'string' || !isPerfKind(value)) { + return new AppError('INVALID_ARGS', PERF_KIND_ERROR_MESSAGE); + } + return value; +} + +function validatePerfAreaAction( + area: PerfArea, + action: PerfAction, +): DaemonFailureResponse | undefined { + if (action !== 'snapshot' || area === 'memory') return undefined; + return errorResponse('INVALID_ARGS', 'perf snapshot is only supported under perf memory'); +} + +function validatePerfFlags( + req: DaemonRequest, + area: PerfArea, + action: PerfAction, + kind: PerfKind | undefined, +): DaemonFailureResponse | undefined { + return validatePerfOutFlag(req.flags?.out, action) ?? validatePerfKindFlag(kind, area, action); +} + +function validatePerfOutFlag(out: unknown, action: PerfAction): DaemonFailureResponse | undefined { + if (action !== 'sample' || !out) return undefined; + return errorResponse('INVALID_ARGS', '--out is only supported with perf memory snapshot'); +} + +function validatePerfKindFlag( + kind: PerfKind | undefined, + area: PerfArea, + action: PerfAction, +): DaemonFailureResponse | undefined { + if (!kind) return undefined; + if (area !== 'memory' || action !== 'snapshot') { + return errorResponse('INVALID_ARGS', '--kind is only supported with perf memory snapshot'); + } + if (isPerfMemoryKind(kind)) return undefined; + return errorResponse('INVALID_ARGS', PERF_MEMORY_KIND_ERROR_MESSAGE); +} + +function readOptionalStringFlag(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + // --------------------------------------------------------------------------- // logs // --------------------------------------------------------------------------- diff --git a/src/daemon/handlers/session-perf.ts b/src/daemon/handlers/session-perf.ts index eb19d8861..071dbbafa 100644 --- a/src/daemon/handlers/session-perf.ts +++ b/src/daemon/handlers/session-perf.ts @@ -1,23 +1,33 @@ +import path from 'node:path'; import type { SessionAction, SessionState } from '../types.ts'; -import { normalizeError } from '../../utils/errors.ts'; +import { AppError, normalizeError } from '../../utils/errors.ts'; import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts'; import { + ANDROID_HPROF_SNAPSHOT_DESCRIPTION, + ANDROID_HPROF_SNAPSHOT_METHOD, ANDROID_CPU_SAMPLE_DESCRIPTION, ANDROID_CPU_SAMPLE_METHOD, ANDROID_FRAME_SAMPLE_DESCRIPTION, ANDROID_FRAME_SAMPLE_METHOD, ANDROID_MEMORY_SAMPLE_DESCRIPTION, ANDROID_MEMORY_SAMPLE_METHOD, + captureAndroidHeapSnapshot, sampleAndroidCpuPerf, sampleAndroidFramePerf, sampleAndroidMemoryPerf, } from '../../platforms/android/perf.ts'; import { + APPLE_MEMGRAPH_SNAPSHOT_DESCRIPTION, + APPLE_MEMGRAPH_SNAPSHOT_METHOD, + buildAppleMemorySnapshotSupport, buildAppleFrameSamplingMetadata, buildAppleSamplingMetadata, + captureAppleMemorySnapshot, sampleAppleFramePerf, sampleApplePerfMetrics, } from '../../platforms/ios/perf.ts'; +import type { PerfKind } from '../../contracts/perf.ts'; +import { SessionStore } from '../session-store.ts'; import { PERF_STARTUP_SAMPLE_LIMIT, PERF_UNAVAILABLE_REASON, @@ -29,7 +39,7 @@ import { type SettledMetricResult = PromiseSettledResult>; type MetricResult = | ({ available: true } & Record) - | { available: false; reason: string; error: ReturnType }; + | { available: false; reason: string; error?: ReturnType }; type PerfResponseData = { session: string; platform: string; @@ -42,9 +52,23 @@ type PerfFramesResponseData = Omit & { metrics: { fps: unknown }; sampling: { fps: unknown }; }; +type PerfMemoryResponseData = Omit & { + metrics?: { memory: unknown }; + artifact?: Record; + sampling: { memory?: unknown; snapshot?: unknown }; + support?: Record; +}; type BuildPerfResponseOptions = { androidAdb?: AndroidAdbExecutor; }; +type BuildPerfMemoryResponseOptions = BuildPerfResponseOptions & { + action: 'sample' | 'snapshot'; + kind?: PerfKind; + out?: string; + cwd?: string; + sessionName: string; + sessionStore: SessionStore; +}; const RELATED_PERF_ACTION_LIMIT = 12; @@ -124,6 +148,37 @@ export async function buildPerfFramesResponseData( return response; } +export async function buildPerfMemoryResponseData( + session: SessionState, + options: BuildPerfMemoryResponseOptions, +): Promise { + const response = buildBasePerfMemoryResponse(session); + + if (!supportsPlatformPerfMetrics(session)) { + if (options.action === 'snapshot') { + const kind = resolveMemorySnapshotKind(session, options.kind); + response.artifact = unsupportedMemorySnapshotArtifact(session, kind); + response.support = + readSupportRecord(response.artifact.support) ?? buildMemorySnapshotSupport(session); + return response; + } + response.metrics = { memory: { available: false, reason: PERF_UNAVAILABLE_REASON } }; + return response; + } + + if (options.action === 'sample') { + response.metrics = { + memory: await buildMemorySampleMetric(session, options), + }; + return response; + } + + response.artifact = await buildMemorySnapshotArtifact(session, options); + response.support = + readSupportRecord(response.artifact.support) ?? buildMemorySnapshotSupport(session); + return response; +} + function buildBasePerfResponse(session: SessionState): PerfResponseData { const startupSamples = readStartupPerfSamples(session.actions); const latestStartupSample = startupSamples.at(-1); @@ -192,6 +247,19 @@ function buildBasePerfFramesResponse(session: SessionState): PerfFramesResponseD }; } +function buildBasePerfMemoryResponse(session: SessionState): PerfMemoryResponseData { + return { + session: session.name, + platform: session.device.platform, + device: session.device.name, + deviceId: session.device.id, + sampling: { + memory: buildMemorySamplingMetadata(session), + snapshot: buildMemorySnapshotSamplingMetadata(session), + }, + }; +} + function applyMissingAppPerfMetrics(response: PerfResponseData, session: SessionState): void { const reason = buildMissingAppPerfReason(session); response.metrics.fps = { available: false, reason }; @@ -294,6 +362,35 @@ function buildPlatformSamplingMetadata(session: SessionState): Record { + if (session.device.platform === 'android') { + return { + method: ANDROID_MEMORY_SAMPLE_METHOD, + description: ANDROID_MEMORY_SAMPLE_DESCRIPTION, + unit: 'kB', + topConsumerLimit: 5, + }; + } + return buildAppleSamplingMetadata(session.device).memory as Record; +} + +function buildMemorySnapshotSamplingMetadata(session: SessionState): Record { + if (session.device.platform === 'android') { + return { + method: ANDROID_HPROF_SNAPSHOT_METHOD, + description: ANDROID_HPROF_SNAPSHOT_DESCRIPTION, + defaultKind: 'android-hprof', + artifactOnly: true, + }; + } + return { + method: APPLE_MEMGRAPH_SNAPSHOT_METHOD, + description: APPLE_MEMGRAPH_SNAPSHOT_DESCRIPTION, + defaultKind: 'memgraph', + artifactOnly: true, + }; +} + function buildFrameSamplingMetadata(session: SessionState): Record { if (session.device.platform === 'android') { return { @@ -355,6 +452,152 @@ async function sampleApplePerfResultsForSession( }; } +async function buildMemorySampleMetric( + session: SessionState, + options: BuildPerfResponseOptions, +): Promise { + if (!session.appBundleId) { + return { available: false, reason: buildMissingAppPerfReason(session) }; + } + + const result = + session.device.platform === 'android' + ? await settleMetric( + sampleAndroidMemoryPerf(session.device, session.appBundleId, { + adb: options.androidAdb, + }), + ) + : await settleMetric(sampleAppleMemoryPerf(session)); + return buildMetricResult(result); +} + +async function sampleAppleMemoryPerf(session: SessionState): Promise> { + if (!session.appBundleId) { + throw new AppError('INVALID_ARGS', buildMissingAppPerfReason(session)); + } + const processSample = await sampleApplePerfMetrics(session.device, session.appBundleId); + return processSample.memory; +} + +async function buildMemorySnapshotArtifact( + session: SessionState, + options: BuildPerfMemoryResponseOptions, +): Promise> { + if (!session.appBundleId) { + throw new AppError('INVALID_ARGS', buildMissingAppPerfReason(session), { + hint: 'Run open first so perf memory snapshot can resolve the app process.', + }); + } + + const kind = resolveMemorySnapshotKind(session, options.kind); + const outPath = resolveMemorySnapshotOutPath(options, kind); + if (session.device.platform === 'android') { + if (kind !== 'android-hprof') return unsupportedMemorySnapshotArtifact(session, kind); + return await captureAndroidHeapSnapshot(session.device, session.appBundleId, outPath, { + adb: options.androidAdb, + }); + } + if (kind !== 'memgraph') return unsupportedMemorySnapshotArtifact(session, kind); + return await captureAppleMemorySnapshot(session.device, session.appBundleId, outPath); +} + +function resolveMemorySnapshotKind( + session: SessionState, + requestedKind: PerfKind | undefined, +): PerfKind { + if (requestedKind) return requestedKind; + return session.device.platform === 'android' ? 'android-hprof' : 'memgraph'; +} + +function resolveMemorySnapshotOutPath( + options: BuildPerfMemoryResponseOptions, + kind: PerfKind, +): string { + if (options.out) return SessionStore.expandHome(options.out, options.cwd); + const extension = + kind === 'android-hprof' ? 'hprof' : kind === 'memgraph' ? 'memgraph' : 'artifact'; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const sessionDir = options.sessionStore.ensureSessionDir(options.sessionName); + return path.join(sessionDir, 'artifacts', `memory-${kind}-${timestamp}.${extension}`); +} + +function unsupportedMemorySnapshotArtifact( + session: SessionState, + kind: PerfKind, +): Record { + const support = buildMemorySnapshotSupport(session); + const guidance = buildUnsupportedMemorySnapshotGuidance(session, kind); + return { + available: false, + kind, + reason: guidance.reason, + hint: guidance.hint, + support, + }; +} + +function buildUnsupportedMemorySnapshotGuidance( + session: SessionState, + kind: PerfKind, +): { reason: string; hint: string } { + if (session.device.platform === 'android') { + return { + reason: `Android perf memory snapshot supports android-hprof, not ${kind}.`, + hint: 'Use perf memory snapshot --kind android-hprof for Android Java heap artifacts.', + }; + } + if (session.device.platform === 'ios' || session.device.platform === 'macos') { + return { + reason: `Apple perf memory snapshot supports memgraph, not ${kind}.`, + hint: 'Use perf memory snapshot --kind memgraph for supported Apple app sessions.', + }; + } + return { + reason: `Memory snapshot artifacts are not supported on ${session.device.platform}.`, + hint: 'Use perf memory sample where supported, or run the snapshot against Android, iOS simulator, or macOS.', + }; +} + +function buildMemorySnapshotSupport(session: SessionState): Record { + if (session.device.platform === 'android') { + return { + platform: session.device.platform, + defaultKind: 'android-hprof', + androidHprof: true, + memgraph: false, + heapprofd: false, + heapprofdDecision: + 'Deferred until Android Perfetto/heapprofd plumbing is available in the perf trace slice.', + }; + } + if (session.device.platform !== 'ios' && session.device.platform !== 'macos') { + return { + platform: session.device.platform, + defaultKind: 'memgraph', + androidHprof: false, + memgraph: false, + heapprofd: false, + reason: 'Memory snapshot artifacts are available only on Android, iOS simulator, and macOS.', + hint: 'Use perf memory sample where supported, or switch to a platform with memory artifact support.', + heapprofdDecision: + 'Deferred because heapprofd is Android/Perfetto-specific and outside this memory artifact slice.', + }; + } + return { + ...buildAppleMemorySnapshotSupport(session.device), + androidHprof: false, + heapprofd: false, + heapprofdDecision: + 'Deferred because heapprofd is Android/Perfetto-specific and outside this memory artifact slice.', + }; +} + +function readSupportRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + async function settleMetric(promise: Promise): Promise { try { return { status: 'fulfilled', value: (await promise) as Record }; diff --git a/src/platforms/android/__tests__/perf.test.ts b/src/platforms/android/__tests__/perf.test.ts index 274f5a726..2f5dbe050 100644 --- a/src/platforms/android/__tests__/perf.test.ts +++ b/src/platforms/android/__tests__/perf.test.ts @@ -1,6 +1,23 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { parseAndroidFramePerfSample, parseAndroidMemInfoSample } from '../perf.ts'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + captureAndroidHeapSnapshot, + parseAndroidFramePerfSample, + parseAndroidMemInfoSample, +} from '../perf.ts'; +import type { AndroidAdbExecutor } from '../adb-executor.ts'; +import type { DeviceInfo } from '../../../utils/device.ts'; + +const ANDROID_DEVICE: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel API', + kind: 'emulator', + booted: true, +}; test('parseAndroidMemInfoSample supports legacy total row layout', () => { const sample = parseAndroidMemInfoSample( @@ -21,6 +38,147 @@ test('parseAndroidMemInfoSample supports legacy total row layout', () => { assert.equal(sample.totalRssKb, undefined); }); +test('parseAndroidMemInfoSample returns bounded top memory consumers', () => { + const sample = parseAndroidMemInfoSample( + [ + '** MEMINFO in pid 9953 [com.example.app] **', + ' Pss Private Private Swapped Heap Heap Heap', + ' Total Dirty Clean Dirty Size Alloc Free', + ' ------ ------ ------ ------ ------ ------ ------', + ' Native Heap 12000 10000 0 0 20000 14000 6000', + ' Dalvik Heap 32000 20000 0 0 50000 40000 10000', + ' Other mmap 8000 1000 7000 0', + ' TOTAL 52000 31000 7000 0 70000 54000 16000', + 'App Summary', + ' TOTAL PSS: 52,000 TOTAL RSS: 100,112 TOTAL SWAP PSS: 0', + ].join('\n'), + 'com.example.app', + '2026-04-01T10:00:00.000Z', + ); + + assert.equal(sample.totalPssKb, 52000); + assert.deepEqual(sample.topConsumers, [ + { name: 'Dalvik Heap', pssKb: 32000 }, + { name: 'Native Heap', pssKb: 12000 }, + { name: 'Other mmap', pssKb: 8000 }, + ]); +}); + +test('captureAndroidHeapSnapshot resolves pid, dumps heap, pulls artifact, and cleans remote path', async () => { + const calls: string[][] = []; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-android-hprof-test-')); + const outPath = path.join(tmpDir, 'app.hprof'); + const adb: AndroidAdbExecutor = async (args) => { + calls.push([...args]); + if (args.join(' ') === 'shell pidof com.example.app') { + return { stdout: '4242\n', stderr: '', exitCode: 0 }; + } + if (args.slice(0, 4).join(' ') === 'shell am dumpheap com.example.app') { + assert.match( + args[4] ?? '', + /^\/data\/local\/tmp\/agent-device-com\.example\.app-\d+\.hprof$/, + ); + return { stdout: 'Dumping Java heap to ', stderr: '', exitCode: 0 }; + } + if (args[0] === 'pull') { + assert.equal(args[2], outPath); + fs.writeFileSync(outPath, 'hprof-bytes'); + return { stdout: '', stderr: '', exitCode: 0 }; + } + if (args.slice(0, 3).join(' ') === 'shell rm -f') { + return { stdout: '', stderr: '', exitCode: 0 }; + } + throw new Error(`unexpected adb call: ${args.join(' ')}`); + }; + + try { + const snapshot = await captureAndroidHeapSnapshot(ANDROID_DEVICE, 'com.example.app', outPath, { + adb, + }); + + assert.equal(snapshot.kind, 'android-hprof'); + assert.equal(snapshot.path, outPath); + assert.equal(snapshot.sizeBytes, 'hprof-bytes'.length); + assert.equal(snapshot.pid, 4242); + assert.equal(calls[0]?.join(' '), 'shell pidof com.example.app'); + assert.equal(calls[1]?.slice(0, 4).join(' '), 'shell am dumpheap com.example.app'); + assert.equal(calls[2]?.[0], 'pull'); + assert.equal(calls.at(-1)?.slice(0, 3).join(' '), 'shell rm -f'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +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 }), + /No running Android process found/, + ); +}); + +test('captureAndroidHeapSnapshot cleans remote path when dumpheap fails', async () => { + const calls: string[][] = []; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-android-hprof-dump-fail-')); + const outPath = path.join(tmpDir, 'app.hprof'); + const adb: AndroidAdbExecutor = async (args) => { + calls.push([...args]); + if (args.join(' ') === 'shell pidof com.example.app') { + return { stdout: '4242\n', stderr: '', exitCode: 0 }; + } + if (args.slice(0, 4).join(' ') === 'shell am dumpheap com.example.app') { + return { stdout: '', stderr: 'Process not debuggable', exitCode: 1 }; + } + if (args.slice(0, 3).join(' ') === 'shell rm -f') { + return { stdout: '', stderr: '', exitCode: 0 }; + } + throw new Error(`unexpected adb call: ${args.join(' ')}`); + }; + + try { + await assert.rejects( + () => captureAndroidHeapSnapshot(ANDROID_DEVICE, 'com.example.app', outPath, { adb }), + /Failed to capture Android heap dump/, + ); + assert.equal(calls.at(-1)?.slice(0, 3).join(' '), 'shell rm -f'); + assert.equal(fs.existsSync(outPath), false); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test('captureAndroidHeapSnapshot removes partial local artifact when pull fails', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-android-hprof-pull-fail-')); + const outPath = path.join(tmpDir, 'app.hprof'); + const adb: AndroidAdbExecutor = async (args) => { + if (args.join(' ') === 'shell pidof com.example.app') { + return { stdout: '4242\n', stderr: '', exitCode: 0 }; + } + if (args.slice(0, 4).join(' ') === 'shell am dumpheap com.example.app') { + return { stdout: '', stderr: '', exitCode: 0 }; + } + if (args[0] === 'pull') { + fs.writeFileSync(outPath, 'partial-hprof'); + return { stdout: '', stderr: 'pull failed', exitCode: 1 }; + } + if (args.slice(0, 3).join(' ') === 'shell rm -f') { + return { stdout: '', stderr: '', exitCode: 0 }; + } + throw new Error(`unexpected adb call: ${args.join(' ')}`); + }; + + try { + await assert.rejects( + () => captureAndroidHeapSnapshot(ANDROID_DEVICE, 'com.example.app', outPath, { adb }), + /Failed to pull Android heap dump/, + ); + assert.equal(fs.existsSync(outPath), false); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + test('parseAndroidFramePerfSample summarizes dropped frame percentage from framestats rows', () => { const sample = parseAndroidFramePerfSample( [ diff --git a/src/platforms/android/perf.ts b/src/platforms/android/perf.ts index a21231616..97384d291 100644 --- a/src/platforms/android/perf.ts +++ b/src/platforms/android/perf.ts @@ -1,3 +1,5 @@ +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 { splitNonEmptyTrimmedLines } from '../../utils/parsing.ts'; @@ -20,8 +22,14 @@ export const ANDROID_CPU_SAMPLE_DESCRIPTION = export const ANDROID_MEMORY_SAMPLE_METHOD = 'adb-shell-dumpsys-meminfo'; export const ANDROID_MEMORY_SAMPLE_DESCRIPTION = 'Memory snapshot from adb shell dumpsys meminfo . Values are reported in kilobytes.'; +export const ANDROID_HPROF_SNAPSHOT_METHOD = 'adb-shell-am-dumpheap'; +export const ANDROID_HPROF_SNAPSHOT_DESCRIPTION = + 'Java heap dump captured with adb shell am dumpheap, pulled to a local artifact path.'; const ANDROID_PERF_TIMEOUT_MS = 15_000; +const ANDROID_HEAP_DUMP_TIMEOUT_MS = 120_000; +const ANDROID_REMOTE_HEAP_DIR = '/data/local/tmp'; +const ANDROID_MEMORY_TOP_CONSUMER_LIMIT = 5; export type AndroidPerfOptions = { adb?: AndroidAdbExecutor; @@ -39,6 +47,24 @@ export type AndroidMemoryPerfSample = { totalRssKb?: number; measuredAt: string; method: typeof ANDROID_MEMORY_SAMPLE_METHOD; + topConsumers?: AndroidMemoryConsumer[]; +}; + +export type AndroidMemoryConsumer = { + name: string; + pssKb: number; +}; + +export type AndroidHeapSnapshotResult = { + available: true; + kind: 'android-hprof'; + path: string; + sizeBytes: number; + measuredAt: string; + method: typeof ANDROID_HPROF_SNAPSHOT_METHOD; + packageName: string; + pid: number; + remotePath: string; }; export async function sampleAndroidCpuPerf( @@ -73,6 +99,102 @@ export async function sampleAndroidMemoryPerf( } } +export async function captureAndroidHeapSnapshot( + device: DeviceInfo, + packageName: string, + outPath: string, + options: AndroidPerfOptions = {}, +): Promise { + const adb = resolveAndroidAdbExecutor(device, options.adb); + const pid = await resolveAndroidAppPid(adb, packageName); + const remotePath = buildAndroidRemoteHeapPath(packageName); + await fs.mkdir(path.dirname(outPath), { recursive: true }); + const hadLocalArtifact = await fileExists(outPath); + try { + const dumpResult = await adb(['shell', 'am', 'dumpheap', packageName, remotePath], { + allowFailure: true, + timeoutMs: ANDROID_HEAP_DUMP_TIMEOUT_MS, + }); + if (dumpResult.exitCode !== 0) { + throw new AppError( + 'COMMAND_FAILED', + `Failed to capture Android heap dump for ${packageName}`, + { + kind: 'android-hprof', + package: packageName, + pid, + remotePath, + exitCode: dumpResult.exitCode, + stdout: dumpResult.stdout, + stderr: dumpResult.stderr, + hint: resolveAndroidHeapDumpHint(dumpResult.stdout, dumpResult.stderr), + }, + ); + } + + const pullResult = await adb(['pull', remotePath, outPath], { + allowFailure: true, + timeoutMs: ANDROID_HEAP_DUMP_TIMEOUT_MS, + }); + if (pullResult.exitCode !== 0) { + await cleanupLocalArtifact(outPath, hadLocalArtifact); + throw new AppError('COMMAND_FAILED', `Failed to pull Android heap dump for ${packageName}`, { + kind: 'android-hprof', + package: packageName, + pid, + remotePath, + path: outPath, + exitCode: pullResult.exitCode, + stdout: pullResult.stdout, + stderr: pullResult.stderr, + hint: 'Verify the daemon can write the requested --out path and retry. The heap dump stays on-device only until cleanup runs.', + }); + } + + const stat = await fs.stat(outPath).catch(() => null); + if (!stat?.isFile() || stat.size <= 0) { + await cleanupLocalArtifact(outPath, hadLocalArtifact); + throw new AppError('COMMAND_FAILED', `Android heap dump artifact is missing or empty`, { + kind: 'android-hprof', + package: packageName, + pid, + path: outPath, + remotePath, + hint: 'Retry with a writable --out path. If the file is still empty, inspect adb pull output with --debug.', + }); + } + + return { + available: true, + kind: 'android-hprof', + path: outPath, + sizeBytes: stat.size, + measuredAt: new Date().toISOString(), + method: ANDROID_HPROF_SNAPSHOT_METHOD, + packageName, + pid, + remotePath, + }; + } finally { + await adb(['shell', 'rm', '-f', remotePath], { + allowFailure: true, + timeoutMs: ANDROID_PERF_TIMEOUT_MS, + }).catch(() => {}); + } +} + +async function fileExists(filePath: string): Promise { + return await fs + .stat(filePath) + .then((stat) => stat.isFile()) + .catch(() => false); +} + +async function cleanupLocalArtifact(filePath: string, existedBefore: boolean): Promise { + if (existedBefore) return; + await fs.rm(filePath, { force: true }).catch(() => {}); +} + function parseAndroidCpuInfoSample( stdout: string, packageName: string, @@ -140,9 +262,80 @@ export function parseAndroidMemInfoSample( totalRssKb: matchLabeledNumber(stdout, 'TOTAL RSS'), measuredAt, method: ANDROID_MEMORY_SAMPLE_METHOD, + topConsumers: parseAndroidMemInfoTopConsumers(stdout), }; } +async function resolveAndroidAppPid(adb: AndroidAdbExecutor, packageName: string): Promise { + const result = await adb(['shell', 'pidof', packageName], { + allowFailure: true, + timeoutMs: ANDROID_PERF_TIMEOUT_MS, + }); + const pid = result.stdout + .trim() + .split(/\s+/) + .map((token) => Number(token)) + .find((value) => Number.isInteger(value) && value > 0); + if (result.exitCode === 0 && pid !== undefined) return pid; + throw new AppError('COMMAND_FAILED', `No running Android process found for ${packageName}`, { + kind: 'android-hprof', + package: packageName, + stdout: result.stdout, + stderr: result.stderr, + hint: 'Run open for this session again to ensure the Android app is active, then retry perf memory snapshot.', + }); +} + +function buildAndroidRemoteHeapPath(packageName: string): string { + const safePackage = packageName.replace(/[^a-zA-Z0-9._-]/g, '_'); + return `${ANDROID_REMOTE_HEAP_DIR}/agent-device-${safePackage}-${Date.now()}.hprof`; +} + +function resolveAndroidHeapDumpHint(stdout: string, stderr: string): string { + const text = `${stdout}\n${stderr}`.toLowerCase(); + if (text.includes('profileable') || text.includes('debuggable') || text.includes('not allowed')) { + return 'Android heap dumps require a debuggable/profileable app process on many devices. Use a debug/profileable build, reopen the app, then retry.'; + } + if (text.includes('permission') || text.includes('denied')) { + return 'The device denied heap dump access. Use a debug/profileable build or a device image that permits app heap dumping.'; + } + return 'Reopen the app to refresh the process, then retry perf memory snapshot. If it still fails, run with --debug and inspect adb am dumpheap output.'; +} + +function parseAndroidMemInfoTopConsumers(stdout: string): AndroidMemoryConsumer[] | undefined { + const consumers = stdout.split('\n').flatMap((line) => readAndroidMemInfoConsumer(line) ?? []); + const topConsumers = consumers + .sort((left, right) => right.pssKb - left.pssKb) + .slice(0, ANDROID_MEMORY_TOP_CONSUMER_LIMIT); + return topConsumers.length > 0 ? topConsumers : undefined; +} + +function readAndroidMemInfoConsumer(rawLine: string): AndroidMemoryConsumer | undefined { + const line = rawLine.trim(); + if (shouldSkipAndroidMemInfoConsumerLine(line)) return undefined; + const match = line.match(/^(.+?)\s+([0-9][0-9,]*(?:\(\d+\))?)(?:\s|$)/); + if (!match) return undefined; + return buildAndroidMemInfoConsumer(match[1], match[2]); +} + +function shouldSkipAndroidMemInfoConsumerLine(line: string): boolean { + if (!line || line.startsWith('**') || line.startsWith('-') || line.includes(':')) return true; + const looksLikeDataRow = /^\S.+\s+\d/.test(line); + const isHeaderRow = /^(pss|total|native|dalvik|unknown|app summary\b)/i.test(line); + return isHeaderRow && !looksLikeDataRow; +} + +function buildAndroidMemInfoConsumer( + rawName: string | undefined, + rawPssKb: string | undefined, +): AndroidMemoryConsumer | undefined { + const name = rawName?.trim(); + const pssKb = rawPssKb ? parseNumericToken(rawPssKb) : null; + if (!name || name === 'TOTAL' || pssKb === null || pssKb <= 0) return undefined; + if (/^(pss|private|shared|heap|size|alloc|free)$/i.test(name)) return undefined; + return { name, pssKb }; +} + function annotateAndroidPerfSamplingError( metric: 'cpu' | 'memory', packageName: string, diff --git a/src/platforms/ios/__tests__/perf.test.ts b/src/platforms/ios/__tests__/perf.test.ts index 3c9353b7c..a79f5704e 100644 --- a/src/platforms/ios/__tests__/perf.test.ts +++ b/src/platforms/ios/__tests__/perf.test.ts @@ -9,10 +9,16 @@ vi.mock('../../../utils/exec.ts', async (importOriginal) => { return { ...actual, runCmd: vi.fn(actual.runCmd) }; }); -import { parseApplePsOutput, sampleAppleFramePerf, sampleApplePerfMetrics } from '../perf.ts'; +import { + captureAppleMemorySnapshot, + parseApplePsOutput, + sampleAppleFramePerf, + sampleApplePerfMetrics, +} from '../perf.ts'; import { parseAppleFramePerfSample } from '../perf-frame.ts'; import { runCmd } from '../../../utils/exec.ts'; import type { DeviceInfo } from '../../../utils/device.ts'; +import { AppError } from '../../../utils/errors.ts'; const mockRunCmd = vi.mocked(runCmd); type MockRunCmdResult = Awaited>; @@ -194,6 +200,295 @@ test('sampleApplePerfMetrics uses simctl spawn ps for iOS simulators', async () } }); +test('captureAppleMemorySnapshot records memgraph for iOS simulator processes', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-sim-memgraph-')); + const appPath = path.join(tmpDir, 'Example.app'); + const outPath = path.join(tmpDir, 'app.memgraph'); + await fs.mkdir(appPath, { recursive: true }); + await fs.writeFile( + path.join(appPath, 'Info.plist'), + [ + '', + '', + 'CFBundleExecutableExampleSimExec', + '', + ].join(''), + 'utf8', + ); + + mockRunCmd.mockImplementation(async (cmd, args, options) => { + if (cmd === 'xcrun' && args.includes('get_app_container')) { + return { stdout: `${appPath}\n`, stderr: '', exitCode: 0 }; + } + if (cmd === 'plutil') { + return { stdout: '', stderr: 'mock fallback', exitCode: 1 }; + } + if (cmd === 'xcrun' && args.includes('ps')) { + return { + stdout: [ + `111 1.0 8192 ${path.join(appPath, 'ExampleSimExec')}`, + `222 1.0 16384 ${path.join(appPath, 'ExampleSimExec')} --helper`, + ].join('\n'), + stderr: '', + exitCode: 0, + }; + } + if (cmd === 'xcrun' && args.includes('leaks')) { + assert.equal(options?.timeoutMs, 120_000); + assert.deepEqual(args, [ + 'simctl', + 'spawn', + 'sim-1', + 'leaks', + `--outputGraph=${outPath}`, + '222', + ]); + await fs.writeFile(outPath, 'memgraph-bytes', 'utf8'); + return { stdout: '', stderr: '', exitCode: 0 }; + } + throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`); + }); + + try { + const snapshot = await captureAppleMemorySnapshot(IOS_SIMULATOR, 'com.example.sim', outPath); + assert.equal(snapshot.available, true); + if (snapshot.available !== true) assert.fail(JSON.stringify(snapshot)); + assert.equal(snapshot.kind, 'memgraph'); + assert.equal(snapshot.path, outPath); + assert.equal(snapshot.pid, 222); + assert.equal(snapshot.sizeBytes, 'memgraph-bytes'.length); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + +test('captureAppleMemorySnapshot records memgraph for macOS app processes', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-macos-memgraph-')); + const bundlePath = path.join(tmpDir, 'Example.app'); + const outPath = path.join(tmpDir, 'app.memgraph'); + await fs.mkdir(path.join(bundlePath, 'Contents'), { recursive: true }); + await fs.writeFile( + path.join(bundlePath, 'Contents', 'Info.plist'), + [ + '', + '', + 'CFBundleExecutableExampleExec', + '', + ].join(''), + 'utf8', + ); + + mockRunCmd.mockImplementation(async (cmd, args) => { + if (cmd === 'mdfind') { + return { stdout: `${bundlePath}\n`, stderr: '', exitCode: 0 }; + } + if (cmd === 'plutil') { + return { stdout: '', stderr: 'mock fallback', exitCode: 1 }; + } + if (cmd === 'ps') { + return { + stdout: `111 1.0 12000 ${path.join(bundlePath, 'Contents', 'MacOS', 'ExampleExec')}`, + stderr: '', + exitCode: 0, + }; + } + if (cmd === 'leaks') { + assert.deepEqual(args, [`--outputGraph=${outPath}`, '111']); + await fs.writeFile(outPath, 'mac-memgraph-bytes', 'utf8'); + return { stdout: '', stderr: '', exitCode: 0 }; + } + throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`); + }); + + try { + const snapshot = await captureAppleMemorySnapshot(MACOS_DEVICE, 'com.example.app', outPath); + assert.equal(snapshot.available, true); + if (snapshot.available !== true) assert.fail(JSON.stringify(snapshot)); + assert.equal(snapshot.path, outPath); + assert.equal(snapshot.pid, 111); + assert.equal(snapshot.sizeBytes, 'mac-memgraph-bytes'.length); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + +test('captureAppleMemorySnapshot removes partial memgraph when leaks exits nonzero', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-memgraph-fail-')); + const appPath = path.join(tmpDir, 'Example.app'); + const outPath = path.join(tmpDir, 'app.memgraph'); + await fs.mkdir(appPath, { recursive: true }); + await fs.writeFile( + path.join(appPath, 'Info.plist'), + [ + '', + '', + 'CFBundleExecutableExampleSimExec', + '', + ].join(''), + 'utf8', + ); + + mockRunCmd.mockImplementation(async (cmd, args) => { + if (cmd === 'xcrun' && args.includes('get_app_container')) { + return { stdout: `${appPath}\n`, stderr: '', exitCode: 0 }; + } + if (cmd === 'plutil') { + return { stdout: '', stderr: 'mock fallback', exitCode: 1 }; + } + if (cmd === 'xcrun' && args.includes('ps')) { + return { + stdout: `111 1.0 8192 ${path.join(appPath, 'ExampleSimExec')}`, + stderr: '', + exitCode: 0, + }; + } + if (cmd === 'xcrun' && args.includes('leaks')) { + await fs.writeFile(outPath, 'partial-memgraph', 'utf8'); + return { stdout: '', stderr: 'permission denied', exitCode: 1 }; + } + throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`); + }); + + try { + await assert.rejects( + () => captureAppleMemorySnapshot(IOS_SIMULATOR, 'com.example.sim', outPath), + /Failed to capture Apple memgraph/, + ); + assert.equal(await fileExists(outPath), false); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + +test('captureAppleMemorySnapshot removes partial memgraph and hints when leaks times out', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-memgraph-timeout-')); + const appPath = path.join(tmpDir, 'Example.app'); + const outPath = path.join(tmpDir, 'app.memgraph'); + await fs.mkdir(appPath, { recursive: true }); + await fs.writeFile( + path.join(appPath, 'Info.plist'), + [ + '', + '', + 'CFBundleExecutableExampleSimExec', + '', + ].join(''), + 'utf8', + ); + + mockRunCmd.mockImplementation(async (cmd, args) => { + if (cmd === 'xcrun' && args.includes('get_app_container')) { + return { stdout: `${appPath}\n`, stderr: '', exitCode: 0 }; + } + if (cmd === 'plutil') { + return { stdout: '', stderr: 'mock fallback', exitCode: 1 }; + } + if (cmd === 'xcrun' && args.includes('ps')) { + return { + stdout: `111 1.0 8192 ${path.join(appPath, 'ExampleSimExec')}`, + stderr: '', + exitCode: 0, + }; + } + if (cmd === 'xcrun' && args.includes('leaks')) { + await fs.writeFile(outPath, 'partial-memgraph', 'utf8'); + throw new AppError('COMMAND_FAILED', 'xcrun timed out after 120000ms', { + cmd, + args, + stdout: '', + stderr: '', + exitCode: -1, + timeoutMs: 120_000, + }); + } + throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`); + }); + + try { + await assert.rejects( + async () => { + await captureAppleMemorySnapshot(IOS_SIMULATOR, 'com.example.sim', outPath); + }, + (error) => { + assert.ok(error instanceof AppError); + assert.match(String(error.details?.hint), /timed out|longer than metric sampling/i); + return true; + }, + ); + assert.equal(await fileExists(outPath), false); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + +test('captureAppleMemorySnapshot reports physical iOS as unavailable', async () => { + const snapshot = await captureAppleMemorySnapshot( + IOS_DEVICE, + 'com.example.device', + '/tmp/app.memgraph', + ); + + assert.equal(snapshot.available, false); + if (snapshot.available !== false) assert.fail(JSON.stringify(snapshot)); + assert.equal(snapshot.kind, 'memgraph'); + assert.match(snapshot.reason, /Physical iOS device memgraph capture/i); + assert.equal(mockRunCmd.mock.calls.length, 0); +}); + +test('captureAppleMemorySnapshot reports iOS simulator without process tools as unavailable', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-sim-no-ps-')); + const appPath = path.join(tmpDir, 'Example.app'); + await fs.mkdir(appPath, { recursive: true }); + await fs.writeFile( + path.join(appPath, 'Info.plist'), + [ + '', + '', + 'CFBundleExecutableExampleSimExec', + '', + ].join(''), + 'utf8', + ); + + mockRunCmd.mockImplementation(async (cmd, args) => { + if (cmd === 'xcrun' && args.includes('get_app_container')) { + return { stdout: `${appPath}\n`, stderr: '', exitCode: 0 }; + } + if (cmd === 'plutil') { + return { stdout: '', stderr: 'mock fallback', exitCode: 1 }; + } + if (cmd === 'xcrun' && args.includes('ps')) { + throw new AppError( + 'COMMAND_FAILED', + 'The operation couldn’t be completed. No such file or directory', + { + cmd, + args, + stdout: '', + stderr: 'An error was encountered processing the command: No such file or directory', + exitCode: 2, + processExitError: true, + }, + ); + } + throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`); + }); + + try { + const snapshot = await captureAppleMemorySnapshot( + IOS_SIMULATOR, + 'com.example.sim', + path.join(tmpDir, 'app.memgraph'), + ); + assert.equal(snapshot.available, false); + if (snapshot.available !== false) assert.fail(JSON.stringify(snapshot)); + assert.match(snapshot.reason, /did not provide ps/i); + assert.equal(snapshot.support.memgraph, false); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + test('sampleApplePerfMetrics uses xctrace Activity Monitor for iOS devices', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-04-01T10:00:00.000Z')); @@ -408,6 +703,13 @@ function emptyRunResult(): MockRunCmdResult { return { stdout: '', stderr: '', exitCode: 0 }; } +async function fileExists(filePath: string): Promise { + return await fs + .stat(filePath) + .then((stat) => stat.isFile()) + .catch(() => false); +} + function makeActivityMonitorCaptureXmls(): string[] { const firstCaptureXml = makeActivityMonitorCaptureXml(); const secondCaptureXml = firstCaptureXml diff --git a/src/platforms/ios/perf.ts b/src/platforms/ios/perf.ts index d6f289964..e0f35e71d 100644 --- a/src/platforms/ios/perf.ts +++ b/src/platforms/ios/perf.ts @@ -37,8 +37,12 @@ const APPLE_CPU_SAMPLE_METHOD = 'ps-process-snapshot'; const APPLE_MEMORY_SAMPLE_METHOD = 'ps-process-snapshot'; const IOS_DEVICE_CPU_SAMPLE_METHOD = 'xctrace-activity-monitor'; const IOS_DEVICE_MEMORY_SAMPLE_METHOD = 'xctrace-activity-monitor'; +export const APPLE_MEMGRAPH_SNAPSHOT_METHOD = 'leaks-output-graph'; +export const APPLE_MEMGRAPH_SNAPSHOT_DESCRIPTION = + 'Memory graph captured with leaks --outputGraph for host-visible Apple app processes.'; const APPLE_PERF_TIMEOUT_MS = 15_000; +const APPLE_MEMORY_SNAPSHOT_TIMEOUT_MS = 120_000; // Physical device tracing can take materially longer to initialize than the 1s sample window. const IOS_DEVICE_PERF_RECORD_TIMEOUT_MS = 60_000; const IOS_DEVICE_PERF_EXPORT_TIMEOUT_MS = 15_000; @@ -61,6 +65,27 @@ export type AppleMemoryPerfSample = { matchedProcesses: string[]; }; +export type AppleMemorySnapshotResult = + | { + available: true; + kind: 'memgraph'; + path: string; + sizeBytes: number; + measuredAt: string; + method: typeof APPLE_MEMGRAPH_SNAPSHOT_METHOD; + appBundleId: string; + pid: number; + processName: string; + support: ReturnType; + } + | { + available: false; + kind: 'memgraph'; + reason: string; + hint: string; + support: ReturnType; + }; + type AppleProcessSample = { pid: number; cpuPercent: number; @@ -126,6 +151,155 @@ export async function sampleApplePerfMetrics( }); } +export async function captureAppleMemorySnapshot( + device: DeviceInfo, + appBundleId: string, + outPath: string, +): Promise { + const support = buildAppleMemorySnapshotSupport(device); + if (!support.memgraph) { + return { + available: false, + kind: 'memgraph', + reason: support.reason, + hint: support.hint, + support, + }; + } + + const target = await resolveAppleMemorySnapshotTarget(device, appBundleId, support); + if (target.available === false) return target; + const { process } = target; + await fs.mkdir(path.dirname(outPath), { recursive: true }); + const hadLocalArtifact = await fileExists(outPath); + let result: ExecResult; + try { + result = await runAppleMemorySnapshotTool(device, outPath, process.pid); + } catch (error) { + await cleanupLocalArtifact(outPath, hadLocalArtifact); + throw annotateAppleMemorySnapshotToolError(device, appBundleId, process, outPath, error); + } + if (result.exitCode !== 0) { + await cleanupLocalArtifact(outPath, hadLocalArtifact); + throw new AppError('COMMAND_FAILED', `Failed to capture Apple memgraph for ${appBundleId}`, { + kind: 'memgraph', + appBundleId, + pid: process.pid, + processName: path.basename(readProcessCommandToken(process.command)), + path: outPath, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + hint: resolveAppleMemorySnapshotHint(device, result.stdout, result.stderr), + }); + } + + const stat = await fs.stat(outPath).catch(() => null); + if (!stat?.isFile() || stat.size <= 0) { + await cleanupLocalArtifact(outPath, hadLocalArtifact); + throw new AppError('COMMAND_FAILED', 'Apple memgraph artifact is missing or empty', { + kind: 'memgraph', + appBundleId, + pid: process.pid, + path: outPath, + hint: 'Retry with a writable --out path. If the file is still empty, run with --debug and inspect leaks output.', + }); + } + + return { + available: true, + kind: 'memgraph', + path: outPath, + sizeBytes: stat.size, + measuredAt: new Date().toISOString(), + method: APPLE_MEMGRAPH_SNAPSHOT_METHOD, + appBundleId, + pid: process.pid, + processName: path.basename(readProcessCommandToken(process.command)), + support, + }; +} + +async function runAppleMemorySnapshotTool( + device: DeviceInfo, + outPath: string, + pid: number, +): Promise { + if (device.platform === 'macos') { + return await runAppleToolCommand('leaks', [`--outputGraph=${outPath}`, String(pid)], { + allowFailure: true, + timeoutMs: APPLE_MEMORY_SNAPSHOT_TIMEOUT_MS, + }); + } + return await runXcrun( + buildSimctlArgsForDevice(device, [ + 'spawn', + device.id, + 'leaks', + `--outputGraph=${outPath}`, + String(pid), + ]), + { allowFailure: true, timeoutMs: APPLE_MEMORY_SNAPSHOT_TIMEOUT_MS }, + ); +} + +function annotateAppleMemorySnapshotToolError( + device: DeviceInfo, + appBundleId: string, + process: AppleProcessSample, + outPath: string, + error: unknown, +): AppError { + if (error instanceof AppError) { + const details = error.details ?? {}; + return new AppError( + error.code, + `Failed to capture Apple memgraph for ${appBundleId}`, + { + ...details, + kind: 'memgraph', + appBundleId, + pid: process.pid, + processName: path.basename(readProcessCommandToken(process.command)), + path: outPath, + hint: resolveAppleMemorySnapshotHint( + device, + typeof details.stdout === 'string' ? details.stdout : '', + typeof details.stderr === 'string' && details.stderr.length > 0 + ? details.stderr + : error.message, + ), + }, + error, + ); + } + return new AppError( + 'COMMAND_FAILED', + `Failed to capture Apple memgraph for ${appBundleId}`, + { + kind: 'memgraph', + appBundleId, + pid: process.pid, + processName: path.basename(readProcessCommandToken(process.command)), + path: outPath, + hint: 'Retry perf memory snapshot. If it still fails, run with --debug and inspect leaks output.', + }, + error, + ); +} + +async function fileExists(filePath: string): Promise { + return await fs + .stat(filePath) + .then((stat) => stat.isFile()) + .catch(() => false); +} + +async function cleanupLocalArtifact(filePath: string, existedBefore: boolean): Promise { + if (existedBefore) return; + await fs.rm(filePath, { force: true }).catch(() => {}); +} + export async function sampleAppleFramePerf( device: DeviceInfo, appBundleId: string, @@ -216,6 +390,55 @@ export function buildAppleSamplingMetadata(device: DeviceInfo): Record { + const processes = await readAppleProcessSamples(device, executable); + const process = processes.sort((left, right) => right.rssKb - left.rssKb)[0]; + if (process) return process; + throw new AppError('COMMAND_FAILED', `No running process found for ${appBundleId}`, { + kind: 'memgraph', + appBundleId, + hint: 'Run open for this session again to ensure the Apple app is active, then retry perf memory snapshot.', + }); +} + +async function resolveAppleMemorySnapshotTarget( + device: DeviceInfo, + appBundleId: string, + support: ReturnType, +): Promise< + | { available: true; process: AppleProcessSample } + | Extract +> { + try { + const executable = await resolveAppleExecutable(device, appBundleId); + return { + available: true, + process: await resolveAppleMemorySnapshotProcess(device, appBundleId, executable), + }; + } catch (error) { + if (isMissingIosSimulatorProcessToolError(device, error)) { + return { + available: false, + kind: 'memgraph', + reason: + 'iOS simulator memgraph capture needs process tools inside simctl spawn, but this simulator runtime did not provide ps.', + hint: 'Use perf memory sample when available, or retry memgraph on a simulator runtime that includes process tools such as ps and leaks.', + support: { ...support, memgraph: false }, + }; + } + throw error; + } +} + +function isMissingIosSimulatorProcessToolError(device: DeviceInfo, error: unknown): boolean { + if (device.platform !== 'ios' || device.kind !== 'simulator') return false; + if (!(error instanceof AppError)) return false; + const details = error.details ?? {}; + const args = Array.isArray(details.args) ? details.args.join(' ') : ''; + const stderr = typeof details.stderr === 'string' ? details.stderr : ''; + const message = `${error.message}\n${stderr}`.toLowerCase(); + return args.includes('simctl spawn') && args.includes(' ps ') && message.includes('no such file'); +} + +function resolveAppleMemorySnapshotHint( + device: DeviceInfo, + stdout: string, + stderr: string, +): string { + const text = `${stdout}\n${stderr}`.toLowerCase(); + if (text.includes('timed out') || text.includes('timeout')) { + return 'Apple memgraph capture can take longer than metric sampling. Keep the app running and retry; if it times out again, collect a smaller reproduction before capturing leaks --outputGraph.'; + } + if (text.includes('not found') || text.includes('no such file')) { + return 'Install Xcode command line tools and ensure leaks is available, then retry.'; + } + if (text.includes('permission') || text.includes('denied') || text.includes('not authorized')) { + return device.platform === 'macos' + ? 'Grant the agent terminal process permission to inspect this macOS app, then retry.' + : 'Keep the simulator booted and app running; if inspection is denied, retry with a debug simulator build.'; + } + return 'Keep the app process running and retry perf memory snapshot with --debug if the failure persists.'; +} + function matchesAppleExecutableProcess( command: string, executable: { executableName: string; executablePath?: string }, diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index ed1413dd8..7ca2cb25d 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -415,7 +415,7 @@ test('parseArgs accepts metro prepare arguments', () => { assert.equal(parsed.flags.metroProxyBaseUrl, 'https://proxy.example.test'); assert.equal(parsed.flags.metroBearerToken, 'secret'); assert.equal(parsed.flags.metroPreparePort, 9090); - assert.equal(parsed.flags.metroKind, 'expo'); + assert.equal(parsed.flags.kind, 'expo'); assert.equal(parsed.flags.metroRuntimeFile, './.agent-device/metro-runtime.json'); assert.equal(parsed.flags.metroNoReuseExisting, true); assert.equal(parsed.flags.metroNoInstallDeps, true); @@ -1009,7 +1009,7 @@ test('usage includes agent workflows, config, environment, and examples footers' assert.match(usageText, /Exploratory QA: agent-device help dogfood/); assert.match(usageText, /Agent Workflows:/); assert.match(usageText, /help workflow\s+Normal bootstrap, exploration, and validation loop/); - assert.match(usageText, /help debugging\s+Logs, network, alerts, diagnostics, and traces/); + assert.match(usageText, /help debugging\s+Logs, network, perf memory, and traces/); assert.match( usageText, /help react-devtools\s+React Native performance, profiling, component tree, and renders/, @@ -1187,6 +1187,10 @@ test('usageForCommand resolves debugging help topic', () => { assert.match(help, /runnerLogPath and requestLogPath/); assert.match(help, /requests\/\.ndjson holds daemon request diagnostics/); assert.match(help, /daemon\.log is global daemon lifecycle evidence/); + assert.match(help, /agent-device perf memory sample --json/); + assert.match(help, /Memory artifact \(android-hprof\): \/tmp\/app\.hprof \(42MB\)/); + 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/); }); diff --git a/src/utils/__tests__/perf-args.test.ts b/src/utils/__tests__/perf-args.test.ts index fbbbce493..defc39eb8 100644 --- a/src/utils/__tests__/perf-args.test.ts +++ b/src/utils/__tests__/perf-args.test.ts @@ -10,10 +10,18 @@ test('parseArgs accepts perf area subcommands', () => { const frames = parseArgs(['perf', 'frames'], { strictFlags: true }); assert.equal(frames.command, 'perf'); assert.deepEqual(frames.positionals, ['frames']); + + const memory = parseArgs(['perf', 'memory', 'snapshot', '--kind', 'memgraph'], { + strictFlags: true, + }); + assert.equal(memory.command, 'perf'); + assert.deepEqual(memory.positionals, ['memory', 'snapshot']); + assert.equal(memory.flags.kind, 'memgraph'); }); test('usageForCommand advertises perf area subcommands for metrics alias', () => { const help = usageForCommand('metrics'); assert.equal(help === null, false); - assert.match(help ?? '', /agent-device perf \[metrics\|frames\]/); + assert.match(help ?? '', /agent-device perf \[metrics\|frames\|memory\]/); + assert.match(help ?? '', /perf memory snapshot/); }); diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index 5f474f25f..f7a707355 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -150,12 +150,14 @@ const CLI_COMMAND_OVERRIDES = { helpDescription: 'Show foreground app/activity', }, perf: { - usageOverride: 'perf [metrics|frames] [sample]', - listUsageOverride: 'perf [metrics|frames]', + 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 ]', + listUsageOverride: 'perf [metrics|frames|memory]', 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', + 'Show session performance metrics, focused frame/jank health, or memory diagnostics artifacts. Bare perf and metrics are aliases for perf metrics.', + summary: 'Show session performance, frame health, and memory diagnostics', positionalArgs: ['area?', 'action?'], + allowedFlags: ['kind', 'out'], }, metro: { usageOverride: diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index 049d8e825..7d4f98f01 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -16,6 +16,7 @@ import { SCREENSHOT_SPECIFIC_FLAG_DEFINITIONS, type ScreenshotRequestFlags, } from '../contracts/screenshot.ts'; +import { PERF_KIND_VALUES } from '../contracts/perf.ts'; import { MAESTRO_COMPAT_TRACKER_URL, formatMaestroSupportedSubsetForCli, @@ -38,6 +39,7 @@ export type CliFlags = RemoteConfigMetroOptions & leaseBackend?: LeaseBackend; force?: boolean; noLogin?: boolean; + kind?: string; sessionLock?: 'reject' | 'strip'; sessionLocked?: boolean; sessionLockConflicts?: 'reject' | 'strip'; @@ -157,6 +159,7 @@ export const SELECTOR_SNAPSHOT_FLAGS = flagKeys('snapshotDepth', 'snapshotScope' export const METRO_PREPARE_FLAGS = flagKeys( 'metroProjectRoot', + 'kind', 'metroKind', 'metroPublicBaseUrl', 'metroProxyBaseUrl', @@ -385,11 +388,20 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageDescription: 'metro prepare: React Native project root (default: cwd)', }, { - key: 'metroKind', + key: 'kind', names: ['--kind'], type: 'enum', + 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', + }, + { + key: 'metroKind', + names: ['--metro-kind'], + type: 'enum', enumValues: ['auto', 'react-native', 'expo'], - usageLabel: '--kind auto|react-native|expo', + usageLabel: '--metro-kind auto|react-native|expo', usageDescription: 'metro prepare: detect or force the Metro launcher kind', }, { diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 614aad5ab..59b94742a 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -11,7 +11,7 @@ import { const AGENT_WORKFLOWS = [ { label: 'help workflow', description: 'Normal bootstrap, exploration, and validation loop' }, - { label: 'help debugging', description: 'Logs, network, alerts, diagnostics, and traces' }, + { label: 'help debugging', description: 'Logs, network, perf memory, and traces' }, { label: 'help react-native', description: 'React Native app automation hazards, overlays, Metro, and routing', @@ -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. 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. 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: @@ -297,6 +297,19 @@ Diagnostics and traces: agent-device trace stop ./traces/diagnostics.trace The trace path is positional. Do not use --path for trace start or trace stop. +Memory diagnostics: + Use perf memory when the symptom is leak/growth/OOM suspicion and you need agent-readable evidence. + agent-device perf memory sample --json + agent-device perf memory snapshot --kind android-hprof --out ./artifacts/app.hprof + agent-device perf memory snapshot --kind memgraph --out ./artifacts/app.memgraph + Example sample shape: + {"metrics":{"memory":{"available":true,"totalPssKb":562958,"totalRssKb":570304,"topConsumers":[{"name":"Dalvik Heap","pssKb":213456}]}}} + Example default snapshot output: + Memory artifact (android-hprof): /tmp/app.hprof (42MB) + Prefer perf memory sample over raw dumpsys/leaks output for first-pass agent diagnosis: it keeps arrays bounded, preserves the same memory source as perf metrics, and returns only memory data instead of startup/CPU/frame noise. + Prefer perf memory snapshot over printing heap/memgraph data: snapshots return path, size, kind, method, and support metadata while the large artifact stays on disk for external inspection. + Unsupported platforms return artifact.available=false with reason/hint; do not pretend a heap or memgraph was captured. + Stabilizers: Android animation-sensitive flows: agent-device settings animations off diff --git a/test/integration/provider-scenarios/android-lifecycle.test.ts b/test/integration/provider-scenarios/android-lifecycle.test.ts index a332ace1e..e9caba060 100644 --- a/test/integration/provider-scenarios/android-lifecycle.test.ts +++ b/test/integration/provider-scenarios/android-lifecycle.test.ts @@ -829,6 +829,50 @@ async function runAndroidAppControlAndObservabilityWorkflow( 'startup', ]); + const memorySample = await client.observability.perf({ + area: 'memory', + action: 'sample', + ...selection, + }); + const memoryMetrics = memorySample.metrics as Record; + assert.deepEqual(Object.keys(memoryMetrics), ['memory']); + assert.equal(memoryMetrics.memory?.available, true, JSON.stringify(memorySample)); + assert.equal(memoryMetrics.memory?.totalPssKb, 216524); + assert.deepEqual(Object.keys(memorySample.sampling as Record).sort(), [ + 'memory', + 'snapshot', + ]); + + const heapPath = path.join(world.tempRoot, 'demo.hprof'); + const memorySnapshot = await client.observability.perf({ + area: 'memory', + action: 'snapshot', + kind: 'android-hprof', + out: heapPath, + ...selection, + }); + const heapArtifact = memorySnapshot.artifact as Record; + assert.equal(heapArtifact.available, true, JSON.stringify(memorySnapshot)); + assert.equal(heapArtifact.kind, 'android-hprof'); + assert.equal(heapArtifact.path, heapPath); + assert.equal(heapArtifact.sizeBytes, 'provider-hprof-bytes'.length); + assert.equal(fs.existsSync(heapPath), true); + assertCommandCall(world.adbCalls, ['shell', 'pidof', 'com.example.demo']); + assert.ok( + world.adbCalls.some( + (call) => call.slice(0, 4).join(' ') === 'shell am dumpheap com.example.demo', + ), + JSON.stringify(world.adbCalls), + ); + assert.ok( + world.adbCalls.some((call) => call[0] === 'pull' && call[2] === heapPath), + JSON.stringify(world.adbCalls), + ); + assert.ok( + world.adbCalls.some((call) => call.slice(0, 3).join(' ') === 'shell rm -f'), + JSON.stringify(world.adbCalls), + ); + const frameCallStart = world.adbCalls.length; const frames = await client.observability.perf({ area: 'frames', diff --git a/test/integration/provider-scenarios/android-world.ts b/test/integration/provider-scenarios/android-world.ts index df39442ea..ecd3d52c3 100644 --- a/test/integration/provider-scenarios/android-world.ts +++ b/test/integration/provider-scenarios/android-world.ts @@ -83,6 +83,8 @@ export async function createAndroidSettingsWorld(options?: { updateAndroidProviderShellState(args, shellState); const stateResult = updateAndroidProviderAppState(args, appState); if (stateResult) return stateResult; + const heapResult = androidHeapDumpAdbResult(args); + if (heapResult) return heapResult; return androidAdbResult(args, shellState.searchText, shellState.clipboardText, { snapshotXml: options?.snapshotXml, dumpsysWindow: @@ -177,6 +179,18 @@ export async function createAndroidSettingsWorld(options?: { }; } +function androidHeapDumpAdbResult(args: string[]): AndroidAdbResult | undefined { + if (args.slice(0, 4).join(' ') === 'shell am dumpheap com.example.demo') { + return { stdout: 'Dumping Java heap\n', stderr: '', exitCode: 0 }; + } + if (args[0] === 'pull' && args[1]?.endsWith('.hprof') && args[2]) { + fs.mkdirSync(path.dirname(args[2]), { recursive: true }); + fs.writeFileSync(args[2], 'provider-hprof-bytes'); + return { stdout: `${args[1]}: 1 file pulled\n`, stderr: '', exitCode: 0 }; + } + return undefined; +} + async function createAndroidManifestApk( tempRoot: string, options: { fileName: string; packageName: string }, diff --git a/test/skillgym/suites/agent-device-smoke-suite.ts b/test/skillgym/suites/agent-device-smoke-suite.ts index 6979830b2..ba8f59c16 100644 --- a/test/skillgym/suites/agent-device-smoke-suite.ts +++ b/test/skillgym/suites/agent-device-smoke-suite.ts @@ -1329,6 +1329,29 @@ const SKILL_GUIDANCE_CASES: Case[] = [ outputs: [plannedCommand('perf frames'), /--json/i], forbiddenOutputs: [plannedCommand('react-devtools'), plannedCommand('network')], }), + makeCase({ + id: 'perf-memory-diagnostics', + contract: [ + 'App name: Agent Device Tester', + 'Platform: Android emulator', + 'The app is already open', + 'Symptom: memory grows after repeatedly opening the Settings diagnostics screen', + 'Need a compact first-pass memory sample, then a Java heap artifact only if the sample suggests escalation', + ], + task: 'Plan the commands for memory diagnostics without using React DevTools or debug symbols.', + outputs: [ + plannedCommand('perf memory sample'), + /--json/i, + plannedCommand('perf memory snapshot'), + /--kind\s+android-hprof/i, + /--out\s+\S+\.hprof/i, + ], + forbiddenOutputs: [ + plannedCommand('react-devtools'), + plannedCommand('debug'), + plannedCommand('perf frames'), + ], + }), makeCase({ id: 'react-devtools-profile-search', contract: [ diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 2cca05e8e..47e29d02d 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, 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, `{ 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.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..ffde0f122 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 memory sample --json +agent-device perf memory snapshot --kind android-hprof --out app.hprof +agent-device perf memory snapshot --kind memgraph --out app.memgraph ``` - `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 memory sample` returns a compact memory-only JSON blob for agents investigating growth/leaks without collecting a large artifact. It is better than raw memory command output for first-pass diagnosis because arrays are bounded, top offenders are compact, and the payload omits unrelated startup/CPU/frame data. +- Example sample shape: `{"metrics":{"memory":{"available":true,"totalPssKb":562958,"totalRssKb":570304,"topConsumers":[{"name":"Dalvik Heap","pssKb":213456}]}}}`. +- `perf memory snapshot` writes a heap/memgraph artifact to disk and returns path, size, kind, method, and support metadata. Large artifacts are never dumped into CLI/MCP/default JSON output. +- Example default snapshot output: `Memory artifact (android-hprof): /tmp/app.hprof (42MB)`. - 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: @@ -604,6 +611,9 @@ agent-device perf frames --json - `startup`: iOS simulator, iOS physical device, Android emulator/device - `memory` and `cpu`: Android emulator/device, macOS app sessions, iOS simulators with an active app session (`open ` first), and iOS physical devices with an active app session - `fps`: Android emulator/device app sessions and connected iOS device app sessions. iOS simulator and macOS frame health is reported unavailable because Apple tooling does not expose trustworthy app hitch data there. + - `perf memory snapshot --kind android-hprof`: Android emulator/device app sessions with a running debuggable/profileable process and permitted heap dumping + - `perf memory snapshot --kind memgraph`: iOS simulator and macOS app sessions with a running app process. Physical iOS devices report memgraph unavailable with a recovery hint. + - `perf memory trace --kind heapprofd`: deferred until Android Perfetto/heapprofd plumbing is available. - 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. diff --git a/website/docs/docs/debugging-profiling.md b/website/docs/docs/debugging-profiling.md index f9acceb76..9f0198805 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -98,10 +98,17 @@ agent-device perf --json agent-device metrics --json agent-device perf metrics --json agent-device perf frames --json +agent-device perf memory sample --json +agent-device perf memory snapshot --kind android-hprof --out app.hprof +agent-device perf memory snapshot --kind memgraph --out app.memgraph ``` - `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. +- `perf memory sample` returns a compact memory-only payload, preserving the memory metric source used by `perf metrics`. Prefer it over raw `dumpsys`/`leaks` output for first-pass agent diagnosis because it keeps arrays bounded, reports top offenders compactly, and omits unrelated startup/CPU/frame data. +- Example sample shape: `{"metrics":{"memory":{"available":true,"totalPssKb":562958,"totalRssKb":570304,"topConsumers":[{"name":"Dalvik Heap","pssKb":213456}]}}}`. +- `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. - 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.