diff --git a/src/__tests__/cli-perf.test.ts b/src/__tests__/cli-perf.test.ts index 362bf2c3a..5d116e376 100644 --- a/src/__tests__/cli-perf.test.ts +++ b/src/__tests__/cli-perf.test.ts @@ -106,6 +106,122 @@ test('perf frames sample forwards explicit sample action to daemon', async () => assert.deepEqual(result.calls[0]?.positionals, ['frames', 'sample']); }); +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, @@ -142,7 +258,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: {}, @@ -152,7 +268,7 @@ test('perf rejects unknown CLI area before daemon dispatch', async () => { assert.equal(result.calls.length, 0); const payload = JSON.parse(result.stdout); assert.equal(payload.error.code, 'INVALID_ARGS'); - assert.match(payload.error.message, /perf area must be metrics or frames/i); + assert.match(payload.error.message, /perf 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 1d4a8c25f..0ba55e21b 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -40,7 +40,7 @@ import type { import type { MetroBridgeScope } from './client-companion-tunnel-contract.ts'; import type { AppsFilter } from './contracts/app-inventory.ts'; import type { ScreenshotRequestFlags } from './contracts/screenshot.ts'; -import type { PerfAction, PerfArea } from './contracts/perf.ts'; +import type { PerfAction, PerfArea, PerfKind, PerfSubject } from './contracts/perf.ts'; import type { DaemonBatchStep } from './core/batch.ts'; import type { AlertAction, AlertInfo } from './alert-contract.ts'; @@ -741,7 +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 ae274608c..08ff46da9 100644 --- a/src/commands/cli-grammar/observability.ts +++ b/src/commands/cli-grammar/observability.ts @@ -12,10 +12,16 @@ import { LOG_ACTION_VALUES, type LogAction } from '../log-command-contract.ts'; 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, @@ -30,7 +36,11 @@ import type { CliReader, DaemonWriter } from './types.ts'; export const observabilityCliReaders = { perf: (positionals, flags) => ({ ...commonInputFromFlags(flags), - ...readPerfPositionals(positionals), + ...readPerfPositionals(positionals, { + kind: readPerfKindFlag(flags.perfKind), + template: flags.perfTemplate, + out: flags.out, + }), }), logs: (positionals, flags) => ({ ...commonInputFromFlags(flags), @@ -73,16 +83,70 @@ 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 }; } + 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]), }; } @@ -122,6 +186,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 d5ac175d0..af2343d63 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 } 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; @@ -184,7 +189,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), + 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 e33800f75..ec9dbfad6 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 metrics = readRecord(data.metrics); const fps = readRecord(metrics?.fps); const resourceSummary = buildResourcePerfSummary(metrics); @@ -169,6 +171,30 @@ function formatPerfCliOutput(data: Record): string { return lines.join('\n'); } +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 eaa6ea386..acded8598 100644 --- a/src/contracts/perf.ts +++ b/src/contracts/perf.ts @@ -1,16 +1,28 @@ import { defineStringEnum } from '../utils/string-enum.ts'; -export const PERF_AREA_VALUES = ['metrics', 'frames'] as const; -export const PERF_ACTION_VALUES = ['sample'] as const; +export const PERF_AREA_VALUES = ['metrics', 'frames', 'cpu', 'trace'] as const; +export const PERF_ACTION_VALUES = ['sample', 'start', 'stop', 'report'] as const; +export const PERF_SUBJECT_VALUES = ['profile'] as const; +export const PERF_KIND_VALUES = ['xctrace'] 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); 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 const PERF_AREA_ERROR_MESSAGE = 'perf area must be metrics or frames'; -export const PERF_ACTION_ERROR_MESSAGE = 'perf action must be sample'; +export const PERF_AREA_ERROR_MESSAGE = 'perf area must be metrics, frames, cpu, or trace'; +export const PERF_ACTION_ERROR_MESSAGE = 'perf action must be sample, start, stop, or report'; +export const PERF_SUBJECT_ERROR_MESSAGE = 'perf cpu requires profile'; +export const PERF_KIND_ERROR_MESSAGE = 'perf native collection currently supports --kind xctrace'; export const isPerfArea = PERF_AREAS.is; export const isPerfAction = PERF_ACTIONS.is; + +export const isPerfSubject = PERF_SUBJECTS.is; + +export const isPerfKind = PERF_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 2007b004b..34f21dcf1 100644 --- a/src/daemon/handlers/__tests__/session-observability.test.ts +++ b/src/daemon/handlers/__tests__/session-observability.test.ts @@ -1,8 +1,32 @@ import assert from 'node:assert/strict'; -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'; +beforeEach(() => { + vi.resetAllMocks(); +}); + test('network dump validates include mode directly', async () => { const sessionStore = makeSessionStore('agent-device-session-observability-'); sessionStore.set('android', { @@ -39,6 +63,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 4d1225427..5026b2e89 100644 --- a/src/daemon/handlers/session-observability.ts +++ b/src/daemon/handlers/session-observability.ts @@ -21,6 +21,7 @@ import { } from '../app-log.ts'; import { buildPerfFramesResponseData, 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 { @@ -88,26 +89,23 @@ export async function handleSessionObservabilityCommands( // --------------------------------------------------------------------------- async function handlePerfCommand(params: ObservabilityParams): Promise { - const { req, sessionName, sessionStore, androidAdbExecutor } = params; + const { sessionName, sessionStore, androidAdbExecutor } = params; const session = sessionStore.get(sessionName); if (!session) { return errorResponse('SESSION_NOT_FOUND', 'perf requires an active session. Run open first.'); } - const area = (req.positionals?.[0] ?? 'metrics').toLowerCase(); - const action = (req.positionals?.[1] ?? 'sample').toLowerCase(); - if (!isPerfArea(area)) { - return errorResponse('INVALID_ARGS', PERF_AREA_ERROR_MESSAGE); - } - if (!isPerfAction(action)) { - return errorResponse('INVALID_ARGS', PERF_ACTION_ERROR_MESSAGE); + const request = resolvePerfRequest(params.req.positionals ?? []); + if (!request.ok) return request; + if (request.native) { + return await handleNativePerfCommand(params, session); } try { return { ok: true, data: - area === 'frames' + request.area === 'frames' ? await buildPerfFramesResponseData(session, { androidAdb: androidAdbExecutor }) : await buildPerfResponseData(session, { androidAdb: androidAdbExecutor }), }; @@ -116,6 +114,24 @@ async function handlePerfCommand(params: ObservabilityParams): 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 3c9353b7c..12baf62c6 100644 --- a/src/platforms/ios/__tests__/perf.test.ts +++ b/src/platforms/ios/__tests__/perf.test.ts @@ -6,15 +6,26 @@ 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 { parseApplePsOutput, 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'; const mockRunCmd = vi.mocked(runCmd); +const mockRunCmdBackground = vi.mocked(runCmdBackground); type MockRunCmdResult = Awaited>; type XcrunMockHandler = (args: string[]) => Promise; @@ -45,6 +56,7 @@ const IOS_DEVICE: DeviceInfo = { beforeEach(() => { vi.resetAllMocks(); + mockRunCmdBackground.mockImplementation(() => mockBackgroundXctrace()); vi.useRealTimers(); }); @@ -194,6 +206,54 @@ test('sampleApplePerfMetrics uses simctl spawn ps for iOS simulators', async () } }); +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')); @@ -263,6 +323,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(' ')}`); @@ -408,6 +715,32 @@ function emptyRunResult(): MockRunCmdResult { return { stdout: '', stderr: '', exitCode: 0 }; } +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 d6f289964..ec4b4eaca 100644 --- a/src/platforms/ios/perf.ts +++ b/src/platforms/ios/perf.ts @@ -61,7 +61,7 @@ export type AppleMemoryPerfSample = { matchedProcesses: string[]; }; -type AppleProcessSample = { +export type AppleProcessSample = { pid: number; cpuPercent: number; rssKb: number; @@ -200,7 +200,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, @@ -345,7 +342,7 @@ async function runIosDeviceTraceRecord( return lastAttempt as IosDeviceTraceRecordAttempt; } -function isRetryableIosDeviceTraceRecordFailure(result: { +export function isRetryableIosDeviceTraceRecordFailure(result: { stdout: string; stderr: string; }): boolean { @@ -357,6 +354,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; @@ -521,7 +528,7 @@ async function parseIosDevicePerfTable(xml: string): Promise { @@ -606,7 +613,7 @@ async function sampleIosDevicePerfMetrics( }); } -async function resolveIosDevicePerfTarget( +export async function resolveIosDevicePerfTarget( device: DeviceInfo, appBundleId: string, ): Promise { @@ -803,7 +810,7 @@ async function resolveIosSimulatorAppContainer( return appPath; } -async function readAppleProcessSamples( +export async function readAppleProcessSamples( device: DeviceInfo, executable: { executableName: string; executablePath?: string }, ): Promise { @@ -820,28 +827,52 @@ 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), ); } +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; @@ -888,7 +919,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 fbbbce493..35bdf4461 100644 --- a/src/utils/__tests__/perf-args.test.ts +++ b/src/utils/__tests__/perf-args.test.ts @@ -10,10 +10,20 @@ test('parseArgs accepts perf area subcommands', () => { const frames = parseArgs(['perf', 'frames'], { strictFlags: true }); assert.equal(frames.command, 'perf'); assert.deepEqual(frames.positionals, ['frames']); + + 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.perfKind, 'xctrace'); + assert.equal(profile.flags.out, 'app.trace'); }); test('usageForCommand advertises perf area subcommands for metrics alias', () => { const help = usageForCommand('metrics'); assert.equal(help === null, false); assert.match(help ?? '', /agent-device perf \[metrics\|frames\]/); + assert.match(help ?? '', /perf cpu profile start\|stop\|report/); }); diff --git a/src/utils/args.ts b/src/utils/args.ts index 25b03f6e8..6b284d3f4 100644 --- a/src/utils/args.ts +++ b/src/utils/args.ts @@ -4,6 +4,7 @@ import { applyCommandDefaults, getCommandSchema, getFlagDefinition, + getFlagDefinitionByKey, type CliFlags, type FlagDefinition, type FlagKey, @@ -67,7 +68,7 @@ export function parseRawArgs(argv: string[]): RawParsedArgs { } const [token, inlineValue] = isLongFlag ? splitLongFlag(arg) : [arg, undefined]; - const definition = getFlagDefinition(token); + const definition = resolveFlagDefinition(command, token); if (shouldPassThroughReactDevtoolsFlag(command, definition)) { positionals.push(arg); continue; @@ -109,6 +110,13 @@ function shouldPassThroughReactDevtoolsFlag( return !isFlagSupportedForCommand(definition.key, command); } +function resolveFlagDefinition(command: string | null, token: string): FlagDefinition | undefined { + if (command === 'perf' && token === '--kind') { + return getFlagDefinitionByKey('perfKind'); + } + 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 5f474f25f..e1004b89c 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -150,12 +150,15 @@ const CLI_COMMAND_OVERRIDES = { helpDescription: 'Show foreground app/activity', }, perf: { - usageOverride: 'perf [metrics|frames] [sample]', - listUsageOverride: 'perf [metrics|frames]', + usageOverride: + 'perf [metrics|frames] [sample] | perf cpu profile start|stop|report --kind xctrace [--template ] --out | perf trace start|stop --kind xctrace [--template ] --out ', + listUsageOverride: + 'perf [metrics|frames] | perf cpu profile start|stop|report | perf trace start|stop', helpDescription: - 'Show session performance metrics or focused frame/jank health. Bare perf and metrics are aliases for perf metrics.', - summary: 'Show session performance and frame health', - positionalArgs: ['area?', 'action?'], + 'Show session performance metrics, focused frame/jank health, or collect Apple xctrace artifacts. Bare perf and metrics are aliases for perf metrics.', + summary: 'Show performance metrics or collect Apple xctrace artifacts', + positionalArgs: ['area?', 'subjectOrAction?', 'action?'], + allowedFlags: ['perfKind', 'perfTemplate', 'out'], }, metro: { usageOverride: diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index 049d8e825..5e451d9fb 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -63,6 +63,8 @@ export type CliFlags = RemoteConfigMetroOptions & snapshotRaw?: boolean; snapshotForceFull?: boolean; networkInclude?: NetworkIncludeMode; + perfKind?: 'xctrace'; + perfTemplate?: string; baseline?: string; threshold?: string; appsFilter?: 'user-installed' | 'all'; @@ -384,6 +386,14 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--project-root ', usageDescription: 'metro prepare: React Native project root (default: cwd)', }, + { + key: 'perfKind', + names: ['--kind'], + type: 'enum', + enumValues: ['xctrace'], + usageLabel: '--kind xctrace', + usageDescription: 'Perf collector kind', + }, { key: 'metroKind', names: ['--kind'], @@ -392,6 +402,13 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--kind auto|react-native|expo', usageDescription: 'metro prepare: detect or force the Metro launcher kind', }, + { + key: 'perfTemplate', + names: ['--template'], + type: 'string', + usageLabel: '--template ', + usageDescription: 'Perf xctrace template name, for example Time Profiler', + }, { key: 'metroPublicBaseUrl', names: ['--public-base-url'], @@ -1055,6 +1072,10 @@ export function getFlagDefinition(token: string): FlagDefinition | undefined { return flagDefinitionByName.get(token); } +export function getFlagDefinitionByKey(key: FlagKey): FlagDefinition | undefined { + return FLAG_DEFINITIONS.find((definition) => definition.key === key); +} + export function getFlagDefinitions(): readonly FlagDefinition[] { return FLAG_DEFINITIONS; } diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 614aad5ab..7ec642b9c 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. Stabilizers: Android animation-sensitive flows: diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index 82fdff869..09e3da5b3 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -4,6 +4,7 @@ import type { CommandSchema, CommandSchemaOverride } from './cli-command-schema- import { getCliCommandOverride, getSchemaOnlyCliCommandSchema } from './cli-command-overrides.ts'; import { getFlagDefinition, + getFlagDefinitionByKey, getFlagDefinitions, GLOBAL_FLAG_KEYS, type CliFlags, @@ -14,7 +15,7 @@ import { export type { CliFlags, DaemonExcludedCliFlag, FlagDefinition, FlagKey }; export type { CommandSchema, CommandSchemaOverride }; -export { getFlagDefinition, getFlagDefinitions, GLOBAL_FLAG_KEYS }; +export { getFlagDefinition, getFlagDefinitionByKey, getFlagDefinitions, GLOBAL_FLAG_KEYS }; const COMMAND_SCHEMA_BASES = new Map( listCommandDescriptionMetadata().map((definition) => [ diff --git a/test/skillgym/suites/agent-device-smoke-suite.ts b/test/skillgym/suites/agent-device-smoke-suite.ts index 6979830b2..5f4f21a2b 100644 --- a/test/skillgym/suites/agent-device-smoke-suite.ts +++ b/test/skillgym/suites/agent-device-smoke-suite.ts @@ -1329,6 +1329,27 @@ const SKILL_GUIDANCE_CASES: Case[] = [ outputs: [plannedCommand('perf frames'), /--json/i], forbiddenOutputs: [plannedCommand('react-devtools'), plannedCommand('network')], }), + 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 2cca05e8e..f8fe40057 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, or `{ area: 'frames' }` for a focused frame/jank-health payload. On Android and supported Apple targets, `data.metrics.fps.droppedFramePercent` is the primary frame-smoothness value. Android derives it from the current `adb shell dumpsys gfxinfo framestats` window; connected iOS devices derive it from `xcrun xctrace` Animation Hitches for the active app process. Frame samples include `windowStartedAt`, `windowEndedAt`, and `worstWindows` so agents can correlate dropped-frame clusters with logs, network entries, and their own session actions. A successful Android read resets Android frame stats; `open ` resets the Android frame window too, so agents can call `perf({ area: 'frames' })`, perform a transition or gesture, then call it again to inspect that focused window. iOS simulator and macOS app sessions report frame health as unavailable rather than inventing FPS or dropped-frame values. +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 bec930e24..bd1fecb0c 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -586,10 +586,18 @@ agent-device perf --json agent-device metrics --json agent-device perf metrics --json agent-device perf frames --json +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`. - `perf frames` returns a focused frame/jank-health JSON blob from the same frame sampling source used by `perf metrics`. +- `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: @@ -604,6 +612,9 @@ agent-device perf frames --json - `startup`: iOS simulator, iOS physical device, Android emulator/device - `memory` and `cpu`: Android emulator/device, macOS app sessions, iOS simulators with an active app session (`open ` first), and iOS physical devices with an active app session - `fps`: Android emulator/device app sessions and connected iOS device app sessions. iOS simulator and macOS frame health is reported unavailable because Apple tooling does not expose trustworthy app hitch data there. + - `perf 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 f9acceb76..f5341e115 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -98,10 +98,14 @@ agent-device perf --json agent-device metrics --json agent-device perf metrics --json agent-device perf frames --json +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. - `perf frames` returns a focused frame/jank-health payload. +- `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.