Skip to content

fix(ios): parse the Xcode 27 <tagged-backtrace> time-profile stack - #2889

Merged
thymikee merged 4 commits into
mainfrom
fix/2860-xcode27-tagged-backtrace
Sep 24, 2026
Merged

thymikee merged 4 commits into
mainfrom
fix/2860-xcode27-tagged-backtrace

Conversation

@thymikee

@thymikee thymikee commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Summary

Xcode 27 exports every time-profile sample stack as <tagged-backtrace>; the parser read only
<backtrace>, so each row resolved no stack and perf cpu profile report --kind xctrace failed with
Apple xctrace CPU report contained no samples on traces holding tens of thousands of samples. Both
spellings now resolve through the same id/ref path, so the innermost-frame rule, older Xcode
exports, and the report shape are unchanged. Three files, no committed capture per review.

Closes #2860

Validation

  • 9dd083741: pnpm check:affected --run — 13 selected checks, all runnable ones passed. Nothing
    non-.ts lands, so the plan no longer fails open to the full set.
  • The synthetic rows keep the shapes a real 27.1 export showed: a <tagged-backtrace> definition, a
    <tagged-backtrace ref>, a truncated="YES" stack whose innermost frame is a <frame ref>, an
    innermost <binary ref>, and a stack-column <sentinel/> row. Mutations that now fail a test: the
    pre-fix parser, tagged-only matching, last-frame-as-innermost, unresolved stack/frame/binary refs,
    and an id index that cannot reach an earlier run's definitions. The <binary ref> case is one the
    previous suite survived.
  • Live on an iPhone 17 Pro simulator (iOS 27.0 runtime, Xcode 27.1) with session app
    com.callstack.agentdevicelab: perf cpu profile start --kind xctrace --out ios27.trace, then
    stop, then report --kind xctrace --out report.json → sampleCount: 13,
    totalSampleWeightMs: 13, top entry __sysctl | libsystem_kernel.dylib | 3 ms | 23.1%. That trace
    exports 13 <tagged-backtrace> and 0 <backtrace>. Session closed, app uninstalled, simulator
    shut down.
  • Out of scope, confirmed pre-existing: findAllXmlNodes throws RangeError past ~130k rows.

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 <backtrace>, 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
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.
@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.80 MB 4.80 MB +45 B
Package (unpacked) 4.80 MB 4.80 MB +45 B
Package (download) 1.44 MB 1.44 MB +15 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 19.0 ms 19.5 ms +0.5 ms
CLI --help 53.9 ms 53.5 ms -0.4 ms

@thymikee thymikee left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thermo-nuclear structural pass. The production change is genuinely good and I would take it on its own: the APPLE_TIME_PROFILE_STACK_ELEMENT_NAMES Set honestly expresses "either spelling of the stack element", the backtrace -> stack rename drops the now-false singular, perf-xml.ts is untouched, and the findAllXmlNodes ~130k RangeError is pre-existing and not made newly reachable by this diff (traversal was already document-wide). No file crossed 1k.

Requesting changes for one reason only: the committed fixture leaks personal + corporate identity. Fix that and this is approvable. One minor below it.

Minor, non-blocking (not inline): the rewritten aggregates rows exported from multiple trace runs through one document-wide id index test pins a real (verified: indexXmlNodesById / findAllXmlNodes are document-wide) but pre-existing cross-<node> ref property — off-topic for a tagged-backtrace fix — and it retires the original independent-per-run aggregation case it replaced. Consider a separate focused test rather than repurposing the multi-run aggregation test.


test('reads stacks from a real Xcode 27 tagged-backtrace export', () => {
const xml = readFileSync(
path.join(import.meta.dirname, 'fixtures', 'xcode27-time-profile.xml'),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKER — this fixture is verbatim, unscrubbed xctrace output from your machine and should not land as-is. It embeds author + corporate identity: your real home dir / username (/Users/michal/..., twice), your per-user var/folders/<hash>/T temp token, and installed-software identity (io.kandji.KandjiAgent.ESF-Extension — an MDM / endpoint-security agent — plus dtsecurity, opencode, fff-scan). Those path= / fmt= values carry zero assertion value and permanently publish personal + employer/security posture into a public repo. The repo's other captured fixtures (e.g. wire-vocabulary.json) are scrubbed; this one is not.

It is also near-redundant: the new synthetic resolves tagged-backtrace and frame references and skips stack sentinels test already covers tagged-backtrace inline + ref, <frame ref>, <weight ref>, and sentinels, and aggregates weighted innermost frames already covers <binary ref> resolution — which was the only unique coverage the real export claimed. A ~30 KB / 24-line near-raw blob (lines up to 2000 chars) is the worst fixture shape: it defeats diff, format, and grep for no behavioral gain.

Either drop it and keep the synthetic test (add a <binary ref> case if you want that explicit), or keep a real-shaped fixture scrubbed to synthetic paths / process names and trimmed to a few representative rows. Please do not merge near-raw machine output.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 42b3d97 — the capture is gone and this PR ships no fixture. The shapes it proved now sit in the synthetic rows with synthetic symbols and binaries: a <tagged-backtrace> definition, a <tagged-backtrace ref>, a truncated="YES" stack whose innermost frame is a <frame ref>, an innermost <binary ref>, and a stack-column <sentinel/> row.

Taking the first option also retires the check-affected .xml ownership rule (9dd0837), since nothing non-.ts lands anymore — the plan now selects 13 checks instead of failing open to 59.

Comment thread scripts/check-affected/model.ts Outdated
owns: (file) =>
file.startsWith('packages/') &&
file.endsWith('.json') &&
(file.endsWith('.json') || file.endsWith('.xml')) &&

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extension list now lives twice: this (endsWith('.json') || endsWith('.xml')) chain, and verbatim in the block comment directly above (fixtures/*.json or *.xml). The next capture format means editing both and they will drift — classic "list wants to be data". Can we lift one const PACKAGE_CAPTURE_EXTENSIONS = ['.json', '.xml'] as const used as .some((ext) => file.endsWith(ext)), and reword the comment to "a known payload-capture extension" without re-listing? If two entries do not earn a constant, at least drop the duplication in the comment.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped in 9dd0837 rather than de-duplicated: the rule only widened for the capture this branch no longer commits, so it would own nothing real and its test would pin a path that does not exist. model.ts is back to exactly origin/main. If you want the extension list anyway for the next recorded capture, I would take it as its own change with a capture behind it.

…tting a capture

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 `<tagged-backtrace>` definition, a row that
names its stack by ref, a `truncated="YES"` stack whose innermost frame is a
`<frame ref>`, an innermost `<binary ref>`, and a stack-column `<sentinel/>` 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.
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.

@thymikee thymikee left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied the non-blocking point in 42b3d97: aggregates rows exported from multiple trace runs is restored to the version on main, and the cross-run reference moved to resolves a frame reference an earlier trace run defined.

Checked that the new test is the only one carrying that claim — replacing the document-wide index with one that cannot see the earlier run's ids trips exactly that test and leaves the other four green.

Rebased evidence for the reworked tests is in the Validation section; pnpm check:affected --run is green at 9dd0837.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 9dd0837. The code looks good: the parser now reads both <backtrace> and <tagged-backtrace> stacks, and the change stays inside the xctrace report parser.

Not blocking: the comment at packages/platform-apple/src/core/perf-time-profile.ts line 6 says Xcode 27 renamed engineering-type to tagged-backtrace, but nothing here reads an engineering-type attribute. Can you drop that clause, or point to the schema attribute it means?

I did not repeat the live Xcode 27 capture. The sample counts in the PR body are your reported result, and the fixture rows are synthetic because the original capture was removed. Smoke Tests, Repo Guards and Coverage were still running at review time.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 24, 2026
@thymikee
thymikee merged commit 0192348 into main Sep 24, 2026
18 checks passed
@thymikee
thymikee deleted the fix/2860-xcode27-tagged-backtrace branch September 24, 2026 12:46
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-24 12:48 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(ios): parse Xcode 27 <tagged-backtrace> in xctrace CPU profile reports

1 participant