From 0558617a645f54941e016a4b4e9d2d5d4e98b9c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 16:10:15 +0200 Subject: [PATCH 1/4] refactor(platform-apple): dedupe xctrace recording, export and trace checks behind perf-xctrace perf.ts now delegates timed xctrace recording, table export and the trace-has-data check to perf-xctrace.ts, which owns one record-argv builder, one retry loop, one export implementation and one data check. Process-target resolution moves to perf-target.ts so perf-xctrace no longer imports perf.ts, bringing perf.ts under 1,000 lines. perf.ts and perf-frame.ts share one xctrace XML reference resolver in perf-xml.ts. physical-device-coredevice.ts reads device details through the shared devicectl --json-output helper, which now carries the failure payload and cleans up its temp file when the command throws. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/core/__tests__/perf-target.test.ts | 26 + .../src/core/__tests__/perf.test.ts | 24 - packages/platform-apple/src/core/devicectl.ts | 41 +- .../src/core/display-inventory.ts | 6 +- .../platform-apple/src/core/perf-frame.ts | 42 +- .../platform-apple/src/core/perf-target.ts | 182 +++++++ .../platform-apple/src/core/perf-xctrace.ts | 215 ++++++-- packages/platform-apple/src/core/perf-xml.ts | 46 +- packages/platform-apple/src/core/perf.ts | 508 ++---------------- .../src/core/physical-device-coredevice.ts | 124 ++--- packages/platform-apple/src/perf-facade.ts | 12 +- 11 files changed, 548 insertions(+), 678 deletions(-) create mode 100644 packages/platform-apple/src/core/__tests__/perf-target.test.ts create mode 100644 packages/platform-apple/src/core/perf-target.ts diff --git a/packages/platform-apple/src/core/__tests__/perf-target.test.ts b/packages/platform-apple/src/core/__tests__/perf-target.test.ts new file mode 100644 index 0000000000..9ffdc72b9f --- /dev/null +++ b/packages/platform-apple/src/core/__tests__/perf-target.test.ts @@ -0,0 +1,26 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { parseApplePsOutput } from '../perf-target.ts'; + +test('parseApplePsOutput reads pid cpu rss and command columns', () => { + const rows = parseApplePsOutput( + ['123 12.5 45678 /Applications/Test.app/Contents/MacOS/Test --flag', '456 0.0 2048 Test'].join( + '\n', + ), + ); + + assert.deepEqual(rows, [ + { + pid: 123, + cpuPercent: 12.5, + rssKb: 45678, + command: '/Applications/Test.app/Contents/MacOS/Test --flag', + }, + { + pid: 456, + cpuPercent: 0, + rssKb: 2048, + command: 'Test', + }, + ]); +}); diff --git a/packages/platform-apple/src/core/__tests__/perf.test.ts b/packages/platform-apple/src/core/__tests__/perf.test.ts index 87f1b9fa44..0012fca1e2 100644 --- a/packages/platform-apple/src/core/__tests__/perf.test.ts +++ b/packages/platform-apple/src/core/__tests__/perf.test.ts @@ -16,7 +16,6 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => { import { buildAppleMemorySnapshotSupport, captureAppleMemorySnapshot, - parseApplePsOutput, sampleAppleFramePerf, sampleAppleMemoryPerf, } from '../perf.ts'; @@ -115,29 +114,6 @@ test('buildAppleMemorySnapshotSupport never emits the internal apple platform', } }); -test('parseApplePsOutput reads pid cpu rss and command columns', () => { - const rows = parseApplePsOutput( - ['123 12.5 45678 /Applications/Test.app/Contents/MacOS/Test --flag', '456 0.0 2048 Test'].join( - '\n', - ), - ); - - assert.deepEqual(rows, [ - { - pid: 123, - cpuPercent: 12.5, - rssKb: 45678, - command: '/Applications/Test.app/Contents/MacOS/Test --flag', - }, - { - pid: 456, - cpuPercent: 0, - rssKb: 2048, - command: 'Test', - }, - ]); -}); - test('parseAppleFramePerfSample summarizes app hitches and worst windows', () => { const sample = parseAppleFramePerfSample({ hitchesXml: makeAppleHitchesXml(), diff --git a/packages/platform-apple/src/core/devicectl.ts b/packages/platform-apple/src/core/devicectl.ts index d1a921ff7c..3403b37068 100644 --- a/packages/platform-apple/src/core/devicectl.ts +++ b/packages/platform-apple/src/core/devicectl.ts @@ -184,7 +184,9 @@ export type IosDevicectlJsonOutcome = ok: false; reason: IosDevicectlJsonFailureReason; args: string[]; - result?: ExecResult; + result: ExecResult; + /** The JSON a failed command still wrote, when it was readable. */ + payload?: unknown; cause?: string; }; @@ -210,28 +212,35 @@ export async function runIosDevicectlJsonRequest(options: { `${options.jsonPrefix}-${hostProcessId()}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, ); const args = [...options.args, '--json-output', jsonPath]; - const result = await runXcrun(args, { - allowFailure: true, - signal: options.signal, - timeoutMs: options.timeoutMs ?? IOS_DEVICECTL_TIMEOUT_MS, - }); - try { + const result = await runXcrun(args, { + allowFailure: true, + signal: options.signal, + timeoutMs: options.timeoutMs ?? IOS_DEVICECTL_TIMEOUT_MS, + }); if (result.exitCode !== 0) { - if (options.tolerateFailurePayload) { - const failurePayload = await readJsonFile(jsonPath).catch(() => undefined); - if (failurePayload !== undefined && options.tolerateFailurePayload(failurePayload)) { - return { ok: true, payload: failurePayload }; - } + const failurePayload = await readJsonFile(jsonPath).catch(() => undefined); + if (failurePayload !== undefined && options.tolerateFailurePayload?.(failurePayload)) { + return { ok: true, payload: failurePayload }; } - return { ok: false, reason: 'command-failed', args, result }; + return { ok: false, reason: 'command-failed', args, result, payload: failurePayload }; } + return await readIosDevicectlJsonPayload(jsonPath, args, result); + } finally { + await unlinkHostFile(jsonPath).catch(() => {}); + } +} + +async function readIosDevicectlJsonPayload( + jsonPath: string, + args: string[], + result: ExecResult, +): Promise { + try { return { ok: true, payload: await readJsonFile(jsonPath) }; } catch (error) { if (error instanceof AppError) throw error; return { ok: false, reason: 'unreadable-json', args, result, cause: String(error) }; - } finally { - await unlinkHostFile(jsonPath).catch(() => {}); } } @@ -251,7 +260,7 @@ async function runIosDevicectlJsonCommand( const outcome = await runIosDevicectlJsonRequest(options); if (outcome.ok) return outcome.payload; - if (outcome.reason === 'command-failed' && outcome.result) { + if (outcome.reason === 'command-failed') { const { stdout, stderr } = outcome.result; throw new AppError( 'COMMAND_FAILED', diff --git a/packages/platform-apple/src/core/display-inventory.ts b/packages/platform-apple/src/core/display-inventory.ts index 3e72731e74..ab995efa04 100644 --- a/packages/platform-apple/src/core/display-inventory.ts +++ b/packages/platform-apple/src/core/display-inventory.ts @@ -103,11 +103,7 @@ export async function queryAppleDisplayInventory( signal: options.signal, }); if (!outcome.ok) { - emitUnresolvedDiagnostic( - device, - outcome.reason, - outcome.result ? outcome.result.stderr.trim() : (outcome.cause ?? ''), - ); + emitUnresolvedDiagnostic(device, outcome.reason, outcome.result.stderr.trim()); return unresolvedInventory(); } const displays = parseCoreDeviceDisplays(outcome.payload); diff --git a/packages/platform-apple/src/core/perf-frame.ts b/packages/platform-apple/src/core/perf-frame.ts index dc8cbda192..ce2914af79 100644 --- a/packages/platform-apple/src/core/perf-frame.ts +++ b/packages/platform-apple/src/core/perf-frame.ts @@ -3,10 +3,11 @@ import { roundOneDecimal, roundPercent } from '@agent-device/kernel/numeric'; import { parseXmlDocumentSync, type XmlNode } from '@agent-device/xml'; import { findAllXmlNodes, - findFirstXmlNode, - parseDirectXmlNumber, readSchemaColumns, + rememberXmlReferences, resolveXmlNumber, + resolveXmlProcess, + type XmlReference, } from './perf-xml.ts'; const MAX_WORST_WINDOWS = 3; @@ -54,11 +55,6 @@ type AppleHitchSchemaIndexes = { isSystem: number; }; -type XmlReference = { - numberValue?: number | null; - process?: { pid?: number; name?: string } | null; -}; - export function parseAppleFramePerfSample(options: { hitchesXml: string; frameLifetimesXml: string; @@ -236,17 +232,6 @@ function parseTable(xml: string, schemaName: string): { rows: XmlNode[]; schema: }; } -function rememberXmlReferences(elements: XmlNode[], references: Map): void { - for (const element of elements) { - rememberXmlReferences(element.children, references); - if (!element.attributes.id) continue; - references.set(element.attributes.id, { - numberValue: parseDirectXmlNumber(element), - process: readDirectProcess(element), - }); - } -} - function resolveXmlBoolean( element: XmlNode | undefined, references: Map, @@ -255,24 +240,3 @@ function resolveXmlBoolean( if (value === null) return null; return value !== 0; } - -function resolveXmlProcess( - element: XmlNode | undefined, - references: Map, -): { pid?: number; name?: string } | null { - if (!element) return null; - if (element.attributes.ref) return references.get(element.attributes.ref)?.process ?? null; - return readDirectProcess(element); -} - -function readDirectProcess(element: XmlNode | undefined): { pid?: number; name?: string } | null { - if (!element || element.children.some((child) => child.name === 'sentinel')) return null; - const pidNode = findFirstXmlNode(element.children, (child) => child.name === 'pid'); - const pid = parseDirectXmlNumber(pidNode); - const name = (element.attributes.fmt ?? '').replace(/\s+\(\d+\)$/, '').trim(); - if (pid === null && name.length === 0) return null; - return { - pid: pid ?? undefined, - name: name.length > 0 ? name : undefined, - }; -} diff --git a/packages/platform-apple/src/core/perf-target.ts b/packages/platform-apple/src/core/perf-target.ts new file mode 100644 index 0000000000..facc6e8650 --- /dev/null +++ b/packages/platform-apple/src/core/perf-target.ts @@ -0,0 +1,182 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { requireExecSuccess, type ExecResult } from '@agent-device/host-kit/command'; +import { splitNonEmptyTrimmedLines } from '@agent-device/kernel/record'; +import type { IosDeviceProcessInfo } from './app-info.ts'; +import { resolveIosPhysicalDeviceControl } from './physical-device-control.ts'; +import { readInfoPlistString } from './plist.ts'; +import { buildSimctlArgsForDevice } from './simctl.ts'; +import { runAppleToolCommand, runXcrun } from './tool-provider.ts'; + +const APPLE_PERF_TIMEOUT_MS = 15_000; + +export type AppleProcessSample = { + pid: number; + cpuPercent: number; + rssKb: number; + command: string; +}; + +export function parseApplePsOutput(stdout: string): AppleProcessSample[] { + const rows: AppleProcessSample[] = []; + for (const line of splitNonEmptyTrimmedLines(stdout)) { + const match = line.match(/^(\d+)\s+([0-9]+(?:\.[0-9]+)?)\s+(\d+)\s+(.+)$/); + if (!match) continue; + const [pidText, cpuText, rssText, commandText] = match.slice(1); + if ( + pidText === undefined || + cpuText === undefined || + rssText === undefined || + commandText === undefined + ) { + continue; + } + const pid = Number(pidText); + const cpuPercent = Number(cpuText); + const rssKb = Number(rssText); + const command = commandText.trim(); + if (!Number.isFinite(pid) || !Number.isFinite(cpuPercent) || !Number.isFinite(rssKb)) { + continue; + } + rows.push({ pid, cpuPercent, rssKb, command }); + } + return rows; +} + +export async function resolveAppleExecutable( + device: DeviceInfo, + appBundleId: string, +): Promise<{ executableName: string; executablePath?: string }> { + const appPath = isMacOs(device) + ? await resolveMacOsBundlePath(appBundleId) + : await resolveIosSimulatorAppContainer(device, appBundleId); + const infoPlistPath = isMacOs(device) + ? path.join(appPath, 'Contents', 'Info.plist') + : path.join(appPath, 'Info.plist'); + const executableName = await readInfoPlistString(infoPlistPath, 'CFBundleExecutable'); + if (!executableName) { + throw new AppError('COMMAND_FAILED', `Failed to resolve executable for ${appBundleId}`, { + appBundleId, + appPath, + }); + } + + return { + executableName, + executablePath: isMacOs(device) + ? path.join(appPath, 'Contents', 'MacOS', executableName) + : path.join(appPath, executableName), + }; +} + +export async function resolveIosDevicePerfTarget( + device: DeviceInfo, + appBundleId: string, +): Promise { + const { appBundleUrl, processes } = await resolveIosPhysicalDeviceControl( + device, + ).resolveAppProcesses(device, appBundleId); + const appBundlePath = fileURLToPath(appBundleUrl); + if (processes.length === 0) { + throw new AppError('COMMAND_FAILED', `No running process found for ${appBundleId}`, { + appBundleId, + deviceId: device.id, + appBundlePath, + hint: 'Run open for this session again to ensure the iOS app is active, then retry perf.', + }); + } + + return processes; +} + +async function resolveMacOsBundlePath(appBundleId: string): Promise { + const query = `kMDItemCFBundleIdentifier == "${appBundleId.replaceAll('"', String.raw`\"`)}"`; + const result = requireExecSuccess( + await runAppleToolCommand('mdfind', [query], { + allowFailure: true, + timeoutMs: APPLE_PERF_TIMEOUT_MS, + }), + `Failed to resolve macOS app bundle for ${appBundleId}`, + { appBundleId }, + ); + + const bundlePath = result.stdout + .split('\n') + .map((entry) => entry.trim()) + .find((entry) => entry.endsWith('.app')); + if (!bundlePath) { + throw new AppError('APP_NOT_INSTALLED', `No macOS app found for ${appBundleId}`, { + appBundleId, + }); + } + return bundlePath; +} + +async function resolveIosSimulatorAppContainer( + device: DeviceInfo, + appBundleId: string, +): Promise { + const args = buildSimctlArgsForDevice(device, [ + 'get_app_container', + device.id, + appBundleId, + 'app', + ]); + const result = requireExecSuccess( + await runXcrun(args, { + allowFailure: true, + timeoutMs: APPLE_PERF_TIMEOUT_MS, + }), + `Failed to resolve iOS simulator app container for ${appBundleId}`, + { + appBundleId, + hint: 'Ensure the iOS simulator app is installed and booted, then retry perf.', + }, + ); + const appPath = result.stdout.trim(); + if (appPath.length === 0) { + throw new AppError( + 'APP_NOT_INSTALLED', + `No iOS simulator app container found for ${appBundleId}`, + { + appBundleId, + }, + ); + } + return appPath; +} + +export async function readAppleProcessSamples( + device: DeviceInfo, + executable: { executableName: string; executablePath?: string }, +): Promise { + const args = isMacOs(device) + ? ['-axo', 'pid=,%cpu=,rss=,command='] + : buildSimctlArgsForDevice(device, [ + 'spawn', + device.id, + 'ps', + '-axo', + 'pid=,%cpu=,rss=,command=', + ]); + const result = isMacOs(device) + ? await runAppleToolCommand('ps', args, { timeoutMs: APPLE_PERF_TIMEOUT_MS }) + : await runAppleSimulatorProcessCommand(args); + const { matchesAppleExecutableProcess } = await import('./perf-process-identity.ts'); + return parseApplePsOutput(result.stdout).filter((processInfo) => + matchesAppleExecutableProcess(processInfo.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, + }); +} diff --git a/packages/platform-apple/src/core/perf-xctrace.ts b/packages/platform-apple/src/core/perf-xctrace.ts index 87a622f1ab..26bbef5c83 100644 --- a/packages/platform-apple/src/core/perf-xctrace.ts +++ b/packages/platform-apple/src/core/perf-xctrace.ts @@ -34,15 +34,15 @@ import { type AppleTimeProfileFunction, } from './perf-time-profile.ts'; import { - isRetryableIosDeviceTraceRecordFailure, - prepareAppleTraceRecordRetry, readAppleProcessSamples, resolveAppleExecutable, - resolveIosDevicePerfHint, resolveIosDevicePerfTarget, -} from './perf.ts'; +} from './perf-target.ts'; +import { IOS_DEVICECTL_DEFAULT_HINT, resolveIosDevicectlHint } from './devicectl.ts'; import { runXcrun } from './tool-provider.ts'; +// Physical device tracing can take materially longer to initialize than the 1s sample window. +const IOS_DEVICE_PERF_RECORD_TIMEOUT_MS = 60_000; 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; @@ -52,6 +52,16 @@ const APPLE_XCTRACE_STOP_FORCE_TIMEOUT_MS = 5_000; export type AppleXctracePerfMode = 'cpu-profile' | 'trace'; +type AppleXctraceRecordTarget = number[] | 'all-processes'; + +type AppleXctraceRecordAttempt = { started: T } | { failure: ExecResult }; + +export type AppleXctraceTimedRecord = { + startedAt: string; + endedAt: string; + capturedAtMs: number; +}; + export type AppleXctracePerfCapture = { kind: 'xctrace'; mode: AppleXctracePerfMode; @@ -112,15 +122,24 @@ export async function startAppleXctracePerfCapture(params: { const args = buildAppleXctraceRecordArgs({ device: params.device, template: params.template, - targetPids: target.pids, + target: 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}`, - }); + const background = await recordAppleXctraceWithRetry( + args, + params.outPath, + { + device: params.device, + appBundleId: params.appBundleId, + failureMessage: `Failed to start Apple xctrace ${params.mode} capture for ${params.appBundleId}`, + }, + async (): Promise> => { + const started = runCmdBackground('xcrun', args, { allowFailure: true }); + const immediate = await waitForImmediateAppleXctraceExit(started.wait); + return immediate ? { failure: immediate } : { started }; + }, + ); return { kind: 'xctrace', mode: params.mode, @@ -160,6 +179,8 @@ export async function stopAppleXctracePerfCapture( }); } await assertTracePathHasData(outPath, { + message: 'xctrace produced no trace data', + hint: 'Keep the Apple device unlocked and connected, keep the app active, then retry perf.', appBundleId: capture.appBundleId, deviceId: capture.deviceId, stdout: result.stdout, @@ -196,30 +217,20 @@ export async function writeAppleXctracePerfReport(params: { const tocPath = path.join(tempDir, 'trace-toc.xml'); const timeProfilePath = path.join(tempDir, 'time-profile.xml'); try { - const exportArgs = [ - 'xctrace', - 'export', - '--input', - params.tracePath, - '--toc', - '--output', - tocPath, - ]; - requireExecSuccess( - await runXcrun(exportArgs, { - allowFailure: true, - timeoutMs: IOS_DEVICE_PERF_EXPORT_TIMEOUT_MS, - }), - 'Failed to export Apple xctrace report metadata', - (exportResult) => ({ - cmd: 'xcrun', - args: exportArgs, - tracePath: params.tracePath, - hint: resolveIosDevicePerfHint(exportResult.stdout, exportResult.stderr), - }), - ); - const tocXml = await readHostTextFile(tocPath); - const timeProfileXml = await exportAppleTimeProfile(params.tracePath, timeProfilePath); + const tocXml = await exportAppleXctraceData({ + tracePath: params.tracePath, + outPath: tocPath, + query: 'toc', + failureMessage: 'Failed to export Apple xctrace report metadata', + failureDetails: { tracePath: params.tracePath }, + }); + const timeProfileXml = await exportAppleXctraceData({ + tracePath: params.tracePath, + outPath: timeProfilePath, + query: { schema: 'time-profile' }, + failureMessage: 'Failed to export Apple xctrace Time Profiler samples', + failureDetails: { tracePath: params.tracePath }, + }); const report = buildAppleXctracePerfReport({ ...params, tocXml, @@ -240,31 +251,86 @@ export async function writeAppleXctracePerfReport(params: { } } -async function exportAppleTimeProfile(tracePath: string, outPath: string): Promise { +export async function recordAppleXctraceTimedTrace(params: { + device: DeviceInfo; + appBundleId: string; + tracePath: string; + template: string; + timeLimit: string; + target: AppleXctraceRecordTarget; + requireTraceData?: boolean; + failureMessage: string; +}): Promise { + const args = buildAppleXctraceRecordArgs({ + device: params.device, + template: params.template, + target: params.target, + timeLimit: params.timeLimit, + outPath: params.tracePath, + }); + const { result, ...record } = await recordAppleXctraceWithRetry( + args, + params.tracePath, + params, + async (): Promise< + AppleXctraceRecordAttempt + > => { + const startedAt = new Date().toISOString(); + const result = await runXcrun(args, { + allowFailure: true, + timeoutMs: IOS_DEVICE_PERF_RECORD_TIMEOUT_MS, + }); + if (result.exitCode !== 0) return { failure: result }; + return { + started: { result, startedAt, endedAt: new Date().toISOString(), capturedAtMs: Date.now() }, + }; + }, + ); + if (params.requireTraceData) { + await assertTracePathHasData(params.tracePath, { + message: `${params.failureMessage}: xctrace produced no trace data`, + hint: 'Keep the iOS device unlocked and connected by cable, keep the app active, then retry perf.', + appBundleId: params.appBundleId, + deviceId: params.device.id, + stdout: result.stdout, + stderr: result.stderr, + }); + } + return record; +} + +export async function exportAppleXctraceData(params: { + tracePath: string; + outPath: string; + query: 'toc' | { schema: string }; + failureMessage: string; + failureDetails: Record; +}): Promise { const exportArgs = [ 'xctrace', 'export', '--input', - tracePath, - '--xpath', - '/trace-toc/run/data/table[@schema="time-profile"]', + params.tracePath, + ...(params.query === 'toc' + ? ['--toc'] + : ['--xpath', `/trace-toc/run/data/table[@schema="${params.query.schema}"]`]), '--output', - outPath, + params.outPath, ]; requireExecSuccess( await runXcrun(exportArgs, { allowFailure: true, timeoutMs: IOS_DEVICE_PERF_EXPORT_TIMEOUT_MS, }), - 'Failed to export Apple xctrace Time Profiler samples', + params.failureMessage, (exportResult) => ({ cmd: 'xcrun', args: exportArgs, - tracePath, + ...params.failureDetails, hint: resolveIosDevicePerfHint(exportResult.stdout, exportResult.stderr), }), ); - return await readHostTextFile(outPath); + return await readHostTextFile(params.outPath); } async function resolveAppleXctracePerfTarget( @@ -305,7 +371,8 @@ async function resolveAppleXctracePerfTarget( function buildAppleXctraceRecordArgs(params: { device: DeviceInfo; template: string; - targetPids: number[]; + target: AppleXctraceRecordTarget; + timeLimit?: string; outPath: string; }): string[] { return [ @@ -314,7 +381,10 @@ function buildAppleXctraceRecordArgs(params: { '--template', params.template, ...(isIosFamily(params.device) ? ['--device', params.device.id] : []), - ...params.targetPids.flatMap((pid) => ['--attach', String(pid)]), + ...(params.target === 'all-processes' + ? ['--all-processes'] + : params.target.flatMap((pid) => ['--attach', String(pid)])), + ...(params.timeLimit ? ['--time-limit', params.timeLimit] : []), '--output', params.outPath, '--quiet', @@ -322,7 +392,7 @@ function buildAppleXctraceRecordArgs(params: { ]; } -async function startAppleXctraceRecordWithRetry( +async function recordAppleXctraceWithRetry( args: string[], tracePath: string, context: { @@ -330,18 +400,16 @@ async function startAppleXctraceRecordWithRetry( appBundleId: string; failureMessage: string; }, -): Promise { - let lastImmediateFailure: ExecResult | undefined; + attemptRecord: () => Promise>, +): Promise { + let failure: ExecResult = { stdout: '', stderr: '', exitCode: 1 }; 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; + await prepareAppleTraceRecordRetry(tracePath, attempt); + const outcome = await attemptRecord(); + if ('started' in outcome) return outcome.started; + failure = outcome.failure; + if (!isRetryableIosDeviceTraceRecordFailure(failure)) break; } - - const failure = lastImmediateFailure ?? { stdout: '', stderr: '', exitCode: 1 }; throw new AppError( 'COMMAND_FAILED', context.failureMessage, @@ -355,6 +423,24 @@ async function startAppleXctraceRecordWithRetry( ); } +export function isRetryableIosDeviceTraceRecordFailure(result: { + stdout: string; + stderr: string; +}): boolean { + const text = `${result.stdout}\n${result.stderr}`.toLowerCase(); + return ( + text.includes('_lockkperf') || + text.includes('could not lock kperf') || + text.includes('likely another session just started') + ); +} + +async function prepareAppleTraceRecordRetry(tracePath: string, attempt: number): Promise { + if (attempt <= 1) return; + await removeHostPath(tracePath).catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, IOS_DEVICE_TRACE_RECORD_RETRY_DELAY_MS)); +} + async function waitForImmediateAppleXctraceExit( wait: Promise, ): Promise { @@ -415,6 +501,8 @@ async function waitForAppleXctraceExit( async function assertTracePathHasData( tracePath: string, context: { + message: string; + hint: string; appBundleId?: string; deviceId?: string; stdout: string; @@ -427,13 +515,13 @@ async function assertTracePathHasData( ? (await readHostDirectory(tracePath).catch(() => [])).length > 0 : (stat?.size ?? 0) > 0; if (hasTrace) return; - throw new AppError('COMMAND_FAILED', 'xctrace produced no trace data', { + throw new AppError('COMMAND_FAILED', context.message, { 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.', + hint: context.hint, }); } @@ -468,3 +556,16 @@ function buildAppleXctracePerfReport(params: { }, }; } + +export function resolveIosDevicePerfHint(stdout: string, stderr: string): string { + const devicectlHint = resolveIosDevicectlHint(stdout, stderr); + if (devicectlHint) return devicectlHint; + const text = `${stdout}\n${stderr}`.toLowerCase(); + if (text.includes('no device matched') || text.includes('failed to find device')) { + return IOS_DEVICECTL_DEFAULT_HINT; + } + if (text.includes('timed out')) { + return 'Keep the iOS device unlocked and connected by cable, keep the app active, then retry perf.'; + } + return 'Ensure the iOS device is unlocked, trusted, visible to xctrace, and the target app stays active while perf samples it.'; +} diff --git a/packages/platform-apple/src/core/perf-xml.ts b/packages/platform-apple/src/core/perf-xml.ts index 3b27494fe2..e7da1f0c54 100644 --- a/packages/platform-apple/src/core/perf-xml.ts +++ b/packages/platform-apple/src/core/perf-xml.ts @@ -1,6 +1,6 @@ import type { XmlNode } from '@agent-device/xml'; -export function findFirstXmlNode( +function findFirstXmlNode( nodes: XmlNode[], predicate: (node: XmlNode) => boolean, ): XmlNode | undefined { @@ -40,7 +40,7 @@ export function readSchemaColumns(document: XmlNode[], schemaName: string): stri .map((column) => readFirstChildText(column, 'mnemonic') ?? ''); } -export function parseDirectXmlNumber(element: XmlNode | undefined): number | null { +function parseDirectXmlNumber(element: XmlNode | undefined): number | null { if (!element || element.children.some((child) => child.name === 'sentinel')) return null; if (!element.text) return null; const value = Number(element.text); @@ -56,6 +56,48 @@ export function resolveXmlNumber( return parseDirectXmlNumber(element); } +type XmlProcess = { pid?: number; name?: string }; + +export type XmlReference = { + numberValue?: number | null; + process?: XmlProcess | null; +}; + +export function rememberXmlReferences( + elements: XmlNode[], + references: Map, +): void { + for (const element of elements) { + rememberXmlReferences(element.children, references); + if (!element.attributes.id) continue; + references.set(element.attributes.id, { + numberValue: parseDirectXmlNumber(element), + process: readDirectXmlProcess(element), + }); + } +} + +export function resolveXmlProcess( + element: XmlNode | undefined, + references: Map, +): XmlProcess | null { + if (!element) return null; + if (element.attributes.ref) return references.get(element.attributes.ref)?.process ?? null; + return readDirectXmlProcess(element); +} + +function readDirectXmlProcess(element: XmlNode | undefined): XmlProcess | null { + if (!element || element.children.some((child) => child.name === 'sentinel')) return null; + const pidNode = findFirstXmlNode(element.children, (child) => child.name === 'pid'); + const pid = parseDirectXmlNumber(pidNode); + const name = (element.attributes.fmt ?? '').replace(/\s+\(\d+\)$/, '').trim(); + if (pid === null && name.length === 0) return null; + return { + pid: pid ?? undefined, + name: name.length > 0 ? name : undefined, + }; +} + export function indexXmlNodesById(document: XmlNode[]): Map { return new Map( findAllXmlNodes(document, (node) => Boolean(node.attributes.id)).flatMap((node) => { diff --git a/packages/platform-apple/src/core/perf.ts b/packages/platform-apple/src/core/perf.ts index 5b68e16eb8..ab246f8eff 100644 --- a/packages/platform-apple/src/core/perf.ts +++ b/packages/platform-apple/src/core/perf.ts @@ -8,35 +8,33 @@ import { type PublicPlatform, } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; -import { parseXmlDocumentSync, type XmlNode } from '@agent-device/xml'; -import { - execFailureDetails, - requireExecSuccess, - type ExecResult, -} from '@agent-device/host-kit/command'; -import { splitNonEmptyTrimmedLines } from '@agent-device/kernel/record'; +import { parseXmlDocumentSync } from '@agent-device/xml'; +import { execFailureDetails, type ExecResult } from '@agent-device/host-kit/command'; import { uniqueStrings } from '@agent-device/kernel/collections'; import { ensureHostDirectory, hostFileStat, makeHostTemporaryDirectory, - readHostDirectory, - readHostTextFile, removeHostPath, } from '@agent-device/host-kit/host-file'; -import { IOS_DEVICECTL_DEFAULT_HINT, resolveIosDevicectlHint } from './devicectl.ts'; import type { IosDeviceProcessInfo } from './app-info.ts'; -import { resolveIosPhysicalDeviceControl } from './physical-device-control.ts'; -import { readInfoPlistString } from './plist.ts'; import { buildSimctlArgsForDevice } from './simctl.ts'; import { runAppleToolCommand, runXcrun } from './tool-provider.ts'; import { findAllXmlNodes, - findFirstXmlNode, - parseDirectXmlNumber, readSchemaColumns, + rememberXmlReferences, resolveXmlNumber, + resolveXmlProcess, + type XmlReference, } from './perf-xml.ts'; +import { + readAppleProcessSamples, + resolveAppleExecutable, + resolveIosDevicePerfTarget, + type AppleProcessSample, +} from './perf-target.ts'; +import { exportAppleXctraceData, recordAppleXctraceTimedTrace } from './perf-xctrace.ts'; import { APPLE_FRAME_SAMPLE_DESCRIPTION, APPLE_FRAME_SAMPLE_METHOD, @@ -48,15 +46,9 @@ const APPLE_MEMORY_SAMPLE_METHOD = 'ps-process-snapshot'; const IOS_DEVICE_MEMORY_SAMPLE_METHOD = 'xctrace-activity-monitor'; const APPLE_MEMGRAPH_SNAPSHOT_METHOD = 'leaks-output-graph'; -const APPLE_PERF_TIMEOUT_MS = 15_000; const APPLE_MEMORY_SNAPSHOT_TIMEOUT_MS = 120_000; -// Physical device tracing can take materially longer to initialize than the 1s sample window. -const IOS_DEVICE_PERF_RECORD_TIMEOUT_MS = 60_000; -const IOS_DEVICE_PERF_EXPORT_TIMEOUT_MS = 15_000; const IOS_DEVICE_PERF_TRACE_DURATION = '1s'; const IOS_DEVICE_FRAME_TRACE_DURATION = '2s'; -const IOS_DEVICE_TRACE_RECORD_MAX_ATTEMPTS = 3; -const IOS_DEVICE_TRACE_RECORD_RETRY_DELAY_MS = 1_500; export type AppleMemoryPerfSample = { residentMemoryKb: number; @@ -86,13 +78,6 @@ export type AppleMemorySnapshotResult = support: ReturnType; }; -export type AppleProcessSample = { - pid: number; - cpuPercent: number; - rssKb: number; - command: string; -}; - type IosDevicePerfProcessSample = { pid: number; processName: string; @@ -112,16 +97,6 @@ type IosDeviceFramePerfCapture = { displayInfoXml?: string; }; -type IosDeviceTraceRecord = { - startedAt: string; - endedAt: string; - capturedAtMs: number; -}; - -type IosDeviceTraceRecordAttempt = IosDeviceTraceRecord & { - result: ExecResult; -}; - export async function sampleAppleMemoryPerf( device: DeviceInfo, appBundleId: string, @@ -427,246 +402,53 @@ async function captureIosDeviceFramePerf( ): Promise { const tempDir = await makeHostTemporaryDirectory('agent-device-ios-frame-perf-'); const tracePath = path.join(tempDir, 'animation-hitches.trace'); - const hitchesPath = path.join(tempDir, 'hitches.xml'); - const frameLifetimesPath = path.join(tempDir, 'frame-lifetimes.xml'); - const displayInfoPath = path.join(tempDir, 'display-info.xml'); try { - const record = await recordIosDeviceTrace({ + const record = await recordAppleXctraceTimedTrace({ device, appBundleId, tracePath, template: 'Animation Hitches', - duration: IOS_DEVICE_FRAME_TRACE_DURATION, - targetPids: processes.map((processInfo) => processInfo.pid), - validateTraceOutput: true, + timeLimit: IOS_DEVICE_FRAME_TRACE_DURATION, + target: processes.map((processInfo) => processInfo.pid), + requireTraceData: true, failureMessage: `Failed to record iOS frame-health sample for ${appBundleId}`, }); - await exportIosDevicePerfTable(device, appBundleId, tracePath, 'hitches', hitchesPath); - await exportIosDevicePerfTable( - device, - appBundleId, - tracePath, - 'hitches-frame-lifetimes', - frameLifetimesPath, - ); - const hasDisplayInfo = await exportOptionalIosDevicePerfTable( - device, - appBundleId, - tracePath, - 'device-display-info', - displayInfoPath, - ); + const exportTable = async (schema: string, fileName: string) => + await exportIosDevicePerfTable( + device, + appBundleId, + tracePath, + schema, + path.join(tempDir, fileName), + ); return { windowStartedAt: record.startedAt, windowEndedAt: record.endedAt, - hitchesXml: await readHostTextFile(hitchesPath), - frameLifetimesXml: await readHostTextFile(frameLifetimesPath), - displayInfoXml: hasDisplayInfo ? await readHostTextFile(displayInfoPath) : undefined, + hitchesXml: await exportTable('hitches', 'hitches.xml'), + frameLifetimesXml: await exportTable('hitches-frame-lifetimes', 'frame-lifetimes.xml'), + displayInfoXml: await exportTable('device-display-info', 'display-info.xml').catch( + () => undefined, + ), }; } finally { await removeHostPath(tempDir).catch(() => {}); } } -async function recordIosDeviceTrace(params: { - device: DeviceInfo; - appBundleId: string; - tracePath: string; - template: 'Activity Monitor' | 'Animation Hitches'; - duration: string; - targetPids?: number[]; - allProcesses?: boolean; - validateTraceOutput?: boolean; - failureMessage: string; -}): Promise { - const { device, appBundleId, tracePath, template, duration } = params; - const targetArgs = params.allProcesses - ? ['--all-processes'] - : (params.targetPids ?? []).flatMap((pid) => ['--attach', String(pid)]); - const recordArgs = [ - 'xctrace', - 'record', - '--template', - template, - '--device', - device.id, - ...targetArgs, - '--time-limit', - duration, - '--output', - tracePath, - '--quiet', - '--no-prompt', - ]; - const record = await runIosDeviceTraceRecord(recordArgs, params.tracePath); - if (record.result.exitCode === 0) { - if (params.validateTraceOutput) { - await assertUsableTraceOutput(params, record.result.stdout, record.result.stderr); - } - return { - startedAt: record.startedAt, - endedAt: record.endedAt, - capturedAtMs: record.capturedAtMs, - }; - } - throw new AppError( - 'COMMAND_FAILED', - params.failureMessage, - execFailureDetails(record.result, { - cmd: 'xcrun', - args: recordArgs, - appBundleId, - deviceId: device.id, - hint: resolveIosDevicePerfHint(record.result.stdout, record.result.stderr), - }), - ); -} - -async function runIosDeviceTraceRecord( - recordArgs: string[], - tracePath: string, -): Promise { - let lastAttempt: IosDeviceTraceRecordAttempt | 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 startedAt = new Date().toISOString(); - const result = await runXcrun(recordArgs, { - allowFailure: true, - timeoutMs: IOS_DEVICE_PERF_RECORD_TIMEOUT_MS, - }); - lastAttempt = { - result, - startedAt, - endedAt: new Date().toISOString(), - capturedAtMs: Date.now(), - }; - if (result.exitCode === 0 || !isRetryableIosDeviceTraceRecordFailure(result)) { - return lastAttempt; - } - } - return lastAttempt as IosDeviceTraceRecordAttempt; -} - -export function isRetryableIosDeviceTraceRecordFailure(result: { - stdout: string; - stderr: string; -}): boolean { - const text = `${result.stdout}\n${result.stderr}`.toLowerCase(); - return ( - text.includes('_lockkperf') || - text.includes('could not lock kperf') || - text.includes('likely another session just started') - ); -} - -export async function prepareAppleTraceRecordRetry( - tracePath: string, - attempt: number, - retryDelayMs: number, -): Promise { - if (attempt <= 1) return; - await removeHostPath(tracePath).catch(() => {}); - await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); -} - -async function assertUsableTraceOutput( - params: { - device: DeviceInfo; - appBundleId: string; - tracePath: string; - failureMessage: string; - }, - stdout: string, - stderr: string, -): Promise { - const stat = await hostFileStat(params.tracePath).catch(() => null); - const hasTrace = - stat?.isDirectory() === true - ? (await readHostDirectory(params.tracePath).catch(() => [])).length > 0 - : (stat?.size ?? 0) > 0; - if (hasTrace) return; - throw new AppError('COMMAND_FAILED', `${params.failureMessage}: xctrace produced no trace data`, { - tracePath: params.tracePath, - appBundleId: params.appBundleId, - deviceId: params.device.id, - stdout, - stderr, - hint: 'Keep the iOS device unlocked and connected by cable, keep the app active, then retry perf.', - }); -} - async function exportIosDevicePerfTable( device: DeviceInfo, appBundleId: string, tracePath: string, schema: string, - outputPath: string, -): Promise { - const exportArgs = [ - 'xctrace', - 'export', - '--input', + outPath: string, +): Promise { + return await exportAppleXctraceData({ tracePath, - '--xpath', - `/trace-toc/run/data/table[@schema="${schema}"]`, - '--output', - outputPath, - ]; - requireExecSuccess( - await runXcrun(exportArgs, { - allowFailure: true, - timeoutMs: IOS_DEVICE_PERF_EXPORT_TIMEOUT_MS, - }), - `Failed to export iOS device ${schema} data`, - (exportResult) => ({ - cmd: 'xcrun', - args: exportArgs, - appBundleId, - deviceId: device.id, - hint: resolveIosDevicePerfHint(exportResult.stdout, exportResult.stderr), - }), - ); -} - -async function exportOptionalIosDevicePerfTable( - device: DeviceInfo, - appBundleId: string, - tracePath: string, - schema: string, - outputPath: string, -): Promise { - try { - await exportIosDevicePerfTable(device, appBundleId, tracePath, schema, outputPath); - return true; - } catch { - return false; - } -} - -export function parseApplePsOutput(stdout: string): AppleProcessSample[] { - const rows: AppleProcessSample[] = []; - for (const line of splitNonEmptyTrimmedLines(stdout)) { - const match = line.match(/^(\d+)\s+([0-9]+(?:\.[0-9]+)?)\s+(\d+)\s+(.+)$/); - if (!match) continue; - const [pidText, cpuText, rssText, commandText] = match.slice(1); - if ( - pidText === undefined || - cpuText === undefined || - rssText === undefined || - commandText === undefined - ) { - continue; - } - const pid = Number(pidText); - const cpuPercent = Number(cpuText); - const rssKb = Number(rssText); - const command = commandText.trim(); - if (!Number.isFinite(pid) || !Number.isFinite(cpuPercent) || !Number.isFinite(rssKb)) { - continue; - } - rows.push({ pid, cpuPercent, rssKb, command }); - } - return rows; + outPath, + query: { schema }, + failureMessage: `Failed to export iOS device ${schema} data`, + failureDetails: { appBundleId, deviceId: device.id }, + }); } async function parseIosDevicePerfTable(xml: string): Promise { @@ -690,36 +472,14 @@ async function parseIosDevicePerfTable(xml: string): Promise node.name === 'row'); const samples: IosDevicePerfProcessSample[] = []; - const references = new Map< - string, - { - numberValue?: number | null; - processName?: string | null; - } - >(); + const references = new Map(); for (const row of rows) { const elements = row.children; if (elements.length === 0) continue; - for (const element of elements) { - const nestedPid = findFirstXmlNode( - element.children, - (child) => child.name === 'pid' && typeof child.attributes.id === 'string', - ); - if (nestedPid?.attributes.id) { - const pidValue = Number(nestedPid.text); - references.set(nestedPid.attributes.id, { - numberValue: Number.isFinite(pidValue) ? pidValue : null, - }); - } - if (!element.attributes.id) continue; - references.set(element.attributes.id, { - numberValue: parseDirectXmlNumber(element), - processName: readDirectProcessNameFromXml(element), - }); - } + rememberXmlReferences(elements, references); const pid = resolveXmlNumber(elements[pidIndex], references); - const processName = resolveProcessName(elements[processIndex], references); + const processName = resolveXmlProcess(elements[processIndex], references)?.name; if (pid === null || !Number.isFinite(pid) || !processName) continue; samples.push({ pid, @@ -730,32 +490,6 @@ async function parseIosDevicePerfTable(xml: string): Promise { - const appPath = isMacOs(device) - ? await resolveMacOsBundlePath(appBundleId) - : await resolveIosSimulatorAppContainer(device, appBundleId); - const infoPlistPath = isMacOs(device) - ? path.join(appPath, 'Contents', 'Info.plist') - : path.join(appPath, 'Info.plist'); - const executableName = await readInfoPlistString(infoPlistPath, 'CFBundleExecutable'); - if (!executableName) { - throw new AppError('COMMAND_FAILED', `Failed to resolve executable for ${appBundleId}`, { - appBundleId, - appPath, - }); - } - - return { - executableName, - executablePath: isMacOs(device) - ? path.join(appPath, 'Contents', 'MacOS', executableName) - : path.join(appPath, executableName), - }; -} - async function sampleIosDeviceMemoryPerf( device: DeviceInfo, appBundleId: string, @@ -784,53 +518,31 @@ async function sampleIosDeviceMemoryPerf( }); } -export async function resolveIosDevicePerfTarget( - device: DeviceInfo, - appBundleId: string, -): Promise { - const { appBundleUrl, processes } = await resolveIosPhysicalDeviceControl( - device, - ).resolveAppProcesses(device, appBundleId); - const appBundlePath = fileURLToPath(appBundleUrl); - if (processes.length === 0) { - throw new AppError('COMMAND_FAILED', `No running process found for ${appBundleId}`, { - appBundleId, - deviceId: device.id, - appBundlePath, - hint: 'Run open for this session again to ensure the iOS app is active, then retry perf.', - }); - } - - return processes; -} - async function captureIosDevicePerfTable( device: DeviceInfo, appBundleId: string, ): Promise { const tempDir = await makeHostTemporaryDirectory('agent-device-ios-perf-'); const tracePath = path.join(tempDir, 'sample.trace'); - const exportPath = path.join(tempDir, 'activity-monitor-process-live.xml'); try { - const record = await recordIosDeviceTrace({ + const record = await recordAppleXctraceTimedTrace({ device, appBundleId, tracePath, template: 'Activity Monitor', - duration: IOS_DEVICE_PERF_TRACE_DURATION, - allProcesses: true, + timeLimit: IOS_DEVICE_PERF_TRACE_DURATION, + target: 'all-processes', failureMessage: `Failed to record iOS device Activity Monitor sample for ${appBundleId}`, }); - await exportIosDevicePerfTable( - device, - appBundleId, - tracePath, - 'activity-monitor-process-live', - exportPath, - ); return { capturedAtMs: record.capturedAtMs, - xml: await readHostTextFile(exportPath), + xml: await exportIosDevicePerfTable( + device, + appBundleId, + tracePath, + 'activity-monitor-process-live', + path.join(tempDir, 'activity-monitor-process-live.xml'), + ), }; } finally { await removeHostPath(tempDir).catch(() => {}); @@ -891,85 +603,6 @@ function summarizeIosDeviceMemorySnapshot( }; } -async function resolveMacOsBundlePath(appBundleId: string): Promise { - const query = `kMDItemCFBundleIdentifier == "${appBundleId.replaceAll('"', String.raw`\"`)}"`; - const result = requireExecSuccess( - await runAppleToolCommand('mdfind', [query], { - allowFailure: true, - timeoutMs: APPLE_PERF_TIMEOUT_MS, - }), - `Failed to resolve macOS app bundle for ${appBundleId}`, - { appBundleId }, - ); - - const bundlePath = result.stdout - .split('\n') - .map((entry) => entry.trim()) - .find((entry) => entry.endsWith('.app')); - if (!bundlePath) { - throw new AppError('APP_NOT_INSTALLED', `No macOS app found for ${appBundleId}`, { - appBundleId, - }); - } - return bundlePath; -} - -async function resolveIosSimulatorAppContainer( - device: DeviceInfo, - appBundleId: string, -): Promise { - const args = buildSimctlArgsForDevice(device, [ - 'get_app_container', - device.id, - appBundleId, - 'app', - ]); - const result = requireExecSuccess( - await runXcrun(args, { - allowFailure: true, - timeoutMs: APPLE_PERF_TIMEOUT_MS, - }), - `Failed to resolve iOS simulator app container for ${appBundleId}`, - { - appBundleId, - hint: 'Ensure the iOS simulator app is installed and booted, then retry perf.', - }, - ); - const appPath = result.stdout.trim(); - if (appPath.length === 0) { - throw new AppError( - 'APP_NOT_INSTALLED', - `No iOS simulator app container found for ${appBundleId}`, - { - appBundleId, - }, - ); - } - return appPath; -} - -export async function readAppleProcessSamples( - device: DeviceInfo, - executable: { executableName: string; executablePath?: string }, -): Promise { - const args = isMacOs(device) - ? ['-axo', 'pid=,%cpu=,rss=,command='] - : buildSimctlArgsForDevice(device, [ - 'spawn', - device.id, - 'ps', - '-axo', - 'pid=,%cpu=,rss=,command=', - ]); - const result = isMacOs(device) - ? await runAppleToolCommand('ps', args, { timeoutMs: APPLE_PERF_TIMEOUT_MS }) - : await runAppleSimulatorProcessCommand(args); - const { matchesAppleExecutableProcess } = await import('./perf-process-identity.ts'); - return parseApplePsOutput(result.stdout).filter((processInfo) => - matchesAppleExecutableProcess(processInfo.command, executable), - ); -} - function readProcessCommandToken(command: string): string { const [token = ''] = command.trim().split(/\s+/, 1); return token; @@ -1049,17 +682,6 @@ 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 buildAppleMemoryPerfSample(args: { residentMemoryKb: number; measuredAt: string; @@ -1074,36 +696,6 @@ function buildAppleMemoryPerfSample(args: { }; } -function readDirectProcessNameFromXml(element: XmlNode | undefined): string | null { - const fmt = element?.attributes.fmt?.trim() ?? ''; - if (!fmt) return null; - return fmt.replace(/\s+\(\d+\)$/, '').trim(); -} - -function resolveProcessName( - element: XmlNode | undefined, - references: Map, -): string | null { - if (!element) return null; - if (element.attributes.ref) { - return references.get(element.attributes.ref)?.processName ?? null; - } - return readDirectProcessNameFromXml(element); -} - -export function resolveIosDevicePerfHint(stdout: string, stderr: string): string { - const devicectlHint = resolveIosDevicectlHint(stdout, stderr); - if (devicectlHint) return devicectlHint; - const text = `${stdout}\n${stderr}`.toLowerCase(); - if (text.includes('no device matched') || text.includes('failed to find device')) { - return IOS_DEVICECTL_DEFAULT_HINT; - } - if (text.includes('timed out')) { - return 'Keep the iOS device unlocked and connected by cable, keep the app active, then retry perf.'; - } - return 'Ensure the iOS device is unlocked, trusted, visible to xctrace, and the target app stays active while perf samples it.'; -} - function maxNullableNumber(left: number | null, right: number | null): number | null { if (left === null) return right; if (right === null) return left; diff --git a/packages/platform-apple/src/core/physical-device-coredevice.ts b/packages/platform-apple/src/core/physical-device-coredevice.ts index bc00fa9dc4..ec983782cb 100644 --- a/packages/platform-apple/src/core/physical-device-coredevice.ts +++ b/packages/platform-apple/src/core/physical-device-coredevice.ts @@ -1,25 +1,18 @@ -import path from 'node:path'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; -import { execFailureDetails } from '@agent-device/host-kit/command'; -import { - hostTemporaryDirectory, - readHostTextFile, - removeHostPath, -} from '@agent-device/host-kit/host-file'; -import { hostProcessId } from '@agent-device/host-kit/process'; +import { execFailureDetails, type ExecResult } from '@agent-device/host-kit/command'; import { IOS_DEVICE_DEVELOPER_DISK_IMAGE_HINT, IOS_DEVICE_DEVELOPER_MODE_OFF_HINT, IOS_DEVICECTL_DEFAULT_HINT, resolveIosDevicectlHint, runIosDevicectl, + runIosDevicectlJsonRequest, } from './devicectl.ts'; import { IOS_DEVICE_READY_COMMAND_TIMEOUT_BUFFER_MS, IOS_DEVICE_READY_TIMEOUT_MS, } from './physical-device-constants.ts'; -import { runXcrun } from './tool-provider.ts'; const IOS_RUNNER_DEVICE_INFO_TIMEOUT_MS = 10_000; @@ -50,18 +43,17 @@ export async function ensureCoreDeviceReady( IOS_DEVICE_READY_COMMAND_TIMEOUT_BUFFER_MS, signal, ); - const { result, parsed } = probe; - if (result.exitCode === 0) { - if (!parsed.parsed) { - throw new AppError('COMMAND_FAILED', 'iOS device readiness probe failed', { - kind: 'probe_inconclusive', - deviceId: device.id, - stdout: result.stdout, - stderr: result.stderr, - hint: 'CoreDevice returned success but readiness JSON output was missing or invalid. Retry; if it persists restart Xcode and the iOS device.', - }); - } - const tunnelState = parsed.tunnelState?.toLowerCase(); + if (probe.status === 'unreadable') { + throw new AppError('COMMAND_FAILED', 'iOS device readiness probe failed', { + kind: 'probe_inconclusive', + deviceId: device.id, + stdout: probe.result.stdout, + stderr: probe.result.stderr, + hint: 'CoreDevice returned success but readiness JSON output was missing or invalid. Retry; if it persists restart Xcode and the iOS device.', + }); + } + if (probe.status === 'reported') { + const tunnelState = probe.details.tunnelState?.toLowerCase(); if (tunnelState === 'connecting') { throw new AppError('COMMAND_FAILED', 'iOS device is not ready for automation', { kind: 'not_ready', @@ -75,11 +67,11 @@ export async function ensureCoreDeviceReady( throw new AppError( 'COMMAND_FAILED', 'iOS device is not ready for automation', - execFailureDetails(result, { + execFailureDetails(probe.result, { kind: 'not_ready', deviceId: device.id, - tunnelState: parsed.tunnelState, - hint: resolveIosReadyHint(result.stdout, result.stderr), + tunnelState: probe.details.tunnelState, + hint: resolveIosReadyHint(probe.result.stdout, probe.result.stderr), }), ); } catch (error) { @@ -159,66 +151,54 @@ async function readIosDeviceDetails( const timeoutMs = Math.max(1, Math.min(IOS_RUNNER_DEVICE_INFO_TIMEOUT_MS, timeoutBudgetMs)); try { const probe = await runCoreDeviceDetails(device.id, timeoutMs, 0, signal); - if (probe.result.exitCode !== 0 || !probe.parsed.parsed) return null; - if (probe.parsed.outcome && probe.parsed.outcome !== 'success') return null; - const { parsed } = probe; - const { parsed: _parsed, ...details } = parsed; - return details; + if (probe.status !== 'reported') return null; + if (probe.details.outcome && probe.details.outcome !== 'success') return null; + return probe.details; } catch { return null; } } +/** + * `reported` when the command succeeded with a readable payload, `unreadable` when it succeeded + * without one, and `failed` when it exited non-zero, carrying whatever its failure payload reported. + */ +type CoreDeviceDetailsProbe = + | { status: 'reported'; details: IosDeviceDetails } + | { status: 'unreadable'; result: ExecResult } + | { status: 'failed'; result: ExecResult; details: IosDeviceDetails }; + async function runCoreDeviceDetails( deviceId: string, timeoutMs: number, commandTimeoutBufferMs = 0, signal?: AbortSignal, -): Promise<{ - result: Awaited>; - parsed: { parsed: boolean } & IosDeviceDetails; -}> { - const jsonPath = path.join( - hostTemporaryDirectory(), - `agent-device-coredevice-info-${hostProcessId()}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, - ); +): Promise { const timeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000)); - try { - const result = await runXcrun( - [ - 'devicectl', - 'device', - 'info', - 'details', - '--device', - deviceId, - '--json-output', - jsonPath, - '--timeout', - String(timeoutSeconds), - ], - { - allowFailure: true, - signal, - timeoutMs: timeoutMs + commandTimeoutBufferMs, - }, - ); - return { result, parsed: await readCoreDeviceDetails(jsonPath) }; - } finally { - await removeHostPath(jsonPath).catch(() => {}); - } -} - -async function readCoreDeviceDetails( - jsonPath: string, -): Promise<{ parsed: boolean } & IosDeviceDetails> { - try { - const payload = JSON.parse(await readHostTextFile(jsonPath)) as unknown; - const details = parseIosDeviceDetailsPayload(payload); - return { parsed: true, ...details }; - } catch { - return { parsed: false }; + const outcome = await runIosDevicectlJsonRequest({ + jsonPrefix: 'agent-device-coredevice-info', + args: [ + 'devicectl', + 'device', + 'info', + 'details', + '--device', + deviceId, + '--timeout', + String(timeoutSeconds), + ], + signal, + timeoutMs: timeoutMs + commandTimeoutBufferMs, + }); + if (outcome.ok) { + return { status: 'reported', details: parseIosDeviceDetailsPayload(outcome.payload) }; } + if (outcome.reason === 'unreadable-json') return { status: 'unreadable', result: outcome.result }; + return { + status: 'failed', + result: outcome.result, + details: parseIosDeviceDetailsPayload(outcome.payload), + }; } /** diff --git a/packages/platform-apple/src/perf-facade.ts b/packages/platform-apple/src/perf-facade.ts index a3afca34b1..4772c62638 100644 --- a/packages/platform-apple/src/perf-facade.ts +++ b/packages/platform-apple/src/perf-facade.ts @@ -3,16 +3,18 @@ export { buildAppleMemorySamplingMetadata, buildAppleMemorySnapshotSupport, captureAppleMemorySnapshot, - isRetryableIosDeviceTraceRecordFailure, - readAppleProcessSamples, - resolveAppleExecutable, - resolveIosDevicePerfTarget, - resolveIosDevicePerfHint, sampleAppleFramePerf, sampleAppleMemoryPerf, } from './core/perf.ts'; +export { + readAppleProcessSamples, + resolveAppleExecutable, + resolveIosDevicePerfTarget, +} from './core/perf-target.ts'; export { cleanupAppleXctracePerfCapture, + isRetryableIosDeviceTraceRecordFailure, + resolveIosDevicePerfHint, startAppleXctracePerfCapture, stopAppleXctracePerfCapture, writeAppleXctracePerfReport, From 418f0b7fe1e6ace2b52bc52d9ce2c05e38904a9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 16:15:38 +0200 Subject: [PATCH 2/4] refactor(platform-apple): tighten the xctrace record retry loop and shared process-name reader Co-Authored-By: Claude Opus 5.5 (1M context) --- .../platform-apple/src/core/perf-xctrace.ts | 51 +++++++++++-------- packages/platform-apple/src/core/perf-xml.ts | 5 +- packages/platform-apple/src/core/perf.ts | 31 ++++------- 3 files changed, 43 insertions(+), 44 deletions(-) diff --git a/packages/platform-apple/src/core/perf-xctrace.ts b/packages/platform-apple/src/core/perf-xctrace.ts index 26bbef5c83..ec8f94fdec 100644 --- a/packages/platform-apple/src/core/perf-xctrace.ts +++ b/packages/platform-apple/src/core/perf-xctrace.ts @@ -54,7 +54,7 @@ export type AppleXctracePerfMode = 'cpu-profile' | 'trace'; type AppleXctraceRecordTarget = number[] | 'all-processes'; -type AppleXctraceRecordAttempt = { started: T } | { failure: ExecResult }; +type AppleXctraceRecordAttempt = { recorded: T } | { failure: ExecResult }; export type AppleXctraceTimedRecord = { startedAt: string; @@ -135,9 +135,9 @@ export async function startAppleXctracePerfCapture(params: { failureMessage: `Failed to start Apple xctrace ${params.mode} capture for ${params.appBundleId}`, }, async (): Promise> => { - const started = runCmdBackground('xcrun', args, { allowFailure: true }); - const immediate = await waitForImmediateAppleXctraceExit(started.wait); - return immediate ? { failure: immediate } : { started }; + const recorded = runCmdBackground('xcrun', args, { allowFailure: true }); + const immediate = await waitForImmediateAppleXctraceExit(recorded.wait); + return immediate ? { failure: immediate } : { recorded }; }, ); return { @@ -282,7 +282,12 @@ export async function recordAppleXctraceTimedTrace(params: { }); if (result.exitCode !== 0) return { failure: result }; return { - started: { result, startedAt, endedAt: new Date().toISOString(), capturedAtMs: Date.now() }, + recorded: { + result, + startedAt, + endedAt: new Date().toISOString(), + capturedAtMs: Date.now(), + }, }; }, ); @@ -402,25 +407,29 @@ async function recordAppleXctraceWithRetry( }, attemptRecord: () => Promise>, ): Promise { - let failure: ExecResult = { stdout: '', stderr: '', exitCode: 1 }; - for (let attempt = 1; attempt <= IOS_DEVICE_TRACE_RECORD_MAX_ATTEMPTS; attempt += 1) { + for (let attempt = 1; ; attempt += 1) { await prepareAppleTraceRecordRetry(tracePath, attempt); const outcome = await attemptRecord(); - if ('started' in outcome) return outcome.started; - failure = outcome.failure; - if (!isRetryableIosDeviceTraceRecordFailure(failure)) break; + if ('recorded' in outcome) return outcome.recorded; + if ( + attempt < IOS_DEVICE_TRACE_RECORD_MAX_ATTEMPTS && + isRetryableIosDeviceTraceRecordFailure(outcome.failure) + ) { + continue; + } + const { failure } = outcome; + throw new AppError( + 'COMMAND_FAILED', + context.failureMessage, + execFailureDetails(failure, { + cmd: 'xcrun', + args, + appBundleId: context.appBundleId, + deviceId: context.device.id, + hint: resolveIosDevicePerfHint(failure.stdout, failure.stderr), + }), + ); } - throw new AppError( - 'COMMAND_FAILED', - context.failureMessage, - execFailureDetails(failure, { - cmd: 'xcrun', - args, - appBundleId: context.appBundleId, - deviceId: context.device.id, - hint: resolveIosDevicePerfHint(failure.stdout, failure.stderr), - }), - ); } export function isRetryableIosDeviceTraceRecordFailure(result: { diff --git a/packages/platform-apple/src/core/perf-xml.ts b/packages/platform-apple/src/core/perf-xml.ts index e7da1f0c54..a4a8821d68 100644 --- a/packages/platform-apple/src/core/perf-xml.ts +++ b/packages/platform-apple/src/core/perf-xml.ts @@ -90,7 +90,10 @@ function readDirectXmlProcess(element: XmlNode | undefined): XmlProcess | null { if (!element || element.children.some((child) => child.name === 'sentinel')) return null; const pidNode = findFirstXmlNode(element.children, (child) => child.name === 'pid'); const pid = parseDirectXmlNumber(pidNode); - const name = (element.attributes.fmt ?? '').replace(/\s+\(\d+\)$/, '').trim(); + const name = (element.attributes.fmt ?? '') + .trim() + .replace(/\s+\(\d+\)$/, '') + .trim(); if (pid === null && name.length === 0) return null; return { pid: pid ?? undefined, diff --git a/packages/platform-apple/src/core/perf.ts b/packages/platform-apple/src/core/perf.ts index ab246f8eff..59a0e0917f 100644 --- a/packages/platform-apple/src/core/perf.ts +++ b/packages/platform-apple/src/core/perf.ts @@ -413,20 +413,13 @@ async function captureIosDeviceFramePerf( requireTraceData: true, failureMessage: `Failed to record iOS frame-health sample for ${appBundleId}`, }); - const exportTable = async (schema: string, fileName: string) => - await exportIosDevicePerfTable( - device, - appBundleId, - tracePath, - schema, - path.join(tempDir, fileName), - ); + const context = { device, appBundleId, tracePath, tempDir }; return { windowStartedAt: record.startedAt, windowEndedAt: record.endedAt, - hitchesXml: await exportTable('hitches', 'hitches.xml'), - frameLifetimesXml: await exportTable('hitches-frame-lifetimes', 'frame-lifetimes.xml'), - displayInfoXml: await exportTable('device-display-info', 'display-info.xml').catch( + hitchesXml: await exportIosDevicePerfTable(context, 'hitches'), + frameLifetimesXml: await exportIosDevicePerfTable(context, 'hitches-frame-lifetimes'), + displayInfoXml: await exportIosDevicePerfTable(context, 'device-display-info').catch( () => undefined, ), }; @@ -436,18 +429,15 @@ async function captureIosDeviceFramePerf( } async function exportIosDevicePerfTable( - device: DeviceInfo, - appBundleId: string, - tracePath: string, + context: { device: DeviceInfo; appBundleId: string; tracePath: string; tempDir: string }, schema: string, - outPath: string, ): Promise { return await exportAppleXctraceData({ - tracePath, - outPath, + tracePath: context.tracePath, + outPath: path.join(context.tempDir, `${schema}.xml`), query: { schema }, failureMessage: `Failed to export iOS device ${schema} data`, - failureDetails: { appBundleId, deviceId: device.id }, + failureDetails: { appBundleId: context.appBundleId, deviceId: context.device.id }, }); } @@ -537,11 +527,8 @@ async function captureIosDevicePerfTable( return { capturedAtMs: record.capturedAtMs, xml: await exportIosDevicePerfTable( - device, - appBundleId, - tracePath, + { device, appBundleId, tracePath, tempDir }, 'activity-monitor-process-live', - path.join(tempDir, 'activity-monitor-process-live.xml'), ), }; } finally { From bb0b277ff20259bfb8fdd529526b87320307ae79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 17:42:34 +0200 Subject: [PATCH 3/4] fix(platform-apple): keep perf-target resolution lazy behind the perf facade perf-facade.ts, perf.ts, and perf-xctrace.ts all statically imported the newly split perf-target.ts, adding it as a genuinely new eager module to the facade's closure. Load it on demand at each call site instead, the same pattern already used by simctl-facade.ts and runner-operations-facade.ts, keeping the facade's process-target path implementation-lazy. --- .../platform-apple/src/core/perf-xctrace.ts | 7 ++--- packages/platform-apple/src/core/perf.ts | 12 ++++----- packages/platform-apple/src/perf-facade.ts | 26 +++++++++++++++---- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/packages/platform-apple/src/core/perf-xctrace.ts b/packages/platform-apple/src/core/perf-xctrace.ts index ec8f94fdec..44602afbc4 100644 --- a/packages/platform-apple/src/core/perf-xctrace.ts +++ b/packages/platform-apple/src/core/perf-xctrace.ts @@ -33,11 +33,6 @@ import { parseAppleTimeProfileSummary, type AppleTimeProfileFunction, } from './perf-time-profile.ts'; -import { - readAppleProcessSamples, - resolveAppleExecutable, - resolveIosDevicePerfTarget, -} from './perf-target.ts'; import { IOS_DEVICECTL_DEFAULT_HINT, resolveIosDevicectlHint } from './devicectl.ts'; import { runXcrun } from './tool-provider.ts'; @@ -348,6 +343,8 @@ async function resolveAppleXctracePerfTarget( hint: 'Android native profiling belongs to the Android perf rollout and is not implemented under Apple xctrace.', }); } + const { readAppleProcessSamples, resolveAppleExecutable, resolveIosDevicePerfTarget } = + await import('./perf-target.ts'); if (isIosFamily(device) && device.kind === 'device') { const processes = await resolveIosDevicePerfTarget(device, appBundleId); return { diff --git a/packages/platform-apple/src/core/perf.ts b/packages/platform-apple/src/core/perf.ts index 59a0e0917f..fb2b7489fa 100644 --- a/packages/platform-apple/src/core/perf.ts +++ b/packages/platform-apple/src/core/perf.ts @@ -28,12 +28,7 @@ import { resolveXmlProcess, type XmlReference, } from './perf-xml.ts'; -import { - readAppleProcessSamples, - resolveAppleExecutable, - resolveIosDevicePerfTarget, - type AppleProcessSample, -} from './perf-target.ts'; +import type { AppleProcessSample } from './perf-target.ts'; import { exportAppleXctraceData, recordAppleXctraceTimedTrace } from './perf-xctrace.ts'; import { APPLE_FRAME_SAMPLE_DESCRIPTION, @@ -105,6 +100,7 @@ export async function sampleAppleMemoryPerf( return await sampleIosDeviceMemoryPerf(device, appBundleId); } + const { readAppleProcessSamples, resolveAppleExecutable } = await import('./perf-target.ts'); const executable = await resolveAppleExecutable(device, appBundleId); const processes = await readAppleProcessSamples(device, executable); if (processes.length === 0) { @@ -290,6 +286,7 @@ export async function sampleAppleFramePerf( ); } + const { resolveIosDevicePerfTarget } = await import('./perf-target.ts'); const processes = await resolveIosDevicePerfTarget(device, appBundleId); const capture = await captureIosDeviceFramePerf(device, appBundleId, processes); return parseAppleFramePerfSample({ @@ -484,6 +481,7 @@ async function sampleIosDeviceMemoryPerf( device: DeviceInfo, appBundleId: string, ): Promise { + const { resolveIosDevicePerfTarget } = await import('./perf-target.ts'); const processes = await resolveIosDevicePerfTarget(device, appBundleId); const capture = await captureIosDevicePerfTable(device, appBundleId); const snapshot = summarizeIosDeviceMemorySnapshot( @@ -600,6 +598,7 @@ async function resolveAppleMemorySnapshotProcess( appBundleId: string, executable: { executableName: string; executablePath?: string }, ): Promise { + const { readAppleProcessSamples } = await import('./perf-target.ts'); const processes = await readAppleProcessSamples(device, executable); const processInfo = processes.sort((left, right) => right.rssKb - left.rssKb)[0]; if (processInfo) return processInfo; @@ -619,6 +618,7 @@ async function resolveAppleMemorySnapshotTarget( | Extract > { try { + const { resolveAppleExecutable } = await import('./perf-target.ts'); const executable = await resolveAppleExecutable(device, appBundleId); return { available: true, diff --git a/packages/platform-apple/src/perf-facade.ts b/packages/platform-apple/src/perf-facade.ts index 4772c62638..da69aebebf 100644 --- a/packages/platform-apple/src/perf-facade.ts +++ b/packages/platform-apple/src/perf-facade.ts @@ -6,11 +6,27 @@ export { sampleAppleFramePerf, sampleAppleMemoryPerf, } from './core/perf.ts'; -export { - readAppleProcessSamples, - resolveAppleExecutable, - resolveIosDevicePerfTarget, -} from './core/perf-target.ts'; +export async function readAppleProcessSamples( + ...args: Parameters<(typeof import('./core/perf-target.ts'))['readAppleProcessSamples']> +): ReturnType<(typeof import('./core/perf-target.ts'))['readAppleProcessSamples']> { + const { readAppleProcessSamples: run } = await import('./core/perf-target.ts'); + return run(...args); +} + +export async function resolveAppleExecutable( + ...args: Parameters<(typeof import('./core/perf-target.ts'))['resolveAppleExecutable']> +): ReturnType<(typeof import('./core/perf-target.ts'))['resolveAppleExecutable']> { + const { resolveAppleExecutable: run } = await import('./core/perf-target.ts'); + return run(...args); +} + +export async function resolveIosDevicePerfTarget( + ...args: Parameters<(typeof import('./core/perf-target.ts'))['resolveIosDevicePerfTarget']> +): ReturnType<(typeof import('./core/perf-target.ts'))['resolveIosDevicePerfTarget']> { + const { resolveIosDevicePerfTarget: run } = await import('./core/perf-target.ts'); + return run(...args); +} + export { cleanupAppleXctracePerfCapture, isRetryableIosDeviceTraceRecordFailure, From c5681af5ed57029e8d162c759341feb5e827c7f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 20:40:07 +0200 Subject: [PATCH 4/4] refactor(platform-apple): drop the unimported perf-target forwarders from the perf facade readAppleProcessSamples, resolveAppleExecutable and resolveIosDevicePerfTarget had no importer through @agent-device/platform-apple/perf; perf.ts and perf-xctrace.ts load perf-target.ts directly. The package is private and the async forwarders were added on this unreleased branch, so remove them and their .fallowrc.json entries instead of keeping them alive by allowlist. Co-Authored-By: Claude Opus 5.5 (1M context) --- .fallowrc.json | 3 --- packages/platform-apple/src/perf-facade.ts | 21 --------------------- 2 files changed, 24 deletions(-) diff --git a/.fallowrc.json b/.fallowrc.json index 117ad4dcb4..7c3e938e4d 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -115,9 +115,6 @@ "isRetryableIosDeviceTraceRecordFailure", "resolveIosDevicePerfHint", "captureAppleMemorySnapshot", - "readAppleProcessSamples", - "resolveAppleExecutable", - "resolveIosDevicePerfTarget", "sampleAppleFramePerf", "sampleAppleMemoryPerf", "cleanupAppleXctracePerfCapture", diff --git a/packages/platform-apple/src/perf-facade.ts b/packages/platform-apple/src/perf-facade.ts index da69aebebf..5f29dc1adb 100644 --- a/packages/platform-apple/src/perf-facade.ts +++ b/packages/platform-apple/src/perf-facade.ts @@ -6,27 +6,6 @@ export { sampleAppleFramePerf, sampleAppleMemoryPerf, } from './core/perf.ts'; -export async function readAppleProcessSamples( - ...args: Parameters<(typeof import('./core/perf-target.ts'))['readAppleProcessSamples']> -): ReturnType<(typeof import('./core/perf-target.ts'))['readAppleProcessSamples']> { - const { readAppleProcessSamples: run } = await import('./core/perf-target.ts'); - return run(...args); -} - -export async function resolveAppleExecutable( - ...args: Parameters<(typeof import('./core/perf-target.ts'))['resolveAppleExecutable']> -): ReturnType<(typeof import('./core/perf-target.ts'))['resolveAppleExecutable']> { - const { resolveAppleExecutable: run } = await import('./core/perf-target.ts'); - return run(...args); -} - -export async function resolveIosDevicePerfTarget( - ...args: Parameters<(typeof import('./core/perf-target.ts'))['resolveIosDevicePerfTarget']> -): ReturnType<(typeof import('./core/perf-target.ts'))['resolveIosDevicePerfTarget']> { - const { resolveIosDevicePerfTarget: run } = await import('./core/perf-target.ts'); - return run(...args); -} - export { cleanupAppleXctracePerfCapture, isRetryableIosDeviceTraceRecordFailure,