Skip to content
Closed
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
4 changes: 4 additions & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,10 @@
"types": "./src/orientation-runtime.ts",
"default": "./src/orientation-runtime.ts"
},
"./perf-hang-next-steps": {
"types": "./src/perf-hang-next-steps.ts",
"default": "./src/perf-hang-next-steps.ts"
},
"./perf-runtime": {
"types": "./src/perf-runtime.ts",
"default": "./src/perf-runtime.ts"
Expand Down
31 changes: 31 additions & 0 deletions packages/contracts/src/__tests__/perf-hang-next-steps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { perfHangNextSteps, perfHitchNextSteps } from '../perf-hang-next-steps.ts';

test('perfHangNextSteps names the top symbol for a running-dominant main-thread hang', () => {
const steps = perfHangNextSteps({
runningMs: 3_800,
blockedMs: 200,
topSymbol: 'HangViewController.blockMainThread(seconds:)',
threadName: 'Main Thread',
});
assert.match(steps.join('\n'), /HangViewController\.blockMainThread\(seconds:\)/);
assert.match(steps.join('\n'), /perf cpu profile/);
assert.ok(!steps.join('\n').includes('blocked, not running'));
});

test('perfHangNextSteps recommends offloading for a blocked-dominant hang', () => {
const steps = perfHangNextSteps({ runningMs: 10, blockedMs: 2_900, threadName: 'main' });
assert.equal(steps.length, 1);
assert.match(steps.join('\n'), /blocked, not running/);
});

test('perfHangNextSteps recommends a CPU profile without a top symbol', () => {
const steps = perfHangNextSteps({ runningMs: 900, blockedMs: 0 });
assert.equal(steps.length, 1);
assert.match(steps.join('\n'), /perf cpu profile/);
});

test('perfHitchNextSteps points at layout cost and trace UIs', () => {
assert.match(perfHitchNextSteps().join('\n'), /layout cost/);
});
89 changes: 89 additions & 0 deletions packages/contracts/src/perf-hang-next-steps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/** One main-thread freeze window attributed from a native trace. */
export type PerfHang = Readonly<{
/** Offset from trace start in milliseconds. */
startOffsetMs: number;
durationMs: number;
thread: string;
/** Innermost sampled symbol with the most running weight inside the window. */
topSymbol?: string;
runningMs: number;
blockedMs: number;
source: string;
nextSteps: string[];
}>;

export type PerfHangCause = Readonly<{
runningMs: number;
blockedMs: number;
topSymbol?: string;
threadName?: string;
}>;

/**
* Static rule table for one classified hang. Returns agent-actionable next steps
* from the hang cause only; it never parses free-form tool output.
*/
export function perfHangNextSteps(cause: PerfHangCause): string[] {
if (cause.runningMs >= cause.blockedMs) {
return cause.topSymbol
? [
`Inspect ${cause.topSymbol} on the main thread; it holds the most samples in this window.`,
'Run perf cpu profile around the interaction for a symbol-level breakdown.',
]
: ['Run perf cpu profile around the interaction to attribute CPU work inside this window.'];
}
return [
'The main thread is blocked, not running; move the waiting work (I/O, binder, lock) off the main thread.',
];
}

/**
* Static rule for hitch evidence without a CPU hotspot.
*/
export function perfHitchNextSteps(): string[] {
return [
'Frame hitches have no matching CPU hotspot; check layout cost and overdraw, then open the trace in Instruments or the Perfetto UI.',
];
}

/** A symbol ranked by accumulated on-CPU self-sample weight. */
export type PerfTraceHotspot = Readonly<{
symbol: string;
binary?: string;
selfSampleMs: number;
}>;

/** Hitch tally for the attached app only. */
export type PerfTraceHitches = Readonly<{
available: boolean;
count: number;
maxDurationMs?: number;
nextSteps: string[];
}>;

/**
* Shared unavailable half of a trace-analysis stop summary. Each platform names
* its own typed reasons; the envelope stays one shape for CLI and clients.
*/
export type PerfTraceAnalysisUnavailable<Reason extends string> = Readonly<{
available: false;
reason: Reason;
message: string;
hint: string;
}>;

/**
* Shared trace-analysis envelope attached to a native `perf trace stop` result
* `summary` on every platform: either interpreted evidence or a typed failure.
*/
export type PerfTraceAnalysis<Hang, Hotspot, UnavailableReason extends string> =
| Readonly<{
available: true;
tracePath: string;
hangs: Hang[];
hangCount: number;
hitches?: PerfTraceHitches;
hotspots: Hotspot[];
notes: string[];
}>
| PerfTraceAnalysisUnavailable<UnavailableReason>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0"?>
<!-- Real "Animation Hitches" table export (xctrace, Xcode 27.1, macOS host, attached HangTool).
The three HangTool rows are byte-exact from the export. The HangTool2 row is a
foreign-process row in the identical cell encoding: attaching to one process never
emitted foreign rows, and every multi-process export crashed xctrace here. -->
<trace-query-result>
<node xpath='//trace-toc[1]/run[1]/data[1]/table[38]'><schema name="hitches"><col><mnemonic>start</mnemonic><name>Start</name><engineering-type>start-time</engineering-type></col><col><mnemonic>duration</mnemonic><name>Duration</name><engineering-type>duration</engineering-type></col><col><mnemonic>process</mnemonic><name>Process</name><engineering-type>process</engineering-type></col><col><mnemonic>is-system</mnemonic><name>Is System</name><engineering-type>boolean</engineering-type></col><col><mnemonic>swap-id</mnemonic><name>Swap ID</name><engineering-type>uint32</engineering-type></col><col><mnemonic>label</mnemonic><name>Swap ID</name><engineering-type>string</engineering-type></col><col><mnemonic>display</mnemonic><name>Display</name><engineering-type>display-name</engineering-type></col><col><mnemonic>narrative-description</mnemonic><name>Potential Issue</name><engineering-type>string</engineering-type></col></schema><row><start-time id="1" fmt="00:03.751.313">3751313541</start-time><duration id="2" fmt="8.33 ms">8333291</duration><process id="3" fmt="HangTool (57204)"><pid id="4" fmt="57204">57204</pid><device-session id="5" fmt="TODO">TODO</device-session></process><boolean id="6" fmt="No">0</boolean><uint32 id="7" fmt="16662174">16662174</uint32><string id="8" fmt="0xfe3e9e">0xfe3e9e</string><display-name id="9" fmt="Display 1">Display 1</display-name><sentinel/></row>
<row><start-time id="10" fmt="00:05.167.964">5167964500</start-time><duration ref="2"/><process ref="3"/><boolean ref="6"/><uint32 id="11" fmt="16662287">16662287</uint32><string id="12" fmt="0xfe3f0f">0xfe3f0f</string><display-name ref="9"/><sentinel/></row>
<row><start-time id="13" fmt="00:15.892.845">15892845666</start-time><duration id="14" fmt="16.67 ms">16666583</duration><process ref="3"/><boolean ref="6"/><uint32 id="15" fmt="16662977">16662977</uint32><string id="16" fmt="0xfe41c1">0xfe41c1</string><display-name ref="9"/><sentinel/></row>
<row><start-time id="20" fmt="00:08.512.004">8512004000</start-time><duration id="21" fmt="20.00 ms">20000000</duration><process id="22" fmt="HangTool2 (91502)"><pid id="23" fmt="91502">91502</pid><device-session id="24" fmt="TODO">TODO</device-session></process><boolean id="25" fmt="No">0</boolean><uint32 id="26" fmt="16662500">16662500</uint32><string id="27" fmt="0xfe4000">0xfe4000</string><display-name ref="9"/><sentinel/></row></node></trace-query-result>

Large diffs are not rendered by default.

Loading
Loading