From 3fc1a2829733a5fc54c1742d5865fac2fb6886e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 13:26:33 +0200 Subject: [PATCH 1/4] fix(ios): parse the Xcode 27 time-profile stack 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. The parser matched only , so every row of an Xcode 27 export resolved no stack, and writeAppleXctracePerfReport failed a trace holding tens of thousands of samples with "Apple xctrace CPU report contained no samples". Read either spelling through one name set so id/ref resolution, the innermost-frame rule, and the report shape stay exactly as they were for older exports. The fixture is a verbatim 22-row prefix of an xctrace 27.1 (27A9269) export: 19 stack definitions, 2 rows naming their stack through a tagged-backtrace ref, 4 innermost frames and 3 innermost binaries reached through refs, and one row whose stack column is a sentinel. The multi-run test now carries a cross-run frame ref because a real append-run export numbers ids with one counter across its runs. Closes #2860 --- CHANGELOG.md | 6 ++ .../fixtures/xcode27-time-profile.xml | 24 +++++ .../core/__tests__/perf-time-profile.test.ts | 92 ++++++++++++++++++- .../src/core/perf-time-profile.ts | 11 ++- 4 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 packages/platform-apple/src/core/__tests__/fixtures/xcode27-time-profile.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index b94914b0e0..26a9b440a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` instead of ``, 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 diff --git a/packages/platform-apple/src/core/__tests__/fixtures/xcode27-time-profile.xml b/packages/platform-apple/src/core/__tests__/fixtures/xcode27-time-profile.xml new file mode 100644 index 0000000000..269d929eb9 --- /dev/null +++ b/packages/platform-apple/src/core/__tests__/fixtures/xcode27-time-profile.xml @@ -0,0 +1,24 @@ + + +timeSample Timesample-timethreadThreadthreadprocessProcessprocesscoreCorecorethread-stateStatethread-stateweightWeightweightstackBacktracetagged-backtrace2878852082804343681770TODO9Running1000000 +287885375280430818435411 +28788620828009263568236 +2878866662804308410 +28788745828043399817724 +287888000280381799267 +287888583280430868 +287889500280369218042 +28788995828041541232601 +28789016647065343 +28789262528043267843915 +28790141661857630 +288405000 +28840516628039392 +288405750 +288405916 +28840620812843255 +288406708 +288406833 +288407000 +288407500 +1696327 diff --git a/packages/platform-apple/src/core/__tests__/perf-time-profile.test.ts b/packages/platform-apple/src/core/__tests__/perf-time-profile.test.ts index 0fe89b0cd9..2cdae25a8e 100644 --- a/packages/platform-apple/src/core/__tests__/perf-time-profile.test.ts +++ b/packages/platform-apple/src/core/__tests__/perf-time-profile.test.ts @@ -1,7 +1,79 @@ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; import { test } from 'vitest'; import { parseAppleTimeProfileSummary } from '../perf-time-profile.ts'; +const XCODE27_FIXTURE_FUNCTION_LIMIT = 25; +const XCODE27_FIXTURE_FUNCTION_COUNT = 15; + +test('reads stacks from a real Xcode 27 tagged-backtrace export', () => { + const xml = readFileSync( + path.join(import.meta.dirname, 'fixtures', 'xcode27-time-profile.xml'), + 'utf8', + ); + const summary = parseAppleTimeProfileSummary(xml, XCODE27_FIXTURE_FUNCTION_LIMIT); + + // The captured export carries 22 rows: one stack-sentinel row, two rows that name their stack + // with a `` to an earlier id, and innermost frames that reuse a + // ``. Unresolved stacks would drop the count below 21 or leave `` names. + assert.equal(summary.sampleCount, 21); + assert.equal(summary.totalSampleWeightMs, 21); + assert.equal(summary.topFunctions.length, XCODE27_FIXTURE_FUNCTION_COUNT); + assert.ok(!summary.topFunctions.some((entry) => entry.symbol === '')); + // This row names its binary only through ``, so it proves frame-reference + // resolution reaches the binary too. + assert.ok( + summary.topFunctions.some( + (entry) => entry.symbol === '_xzm_free_pac' && entry.binary === 'libsystem_malloc.dylib', + ), + 'a referenced binary must resolve to its name', + ); + assert.deepEqual(summary.topFunctions.slice(0, 2), [ + { + symbol: 'clonefileat', + binary: 'libsystem_kernel.dylib', + selfSampleMs: 5, + selfSamplePercent: 23.8, + }, + { symbol: '__open', binary: 'libsystem_kernel.dylib', selfSampleMs: 2, selfSamplePercent: 9.5 }, + ]); +}); + +test('resolves tagged-backtrace and frame references and skips stack sentinels', () => { + const summary = parseAppleTimeProfileSummary( + ` + + 1000000 + + + + + + + + 1000000 + + + 1000000 + `, + 1, + ); + + assert.deepEqual(summary, { + sampleCount: 3, + totalSampleWeightMs: 3, + topFunctions: [ + { + symbol: 'hot', + binary: 'App', + selfSampleMs: 3, + selfSamplePercent: 100, + }, + ], + }); +}); + test('aggregates weighted innermost frames and follows xctrace references', () => { const summary = parseAppleTimeProfileSummary( ` @@ -33,15 +105,25 @@ test('aggregates weighted innermost frames and follows xctrace references', () = }); }); -test('aggregates rows exported from multiple trace runs', () => { +test('aggregates rows exported from multiple trace runs through one document-wide id index', () => { + // A real multi-run export numbers element ids with one counter across its runs, so a row in a + // later run refs a frame an earlier run defined. Scoping the id index per `` would drop + // that row's frame instead of attributing it to the earlier run's symbol. const summary = parseAppleTimeProfileSummary( ` - 1000000 - 2000000 + 1000000 + + 2000000 + 4000000 + `, ); - assert.equal(summary.sampleCount, 2); - assert.equal(summary.topFunctions[0]?.symbol, 'runTwo'); + assert.equal(summary.sampleCount, 3); + assert.equal(summary.totalSampleWeightMs, 7); + assert.deepEqual(summary.topFunctions, [ + { symbol: 'runOne', binary: undefined, selfSampleMs: 5, selfSamplePercent: 71.4 }, + { symbol: 'runTwo', binary: undefined, selfSampleMs: 2, selfSamplePercent: 28.6 }, + ]); }); test('skips incomplete rows', () => { diff --git a/packages/platform-apple/src/core/perf-time-profile.ts b/packages/platform-apple/src/core/perf-time-profile.ts index ee34f145e3..a13bc9c3ec 100644 --- a/packages/platform-apple/src/core/perf-time-profile.ts +++ b/packages/platform-apple/src/core/perf-time-profile.ts @@ -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 +// `` while keeping the same `` 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; @@ -78,14 +83,14 @@ function readRowWeightNs(row: XmlNode, nodesById: Map): number } function readInnermostFrame(row: XmlNode, nodesById: Map): 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, ); } From ab48e5c29001988f6f6ecf7f33a8b6c23d48ffdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 13:26:33 +0200 Subject: [PATCH 2/4] chore(gates): own .xml package captures in the affected selector The package-capture ownership rule matched .json only, so the first recorded xctrace export under a package fixture directory had no derivable owner and failed the gate open to all 59 checks even though exactly one suite reads it. Accept .xml beside .json and pin both spellings in the ownership test. --- scripts/check-affected/model.test.ts | 1 + scripts/check-affected/model.ts | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/check-affected/model.test.ts b/scripts/check-affected/model.test.ts index 357fe6f2b4..ca7ea89bf2 100644 --- a/scripts/check-affected/model.test.ts +++ b/scripts/check-affected/model.test.ts @@ -235,6 +235,7 @@ test('a payload capture inside a package selects the unit lane', () => { // a capture edit failed the gate open, which punished adding evidence rather than the absence of it. for (const file of [ 'packages/platform-apple/src/core/__tests__/fixtures/ios-device-info-details.json', + 'packages/platform-apple/src/core/__tests__/fixtures/xcode27-time-profile.xml', 'packages/platform-apple/src/snapshot-source/fixtures/wire-vocabulary.json', ]) { const result = plan([file]); diff --git a/scripts/check-affected/model.ts b/scripts/check-affected/model.ts index d21e10de21..baaaba228f 100644 --- a/scripts/check-affected/model.ts +++ b/scripts/check-affected/model.ts @@ -530,16 +530,16 @@ const BUILD_OWNERSHIP: ReadonlyArray<{ file === 'examples/test-app/pnpm-workspace.yaml', }, // In-package payload captures: a recorded tool response checked in under a package's fixture - // directory (`packages/*/**/__tests__/fixtures/*.json`, or a `fixtures/` dir beside the module that - // reads it). Nothing builds them and no `.ts` sibling names them, so without this a capture edit - // fails the gate open even though exactly one suite asserts against it. + // directory (`packages/*/**/__tests__/fixtures/*.json` or `*.xml`, or a `fixtures/` dir beside + // the module that reads it). Nothing builds them and no `.ts` sibling names them, so without + // this a capture edit fails the gate open even though exactly one suite asserts against it. { check: 'unit', rule: 'own:package-capture', detail: 'the vitest unit suite reads the captured payload', owns: (file) => file.startsWith('packages/') && - file.endsWith('.json') && + (file.endsWith('.json') || file.endsWith('.xml')) && (file.includes('/__tests__/fixtures/') || file.includes('/fixtures/')), }, // TS/Swift golden tables (`contracts/fixtures/*.json`): the vitest parity test and the From 42b3d970ac6f1f638772f3a89db6f6b2615b69b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 14:20:57 +0200 Subject: [PATCH 3/4] test(ios): synthesize the Xcode 27 time-profile rows instead of committing a capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The captured export leaked a machine's home directory, per-user temp-dir token, and installed-software identity — an MDM endpoint-security agent among them — into a public repo, and a 30 KB blob of 2000-char lines defeats diff, format, and grep for coverage the synthetic rows already give. Keep the shapes that capture proved: a `` definition, a row that names its stack by ref, a `truncated="YES"` stack whose innermost frame is a ``, an innermost ``, and a stack-column `` row. The binary-ref case is the one the pre-existing suite survived before. Symbols, binaries, and weights are synthetic. Give the cross-run frame reference its own test, so the multi-run aggregation case keeps answering independently the way it did before this PR. --- .../fixtures/xcode27-time-profile.xml | 24 ---- .../core/__tests__/perf-time-profile.test.ts | 116 +++++++++--------- 2 files changed, 55 insertions(+), 85 deletions(-) delete mode 100644 packages/platform-apple/src/core/__tests__/fixtures/xcode27-time-profile.xml diff --git a/packages/platform-apple/src/core/__tests__/fixtures/xcode27-time-profile.xml b/packages/platform-apple/src/core/__tests__/fixtures/xcode27-time-profile.xml deleted file mode 100644 index 269d929eb9..0000000000 --- a/packages/platform-apple/src/core/__tests__/fixtures/xcode27-time-profile.xml +++ /dev/null @@ -1,24 +0,0 @@ - - -timeSample Timesample-timethreadThreadthreadprocessProcessprocesscoreCorecorethread-stateStatethread-stateweightWeightweightstackBacktracetagged-backtrace2878852082804343681770TODO9Running1000000 -287885375280430818435411 -28788620828009263568236 -2878866662804308410 -28788745828043399817724 -287888000280381799267 -287888583280430868 -287889500280369218042 -28788995828041541232601 -28789016647065343 -28789262528043267843915 -28790141661857630 -288405000 -28840516628039392 -288405750 -288405916 -28840620812843255 -288406708 -288406833 -288407000 -288407500 -1696327 diff --git a/packages/platform-apple/src/core/__tests__/perf-time-profile.test.ts b/packages/platform-apple/src/core/__tests__/perf-time-profile.test.ts index 2cdae25a8e..0f7ce6d46b 100644 --- a/packages/platform-apple/src/core/__tests__/perf-time-profile.test.ts +++ b/packages/platform-apple/src/core/__tests__/perf-time-profile.test.ts @@ -1,46 +1,11 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import path from 'node:path'; import { test } from 'vitest'; import { parseAppleTimeProfileSummary } from '../perf-time-profile.ts'; -const XCODE27_FIXTURE_FUNCTION_LIMIT = 25; -const XCODE27_FIXTURE_FUNCTION_COUNT = 15; - -test('reads stacks from a real Xcode 27 tagged-backtrace export', () => { - const xml = readFileSync( - path.join(import.meta.dirname, 'fixtures', 'xcode27-time-profile.xml'), - 'utf8', - ); - const summary = parseAppleTimeProfileSummary(xml, XCODE27_FIXTURE_FUNCTION_LIMIT); - - // The captured export carries 22 rows: one stack-sentinel row, two rows that name their stack - // with a `` to an earlier id, and innermost frames that reuse a - // ``. Unresolved stacks would drop the count below 21 or leave `` names. - assert.equal(summary.sampleCount, 21); - assert.equal(summary.totalSampleWeightMs, 21); - assert.equal(summary.topFunctions.length, XCODE27_FIXTURE_FUNCTION_COUNT); - assert.ok(!summary.topFunctions.some((entry) => entry.symbol === '')); - // This row names its binary only through ``, so it proves frame-reference - // resolution reaches the binary too. - assert.ok( - summary.topFunctions.some( - (entry) => entry.symbol === '_xzm_free_pac' && entry.binary === 'libsystem_malloc.dylib', - ), - 'a referenced binary must resolve to its name', - ); - assert.deepEqual(summary.topFunctions.slice(0, 2), [ - { - symbol: 'clonefileat', - binary: 'libsystem_kernel.dylib', - selfSampleMs: 5, - selfSamplePercent: 23.8, - }, - { symbol: '__open', binary: 'libsystem_kernel.dylib', selfSampleMs: 2, selfSamplePercent: 9.5 }, - ]); -}); - -test('resolves tagged-backtrace and frame references and skips stack sentinels', () => { +test('reads Xcode 27 tagged-backtrace stacks and skips stack sentinels', () => { + // xctrace 27 renamed the `time-profile` stack element to `` 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( ` @@ -52,23 +17,38 @@ test('resolves tagged-backtrace and frame references and skips stack sentinels', - 1000000 - + + - 1000000 + + + + + + + `, - 1, + 2, ); + // `hot` is sampled as a stack definition, through a ``, and through a + // truncated stack whose innermost frame is a ``. `warm` reaches its binary only + // through ``, and the sentinel row carries a weight with no stack at all. assert.deepEqual(summary, { - sampleCount: 3, - totalSampleWeightMs: 3, + sampleCount: 4, + totalSampleWeightMs: 4, topFunctions: [ { symbol: 'hot', binary: 'App', selfSampleMs: 3, - selfSamplePercent: 100, + selfSamplePercent: 75, + }, + { + symbol: 'warm', + binary: 'App', + selfSampleMs: 1, + selfSamplePercent: 25, }, ], }); @@ -105,25 +85,39 @@ test('aggregates weighted innermost frames and follows xctrace references', () = }); }); -test('aggregates rows exported from multiple trace runs through one document-wide id index', () => { - // A real multi-run export numbers element ids with one counter across its runs, so a row in a - // later run refs a frame an earlier run defined. Scoping the id index per `` would drop - // that row's frame instead of attributing it to the earlier run's symbol. +test('aggregates rows exported from multiple trace runs', () => { + const summary = parseAppleTimeProfileSummary( + ` + 1000000 + 2000000 + `, + ); + assert.equal(summary.sampleCount, 2); + 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 + // `` would drop that row's frame instead of attributing it to the earlier symbol. const summary = parseAppleTimeProfileSummary( ` - 1000000 - - 2000000 - 4000000 - + 1000000 + 3000000 `, ); - assert.equal(summary.sampleCount, 3); - assert.equal(summary.totalSampleWeightMs, 7); - assert.deepEqual(summary.topFunctions, [ - { symbol: 'runOne', binary: undefined, selfSampleMs: 5, selfSamplePercent: 71.4 }, - { symbol: 'runTwo', binary: undefined, selfSampleMs: 2, selfSamplePercent: 28.6 }, - ]); + assert.deepEqual(summary, { + sampleCount: 2, + totalSampleWeightMs: 4, + topFunctions: [ + { + symbol: 'shared', + binary: undefined, + selfSampleMs: 4, + selfSamplePercent: 100, + }, + ], + }); }); test('skips incomplete rows', () => { From 9dd0837419732794e95b6075865bc51ab69d18dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 14:20:57 +0200 Subject: [PATCH 4/4] chore(gates): drop the .xml package-capture extension That extension was added only to own the captured xctrace export this branch no longer commits, so the rule would own nothing real. Revert to the `.json` capture rule and keep the ownership test on the captures that exist. --- scripts/check-affected/model.test.ts | 1 - scripts/check-affected/model.ts | 8 ++++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/scripts/check-affected/model.test.ts b/scripts/check-affected/model.test.ts index ca7ea89bf2..357fe6f2b4 100644 --- a/scripts/check-affected/model.test.ts +++ b/scripts/check-affected/model.test.ts @@ -235,7 +235,6 @@ test('a payload capture inside a package selects the unit lane', () => { // a capture edit failed the gate open, which punished adding evidence rather than the absence of it. for (const file of [ 'packages/platform-apple/src/core/__tests__/fixtures/ios-device-info-details.json', - 'packages/platform-apple/src/core/__tests__/fixtures/xcode27-time-profile.xml', 'packages/platform-apple/src/snapshot-source/fixtures/wire-vocabulary.json', ]) { const result = plan([file]); diff --git a/scripts/check-affected/model.ts b/scripts/check-affected/model.ts index baaaba228f..d21e10de21 100644 --- a/scripts/check-affected/model.ts +++ b/scripts/check-affected/model.ts @@ -530,16 +530,16 @@ const BUILD_OWNERSHIP: ReadonlyArray<{ file === 'examples/test-app/pnpm-workspace.yaml', }, // In-package payload captures: a recorded tool response checked in under a package's fixture - // directory (`packages/*/**/__tests__/fixtures/*.json` or `*.xml`, or a `fixtures/` dir beside - // the module that reads it). Nothing builds them and no `.ts` sibling names them, so without - // this a capture edit fails the gate open even though exactly one suite asserts against it. + // directory (`packages/*/**/__tests__/fixtures/*.json`, or a `fixtures/` dir beside the module that + // reads it). Nothing builds them and no `.ts` sibling names them, so without this a capture edit + // fails the gate open even though exactly one suite asserts against it. { check: 'unit', rule: 'own:package-capture', detail: 'the vitest unit suite reads the captured payload', owns: (file) => file.startsWith('packages/') && - (file.endsWith('.json') || file.endsWith('.xml')) && + file.endsWith('.json') && (file.includes('/__tests__/fixtures/') || file.includes('/fixtures/')), }, // TS/Swift golden tables (`contracts/fixtures/*.json`): the vitest parity test and the