Skip to content
Draft
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
23 changes: 23 additions & 0 deletions packages/tool-server/src/blueprints/native-profiler-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ export interface NativeProfilerParsedData {
memoryLeaks: MemoryLeak[];
}

/**
* How the active (or last) recording was captured.
* - `device-attach`: the normal path — xctrace `--device <udid> --attach <app>`,
* full fidelity (CPU + hangs + leaks).
* - `host-all-processes`: fallback used when the simulator-targeted Instruments
* tap is broken (Xcode 26.x / Instruments 16 cannot package `--device`
* simulator traces — `coreprofilesessiontap` fails). We record the host with
* `--all-processes` Time Profiler instead and scope results to the app's host
* PID. CPU-only: Leaks/Allocations cannot target "All Processes".
*/
export type NativeProfilerRecordingMode = "device-attach" | "host-all-processes";

export interface NativeProfilerSessionApi {
deviceId: string;
appProcess: string | null;
Expand All @@ -49,6 +61,15 @@ export interface NativeProfilerSessionApi {
recordingTimedOut: boolean;
recordingExitedUnexpectedly: boolean;
lastExitInfo: { code: number | null; signal: string | null } | null;
/** How the current/last recording was captured (set at start). */
recordingMode: NativeProfilerRecordingMode | null;
/**
* Host PID of the profiled app. Only meaningful in `host-all-processes` mode,
* where the trace contains every process and downstream analysis must filter
* to `pid: <processFilterPid>` to stay app-scoped. Null in `device-attach`
* mode (the trace already contains only the attached app).
*/
processFilterPid: string | null;
}

// Discard semantics on dispose: registry teardown only fires from process
Expand Down Expand Up @@ -102,6 +123,8 @@ export const nativeProfilerSessionBlueprint: ServiceBlueprint<
recordingTimedOut: false,
recordingExitedUnexpectedly: false,
lastExitInfo: null,
recordingMode: null,
processFilterPid: null,
};

const events = new TypedEventEmitter<ServiceEvents>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const nativeProfilerAnalyzeTool: ToolDefinition<
id: "native-profiler-analyze",
requires: ["xcrun"],
capability: { apple: { simulator: true, device: true } },
longRunning: true,
description: `Analyze exported native trace data and return an LLM-optimized markdown report.
iOS: parses CPU time profile, UI hangs, and memory leaks from the exported XML files.
Returns a structured markdown report with severity indicators, tables, and actionable suggestions.
Expand All @@ -59,6 +60,12 @@ Fails if native-profiler-stop has not been called first to export trace data.`,
throw new Error("No exported trace data found. Call native-profiler-stop first.");
}

// The host all-processes fallback (used when the simulator Instruments tap
// is broken) is CPU-only: Leaks/Allocations cannot target "All Processes",
// so hangs/leaks are *expected* to be absent and must not be reported as
// failures. CPU is the sole data source in that mode.
const isHostFallback = api.recordingMode === "host-all-processes";

// Pre-flight every set path: if the file is missing/unreadable the parsers
// silently produce [], which would otherwise render as "All clear".
const [cpuMissing, hangsMissing, leaksMissing] = await Promise.all([
Expand All @@ -68,11 +75,12 @@ Fails if native-profiler-stop has not been called first to export trace data.`,
]);

const { bottlenecks, cpuSamples, uiHangs, cpuHotspots, memoryLeaks } =
await runIosProfilerPipeline(api.exportedFiles);
await runIosProfilerPipeline(api.exportedFiles, api.processFilterPid ?? null);

api.parsedData = { cpuSamples, uiHangs, cpuHotspots, memoryLeaks };

const exportErrors: Record<string, string> = {};
const cpuOk = !!api.exportedFiles.cpu && !cpuMissing;
if (!api.exportedFiles.cpu) {
exportErrors.cpu =
"CPU time-profile export failed — xctrace could not export CPU data from this trace. " +
Expand All @@ -84,17 +92,58 @@ Fails if native-profiler-stop has not been called first to export trace data.`,
`CPU time-profile export ${cpuMissing} — the trace export claims it succeeded but the ` +
`file is gone or unreadable, so no CPU data could be analyzed. Re-run native-profiler-stop.`;
}
if (!api.exportedFiles.hangs) {
exportErrors.hangs = "Hangs export failed — no potential-hangs table found in trace.";
} else if (hangsMissing) {
exportErrors.hangs =
`Hangs export ${hangsMissing} — the trace export claims it succeeded but the file is gone ` +
`or unreadable, so no hang data could be analyzed. Re-run native-profiler-stop.`;
// In host-fallback mode, hangs/leaks are intentionally not captured — skip
// the "export failed" warnings that would otherwise be noise.
if (!isHostFallback) {
if (!api.exportedFiles.hangs) {
exportErrors.hangs = "Hangs export failed — no potential-hangs table found in trace.";
} else if (hangsMissing) {
exportErrors.hangs =
`Hangs export ${hangsMissing} — the trace export claims it succeeded but the file is gone ` +
`or unreadable, so no hang data could be analyzed. Re-run native-profiler-stop.`;
}
if (api.exportedFiles.leaks && leaksMissing) {
exportErrors.leaks =
`Leaks export ${leaksMissing} — the trace export claims it succeeded but the file is gone ` +
`or unreadable, so no leak data could be analyzed. Re-run native-profiler-stop.`;
}
}
if (api.exportedFiles.leaks && leaksMissing) {
exportErrors.leaks =
`Leaks export ${leaksMissing} — the trace export claims it succeeded but the file is gone ` +
`or unreadable, so no leak data could be analyzed. Re-run native-profiler-stop.`;

const modeNote = isHostFallback
? `Captured via host all-processes fallback (the simulator Instruments tap could not package a ` +
`\`--device\` trace). CPU-only and scoped to ${api.appProcess ?? "the app"} (pid: ${api.processFilterPid}) — hangs and leaks are unavailable in this mode.`
: undefined;

// Decide whether the analysis is inconclusive: no data source could be
// read at all, or (host mode) the CPU file read but nothing matched the
// app PID. Either way, zero findings is meaningless and must not render as
// "All clear".
let inconclusive: { reason: string } | undefined;
if (isHostFallback) {
if (!cpuOk) {
inconclusive = {
reason:
"The host all-processes Time Profiler trace produced no readable CPU export, so there was " +
"nothing to analyze for the app.",
};
} else if (cpuSamples.length === 0) {
inconclusive = {
reason:
`The host all-processes trace exported CPU data, but no samples matched ${api.appProcess ?? "the app"} ` +
`(pid: ${api.processFilterPid}). The app may have been idle during the recording, or it was not the ` +
`running process. Interact with the app while recording, then stop.`,
};
}
} else {
const hangsOk = !!api.exportedFiles.hangs && !hangsMissing;
const leaksOk = !!api.exportedFiles.leaks && !leaksMissing;
if (!cpuOk && !hangsOk && !leaksOk) {
inconclusive = {
reason:
"None of the CPU, hangs, or leaks exports could be read, so there was no trace data to analyze. " +
"The recording likely failed to package (see native-profiler-stop exportDiagnostics).",
};
}
}

const payload = {
Expand All @@ -110,6 +159,9 @@ Fails if native-profiler-stop has not been called first to export trace data.`,
payload,
traceFile: api.traceFile,
exportErrors,
inconclusive,
mode: api.recordingMode ?? undefined,
modeNote,
});
},
};
Loading