Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 211 additions & 0 deletions src/platforms/android/perf-native-artifacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
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<AndroidNativePerfStopResult> {
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<void> {
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<void> {
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<void> {
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<number> {
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<void> {
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<void> {
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<void> {
let previousSize: number | undefined;
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) {
return;
}
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<number | undefined> {
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;
}
86 changes: 86 additions & 0 deletions src/platforms/android/perf-native-errors.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}

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, unknown>,
): 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');
}
Loading
Loading