diff --git a/packages/platform-android/src/__tests__/fixtures/perf-trace-processor-hangs.csv b/packages/platform-android/src/__tests__/fixtures/perf-trace-processor-hangs.csv new file mode 100644 index 0000000000..d909df83dd --- /dev/null +++ b/packages/platform-android/src/__tests__/fixtures/perf-trace-processor-hangs.csv @@ -0,0 +1,5 @@ +"kind","ts","dur","running_ns" +"bounds",767140246432,794060484753,0 +"run",769264455116,4000147294,3999833501 +"run",776264110703,3999344585,3999233377 +"run",783267376706,4000076960,3999959169 diff --git a/packages/platform-android/src/__tests__/fixtures/perf-trace-processor-idle.csv b/packages/platform-android/src/__tests__/fixtures/perf-trace-processor-idle.csv new file mode 100644 index 0000000000..0ecdd8b1cc --- /dev/null +++ b/packages/platform-android/src/__tests__/fixtures/perf-trace-processor-idle.csv @@ -0,0 +1,2 @@ +"kind","ts","dur","running_ns" +"bounds",822595650933,842403285693,0 diff --git a/packages/platform-android/src/__tests__/perf-native-summary.test.ts b/packages/platform-android/src/__tests__/perf-native-summary.test.ts new file mode 100644 index 0000000000..660867e580 --- /dev/null +++ b/packages/platform-android/src/__tests__/perf-native-summary.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import type { DeviceInfo } from '@agent-device/kernel/device'; + +vi.mock('../perf-trace-processor.ts', () => ({ + analyzeAndroidPerfettoTrace: vi.fn(), +})); +vi.mock('../perf-frame.ts', () => ({ + sampleAndroidFramePerf: vi.fn(async () => { + throw new Error('gfxinfo unavailable in test'); + }), +})); + +import { analyzeAndroidPerfettoTrace } from '../perf-trace-processor.ts'; +import { buildAndroidNativePerfStopSummary } from '../perf-native-summary.ts'; +import type { AndroidNativePerfSession } from '../perf-native-types.ts'; + +const mockedAnalyze = vi.mocked(analyzeAndroidPerfettoTrace); + +const DEVICE = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +} as unknown as DeviceInfo; + +function traceSession(overrides: Partial = {}): AndroidNativePerfSession { + return { + kind: 'perfetto', + type: 'trace', + packageName: 'com.example.hangapp', + appPid: '4321', + profilerPid: '4399', + state: 'running', + startedAt: 1, + outPath: '/tmp/app.perfetto-trace', + remotePath: '/data/misc/perfetto-traces/app.perfetto-trace', + ...overrides, + } as AndroidNativePerfSession; +} + +beforeEach(() => { + mockedAnalyze.mockReset(); +}); + +test('trace stop summary mirrors interpreted hangs onto the summary', async () => { + const hang = { + startOffsetMs: 2_100, + durationMs: 4_000, + thread: 'main (com.example.hangapp)', + runningMs: 3_900, + blockedMs: 100, + source: 'thread_state', + nextSteps: ['Run perf cpu profile around the interaction.'], + }; + mockedAnalyze.mockResolvedValue({ + available: true, + tracePath: '/tmp/app.perfetto-trace', + hotspots: [], + hangs: [hang], + hangCount: 1, + notes: [], + }); + const summary = await buildAndroidNativePerfStopSummary(DEVICE, traceSession(), 100, 26_000, {}); + // The envelope spreads onto the summary: hangs and hangCount once, at top level. + assert.equal(summary.available, true); + assert.deepEqual(summary.hangs, [hang]); + assert.equal(summary.hangCount, 1); + assert.deepEqual(summary.notes, [ + 'Frame health is sampled from Android gfxinfo around the trace window; open the Perfetto artifact for timeline root cause.', + ]); + assert.deepEqual(mockedAnalyze.mock.calls[0]?.[0], { + tracePath: '/tmp/app.perfetto-trace', + packageName: 'com.example.hangapp', + }); +}); + +test('trace stop summary adds the install note when trace_processor is missing', async () => { + mockedAnalyze.mockResolvedValue({ + available: false, + reason: 'trace-processor-missing', + message: 'trace_processor is not installed, so the Perfetto trace was not interpreted.', + hint: 'Install the Perfetto trace processor on PATH.', + }); + const summary = await buildAndroidNativePerfStopSummary(DEVICE, traceSession(), 100, 26_000, {}); + // The unavailable half of the envelope spreads onto the summary, like Apple's. + assert.equal(summary.available, false); + if (summary.available) return; + assert.equal(summary.reason, 'trace-processor-missing'); + assert.match(summary.message, /trace_processor is not installed/); +}); + +test('trace stop summary maps an analyzer throw to the typed failed reason', async () => { + mockedAnalyze.mockRejectedValue(new Error('Android adb host binding is not registered')); + const summary = await buildAndroidNativePerfStopSummary(DEVICE, traceSession(), 100, 26_000, {}); + assert.equal(summary.available, false); + if (summary.available) return; + assert.equal(summary.reason, 'trace-processor-failed'); + assert.match(summary.message, /adb host binding/); +}); + +test('cpu-profile stop summary never runs trace interpretation', async () => { + const summary = await buildAndroidNativePerfStopSummary( + DEVICE, + traceSession({ kind: 'simpleperf', type: 'cpu-profile' }), + 100, + 26_000, + {}, + ); + assert.equal(mockedAnalyze.mock.calls.length, 0); + assert.equal(summary.available, undefined); + assert.ok(!summary.notes.join('\n').includes('trace_processor')); +}); diff --git a/packages/platform-android/src/__tests__/perf-trace-processor.test.ts b/packages/platform-android/src/__tests__/perf-trace-processor.test.ts new file mode 100644 index 0000000000..9a71dbe1f0 --- /dev/null +++ b/packages/platform-android/src/__tests__/perf-trace-processor.test.ts @@ -0,0 +1,325 @@ +import { beforeEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + analyzeAndroidPerfettoTrace, + buildAndroidTraceHangs, + buildAndroidTraceHangsSql, + parseTraceProcessorCsv, + type TraceProcessorRow, +} from '../perf-trace-processor.ts'; + +vi.mock('@agent-device/host-kit/command', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runCmd: vi.fn(), + whichCmd: vi.fn(async () => false), + }; +}); + +import { runCmd, whichCmd } from '@agent-device/host-kit/command'; +import { bindAndroidAdbHostStub } from '../adb-host.fixtures.ts'; + +const mockRunCmd = vi.mocked(runCmd); +const mockWhichCmd = vi.mocked(whichCmd); + +// CSVs captured with trace_processor (v57) running the production query over live +// Perfetto captures on the API 35 emulator: the hang fixture holds three induced +// 4s busy main-thread windows; the idle fixture holds a settled app whose main +// thread only sleeps. +const fixtures = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures'); +const hangsCsv = readFileSync(path.join(fixtures, 'perf-trace-processor-hangs.csv'), 'utf8'); +const idleCsv = readFileSync(path.join(fixtures, 'perf-trace-processor-idle.csv'), 'utf8'); + +/** In-memory host port so the SQL file and cleanup are observable without touching disk. */ +function bindInMemoryHost(written: Map): { removedDirs: string[] } { + const removedDirs: string[] = []; + bindAndroidAdbHostStub({ + files: { + access: async () => {}, + ensureDirectory: async () => {}, + isExecutable: async () => false, + makeTempDirectory: async (prefix) => `/tmp/${prefix}one`, + readBytes: async () => Buffer.alloc(0), + readDirectory: async () => [], + readText: async () => '', + remove: async (target) => { + removedDirs.push(target); + }, + sha256: () => 'stub', + stat: async () => ({ isFile: true, size: 0 }), + writeAtomicText: async (target, value) => { + written.set(target, value); + }, + writeBytes: async () => {}, + }, + }); + return { removedDirs }; +} + +function bindResolvableTool(): void { + mockWhichCmd.mockImplementation(async (candidate) => candidate === 'trace_processor_shell'); +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +test('parseTraceProcessorCsv reads trace_processor window rows', () => { + const rows = parseTraceProcessorCsv(hangsCsv); + assert.equal(rows.length, 4); + assert.deepEqual( + rows.map((row) => row.kind), + ['bounds', 'run', 'run', 'run'], + ); + const run = rows.find((row) => row.kind === 'run'); + assert.ok(run); + assert.ok(run.dur > 3_000_000_000, 'the first induced hang spans ~4s'); + assert.ok(run.runningNs <= run.dur, 'running time cannot exceed the window'); +}); + +test('buildAndroidTraceHangs turns real induced hangs into separate running-dominant hangs', () => { + const result = buildAndroidTraceHangs({ + rows: parseTraceProcessorCsv(hangsCsv), + packageName: 'com.example.hangapp', + tracePath: '/tmp/app.perfetto-trace', + }); + assert.equal(result.hangs.length, 3, 'three induced 4s hangs must stay three windows'); + for (const hang of result.hangs) { + assert.equal(Math.abs(hang.durationMs - 4000) < 100, true); + assert.ok(hang.runningMs > hang.blockedMs, 'busy hangs must be running-dominant'); + assert.equal(hang.runningMs + hang.blockedMs <= hang.durationMs + 0.2, true); + assert.equal(hang.thread, 'main (com.example.hangapp)'); + assert.equal(hang.source, 'thread_state'); + assert.match(hang.nextSteps.join('\n'), /perf cpu profile/); + } + const starts = result.hangs.map((hang) => hang.startOffsetMs).sort((left, right) => left - right); + assert.equal(starts.length, 3); + assert.ok( + starts[1]! - starts[0]! > 3_000 && starts[2]! - starts[1]! > 3_000, + 'windows must sit apart, near the +3/+10/+17s schedule', + ); +}); + +test('buildAndroidTraceHangs reports no hangs for an idle main thread', () => { + const result = buildAndroidTraceHangs({ + rows: parseTraceProcessorCsv(idleCsv), + packageName: 'com.example.hangapp', + tracePath: '/tmp/app.perfetto-trace', + }); + assert.deepEqual(result.hangs, []); + assert.equal(result.hangCount, 0); +}); + +test('buildAndroidTraceHangsSql excludes idle sleep from windows and filters the threshold in SQL', () => { + const sql = buildAndroidTraceHangsSql("com.example.o'brien"); + assert.ok(sql.includes("'com.example.o''brien'"), 'the package name must be escaped'); + // One SELECT chain so trace_processor prints one CSV block. + assert.equal((sql.match(/^SELECT/gm) ?? []).length, 2); + assert.equal((sql.match(/UNION ALL/g) ?? []).length, 1); + assert.ok(sql.includes('ORDER BY kind, ts')); + // Idle sleep closes a window and never counts: only non-idle states are grouped. + assert.ok(sql.includes("state IN ('Running', 'R', 'R+', 'D')")); + assert.ok(sql.includes('LAG(state)')); + assert.ok(sql.includes("prev_state NOT IN ('Running', 'R', 'R+', 'D')")); + // The main thread is found by joining thread to process, not a scalar pid subquery. + assert.ok(sql.includes('JOIN process p USING (upid)')); + assert.ok(sql.includes('t.tid = p.pid')); + // The 250ms threshold is stated once, in SQL. + assert.equal((sql.match(/250000000/g) ?? []).length, 1); + assert.ok(!sql.includes('actual_frame_timeline_slice'), 'the frame-timeline pass is dropped'); +}); + +// The window rule itself, run over an in-memory database shaped like Perfetto's +// thread_state/process tables. SQLite evaluates the same window functions +// trace_processor's engine does, so this pins S-closes-window, the state split, +// and the threshold independently of the committed CSV fixtures. +function queryHangWindowsSqlite(states: Array<[number, number, string]>): TraceProcessorRow[] { + const db = new DatabaseSync(':memory:'); + db.exec(` + CREATE TABLE process (upid INTEGER PRIMARY KEY, pid INTEGER, name TEXT); + CREATE TABLE thread (utid INTEGER PRIMARY KEY, tid INTEGER, upid INTEGER); + CREATE TABLE thread_state (utid INTEGER, ts INTEGER, dur INTEGER, state TEXT); + CREATE TABLE trace_bounds (start_ts INTEGER, end_ts INTEGER); + INSERT INTO process VALUES (1, 4321, 'com.example.hangapp'); + INSERT INTO thread VALUES (7, 4321, 1); + INSERT INTO trace_bounds VALUES (0, 30000000000); + `); + const insert = db.prepare('INSERT INTO thread_state VALUES (7, ?, ?, ?)'); + for (const [ts, dur, state] of states) insert.run(ts, dur, state); + const sql = buildAndroidTraceHangsSql('com.example.hangapp'); + const rows = db.prepare(sql).all() as Array<{ + kind: string; + ts: number; + dur: number; + running_ns: number; + }>; + db.close(); + return rows.map((row) => ({ + kind: row.kind, + ts: Number(row.ts), + dur: Number(row.dur), + runningNs: Number(row.running_ns), + })); +} + +test('the window SQL yields separate non-idle windows, S closing them, over an in-memory database', () => { + // One 4s busy window, a 6s idle sleep, another 4s busy window, an 80ms blocked + // window under the threshold, and one 4s window with a D (blocked) state row. + const states: Array<[number, number, string]> = [ + [1_000_000_000, 2_000_000_000, 'Running'], + [3_000_000_000, 2_000_000_000, 'Running'], // continues the first window + [5_000_000_000, 6_000_000_000, 'S'], // idle sleep closes the window + [11_000_000_000, 4_000_000_000, 'Running'], + [15_000_000_000, 2_000_000_000, 'S'], + [17_000_000_000, 80_000_000, 'D'], // below the 250ms threshold + [17_080_000_000, 2_000_000_000, 'S'], + [19_000_000_000, 2_000_000_000, 'Running'], + [21_000_000_000, 2_000_000_000, 'D'], // blocked inside the same window + ]; + const rows = queryHangWindowsSqlite(states); + const windows = rows.filter((row) => row.kind === 'run'); + assert.equal(windows.length, 3, 'S closes windows; the short D window drops below the threshold'); + assert.deepEqual( + windows.map((w) => [w.ts, w.dur]), + [ + [1_000_000_000, 4_000_000_000], + [11_000_000_000, 4_000_000_000], + [19_000_000_000, 4_000_000_000], + ], + 'idle sleep must not stretch or merge windows', + ); + assert.deepEqual( + windows.map((w) => w.runningNs), + [4_000_000_000, 4_000_000_000, 2_000_000_000], + 'only Running counts as running; the D half counts as blocked', + ); + assert.equal(rows.find((row) => row.kind === 'bounds')?.dur, 30_000_000_000); +}); + +test('analyzeAndroidPerfettoTrace reports a typed note when trace_processor is missing', async () => { + mockWhichCmd.mockResolvedValue(false); + bindInMemoryHost(new Map()); + const result = await analyzeAndroidPerfettoTrace({ + tracePath: '/tmp/app.perfetto-trace', + packageName: 'com.example.hangapp', + }); + assert.equal(result.available, false); + if (result.available) return; + assert.equal(result.reason, 'trace-processor-missing'); + assert.match(result.hint, /trace_processor/); +}); + +test('analyzeAndroidPerfettoTrace prefers trace_processor_shell when both tools are on PATH', async () => { + mockWhichCmd.mockResolvedValue(true); + bindInMemoryHost(new Map()); + mockRunCmd.mockResolvedValue({ stdout: hangsCsv, stderr: '', exitCode: 0 }); + await analyzeAndroidPerfettoTrace({ + tracePath: '/tmp/app.perfetto-trace', + packageName: 'com.example.hangapp', + }); + const shellCall = mockRunCmd.mock.calls.find(([command]) => command === 'trace_processor_shell'); + assert.ok(shellCall, 'PATH lookup must prefer the shell build'); + assert.equal( + mockRunCmd.mock.calls.some(([command]) => command === 'trace_processor'), + false, + 'the plain tool must not also run', + ); +}); + +test('analyzeAndroidPerfettoTrace writes the SQL script and runs the resolved tool', async () => { + bindResolvableTool(); + const written = new Map(); + bindInMemoryHost(written); + mockRunCmd.mockResolvedValue({ stdout: hangsCsv, stderr: '', exitCode: 0 }); + const result = await analyzeAndroidPerfettoTrace({ + tracePath: '/tmp/app.perfetto-trace', + packageName: 'com.example.hangapp', + }); + assert.equal(result.available, true); + if (!result.available) return; + assert.equal(result.tracePath, '/tmp/app.perfetto-trace'); + assert.deepEqual(result.hotspots, []); + assert.equal(result.hangCount, 3); + const sqlPath = [...written.keys()][0]; + assert.ok(sqlPath && sqlPath.endsWith('hangs.sql')); + assert.equal(written.get(sqlPath), buildAndroidTraceHangsSql('com.example.hangapp')); + const call = mockRunCmd.mock.calls.find(([command]) => command === 'trace_processor_shell'); + assert.ok(call); + assert.deepEqual(call[1].slice(0, 3), ['query', '-f', sqlPath]); + assert.equal(call[2]?.allowFailure, true); + assert.equal(typeof call[2]?.timeoutMs, 'number'); +}); + +test('analyzeAndroidPerfettoTrace reports a failed query with the tool stderr detail', async () => { + bindResolvableTool(); + bindInMemoryHost(new Map()); + mockRunCmd.mockResolvedValue({ + stdout: '', + stderr: 'error: no such column: bogus\nmore output', + exitCode: 1, + }); + const result = await analyzeAndroidPerfettoTrace({ + tracePath: '/tmp/app.perfetto-trace', + packageName: 'com.example.hangapp', + }); + assert.equal(result.available, false); + if (result.available) return; + assert.equal(result.reason, 'trace-processor-failed'); + assert.match(result.message, /no such column: bogus/); + assert.match(result.hint, /ui\.perfetto\.dev/); +}); + +test('analyzeAndroidPerfettoTrace reports a failed query when the tool run throws', async () => { + bindResolvableTool(); + const host = bindInMemoryHost(new Map()); + mockRunCmd.mockRejectedValue(new Error('spawn trace_processor_shell timeout')); + const result = await analyzeAndroidPerfettoTrace({ + tracePath: '/tmp/app.perfetto-trace', + packageName: 'com.example.hangapp', + }); + assert.equal(result.available, false); + if (result.available) return; + assert.equal(result.reason, 'trace-processor-failed'); + assert.match(result.message, /timeout/); + // The SQL temp directory is cleaned even when the query dies mid-run. + assert.equal(host.removedDirs.includes('/tmp/agent-device-perfetto-query-one'), true); +}); + +test('analyzeAndroidPerfettoTrace reports a failed query when the SQL write fails', async () => { + bindResolvableTool(); + // The default stub host throws from makeTempDirectory, pinning the catch around + // the SQL-file setup. + bindAndroidAdbHostStub(); + const result = await analyzeAndroidPerfettoTrace({ + tracePath: '/tmp/app.perfetto-trace', + packageName: 'com.example.hangapp', + }); + assert.equal(result.available, false); + if (result.available) return; + assert.equal(result.reason, 'trace-processor-failed'); + assert.match(result.message, /makeTempDirectory/); +}); + +test('analyzeAndroidPerfettoTrace stays available when trace_processor prints malformed CSV', async () => { + bindResolvableTool(); + bindInMemoryHost(new Map()); + mockRunCmd.mockResolvedValue({ + stdout: 'kind,ts,dur,running_ns\n"broken,row\nrun,notanumber,alsonotanumber,0\n', + stderr: '', + exitCode: 0, + }); + const result = await analyzeAndroidPerfettoTrace({ + tracePath: '/tmp/app.perfetto-trace', + packageName: 'com.example.hangapp', + }); + assert.equal(result.available, true); + if (!result.available) return; + // Garbage lines drop at the parse boundary; the capture result stays intact. + assert.deepEqual(result.hangs, []); + assert.equal(result.hangCount, 0); +}); diff --git a/packages/platform-android/src/perf-native-summary.ts b/packages/platform-android/src/perf-native-summary.ts index 5a5d348a24..613f4f33c0 100644 --- a/packages/platform-android/src/perf-native-summary.ts +++ b/packages/platform-android/src/perf-native-summary.ts @@ -1,5 +1,6 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { sampleAndroidFramePerf } from './perf-frame.ts'; +import type { AndroidTraceAnalysis } from './perf-trace-processor.ts'; import type { AndroidNativePerfFrameHealthSummary, AndroidNativePerfOptions, @@ -14,6 +15,31 @@ export async function buildAndroidNativePerfStopSummary( durationMs: number, options: AndroidNativePerfOptions, ): Promise { + let traceAnalysis: AndroidTraceAnalysis | undefined; + if (session.kind === 'perfetto' && session.type === 'trace') { + // The trace analyzer and host-tool lookup load on demand so the mechanics + // closure stays as light as the merge-base. Unexpected throws (for example a + // missing host binding) become the typed failed reason instead of vanishing. + traceAnalysis = await import('./perf-trace-processor.ts') + .then(({ analyzeAndroidPerfettoTrace }) => + analyzeAndroidPerfettoTrace({ + tracePath: session.outPath, + packageName: session.packageName, + }), + ) + .catch((error: unknown): AndroidTraceAnalysis => ({ + available: false, + reason: 'trace-processor-failed', + message: `Trace analysis failed: ${error instanceof Error ? error.message : String(error)}`, + hint: `Open ${session.outPath} in the Perfetto UI (https://ui.perfetto.dev) to inspect the raw trace.`, + })); + } + // Like Apple, the trace-analysis envelope is spread onto the stop summary; the + // frame-health capture note leads and the envelope's notes follow it. + const frameNote = + session.kind === 'perfetto' + ? 'Frame health is sampled from Android gfxinfo around the trace window; open the Perfetto artifact for timeline root cause.' + : 'Open the Simpleperf report artifact for symbol-level CPU attribution.'; return { capture: { durationMs, @@ -26,11 +52,8 @@ export async function buildAndroidNativePerfStopSummary( session.kind === 'perfetto' ? await sampleAndroidNativePerfFrameHealth(device, session.packageName, options) : undefined, - notes: [ - session.kind === 'perfetto' - ? 'Frame health is sampled from Android gfxinfo around the trace window; open the Perfetto artifact for timeline root cause.' - : 'Open the Simpleperf report artifact for symbol-level CPU attribution.', - ], + ...(traceAnalysis ?? {}), + notes: [frameNote, ...(traceAnalysis?.available ? traceAnalysis.notes : [])], }; } diff --git a/packages/platform-android/src/perf-native-types.ts b/packages/platform-android/src/perf-native-types.ts index 01c4fa41d0..c09264e5e1 100644 --- a/packages/platform-android/src/perf-native-types.ts +++ b/packages/platform-android/src/perf-native-types.ts @@ -1,5 +1,6 @@ import type { AndroidAdbExecutor } from './adb-executor.ts'; import type { AndroidAdbFileHost } from './adb-host.ts'; +import type { AndroidTraceAnalysis } from './perf-trace-processor.ts'; export const ANDROID_SIMPLEPERF_METHOD = 'adb-shell-simpleperf'; export const ANDROID_PERFETTO_METHOD = 'adb-shell-perfetto'; @@ -63,7 +64,12 @@ export type AndroidNativePerfStopSummary = { }; frameHealth?: AndroidNativePerfFrameHealthSummary; notes: string[]; -}; +} & ( + | { available?: undefined } + // Trace-analysis envelope spread onto the summary (same placement as Apple's + // stop summary); trace captures only. + | AndroidTraceAnalysis +); export type AndroidNativePerfFrameHealthSummary = | { diff --git a/packages/platform-android/src/perf-trace-processor.ts b/packages/platform-android/src/perf-trace-processor.ts new file mode 100644 index 0000000000..9e9f491243 --- /dev/null +++ b/packages/platform-android/src/perf-trace-processor.ts @@ -0,0 +1,265 @@ +import path from 'node:path'; +import { runCmd, whichCmd } from '@agent-device/host-kit/command'; +import { + perfHangNextSteps, + type PerfHang, + type PerfTraceAnalysis, + type PerfTraceHotspot, +} from '@agent-device/contracts/perf-hang-next-steps'; +import { requireAndroidAdbHost } from './adb-host.ts'; + +// Mirrors the Time Profiler "Hangs" instrument default hangsThreshold (ms). +const ANDROID_HANG_THRESHOLD_MS = 250; + +const TRACE_PROCESSOR_TIMEOUT_MS = 60_000; + +/** Main-thread states that keep a busy window open; `S` and unknown states close it. */ +const ANDROID_MAIN_THREAD_NON_IDLE_STATES = "('Running', 'R', 'R+', 'D')"; + +export type AndroidTraceUnavailableReason = 'trace-processor-missing' | 'trace-processor-failed'; + +/** + * Shared trace-analysis envelope: interpreted main-thread hangs plus an empty + * hotspot list (simpleperf owns Android symbol attribution), or a typed failure. + */ +export type AndroidTraceAnalysis = PerfTraceAnalysis< + PerfHang, + PerfTraceHotspot, + AndroidTraceUnavailableReason +>; + +/** + * Runs one fixed SQL script over a pulled .perfetto-trace with the host + * trace_processor tool and interprets main-thread hangs. Best-effort by + * contract: every failure path returns a typed unavailable summary instead of + * failing the capture stop. + */ +export async function analyzeAndroidPerfettoTrace(params: { + tracePath: string; + packageName: string; +}): Promise { + const tool = await resolveAndroidTraceProcessorTool(); + if (!tool) { + return { + available: false, + reason: 'trace-processor-missing', + message: + 'trace_processor is not installed, so the Perfetto trace was not interpreted for hangs.', + hint: 'Install the Perfetto trace processor on PATH (curl -LO https://get.perfetto.dev/trace_processor && chmod +x ./trace_processor && mv ./trace_processor /usr/local/bin/) to get hangs and next steps at perf trace stop.', + }; + } + const result = await runTraceProcessorQuery({ + tool, + tracePath: params.tracePath, + packageName: params.packageName, + }); + if (result.status === 'failed') { + return { + available: false, + reason: 'trace-processor-failed', + message: `trace_processor could not query the Perfetto trace (${result.detail}).`, + hint: `Open ${params.tracePath} in the Perfetto UI (https://ui.perfetto.dev) to inspect the raw trace.`, + }; + } + return { + available: true, + tracePath: params.tracePath, + hotspots: [], + ...buildAndroidTraceHangs({ + rows: parseTraceProcessorCsv(result.csv), + packageName: params.packageName, + tracePath: params.tracePath, + }), + }; +} + +async function resolveAndroidTraceProcessorTool(): Promise { + const environment = requireAndroidAdbHost().environment; + for (const candidate of ['trace_processor_shell', 'trace_processor']) { + if (await whichCmd(candidate, environment)) return candidate; + } + return undefined; +} + +async function runTraceProcessorQuery(params: { + tool: string; + tracePath: string; + packageName: string; +}): Promise<{ status: 'ok'; csv: string } | { status: 'failed'; detail: string }> { + const host = requireAndroidAdbHost(); + let sqlPath: string | undefined; + try { + sqlPath = path.join( + await host.files.makeTempDirectory('agent-device-perfetto-query-'), + 'hangs.sql', + ); + await host.files.writeAtomicText(sqlPath, buildAndroidTraceHangsSql(params.packageName)); + const result = await runCmd(params.tool, ['query', '-f', sqlPath, params.tracePath], { + allowFailure: true, + timeoutMs: TRACE_PROCESSOR_TIMEOUT_MS, + env: host.environment, + }); + if (result.exitCode !== 0) { + return { + status: 'failed', + detail: firstLine(result.stderr) || `exit code ${result.exitCode}`, + }; + } + return { status: 'ok', csv: result.stdout }; + } catch (error) { + return { status: 'failed', detail: error instanceof Error ? error.message : String(error) }; + } finally { + if (sqlPath) { + await host.files + .remove(path.dirname(sqlPath), { force: true, recursive: true }) + .catch(() => {}); + } + } +} + +/** + * One SELECT (UNION ALL) so trace_processor prints one CSV block. A hang window + * is a maximal run of consecutive main-thread `thread_state` rows whose state is + * non-idle: idle sleep (`S`) and unknown states close a window and never count + * toward duration or blocked time. Only `Running` counts as running; `R`, `R+` + * and `D` count as blocked. Windows below the 250ms threshold are filtered in + * SQL. Rows: `run` = hang windows, `bounds` = trace window. + */ +export function buildAndroidTraceHangsSql(packageName: string): string { + const safePackage = sqlString(packageName); + // Every non-idle row whose previous main-thread row was idle (or absent) opens + // a new window; summing that flag gives each window its run id. + return `WITH main AS ( + SELECT t.utid AS utid + FROM thread t JOIN process p USING (upid) + WHERE t.tid = p.pid AND p.name = ${safePackage} +), states AS ( + SELECT ts, dur, state, + LAG(state) OVER (ORDER BY ts) AS prev_state + FROM thread_state WHERE utid IN (SELECT utid FROM main) +), grouped AS ( + SELECT ts, dur, state, + SUM(CASE WHEN prev_state IS NULL OR prev_state NOT IN ${ANDROID_MAIN_THREAD_NON_IDLE_STATES} + THEN 1 ELSE 0 END) OVER (ORDER BY ts) AS window_id + FROM states WHERE state IN ${ANDROID_MAIN_THREAD_NON_IDLE_STATES} +) +SELECT 'run' kind, MIN(ts) ts, MAX(ts + dur) - MIN(ts) dur, + SUM(CASE WHEN state = 'Running' THEN dur ELSE 0 END) running_ns +FROM grouped GROUP BY window_id +HAVING MAX(ts + dur) - MIN(ts) >= ${ANDROID_HANG_THRESHOLD_MS * 1_000_000} +UNION ALL +SELECT 'bounds', start_ts, end_ts, 0 FROM trace_bounds +ORDER BY kind, ts +`; +} + +export type TraceProcessorRow = { + kind: string; + ts: number; + dur: number; + runningNs: number; +}; + +export function parseTraceProcessorCsv(csv: string): TraceProcessorRow[] { + const rows: TraceProcessorRow[] = []; + const lines = csv.split('\n'); + let header: string[] | undefined; + for (const line of lines) { + if (line.trim().length === 0) continue; + const cells = splitCsvLine(line); + if (!header) { + header = cells; + continue; + } + if (cells.length !== header.length) continue; + const record = Object.fromEntries(header.map((key, index) => [key, cells[index]])); + const ts = Number(record.ts); + const dur = Number(record.dur); + if (!Number.isFinite(ts) || !Number.isFinite(dur)) continue; + rows.push({ + kind: record.kind ?? '', + ts, + dur, + runningNs: Number(record.running_ns) || 0, + }); + } + return rows; +} + +export function buildAndroidTraceHangs(params: { + rows: TraceProcessorRow[]; + packageName: string; + tracePath: string; +}): Pick, 'hangs' | 'hangCount' | 'notes'> { + const bounds = params.rows.find((row) => row.kind === 'bounds'); + const runs = params.rows + .filter((row) => row.kind === 'run') + .sort((left, right) => right.dur - left.dur); + const traceStartNs = bounds?.ts ?? (runs.length > 0 ? Math.min(...runs.map((r) => r.ts)) : 0); + const hangs: PerfHang[] = runs.map((run) => { + const runningMs = ms(run.runningNs); + const blockedMs = ms(run.dur - run.runningNs); + const cause = { + runningMs, + blockedMs, + // Simpleperf owns symbol attribution for Android; state rows do not carry symbols. + topSymbol: undefined, + threadName: params.packageName, + }; + return { + startOffsetMs: ms(run.ts - traceStartNs), + durationMs: ms(run.dur), + thread: `main (${params.packageName})`, + runningMs, + blockedMs, + source: 'thread_state', + nextSteps: perfHangNextSteps(cause), + }; + }); + const notes: string[] = []; + notes.push(`Open ${params.tracePath} in https://ui.perfetto.dev for the full timeline.`); + return { hangs, hangCount: hangs.length, notes }; +} + +function sqlString(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +function splitCsvLine(line: string): string[] { + const cells: string[] = []; + let current = ''; + let quoted = false; + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + if (char === '"') { + if (quoted && line[index + 1] === '"') { + current += '"'; + index += 1; + } else { + quoted = !quoted; + } + continue; + } + if (char === ',' && !quoted) { + cells.push(current); + current = ''; + continue; + } + current += char; + } + cells.push(current); + return cells; +} + +function firstLine(value: string): string { + return ( + value + .split('\n') + .find((line) => line.trim().length > 0) + ?.trim() ?? '' + ); +} + +function ms(nanoseconds: number): number { + return Math.round((nanoseconds / 1_000_000) * 10) / 10; +} diff --git a/src/commands/perf/output.test.ts b/src/commands/perf/output.test.ts index 008770b37c..d3c21e0ede 100644 --- a/src/commands/perf/output.test.ts +++ b/src/commands/perf/output.test.ts @@ -45,3 +45,61 @@ test('Apple trace stop output lists hangs, hotspots, and next steps', () => { assert.match(text, /Hotspots:\n- 1s swift_getTupleTypeMetadata/); assert.match(text, /Next steps:\n- Inspect HangViewController/); }); +test('Android trace stop output lists hangs and bounds them to five rows', () => { + const hangs = Array.from({ length: 7 }, (_, index) => ({ + startOffsetMs: index * 1_000, + durationMs: 500 + index, + thread: 'main (com.example.hangapp)', + runningMs: 400, + blockedMs: 100, + source: 'thread_state', + nextSteps: ['Run perf cpu profile around the interaction.'], + })); + const text = format({ + action: 'stop', + kind: 'perfetto', + type: 'trace', + platform: 'android', + state: 'stopped', + outPath: '/tmp/app.perfetto-trace', + sizeBytes: 2_190_000, + summary: { + capture: { durationMs: 26_000, packageName: 'com.example.hangapp' }, + // Interpreted hangs appear once on the summary, beside capture metadata. + hangs, + notes: [], + }, + }); + assert.match(text, /Hangs \(7\):/); + const hangSection = text.split('Next steps:')[0] ?? ''; + const hangLines = hangSection.split('\n').filter((line) => line.startsWith('- ')); + assert.equal(hangLines.length, 6, 'five hangs plus the overflow line'); + assert.match(text, /and 2 more, see the JSON summary/); + const nextStepsSection = text.split('Next steps:')[1] ?? ''; + assert.equal(nextStepsSection.split('\n').filter((line) => line.startsWith('- ')).length, 1); +}); + +test('Android trace stop output prints the typed unavailable note without hangs', () => { + const text = format({ + action: 'stop', + kind: 'perfetto', + type: 'trace', + platform: 'android', + state: 'stopped', + outPath: '/tmp/app.perfetto-trace', + summary: { + capture: { durationMs: 26_000, packageName: 'com.example.hangapp' }, + // The unavailable envelope spreads onto the summary, like Apple's shape. + available: false, + reason: 'trace-processor-missing', + message: 'trace_processor is not installed.', + hint: 'Install it.', + notes: [], + }, + }); + assert.match(text, /Trace analysis: unavailable - trace-processor-missing/); + // The CLI text carries the actionable hint, so the doc line "adds a note with + // install steps" and the help line "prints an install hint" are true in text. + assert.match(text, /Install it\./); + assert.ok(!text.includes('Hangs')); +}); diff --git a/src/commands/perf/output.ts b/src/commands/perf/output.ts index 711d1eef33..bdd736c470 100644 --- a/src/commands/perf/output.ts +++ b/src/commands/perf/output.ts @@ -150,10 +150,16 @@ function formatTraceHangSection(hangs: Array>): string | function formatTraceAnalysisUnavailable( summary: Record | undefined, ): string | undefined { + // Both platforms spread the analysis envelope onto the stop summary, so the + // unavailable case is always summary.available === false with the typed reason. + // The hint rides along so the CLI text carries the actionable steps (for + // example the trace_processor install note), not just the reason token. if (!isRecord(summary) || summary.available !== false) return undefined; const message = readString(summary.message); if (!message) return undefined; - return `Trace analysis: unavailable - ${readString(summary.reason) ?? message}`; + const head = `Trace analysis: unavailable - ${readString(summary.reason) ?? message}`; + const hint = readString(summary.hint); + return hint ? `${head} ${hint}` : head; } function formatAppleTraceSections(summary: Record): string[] { diff --git a/src/commands/schema/cli-help-workflows.ts b/src/commands/schema/cli-help-workflows.ts index 43b12c0cff..62d2c6b7b9 100644 --- a/src/commands/schema/cli-help-workflows.ts +++ b/src/commands/schema/cli-help-workflows.ts @@ -158,6 +158,7 @@ Diagnostics and traces: agent-device perf trace start --kind perfetto --out /tmp/app.perfetto-trace agent-device perf trace stop --kind perfetto Treat native perf output as the agent evidence: for example, state=stopped, outPath=/tmp/app.perfetto-trace, sizeBytes=5392410, method=adb-shell-perfetto. The 5.3 MB raw trace stays in the artifact. + perf trace stop interprets the Perfetto trace with the host trace_processor tool and reports main-thread hangs with next steps; without that tool the stop result keeps the artifact and prints an install hint. CPU reports return at most ten top functions in structured data and print five in default CLI output. Use explicit perf frames, perf memory, perf cpu, or perf trace forms. Aggregate perf was removed in 0.21: bare perf, perf sample, perf metrics, and metrics fail with exact replacements. Startup duration belongs to the open command's startup result; read it there. diff --git a/src/platform-runtime-perf-capture-host.test.ts b/src/platform-runtime-perf-capture-host.test.ts index c1135500b2..d817e7fefc 100644 --- a/src/platform-runtime-perf-capture-host.test.ts +++ b/src/platform-runtime-perf-capture-host.test.ts @@ -158,6 +158,15 @@ test('starts, retargets, finishes, and cleans a Simpleperf profile through one l test('starts and finishes an Android Perfetto trace', async () => { const capture = androidCapture({ kind: 'perfetto', type: 'trace' }); + const hang = { + startOffsetMs: 2_100, + durationMs: 4_000, + thread: 'main (com.example.app)', + runningMs: 3_900, + blockedMs: 100, + source: 'thread_state', + nextSteps: ['Run perf cpu profile around the interaction.'], + }; androidNative.startAndroidPerfettoTrace.mockResolvedValue(capture); androidNative.stopAndroidPerfettoTrace.mockResolvedValue({ ...capture, @@ -165,7 +174,7 @@ test('starts and finishes an Android Perfetto trace', async () => { state: 'stopped', stoppedAt: 3, durationMs: 2, - summary: { packets: 4 }, + summary: { packets: 4, hangs: [hang] }, }); const started = await startAndroidPerfCapture( @@ -173,10 +182,17 @@ test('starts and finishes an Android Perfetto trace', async () => { localRuntimeOwner('android'), captureInput({ kind: 'perfetto', mode: 'trace' }), ); - await expect(started.pendingHandle.transfer().finish()).resolves.toMatchObject({ - status: 'completed', - result: { kind: 'perfetto', mode: 'trace', state: 'stopped' }, + const finished = await started.pendingHandle.transfer().finish(); + expect(finished.status).toBe('completed'); + if (finished.status !== 'completed') return; + expect(finished.result).toMatchObject({ + kind: 'perfetto', + mode: 'trace', + state: 'stopped', + summary: { packets: 4, hangs: [hang] }, }); + // Hangs ride on the summary only; the snapshot must not mirror them. + expect(finished.result as unknown as Record).not.toHaveProperty('hangs'); expect(androidNative.startAndroidPerfettoTrace).toHaveBeenCalledWith( device, 'com.example.app', diff --git a/src/platform-runtime-perf-capture-host.ts b/src/platform-runtime-perf-capture-host.ts index 30019614f3..7b58004f21 100644 --- a/src/platform-runtime-perf-capture-host.ts +++ b/src/platform-runtime-perf-capture-host.ts @@ -320,7 +320,12 @@ function compactAndroidCapture( remotePath: result.remotePath, method: result.method, message: result.message, - ...(result.action === 'stop' ? { durationMs: result.durationMs, summary: result.summary } : {}), + ...(result.action === 'stop' + ? { + durationMs: result.durationMs, + summary: result.summary, + } + : {}), }; return result.kind === 'simpleperf' ? { ...common, kind: 'simpleperf', mode: 'cpu-profile' } diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index ab9040294f..1f9f8d74f8 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -905,7 +905,7 @@ agent-device perf trace stop --kind perfetto --out app.perfetto-trace - Name one area: `frames`, `memory`, `cpu`, or `trace`. Bare `perf`, `perf sample`, `perf metrics`, and the `metrics` alias fail with guidance to the focused replacements. - `perf frames` returns frame/jank health (`droppedFramePercent`, `worstWindows`). `perf memory sample` returns a compact process memory sample in kilobytes. `perf memory snapshot` writes a heap or memgraph artifact and returns its path. - `perf cpu profile` and `perf trace` record Apple (`--kind xctrace`) or Android (`--kind simpleperf`, `--kind perfetto`) profiles and traces, and return artifact paths plus compact metadata; raw contents stay on disk. -- `perf trace stop` interprets the capture: Apple reports hangs, hitches, and hotspots. Each hang carries bounded `nextSteps` guidance; the CLI text prints at most five entries per list and JSON carries the full list. `perf trace` records with the Time Profiler template by default, so hitch tables need `--template "Animation Hitches"`. +- `perf trace stop` interprets the capture: Apple reports hangs, hitches, and hotspots; Android reports main-thread hangs when `trace_processor` is installed on the host. Each hang carries bounded `nextSteps` guidance; the CLI text prints at most five entries per list and JSON carries the full list. `perf trace` records with the Time Profiler template by default, so hitch tables need `--template "Animation Hitches"`. - `cdp` samples and snapshots the React Native JavaScript heap through Metro CDP; see [Debugging & Profiling](/docs/debugging-profiling#react-native-js-memory-through-cdp). - App startup duration is returned in `open`'s `startup` result. - Perf commands need an app session with a package or bundle ID; run `open ` first. diff --git a/website/docs/docs/debugging-profiling.md b/website/docs/docs/debugging-profiling.md index 4c3f26c89e..8bbd002b90 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -265,7 +265,7 @@ agent-device perf trace stop --kind perfetto --out app.perfetto-trace - `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. On physical iOS devices, memgraph capture reports unavailable with a hint. - 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. Native allocation tracing (heapprofd) is not supported. - `perf cpu profile ... --kind xctrace` collects an Apple native `.trace`; `report` aggregates every run, returns at most ten weighted top functions in JSON, and prints five. `perf trace ... --kind xctrace` keeps trace data as an artifact. -- `perf trace stop` interprets the finished trace before it returns. Apple captures report hangs with start offset, duration, thread, and top symbol, plus hitches and hotspots. Each hang carries rule-based `nextSteps`. CLI text prints at most five entries per list; JSON carries the full list. +- `perf trace stop` interprets the finished trace before it returns. Apple captures report hangs with start offset, duration, thread, and top symbol, plus hitches and hotspots; Android Perfetto captures report main-thread hangs: contiguous busy windows where the main thread does not return to idle. The idle state also covers lock, binder, and GC waits, so a blocked stretch counts as idle and a busy hang that pauses briefly can split into two windows. Each hang carries rule-based `nextSteps`. CLI text prints at most five entries per list; JSON carries the full list. Android interpretation uses the host `trace_processor` tool; without it on PATH the stop result keeps the artifact and adds a note with install steps. - On iOS simulators and macOS, process sampling and captures target the resolved app executable. Other running copies with the same executable name are excluded, including copies installed on another simulator. - Android native profiling uses `perf cpu profile ... --kind simpleperf`; its report likewise returns at most ten top functions and prints five. Android native trace capture uses `perf trace ... --kind perfetto`. These commands require an active Android app session and return artifact paths/summaries instead of dumping profile or trace contents. - Use the compact result as evidence. For example, a successful Perfetto stop may return `state: "stopped"`, `outPath: "/tmp/app.perfetto-trace"`, `sizeBytes: 5392410`, and `method: "adb-shell-perfetto"` while the 5.3 MB raw trace remains on disk as the artifact.