diff --git a/src/__tests__/cli-perf.test.ts b/src/__tests__/cli-perf.test.ts index 67b81da4a..c25aab937 100644 --- a/src/__tests__/cli-perf.test.ts +++ b/src/__tests__/cli-perf.test.ts @@ -170,6 +170,122 @@ test('perf forwards shared perf kind values through CLI parsing', async () => { assert.equal(payload.error.code, 'INVALID_ARGS'); }); +test('perf cpu profile start forwards xctrace options to daemon positionals', async () => { + const result = await runCliCapture( + [ + 'perf', + 'cpu', + 'profile', + 'start', + '--kind', + 'xctrace', + '--template', + 'Time Profiler', + '--out', + 'app.trace', + '--json', + ], + async () => ({ + ok: true, + data: { + perf: 'started', + kind: 'xctrace', + mode: 'cpu-profile', + outPath: '/tmp/app.trace', + }, + }), + ); + + assert.equal(result.code, null); + assert.equal(result.calls[0]?.command, 'perf'); + assert.deepEqual(result.calls[0]?.positionals, [ + 'cpu', + 'profile', + 'start', + 'xctrace', + 'Time Profiler', + 'app.trace', + ]); +}); + +test('perf trace stop forwards xctrace trace artifact path', async () => { + const result = await runCliCapture( + ['perf', 'trace', 'stop', '--kind', 'xctrace', '--out', 'hitches.trace', '--json'], + async () => ({ + ok: true, + data: { + perf: 'stopped', + kind: 'xctrace', + mode: 'trace', + outPath: '/tmp/hitches.trace', + }, + }), + ); + + assert.equal(result.code, null); + assert.equal(result.calls[0]?.command, 'perf'); + assert.deepEqual(result.calls[0]?.positionals, ['trace', 'stop', 'xctrace', '', 'hitches.trace']); +}); + +test('perf cpu profile report preserves the report out path when template is omitted', async () => { + const result = await runCliCapture( + [ + 'perf', + 'cpu', + 'profile', + 'report', + '--kind', + 'xctrace', + '--out', + 'app-profile.json', + '--json', + ], + async () => ({ + ok: true, + data: { + perf: 'reported', + kind: 'xctrace', + mode: 'cpu-profile', + reportPath: '/tmp/app-profile.json', + }, + }), + ); + + assert.equal(result.code, null); + assert.equal(result.calls[0]?.command, 'perf'); + assert.deepEqual(result.calls[0]?.positionals, [ + 'cpu', + 'profile', + 'report', + 'xctrace', + '', + 'app-profile.json', + ]); +}); + +test('perf xctrace output prints only compact artifact metadata by default', async () => { + const result = await runCliCapture( + ['perf', 'cpu', 'profile', 'report', '--kind', 'xctrace', '--out', 'app-profile.json'], + async () => ({ + ok: true, + data: { + perf: 'reported', + kind: 'xctrace', + mode: 'cpu-profile', + reportPath: '/tmp/app-profile.json', + tracePath: '/tmp/app.trace', + summary: { + tableSchemas: ['time-profile'], + }, + }, + }), + ); + + assert.equal(result.code, null); + assert.equal(result.stdout, '/tmp/app-profile.json\nPerf cpu-profile: reported\n'); + assert.doesNotMatch(result.stdout, /time-profile|app\.trace/); +}); + test('perf sample defaults to metrics sample', async () => { const result = await runCliCapture(['perf', 'sample', '--json'], async () => ({ ok: true, @@ -206,7 +322,7 @@ test('perf area and action positionals are case-insensitive', async () => { assert.deepEqual(result.calls[0]?.positionals, ['frames', 'sample']); }); -test('perf rejects unknown CLI area before daemon dispatch', async () => { +test('perf rejects incomplete native CLI area before daemon dispatch', async () => { const result = await runCliCapture(['perf', 'cpu', '--json'], async () => ({ ok: true, data: {}, @@ -216,7 +332,7 @@ test('perf rejects unknown CLI area before daemon dispatch', async () => { assert.equal(result.calls.length, 0); const payload = JSON.parse(result.stdout); assert.equal(payload.error.code, 'INVALID_ARGS'); - assert.match(payload.error.message, /perf area must be metrics, frames, or memory/i); + assert.match(payload.error.message, /perf cpu requires profile/i); }); test('perf prints unavailable frame health reason by default', async () => { diff --git a/src/client-types.ts b/src/client-types.ts index 8ca5aee3c..507268d97 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -40,7 +40,7 @@ import type { import type { MetroBridgeScope } from './client-companion-tunnel-contract.ts'; import type { AppsFilter } from './contracts/app-inventory.ts'; import type { ScreenshotRequestFlags } from './contracts/screenshot.ts'; -import type { PerfAction, PerfArea, PerfKind } from './contracts/perf.ts'; +import type { PerfAction, PerfArea, PerfKind, PerfSubject } from './contracts/perf.ts'; import type { DaemonBatchStep } from './core/batch.ts'; import type { AlertAction, AlertInfo } from './alert-contract.ts'; @@ -741,9 +741,12 @@ export type BatchRunOptions = AgentDeviceRequestOverrides & { export type PerfOptions = DeviceCommandBaseOptions & { area?: PerfArea; + subject?: PerfSubject; action?: PerfAction; kind?: PerfKind; + template?: string; out?: string; + tracePath?: string; }; export type LogsOptions = AgentDeviceRequestOverrides & { diff --git a/src/commands/cli-grammar/observability.ts b/src/commands/cli-grammar/observability.ts index f5501812a..8c1a72ff6 100644 --- a/src/commands/cli-grammar/observability.ts +++ b/src/commands/cli-grammar/observability.ts @@ -13,12 +13,15 @@ import { isPerfAction, isPerfArea, isPerfKind, + isPerfSubject, PERF_ACTION_ERROR_MESSAGE, PERF_AREA_ERROR_MESSAGE, PERF_KIND_ERROR_MESSAGE, + PERF_SUBJECT_ERROR_MESSAGE, type PerfAction, type PerfArea, type PerfKind, + type PerfSubject, } from '../perf-command-contract.ts'; import { commonInputFromFlags, @@ -33,9 +36,11 @@ import type { CliReader, DaemonWriter } from './types.ts'; export const observabilityCliReaders = { perf: (positionals, flags) => ({ ...commonInputFromFlags(flags), - ...readPerfPositionals(positionals), - kind: readPerfKind(flags.kind), - out: flags.out, + ...readPerfPositionals(positionals, { + kind: readPerfKindFlag(flags.kind), + template: flags.perfTemplate, + out: flags.out, + }), }), logs: (positionals, flags) => ({ ...commonInputFromFlags(flags), @@ -78,26 +83,76 @@ export const observabilityDaemonWriters = { function perfPositionals(input: PerfOptions): string[] { const area = input.area ?? (input.action ? 'metrics' : undefined); + if (area === 'cpu') { + return nativePerfPositionals( + [ + ...optionalString(area), + ...optionalString(input.subject), + ...optionalString(input.action), + ...optionalString(input.kind), + ], + input, + ); + } + if (area === 'trace') { + return nativePerfPositionals( + [...optionalString(area), ...optionalString(input.action), ...optionalString(input.kind)], + input, + ); + } return [...optionalString(area), ...optionalString(input.action)]; } -function readPerfPositionals(positionals: string[]): Pick { +function nativePerfPositionals(base: string[], input: PerfOptions): string[] { + const positionals = [...base]; + if (input.template || input.out || input.tracePath) { + positionals.push(input.template ?? ''); + } + if (input.out || input.tracePath) { + positionals.push(input.out ?? ''); + } + if (input.tracePath) { + positionals.push(input.tracePath); + } + return positionals; +} + +function readPerfPositionals( + positionals: string[], + flags: Pick = {}, +): Pick { if (positionals[0] !== undefined && positionals[1] === undefined) { const action = readPerfAction(positionals[0], { allowUndefined: true }); - if (action) return { action }; + if (action) return { action, kind: readPerfKind(flags.kind), out: flags.out }; + } + const area = readPerfArea(positionals[0]); + if (area === 'cpu') { + return { + area, + subject: readPerfSubject(positionals[1]), + action: readPerfAction(positionals[2]), + kind: readPerfKind(flags.kind), + template: flags.template, + out: flags.out, + }; + } + if (area === 'trace') { + return { + area, + action: readPerfAction(positionals[1]), + kind: readPerfKind(flags.kind), + template: flags.template, + out: flags.out, + }; } return { - area: readPerfArea(positionals[0]), + area, action: readPerfAction(positionals[1]), + kind: readPerfKind(flags.kind), + out: flags.out, }; } -function readPerfKind(value: string | undefined): PerfKind | undefined { - if (value === undefined) return undefined; - if (isPerfKind(value)) return value; - throw new AppError('INVALID_ARGS', PERF_KIND_ERROR_MESSAGE); -} - function logsPositionals(input: { action?: string; message?: string }): string[] { return [input.action ?? 'path', ...optionalString(input.message)]; } @@ -133,6 +188,23 @@ function readPerfAction( throw new AppError('INVALID_ARGS', PERF_ACTION_ERROR_MESSAGE); } +function readPerfSubject(value: string | undefined): PerfSubject { + const normalized = value?.toLowerCase(); + if (normalized !== undefined && isPerfSubject(normalized)) return normalized; + throw new AppError('INVALID_ARGS', PERF_SUBJECT_ERROR_MESSAGE); +} + +function readPerfKind(value: string | undefined): PerfKind | undefined { + if (value === undefined) return undefined; + const normalized = value.toLowerCase(); + if (isPerfKind(normalized)) return normalized; + throw new AppError('INVALID_ARGS', PERF_KIND_ERROR_MESSAGE); +} + +function readPerfKindFlag(value: unknown): PerfKind | undefined { + return typeof value === 'string' ? readPerfKind(value) : undefined; +} + function readLogsAction(value: string | undefined): LogAction | undefined { if (value === undefined) return undefined; return parseStringMember(LOG_ACTION_VALUES, value, { diff --git a/src/commands/client-command-metadata.ts b/src/commands/client-command-metadata.ts index cdc26215b..360075bd8 100644 --- a/src/commands/client-command-metadata.ts +++ b/src/commands/client-command-metadata.ts @@ -23,7 +23,12 @@ import { type CommandFieldMap, } from './command-input.ts'; import { defineFieldCommandMetadata } from './field-command-contract.ts'; -import { PERF_ACTION_VALUES, PERF_AREA_VALUES, PERF_KIND_VALUES } from './perf-command-contract.ts'; +import { + PERF_ACTION_VALUES, + PERF_AREA_VALUES, + PERF_KIND_VALUES, + PERF_SUBJECT_VALUES, +} from './perf-command-contract.ts'; import { WAIT_KIND_VALUES } from './wait-command-contract.ts'; const CLIPBOARD_ACTION_VALUES = ['read', 'write'] as const; @@ -182,9 +187,12 @@ export const clientCommandMetadata = [ }), defineClientCommandMetadata('perf', { area: enumField(PERF_AREA_VALUES), + subject: enumField(PERF_SUBJECT_VALUES), action: enumField(PERF_ACTION_VALUES), kind: enumField(PERF_KIND_VALUES), - out: stringField(), + template: stringField('xctrace template name, for example Time Profiler.'), + out: stringField('Output artifact path.'), + tracePath: stringField('Existing .trace path to report, defaults to the latest session trace.'), }), defineClientCommandMetadata('logs', { action: enumField(LOG_ACTION_VALUES), diff --git a/src/commands/runtime-output.ts b/src/commands/runtime-output.ts index 7d7f2b14c..02ff7fafc 100644 --- a/src/commands/runtime-output.ts +++ b/src/commands/runtime-output.ts @@ -147,6 +147,8 @@ function joinDefinedLines(lines: Array): string | undefined } function formatPerfCliOutput(data: Record): string { + const nativeOutput = formatNativePerfOutput(data); + if (nativeOutput) return nativeOutput; const artifact = readRecord(data.artifact); if (artifact) { return formatMemoryArtifactSummary(artifact); @@ -190,6 +192,30 @@ function formatMemoryArtifactSummary(artifact: Record): string : `Memory artifact (${kind}): captured${sizeText}`; } +function formatNativePerfOutput(data: Record): string | undefined { + const state = typeof data.perf === 'string' ? data.perf : undefined; + const outPath = readNativePerfArtifactPath(data); + if (!state || !outPath || data.kind !== 'xctrace') return undefined; + const mode = typeof data.mode === 'string' ? data.mode : 'capture'; + return formatNativePerfLines(outPath, mode, state, data.template); +} + +function readNativePerfArtifactPath(data: Record): string | undefined { + if (typeof data.outPath === 'string') return data.outPath; + return typeof data.reportPath === 'string' ? data.reportPath : undefined; +} + +function formatNativePerfLines( + outPath: string, + mode: string, + state: string, + template: unknown, +): string { + const lines = [outPath, `Perf ${mode}: ${state}`]; + if (typeof template === 'string') lines.push(`Template: ${template}`); + return lines.join('\n'); +} + function formatPerfUnavailable(resourceSummary: string | undefined, reason: string): string { return resourceSummary ? `Performance: ${resourceSummary}` diff --git a/src/contracts/perf.ts b/src/contracts/perf.ts index 5cd35b327..229433faa 100644 --- a/src/contracts/perf.ts +++ b/src/contracts/perf.ts @@ -1,7 +1,8 @@ import { defineStringEnum } from '../utils/string-enum.ts'; -export const PERF_AREA_VALUES = ['metrics', 'frames', 'memory'] as const; -export const PERF_ACTION_VALUES = ['sample', 'snapshot'] as const; +export const PERF_AREA_VALUES = ['metrics', 'frames', 'memory', 'cpu', 'trace'] as const; +export const PERF_ACTION_VALUES = ['sample', 'snapshot', 'start', 'stop', 'report'] as const; +export const PERF_SUBJECT_VALUES = ['profile'] as const; export const PERF_KIND_VALUES = [ 'xctrace', 'simpleperf', @@ -12,16 +13,20 @@ export const PERF_KIND_VALUES = [ export const PERF_MEMORY_KIND_VALUES = ['android-hprof', 'memgraph'] as const; const PERF_AREAS = defineStringEnum(PERF_AREA_VALUES); const PERF_ACTIONS = defineStringEnum(PERF_ACTION_VALUES); +const PERF_SUBJECTS = defineStringEnum(PERF_SUBJECT_VALUES); const PERF_KINDS = defineStringEnum(PERF_KIND_VALUES); const PERF_MEMORY_KINDS = defineStringEnum(PERF_MEMORY_KIND_VALUES); export type PerfArea = (typeof PERF_AREA_VALUES)[number]; export type PerfAction = (typeof PERF_ACTION_VALUES)[number]; +export type PerfSubject = (typeof PERF_SUBJECT_VALUES)[number]; export type PerfKind = (typeof PERF_KIND_VALUES)[number]; export type PerfMemoryKind = (typeof PERF_MEMORY_KIND_VALUES)[number]; -export const PERF_AREA_ERROR_MESSAGE = 'perf area must be metrics, frames, or memory'; -export const PERF_ACTION_ERROR_MESSAGE = 'perf action must be sample or snapshot'; +export const PERF_AREA_ERROR_MESSAGE = 'perf area must be metrics, frames, memory, cpu, or trace'; +export const PERF_ACTION_ERROR_MESSAGE = + 'perf action must be sample, snapshot, start, stop, or report'; +export const PERF_SUBJECT_ERROR_MESSAGE = 'perf cpu requires profile'; export const PERF_KIND_ERROR_MESSAGE = 'perf --kind must be xctrace, simpleperf, perfetto, android-hprof, or memgraph'; export const PERF_MEMORY_KIND_ERROR_MESSAGE = @@ -31,6 +36,8 @@ export const isPerfArea = PERF_AREAS.is; export const isPerfAction = PERF_ACTIONS.is; +export const isPerfSubject = PERF_SUBJECTS.is; + export const isPerfKind = PERF_KINDS.is; export const isPerfMemoryKind = PERF_MEMORY_KINDS.is; diff --git a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts index 30f9c00f1..a932f50e1 100644 --- a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts +++ b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts @@ -18,6 +18,10 @@ vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, stopIosRunnerSession: vi.fn(async () => {}) }; }); +vi.mock('../../../platforms/ios/perf-xctrace.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, cleanupAppleXctracePerfCapture: vi.fn(async () => ({})) }; +}); vi.mock('../../../platforms/ios/macos-helper.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, runMacOsAlertAction: vi.fn(async () => {}) }; @@ -36,11 +40,14 @@ vi.mock('../session-device-utils.ts', async (importOriginal) => { }); import { handleSessionCommands } from '../session.ts'; +import { teardownSessionResources } from '../session-close.ts'; import { shutdownSimulator } from '../../../platforms/ios/simulator.ts'; import { runCmd } from '../../../utils/exec.ts'; +import { cleanupAppleXctracePerfCapture } from '../../../platforms/ios/perf-xctrace.ts'; const mockShutdownSimulator = vi.mocked(shutdownSimulator); const mockRunCmd = vi.mocked(runCmd); +const mockCleanupAppleXctracePerfCapture = vi.mocked(cleanupAppleXctracePerfCapture); const noopInvoke = async (_req: DaemonRequest): Promise => ({ ok: true, data: {} }); @@ -198,6 +205,92 @@ test('close --shutdown is ignored for non-simulator iOS devices', async () => { } }); +test('close stops active Apple xctrace perf capture before deleting session', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-active-xctrace-session'; + const activeCapture = { + kind: 'xctrace', + mode: 'cpu-profile', + template: 'Time Profiler', + outPath: '/tmp/app.trace', + appBundleId: 'com.example.app', + deviceId: 'sim-udid-4', + platform: 'ios', + targetPids: [111], + targetProcesses: ['Example'], + startedAt: '2026-04-01T10:00:00.000Z', + child: { kill: vi.fn(() => true), pid: 1234 }, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + }; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'ios', + id: 'sim-udid-4', + name: 'iPhone 15', + kind: 'simulator', + booted: true, + }), + appBundleId: 'com.example.app', + applePerf: { + active: activeCapture, + }, + } as unknown as SessionState); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + expect(mockCleanupAppleXctracePerfCapture).toHaveBeenCalledWith(activeCapture); + expect(sessionStore.get(sessionName)).toBeUndefined(); +}); + +test('daemon session teardown stops active Apple xctrace perf capture', async () => { + const sessionName = 'ios-active-xctrace-teardown-session'; + const activeCapture = { + kind: 'xctrace', + mode: 'cpu-profile', + template: 'Time Profiler', + outPath: '/tmp/app.trace', + appBundleId: 'com.example.app', + deviceId: 'sim-udid-5', + platform: 'ios', + targetPids: [111], + targetProcesses: ['Example'], + startedAt: '2026-04-01T10:00:00.000Z', + child: { kill: vi.fn(() => true), pid: 1234 }, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + }; + const session = { + ...makeSession(sessionName, { + platform: 'ios', + id: 'sim-udid-5', + name: 'iPhone 15', + kind: 'simulator', + booted: true, + }), + appBundleId: 'com.example.app', + applePerf: { + active: activeCapture, + }, + } as unknown as SessionState; + + await teardownSessionResources(session, sessionName); + + expect(mockCleanupAppleXctracePerfCapture).toHaveBeenCalledWith(activeCapture); + expect(session.applePerf?.active).toBeUndefined(); +}); + test('close --shutdown is ignored for Android devices', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-device-shutdown-session'; diff --git a/src/daemon/handlers/__tests__/session-observability.test.ts b/src/daemon/handlers/__tests__/session-observability.test.ts index f45de7837..b366327e8 100644 --- a/src/daemon/handlers/__tests__/session-observability.test.ts +++ b/src/daemon/handlers/__tests__/session-observability.test.ts @@ -2,11 +2,35 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { test } from 'vitest'; +import { beforeEach, test, vi } from 'vitest'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; +import { makeAndroidSession, makeIosSession } from '../../../__tests__/test-utils/index.ts'; +import { AppError } from '../../../utils/errors.ts'; +import type { AppleXctracePerfCapture } from '../../../platforms/ios/perf-xctrace.ts'; + +const applePerfMocks = vi.hoisted(() => ({ + startAppleXctracePerfCapture: vi.fn(), + stopAppleXctracePerfCapture: vi.fn(), + writeAppleXctracePerfReport: vi.fn(), +})); + +vi.mock('../../../platforms/ios/perf-xctrace.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + startAppleXctracePerfCapture: applePerfMocks.startAppleXctracePerfCapture, + stopAppleXctracePerfCapture: applePerfMocks.stopAppleXctracePerfCapture, + writeAppleXctracePerfReport: applePerfMocks.writeAppleXctracePerfReport, + }; +}); + import { handleSessionObservabilityCommands } from '../session-observability.ts'; import type { AndroidAdbExecutor } from '../../../platforms/android/adb-executor.ts'; +beforeEach(() => { + vi.resetAllMocks(); +}); + test('network dump validates include mode directly', async () => { const sessionStore = makeSessionStore('agent-device-session-observability-'); sessionStore.set('android', { @@ -43,6 +67,299 @@ test('network dump validates include mode directly', async () => { } }); +test('perf cpu profile xctrace start and stop manage compact artifact lifecycle', async () => { + const sessionStore = makeSessionStore('agent-device-session-observability-perf-'); + sessionStore.set( + 'ios', + makeIosSession('ios', { + appBundleId: 'com.example.app', + }), + ); + const activeCapture = { + kind: 'xctrace', + mode: 'cpu-profile', + template: 'Time Profiler', + outPath: '/tmp/app.trace', + appBundleId: 'com.example.app', + deviceId: 'ios-sim', + platform: 'ios', + targetPids: [111], + targetProcesses: ['Example'], + startedAt: '2026-04-01T10:00:00.000Z', + child: { kill: vi.fn(() => true), pid: 1234 }, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + }; + applePerfMocks.startAppleXctracePerfCapture.mockResolvedValue(activeCapture); + applePerfMocks.stopAppleXctracePerfCapture.mockResolvedValue({ + ...activeCapture, + endedAt: '2026-04-01T10:00:05.000Z', + }); + + const startResponse = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'ios', + command: 'perf', + positionals: ['cpu', 'profile', 'start', 'xctrace', 'Time Profiler', '/tmp/app.trace'], + flags: {}, + }, + sessionName: 'ios', + sessionStore, + }); + + assert.equal(startResponse?.ok, true); + assert.equal(startResponse?.data?.perf, 'started'); + assert.equal(startResponse?.data?.outPath, '/tmp/app.trace'); + assert.equal(sessionStore.get('ios')?.applePerf?.active?.outPath, '/tmp/app.trace'); + assert.equal( + applePerfMocks.startAppleXctracePerfCapture.mock.calls[0]?.[0].template, + 'Time Profiler', + ); + + const stopResponse = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'ios', + command: 'perf', + positionals: ['cpu', 'profile', 'stop', 'xctrace', '', '/tmp/app.trace'], + flags: {}, + }, + sessionName: 'ios', + sessionStore, + }); + + assert.equal(stopResponse?.ok, true); + assert.equal(stopResponse?.data?.perf, 'stopped'); + assert.equal(sessionStore.get('ios')?.applePerf?.active, undefined); + assert.equal(sessionStore.get('ios')?.applePerf?.lastProfileTracePath, '/tmp/app.trace'); +}); + +test('perf xctrace stop clears active capture when xctrace cleanup is confirmed', async () => { + const sessionStore = makeSessionStore('agent-device-session-observability-perf-'); + const activeCapture = { + kind: 'xctrace', + mode: 'trace', + template: 'Animation Hitches', + outPath: '/tmp/hitches.trace', + appBundleId: 'com.example.app', + deviceId: 'ios-sim', + platform: 'ios', + targetPids: [111], + targetProcesses: ['Example'], + startedAt: '2026-04-01T10:00:00.000Z', + child: { kill: vi.fn(() => true), pid: 1234 }, + wait: Promise.resolve({ + stdout: '', + stderr: 'Hitches is not supported on this platform.', + exitCode: 2, + }), + }; + sessionStore.set( + 'ios', + makeIosSession('ios', { + appBundleId: 'com.example.app', + applePerf: { + active: activeCapture as unknown as AppleXctracePerfCapture, + }, + }), + ); + applePerfMocks.stopAppleXctracePerfCapture.mockRejectedValue( + new AppError('COMMAND_FAILED', 'Hitches is not supported on this platform.', { + captureCleanedUp: true, + }), + ); + + const response = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'ios', + command: 'perf', + positionals: ['trace', 'stop', 'xctrace', '', '/tmp/hitches.trace'], + flags: {}, + }, + sessionName: 'ios', + sessionStore, + }); + + assert.equal(response?.ok, false); + assert.equal(sessionStore.get('ios')?.applePerf?.active, undefined); +}); + +test('perf xctrace stop keeps active capture when cleanup is not confirmed', async () => { + const sessionStore = makeSessionStore('agent-device-session-observability-perf-'); + const activeCapture = { + kind: 'xctrace', + mode: 'trace', + template: 'Animation Hitches', + outPath: '/tmp/hitches.trace', + appBundleId: 'com.example.app', + deviceId: 'ios-sim', + platform: 'ios', + targetPids: [111], + targetProcesses: ['Example'], + startedAt: '2026-04-01T10:00:00.000Z', + child: { kill: vi.fn(() => true), pid: 1234 }, + wait: new Promise(() => {}), + }; + sessionStore.set( + 'ios', + makeIosSession('ios', { + appBundleId: 'com.example.app', + applePerf: { + active: activeCapture as unknown as AppleXctracePerfCapture, + }, + }), + ); + applePerfMocks.stopAppleXctracePerfCapture.mockRejectedValue( + new AppError('COMMAND_FAILED', 'Timed out waiting for Apple xctrace capture to stop', { + captureCleanedUp: false, + }), + ); + + const response = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'ios', + command: 'perf', + positionals: ['trace', 'stop', 'xctrace', '', '/tmp/hitches.trace'], + flags: {}, + }, + sessionName: 'ios', + sessionStore, + }); + + assert.equal(response?.ok, false); + assert.equal(sessionStore.get('ios')?.applePerf?.active?.outPath, '/tmp/hitches.trace'); +}); + +test('perf cpu profile report rejects active xctrace captures', async () => { + const sessionStore = makeSessionStore('agent-device-session-observability-perf-'); + const activeCapture = { + kind: 'xctrace', + mode: 'cpu-profile', + template: 'Time Profiler', + outPath: '/tmp/app.trace', + appBundleId: 'com.example.app', + deviceId: 'ios-sim', + platform: 'ios', + targetPids: [111], + targetProcesses: ['Example'], + startedAt: '2026-04-01T10:00:00.000Z', + child: { kill: vi.fn(() => true), pid: 1234 }, + wait: new Promise(() => {}), + }; + sessionStore.set( + 'ios', + makeIosSession('ios', { + appBundleId: 'com.example.app', + applePerf: { + active: activeCapture as unknown as AppleXctracePerfCapture, + lastProfileTracePath: '/tmp/previous.trace', + }, + }), + ); + + const response = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'ios', + command: 'perf', + positionals: ['cpu', 'profile', 'report', 'xctrace', '', '/tmp/app-profile.json'], + flags: {}, + }, + sessionName: 'ios', + sessionStore, + }); + + assert.equal(response?.ok, false); + assert.equal(applePerfMocks.writeAppleXctracePerfReport.mock.calls.length, 0); + if (response && !response.ok) { + assert.match(response.error.message, /stop the active capture first/i); + } +}); + +test('perf cpu profile report uses last profile trace and writes compact JSON report', async () => { + const sessionStore = makeSessionStore('agent-device-session-observability-perf-'); + sessionStore.set( + 'ios', + makeIosSession('ios', { + appBundleId: 'com.example.app', + applePerf: { + lastProfileTracePath: '/tmp/app.trace', + lastProfileTemplate: 'Time Profiler', + }, + }), + ); + applePerfMocks.writeAppleXctracePerfReport.mockResolvedValue({ + kind: 'xctrace', + mode: 'cpu-profile', + template: 'Time Profiler', + tracePath: '/tmp/app.trace', + reportPath: '/tmp/app-profile.json', + appBundleId: 'com.example.app', + generatedAt: '2026-04-01T10:00:05.000Z', + summary: { + runCount: 1, + tableSchemas: ['time-profile'], + }, + }); + + const response = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'ios', + command: 'perf', + positionals: ['cpu', 'profile', 'report', 'xctrace', '', '/tmp/app-profile.json'], + flags: {}, + }, + sessionName: 'ios', + sessionStore, + }); + + assert.equal(response?.ok, true); + assert.equal(response?.data?.perf, 'reported'); + assert.deepEqual(response?.data?.summary, { + runCount: 1, + tableSchemas: ['time-profile'], + }); + assert.equal( + applePerfMocks.writeAppleXctracePerfReport.mock.calls[0]?.[0].tracePath, + '/tmp/app.trace', + ); +}); + +test('perf native xctrace reports Android support as out of scope', async () => { + const sessionStore = makeSessionStore('agent-device-session-observability-perf-'); + sessionStore.set( + 'android', + makeAndroidSession('android', { + appBundleId: 'com.example.app', + }), + ); + + const response = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'android', + command: 'perf', + positionals: ['cpu', 'profile', 'start', 'xctrace', 'Time Profiler', '/tmp/app.trace'], + flags: {}, + }, + sessionName: 'android', + sessionStore, + }); + + assert.equal(response?.ok, false); + if (response && !response.ok) { + assert.equal(response.error.code, 'UNSUPPORTED_OPERATION'); + assert.match( + response.error.message, + /Android native profiling belongs to the Android perf rollout/i, + ); + } + assert.equal(applePerfMocks.startAppleXctracePerfCapture.mock.calls.length, 0); +}); + test('network dump accepts explicit include flag and rejects conflicting values', async () => { const sessionStore = makeSessionStore('agent-device-session-observability-'); sessionStore.set('android', { diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index 41ba491e1..ac3bfba9d 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -7,6 +7,7 @@ import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { stopAppLog } from '../app-log.ts'; import { stopIosRunnerSession } from '../../platforms/ios/runner-client.ts'; +import { cleanupAppleXctracePerfCapture } from '../../platforms/ios/perf-xctrace.ts'; import { clearRuntimeHintsFromApp, hasRuntimeTransportHints } from '../runtime-hints.ts'; import { cleanupRetainedMaterializedPathsForSession } from '../materialized-path-registry.ts'; import { @@ -65,6 +66,12 @@ function shouldStopAppleRunnerBeforeTargetedClose(session: SessionState): boolea return isApplePlatform(session.device.platform) && !isIosSimulator(session.device); } +async function stopSessionApplePerfCapture(session: SessionState): Promise { + if (!session.applePerf?.active) return; + await cleanupAppleXctracePerfCapture(session.applePerf.active); + session.applePerf = { ...(session.applePerf ?? {}), active: undefined }; +} + export async function teardownSessionResources( session: SessionState, sessionName: string, @@ -72,6 +79,7 @@ export async function teardownSessionResources( if (session.appLog) { await stopAppLog(session.appLog); } + await stopSessionApplePerfCapture(session); if (isApplePlatform(session.device.platform)) { await stopAppleRunnerForClose(session); } @@ -92,6 +100,7 @@ export async function handleCloseCommand(params: { if (session.appLog) { await stopAppLog(session.appLog); } + await stopSessionApplePerfCapture(session); if (req.positionals && req.positionals.length > 0) { if (shouldStopAppleRunnerBeforeTargetedClose(session)) { await stopAppleRunnerForClose(session); diff --git a/src/daemon/handlers/session-observability.ts b/src/daemon/handlers/session-observability.ts index 8caebc1ca..d4a27dcc5 100644 --- a/src/daemon/handlers/session-observability.ts +++ b/src/daemon/handlers/session-observability.ts @@ -32,6 +32,7 @@ import { buildPerfResponseData, } from './session-perf.ts'; import { errorResponse, type DaemonFailureResponse } from './response.ts'; +import { handleNativePerfCommand } from './session-perf-xctrace.ts'; import { NETWORK_INCLUDE_MODES, type NetworkIncludeMode } from '../../contracts.ts'; import type { LogBackend } from '../network-log.ts'; import { @@ -107,6 +108,9 @@ async function handlePerfCommand(params: ObservabilityParams): Promise, ): Promise { const { sessionName, sessionStore, androidAdbExecutor } = params; if (request.area === 'memory') { @@ -197,30 +210,42 @@ function readPerfKind(value: unknown): PerfKind | undefined | AppError { } function validatePerfAreaAction( - area: PerfArea, + area: Exclude, action: PerfAction, ): DaemonFailureResponse | undefined { - if (action !== 'snapshot' || area === 'memory') return undefined; - return errorResponse('INVALID_ARGS', 'perf snapshot is only supported under perf memory'); + if (action === 'sample') return undefined; + if (area === 'memory' && action === 'snapshot') return undefined; + return errorResponse( + 'INVALID_ARGS', + area === 'memory' + ? 'perf memory only supports snapshot' + : 'perf metrics and perf frames only support sample', + ); } function validatePerfFlags( req: DaemonRequest, - area: PerfArea, + area: Exclude, action: PerfAction, kind: PerfKind | undefined, ): DaemonFailureResponse | undefined { - return validatePerfOutFlag(req.flags?.out, action) ?? validatePerfKindFlag(kind, area, action); + return ( + validatePerfOutFlag(req.flags?.out, area, action) ?? validatePerfKindFlag(kind, area, action) + ); } -function validatePerfOutFlag(out: unknown, action: PerfAction): DaemonFailureResponse | undefined { - if (action !== 'sample' || !out) return undefined; +function validatePerfOutFlag( + out: unknown, + area: Exclude, + action: PerfAction, +): DaemonFailureResponse | undefined { + if (!out || (area === 'memory' && action === 'snapshot')) return undefined; return errorResponse('INVALID_ARGS', '--out is only supported with perf memory snapshot'); } function validatePerfKindFlag( kind: PerfKind | undefined, - area: PerfArea, + area: Exclude, action: PerfAction, ): DaemonFailureResponse | undefined { if (!kind) return undefined; diff --git a/src/daemon/handlers/session-perf-xctrace.ts b/src/daemon/handlers/session-perf-xctrace.ts new file mode 100644 index 000000000..077a85147 --- /dev/null +++ b/src/daemon/handlers/session-perf-xctrace.ts @@ -0,0 +1,324 @@ +import { asAppError, normalizeError } from '../../utils/errors.ts'; +import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; +import { SessionStore } from '../session-store.ts'; +import { + startAppleXctracePerfCapture, + stopAppleXctracePerfCapture, + writeAppleXctracePerfReport, + type AppleXctracePerfMode, + type AppleXctracePerfResult, +} from '../../platforms/ios/perf-xctrace.ts'; +import { PERF_AREA_ERROR_MESSAGE } from '../../contracts/perf.ts'; +import { errorResponse, type DaemonFailureResponse } from './response.ts'; + +type NativePerfParams = { + req: DaemonRequest; + sessionName: string; + sessionStore: SessionStore; +}; + +type NativePerfRequest = { + area: 'cpu' | 'trace'; + mode: AppleXctracePerfMode; + action: 'start' | 'stop' | 'report'; + kind: 'xctrace'; + template?: string; + outPath?: string; + tracePath?: string; +}; + +export async function handleNativePerfCommand( + params: NativePerfParams, + session: SessionState, +): Promise { + const parsed = resolveNativePerfRequest(params.req); + if (!parsed.ok) return parsed; + if (session.device.platform === 'android') { + return errorResponse( + 'UNSUPPORTED_OPERATION', + 'Android native profiling belongs to the Android perf rollout; Apple xctrace perf supports iOS and macOS sessions only.', + ); + } + if (session.device.platform !== 'ios' && session.device.platform !== 'macos') { + return errorResponse( + 'UNSUPPORTED_OPERATION', + `Apple xctrace perf is not supported on ${session.device.platform}.`, + ); + } + if (!session.appBundleId) { + return errorResponse( + 'INVALID_ARGS', + 'Apple xctrace perf requires an active app session. Run open first.', + ); + } + + try { + if (parsed.action === 'start') { + return await handleNativePerfStart(params, session, parsed); + } + if (parsed.action === 'stop') { + return await handleNativePerfStop(params, session, parsed); + } + return await handleNativePerfReport(params, session, parsed); + } catch (error) { + return { ok: false, error: normalizeError(error) }; + } +} + +function resolveNativePerfRequest( + req: DaemonRequest, +): ({ ok: true } & NativePerfRequest) | DaemonFailureResponse { + const positionals = req.positionals ?? []; + const area = positionals[0]?.toLowerCase(); + if (area === 'cpu') return resolveNativeCpuPerfRequest(positionals); + if (area === 'trace') return resolveNativeTracePerfRequest(positionals); + return errorResponse('INVALID_ARGS', PERF_AREA_ERROR_MESSAGE); +} + +function resolveNativeCpuPerfRequest( + positionals: string[], +): ({ ok: true } & NativePerfRequest) | DaemonFailureResponse { + if (positionals[1]?.toLowerCase() !== 'profile') { + return errorResponse('INVALID_ARGS', 'perf cpu requires profile'); + } + const action = readNativePerfAction(positionals[2], 'perf cpu profile', true); + if (!action.ok) return action; + const kind = readXctraceKind(positionals[3]); + if (!kind.ok) return kind; + return { + ok: true, + area: 'cpu', + mode: 'cpu-profile', + action: action.value, + kind: 'xctrace', + template: positionals[4] || undefined, + outPath: positionals[5] || undefined, + tracePath: positionals[6] || undefined, + }; +} + +function resolveNativeTracePerfRequest( + positionals: string[], +): ({ ok: true } & NativePerfRequest) | DaemonFailureResponse { + const action = readNativePerfAction(positionals[1], 'perf trace', false); + if (!action.ok) return action; + const kind = readXctraceKind(positionals[2]); + if (!kind.ok) return kind; + return { + ok: true, + area: 'trace', + mode: 'trace', + action: action.value, + kind: 'xctrace', + template: positionals[3] || undefined, + outPath: positionals[4] || undefined, + tracePath: positionals[5] || undefined, + }; +} + +function readNativePerfAction( + value: string | undefined, + label: string, + allowReport: boolean, +): { ok: true; value: NativePerfRequest['action'] } | DaemonFailureResponse { + const action = value?.toLowerCase(); + if (action === 'start' || action === 'stop' || (allowReport && action === 'report')) { + return { ok: true, value: action }; + } + return errorResponse( + 'INVALID_ARGS', + allowReport ? `${label} requires start, stop, or report` : `${label} requires start or stop`, + ); +} + +function readXctraceKind(value: string | undefined): { ok: true } | DaemonFailureResponse { + return value?.toLowerCase() === 'xctrace' + ? { ok: true } + : errorResponse('INVALID_ARGS', 'perf native collection currently supports --kind xctrace'); +} + +async function handleNativePerfStart( + params: NativePerfParams, + session: SessionState, + request: NativePerfRequest, +): Promise { + if (session.applePerf?.active) { + return errorResponse('INVALID_ARGS', 'Apple xctrace perf capture already in progress'); + } + const template = request.template ?? defaultAppleXctraceTemplate(request.mode); + const outPath = resolveNativePerfOutPath(params, request); + const capture = await startAppleXctracePerfCapture({ + device: session.device, + appBundleId: session.appBundleId as string, + mode: request.mode, + template, + outPath, + }); + session.applePerf = { ...(session.applePerf ?? {}), active: capture }; + params.sessionStore.set(params.sessionName, session); + const data = compactNativePerfResult('started', capture); + recordNativePerfAction(params, session, data); + return { ok: true, data }; +} + +async function handleNativePerfStop( + params: NativePerfParams, + session: SessionState, + request: NativePerfRequest, +): Promise { + const capture = session.applePerf?.active; + if (!capture) { + return errorResponse('INVALID_ARGS', 'no active Apple xctrace perf capture'); + } + const outPath = request.outPath + ? SessionStore.expandHome(request.outPath, params.req.meta?.cwd) + : capture.outPath; + let result: AppleXctracePerfResult; + try { + result = await stopAppleXctracePerfCapture(capture, outPath); + } catch (error) { + if (didCleanupNativePerfCapture(error)) { + clearNativePerfCapture(params, session); + } + throw error; + } + storeStoppedNativePerfCapture(params, session, result); + const data = compactNativePerfResult('stopped', result); + recordNativePerfAction(params, session, data); + return { ok: true, data }; +} + +function clearNativePerfCapture(params: NativePerfParams, session: SessionState): void { + session.applePerf = { + ...(session.applePerf ?? {}), + active: undefined, + }; + params.sessionStore.set(params.sessionName, session); +} + +function didCleanupNativePerfCapture(error: unknown): boolean { + return asAppError(error).details?.captureCleanedUp === true; +} + +function storeStoppedNativePerfCapture( + params: NativePerfParams, + session: SessionState, + result: AppleXctracePerfResult, +): void { + session.applePerf = { + ...(session.applePerf ?? {}), + active: undefined, + lastMode: result.mode, + ...lastNativePerfArtifactState(result), + }; + params.sessionStore.set(params.sessionName, session); +} + +function lastNativePerfArtifactState(result: AppleXctracePerfResult): Record { + return result.mode === 'cpu-profile' + ? { lastProfileTracePath: result.outPath, lastProfileTemplate: result.template } + : { lastTracePath: result.outPath }; +} + +async function handleNativePerfReport( + params: NativePerfParams, + session: SessionState, + request: NativePerfRequest, +): Promise { + if (request.mode !== 'cpu-profile') { + return errorResponse('INVALID_ARGS', 'perf trace does not support report'); + } + if (session.applePerf?.active) { + return errorResponse( + 'INVALID_ARGS', + 'perf cpu profile report requires a stopped profile trace; stop the active capture first.', + ); + } + const outPath = resolveNativePerfOutPath(params, request); + const tracePath = resolveNativePerfReportTracePath(session, request); + if (!tracePath.ok) { + return tracePath; + } + const report = await writeAppleXctracePerfReport({ + tracePath: SessionStore.expandHome(tracePath.value, params.req.meta?.cwd), + outPath, + mode: request.mode, + template: session.applePerf?.lastProfileTemplate ?? request.template, + appBundleId: session.appBundleId, + }); + const data = { perf: 'reported', ...report }; + recordNativePerfAction(params, session, data); + return { ok: true, data }; +} + +function resolveNativePerfReportTracePath( + session: SessionState, + request: NativePerfRequest, +): { ok: true; value: string } | DaemonFailureResponse { + const tracePath = + request.tracePath ?? + session.applePerf?.lastProfileTracePath ?? + session.applePerf?.active?.outPath; + if (tracePath) return { ok: true, value: tracePath }; + return errorResponse( + 'INVALID_ARGS', + 'perf cpu profile report requires a stopped profile trace or tracePath option', + ); +} + +function recordNativePerfAction( + params: NativePerfParams, + session: SessionState, + data: Record, +): void { + params.sessionStore.recordAction(session, { + command: 'perf', + positionals: params.req.positionals ?? [], + flags: params.req.flags ?? {}, + result: data, + }); +} + +function resolveNativePerfOutPath(params: NativePerfParams, request: NativePerfRequest): string { + if (request.outPath) return SessionStore.expandHome(request.outPath, params.req.meta?.cwd); + const sessionDir = params.sessionStore.ensureSessionDir(params.sessionName); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const extension = request.action === 'report' ? 'json' : 'trace'; + return `${sessionDir}/perf-${request.mode}-${timestamp}.${extension}`; +} + +function defaultAppleXctraceTemplate(mode: AppleXctracePerfMode): string { + return mode === 'cpu-profile' ? 'Time Profiler' : 'Animation Hitches'; +} + +function compactNativePerfResult( + state: 'started' | 'stopped', + result: { + kind: 'xctrace'; + mode: AppleXctracePerfMode; + template: string; + outPath: string; + appBundleId: string; + deviceId: string; + platform: string; + targetPids: number[]; + targetProcesses: string[]; + startedAt: string; + endedAt?: string; + }, +): Record { + return { + perf: state, + kind: result.kind, + mode: result.mode, + template: result.template, + outPath: result.outPath, + appBundleId: result.appBundleId, + deviceId: result.deviceId, + platform: result.platform, + targetPids: result.targetPids, + targetProcesses: result.targetProcesses, + startedAt: result.startedAt, + endedAt: result.endedAt, + }; +} diff --git a/src/daemon/types.ts b/src/daemon/types.ts index d880771cd..cd7daf84d 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -18,6 +18,10 @@ import type { DeviceInfo, Platform, PlatformSelector } from '../utils/device.ts' import type { ExecBackgroundResult, ExecResult } from '../utils/exec.ts'; import type { SnapshotState } from '../utils/snapshot.ts'; import type { AppLogState } from './app-log-process.ts'; +import type { + AppleXctracePerfCapture, + AppleXctracePerfMode, +} from '../platforms/ios/perf-xctrace.ts'; export type DaemonInstallSource = PublicDaemonInstallSource; export type SessionRuntimeHints = PublicSessionRuntimeHints; @@ -230,6 +234,13 @@ export type SessionState = { outPath: string; startedAt: number; }; + applePerf?: { + active?: AppleXctracePerfCapture; + lastProfileTracePath?: string; + lastProfileTemplate?: string; + lastTracePath?: string; + lastMode?: AppleXctracePerfMode; + }; /** Session was created by record start and should be released when recording stops. */ recordOnlySession?: boolean; recordSession?: boolean; diff --git a/src/platforms/ios/__tests__/perf.test.ts b/src/platforms/ios/__tests__/perf.test.ts index a79f5704e..2355412e1 100644 --- a/src/platforms/ios/__tests__/perf.test.ts +++ b/src/platforms/ios/__tests__/perf.test.ts @@ -6,7 +6,11 @@ import path from 'node:path'; vi.mock('../../../utils/exec.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, runCmd: vi.fn(actual.runCmd) }; + return { + ...actual, + runCmd: vi.fn(actual.runCmd), + runCmdBackground: vi.fn(actual.runCmdBackground), + }; }); import { @@ -15,12 +19,19 @@ import { sampleAppleFramePerf, sampleApplePerfMetrics, } from '../perf.ts'; +import { + startAppleXctracePerfCapture, + stopAppleXctracePerfCapture, + writeAppleXctracePerfReport, + type AppleXctracePerfCapture, +} from '../perf-xctrace.ts'; import { parseAppleFramePerfSample } from '../perf-frame.ts'; -import { runCmd } from '../../../utils/exec.ts'; +import { runCmd, runCmdBackground } from '../../../utils/exec.ts'; import type { DeviceInfo } from '../../../utils/device.ts'; import { AppError } from '../../../utils/errors.ts'; const mockRunCmd = vi.mocked(runCmd); +const mockRunCmdBackground = vi.mocked(runCmdBackground); type MockRunCmdResult = Awaited>; type XcrunMockHandler = (args: string[]) => Promise; @@ -51,6 +62,7 @@ const IOS_DEVICE: DeviceInfo = { beforeEach(() => { vi.resetAllMocks(); + mockRunCmdBackground.mockImplementation(() => mockBackgroundXctrace()); vi.useRealTimers(); }); @@ -489,6 +501,54 @@ test('captureAppleMemorySnapshot reports iOS simulator without process tools as } }); +test('sampleApplePerfMetrics falls back to host ps when simulator ps is unavailable', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-sim-perf-')); + const appPath = path.join(tmpDir, 'Example.app'); + await fs.mkdir(appPath, { recursive: true }); + await fs.writeFile( + path.join(appPath, 'Info.plist'), + [ + '', + '', + 'CFBundleExecutableExample Sim Exec', + '', + ].join(''), + 'utf8', + ); + + mockRunCmd.mockImplementation(async (cmd, args) => { + if (cmd === 'xcrun' && args.includes('get_app_container')) { + return { stdout: `${appPath}\n`, stderr: '', exitCode: 0 }; + } + if (cmd === 'plutil') { + return { stdout: '', stderr: 'mock fallback', exitCode: 1 }; + } + if (cmd === 'xcrun' && args.includes('spawn') && args.includes('ps')) { + return { stdout: '', stderr: 'No such file or directory', exitCode: 2 }; + } + if (cmd === 'ps') { + return { + stdout: [ + `111 12.0 8192 ${path.join(appPath, 'Example Sim Exec')}`, + '222 4.0 1024 SpringBoard', + ].join('\n'), + stderr: '', + exitCode: 0, + }; + } + throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`); + }); + + try { + const metrics = await sampleApplePerfMetrics(IOS_SIMULATOR, 'com.example.sim'); + assert.equal(metrics.cpu.usagePercent, 12); + assert.equal(metrics.memory.residentMemoryKb, 8192); + assert.deepEqual(metrics.cpu.matchedProcesses, ['Example Sim Exec']); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + test('sampleApplePerfMetrics uses xctrace Activity Monitor for iOS devices', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-04-01T10:00:00.000Z')); @@ -558,6 +618,253 @@ test('sampleAppleFramePerf retries transient kperf lock failures', async () => { assert.ok(sample.sampleWindowMs < 1000); }, 10_000); +test('startAppleXctracePerfCapture attaches to an active iOS simulator app process', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-xctrace-sim-')); + const appPath = path.join(tmpDir, 'Example.app'); + const tracePath = path.join(tmpDir, 'app.trace'); + await fs.mkdir(appPath, { recursive: true }); + await fs.writeFile( + path.join(appPath, 'Info.plist'), + [ + '', + '', + 'CFBundleExecutableExample Sim Exec', + '', + ].join(''), + 'utf8', + ); + + mockRunCmd.mockImplementation(async (cmd, args) => { + if (cmd === 'xcrun' && args.includes('get_app_container')) { + return { stdout: `${appPath}\n`, stderr: '', exitCode: 0 }; + } + if (cmd === 'plutil') { + return { stdout: '', stderr: 'mock fallback', exitCode: 1 }; + } + if (cmd === 'xcrun' && args.includes('spawn') && args.includes('ps')) { + return { + stdout: [ + `111 12.0 8192 ${path.join(appPath, 'Example Sim Exec')}`, + '222 4.0 1024 SpringBoard', + ].join('\n'), + stderr: '', + exitCode: 0, + }; + } + throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`); + }); + + try { + const capture = await startAppleXctracePerfCapture({ + device: IOS_SIMULATOR, + appBundleId: 'com.example.sim', + mode: 'cpu-profile', + template: 'Time Profiler', + outPath: tracePath, + }); + + assert.equal(capture.outPath, tracePath); + assert.deepEqual(capture.targetPids, [111]); + assert.deepEqual(capture.targetProcesses, ['Example Sim Exec']); + assert.deepEqual(mockRunCmdBackground.mock.calls[0]?.[1], [ + 'xctrace', + 'record', + '--template', + 'Time Profiler', + '--device', + 'sim-1', + '--attach', + '111', + '--output', + tracePath, + '--quiet', + '--no-prompt', + ]); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + +test('startAppleXctracePerfCapture retries transient kperf lock failures', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-xctrace-retry-')); + const tracePath = path.join(tmpDir, 'app.trace'); + mockXcrunCommands([mockIosDeviceApps, mockIosDeviceProcesses]); + mockRunCmdBackground + .mockImplementationOnce(() => + mockBackgroundXctrace({ + stdout: '', + stderr: '_lockKPerf: could not lock kperf. Likely another session just started.', + exitCode: 2, + }), + ) + .mockImplementationOnce(() => mockBackgroundXctrace()); + + try { + const capture = await startAppleXctracePerfCapture({ + device: IOS_DEVICE, + appBundleId: 'com.example.device', + mode: 'trace', + template: 'Animation Hitches', + outPath: tracePath, + }); + + assert.equal(capture.mode, 'trace'); + assert.equal(mockRunCmdBackground.mock.calls.length, 2); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}, 10_000); + +test('stopAppleXctracePerfCapture returns compact artifact metadata', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-xctrace-stop-')); + const tracePath = path.join(tmpDir, 'app.trace'); + const child = { kill: vi.fn((_signal?: NodeJS.Signals) => true), pid: 1234 }; + await fs.writeFile(tracePath, 'trace', 'utf8'); + const capture: AppleXctracePerfCapture = { + kind: 'xctrace', + mode: 'cpu-profile', + template: 'Time Profiler', + outPath: tracePath, + appBundleId: 'com.example.app', + deviceId: 'sim-1', + platform: 'ios', + targetPids: [111], + targetProcesses: ['Example'], + startedAt: '2026-04-01T10:00:00.000Z', + child: child as unknown as AppleXctracePerfCapture['child'], + wait: Promise.resolve(emptyRunResult()), + }; + + try { + const result = await stopAppleXctracePerfCapture(capture); + assert.equal(child.kill.mock.calls[0]?.[0], 'SIGINT'); + assert.equal(result.outPath, tracePath); + assert.deepEqual(result.targetPids, [111]); + assert.equal(result.template, 'Time Profiler'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + +test('stopAppleXctracePerfCapture force-kills xctrace when graceful stop times out', async () => { + vi.useFakeTimers(); + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-xctrace-stop-timeout-')); + const tracePath = path.join(tmpDir, 'app.trace'); + const child = { kill: vi.fn((_signal?: NodeJS.Signals) => true), pid: 1234 }; + const capture: AppleXctracePerfCapture = { + kind: 'xctrace', + mode: 'cpu-profile', + template: 'Time Profiler', + outPath: tracePath, + appBundleId: 'com.example.app', + deviceId: 'sim-1', + platform: 'ios', + targetPids: [111], + targetProcesses: ['Example'], + startedAt: '2026-04-01T10:00:00.000Z', + child: child as unknown as AppleXctracePerfCapture['child'], + wait: new Promise(() => {}), + }; + + try { + const stopPromise = stopAppleXctracePerfCapture(capture).then( + () => undefined, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(45_000); + assert.deepEqual( + child.kill.mock.calls.map((call) => call[0]), + ['SIGINT', 'SIGKILL'], + ); + await vi.advanceTimersByTimeAsync(5_000); + const error = await stopPromise; + assert.match((error as Error).message, /after SIGKILL/); + } finally { + vi.useRealTimers(); + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + +test('stopAppleXctracePerfCapture reports confirmed cleanup after forced kill exits', async () => { + vi.useFakeTimers(); + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-xctrace-force-exit-')); + const tracePath = path.join(tmpDir, 'app.trace'); + let resolveWait!: (result: MockRunCmdResult) => void; + const wait = new Promise((resolve) => { + resolveWait = resolve; + }); + const child = { + kill: vi.fn((signal?: NodeJS.Signals) => { + if (signal === 'SIGKILL') { + resolveWait({ stdout: '', stderr: 'killed', exitCode: 1 }); + } + return true; + }), + pid: 1234, + }; + const capture: AppleXctracePerfCapture = { + kind: 'xctrace', + mode: 'trace', + template: 'Animation Hitches', + outPath: tracePath, + appBundleId: 'com.example.app', + deviceId: 'sim-1', + platform: 'ios', + targetPids: [111], + targetProcesses: ['Example'], + startedAt: '2026-04-01T10:00:00.000Z', + child: child as unknown as AppleXctracePerfCapture['child'], + wait, + }; + + try { + const stopPromise = stopAppleXctracePerfCapture(capture).then( + () => undefined, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(45_000); + const error = (await stopPromise) as { details?: Record }; + assert.equal(error.details?.captureCleanedUp, true); + assert.equal(error.details?.forcedKill, true); + } finally { + vi.useRealTimers(); + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + +test('writeAppleXctracePerfReport writes compact trace metadata JSON', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-xctrace-report-')); + const tracePath = path.join(tmpDir, 'app.trace'); + const reportPath = path.join(tmpDir, 'app-profile.json'); + await fs.writeFile(tracePath, 'trace', 'utf8'); + mockXcrunCommands([ + async (args) => { + if (args[0] !== 'xctrace' || args[1] !== 'export') return null; + assert.equal(args[args.indexOf('--input') + 1], tracePath); + assert.equal(args[args.indexOf('--xpath') + 1], '/trace-toc'); + await fs.writeFile(readOutputPath(args), makeTraceTocXml(), 'utf8'); + return emptyRunResult(); + }, + ]); + + try { + const report = await writeAppleXctracePerfReport({ + tracePath, + outPath: reportPath, + mode: 'cpu-profile', + template: 'Time Profiler', + appBundleId: 'com.example.app', + }); + assert.equal(report.reportPath, reportPath); + assert.deepEqual(report.summary.tableSchemas, ['cpu-profile', 'time-profile']); + const written = JSON.parse(await fs.readFile(reportPath, 'utf8')) as typeof report; + assert.equal(written.tracePath, tracePath); + assert.deepEqual(written.summary.tableSchemas, ['cpu-profile', 'time-profile']); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + function mockXcrunCommands(handlers: XcrunMockHandler[]): void { mockRunCmd.mockImplementation(async (cmd, args) => { if (cmd !== 'xcrun') throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`); @@ -710,6 +1017,32 @@ async function fileExists(filePath: string): Promise { .catch(() => false); } +function mockBackgroundXctrace(result?: MockRunCmdResult): ReturnType { + const child = { + kill: vi.fn((_signal?: NodeJS.Signals) => true), + pid: 1234, + }; + return { + child: child as unknown as ReturnType['child'], + wait: result ? Promise.resolve(result) : new Promise(() => {}), + }; +} + +function makeTraceTocXml(): string { + return [ + '', + '', + '', + '', + '', + '
', + '
', + '', + '', + '', + ].join(''); +} + function makeActivityMonitorCaptureXmls(): string[] { const firstCaptureXml = makeActivityMonitorCaptureXml(); const secondCaptureXml = firstCaptureXml diff --git a/src/platforms/ios/perf-xctrace.ts b/src/platforms/ios/perf-xctrace.ts new file mode 100644 index 000000000..e1dc34f3c --- /dev/null +++ b/src/platforms/ios/perf-xctrace.ts @@ -0,0 +1,403 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { DeviceInfo } from '../../utils/device.ts'; +import { AppError } from '../../utils/errors.ts'; +import { runCmdBackground, type ExecBackgroundResult, type ExecResult } from '../../utils/exec.ts'; +import { uniqueStrings } from '../../daemon/action-utils.ts'; +import { findAllXmlNodes } from './perf-xml.ts'; +import { + isRetryableIosDeviceTraceRecordFailure, + prepareAppleTraceRecordRetry, + readAppleProcessSamples, + resolveAppleExecutable, + resolveIosDevicePerfHint, + resolveIosDevicePerfTarget, +} from './perf.ts'; +import { runXcrun } from './tool-provider.ts'; +import { parseXmlDocumentSync } from './xml.ts'; + +const IOS_DEVICE_PERF_EXPORT_TIMEOUT_MS = 15_000; +const IOS_DEVICE_TRACE_RECORD_MAX_ATTEMPTS = 3; +const IOS_DEVICE_TRACE_RECORD_RETRY_DELAY_MS = 1_500; +const APPLE_XCTRACE_START_SETTLE_MS = 500; +const APPLE_XCTRACE_STOP_GRACE_TIMEOUT_MS = 45_000; +const APPLE_XCTRACE_STOP_FORCE_TIMEOUT_MS = 5_000; + +export type AppleXctracePerfMode = 'cpu-profile' | 'trace'; + +export type AppleXctracePerfCapture = { + kind: 'xctrace'; + mode: AppleXctracePerfMode; + template: string; + outPath: string; + appBundleId: string; + deviceId: string; + platform: DeviceInfo['platform']; + targetPids: number[]; + targetProcesses: string[]; + startedAt: string; + child: ExecBackgroundResult['child']; + wait: ExecBackgroundResult['wait']; +}; + +export type AppleXctracePerfResult = { + kind: 'xctrace'; + mode: AppleXctracePerfMode; + template: string; + outPath: string; + appBundleId: string; + deviceId: string; + platform: DeviceInfo['platform']; + targetPids: number[]; + targetProcesses: string[]; + startedAt: string; + endedAt: string; +}; + +export type AppleXctracePerfReport = { + kind: 'xctrace'; + mode: AppleXctracePerfMode; + template?: string; + tracePath: string; + reportPath: string; + appBundleId?: string; + generatedAt: string; + summary: { + runCount: number; + tableSchemas: string[]; + }; +}; + +export async function startAppleXctracePerfCapture(params: { + device: DeviceInfo; + appBundleId: string; + mode: AppleXctracePerfMode; + template: string; + outPath: string; +}): Promise { + const target = await resolveAppleXctracePerfTarget(params.device, params.appBundleId); + await fs.mkdir(path.dirname(params.outPath), { recursive: true }); + const args = buildAppleXctraceRecordArgs({ + device: params.device, + template: params.template, + targetPids: target.pids, + outPath: params.outPath, + }); + const startedAt = new Date().toISOString(); + const background = await startAppleXctraceRecordWithRetry(args, params.outPath, { + device: params.device, + appBundleId: params.appBundleId, + failureMessage: `Failed to start Apple xctrace ${params.mode} capture for ${params.appBundleId}`, + }); + return { + kind: 'xctrace', + mode: params.mode, + template: params.template, + outPath: params.outPath, + appBundleId: params.appBundleId, + deviceId: params.device.id, + platform: params.device.platform, + targetPids: target.pids, + targetProcesses: target.processNames, + startedAt, + child: background.child, + wait: background.wait, + }; +} + +export async function stopAppleXctracePerfCapture( + capture: AppleXctracePerfCapture, + outPath = capture.outPath, +): Promise { + if (outPath !== capture.outPath) { + await fs.mkdir(path.dirname(outPath), { recursive: true }); + } + const result = await stopAppleXctraceProcess(capture, { failOnForcedKill: true }); + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', `Failed to stop Apple xctrace ${capture.mode} capture`, { + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + tracePath: capture.outPath, + captureCleanedUp: true, + hint: resolveIosDevicePerfHint(result.stdout, result.stderr), + }); + } + if (outPath !== capture.outPath) { + await fs.rename(capture.outPath, outPath).catch(async () => { + await fs.cp(capture.outPath, outPath, { recursive: true }); + await fs.rm(capture.outPath, { recursive: true, force: true }); + }); + } + await assertTracePathHasData(outPath, { + appBundleId: capture.appBundleId, + deviceId: capture.deviceId, + stdout: result.stdout, + stderr: result.stderr, + }); + return { + kind: 'xctrace', + mode: capture.mode, + template: capture.template, + outPath, + appBundleId: capture.appBundleId, + deviceId: capture.deviceId, + platform: capture.platform, + targetPids: capture.targetPids, + targetProcesses: capture.targetProcesses, + startedAt: capture.startedAt, + endedAt: new Date().toISOString(), + }; +} + +export async function cleanupAppleXctracePerfCapture( + capture: AppleXctracePerfCapture, +): Promise { + return await stopAppleXctraceProcess(capture, { failOnForcedKill: false }); +} + +export async function writeAppleXctracePerfReport(params: { + tracePath: string; + outPath: string; + mode: AppleXctracePerfMode; + template?: string; + appBundleId?: string; +}): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-xctrace-report-')); + const tocPath = path.join(tempDir, 'trace-toc.xml'); + try { + const exportArgs = [ + 'xctrace', + 'export', + '--input', + params.tracePath, + '--xpath', + '/trace-toc', + '--output', + tocPath, + ]; + const exportResult = await runXcrun(exportArgs, { + allowFailure: true, + timeoutMs: IOS_DEVICE_PERF_EXPORT_TIMEOUT_MS, + }); + if (exportResult.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', 'Failed to export Apple xctrace report metadata', { + cmd: 'xcrun', + args: exportArgs, + exitCode: exportResult.exitCode, + stdout: exportResult.stdout, + stderr: exportResult.stderr, + tracePath: params.tracePath, + hint: resolveIosDevicePerfHint(exportResult.stdout, exportResult.stderr), + }); + } + const report = buildAppleXctracePerfReport({ + ...params, + tocXml: await fs.readFile(tocPath, 'utf8'), + }); + await fs.mkdir(path.dirname(params.outPath), { recursive: true }); + await fs.writeFile(params.outPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + return report; + } finally { + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } +} + +async function resolveAppleXctracePerfTarget( + device: DeviceInfo, + appBundleId: string, +): Promise<{ pids: number[]; processNames: string[] }> { + if (device.platform !== 'ios' && device.platform !== 'macos') { + throw new AppError('UNSUPPORTED_OPERATION', 'Apple xctrace perf is not supported on Android.', { + platform: device.platform, + hint: 'Android native profiling belongs to the Android perf rollout and is not implemented under Apple xctrace.', + }); + } + if (device.platform === 'ios' && device.kind === 'device') { + const processes = await resolveIosDevicePerfTarget(device, appBundleId); + return { + pids: processes.map((process) => process.pid), + processNames: uniqueStrings( + processes.map((process) => path.basename(fileURLToPath(process.executable))), + ), + }; + } + + const executable = await resolveAppleExecutable(device, appBundleId); + const processes = await readAppleProcessSamples(device, executable); + if (processes.length === 0) { + throw new AppError('COMMAND_FAILED', `No running process found for ${appBundleId}`, { + appBundleId, + deviceId: device.id, + hint: 'Run open for this session again to ensure the Apple app is active, then retry perf.', + }); + } + return { + pids: processes.map((process) => process.pid), + processNames: [executable.executableName], + }; +} + +function buildAppleXctraceRecordArgs(params: { + device: DeviceInfo; + template: string; + targetPids: number[]; + outPath: string; +}): string[] { + return [ + 'xctrace', + 'record', + '--template', + params.template, + ...(params.device.platform === 'ios' ? ['--device', params.device.id] : []), + ...params.targetPids.flatMap((pid) => ['--attach', String(pid)]), + '--output', + params.outPath, + '--quiet', + '--no-prompt', + ]; +} + +async function startAppleXctraceRecordWithRetry( + args: string[], + tracePath: string, + context: { + device: DeviceInfo; + appBundleId: string; + failureMessage: string; + }, +): Promise { + let lastImmediateFailure: ExecResult | undefined; + for (let attempt = 1; attempt <= IOS_DEVICE_TRACE_RECORD_MAX_ATTEMPTS; attempt += 1) { + await prepareAppleTraceRecordRetry(tracePath, attempt, IOS_DEVICE_TRACE_RECORD_RETRY_DELAY_MS); + const background = runCmdBackground('xcrun', args, { allowFailure: true }); + const immediate = await waitForImmediateAppleXctraceExit(background.wait); + if (!immediate) return background; + lastImmediateFailure = immediate; + if (!isRetryableIosDeviceTraceRecordFailure(immediate)) break; + } + + const failure = lastImmediateFailure ?? { stdout: '', stderr: '', exitCode: 1 }; + throw new AppError('COMMAND_FAILED', context.failureMessage, { + cmd: 'xcrun', + args, + exitCode: failure.exitCode, + stdout: failure.stdout, + stderr: failure.stderr, + appBundleId: context.appBundleId, + deviceId: context.device.id, + hint: resolveIosDevicePerfHint(failure.stdout, failure.stderr), + }); +} + +async function waitForImmediateAppleXctraceExit( + wait: Promise, +): Promise { + return await Promise.race([ + wait, + new Promise((resolve) => setTimeout(resolve, APPLE_XCTRACE_START_SETTLE_MS)), + ]); +} + +async function stopAppleXctraceProcess( + capture: AppleXctracePerfCapture, + options: { failOnForcedKill: boolean }, +): Promise { + capture.child.kill('SIGINT'); + const graceful = await waitForAppleXctraceExit(capture.wait, APPLE_XCTRACE_STOP_GRACE_TIMEOUT_MS); + if (graceful) return graceful; + + capture.child.kill('SIGKILL'); + const forced = await waitForAppleXctraceExit(capture.wait, APPLE_XCTRACE_STOP_FORCE_TIMEOUT_MS); + if (forced && !options.failOnForcedKill) return forced; + if (forced) { + throw new AppError('COMMAND_FAILED', 'Timed out waiting for Apple xctrace capture to stop', { + exitCode: forced.exitCode, + stdout: forced.stdout, + stderr: forced.stderr, + tracePath: capture.outPath, + captureCleanedUp: true, + forcedKill: true, + hint: 'xctrace did not finish after SIGINT, so it was force-killed. Retry the perf command after confirming no other xctrace session is active.', + }); + } + + throw new AppError( + 'COMMAND_FAILED', + 'Timed out waiting for Apple xctrace capture to stop after SIGKILL', + { + tracePath: capture.outPath, + captureCleanedUp: false, + forcedKill: true, + hint: 'xctrace did not exit after SIGKILL. Inspect running xctrace processes before retrying.', + }, + ); +} + +async function waitForAppleXctraceExit( + wait: Promise, + timeoutMs: number, +): Promise { + const result = await Promise.race([ + wait, + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]); + return result; +} + +async function assertTracePathHasData( + tracePath: string, + context: { + appBundleId?: string; + deviceId?: string; + stdout: string; + stderr: string; + }, +): Promise { + const stat = await fs.stat(tracePath).catch(() => null); + const hasTrace = + stat?.isDirectory() === true + ? (await fs.readdir(tracePath).catch(() => [])).length > 0 + : (stat?.size ?? 0) > 0; + if (hasTrace) return; + throw new AppError('COMMAND_FAILED', 'xctrace produced no trace data', { + tracePath, + appBundleId: context.appBundleId, + deviceId: context.deviceId, + stdout: context.stdout, + stderr: context.stderr, + hint: 'Keep the Apple device unlocked and connected, keep the app active, then retry perf.', + }); +} + +function buildAppleXctracePerfReport(params: { + tracePath: string; + outPath: string; + mode: AppleXctracePerfMode; + template?: string; + appBundleId?: string; + tocXml: string; +}): AppleXctracePerfReport { + const document = parseXmlDocumentSync(params.tocXml); + const runs = findAllXmlNodes(document, (node) => node.name === 'run'); + const tableSchemas = uniqueStrings( + findAllXmlNodes(document, (node) => node.name === 'table') + .map((node) => node.attributes.schema) + .filter((schema): schema is string => typeof schema === 'string' && schema.length > 0), + ).sort(); + return { + kind: 'xctrace', + mode: params.mode, + template: params.template, + tracePath: params.tracePath, + reportPath: params.outPath, + appBundleId: params.appBundleId, + generatedAt: new Date().toISOString(), + summary: { + runCount: runs.length, + tableSchemas, + }, + }; +} diff --git a/src/platforms/ios/perf.ts b/src/platforms/ios/perf.ts index e0f35e71d..d61005ebd 100644 --- a/src/platforms/ios/perf.ts +++ b/src/platforms/ios/perf.ts @@ -86,7 +86,7 @@ export type AppleMemorySnapshotResult = support: ReturnType; }; -type AppleProcessSample = { +export type AppleProcessSample = { pid: number; cpuPercent: number; rssKb: number; @@ -374,7 +374,7 @@ export function buildAppleSamplingMetadata(device: DeviceInfo): Record { let lastAttempt: IosDeviceTraceRecordAttempt | undefined; for (let attempt = 1; attempt <= IOS_DEVICE_TRACE_RECORD_MAX_ATTEMPTS; attempt += 1) { - if (attempt > 1) { - await fs.rm(tracePath, { recursive: true, force: true }).catch(() => {}); - await new Promise((resolve) => setTimeout(resolve, IOS_DEVICE_TRACE_RECORD_RETRY_DELAY_MS)); - } + await prepareAppleTraceRecordRetry(tracePath, attempt, IOS_DEVICE_TRACE_RECORD_RETRY_DELAY_MS); const startedAt = new Date().toISOString(); const result = await runXcrun(recordArgs, { allowFailure: true, @@ -568,7 +565,7 @@ async function runIosDeviceTraceRecord( return lastAttempt as IosDeviceTraceRecordAttempt; } -function isRetryableIosDeviceTraceRecordFailure(result: { +export function isRetryableIosDeviceTraceRecordFailure(result: { stdout: string; stderr: string; }): boolean { @@ -580,6 +577,16 @@ function isRetryableIosDeviceTraceRecordFailure(result: { ); } +export async function prepareAppleTraceRecordRetry( + tracePath: string, + attempt: number, + retryDelayMs: number, +): Promise { + if (attempt <= 1) return; + await fs.rm(tracePath, { recursive: true, force: true }).catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); +} + async function assertUsableTraceOutput( params: { device: DeviceInfo; @@ -744,7 +751,7 @@ async function parseIosDevicePerfTable(xml: string): Promise { @@ -829,7 +836,7 @@ async function sampleIosDevicePerfMetrics( }); } -async function resolveIosDevicePerfTarget( +export async function resolveIosDevicePerfTarget( device: DeviceInfo, appBundleId: string, ): Promise { @@ -1026,7 +1033,7 @@ async function resolveIosSimulatorAppContainer( return appPath; } -async function readAppleProcessSamples( +export async function readAppleProcessSamples( device: DeviceInfo, executable: { executableName: string; executablePath?: string }, ): Promise { @@ -1043,7 +1050,7 @@ async function readAppleProcessSamples( const result = device.platform === 'macos' ? await runAppleToolCommand('ps', args, { timeoutMs: APPLE_PERF_TIMEOUT_MS }) - : await runXcrun(args, { timeoutMs: APPLE_PERF_TIMEOUT_MS }); + : await runAppleSimulatorProcessCommand(args); return parseApplePsOutput(result.stdout).filter((process) => matchesAppleExecutableProcess(process.command, executable), ); @@ -1123,22 +1130,46 @@ function resolveAppleMemorySnapshotHint( return 'Keep the app process running and retry perf memory snapshot with --debug if the failure persists.'; } +async function runAppleSimulatorProcessCommand(args: string[]): Promise { + const result = await runXcrun(args, { + allowFailure: true, + timeoutMs: APPLE_PERF_TIMEOUT_MS, + }); + if (result.exitCode === 0) return result; + return await runAppleToolCommand('ps', ['-axo', 'pid=,%cpu=,rss=,command='], { + timeoutMs: APPLE_PERF_TIMEOUT_MS, + }); +} + function matchesAppleExecutableProcess( command: string, executable: { executableName: string; executablePath?: string }, ): boolean { const token = readProcessCommandToken(command); - if ( - executable.executablePath && - (command === executable.executablePath || - token === executable.executablePath || - command.startsWith(`${executable.executablePath} `)) - ) { - return true; + if (executable.executablePath) { + for (const executablePath of buildAppleExecutablePathAliases(executable.executablePath)) { + if ( + command === executablePath || + token === executablePath || + command.startsWith(`${executablePath} `) + ) { + return true; + } + } } return path.basename(token) === executable.executableName; } +function buildAppleExecutablePathAliases(executablePath: string): string[] { + const aliases = [executablePath]; + if (executablePath.startsWith('/private/var/')) { + aliases.push(executablePath.replace('/private/var/', '/var/')); + } else if (executablePath.startsWith('/var/')) { + aliases.push(executablePath.replace('/var/', '/private/var/')); + } + return aliases; +} + function readProcessCommandToken(command: string): string { const [token = ''] = command.trim().split(/\s+/, 1); return token; @@ -1185,7 +1216,7 @@ function resolveProcessName( return readDirectProcessNameFromXml(element); } -function resolveIosDevicePerfHint(stdout: string, stderr: string): string { +export function resolveIosDevicePerfHint(stdout: string, stderr: string): string { const devicectlHint = resolveIosDevicectlHint(stdout, stderr); if (devicectlHint) return devicectlHint; const text = `${stdout}\n${stderr}`.toLowerCase(); diff --git a/src/utils/__tests__/perf-args.test.ts b/src/utils/__tests__/perf-args.test.ts index defc39eb8..7253b5375 100644 --- a/src/utils/__tests__/perf-args.test.ts +++ b/src/utils/__tests__/perf-args.test.ts @@ -17,6 +17,15 @@ test('parseArgs accepts perf area subcommands', () => { assert.equal(memory.command, 'perf'); assert.deepEqual(memory.positionals, ['memory', 'snapshot']); assert.equal(memory.flags.kind, 'memgraph'); + + const profile = parseArgs( + ['perf', 'cpu', 'profile', 'start', '--kind', 'xctrace', '--out', 'app.trace'], + { strictFlags: true }, + ); + assert.equal(profile.command, 'perf'); + assert.deepEqual(profile.positionals, ['cpu', 'profile', 'start']); + assert.equal(profile.flags.kind, 'xctrace'); + assert.equal(profile.flags.out, 'app.trace'); }); test('usageForCommand advertises perf area subcommands for metrics alias', () => { @@ -24,4 +33,5 @@ test('usageForCommand advertises perf area subcommands for metrics alias', () => assert.equal(help === null, false); assert.match(help ?? '', /agent-device perf \[metrics\|frames\|memory\]/); assert.match(help ?? '', /perf memory snapshot/); + assert.match(help ?? '', /perf cpu profile start\|stop\|report/); }); diff --git a/src/utils/args.ts b/src/utils/args.ts index 25b03f6e8..863e5fdcc 100644 --- a/src/utils/args.ts +++ b/src/utils/args.ts @@ -67,7 +67,7 @@ export function parseRawArgs(argv: string[]): RawParsedArgs { } const [token, inlineValue] = isLongFlag ? splitLongFlag(arg) : [arg, undefined]; - const definition = getFlagDefinition(token); + const definition = resolveFlagDefinition(token); if (shouldPassThroughReactDevtoolsFlag(command, definition)) { positionals.push(arg); continue; @@ -109,6 +109,10 @@ function shouldPassThroughReactDevtoolsFlag( return !isFlagSupportedForCommand(definition.key, command); } +function resolveFlagDefinition(token: string): FlagDefinition | undefined { + return getFlagDefinition(token); +} + export function finalizeParsedArgs( parsed: RawParsedArgs, options?: FinalizeArgsOptions, diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index 1aff39315..57ca78d5c 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -151,13 +151,14 @@ const CLI_COMMAND_OVERRIDES = { }, perf: { usageOverride: - 'perf [metrics|frames|memory] [sample|snapshot]\n agent-device perf memory sample --json\n agent-device perf memory snapshot [--kind android-hprof|memgraph] [--out ]', - listUsageOverride: 'perf [metrics|frames|memory]', + 'perf [metrics|frames|memory] [sample|snapshot]\n agent-device perf memory sample --json\n agent-device perf memory snapshot [--kind android-hprof|memgraph] [--out ]\n agent-device perf cpu profile start|stop|report --kind xctrace [--template ] --out \n agent-device perf trace start|stop --kind xctrace [--template ] --out ', + listUsageOverride: + 'perf [metrics|frames|memory] | perf cpu profile start|stop|report | perf trace start|stop', helpDescription: - 'Show session performance metrics, focused frame/jank health, or memory diagnostics artifacts. Bare perf and metrics are aliases for perf metrics.', - summary: 'Show session performance, frame health, and memory diagnostics', - positionalArgs: ['area?', 'action?'], - allowedFlags: ['kind', 'out'], + 'Show session performance metrics, focused frame/jank health, memory diagnostics artifacts, or Apple xctrace artifacts. Bare perf and metrics are aliases for perf metrics.', + summary: 'Show performance metrics or collect native perf artifacts', + positionalArgs: ['area?', 'subjectOrAction?', 'action?'], + allowedFlags: ['kind', 'perfTemplate', 'out'], }, metro: { usageOverride: diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index a6fecd644..21c978c27 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -40,6 +40,7 @@ export type CliFlags = RemoteConfigMetroOptions & force?: boolean; noLogin?: boolean; kind?: string; + perfTemplate?: string; sessionLock?: 'reject' | 'strip'; sessionLocked?: boolean; sessionLockConflicts?: 'reject' | 'strip'; @@ -396,6 +397,13 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageDescription: 'Kind selector for commands that support it, such as metro prepare or perf memory snapshot', }, + { + key: 'perfTemplate', + names: ['--template'], + type: 'string', + usageLabel: '--template ', + usageDescription: 'Perf xctrace template name, for example Time Profiler', + }, { key: 'metroKind', names: ['--metro-kind'], diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 59b94742a..5d1268c4c 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -296,6 +296,14 @@ Diagnostics and traces: agent-device press 'id="load-diagnostics"' agent-device trace stop ./traces/diagnostics.trace The trace path is positional. Do not use --path for trace start or trace stop. + Use perf xctrace only for Apple native CPU/profile or Animation Hitches artifacts: + agent-device perf cpu profile start --kind xctrace --template "Time Profiler" --out ./artifacts/app.trace + agent-device perf cpu profile stop --kind xctrace --out ./artifacts/app.trace + agent-device perf cpu profile report --kind xctrace --out ./artifacts/app-profile.json + agent-device perf trace start --kind xctrace --template "Animation Hitches" --out ./artifacts/hitches.trace + agent-device perf trace stop --kind xctrace --out ./artifacts/hitches.trace + perf xctrace returns artifact paths and compact metadata only. Do not dump .trace contents into context. + Android native profiling is out of scope for Apple xctrace perf; use the Android perf rollout when available. Memory diagnostics: Use perf memory when the symptom is leak/growth/OOM suspicion and you need agent-readable evidence. diff --git a/test/skillgym/suites/agent-device-smoke-suite.ts b/test/skillgym/suites/agent-device-smoke-suite.ts index 30964646b..1119cccad 100644 --- a/test/skillgym/suites/agent-device-smoke-suite.ts +++ b/test/skillgym/suites/agent-device-smoke-suite.ts @@ -1364,6 +1364,27 @@ const SKILL_GUIDANCE_CASES: Case[] = [ plannedCommand('perf frames'), ], }), + makeCase({ + id: 'perf-apple-xctrace-profile', + contract: [ + 'App name: Agent Device Tester', + 'Platform: iOS simulator', + 'The app is already open', + 'Need Apple native CPU profiling evidence as a .trace artifact and compact JSON report', + 'Do not use debug or React DevTools for this native profile', + ], + task: 'Plan commands to record an Apple xctrace Time Profiler CPU profile under perf, stop it, then generate the compact report.', + outputs: [ + plannedCommand('perf cpu profile start'), + /--kind\s+xctrace/i, + /--template\s+"?Time Profiler"?/i, + /--out\s+\S+\.trace/i, + plannedCommand('perf cpu profile stop'), + plannedCommand('perf cpu profile report'), + /--out\s+\S+\.json/i, + ], + forbiddenOutputs: [plannedCommand('debug'), plannedCommand('react-devtools')], + }), makeCase({ id: 'react-devtools-profile-search', contract: [ diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index d28acd734..98f065a74 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -265,6 +265,8 @@ Additional CLI-backed methods are exposed on their domain groups with typed opti `client.observability.perf()` returns daemon-shaped JSON so local and remote transports expose the same metrics payload. Pass `{ area: 'metrics' }` for the broad startup/CPU/memory/frame first pass, `{ area: 'frames' }` for a focused frame/jank-health payload, or `{ area: 'memory', action: 'sample' }` for a compact memory-only sample. Use `{ area: 'memory', action: 'snapshot', kind: 'android-hprof', out: 'app.hprof' }` on Android or `{ area: 'memory', action: 'snapshot', kind: 'memgraph', out: 'app.memgraph' }` on supported Apple simulator/macOS app sessions to write large memory artifacts to disk; responses return artifact paths and compact metadata, not artifact bytes. Physical iOS device memgraph capture reports unavailable with a reason/hint. heapprofd allocation tracing is deferred until Perfetto plumbing is available. On Android and supported Apple targets, `data.metrics.fps.droppedFramePercent` is the primary frame-smoothness value. Android derives it from the current `adb shell dumpsys gfxinfo framestats` window; connected iOS devices derive it from `xcrun xctrace` Animation Hitches for the active app process. Frame samples include `windowStartedAt`, `windowEndedAt`, and `worstWindows` so agents can correlate dropped-frame clusters with logs, network entries, and their own session actions. A successful Android read resets Android frame stats; `open ` resets the Android frame window too, so agents can call `perf({ area: 'frames' })`, perform a transition or gesture, then call it again to inspect that focused window. iOS simulator and macOS app sessions report frame health as unavailable rather than inventing FPS or dropped-frame values. +For Apple native profiling, call `perf({ area: 'cpu', subject: 'profile', action: 'start', kind: 'xctrace', template: 'Time Profiler', out: 'app.trace' })`, then stop with the same trace path and write a compact report with `action: 'report'`. `area: 'trace'` supports xctrace templates such as `Animation Hitches`. Responses include artifact paths and compact metadata only. + `client.recording.record({ action: 'start', path, quality: 5 })` starts a smaller 50% resolution video; omit `quality` to keep native/current resolution. `client.batch.run({ steps })` accepts structured steps: diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 79e7e2c87..a2b9caf69 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -589,6 +589,11 @@ agent-device perf frames --json agent-device perf memory sample --json agent-device perf memory snapshot --kind android-hprof --out app.hprof agent-device perf memory snapshot --kind memgraph --out app.memgraph +agent-device perf cpu profile start --kind xctrace --template "Time Profiler" --out app.trace +agent-device perf cpu profile stop --kind xctrace --out app.trace +agent-device perf cpu profile report --kind xctrace --out app-profile.json +agent-device perf trace start --kind xctrace --template "Animation Hitches" --out hitches.trace +agent-device perf trace stop --kind xctrace --out hitches.trace ``` - `perf metrics` returns a session-scoped metrics JSON blob. Bare `perf` and `metrics` remain aliases for `perf metrics`. @@ -597,6 +602,9 @@ agent-device perf memory snapshot --kind memgraph --out app.memgraph - Example sample shape: `{"metrics":{"memory":{"available":true,"totalPssKb":562958,"totalRssKb":570304,"topConsumers":[{"name":"Dalvik Heap","pssKb":213456}]}}}`. - `perf memory snapshot` writes a heap/memgraph artifact to disk and returns path, size, kind, method, and support metadata. Large artifacts are never dumped into CLI/MCP/default JSON output. - Example default snapshot output: `Memory artifact (android-hprof): /tmp/app.hprof (42MB)`. +- `perf cpu profile ... --kind xctrace` records an Apple `.trace` with the requested xctrace template and writes a compact JSON report from the most recent CPU profile trace. +- `perf trace ... --kind xctrace` records an Apple `.trace` such as Animation Hitches for native diagnosis. +- xctrace perf commands return artifact paths and compact metadata only; inspect `.trace` files in Instruments/Xcode instead of dumping trace contents into agent context. - Without `--json`, `perf` prints a compact summary: frame health when reliable frame data is available, otherwise CPU/memory when those samples are available. - `startup` is sampled from `open-command-roundtrip`: elapsed wall-clock time around each `open` command dispatch for the active session app target. - Android app sessions with an active package also sample: @@ -614,6 +622,9 @@ agent-device perf memory snapshot --kind memgraph --out app.memgraph - `perf memory snapshot --kind android-hprof`: Android emulator/device app sessions with a running debuggable/profileable process and permitted heap dumping - `perf memory snapshot --kind memgraph`: iOS simulator and macOS app sessions with a running app process. Physical iOS devices report memgraph unavailable with a recovery hint. - `perf memory trace --kind heapprofd`: deferred until Android Perfetto/heapprofd plumbing is available. + - `perf cpu profile --kind xctrace`: iOS simulator app sessions, connected iOS device app sessions where xctrace can attach to the active process, and macOS app sessions when the app process can be resolved from the bundle ID. + - `perf trace --kind xctrace`: iOS simulator app sessions, connected iOS device app sessions where xctrace can attach to the active process, and macOS app sessions when the selected xctrace template supports the target. + - Android native profiling is not implemented under Apple xctrace perf; Android profiling is tracked separately. - If no startup sample exists yet for the session, run `open ` first and retry `perf metrics`. - Android URL/deep-link opens infer the foreground package after launch when possible, including Expo Go/dev-client shells. If the session still has no app package/bundle ID, package-bound metrics remain unavailable until you `open `. - Android frame health is reset after each successful `perf metrics` or `perf frames` read and after `open `, so run `perf frames`, perform the interaction, then run `perf frames` again for a focused window. diff --git a/website/docs/docs/debugging-profiling.md b/website/docs/docs/debugging-profiling.md index 9f0198805..50862999c 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -101,6 +101,9 @@ agent-device perf frames --json agent-device perf memory sample --json agent-device perf memory snapshot --kind android-hprof --out app.hprof agent-device perf memory snapshot --kind memgraph --out app.memgraph +agent-device perf cpu profile start --kind xctrace --template "Time Profiler" --out app.trace +agent-device perf cpu profile stop --kind xctrace --out app.trace +agent-device perf cpu profile report --kind xctrace --out app-profile.json ``` - `perf metrics` returns session-scoped startup and, where supported, CPU, memory, and frame-health samples. Bare `perf` and `metrics` remain aliases. @@ -109,6 +112,7 @@ agent-device perf memory snapshot --kind memgraph --out app.memgraph - Example sample shape: `{"metrics":{"memory":{"available":true,"totalPssKb":562958,"totalRssKb":570304,"topConsumers":[{"name":"Dalvik Heap","pssKb":213456}]}}}`. - `perf memory snapshot` escalates to file artifacts. Android supports Java HPROF capture for active app processes when the build/device allows heap dumping. iOS simulator and macOS app sessions support memgraph capture through host-visible process tooling; physical iOS device memgraph capture reports unavailable with a hint instead of pretending support. - Heap and memgraph artifacts are returned as paths plus compact metadata. Example default output: `Memory artifact (android-hprof): /tmp/app.hprof (42MB)`. They are not printed or embedded in JSON by default. heapprofd/native allocation tracing is deferred until Perfetto plumbing is available. +- `perf cpu profile ... --kind xctrace` and `perf trace ... --kind xctrace` collect Apple native `.trace` artifacts for iOS/macOS app sessions and return only artifact paths plus compact metadata. - Startup is measured around the `open` command; it is not first-frame instrumentation. - CPU, memory, and Android frame-health availability depend on platform and whether the active session is bound to an app/package. - On Android and supported Apple targets, use `metrics.fps.droppedFramePercent` for the health check and `metrics.fps.worstWindows` to line up jank clusters with logs, network activity, or recent actions.