Skip to content
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ In non-JSON mode, core mutating commands print a short success acknowledgment so

- Startup timing is available on iOS and Android from `open` command round-trip sampling.
- Android app sessions also sample CPU (`adb shell dumpsys cpuinfo`) and memory (`adb shell dumpsys meminfo <package>`) when the session has an active app package context.
- Apple app sessions on macOS and iOS simulators also sample CPU and memory from process snapshots resolved from the active app bundle ID.
- Physical iOS devices still report CPU and memory as unavailable in this release.
- Apple app sessions on macOS and iOS simulators sample CPU and memory from process snapshots resolved from the active app bundle ID.
- Physical iOS devices sample CPU and memory from a short `xcrun xctrace` Activity Monitor capture against the connected device, so `perf` can take a few seconds longer there than on simulators or macOS.

## Where To Go Next

Expand Down
105 changes: 97 additions & 8 deletions src/daemon/handlers/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ const mockDefaultReinstallOpsIos = vi.mocked(defaultReinstallOps.ios);
const mockDefaultReinstallOpsAndroid = vi.mocked(defaultReinstallOps.android);

beforeEach(() => {
vi.useRealTimers();
mockDispatch.mockReset();
mockDispatch.mockResolvedValue({});
mockResolveTargetDevice.mockReset();
Expand Down Expand Up @@ -2135,7 +2136,9 @@ test('perf samples Apple cpu and memory metrics on iOS simulator app sessions',
expect(cpu?.matchedProcesses).toEqual(['ExampleSimExec']);
});

test('perf degrades Apple cpu and memory metrics on physical iOS devices', async () => {
test('perf samples Apple cpu and memory metrics on physical iOS devices', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-01T10:00:00.000Z'));
const sessionStore = makeSessionStore();
const sessionName = 'perf-session-ios-device';
sessionStore.set(sessionName, {
Expand All @@ -2148,6 +2151,91 @@ test('perf degrades Apple cpu and memory metrics on physical iOS devices', async
}),
appBundleId: 'com.example.device',
});
let exportCount = 0;
mockRunCmd.mockImplementation(async (_cmd, args) => {
if (
args[0] === 'devicectl' &&
args[1] === 'device' &&
args[2] === 'info' &&
args[3] === 'apps'
) {
const outputIndex = args.indexOf('--json-output');
fs.writeFileSync(
args[outputIndex + 1]!,
JSON.stringify({
result: {
apps: [
{
bundleIdentifier: 'com.example.device',
name: 'Example Device App',
url: 'file:///private/var/containers/Bundle/Application/ABC123/ExampleDevice.app/',
},
],
},
}),
);
return { stdout: '', stderr: '', exitCode: 0 };
}
if (
args[0] === 'devicectl' &&
args[1] === 'device' &&
args[2] === 'info' &&
args[3] === 'processes'
) {
const outputIndex = args.indexOf('--json-output');
fs.writeFileSync(
args[outputIndex + 1]!,
JSON.stringify({
result: {
runningProcesses: [
{
executable:
'file:///private/var/containers/Bundle/Application/ABC123/ExampleDevice.app/ExampleDeviceApp',
processIdentifier: 4001,
},
],
},
}),
);
return { stdout: '', stderr: '', exitCode: 0 };
}
if (args[0] === 'xctrace' && args[1] === 'record') {
vi.setSystemTime(new Date(Date.now() + 1000));
return { stdout: '', stderr: '', exitCode: 0 };
}
if (args[0] === 'xctrace' && args[1] === 'export') {
const outputIndex = args.indexOf('--output');
exportCount += 1;
fs.writeFileSync(
args[outputIndex + 1]!,
[
'<?xml version="1.0"?>',
'<trace-query-result>',
'<node xpath="//trace-toc[1]/run[1]/data[1]/table[7]">',
'<schema name="activity-monitor-process-live">',
'<col><mnemonic>start</mnemonic></col>',
'<col><mnemonic>process</mnemonic></col>',
'<col><mnemonic>cpu-total</mnemonic></col>',
'<col><mnemonic>memory-real</mnemonic></col>',
'<col><mnemonic>pid</mnemonic></col>',
'</schema>',
'<row>',
'<start-time fmt="00:00.123">123</start-time>',
'<process fmt="ExampleDeviceApp (4001)"><pid fmt="4001">4001</pid></process>',
exportCount === 1
? '<duration-on-core fmt="100.00 ms">100000000</duration-on-core>'
: '<duration-on-core fmt="350.00 ms">350000000</duration-on-core>',
'<size-in-bytes fmt="8.00 MiB">8388608</size-in-bytes>',
'<pid fmt="4001">4001</pid>',
'</row>',
'</node>',
'</trace-query-result>',
].join(''),
);
return { stdout: '', stderr: '', exitCode: 0 };
}
return { stdout: '', stderr: '', exitCode: 0 };
});

const response = await handleSessionCommands({
req: {
Expand All @@ -2167,13 +2255,14 @@ test('perf degrades Apple cpu and memory metrics on physical iOS devices', async
if (!response?.ok) throw new Error('Expected perf response to succeed for physical iOS session');
const memory = (response.data?.metrics as any)?.memory;
const cpu = (response.data?.metrics as any)?.cpu;
expect(memory?.available).toBe(false);
expect(memory?.reason).toMatch(/not yet implemented for physical iOS devices/i);
expect(cpu?.available).toBe(false);
expect(cpu?.reason).toMatch(/not yet implemented for physical iOS devices/i);
expect(memory?.available).toBe(true);
expect(memory?.residentMemoryKb).toBe(8192);
expect(cpu?.available).toBe(true);
expect(cpu?.usagePercent).toBe(25);
expect(cpu?.matchedProcesses).toEqual(['ExampleDeviceApp']);
});

test('perf reports physical iOS cpu and memory as unsupported even without an app bundle id', async () => {
test('perf reports physical iOS cpu and memory as unavailable without an app bundle id', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'perf-session-ios-device-no-bundle';
sessionStore.set(sessionName, {
Expand Down Expand Up @@ -2207,9 +2296,9 @@ test('perf reports physical iOS cpu and memory as unsupported even without an ap
const memory = (response.data?.metrics as any)?.memory;
const cpu = (response.data?.metrics as any)?.cpu;
expect(memory?.available).toBe(false);
expect(memory?.reason).toMatch(/not yet implemented for physical iOS devices/i);
expect(memory?.reason).toMatch(/no apple app bundle id is associated with this session/i);
expect(cpu?.available).toBe(false);
expect(cpu?.reason).toMatch(/not yet implemented for physical iOS devices/i);
expect(cpu?.reason).toMatch(/no apple app bundle id is associated with this session/i);
});

test('open URL on existing iOS session clears stale app bundle id', async () => {
Expand Down
16 changes: 1 addition & 15 deletions src/daemon/handlers/session-perf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,7 @@ import {
sampleAndroidCpuPerf,
sampleAndroidMemoryPerf,
} from '../../platforms/android/perf.ts';
import {
APPLE_DEVICE_PERF_UNAVAILABLE_REASON,
buildAppleSamplingMetadata,
sampleApplePerfMetrics,
} from '../../platforms/ios/perf.ts';
import { buildAppleSamplingMetadata, sampleApplePerfMetrics } from '../../platforms/ios/perf.ts';
import {
PERF_STARTUP_SAMPLE_LIMIT,
PERF_UNAVAILABLE_REASON,
Expand Down Expand Up @@ -109,12 +105,6 @@ export async function buildPerfResponseData(
return response;
}

if (isUnsupportedAppleDevicePerfSession(session)) {
response.metrics.memory = { available: false, reason: APPLE_DEVICE_PERF_UNAVAILABLE_REASON };
response.metrics.cpu = { available: false, reason: APPLE_DEVICE_PERF_UNAVAILABLE_REASON };
return response;
}

if (!session.appBundleId) {
const reason = buildMissingAppPerfReason(session);
response.metrics.memory = { available: false, reason };
Expand Down Expand Up @@ -143,10 +133,6 @@ function buildMissingAppPerfReason(session: SessionState): string {
return 'No Apple app bundle ID is associated with this session. Run open <app> first.';
}

function isUnsupportedAppleDevicePerfSession(session: SessionState): boolean {
return session.device.platform === 'ios' && session.device.kind === 'device';
}

function buildPlatformSamplingMetadata(session: SessionState): Record<string, unknown> {
if (session.device.platform === 'android') {
return {
Expand Down
32 changes: 32 additions & 0 deletions src/platforms/ios/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import { withDiagnosticsScope } from '../../../utils/diagnostics.ts';
import { AppError } from '../../../utils/errors.ts';
import { runCmd } from '../../../utils/exec.ts';
import { retryWithPolicy } from '../../../utils/retry.ts';
import { parseIosDeviceProcessesPayload } from '../devicectl.ts';

const IOS_TEST_DEVICE: DeviceInfo = {
platform: 'ios',
Expand Down Expand Up @@ -1585,6 +1586,7 @@ test('parseIosDeviceAppsPayload maps devicectl app entries', () => {
{
bundleIdentifier: 'com.apple.Maps',
name: 'Maps',
url: 'file:///Applications/Maps.app/',
},
{
bundleIdentifier: 'com.example.NoName',
Expand All @@ -1597,9 +1599,11 @@ test('parseIosDeviceAppsPayload maps devicectl app entries', () => {
assert.deepEqual(apps[0], {
bundleId: 'com.apple.Maps',
name: 'Maps',
url: 'file:///Applications/Maps.app/',
});
assert.equal(apps[1].bundleId, 'com.example.NoName');
assert.equal(apps[1].name, 'com.example.NoName');
assert.equal(apps[1].url, undefined);
});

test('parseIosDeviceAppsPayload ignores malformed entries', () => {
Expand All @@ -1611,6 +1615,34 @@ test('parseIosDeviceAppsPayload ignores malformed entries', () => {
assert.deepEqual(apps, []);
});

test('parseIosDeviceProcessesPayload maps running process entries', () => {
const processes = parseIosDeviceProcessesPayload({
result: {
runningProcesses: [
{
executable: 'file:///private/var/containers/Bundle/Application/ABC123/Demo.app/Demo',
processIdentifier: 421,
},
{
executable: 'file:///usr/libexec/backboardd',
processIdentifier: 72,
},
],
},
});

assert.deepEqual(processes, [
{
executable: 'file:///private/var/containers/Bundle/Application/ABC123/Demo.app/Demo',
pid: 421,
},
{
executable: 'file:///usr/libexec/backboardd',
pid: 72,
},
]);
});

test('resolveIosApp resolves app display name on iOS physical devices', async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-app-resolve-'));
const xcrunPath = path.join(tmpDir, 'xcrun');
Expand Down
Loading
Loading