Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

- Fixed (ios): `perf cpu profile report --kind xctrace` on Xcode 27 no longer fails with
`Apple xctrace CPU report contained no samples` on a trace that holds thousands of samples. Xcode
27 exports each `time-profile` sample stack as `<tagged-backtrace>` instead of `<backtrace>`, and
the parser read only the old element, so every row resolved no stack at all. Both spellings now
parse through the same `id`/`ref` resolution, so a profile recorded with an older Xcode reports
what it did before. (#2860)
- Changed (apple): a read-only runner command is resent inside the same request only when the
runner refused it as `RUNNER_BUSY`. Before, any `COMMAND_FAILED` carrying `details.retriable:
true` was sent up to three times. That flag tells a caller's own poll, such as `wait`, to try
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,58 @@ import assert from 'node:assert/strict';
import { test } from 'vitest';
import { parseAppleTimeProfileSummary } from '../perf-time-profile.ts';

test('reads Xcode 27 tagged-backtrace stacks and skips stack sentinels', () => {
// xctrace 27 renamed the `time-profile` stack element to `<tagged-backtrace>` and kept the same
// `id`/`ref` reuse over frames and binaries. These row shapes come from a real 27.1 export; the
// symbols, binaries, and weights here are synthetic.
const summary = parseAppleTimeProfileSummary(
`<trace-query-result><node>
<row>
<weight id="weight-1">1000000</weight>
<tagged-backtrace id="stack-1">
<frame id="frame-1" name="hot"><binary id="binary-1" name="App"/></frame>
<frame name="caller"><binary name="Framework"/></frame>
</tagged-backtrace>
</row>
<row><weight ref="weight-1"/><tagged-backtrace ref="stack-1"/></row>
<row>
<weight ref="weight-1"/>
<tagged-backtrace id="stack-2" truncated="YES"><frame ref="frame-1"/></tagged-backtrace>
</row>
<row>
<weight ref="weight-1"/>
<tagged-backtrace id="stack-3">
<frame id="frame-2" name="warm"><binary ref="binary-1"/></frame>
</tagged-backtrace>
</row>
<row><weight ref="weight-1"/><sentinel/></row>
</node></trace-query-result>`,
2,
);

// `hot` is sampled as a stack definition, through a `<tagged-backtrace ref>`, and through a
// truncated stack whose innermost frame is a `<frame ref>`. `warm` reaches its binary only
// through `<binary ref>`, and the sentinel row carries a weight with no stack at all.
assert.deepEqual(summary, {
sampleCount: 4,
totalSampleWeightMs: 4,
topFunctions: [
{
symbol: 'hot',
binary: 'App',
selfSampleMs: 3,
selfSamplePercent: 75,
},
{
symbol: 'warm',
binary: 'App',
selfSampleMs: 1,
selfSamplePercent: 25,
},
],
});
});

test('aggregates weighted innermost frames and follows xctrace references', () => {
const summary = parseAppleTimeProfileSummary(
`<trace-query-result><node><row>
Expand Down Expand Up @@ -44,6 +96,30 @@ test('aggregates rows exported from multiple trace runs', () => {
assert.equal(summary.topFunctions[0]?.symbol, 'runTwo');
});

test('resolves a frame reference an earlier trace run defined', () => {
// A real `xctrace record --append-run` export numbers element ids with one counter across its
// runs, so a row of a later run refs a frame an earlier run defined. Scoping the id index per
// `<node>` would drop that row's frame instead of attributing it to the earlier symbol.
const summary = parseAppleTimeProfileSummary(
`<trace-query-result>
<node xpath="/trace-toc/run[1]"><row><weight>1000000</weight><tagged-backtrace><frame id="frame-1" name="shared"/></tagged-backtrace></row></node>
<node xpath="/trace-toc/run[2]"><row><weight>3000000</weight><tagged-backtrace><frame ref="frame-1"/></tagged-backtrace></row></node>
</trace-query-result>`,
);
assert.deepEqual(summary, {
sampleCount: 2,
totalSampleWeightMs: 4,
topFunctions: [
{
symbol: 'shared',
binary: undefined,
selfSampleMs: 4,
selfSamplePercent: 100,
},
],
});
});

test('skips incomplete rows', () => {
assert.deepEqual(
parseAppleTimeProfileSummary(
Expand Down
11 changes: 8 additions & 3 deletions packages/platform-apple/src/core/perf-time-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { findAllXmlNodes, indexXmlNodesById, resolveXmlReference } from './perf-

const APPLE_TIME_PROFILE_FUNCTION_LIMIT = 10;

// Xcode 27 renamed the `time-profile` stack element and its `engineering-type` to
// `<tagged-backtrace>` while keeping the same `<frame>` children and `id`/`ref` reuse, so both
// spellings carry one sampled stack.
const APPLE_TIME_PROFILE_STACK_ELEMENT_NAMES = new Set(['backtrace', 'tagged-backtrace']);

export type AppleTimeProfileFunction = {
symbol: string;
binary?: string;
Expand Down Expand Up @@ -78,14 +83,14 @@ function readRowWeightNs(row: XmlNode, nodesById: Map<string, XmlNode>): number
}

function readInnermostFrame(row: XmlNode, nodesById: Map<string, XmlNode>): XmlNode | undefined {
const backtrace = resolveXmlReference(
row.children.find((node) => node.name === 'backtrace'),
const stack = resolveXmlReference(
row.children.find((node) => APPLE_TIME_PROFILE_STACK_ELEMENT_NAMES.has(node.name)),
nodesById,
);
// xctrace lists a sampled backtrace from the innermost frame outward. The first
// frame therefore owns self time; a focused multi-frame test pins this ordering.
return resolveXmlReference(
backtrace?.children.find((node) => node.name === 'frame'),
stack?.children.find((node) => node.name === 'frame'),
nodesById,
);
}
Expand Down
Loading