From c7505caa66f810290e0316b17b9dee1030f7e8cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 12 Jun 2026 13:55:49 +0200 Subject: [PATCH 1/3] refactor: split Android native perf collector --- .../android/perf-native-artifacts.ts | 215 +++++ src/platforms/android/perf-native-errors.ts | 86 ++ src/platforms/android/perf-native-perfetto.ts | 118 +++ src/platforms/android/perf-native-process.ts | 48 ++ .../android/perf-native-simpleperf.ts | 194 +++++ src/platforms/android/perf-native-summary.ts | 63 ++ src/platforms/android/perf-native-types.ts | 99 +++ src/platforms/android/perf-native.ts | 759 +----------------- 8 files changed, 841 insertions(+), 741 deletions(-) create mode 100644 src/platforms/android/perf-native-artifacts.ts create mode 100644 src/platforms/android/perf-native-errors.ts create mode 100644 src/platforms/android/perf-native-perfetto.ts create mode 100644 src/platforms/android/perf-native-process.ts create mode 100644 src/platforms/android/perf-native-simpleperf.ts create mode 100644 src/platforms/android/perf-native-summary.ts create mode 100644 src/platforms/android/perf-native-types.ts diff --git a/src/platforms/android/perf-native-artifacts.ts b/src/platforms/android/perf-native-artifacts.ts new file mode 100644 index 000000000..d4fb1b9e7 --- /dev/null +++ b/src/platforms/android/perf-native-artifacts.ts @@ -0,0 +1,215 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import type { DeviceInfo } from '../../utils/device.ts'; +import { AppError } from '../../utils/errors.ts'; +import type { AndroidAdbExecutor } from './adb-executor.ts'; +import { resolveAndroidAdbExecutor } from './adb-executor.ts'; +import { annotateAndroidNativePerfError } from './perf-native-errors.ts'; +import { buildAndroidNativePerfStopSummary } from './perf-native-summary.ts'; +import { + ANDROID_NATIVE_PROFILE_TIMEOUT_MS, + ANDROID_NATIVE_REMOTE_DIR, + ANDROID_PERF_TIMEOUT_MS, + ANDROID_PERFETTO_METHOD, + ANDROID_SIMPLEPERF_METHOD, + type AndroidNativePerfOptions, + type AndroidNativePerfSession, + type AndroidNativePerfStopResult, +} from './perf-native-types.ts'; + +const ANDROID_NATIVE_ARTIFACT_POLL_INTERVAL_MS = 250; +const ANDROID_NATIVE_ARTIFACT_POLL_ATTEMPTS = 12; + +export async function stopAndroidNativePerfSession( + device: DeviceInfo, + session: AndroidNativePerfSession, + options: AndroidNativePerfOptions, +): Promise { + const adb = resolveAndroidAdbExecutor(device, options.adb); + await stopAndroidBackgroundTool(adb, session); + await waitForAndroidNativeArtifact(adb, session); + await pullAndroidNativeArtifact(adb, session); + const sizeBytes = await readFileSize(session.outPath); + await cleanupAndroidRemotePath(adb, session.remotePath); + const stoppedAt = Date.now(); + const durationMs = Math.max(0, stoppedAt - session.startedAt); + const summary = await buildAndroidNativePerfStopSummary(device, session, sizeBytes, durationMs, { + adb, + }); + return { + ...session, + action: 'stop', + platform: 'android', + state: 'stopped', + stoppedAt, + durationMs, + sizeBytes, + method: session.kind === 'simpleperf' ? ANDROID_SIMPLEPERF_METHOD : ANDROID_PERFETTO_METHOD, + artifact: { + path: session.outPath, + sizeBytes, + }, + summary, + message: `Stopped Android ${session.kind} ${session.type} for ${session.packageName}`, + }; +} + +export async function cleanupAndroidNativePerfSession( + device: DeviceInfo, + session: AndroidNativePerfSession, + options: AndroidNativePerfOptions = {}, +): Promise { + const adb = resolveAndroidAdbExecutor(device, options.adb); + try { + if (session.state === 'running') { + await stopAndroidBackgroundTool(adb, session); + await waitForAndroidNativeArtifact(adb, session).catch(() => {}); + } + } finally { + await cleanupAndroidRemotePath(adb, session.remotePath); + } +} + +export function buildAndroidNativeRemotePath( + packageName: string, + fileName: string, + remoteDir = ANDROID_NATIVE_REMOTE_DIR, +): string { + const safePackage = packageName.replace(/[^A-Za-z0-9_.-]/g, '_'); + return `${remoteDir}/agent-device-${safePackage}-${Date.now()}-${fileName}`; +} + +export async function cleanupAndroidRemotePath( + adb: AndroidAdbExecutor, + remotePath: string, +): Promise { + try { + await adb(['shell', `rm -f ${shellQuote(remotePath)}`], { + allowFailure: true, + timeoutMs: ANDROID_PERF_TIMEOUT_MS, + }); + } catch { + // Best-effort cleanup must not hide the primary profiling result. + } +} + +export async function writeJsonArtifact(outPath: string, value: unknown): Promise { + await fs.mkdir(path.dirname(outPath), { recursive: true }); + await fs.writeFile(outPath, `${JSON.stringify(value, null, 2)}\n`); +} + +export async function readFileSize(filePath: string): Promise { + try { + return (await fs.stat(filePath)).size; + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + `Profiler artifact was not written: ${filePath}`, + { + outPath: filePath, + hint: 'Retry the profiling command and check daemon logs if the artifact path is still missing.', + }, + error, + ); + } +} + +export function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +async function stopAndroidBackgroundTool( + adb: AndroidAdbExecutor, + session: AndroidNativePerfSession, +): Promise { + try { + await adb(['shell', buildStopProfilerCommand(session.profilerPid)], { + timeoutMs: ANDROID_NATIVE_PROFILE_TIMEOUT_MS, + }); + } catch (error) { + throw annotateAndroidNativePerfError('stop', session.kind, session.packageName, error); + } +} + +function buildStopProfilerCommand(pid: string): string { + return [ + `pid=${shellQuote(pid)}`, + 'kill -INT "$pid" 2>/dev/null || true', + 'for i in 1 2 3 4 5 6 7 8 9 10; do kill -0 "$pid" 2>/dev/null || exit 0; sleep 0.2; done', + 'kill -TERM "$pid" 2>/dev/null || true', + 'for i in 1 2 3 4 5 6 7 8 9 10; do kill -0 "$pid" 2>/dev/null || exit 0; sleep 0.2; done', + 'echo "profiler process did not stop after SIGTERM" >&2', + 'exit 1', + ].join('; '); +} + +async function pullAndroidNativeArtifact( + adb: AndroidAdbExecutor, + session: AndroidNativePerfSession, +): Promise { + await fs.mkdir(path.dirname(session.outPath), { recursive: true }); + try { + await adb(['pull', session.remotePath, session.outPath], { + timeoutMs: ANDROID_NATIVE_PROFILE_TIMEOUT_MS, + }); + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + `Failed to pull Android ${session.kind} artifact for ${session.packageName}`, + { + package: session.packageName, + tool: session.kind, + remotePath: session.remotePath, + outPath: session.outPath, + hint: 'Check that the profiling command ran long enough to create an artifact, then retry stop with the same session.', + }, + error, + ); + } +} + +async function waitForAndroidNativeArtifact( + adb: AndroidAdbExecutor, + session: AndroidNativePerfSession, +): Promise { + let previousSize: number | undefined; + let stableSamples = 0; + for (let attempt = 0; attempt < ANDROID_NATIVE_ARTIFACT_POLL_ATTEMPTS; attempt += 1) { + const size = await readAndroidRemoteFileSize(adb, session.remotePath); + if (size !== undefined && size > 0 && size === previousSize) { + stableSamples += 1; + if (stableSamples >= 1) return; + } else { + stableSamples = 0; + } + previousSize = size; + await delay(ANDROID_NATIVE_ARTIFACT_POLL_INTERVAL_MS); + } + throw new AppError('COMMAND_FAILED', `Android ${session.kind} artifact is not ready to pull`, { + package: session.packageName, + tool: session.kind, + remotePath: session.remotePath, + hint: 'The profiler stopped, but the remote artifact was missing, empty, or still changing. Retry stop with the same session or inspect the device-side artifact.', + }); +} + +async function readAndroidRemoteFileSize( + adb: AndroidAdbExecutor, + remotePath: string, +): Promise { + const quotedPath = shellQuote(remotePath); + const result = await adb( + [ + 'shell', + `if [ -f ${quotedPath} ]; then stat -c %s ${quotedPath} 2>/dev/null || wc -c < ${quotedPath}; fi`, + ], + { + allowFailure: true, + timeoutMs: ANDROID_PERF_TIMEOUT_MS, + }, + ); + if (result.exitCode !== 0) return undefined; + const value = Number(result.stdout.trim().split(/\s+/)[0]); + return Number.isFinite(value) ? value : undefined; +} diff --git a/src/platforms/android/perf-native-errors.ts b/src/platforms/android/perf-native-errors.ts new file mode 100644 index 000000000..68329a787 --- /dev/null +++ b/src/platforms/android/perf-native-errors.ts @@ -0,0 +1,86 @@ +import { AppError } from '../../utils/errors.ts'; +import type { AndroidNativePerfKind } from './perf-native-types.ts'; + +export function annotateAndroidNativePerfError( + action: 'start' | 'stop' | 'report', + tool: AndroidNativePerfKind, + packageName: string, + error: unknown, +): AppError { + if (error instanceof AppError) { + const details = error.details ?? {}; + return new AppError( + error.code, + error.message, + { + ...details, + action, + package: packageName, + tool, + hint: + typeof details.hint === 'string' + ? details.hint + : classifyAndroidNativePerfHint(tool, details), + }, + error, + ); + } + return new AppError( + 'COMMAND_FAILED', + `Failed to ${action} Android ${tool} for ${packageName}`, + { + action, + package: packageName, + tool, + hint: buildAndroidNativePerfHint(tool), + }, + error, + ); +} + +export function buildAndroidNativePerfHint(tool: AndroidNativePerfKind): string { + return tool === 'simpleperf' + ? 'Verify simpleperf is available, the app process is running, and the app/device permits native CPU profiling.' + : 'Verify perfetto is available, the app process is running, and the device permits trace capture.'; +} + +export function buildAndroidNativeToolUnavailableHint(tool: AndroidNativePerfKind): string { + return tool === 'simpleperf' + ? 'Use an emulator/system image with simpleperf available, or install the Android NDK simpleperf binary for this device.' + : 'Use Android 10+ or a system image that exposes the perfetto command-line binary.'; +} + +function classifyAndroidNativePerfHint( + tool: AndroidNativePerfKind, + details: Record, +): string { + const stderr = typeof details.stderr === 'string' ? details.stderr : ''; + const text = stderr.toLowerCase(); + if (tool === 'simpleperf') return classifySimpleperfHint(text); + if (hasPerfettoPermissionError(text)) { + return 'Use a device image that permits perfetto trace capture for shell, keep the app running, then retry perf trace start.'; + } + return buildAndroidNativePerfHint(tool); +} + +function classifySimpleperfHint(text: string): string { + if (hasSimpleperfProfileabilityError(text)) { + return 'Use a debuggable/profileable Android app or a device image that permits simpleperf for the target process, then retry perf cpu profile start.'; + } + if (text.includes('not supported') || text.includes('failed to open perf event')) { + return 'This device image does not expose the requested simpleperf event for the app process. Try a different emulator/system image or a profileable app.'; + } + return buildAndroidNativePerfHint('simpleperf'); +} + +function hasSimpleperfProfileabilityError(text: string): boolean { + return ( + text.includes('permission denied') || + text.includes('not profileable') || + text.includes('profileable') + ); +} + +function hasPerfettoPermissionError(text: string): boolean { + return text.includes('permission denied') || text.includes('not allowed'); +} diff --git a/src/platforms/android/perf-native-perfetto.ts b/src/platforms/android/perf-native-perfetto.ts new file mode 100644 index 000000000..057cb7d09 --- /dev/null +++ b/src/platforms/android/perf-native-perfetto.ts @@ -0,0 +1,118 @@ +import type { DeviceInfo } from '../../utils/device.ts'; +import { AppError } from '../../utils/errors.ts'; +import { resolveAndroidAdbExecutor, type AndroidAdbExecutor } from './adb-executor.ts'; +import { + buildAndroidNativeRemotePath, + cleanupAndroidRemotePath, + stopAndroidNativePerfSession, +} from './perf-native-artifacts.ts'; +import { annotateAndroidNativePerfError } from './perf-native-errors.ts'; +import { resetAndroidFramePerfStats } from './perf-frame.ts'; +import { + assertAndroidNativeToolAvailable, + findPidToken, + resolveAndroidAppPid, +} from './perf-native-process.ts'; +import { + ANDROID_NATIVE_MAX_SECONDS, + ANDROID_NATIVE_PROFILE_TIMEOUT_MS, + ANDROID_PERFETTO_METHOD, + ANDROID_PERFETTO_REMOTE_DIR, + type AndroidNativePerfOptions, + type AndroidNativePerfSession, + type AndroidNativePerfStartResult, + type AndroidNativePerfStopResult, +} from './perf-native-types.ts'; + +export async function startAndroidPerfettoTrace( + device: DeviceInfo, + packageName: string, + outPath: string, + options: AndroidNativePerfOptions = {}, +): Promise { + const adb = resolveAndroidAdbExecutor(device, options.adb); + const appPid = await resolveAndroidAppPid(adb, packageName); + await assertAndroidNativeToolAvailable(adb, 'perfetto', packageName); + const remotePath = buildAndroidNativeRemotePath( + packageName, + 'app.perfetto-trace', + ANDROID_PERFETTO_REMOTE_DIR, + ); + let profilerPid: string; + try { + await resetAndroidFramePerfStats(device, packageName, { adb }); + profilerPid = await startAndroidPerfettoBackgroundTool(adb, remotePath, packageName); + } catch (error) { + await cleanupAndroidRemotePath(adb, remotePath); + throw error; + } + const session = { + type: 'trace', + kind: 'perfetto', + packageName, + appPid, + profilerPid, + remotePath, + outPath, + startedAt: Date.now(), + state: 'running', + } satisfies AndroidNativePerfSession; + return { + ...session, + action: 'start', + platform: 'android', + method: ANDROID_PERFETTO_METHOD, + message: `Started Android Perfetto trace for ${packageName}`, + }; +} + +export async function stopAndroidPerfettoTrace( + device: DeviceInfo, + session: AndroidNativePerfSession, + outPath: string, + options: AndroidNativePerfOptions = {}, +): Promise { + return await stopAndroidNativePerfSession(device, { ...session, outPath }, options); +} + +async function startAndroidPerfettoBackgroundTool( + adb: AndroidAdbExecutor, + remotePath: string, + packageName: string, +): Promise { + try { + const result = await adb( + [ + 'shell', + 'perfetto', + '--background-wait', + '-o', + remotePath, + '-t', + `${ANDROID_NATIVE_MAX_SECONDS}s`, + 'sched', + 'freq', + 'idle', + 'am', + 'wm', + 'gfx', + 'view', + 'binder_driver', + 'hal', + 'dalvik', + ], + { + timeoutMs: ANDROID_NATIVE_PROFILE_TIMEOUT_MS, + }, + ); + const pid = findPidToken(result.stdout); + if (pid) return pid; + throw new AppError('COMMAND_FAILED', 'Android perfetto did not return a profiler pid', { + package: packageName, + tool: 'perfetto', + hint: 'Retry perf trace start. If perfetto exits immediately, verify the device permits trace capture.', + }); + } catch (error) { + throw annotateAndroidNativePerfError('start', 'perfetto', packageName, error); + } +} diff --git a/src/platforms/android/perf-native-process.ts b/src/platforms/android/perf-native-process.ts new file mode 100644 index 000000000..cc2e0f05f --- /dev/null +++ b/src/platforms/android/perf-native-process.ts @@ -0,0 +1,48 @@ +import { AppError } from '../../utils/errors.ts'; +import type { AndroidAdbExecutor } from './adb-executor.ts'; +import { buildAndroidNativeToolUnavailableHint } from './perf-native-errors.ts'; +import { ANDROID_PERF_TIMEOUT_MS, type AndroidNativePerfKind } from './perf-native-types.ts'; + +export async function resolveAndroidAppPid( + adb: AndroidAdbExecutor, + packageName: string, +): Promise { + try { + const result = await adb(['shell', 'pidof', packageName], { + allowFailure: true, + timeoutMs: ANDROID_PERF_TIMEOUT_MS, + }); + const pid = findPidToken(result.stdout); + if (result.exitCode === 0 && pid) return pid; + } catch { + // Fall through to the actionable error below. + } + throw new AppError('COMMAND_FAILED', `No active Android app process found for ${packageName}`, { + package: packageName, + hint: 'Run open for this session again, wait for the app UI to appear, then retry perf.', + }); +} + +export async function assertAndroidNativeToolAvailable( + adb: AndroidAdbExecutor, + tool: AndroidNativePerfKind, + packageName: string, +): Promise { + const result = await adb(['shell', `command -v ${tool} || which ${tool}`], { + allowFailure: true, + timeoutMs: ANDROID_PERF_TIMEOUT_MS, + }); + if (result.exitCode === 0 && result.stdout.trim()) return; + throw new AppError('UNSUPPORTED_OPERATION', `Android device does not expose ${tool}`, { + package: packageName, + tool, + hint: buildAndroidNativeToolUnavailableHint(tool), + }); +} + +export function findPidToken(stdout: string): string | undefined { + return stdout + .trim() + .split(/\s+/) + .find((token) => /^\d+$/.test(token)); +} diff --git a/src/platforms/android/perf-native-simpleperf.ts b/src/platforms/android/perf-native-simpleperf.ts new file mode 100644 index 000000000..4d2158571 --- /dev/null +++ b/src/platforms/android/perf-native-simpleperf.ts @@ -0,0 +1,194 @@ +import type { DeviceInfo } from '../../utils/device.ts'; +import { AppError } from '../../utils/errors.ts'; +import { resolveAndroidAdbExecutor, type AndroidAdbExecutor } from './adb-executor.ts'; +import { + buildAndroidNativeRemotePath, + cleanupAndroidRemotePath, + readFileSize, + shellQuote, + stopAndroidNativePerfSession, + writeJsonArtifact, +} from './perf-native-artifacts.ts'; +import { annotateAndroidNativePerfError } from './perf-native-errors.ts'; +import { parseSimpleperfReportEntries } from './perf-native-report.ts'; +import { + assertAndroidNativeToolAvailable, + findPidToken, + resolveAndroidAppPid, +} from './perf-native-process.ts'; +import { + ANDROID_NATIVE_MAX_SECONDS, + ANDROID_NATIVE_PROFILE_TIMEOUT_MS, + ANDROID_NATIVE_REMOTE_DIR, + ANDROID_SIMPLEPERF_METHOD, + type AndroidNativePerfOptions, + type AndroidNativePerfSession, + type AndroidNativePerfStartResult, + type AndroidNativePerfStopResult, + type AndroidSimpleperfReportResult, +} from './perf-native-types.ts'; + +export async function startAndroidSimpleperfProfile( + device: DeviceInfo, + packageName: string, + outPath: string, + options: AndroidNativePerfOptions = {}, +): Promise { + const adb = resolveAndroidAdbExecutor(device, options.adb); + const appPid = await resolveAndroidAppPid(adb, packageName); + await assertAndroidNativeToolAvailable(adb, 'simpleperf', packageName); + const remotePath = buildAndroidNativeRemotePath(packageName, 'cpu.perf.data'); + let profilerPid: string; + try { + profilerPid = await startAndroidSimpleperfBackgroundTool(adb, appPid, remotePath, packageName); + } catch (error) { + await cleanupAndroidRemotePath(adb, remotePath); + throw error; + } + const session = { + type: 'cpu-profile', + kind: 'simpleperf', + packageName, + appPid, + profilerPid, + remotePath, + outPath, + startedAt: Date.now(), + state: 'running', + } satisfies AndroidNativePerfSession; + return { + ...session, + action: 'start', + platform: 'android', + method: ANDROID_SIMPLEPERF_METHOD, + message: `Started Android Simpleperf CPU profile for ${packageName}`, + }; +} + +export async function stopAndroidSimpleperfProfile( + device: DeviceInfo, + session: AndroidNativePerfSession, + outPath: string, + options: AndroidNativePerfOptions = {}, +): Promise { + return await stopAndroidNativePerfSession(device, { ...session, outPath }, options); +} + +export async function writeAndroidSimpleperfReport( + device: DeviceInfo, + session: AndroidNativePerfSession, + outPath: string, + options: AndroidNativePerfOptions = {}, +): Promise { + const adb = resolveAndroidAdbExecutor(device, options.adb); + await assertAndroidNativeToolAvailable(adb, 'simpleperf', session.packageName); + const report = await runAndroidSimpleperfReport(adb, session); + const generatedAt = new Date().toISOString(); + const entries = parseSimpleperfReportEntries(report.stdout); + const payload = { + kind: 'simpleperf-report', + generatedAt, + packageName: session.packageName, + appPid: session.appPid, + sourceProfilePath: session.outPath, + sourceRemotePath: session.remotePath, + entryCount: entries.length, + entries, + }; + await writeJsonArtifact(outPath, payload); + const sizeBytes = await readFileSize(outPath); + return { + action: 'report', + platform: 'android', + type: 'cpu-profile-report', + kind: 'simpleperf', + packageName: session.packageName, + appPid: session.appPid, + sourceProfilePath: session.outPath, + outPath, + sizeBytes, + generatedAt, + entryCount: entries.length, + method: ANDROID_SIMPLEPERF_METHOD, + message: `Wrote Android Simpleperf report for ${session.packageName}`, + }; +} + +async function startAndroidSimpleperfBackgroundTool( + adb: AndroidAdbExecutor, + appPid: string, + remotePath: string, + packageName: string, +): Promise { + try { + const result = await adb(['shell', buildSimpleperfStartCommand(appPid, remotePath)], { + timeoutMs: ANDROID_NATIVE_PROFILE_TIMEOUT_MS, + }); + const pid = findPidToken(result.stdout); + if (pid) return pid; + throw new AppError('COMMAND_FAILED', 'Android simpleperf did not return a profiler pid', { + package: packageName, + tool: 'simpleperf', + hint: 'Retry perf. If simpleperf exits immediately, verify the app is profileable and the device permits native profiling.', + }); + } catch (error) { + throw annotateAndroidNativePerfError('start', 'simpleperf', packageName, error); + } +} + +function buildSimpleperfStartCommand(appPid: string, remotePath: string): string { + return buildBackgroundShellCommand( + [ + 'simpleperf', + 'record', + '-e', + 'cpu-clock:u', + '-p', + appPid, + '-o', + remotePath, + '--duration', + String(ANDROID_NATIVE_MAX_SECONDS), + ], + 'simpleperf', + ); +} + +function buildBackgroundShellCommand(argv: string[], label: string): string { + const command = argv.map(shellQuote).join(' '); + const stderrPath = `${ANDROID_NATIVE_REMOTE_DIR}/agent-device-${label}-${Date.now()}.err`; + return [ + `err=${shellQuote(stderrPath)}`, + `(${command}) >/dev/null 2>"$err" & pid=$!`, + 'sleep 1', + 'if kill -0 "$pid" 2>/dev/null; then rm -f "$err"; echo "$pid"; exit 0; fi', + 'cat "$err" >&2', + 'rm -f "$err"', + 'exit 1', + ].join('; '); +} + +async function runAndroidSimpleperfReport( + adb: AndroidAdbExecutor, + session: AndroidNativePerfSession, +): Promise<{ stdout: string }> { + try { + return await adb( + [ + 'shell', + 'simpleperf', + 'report', + '-i', + session.remotePath, + '--stdio', + '--sort', + 'comm,dso,symbol', + ], + { + timeoutMs: ANDROID_NATIVE_PROFILE_TIMEOUT_MS, + }, + ); + } catch (error) { + throw annotateAndroidNativePerfError('report', 'simpleperf', session.packageName, error); + } +} diff --git a/src/platforms/android/perf-native-summary.ts b/src/platforms/android/perf-native-summary.ts new file mode 100644 index 000000000..63c757557 --- /dev/null +++ b/src/platforms/android/perf-native-summary.ts @@ -0,0 +1,63 @@ +import type { DeviceInfo } from '../../utils/device.ts'; +import { sampleAndroidFramePerf } from './perf-frame.ts'; +import type { + AndroidNativePerfFrameHealthSummary, + AndroidNativePerfOptions, + AndroidNativePerfSession, + AndroidNativePerfStopSummary, +} from './perf-native-types.ts'; + +export async function buildAndroidNativePerfStopSummary( + device: DeviceInfo, + session: AndroidNativePerfSession, + sizeBytes: number, + durationMs: number, + options: AndroidNativePerfOptions, +): Promise { + return { + capture: { + durationMs, + packageName: session.packageName, + appPid: session.appPid, + artifactPath: session.outPath, + sizeBytes, + }, + frameHealth: + session.kind === 'perfetto' + ? await sampleAndroidNativePerfFrameHealth(device, session.packageName, options) + : undefined, + notes: [ + session.kind === 'perfetto' + ? 'Frame health is sampled from Android gfxinfo around the trace window; open the Perfetto artifact for timeline root cause.' + : 'Open the Simpleperf report artifact for symbol-level CPU attribution.', + ], + }; +} + +async function sampleAndroidNativePerfFrameHealth( + device: DeviceInfo, + packageName: string, + options: AndroidNativePerfOptions, +): Promise { + try { + const sample = await sampleAndroidFramePerf(device, packageName, options); + return { + available: true, + droppedFramePercent: sample.droppedFramePercent, + droppedFrameCount: sample.droppedFrameCount, + totalFrameCount: sample.totalFrameCount, + method: sample.method, + worstWindows: sample.worstWindows?.slice(0, 3).map((window) => ({ + startOffsetMs: window.startOffsetMs, + endOffsetMs: window.endOffsetMs, + missedDeadlineFrameCount: window.missedDeadlineFrameCount, + worstFrameMs: window.worstFrameMs, + })), + }; + } catch (error) { + return { + available: false, + reason: error instanceof Error ? error.message : 'Android frame health was not available', + }; + } +} diff --git a/src/platforms/android/perf-native-types.ts b/src/platforms/android/perf-native-types.ts new file mode 100644 index 000000000..8f49daf49 --- /dev/null +++ b/src/platforms/android/perf-native-types.ts @@ -0,0 +1,99 @@ +import type { AndroidAdbExecutor } from './adb-executor.ts'; + +export const ANDROID_SIMPLEPERF_METHOD = 'adb-shell-simpleperf'; +export const ANDROID_PERFETTO_METHOD = 'adb-shell-perfetto'; + +export const ANDROID_PERF_TIMEOUT_MS = 15_000; +export const ANDROID_NATIVE_PROFILE_TIMEOUT_MS = 30_000; +export const ANDROID_NATIVE_REMOTE_DIR = '/data/local/tmp'; +export const ANDROID_PERFETTO_REMOTE_DIR = '/data/misc/perfetto-traces'; +export const ANDROID_NATIVE_MAX_SECONDS = 60 * 60; + +export type AndroidNativePerfOptions = { + adb?: AndroidAdbExecutor; +}; + +export type AndroidNativePerfKind = 'simpleperf' | 'perfetto'; + +export type AndroidNativePerfType = 'cpu-profile' | 'trace'; + +export type AndroidNativePerfSession = { + type: AndroidNativePerfType; + kind: AndroidNativePerfKind; + packageName: string; + appPid: string; + profilerPid: string; + remotePath: string; + outPath: string; + startedAt: number; + state: 'running' | 'stopped'; + stoppedAt?: number; + sizeBytes?: number; +}; + +export type AndroidNativePerfStartResult = AndroidNativePerfSession & { + action: 'start'; + platform: 'android'; + method: typeof ANDROID_SIMPLEPERF_METHOD | typeof ANDROID_PERFETTO_METHOD; + message: string; +}; + +export type AndroidNativePerfStopResult = AndroidNativePerfSession & { + action: 'stop'; + platform: 'android'; + durationMs: number; + method: typeof ANDROID_SIMPLEPERF_METHOD | typeof ANDROID_PERFETTO_METHOD; + artifact: { + path: string; + sizeBytes: number; + }; + summary: AndroidNativePerfStopSummary; + message: string; +}; + +export type AndroidNativePerfStopSummary = { + capture: { + durationMs: number; + packageName: string; + appPid: string; + artifactPath: string; + sizeBytes: number; + }; + frameHealth?: AndroidNativePerfFrameHealthSummary; + notes: string[]; +}; + +export type AndroidNativePerfFrameHealthSummary = + | { + available: true; + droppedFramePercent: number; + droppedFrameCount: number; + totalFrameCount: number; + method: string; + worstWindows?: Array<{ + startOffsetMs?: number; + endOffsetMs?: number; + missedDeadlineFrameCount: number; + worstFrameMs?: number; + }>; + } + | { + available: false; + reason: string; + }; + +export type AndroidSimpleperfReportResult = { + action: 'report'; + platform: 'android'; + type: 'cpu-profile-report'; + kind: 'simpleperf'; + packageName: string; + appPid: string; + sourceProfilePath: string; + outPath: string; + sizeBytes: number; + generatedAt: string; + entryCount: number; + method: typeof ANDROID_SIMPLEPERF_METHOD; + message: string; +}; diff --git a/src/platforms/android/perf-native.ts b/src/platforms/android/perf-native.ts index 2d861823a..d2038046a 100644 --- a/src/platforms/android/perf-native.ts +++ b/src/platforms/android/perf-native.ts @@ -1,741 +1,18 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { setTimeout as delay } from 'node:timers/promises'; -import type { DeviceInfo } from '../../utils/device.ts'; -import { AppError } from '../../utils/errors.ts'; -import { resolveAndroidAdbExecutor, type AndroidAdbExecutor } from './adb-executor.ts'; -import { resetAndroidFramePerfStats, sampleAndroidFramePerf } from './perf-frame.ts'; -import { parseSimpleperfReportEntries } from './perf-native-report.ts'; - -const ANDROID_SIMPLEPERF_METHOD = 'adb-shell-simpleperf'; -const ANDROID_PERFETTO_METHOD = 'adb-shell-perfetto'; - -const ANDROID_PERF_TIMEOUT_MS = 15_000; -const ANDROID_NATIVE_PROFILE_TIMEOUT_MS = 30_000; -const ANDROID_NATIVE_REMOTE_DIR = '/data/local/tmp'; -const ANDROID_PERFETTO_REMOTE_DIR = '/data/misc/perfetto-traces'; -const ANDROID_NATIVE_MAX_SECONDS = 60 * 60; -const ANDROID_NATIVE_ARTIFACT_POLL_INTERVAL_MS = 250; -const ANDROID_NATIVE_ARTIFACT_POLL_ATTEMPTS = 12; - -export type AndroidNativePerfOptions = { - adb?: AndroidAdbExecutor; -}; - -export type AndroidNativePerfKind = 'simpleperf' | 'perfetto'; - -export type AndroidNativePerfType = 'cpu-profile' | 'trace'; - -export type AndroidNativePerfSession = { - type: AndroidNativePerfType; - kind: AndroidNativePerfKind; - packageName: string; - appPid: string; - profilerPid: string; - remotePath: string; - outPath: string; - startedAt: number; - state: 'running' | 'stopped'; - stoppedAt?: number; - sizeBytes?: number; -}; - -export type AndroidNativePerfStartResult = AndroidNativePerfSession & { - action: 'start'; - platform: 'android'; - method: typeof ANDROID_SIMPLEPERF_METHOD | typeof ANDROID_PERFETTO_METHOD; - message: string; -}; - -export type AndroidNativePerfStopResult = AndroidNativePerfSession & { - action: 'stop'; - platform: 'android'; - durationMs: number; - method: typeof ANDROID_SIMPLEPERF_METHOD | typeof ANDROID_PERFETTO_METHOD; - artifact: { - path: string; - sizeBytes: number; - }; - summary: AndroidNativePerfStopSummary; - message: string; -}; - -export type AndroidNativePerfStopSummary = { - capture: { - durationMs: number; - packageName: string; - appPid: string; - artifactPath: string; - sizeBytes: number; - }; - frameHealth?: AndroidNativePerfFrameHealthSummary; - notes: string[]; -}; - -export type AndroidNativePerfFrameHealthSummary = - | { - available: true; - droppedFramePercent: number; - droppedFrameCount: number; - totalFrameCount: number; - method: string; - worstWindows?: Array<{ - startOffsetMs?: number; - endOffsetMs?: number; - missedDeadlineFrameCount: number; - worstFrameMs?: number; - }>; - } - | { - available: false; - reason: string; - }; - -export type AndroidSimpleperfReportResult = { - action: 'report'; - platform: 'android'; - type: 'cpu-profile-report'; - kind: 'simpleperf'; - packageName: string; - appPid: string; - sourceProfilePath: string; - outPath: string; - sizeBytes: number; - generatedAt: string; - entryCount: number; - method: typeof ANDROID_SIMPLEPERF_METHOD; - message: string; -}; - -export async function startAndroidSimpleperfProfile( - device: DeviceInfo, - packageName: string, - outPath: string, - options: AndroidNativePerfOptions = {}, -): Promise { - const adb = resolveAndroidAdbExecutor(device, options.adb); - const appPid = await resolveAndroidAppPid(adb, packageName); - await assertAndroidToolAvailable(adb, 'simpleperf', packageName); - const remotePath = buildAndroidNativeRemotePath(packageName, 'cpu.perf.data'); - let profilerPid: string; - try { - profilerPid = await startAndroidBackgroundTool( - adb, - buildSimpleperfStartCommand(appPid, remotePath), - 'simpleperf', - packageName, - ); - } catch (error) { - await cleanupAndroidRemotePath(adb, remotePath); - throw error; - } - const session = { - type: 'cpu-profile', - kind: 'simpleperf', - packageName, - appPid, - profilerPid, - remotePath, - outPath, - startedAt: Date.now(), - state: 'running', - } satisfies AndroidNativePerfSession; - return { - ...session, - action: 'start', - platform: 'android', - method: ANDROID_SIMPLEPERF_METHOD, - message: `Started Android Simpleperf CPU profile for ${packageName}`, - }; -} - -export async function stopAndroidSimpleperfProfile( - device: DeviceInfo, - session: AndroidNativePerfSession, - outPath: string, - options: AndroidNativePerfOptions = {}, -): Promise { - return await stopAndroidNativePerfSession(device, { ...session, outPath }, options); -} - -export async function writeAndroidSimpleperfReport( - device: DeviceInfo, - session: AndroidNativePerfSession, - outPath: string, - options: AndroidNativePerfOptions = {}, -): Promise { - const adb = resolveAndroidAdbExecutor(device, options.adb); - await assertAndroidToolAvailable(adb, 'simpleperf', session.packageName); - const report = await runAndroidSimpleperfReport(adb, session); - const generatedAt = new Date().toISOString(); - const entries = parseSimpleperfReportEntries(report.stdout); - const payload = { - kind: 'simpleperf-report', - generatedAt, - packageName: session.packageName, - appPid: session.appPid, - sourceProfilePath: session.outPath, - sourceRemotePath: session.remotePath, - entryCount: entries.length, - entries, - }; - await writeJsonArtifact(outPath, payload); - const sizeBytes = await readFileSize(outPath); - return { - action: 'report', - platform: 'android', - type: 'cpu-profile-report', - kind: 'simpleperf', - packageName: session.packageName, - appPid: session.appPid, - sourceProfilePath: session.outPath, - outPath, - sizeBytes, - generatedAt, - entryCount: entries.length, - method: ANDROID_SIMPLEPERF_METHOD, - message: `Wrote Android Simpleperf report for ${session.packageName}`, - }; -} - -export async function startAndroidPerfettoTrace( - device: DeviceInfo, - packageName: string, - outPath: string, - options: AndroidNativePerfOptions = {}, -): Promise { - const adb = resolveAndroidAdbExecutor(device, options.adb); - const appPid = await resolveAndroidAppPid(adb, packageName); - await assertAndroidToolAvailable(adb, 'perfetto', packageName); - const remotePath = buildAndroidNativeRemotePath( - packageName, - 'app.perfetto-trace', - ANDROID_PERFETTO_REMOTE_DIR, - ); - let profilerPid: string; - try { - await resetAndroidFramePerfStats(device, packageName, { adb }); - profilerPid = await startAndroidPerfettoBackgroundTool(adb, remotePath, packageName); - } catch (error) { - await cleanupAndroidRemotePath(adb, remotePath); - throw error; - } - const session = { - type: 'trace', - kind: 'perfetto', - packageName, - appPid, - profilerPid, - remotePath, - outPath, - startedAt: Date.now(), - state: 'running', - } satisfies AndroidNativePerfSession; - return { - ...session, - action: 'start', - platform: 'android', - method: ANDROID_PERFETTO_METHOD, - message: `Started Android Perfetto trace for ${packageName}`, - }; -} - -export async function stopAndroidPerfettoTrace( - device: DeviceInfo, - session: AndroidNativePerfSession, - outPath: string, - options: AndroidNativePerfOptions = {}, -): Promise { - return await stopAndroidNativePerfSession(device, { ...session, outPath }, options); -} - -export async function cleanupAndroidNativePerfSession( - device: DeviceInfo, - session: AndroidNativePerfSession, - options: AndroidNativePerfOptions = {}, -): Promise { - const adb = resolveAndroidAdbExecutor(device, options.adb); - try { - if (session.state === 'running') { - await stopAndroidBackgroundTool(adb, session); - await waitForAndroidNativeArtifact(adb, session).catch(() => {}); - } - } finally { - await cleanupAndroidRemotePath(adb, session.remotePath); - } -} - -async function stopAndroidNativePerfSession( - device: DeviceInfo, - session: AndroidNativePerfSession, - options: AndroidNativePerfOptions, -): Promise { - const adb = resolveAndroidAdbExecutor(device, options.adb); - await stopAndroidBackgroundTool(adb, session); - await waitForAndroidNativeArtifact(adb, session); - await pullAndroidNativeArtifact(adb, session); - const sizeBytes = await readFileSize(session.outPath); - await cleanupAndroidRemotePath(adb, session.remotePath); - const stoppedAt = Date.now(); - const durationMs = Math.max(0, stoppedAt - session.startedAt); - const summary = await buildAndroidNativePerfStopSummary(device, session, sizeBytes, durationMs, { - adb, - }); - return { - ...session, - action: 'stop', - platform: 'android', - state: 'stopped', - stoppedAt, - durationMs, - sizeBytes, - method: session.kind === 'simpleperf' ? ANDROID_SIMPLEPERF_METHOD : ANDROID_PERFETTO_METHOD, - artifact: { - path: session.outPath, - sizeBytes, - }, - summary, - message: `Stopped Android ${session.kind} ${session.type} for ${session.packageName}`, - }; -} - -async function buildAndroidNativePerfStopSummary( - device: DeviceInfo, - session: AndroidNativePerfSession, - sizeBytes: number, - durationMs: number, - options: AndroidNativePerfOptions, -): Promise { - return { - capture: { - durationMs, - packageName: session.packageName, - appPid: session.appPid, - artifactPath: session.outPath, - sizeBytes, - }, - frameHealth: - session.kind === 'perfetto' - ? await sampleAndroidNativePerfFrameHealth(device, session.packageName, options) - : undefined, - notes: [ - session.kind === 'perfetto' - ? 'Frame health is sampled from Android gfxinfo around the trace window; open the Perfetto artifact for timeline root cause.' - : 'Open the Simpleperf report artifact for symbol-level CPU attribution.', - ], - }; -} - -async function sampleAndroidNativePerfFrameHealth( - device: DeviceInfo, - packageName: string, - options: AndroidNativePerfOptions, -): Promise { - try { - const sample = await sampleAndroidFramePerf(device, packageName, options); - return { - available: true, - droppedFramePercent: sample.droppedFramePercent, - droppedFrameCount: sample.droppedFrameCount, - totalFrameCount: sample.totalFrameCount, - method: sample.method, - worstWindows: sample.worstWindows?.slice(0, 3).map((window) => ({ - startOffsetMs: window.startOffsetMs, - endOffsetMs: window.endOffsetMs, - missedDeadlineFrameCount: window.missedDeadlineFrameCount, - worstFrameMs: window.worstFrameMs, - })), - }; - } catch (error) { - return { - available: false, - reason: error instanceof Error ? error.message : 'Android frame health was not available', - }; - } -} - -async function resolveAndroidAppPid(adb: AndroidAdbExecutor, packageName: string): Promise { - try { - const result = await adb(['shell', 'pidof', packageName], { - allowFailure: true, - timeoutMs: ANDROID_PERF_TIMEOUT_MS, - }); - const pid = findPidToken(result.stdout); - if (result.exitCode === 0 && pid) return pid; - } catch { - // Fall through to the actionable error below. - } - throw new AppError('COMMAND_FAILED', `No active Android app process found for ${packageName}`, { - package: packageName, - hint: 'Run open for this session again, wait for the app UI to appear, then retry perf.', - }); -} - -async function assertAndroidToolAvailable( - adb: AndroidAdbExecutor, - tool: 'simpleperf' | 'perfetto', - packageName: string, -): Promise { - const result = await adb(['shell', `command -v ${tool} || which ${tool}`], { - allowFailure: true, - timeoutMs: ANDROID_PERF_TIMEOUT_MS, - }); - if (result.exitCode === 0 && result.stdout.trim()) return; - throw new AppError('UNSUPPORTED_OPERATION', `Android device does not expose ${tool}`, { - package: packageName, - tool, - hint: - tool === 'simpleperf' - ? 'Use an emulator/system image with simpleperf available, or install the Android NDK simpleperf binary for this device.' - : 'Use Android 10+ or a system image that exposes the perfetto command-line binary.', - }); -} - -function buildAndroidNativeRemotePath( - packageName: string, - fileName: string, - remoteDir = ANDROID_NATIVE_REMOTE_DIR, -): string { - const safePackage = packageName.replace(/[^A-Za-z0-9_.-]/g, '_'); - return `${remoteDir}/agent-device-${safePackage}-${Date.now()}-${fileName}`; -} - -function buildSimpleperfStartCommand(appPid: string, remotePath: string): string { - return buildBackgroundShellCommand( - [ - 'simpleperf', - 'record', - '-e', - 'cpu-clock:u', - '-p', - appPid, - '-o', - remotePath, - '--duration', - String(ANDROID_NATIVE_MAX_SECONDS), - ], - 'simpleperf', - ); -} - -function buildBackgroundShellCommand(argv: string[], label: string): string { - const command = argv.map(shellQuote).join(' '); - const stderrPath = `${ANDROID_NATIVE_REMOTE_DIR}/agent-device-${label}-${Date.now()}.err`; - return [ - `err=${shellQuote(stderrPath)}`, - `(${command}) >/dev/null 2>"$err" & pid=$!`, - 'sleep 1', - 'if kill -0 "$pid" 2>/dev/null; then rm -f "$err"; echo "$pid"; exit 0; fi', - 'cat "$err" >&2', - 'rm -f "$err"', - 'exit 1', - ].join('; '); -} - -async function startAndroidPerfettoBackgroundTool( - adb: AndroidAdbExecutor, - remotePath: string, - packageName: string, -): Promise { - try { - const result = await adb( - [ - 'shell', - 'perfetto', - '--background-wait', - '-o', - remotePath, - '-t', - `${ANDROID_NATIVE_MAX_SECONDS}s`, - 'sched', - 'freq', - 'idle', - 'am', - 'wm', - 'gfx', - 'view', - 'binder_driver', - 'hal', - 'dalvik', - ], - { - timeoutMs: ANDROID_NATIVE_PROFILE_TIMEOUT_MS, - }, - ); - const pid = findPidToken(result.stdout); - if (pid) return pid; - throw new AppError('COMMAND_FAILED', 'Android perfetto did not return a profiler pid', { - package: packageName, - tool: 'perfetto', - hint: 'Retry perf trace start. If perfetto exits immediately, verify the device permits trace capture.', - }); - } catch (error) { - throw annotateAndroidNativePerfError('start', 'perfetto', packageName, error); - } -} - -async function startAndroidBackgroundTool( - adb: AndroidAdbExecutor, - shellCommand: string, - tool: AndroidNativePerfKind, - packageName: string, -): Promise { - try { - const result = await adb(['shell', shellCommand], { - timeoutMs: ANDROID_NATIVE_PROFILE_TIMEOUT_MS, - }); - const pid = findPidToken(result.stdout); - if (pid) return pid; - throw new AppError('COMMAND_FAILED', `Android ${tool} did not return a profiler pid`, { - package: packageName, - tool, - hint: `Retry perf. If ${tool} exits immediately, verify the app is profileable and the device permits native profiling.`, - }); - } catch (error) { - throw annotateAndroidNativePerfError('start', tool, packageName, error); - } -} - -async function stopAndroidBackgroundTool( - adb: AndroidAdbExecutor, - session: AndroidNativePerfSession, -): Promise { - try { - await adb(['shell', buildStopProfilerCommand(session.profilerPid)], { - timeoutMs: ANDROID_NATIVE_PROFILE_TIMEOUT_MS, - }); - } catch (error) { - throw annotateAndroidNativePerfError('stop', session.kind, session.packageName, error); - } -} - -function buildStopProfilerCommand(pid: string): string { - return [ - `pid=${shellQuote(pid)}`, - 'kill -INT "$pid" 2>/dev/null || true', - 'for i in 1 2 3 4 5 6 7 8 9 10; do kill -0 "$pid" 2>/dev/null || exit 0; sleep 0.2; done', - 'kill -TERM "$pid" 2>/dev/null || true', - 'for i in 1 2 3 4 5 6 7 8 9 10; do kill -0 "$pid" 2>/dev/null || exit 0; sleep 0.2; done', - 'echo "profiler process did not stop after SIGTERM" >&2', - 'exit 1', - ].join('; '); -} - -function findPidToken(stdout: string): string | undefined { - return stdout - .trim() - .split(/\s+/) - .find((token) => /^\d+$/.test(token)); -} - -async function pullAndroidNativeArtifact( - adb: AndroidAdbExecutor, - session: AndroidNativePerfSession, -): Promise { - await fs.mkdir(path.dirname(session.outPath), { recursive: true }); - try { - await adb(['pull', session.remotePath, session.outPath], { - timeoutMs: ANDROID_NATIVE_PROFILE_TIMEOUT_MS, - }); - } catch (error) { - throw new AppError( - 'COMMAND_FAILED', - `Failed to pull Android ${session.kind} artifact for ${session.packageName}`, - { - package: session.packageName, - tool: session.kind, - remotePath: session.remotePath, - outPath: session.outPath, - hint: 'Check that the profiling command ran long enough to create an artifact, then retry stop with the same session.', - }, - error, - ); - } -} - -async function waitForAndroidNativeArtifact( - adb: AndroidAdbExecutor, - session: AndroidNativePerfSession, -): Promise { - let previousSize: number | undefined; - let stableSamples = 0; - for (let attempt = 0; attempt < ANDROID_NATIVE_ARTIFACT_POLL_ATTEMPTS; attempt += 1) { - const size = await readAndroidRemoteFileSize(adb, session.remotePath); - if (size !== undefined && size > 0 && size === previousSize) { - stableSamples += 1; - if (stableSamples >= 1) return; - } else { - stableSamples = 0; - } - previousSize = size; - await delay(ANDROID_NATIVE_ARTIFACT_POLL_INTERVAL_MS); - } - throw new AppError('COMMAND_FAILED', `Android ${session.kind} artifact is not ready to pull`, { - package: session.packageName, - tool: session.kind, - remotePath: session.remotePath, - hint: 'The profiler stopped, but the remote artifact was missing, empty, or still changing. Retry stop with the same session or inspect the device-side artifact.', - }); -} - -async function readAndroidRemoteFileSize( - adb: AndroidAdbExecutor, - remotePath: string, -): Promise { - const quotedPath = shellQuote(remotePath); - const result = await adb( - [ - 'shell', - `if [ -f ${quotedPath} ]; then stat -c %s ${quotedPath} 2>/dev/null || wc -c < ${quotedPath}; fi`, - ], - { - allowFailure: true, - timeoutMs: ANDROID_PERF_TIMEOUT_MS, - }, - ); - if (result.exitCode !== 0) return undefined; - const value = Number(result.stdout.trim().split(/\s+/)[0]); - return Number.isFinite(value) ? value : undefined; -} - -async function cleanupAndroidRemotePath( - adb: AndroidAdbExecutor, - remotePath: string, -): Promise { - try { - await adb(['shell', `rm -f ${shellQuote(remotePath)}`], { - allowFailure: true, - timeoutMs: ANDROID_PERF_TIMEOUT_MS, - }); - } catch { - // Best-effort cleanup must not hide the primary profiling result. - } -} - -async function runAndroidSimpleperfReport( - adb: AndroidAdbExecutor, - session: AndroidNativePerfSession, -): Promise<{ stdout: string }> { - try { - return await adb( - [ - 'shell', - 'simpleperf', - 'report', - '-i', - session.remotePath, - '--stdio', - '--sort', - 'comm,dso,symbol', - ], - { - timeoutMs: ANDROID_NATIVE_PROFILE_TIMEOUT_MS, - }, - ); - } catch (error) { - throw annotateAndroidNativePerfError('report', 'simpleperf', session.packageName, error); - } -} - -async function writeJsonArtifact(outPath: string, value: unknown): Promise { - await fs.mkdir(path.dirname(outPath), { recursive: true }); - await fs.writeFile(outPath, `${JSON.stringify(value, null, 2)}\n`); -} - -async function readFileSize(filePath: string): Promise { - try { - return (await fs.stat(filePath)).size; - } catch (error) { - throw new AppError( - 'COMMAND_FAILED', - `Profiler artifact was not written: ${filePath}`, - { - outPath: filePath, - hint: 'Retry the profiling command and check daemon logs if the artifact path is still missing.', - }, - error, - ); - } -} - -function annotateAndroidNativePerfError( - action: 'start' | 'stop' | 'report', - tool: AndroidNativePerfKind, - packageName: string, - error: unknown, -): AppError { - if (error instanceof AppError) { - const details = error.details ?? {}; - return new AppError( - error.code, - error.message, - { - ...details, - action, - package: packageName, - tool, - hint: - typeof details.hint === 'string' - ? details.hint - : classifyAndroidNativePerfHint(tool, details), - }, - error, - ); - } - return new AppError( - 'COMMAND_FAILED', - `Failed to ${action} Android ${tool} for ${packageName}`, - { - action, - package: packageName, - tool, - hint: buildAndroidNativePerfHint(tool), - }, - error, - ); -} - -function buildAndroidNativePerfHint(tool: AndroidNativePerfKind): string { - return tool === 'simpleperf' - ? 'Verify simpleperf is available, the app process is running, and the app/device permits native CPU profiling.' - : 'Verify perfetto is available, the app process is running, and the device permits trace capture.'; -} - -function classifyAndroidNativePerfHint( - tool: AndroidNativePerfKind, - details: Record, -): string { - const stderr = typeof details.stderr === 'string' ? details.stderr : ''; - const text = stderr.toLowerCase(); - if (tool === 'simpleperf') return classifySimpleperfHint(text); - if (hasPerfettoPermissionError(text)) { - return 'Use a device image that permits perfetto trace capture for shell, keep the app running, then retry perf trace start.'; - } - return buildAndroidNativePerfHint(tool); -} - -function classifySimpleperfHint(text: string): string { - if (hasSimpleperfProfileabilityError(text)) { - return 'Use a debuggable/profileable Android app or a device image that permits simpleperf for the target process, then retry perf cpu profile start.'; - } - if (text.includes('not supported') || text.includes('failed to open perf event')) { - return 'This device image does not expose the requested simpleperf event for the app process. Try a different emulator/system image or a profileable app.'; - } - return buildAndroidNativePerfHint('simpleperf'); -} - -function hasSimpleperfProfileabilityError(text: string): boolean { - return ( - text.includes('permission denied') || - text.includes('not profileable') || - text.includes('profileable') - ); -} - -function hasPerfettoPermissionError(text: string): boolean { - return text.includes('permission denied') || text.includes('not allowed'); -} - -function shellQuote(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'`; -} +export { cleanupAndroidNativePerfSession } from './perf-native-artifacts.ts'; +export { startAndroidPerfettoTrace, stopAndroidPerfettoTrace } from './perf-native-perfetto.ts'; +export { + startAndroidSimpleperfProfile, + stopAndroidSimpleperfProfile, + writeAndroidSimpleperfReport, +} from './perf-native-simpleperf.ts'; +export type { + AndroidNativePerfKind, + AndroidNativePerfOptions, + AndroidNativePerfFrameHealthSummary, + AndroidNativePerfSession, + AndroidNativePerfStartResult, + AndroidNativePerfStopResult, + AndroidNativePerfStopSummary, + AndroidNativePerfType, + AndroidSimpleperfReportResult, +} from './perf-native-types.ts'; From f999f0d75eea795add55e18490580b7eb0a0f471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 12 Jun 2026 14:14:01 +0200 Subject: [PATCH 2/3] fix: keep android perf hint helper local --- src/platforms/android/perf-native-errors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platforms/android/perf-native-errors.ts b/src/platforms/android/perf-native-errors.ts index 68329a787..bebe85068 100644 --- a/src/platforms/android/perf-native-errors.ts +++ b/src/platforms/android/perf-native-errors.ts @@ -38,7 +38,7 @@ export function annotateAndroidNativePerfError( ); } -export function buildAndroidNativePerfHint(tool: AndroidNativePerfKind): string { +function buildAndroidNativePerfHint(tool: AndroidNativePerfKind): string { return tool === 'simpleperf' ? 'Verify simpleperf is available, the app process is running, and the app/device permits native CPU profiling.' : 'Verify perfetto is available, the app process is running, and the device permits trace capture.'; From 5b76156b95b69816096ecd9a16b3660c22195671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 12 Jun 2026 14:16:52 +0200 Subject: [PATCH 3/3] refactor: simplify android perf artifact polling --- src/platforms/android/perf-native-artifacts.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/platforms/android/perf-native-artifacts.ts b/src/platforms/android/perf-native-artifacts.ts index d4fb1b9e7..d62048d88 100644 --- a/src/platforms/android/perf-native-artifacts.ts +++ b/src/platforms/android/perf-native-artifacts.ts @@ -174,14 +174,10 @@ async function waitForAndroidNativeArtifact( session: AndroidNativePerfSession, ): Promise { let previousSize: number | undefined; - let stableSamples = 0; for (let attempt = 0; attempt < ANDROID_NATIVE_ARTIFACT_POLL_ATTEMPTS; attempt += 1) { const size = await readAndroidRemoteFileSize(adb, session.remotePath); if (size !== undefined && size > 0 && size === previousSize) { - stableSamples += 1; - if (stableSamples >= 1) return; - } else { - stableSamples = 0; + return; } previousSize = size; await delay(ANDROID_NATIVE_ARTIFACT_POLL_INTERVAL_MS);