Skip to content

Commit 48fa16c

Browse files
committed
feat: add iOS device perf sampling
1 parent 72cc398 commit 48fa16c

9 files changed

Lines changed: 890 additions & 64 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ In non-JSON mode, core mutating commands print a short success acknowledgment so
6565

6666
- Startup timing is available on iOS and Android from `open` command round-trip sampling.
6767
- 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.
68-
- Apple app sessions on macOS and iOS simulators also sample CPU and memory from process snapshots resolved from the active app bundle ID.
69-
- Physical iOS devices still report CPU and memory as unavailable in this release.
68+
- Apple app sessions on macOS and iOS simulators sample CPU and memory from process snapshots resolved from the active app bundle ID.
69+
- 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.
7070

7171
## Where To Go Next
7272

src/daemon/handlers/__tests__/session.test.ts

Lines changed: 97 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ const mockDefaultReinstallOpsIos = vi.mocked(defaultReinstallOps.ios);
128128
const mockDefaultReinstallOpsAndroid = vi.mocked(defaultReinstallOps.android);
129129

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

2138-
test('perf degrades Apple cpu and memory metrics on physical iOS devices', async () => {
2139+
test('perf samples Apple cpu and memory metrics on physical iOS devices', async () => {
2140+
vi.useFakeTimers();
2141+
vi.setSystemTime(new Date('2026-04-01T10:00:00.000Z'));
21392142
const sessionStore = makeSessionStore();
21402143
const sessionName = 'perf-session-ios-device';
21412144
sessionStore.set(sessionName, {
@@ -2148,6 +2151,91 @@ test('perf degrades Apple cpu and memory metrics on physical iOS devices', async
21482151
}),
21492152
appBundleId: 'com.example.device',
21502153
});
2154+
let exportCount = 0;
2155+
mockRunCmd.mockImplementation(async (_cmd, args) => {
2156+
if (
2157+
args[0] === 'devicectl' &&
2158+
args[1] === 'device' &&
2159+
args[2] === 'info' &&
2160+
args[3] === 'apps'
2161+
) {
2162+
const outputIndex = args.indexOf('--json-output');
2163+
fs.writeFileSync(
2164+
args[outputIndex + 1]!,
2165+
JSON.stringify({
2166+
result: {
2167+
apps: [
2168+
{
2169+
bundleIdentifier: 'com.example.device',
2170+
name: 'Example Device App',
2171+
url: 'file:///private/var/containers/Bundle/Application/ABC123/ExampleDevice.app/',
2172+
},
2173+
],
2174+
},
2175+
}),
2176+
);
2177+
return { stdout: '', stderr: '', exitCode: 0 };
2178+
}
2179+
if (
2180+
args[0] === 'devicectl' &&
2181+
args[1] === 'device' &&
2182+
args[2] === 'info' &&
2183+
args[3] === 'processes'
2184+
) {
2185+
const outputIndex = args.indexOf('--json-output');
2186+
fs.writeFileSync(
2187+
args[outputIndex + 1]!,
2188+
JSON.stringify({
2189+
result: {
2190+
runningProcesses: [
2191+
{
2192+
executable:
2193+
'file:///private/var/containers/Bundle/Application/ABC123/ExampleDevice.app/ExampleDeviceApp',
2194+
processIdentifier: 4001,
2195+
},
2196+
],
2197+
},
2198+
}),
2199+
);
2200+
return { stdout: '', stderr: '', exitCode: 0 };
2201+
}
2202+
if (args[0] === 'xctrace' && args[1] === 'record') {
2203+
vi.setSystemTime(new Date(Date.now() + 1000));
2204+
return { stdout: '', stderr: '', exitCode: 0 };
2205+
}
2206+
if (args[0] === 'xctrace' && args[1] === 'export') {
2207+
const outputIndex = args.indexOf('--output');
2208+
exportCount += 1;
2209+
fs.writeFileSync(
2210+
args[outputIndex + 1]!,
2211+
[
2212+
'<?xml version="1.0"?>',
2213+
'<trace-query-result>',
2214+
'<node xpath="//trace-toc[1]/run[1]/data[1]/table[7]">',
2215+
'<schema name="activity-monitor-process-live">',
2216+
'<col><mnemonic>start</mnemonic></col>',
2217+
'<col><mnemonic>process</mnemonic></col>',
2218+
'<col><mnemonic>cpu-total</mnemonic></col>',
2219+
'<col><mnemonic>memory-real</mnemonic></col>',
2220+
'<col><mnemonic>pid</mnemonic></col>',
2221+
'</schema>',
2222+
'<row>',
2223+
'<start-time fmt="00:00.123">123</start-time>',
2224+
'<process fmt="ExampleDeviceApp (4001)"><pid fmt="4001">4001</pid></process>',
2225+
exportCount === 1
2226+
? '<duration-on-core fmt="100.00 ms">100000000</duration-on-core>'
2227+
: '<duration-on-core fmt="350.00 ms">350000000</duration-on-core>',
2228+
'<size-in-bytes fmt="8.00 MiB">8388608</size-in-bytes>',
2229+
'<pid fmt="4001">4001</pid>',
2230+
'</row>',
2231+
'</node>',
2232+
'</trace-query-result>',
2233+
].join(''),
2234+
);
2235+
return { stdout: '', stderr: '', exitCode: 0 };
2236+
}
2237+
return { stdout: '', stderr: '', exitCode: 0 };
2238+
});
21512239

21522240
const response = await handleSessionCommands({
21532241
req: {
@@ -2167,13 +2255,14 @@ test('perf degrades Apple cpu and memory metrics on physical iOS devices', async
21672255
if (!response?.ok) throw new Error('Expected perf response to succeed for physical iOS session');
21682256
const memory = (response.data?.metrics as any)?.memory;
21692257
const cpu = (response.data?.metrics as any)?.cpu;
2170-
expect(memory?.available).toBe(false);
2171-
expect(memory?.reason).toMatch(/not yet implemented for physical iOS devices/i);
2172-
expect(cpu?.available).toBe(false);
2173-
expect(cpu?.reason).toMatch(/not yet implemented for physical iOS devices/i);
2258+
expect(memory?.available).toBe(true);
2259+
expect(memory?.residentMemoryKb).toBe(8192);
2260+
expect(cpu?.available).toBe(true);
2261+
expect(cpu?.usagePercent).toBe(25);
2262+
expect(cpu?.matchedProcesses).toEqual(['ExampleDeviceApp']);
21742263
});
21752264

2176-
test('perf reports physical iOS cpu and memory as unsupported even without an app bundle id', async () => {
2265+
test('perf reports physical iOS cpu and memory as unavailable without an app bundle id', async () => {
21772266
const sessionStore = makeSessionStore();
21782267
const sessionName = 'perf-session-ios-device-no-bundle';
21792268
sessionStore.set(sessionName, {
@@ -2207,9 +2296,9 @@ test('perf reports physical iOS cpu and memory as unsupported even without an ap
22072296
const memory = (response.data?.metrics as any)?.memory;
22082297
const cpu = (response.data?.metrics as any)?.cpu;
22092298
expect(memory?.available).toBe(false);
2210-
expect(memory?.reason).toMatch(/not yet implemented for physical iOS devices/i);
2299+
expect(memory?.reason).toMatch(/no apple app bundle id is associated with this session/i);
22112300
expect(cpu?.available).toBe(false);
2212-
expect(cpu?.reason).toMatch(/not yet implemented for physical iOS devices/i);
2301+
expect(cpu?.reason).toMatch(/no apple app bundle id is associated with this session/i);
22132302
});
22142303

22152304
test('open URL on existing iOS session clears stale app bundle id', async () => {

src/daemon/handlers/session-perf.ts

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,7 @@ import {
88
sampleAndroidCpuPerf,
99
sampleAndroidMemoryPerf,
1010
} from '../../platforms/android/perf.ts';
11-
import {
12-
APPLE_DEVICE_PERF_UNAVAILABLE_REASON,
13-
buildAppleSamplingMetadata,
14-
sampleApplePerfMetrics,
15-
} from '../../platforms/ios/perf.ts';
11+
import { buildAppleSamplingMetadata, sampleApplePerfMetrics } from '../../platforms/ios/perf.ts';
1612
import {
1713
PERF_STARTUP_SAMPLE_LIMIT,
1814
PERF_UNAVAILABLE_REASON,
@@ -109,12 +105,6 @@ export async function buildPerfResponseData(
109105
return response;
110106
}
111107

112-
if (isUnsupportedAppleDevicePerfSession(session)) {
113-
response.metrics.memory = { available: false, reason: APPLE_DEVICE_PERF_UNAVAILABLE_REASON };
114-
response.metrics.cpu = { available: false, reason: APPLE_DEVICE_PERF_UNAVAILABLE_REASON };
115-
return response;
116-
}
117-
118108
if (!session.appBundleId) {
119109
const reason = buildMissingAppPerfReason(session);
120110
response.metrics.memory = { available: false, reason };
@@ -143,10 +133,6 @@ function buildMissingAppPerfReason(session: SessionState): string {
143133
return 'No Apple app bundle ID is associated with this session. Run open <app> first.';
144134
}
145135

146-
function isUnsupportedAppleDevicePerfSession(session: SessionState): boolean {
147-
return session.device.platform === 'ios' && session.device.kind === 'device';
148-
}
149-
150136
function buildPlatformSamplingMetadata(session: SessionState): Record<string, unknown> {
151137
if (session.device.platform === 'android') {
152138
return {

src/platforms/ios/__tests__/index.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import { withDiagnosticsScope } from '../../../utils/diagnostics.ts';
7878
import { AppError } from '../../../utils/errors.ts';
7979
import { runCmd } from '../../../utils/exec.ts';
8080
import { retryWithPolicy } from '../../../utils/retry.ts';
81+
import { parseIosDeviceProcessesPayload } from '../devicectl.ts';
8182

8283
const IOS_TEST_DEVICE: DeviceInfo = {
8384
platform: 'ios',
@@ -1585,6 +1586,7 @@ test('parseIosDeviceAppsPayload maps devicectl app entries', () => {
15851586
{
15861587
bundleIdentifier: 'com.apple.Maps',
15871588
name: 'Maps',
1589+
url: 'file:///Applications/Maps.app/',
15881590
},
15891591
{
15901592
bundleIdentifier: 'com.example.NoName',
@@ -1597,9 +1599,11 @@ test('parseIosDeviceAppsPayload maps devicectl app entries', () => {
15971599
assert.deepEqual(apps[0], {
15981600
bundleId: 'com.apple.Maps',
15991601
name: 'Maps',
1602+
url: 'file:///Applications/Maps.app/',
16001603
});
16011604
assert.equal(apps[1].bundleId, 'com.example.NoName');
16021605
assert.equal(apps[1].name, 'com.example.NoName');
1606+
assert.equal(apps[1].url, undefined);
16031607
});
16041608

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

1618+
test('parseIosDeviceProcessesPayload maps running process entries', () => {
1619+
const processes = parseIosDeviceProcessesPayload({
1620+
result: {
1621+
runningProcesses: [
1622+
{
1623+
executable: 'file:///private/var/containers/Bundle/Application/ABC123/Demo.app/Demo',
1624+
processIdentifier: 421,
1625+
},
1626+
{
1627+
executable: 'file:///usr/libexec/backboardd',
1628+
processIdentifier: 72,
1629+
},
1630+
],
1631+
},
1632+
});
1633+
1634+
assert.deepEqual(processes, [
1635+
{
1636+
executable: 'file:///private/var/containers/Bundle/Application/ABC123/Demo.app/Demo',
1637+
pid: 421,
1638+
},
1639+
{
1640+
executable: 'file:///usr/libexec/backboardd',
1641+
pid: 72,
1642+
},
1643+
]);
1644+
});
1645+
16141646
test('resolveIosApp resolves app display name on iOS physical devices', async () => {
16151647
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-app-resolve-'));
16161648
const xcrunPath = path.join(tmpDir, 'xcrun');

0 commit comments

Comments
 (0)