Skip to content

Commit e7d97f7

Browse files
authored
fix(apple): scope perf processes to the resolved app executable (#2406)
* fix(apple): bind perf process selection to the resolved executable * docs(perf): clarify that executable scoping includes sampling * fix(apple): load perf process identity only when sampling
1 parent 8d5ca68 commit e7d97f7

5 files changed

Lines changed: 95 additions & 35 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { matchesAppleExecutableProcess } from '../perf-process-identity.ts';
4+
5+
const executable = {
6+
executableName: 'Example',
7+
executablePath: '/Devices/selected/data/Example.app/Example',
8+
};
9+
10+
test('a resolved executable path excludes the same app on another simulator', () => {
11+
const processes = [
12+
{ pid: 11, command: executable.executablePath },
13+
{ pid: 22, command: '/Devices/another/data/Example.app/Example' },
14+
{ pid: 33, command: '/Applications/Example.app/Example' },
15+
{ pid: 44, command: 'Example' },
16+
];
17+
assert.deepEqual(
18+
processes
19+
.filter(({ command }) => matchesAppleExecutableProcess(command, executable))
20+
.map(({ pid }) => pid),
21+
[11],
22+
);
23+
});
24+
25+
test('exact paths accept arguments and spaces without accepting a neighboring executable', () => {
26+
const target = {
27+
executableName: 'Example App',
28+
executablePath: '/Apps/Example App.app/Example App',
29+
};
30+
assert.equal(matchesAppleExecutableProcess(`${target.executablePath} --argument`, target), true);
31+
assert.equal(matchesAppleExecutableProcess(`${target.executablePath}-helper`, target), false);
32+
});
33+
34+
test('the private var alias preserves the resolved app identity', () => {
35+
const target = { executableName: 'Example', executablePath: '/private/var/app/Example' };
36+
assert.equal(matchesAppleExecutableProcess('/var/app/Example --argument', target), true);
37+
assert.equal(matchesAppleExecutableProcess('/var/other/Example', target), false);
38+
assert.equal(
39+
matchesAppleExecutableProcess('/private/var/app/Example', {
40+
...target,
41+
executablePath: '/var/app/Example',
42+
}),
43+
true,
44+
);
45+
});
46+
47+
test('name-only matching applies when no executable path is known', () => {
48+
assert.equal(
49+
matchesAppleExecutableProcess('/Apps/Example --argument', { executableName: 'Example' }),
50+
true,
51+
);
52+
assert.equal(
53+
matchesAppleExecutableProcess('/Apps/Different', { executableName: 'Example' }),
54+
false,
55+
);
56+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import path from 'node:path';
2+
3+
export function matchesAppleExecutableProcess(
4+
command: string,
5+
executable: { executableName: string; executablePath?: string },
6+
): boolean {
7+
const [token = ''] = command.trim().split(/\s+/, 1);
8+
if (executable.executablePath) {
9+
for (const executablePath of buildAppleExecutablePathAliases(executable.executablePath)) {
10+
if (
11+
command === executablePath ||
12+
token === executablePath ||
13+
command.startsWith(`${executablePath} `)
14+
) {
15+
return true;
16+
}
17+
}
18+
return false;
19+
}
20+
return path.basename(token) === executable.executableName;
21+
}
22+
23+
function buildAppleExecutablePathAliases(executablePath: string): string[] {
24+
const aliases = [executablePath];
25+
if (executablePath.startsWith('/private/var/')) {
26+
aliases.push(executablePath.replace('/private/var/', '/var/'));
27+
} else if (executablePath.startsWith('/var/')) {
28+
aliases.push(executablePath.replace('/var/', '/private/var/'));
29+
}
30+
return aliases;
31+
}

packages/platform-apple/src/core/perf.ts

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -964,11 +964,17 @@ export async function readAppleProcessSamples(
964964
const result = isMacOs(device)
965965
? await runAppleToolCommand('ps', args, { timeoutMs: APPLE_PERF_TIMEOUT_MS })
966966
: await runAppleSimulatorProcessCommand(args);
967+
const { matchesAppleExecutableProcess } = await import('./perf-process-identity.ts');
967968
return parseApplePsOutput(result.stdout).filter((processInfo) =>
968969
matchesAppleExecutableProcess(processInfo.command, executable),
969970
);
970971
}
971972

973+
function readProcessCommandToken(command: string): string {
974+
const [token = ''] = command.trim().split(/\s+/, 1);
975+
return token;
976+
}
977+
972978
async function resolveAppleMemorySnapshotProcess(
973979
device: DeviceInfo,
974980
appBundleId: string,
@@ -1054,40 +1060,6 @@ async function runAppleSimulatorProcessCommand(args: string[]): Promise<ExecResu
10541060
});
10551061
}
10561062

1057-
function matchesAppleExecutableProcess(
1058-
command: string,
1059-
executable: { executableName: string; executablePath?: string },
1060-
): boolean {
1061-
const token = readProcessCommandToken(command);
1062-
if (executable.executablePath) {
1063-
for (const executablePath of buildAppleExecutablePathAliases(executable.executablePath)) {
1064-
if (
1065-
command === executablePath ||
1066-
token === executablePath ||
1067-
command.startsWith(`${executablePath} `)
1068-
) {
1069-
return true;
1070-
}
1071-
}
1072-
}
1073-
return path.basename(token) === executable.executableName;
1074-
}
1075-
1076-
function buildAppleExecutablePathAliases(executablePath: string): string[] {
1077-
const aliases = [executablePath];
1078-
if (executablePath.startsWith('/private/var/')) {
1079-
aliases.push(executablePath.replace('/private/var/', '/var/'));
1080-
} else if (executablePath.startsWith('/var/')) {
1081-
aliases.push(executablePath.replace('/var/', '/private/var/'));
1082-
}
1083-
return aliases;
1084-
}
1085-
1086-
function readProcessCommandToken(command: string): string {
1087-
const [token = ''] = command.trim().split(/\s+/, 1);
1088-
return token;
1089-
}
1090-
10911063
function buildAppleMemoryPerfSample(args: {
10921064
residentMemoryKb: number;
10931065
measuredAt: string;

src/commands/perf/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export const perfCommandFacet = defineCommandFacet({
7373
text: {
7474
summary: 'Check frames, memory, or native profiles',
7575
cliDetail:
76-
'Use perf frames for bounded frame-health evidence and perf memory sample for a compact process-memory reading. Apple xctrace and Android Simpleperf/Perfetto captures keep raw artifacts on disk; report produces bounded agent-readable evidence. For React render internals, use agent-device react-devtools.',
76+
'Use perf frames for bounded frame-health evidence and perf memory sample for a compact process-memory reading. On iOS simulators and macOS, process sampling and captures target the resolved app executable and exclude other copies with the same name. Apple xctrace and Android Simpleperf/Perfetto captures keep raw artifacts on disk; report produces bounded agent-readable evidence. For React render internals, use agent-device react-devtools.',
7777
mcpDetail:
7878
'For CPU profiles, start and stop write the raw artifact while report writes a compact summary; request the report when the task needs readable native CPU evidence. Profiling output is evidence only: compact state, artifact path, and size.',
7979
},

website/docs/docs/debugging-profiling.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ agent-device perf trace stop --kind perfetto --out app.perfetto-trace
181181
- For React Native JavaScript heap leaks, use `agent-device cdp` against the Metro CDP target instead of native/process memory samples; see the CDP section above.
182182
- Heap and memgraph artifacts are returned as paths plus compact metadata. Example default output: `Memory artifact (android-hprof): /tmp/app.hprof (42MB)`. They are not printed or embedded in JSON by default. heapprofd/native allocation tracing is deferred until Perfetto plumbing is available.
183183
- `perf cpu profile ... --kind xctrace` collects an Apple native `.trace`; `report` aggregates every run, returns at most ten weighted top functions in JSON, and prints five. `perf trace ... --kind xctrace` keeps trace data as an artifact.
184+
- On iOS simulators and macOS, process sampling and captures target the resolved app executable. Other running copies with the same executable name are excluded, including copies installed on another simulator.
184185
- Android native profiling uses `perf cpu profile ... --kind simpleperf`; its report likewise returns at most ten top functions and prints five. Android native trace capture uses `perf trace ... --kind perfetto`. These commands require an active Android app session and return artifact paths/summaries instead of dumping profile or trace contents.
185186
- Use the compact native perf result as agent evidence. For example, a successful Perfetto stop may return `state: "stopped"`, `outPath: "/tmp/app.perfetto-trace"`, `sizeBytes: 5392410`, and `method: "adb-shell-perfetto"` while the 5.3 MB raw trace remains on disk as the artifact.
186187
- Memory and Android frame-health availability depend on platform and whether the active session is bound to an app/package. HarmonyOS reports process RSS through HDC; CPU profiling, frame sampling, and memory-snapshot artifacts remain unavailable on the public HDC surface.

0 commit comments

Comments
 (0)