From f47481ae84617b63b51ad489ab4b6bfd80296ed0 Mon Sep 17 00:00:00 2001 From: Filip131311 Date: Mon, 8 Jun 2026 12:11:48 +0200 Subject: [PATCH] fix(native-profiler): fall back to host all-processes Time Profiler when the simulator Instruments tap is broken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Xcode 26.x / Instruments 16, xctrace can no longer package a `--device --attach ` simulator trace (coreprofilesessiontap fails), which breaks native profiling on the simulator entirely. When device-attach fails on a simulator, transparently fall back to a host `--all-processes` Time Profiler recording and scope the results to the app's host PID. This path is CPU-only by construction — Leaks and Allocations cannot target "All Processes" — so hangs/leaks export is skipped and their absence is no longer reported as a failure. - start: detect the app's host PID, retry device-attach, then fall back to a host-wide Time Profiler scoped to that PID. Clears stale .trace bundles between attempts. Only falls back on simulators. - pipeline: filterSamplesByPid scopes a host-wide trace to the app via the `(AppName, pid: N)` thread fmt tag. - analyze: emit an "Analysis Inconclusive" report (never "All clear") when the only data source produced no readable/app-matching samples. - metadata sidecar: persist mode/pid/appProcess next to the trace so a different tool-server process can reconstruct app-scoped analysis on profiler-load. - export: ExportOptions.keys to restrict export to CPU in fallback mode. - tests: host-fallback PID scoping + inconclusive reporting; update stop-recovery for the new export signature. --- .../src/blueprints/native-profiler-session.ts | 23 ++ .../native-profiler-analyze.ts | 74 +++++- .../native-profiler/native-profiler-start.ts | 249 +++++++++++++++--- .../native-profiler/native-profiler-stop.ts | 39 ++- .../src/tools/profiler/query/profiler-load.ts | 27 +- .../src/utils/ios-profiler/export.ts | 18 +- .../src/utils/ios-profiler/metadata.ts | 59 +++++ .../src/utils/ios-profiler/pipeline/index.ts | 26 +- .../src/utils/ios-profiler/render.ts | 109 +++++++- .../src/utils/ios-profiler/types.ts | 9 + .../ios-instruments/host-fallback.test.ts | 177 +++++++++++++ .../ios-instruments/stop-recovery.test.ts | 3 +- .../native-profiler-missing-trace.test.ts | 2 + 13 files changed, 739 insertions(+), 76 deletions(-) create mode 100644 packages/tool-server/src/utils/ios-profiler/metadata.ts create mode 100644 packages/tool-server/test/ios-instruments/host-fallback.test.ts diff --git a/packages/tool-server/src/blueprints/native-profiler-session.ts b/packages/tool-server/src/blueprints/native-profiler-session.ts index 2f82ac33..66a8ce84 100644 --- a/packages/tool-server/src/blueprints/native-profiler-session.ts +++ b/packages/tool-server/src/blueprints/native-profiler-session.ts @@ -35,6 +35,18 @@ export interface NativeProfilerParsedData { memoryLeaks: MemoryLeak[]; } +/** + * How the active (or last) recording was captured. + * - `device-attach`: the normal path — xctrace `--device --attach `, + * 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; @@ -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: ` 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 @@ -102,6 +123,8 @@ export const nativeProfilerSessionBlueprint: ServiceBlueprint< recordingTimedOut: false, recordingExitedUnexpectedly: false, lastExitInfo: null, + recordingMode: null, + processFilterPid: null, }; const events = new TypedEventEmitter(); diff --git a/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-analyze.ts b/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-analyze.ts index d299a772..bfab1f80 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-analyze.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-analyze.ts @@ -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. @@ -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([ @@ -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 = {}; + 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. " + @@ -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 = { @@ -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, }); }, }; diff --git a/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-start.ts b/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-start.ts index 9e4d8d60..a40f91e3 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-start.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-start.ts @@ -1,10 +1,12 @@ import { z } from "zod"; import { spawn, execFileSync, type ChildProcess } from "child_process"; +import { rmSync } from "fs"; import * as path from "path"; import type { ToolDefinition } from "@argent/registry"; import { nativeProfilerSessionRef, type NativeProfilerSessionApi, + type NativeProfilerRecordingMode, } from "../../../blueprints/native-profiler-session"; import { resolveDevice } from "../../../utils/device-info"; import { getDebugDir } from "../../../utils/react-profiler/debug/dump"; @@ -12,6 +14,12 @@ import { listenForDarwinNotification, type NotifyHandle } from "../../../utils/i import { waitForXctraceReady } from "../../../utils/ios-profiler/startup"; const DEFAULT_TEMPLATE_PATH = path.resolve(__dirname, "Argent.tracetemplate"); +// Built-in Instruments template used for the host-all-processes fallback. The +// bundled Argent template includes Leaks/Allocations instruments, which xctrace +// refuses to run against an "All Processes" target ("Leaks cannot handle a +// target type of 'All Processes'"). Time Profiler is the only CPU instrument +// that records host-wide, so the fallback is CPU-only by construction. +const HOST_FALLBACK_TEMPLATE = "Time Profiler"; const STARTUP_TIMEOUT_MS = 10_000; const DETECT_RUNNING_APP_TIMEOUT_MS = 10_000; const NOTIFY_REGISTER_TIMEOUT_MS = 2_000; @@ -43,7 +51,25 @@ interface AppInfo { ApplicationType: string; } -function detectRunningApp(udid: string): string { +/** The profiled app's executable name (for `--attach`) and its host PID. */ +export interface DetectedApp { + /** CFBundleExecutable — what xctrace `--attach` expects. */ + executable: string; + /** + * Host process id of the running app, parsed from `launchctl list`. On a + * simulator the launchd PID equals the host PID that appears in a host + * `--all-processes` trace, so it doubles as the `pid: N` filter for the + * host-all-processes fallback. Null when the app is not currently running + * (e.g. an explicit `app_process` that hasn't launched yet). + */ + pid: string | null; +} + +/** + * Map every running `UIKitApplication:` entry in `launchctl list` to its host + * PID. Format is `\t\tUIKitApplication:[token][...]`. + */ +function readRunningBundlePids(udid: string): Map { let launchctlOutput: string; try { launchctlOutput = execFileSync("xcrun", ["simctl", "spawn", udid, "launchctl", "list"], { @@ -58,20 +84,16 @@ function detectRunningApp(udid: string): string { ); } - const runningBundleIds = new Set(); + const pids = new Map(); for (const line of launchctlOutput.split("\n")) { - const match = line.match(/UIKitApplication:([^\[]+)/); - if (match) { - runningBundleIds.add(match[1]); - } - } - - if (runningBundleIds.size === 0) { - throw new Error( - "No running apps detected on the simulator. Launch the app first using `launch-app`, then retry." - ); + // `52533\t0\tUIKitApplication:com.apple.mobilesafari[3658][rb-legacy]` + const match = line.match(/^\s*(\d+)\s+\S+\s+UIKitApplication:([^[\s]+)/); + if (match) pids.set(match[2], match[1]); } + return pids; +} +function listInstalledApps(udid: string): Record { let listAppsOutput: string; try { // Two stages, piped in code rather than by /bin/sh, so the udid is never @@ -93,12 +115,29 @@ function detectRunningApp(udid: string): string { `Verify the simulator is booted and responsive, then retry. Underlying error: ${msg}` ); } + return JSON.parse(listAppsOutput); +} + +/** + * Auto-detect the single running user app to profile, plus its host PID. + * Errors if zero or many user apps are running — same contract as before, now + * also returning the PID (used to scope a host-all-processes fallback trace). + * Only called when `app_process` was not pinned, so the simulator enumeration + * stays off the explicit-target path. + */ +function resolveRunningApp(udid: string): DetectedApp { + const runningPids = readRunningBundlePids(udid); - const installedApps: Record = JSON.parse(listAppsOutput); + if (runningPids.size === 0) { + throw new Error( + "No running apps detected on the simulator. Launch the app first using `launch-app`, then retry." + ); + } + const installedApps = listInstalledApps(udid); const runningUserApps: AppInfo[] = []; - for (const [, appInfo] of Object.entries(installedApps)) { - if (appInfo.ApplicationType === "User" && runningBundleIds.has(appInfo.CFBundleIdentifier)) { + for (const appInfo of Object.values(installedApps)) { + if (appInfo.ApplicationType === "User" && runningPids.has(appInfo.CFBundleIdentifier)) { runningUserApps.push(appInfo); } } @@ -121,7 +160,31 @@ function detectRunningApp(udid: string): string { ); } - return runningUserApps[0].CFBundleExecutable; + const app = runningUserApps[0]; + return { + executable: app.CFBundleExecutable, + pid: runningPids.get(app.CFBundleIdentifier) ?? null, + }; +} + +/** + * Best-effort host PID for an explicitly-pinned executable. Used lazily in the + * host-all-processes fallback (the happy/explicit path never enumerates). + * Returns null if the app isn't currently running or enumeration fails. + */ +function resolveAppPid(udid: string, executable: string): string | null { + try { + const runningPids = readRunningBundlePids(udid); + const installedApps = listInstalledApps(udid); + for (const app of Object.values(installedApps)) { + if (app.ApplicationType === "User" && app.CFBundleExecutable === executable) { + return runningPids.get(app.CFBundleIdentifier) ?? null; + } + } + } catch { + // fall through — fallback will report it could not resolve the PID + } + return null; } /** @@ -183,18 +246,28 @@ export function handleXctraceExit( api.lastExitInfo = { code, signal }; } -export const nativeProfilerStartTool: ToolDefinition< - z.infer, - { status: "recording"; pid: number; traceFile: string } -> = { +interface StartResult { + status: "recording"; + pid: number; + traceFile: string; + /** Which capture path was used (see NativeProfilerRecordingMode). */ + mode: NativeProfilerRecordingMode; + /** App host PID used to scope a host-all-processes trace; null otherwise. */ + processFilterPid: string | null; +} + +export const nativeProfilerStartTool: ToolDefinition, StartResult> = { id: "native-profiler-start", requires: ["xcrun"], capability: { apple: { simulator: true, device: true } }, + longRunning: true, description: `Start native profiling on a booted device. iOS: Instruments via xctrace (CPU, hangs, memory). Android: not yet supported. Auto-detects the running app process unless app_process is explicitly provided. After starting, let the user interact with the app, then call native-profiler-stop. Use when you want to capture native CPU, hang, and memory data for a running app. -Returns { status, pid, traceFile } confirming the recording has started. +Returns { status, pid, traceFile, mode } confirming the recording has started. +On simulators where the Instruments device tap is broken (Xcode 26.x cannot package --device traces), +it transparently falls back to a host all-processes Time Profiler recording scoped to the app's PID — CPU-only (no hangs/leaks); mode is then "host-all-processes". Fails if no app is running on the device, the platform is not supported yet, or the profiler cannot attach to the process.`, zodSchema, services: (params) => ({ @@ -208,7 +281,12 @@ Fails if no app is running on the device, the platform is not supported yet, or } const templatePath = params.template_path ?? DEFAULT_TEMPLATE_PATH; - const appProcess = params.app_process ?? detectRunningApp(params.device_id); + // Pinned target: trust it, don't enumerate the simulator (PID is resolved + // lazily only if the host fallback is actually needed). Otherwise auto-detect. + const detected: DetectedApp = params.app_process + ? { executable: params.app_process, pid: null } + : resolveRunningApp(params.device_id); + const appProcess = detected.executable; const debugDir = await getDebugDir(); const timestamp = new Date() @@ -220,26 +298,33 @@ Fails if no app is running on the device, the platform is not supported yet, or api.recordingTimedOut = false; api.recordingExitedUnexpectedly = false; api.lastExitInfo = null; - - const attemptStart = async (): Promise<{ child: ChildProcess; pid: number }> => { - api.appProcess = appProcess; + api.recordingMode = null; + api.processFilterPid = null; + + // Generic spawn + ready-wait, shared by the device-attach and host + // all-processes paths. `baseArgs` is the full xctrace argv minus the + // optional `--notify-tracing-started` pair, which we add here. + const attemptStart = async ( + baseArgs: string[] + ): Promise<{ child: ChildProcess; pid: number }> => { api.traceFile = outputFile; + // A previous failed attempt (a hung device-attach recording that had to be + // killed, or a partial bundle from a cold-start miss) can leave the .trace + // bundle on disk. xctrace refuses to record into an existing path + // ("Trace file already exists"), so clear it before each attempt — the + // path is uniquely timestamped per call, so this only ever removes our own + // stale output, never a real prior recording. + try { + rmSync(outputFile, { recursive: true, force: true }); + } catch { + // best-effort — if it can't be removed, xctrace will surface the error + } + const notifyName = `com.argent.ios-profiler.started.${process.pid}.${Date.now()}`; const notify = await registerStartupNotify(notifyName); - const xctraceArgs = [ - "record", - "--template", - templatePath, - "--device", - params.device_id, - "--attach", - appProcess, - "--output", - outputFile, - "--no-prompt", - ]; + const xctraceArgs = [...baseArgs]; if (notify) { xctraceArgs.push("--notify-tracing-started", notifyName); } @@ -272,14 +357,27 @@ Fails if no app is running on the device, the platform is not supported yet, or return { child: xctraceProcess, pid: xctraceProcess.pid }; }; - // Bounded retry scoped to this single call: xctrace's process resolver can - // miss a freshly cold-launched app even after launchd has registered it. - // Same shape as fetchWithReconnect in packages/argent-mcp/src/mcp-server.ts. + // Preferred path: attach to the app on the simulator/device tap (full + // fidelity — CPU, hangs, leaks). Bounded retry scoped to this single call: + // xctrace's process resolver can miss a freshly cold-launched app even + // after launchd has registered it. + const deviceArgs = [ + "record", + "--template", + templatePath, + "--device", + params.device_id, + "--attach", + appProcess, + "--output", + outputFile, + "--no-prompt", + ]; const startMs = Date.now(); - const startWithRetry = async (): Promise<{ child: ChildProcess; pid: number }> => { + const startDeviceAttach = async (): Promise<{ child: ChildProcess; pid: number }> => { for (let attempt = 1; attempt <= MAX_START_ATTEMPTS; attempt++) { try { - return await attemptStart(); + return await attemptStart(deviceArgs); } catch (err) { const msg = err instanceof Error ? err.message : String(err); const isColdStart = msg.includes(COLD_START_SIGNATURE); @@ -301,8 +399,71 @@ Fails if no app is running on the device, the platform is not supported yet, or ); }; - const { child: xctraceProcess, pid: xctracePid } = await startWithRetry(); + let xctraceProcess: ChildProcess; + let xctracePid: number; + let mode: NativeProfilerRecordingMode; + + try { + ({ child: xctraceProcess, pid: xctracePid } = await startDeviceAttach()); + mode = "device-attach"; + } catch (deviceErr) { + const deviceMsg = deviceErr instanceof Error ? deviceErr.message : String(deviceErr); + const isSimulator = resolveDevice(params.device_id).kind === "simulator"; + + // The host all-processes fallback only makes sense for simulators: their + // processes run on the host, so a host Time Profiler can see them. A + // physical device's processes are not host processes — nothing to fall + // back to. Surface the original error there. + if (!isSimulator) throw deviceErr; + + // Resolve the app's host PID (needed to scope the host trace). Auto-detect + // already has it; an explicit app_process resolves it lazily here. + const appPid = detected.pid ?? resolveAppPid(params.device_id, appProcess); + if (!appPid) { + throw new Error( + `${deviceMsg}\n\nThe simulator-targeted Instruments tap also could not be used, and the host ` + + `all-processes fallback needs the app's host PID, which could not be resolved (is "${appProcess}" actually running?). ` + + `Launch the app with launch-app and retry.` + ); + } + + process.stderr.write( + `[native-profiler] device-attach recording failed (${deviceMsg.split("\n")[0]}); ` + + `falling back to host all-processes Time Profiler scoped to ${appProcess} (pid ${appPid}). ` + + `This captures CPU only — hangs and leaks are unavailable in this mode.\n` + ); + + // Reset the flags the failed device attempts may have left set so the + // host recording starts from a clean session state. + api.recordingTimedOut = false; + api.recordingExitedUnexpectedly = false; + api.lastExitInfo = null; + + const hostArgs = [ + "record", + "--template", + HOST_FALLBACK_TEMPLATE, + "--all-processes", + "--output", + outputFile, + "--no-prompt", + ]; + try { + ({ child: xctraceProcess, pid: xctracePid } = await attemptStart(hostArgs)); + } catch (hostErr) { + const hostMsg = hostErr instanceof Error ? hostErr.message : String(hostErr); + throw new Error( + `Native profiling could not start.\n` + + `Device-attach failed: ${deviceMsg.split("\n")[0]}\n` + + `Host all-processes fallback also failed: ${hostMsg}` + ); + } + mode = "host-all-processes"; + api.processFilterPid = appPid; + } + api.appProcess = appProcess; + api.recordingMode = mode; api.profilingActive = true; api.wallClockStartMs = Date.now(); api.recordingTimeout = setTimeout(() => { @@ -324,6 +485,8 @@ Fails if no app is running on the device, the platform is not supported yet, or status: "recording", pid: xctracePid, traceFile: outputFile, + mode, + processFilterPid: api.processFilterPid, }; }, }; diff --git a/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-stop.ts b/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-stop.ts index d15d54dc..7730c55e 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-stop.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-stop.ts @@ -3,11 +3,13 @@ import type { ToolDefinition } from "@argent/registry"; import { nativeProfilerSessionRef, type NativeProfilerSessionApi, + type NativeProfilerRecordingMode, } from "../../../blueprints/native-profiler-session"; import { resolveDevice } from "../../../utils/device-info"; import { exportIosTraceData } from "../../../utils/ios-profiler/export"; import type { ExportDiagnostics } from "../../../utils/ios-profiler/export"; import { shutdownChild } from "../../../utils/ios-profiler/lifecycle"; +import { writeNativeProfilerMetadata } from "../../../utils/ios-profiler/metadata"; const STOP_GRACE_MS = 30_000; const STOP_TERM_MS = 5_000; @@ -21,6 +23,10 @@ interface StopResult { traceFile: string; exportedFiles: Record; exportDiagnostics: ExportDiagnostics; + /** How the recording was captured (see NativeProfilerRecordingMode). */ + mode: NativeProfilerRecordingMode | null; + /** App host PID used to scope a host-all-processes trace; null otherwise. */ + processFilterPid: string | null; warning?: string; } @@ -28,6 +34,7 @@ export const nativeProfilerStopTool: ToolDefinition, S id: "native-profiler-stop", requires: ["xcrun"], capability: { apple: { simulator: true, device: true } }, + longRunning: true, description: `Stop native profiling and export trace data to XML files. iOS: sends SIGINT to xctrace, waits for packaging, then exports CPU, hangs, and leaks data. Call native-profiler-start first. Use when the user has finished the interaction to profile and you need to export the trace. @@ -40,6 +47,13 @@ Fails if no active native-profiler-start session exists for the given device_id. async execute(services) { const api = services.session as NativeProfilerSessionApi; + // Host all-processes fallback is CPU-only — don't attempt hangs/leaks + // export there (they can't be recorded host-wide and only add noise). + const exportOptions = + api.recordingMode === "host-all-processes" ? { keys: ["cpu"] as const } : {}; + const mode: NativeProfilerRecordingMode | null = api.recordingMode; + const processFilterPid = api.processFilterPid; + // Recover a recording where xctrace is already gone but the trace bundle // is on disk: either the in-process 10-min cap fired, or xctrace exited // unexpectedly (attached app died, simulator daemon hiccup, etc). @@ -51,8 +65,13 @@ Fails if no active native-profiler-start session exists for the given device_id. api.recordingExitedUnexpectedly = false; api.lastExitInfo = null; - const { files: exportedFiles, diagnostics } = exportIosTraceData(traceFile); + const { files: exportedFiles, diagnostics } = exportIosTraceData(traceFile, exportOptions); api.exportedFiles = exportedFiles; + await writeNativeProfilerMetadata(traceFile, { + mode, + processFilterPid, + appProcess: api.appProcess, + }); const warning = wasTimeout ? "Recording timed out at 10 min cap; exported the partial trace. " + @@ -63,7 +82,14 @@ Fails if no active native-profiler-start session exists for the given device_id. "Call native-profiler-start again for a fresh recording."; process.stderr.write(`[native-profiler] ${warning}\n`); - return { traceFile, exportedFiles, exportDiagnostics: diagnostics, warning }; + return { + traceFile, + exportedFiles, + exportDiagnostics: diagnostics, + mode, + processFilterPid, + warning, + }; } if (!api.profilingActive || !api.xctraceProcess || !api.traceFile) { @@ -97,13 +123,20 @@ Fails if no active native-profiler-start session exists for the given device_id. api.recordingExitedUnexpectedly = false; api.lastExitInfo = null; - const { files: exportedFiles, diagnostics } = exportIosTraceData(api.traceFile); + const { files: exportedFiles, diagnostics } = exportIosTraceData(api.traceFile, exportOptions); api.exportedFiles = exportedFiles; + await writeNativeProfilerMetadata(api.traceFile, { + mode, + processFilterPid, + appProcess: api.appProcess, + }); const stopResult: StopResult = { traceFile: api.traceFile, exportedFiles, exportDiagnostics: diagnostics, + mode, + processFilterPid, }; if (warning) stopResult.warning = warning; return stopResult; diff --git a/packages/tool-server/src/tools/profiler/query/profiler-load.ts b/packages/tool-server/src/tools/profiler/query/profiler-load.ts index 400aed02..990f257b 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-load.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-load.ts @@ -13,6 +13,10 @@ import { import { resolveDevice } from "../../../utils/device-info"; import { readCommitTree } from "../../../utils/react-profiler/debug/dump"; import { runIosProfilerPipeline } from "../../../utils/ios-profiler/pipeline/index"; +import { + metadataPathForSession, + readNativeProfilerMetadata, +} from "../../../utils/ios-profiler/metadata"; import { getDebugDir } from "../../../utils/react-profiler/debug/dump"; // session_id is interpolated into on-disk file paths @@ -302,10 +306,29 @@ async function loadNativeSession( ); } - const { cpuSamples, uiHangs, cpuHotspots, memoryLeaks } = await runIosProfilerPipeline(files); + // Recover host-all-processes scoping from the sidecar written at stop time. + // Without it, a host-fallback trace reloaded on a fresh process would parse + // host-wide instead of app-scoped. Falls back to any live session state. + const meta = await readNativeProfilerMetadata(metadataPathForSession(debugDir, sessionId)); + const processFilterPid = meta?.processFilterPid ?? api.processFilterPid ?? null; + + const { cpuSamples, uiHangs, cpuHotspots, memoryLeaks } = await runIosProfilerPipeline( + files, + processFilterPid + ); api.parsedData = { cpuSamples, uiHangs, cpuHotspots, memoryLeaks }; api.exportedFiles = files; + if (meta) { + api.recordingMode = meta.mode; + api.processFilterPid = meta.processFilterPid; + if (meta.appProcess) api.appProcess = meta.appProcess; + } + + const scopeNote = + meta?.mode === "host-all-processes" + ? `\n- Mode: host all-processes fallback, scoped to ${meta.appProcess ?? "app"} (pid: ${meta.processFilterPid})` + : ""; const lines: string[] = [ `Loaded native profiler session \`${sessionId}\`.`, @@ -313,7 +336,7 @@ async function loadNativeSession( `- CPU samples: ${cpuSamples.length}`, `- UI hangs: ${uiHangs.length}`, `- CPU hotspots: ${cpuHotspots.length}`, - `- Memory leaks: ${memoryLeaks.length}`, + `- Memory leaks: ${memoryLeaks.length}${scopeNote}`, "", "Query tools (`profiler-stack-query`) are now ready to use against this data.", ]; diff --git a/packages/tool-server/src/utils/ios-profiler/export.ts b/packages/tool-server/src/utils/ios-profiler/export.ts index ae552dc7..0d991111 100644 --- a/packages/tool-server/src/utils/ios-profiler/export.ts +++ b/packages/tool-server/src/utils/ios-profiler/export.ts @@ -118,7 +118,20 @@ function tryCpuExportFallback( return false; } -export function exportIosTraceData(traceFile: string): { +export interface ExportOptions { + /** + * Restrict which tables to export. Defaults to all of cpu/hangs/leaks. The + * host all-processes fallback passes `["cpu"]` because Leaks/Allocations + * cannot record host-wide, so attempting hangs/leaks export there only + * produces noise in `diagnostics.errors`. + */ + keys?: readonly (keyof typeof EXPORTS)[]; +} + +export function exportIosTraceData( + traceFile: string, + options: ExportOptions = {} +): { files: Record; diagnostics: ExportDiagnostics; } { @@ -130,8 +143,11 @@ export function exportIosTraceData(traceFile: string): { }; const dir = path.dirname(traceFile); const baseName = path.basename(traceFile, ".trace"); + const selectedKeys: readonly (keyof typeof EXPORTS)[] = + options.keys ?? (Object.keys(EXPORTS) as Array); for (const [key, config] of Object.entries(EXPORTS)) { + if (!selectedKeys.includes(key as keyof typeof EXPORTS)) continue; const outPath = path.join(dir, `${baseName}${config.suffix}`); if (key === "cpu") { diff --git a/packages/tool-server/src/utils/ios-profiler/metadata.ts b/packages/tool-server/src/utils/ios-profiler/metadata.ts new file mode 100644 index 00000000..16dca726 --- /dev/null +++ b/packages/tool-server/src/utils/ios-profiler/metadata.ts @@ -0,0 +1,59 @@ +import { promises as fs } from "fs"; +import * as path from "path"; +import type { NativeProfilerRecordingMode } from "../../blueprints/native-profiler-session"; + +/** + * Sidecar metadata written next to a native-profiler trace's exported XML. + * + * Native profiler session state lives in-memory on whichever tool-server + * process ran `native-profiler-start`. If a *different* process later handles + * `profiler-load load_native` (multiple detached servers, or a restart), it has + * no idea the trace was a host all-processes capture that must be filtered to a + * specific PID. Persisting that here lets any process reconstruct an app-scoped + * analysis from disk alone. + */ +export interface NativeProfilerMetadata { + mode: NativeProfilerRecordingMode | null; + processFilterPid: string | null; + appProcess: string | null; +} + +/** `/native-profiler-.trace` → `/native-profiler-_meta.json`. */ +export function metadataPathForTrace(traceFile: string): string { + const dir = path.dirname(traceFile); + const baseName = path.basename(traceFile, ".trace"); + return path.join(dir, `${baseName}_meta.json`); +} + +/** `/native-profiler-_meta.json`. */ +export function metadataPathForSession(debugDir: string, sessionId: string): string { + return path.join(debugDir, `native-profiler-${sessionId}_meta.json`); +} + +export async function writeNativeProfilerMetadata( + traceFile: string, + meta: NativeProfilerMetadata +): Promise { + try { + await fs.writeFile(metadataPathForTrace(traceFile), JSON.stringify(meta, null, 2), "utf8"); + } catch { + // Non-fatal: the sidecar only powers cross-process disk reloads; the live + // session still has the data in memory. + } +} + +export async function readNativeProfilerMetadata( + metaPath: string +): Promise { + try { + const raw = await fs.readFile(metaPath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + return { + mode: parsed.mode ?? null, + processFilterPid: parsed.processFilterPid ?? null, + appProcess: parsed.appProcess ?? null, + }; + } catch { + return null; + } +} diff --git a/packages/tool-server/src/utils/ios-profiler/pipeline/index.ts b/packages/tool-server/src/utils/ios-profiler/pipeline/index.ts index b448d62b..e9d0fd18 100644 --- a/packages/tool-server/src/utils/ios-profiler/pipeline/index.ts +++ b/packages/tool-server/src/utils/ios-profiler/pipeline/index.ts @@ -11,16 +11,38 @@ export interface PipelineOutput { memoryLeaks: MemoryLeak[]; } +/** + * Keep only samples whose thread fmt belongs to `pid`. A host all-processes + * trace contains every process on the machine; xctrace tags each thread fmt + * with `(AppName, pid: N)`, so matching on `pid: N` scopes the trace to the + * profiled app. A no-op when `pid` is null (device-attach traces already + * contain only the attached app). + */ +export function filterSamplesByPid( + samples: T[], + pid: string | null +): T[] { + if (!pid) return samples; + // `pid: 52533)` — anchor on the closing paren so 52533 never matches 525330. + const needle = `pid: ${pid})`; + return samples.filter((s) => s.threadFmt.includes(needle)); +} + export async function runIosProfilerPipeline( - files: Record + files: Record, + processFilterPid: string | null = null ): Promise { // Stage 0: Parse all three XMLs in parallel - const [cpuSamples, rawHangs, rawLeaks] = await Promise.all([ + const [allCpuSamples, allRawHangs, rawLeaks] = await Promise.all([ parseCpuFile(files.cpu ?? null), parseHangsFile(files.hangs ?? null), parseLeaksFile(files.leaks ?? null), ]); + // Stage 0.5: Scope to the profiled app's PID for host all-processes traces. + const cpuSamples = filterSamplesByPid(allCpuSamples, processFilterPid); + const rawHangs = filterSamplesByPid(allRawHangs, processFilterPid); + // Stage 1: Correlate hangs with CPU samples const { uiHangs, hangSampleTimestamps } = correlateHangsWithCpu(rawHangs, cpuSamples); diff --git a/packages/tool-server/src/utils/ios-profiler/render.ts b/packages/tool-server/src/utils/ios-profiler/render.ts index 88438042..ba9b5739 100644 --- a/packages/tool-server/src/utils/ios-profiler/render.ts +++ b/packages/tool-server/src/utils/ios-profiler/render.ts @@ -16,6 +16,16 @@ interface RenderInput { payload: ProfilerPayload; traceFile: string | null; exportErrors?: Record; + /** + * Set when the analysis could not actually be performed (the data it depends + * on never exported). Renders an "Analysis Inconclusive" report instead of + * "All clear" and marks the structured result `status: "inconclusive"`. + */ + inconclusive?: { reason: string }; + /** Mode of the underlying recording — adds a short note + structured field. */ + mode?: "device-attach" | "host-all-processes"; + /** Optional one-line note about the capture mode (e.g. CPU-only fallback). */ + modeNote?: string; } interface InlineCap { @@ -32,24 +42,48 @@ export async function renderIosProfilerReport( const cpuHotspotsCount = payload.bottlenecks.filter((b) => b.type === "ios_cpu_hotspot").length; const uiHangsCount = payload.bottlenecks.filter((b) => b.type === "ios_ui_hang").length; + // Inconclusive overrides everything: there is nothing to report on because + // the underlying data never made it out of xctrace. + if (input.inconclusive) { + const inconclusiveReport = renderInconclusive( + payload, + input.inconclusive.reason, + input.exportErrors, + input.modeNote + ); + const reportFile = traceFile ? deriveReportPath(traceFile) : null; + const wroteFile = reportFile ? await writeReport(reportFile, inconclusiveReport) : false; + return { + report: inconclusiveReport, + reportFile: wroteFile ? reportFile : null, + bottlenecksTotal: 0, + status: "inconclusive", + mode: input.mode, + }; + } + const fullReport = bottlenecksTotal === 0 - ? renderAllClear(payload, input.exportErrors) - : renderFullReport(payload, input.exportErrors, { - hotspotLimit: Infinity, - hangLimit: Infinity, - }); + ? renderAllClear(payload, input.exportErrors, input.modeNote) + : renderFullReport( + payload, + input.exportErrors, + { hotspotLimit: Infinity, hangLimit: Infinity }, + input.modeNote + ); const reportFile = traceFile ? deriveReportPath(traceFile) : null; const wroteFile = reportFile ? await writeReport(reportFile, fullReport) : false; const inlineReport = bottlenecksTotal === 0 - ? renderAllClear(payload, input.exportErrors) - : renderFullReport(payload, input.exportErrors, { - hotspotLimit: MAX_INLINE_HOTSPOTS, - hangLimit: MAX_INLINE_HANGS, - }); + ? renderAllClear(payload, input.exportErrors, input.modeNote) + : renderFullReport( + payload, + input.exportErrors, + { hotspotLimit: MAX_INLINE_HOTSPOTS, hangLimit: MAX_INLINE_HANGS }, + input.modeNote + ); const shownHotspots = Math.min(MAX_INLINE_HOTSPOTS, cpuHotspotsCount); const shownHangs = Math.min(MAX_INLINE_HANGS, uiHangsCount); @@ -59,14 +93,58 @@ export async function renderIosProfilerReport( `\n\n> Full report: \`${reportFile}\` — ${bottlenecksTotal} bottleneck(s) total, showing top ${shownHotspots} CPU hotspots and top ${shownHangs} hangs inline. Use the Read tool to view all details.` : inlineReport; - return { report, reportFile: wroteFile ? reportFile : null, bottlenecksTotal }; + return { + report, + reportFile: wroteFile ? reportFile : null, + bottlenecksTotal, + status: "ok", + mode: input.mode, + }; +} + +function renderInconclusive( + payload: ProfilerPayload, + reason: string, + exportErrors?: Record, + modeNote?: string +): string { + const traceName = payload.metadata.traceFile + ? `\`${path.basename(payload.metadata.traceFile)}\`` + : "unknown"; + const lines = [ + `# iOS Instruments Analysis`, + ``, + `**Trace:** ${traceName} | **Platform:** ${payload.metadata.platform} | **Analyzed:** ${payload.metadata.timestamp}`, + ``, + ]; + if (modeNote) lines.push(`> ${modeNote}`, ``); + + const errorLines = renderExportErrors(exportErrors); + if (errorLines.length > 0) lines.push(...errorLines, ``); + + lines.push( + `---`, + ``, + `## ⚠️ Analysis Inconclusive`, + ``, + reason, + ``, + `This is **not** a clean result — the profiler had no trace data to analyze, so the absence ` + + `of findings means nothing. Re-run \`native-profiler-stop\` (check its \`exportDiagnostics\`), ` + + `or start a fresh \`native-profiler-start\` recording and interact with the app before stopping.` + ); + return lines.join("\n"); } // --------------------------------------------------------------------------- // Report builders // --------------------------------------------------------------------------- -function renderAllClear(payload: ProfilerPayload, exportErrors?: Record): string { +function renderAllClear( + payload: ProfilerPayload, + exportErrors?: Record, + modeNote?: string +): string { const traceName = payload.metadata.traceFile ? `\`${path.basename(payload.metadata.traceFile)}\`` : "unknown"; @@ -77,6 +155,8 @@ function renderAllClear(payload: ProfilerPayload, exportErrors?: Record ${modeNote}`, ``); + const errorLines = renderExportErrors(exportErrors); if (errorLines.length > 0) { lines.push(...errorLines, ``); @@ -95,7 +175,8 @@ function renderAllClear(payload: ProfilerPayload, exportErrors?: Record, - cap: InlineCap = { hotspotLimit: Infinity, hangLimit: Infinity } + cap: InlineCap = { hotspotLimit: Infinity, hangLimit: Infinity }, + modeNote?: string ): string { const traceName = payload.metadata.traceFile ? `\`${path.basename(payload.metadata.traceFile)}\`` @@ -116,6 +197,8 @@ function renderFullReport( ``, ]; + if (modeNote) lines.push(`> ${modeNote}`, ``); + const errorLines = renderExportErrors(exportErrors); if (errorLines.length > 0) { lines.push(...errorLines, ``); diff --git a/packages/tool-server/src/utils/ios-profiler/types.ts b/packages/tool-server/src/utils/ios-profiler/types.ts index 842e8c2a..af034623 100644 --- a/packages/tool-server/src/utils/ios-profiler/types.ts +++ b/packages/tool-server/src/utils/ios-profiler/types.ts @@ -95,4 +95,13 @@ export interface IosProfilerAnalyzeResult { report: string; reportFile: string | null; bottlenecksTotal: number; + /** + * `inconclusive` when no trace data could actually be analyzed (the export(s) + * the analysis depends on failed or produced no app samples). Distinguishes a + * genuine clean run from "the profiler had nothing to read" — the latter must + * never be reported as "All clear". + */ + status: "ok" | "inconclusive"; + /** Capture mode of the underlying recording, when known. */ + mode?: "device-attach" | "host-all-processes"; } diff --git a/packages/tool-server/test/ios-instruments/host-fallback.test.ts b/packages/tool-server/test/ios-instruments/host-fallback.test.ts new file mode 100644 index 00000000..a30a7859 --- /dev/null +++ b/packages/tool-server/test/ios-instruments/host-fallback.test.ts @@ -0,0 +1,177 @@ +/** + * Covers the host-all-processes fallback used when the simulator Instruments + * tap is broken (Xcode 26.x cannot package `--device` simulator traces): + * - CPU samples are scoped to the app's PID (the host trace contains every + * process), and + * - analyze reports "inconclusive" — never "All clear" — when the only data + * source (CPU) was empty or unreadable. + */ +import { describe, it, expect } from "vitest"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { + filterSamplesByPid, + runIosProfilerPipeline, +} from "../../src/utils/ios-profiler/pipeline/index"; +import { nativeProfilerAnalyzeTool } from "../../src/tools/profiler/native-profiler/native-profiler-analyze"; +import type { NativeProfilerSessionApi } from "../../src/blueprints/native-profiler-session"; + +// A host all-processes time-profile XML with two processes: the app (pid 111) +// and an unrelated host process (pid 222). Only the app rows must survive. +const HOST_CPU_XML = ` + + + + 1000000 + + 2000000 + + + + 2000000 + + 9000000 + + + +`; + +function hostSession(over: Partial): NativeProfilerSessionApi { + return { + deviceId: "TEST-DEVICE", + appProcess: "MyApp", + xctracePid: null, + xctraceProcess: null, + traceFile: "/tmp/fake.trace", + exportedFiles: null, + profilingActive: false, + wallClockStartMs: null, + parsedData: null, + recordingTimeout: null, + recordingTimedOut: false, + recordingExitedUnexpectedly: false, + lastExitInfo: null, + recordingMode: "host-all-processes", + processFilterPid: "111", + ...over, + }; +} + +describe("filterSamplesByPid", () => { + it("keeps only the matching pid and anchors on the closing paren", () => { + const samples = [ + { threadFmt: "Main Thread (MyApp, pid: 111)" }, + { threadFmt: "Main Thread (Other, pid: 222)" }, + { threadFmt: "Main Thread (Decoy, pid: 1110)" }, // must NOT match 111 + ]; + expect(filterSamplesByPid(samples, "111")).toEqual([ + { threadFmt: "Main Thread (MyApp, pid: 111)" }, + ]); + }); + + it("is a no-op when pid is null", () => { + const samples = [{ threadFmt: "a" }, { threadFmt: "b" }]; + expect(filterSamplesByPid(samples, null)).toBe(samples); + }); +}); + +describe("runIosProfilerPipeline with a process filter", () => { + it("scopes host all-processes CPU samples to the app pid", async () => { + const dir = await mkdtemp(join(tmpdir(), "host-fallback-")); + try { + const cpu = join(dir, "cpu.xml"); + await writeFile(cpu, HOST_CPU_XML, "utf8"); + + const unfiltered = await runIosProfilerPipeline({ cpu, hangs: null, leaks: null }); + expect(unfiltered.cpuSamples.length).toBe(2); // both processes present + + const filtered = await runIosProfilerPipeline({ cpu, hangs: null, leaks: null }, "111"); + expect(filtered.cpuSamples.length).toBe(1); + expect(filtered.cpuSamples[0].threadFmt).toContain("pid: 111)"); + // The dominant hotspot must be the app's function, not the louder host one. + expect(filtered.cpuHotspots.some((h) => h.dominantFunction === "appHotFunction")).toBe(true); + expect(filtered.cpuHotspots.some((h) => h.dominantFunction === "hostHotFunction")).toBe( + false + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +describe("native-profiler-analyze host fallback", () => { + it("reports app-scoped findings (not host-wide) when samples match the pid", async () => { + const dir = await mkdtemp(join(tmpdir(), "host-analyze-ok-")); + try { + const cpu = join(dir, "cpu.xml"); + await writeFile(cpu, HOST_CPU_XML, "utf8"); + const api = hostSession({ + traceFile: join(dir, "fake.trace"), + exportedFiles: { cpu, hangs: null, leaks: null }, + processFilterPid: "111", + }); + + const result = await nativeProfilerAnalyzeTool.execute( + { session: api }, + { device_id: "TEST-DEVICE" } + ); + + expect(result.status).toBe("ok"); + expect(result.mode).toBe("host-all-processes"); + expect(result.report).toContain("appHotFunction"); + expect(result.report).not.toContain("hostHotFunction"); + // The findings report must carry the CPU-only / app-scoped fallback note. + expect(result.report).toMatch(/host all-processes fallback/i); + // Must NOT warn about missing hangs/leaks — they're expected-absent here. + expect(result.report).not.toMatch(/Hangs export failed/i); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("is inconclusive (not All clear) when the CPU export is missing", async () => { + const api = hostSession({ exportedFiles: { cpu: null, hangs: null, leaks: null } }); + const result = await nativeProfilerAnalyzeTool.execute( + { session: api }, + { device_id: "TEST-DEVICE" } + ); + expect(result.status).toBe("inconclusive"); + expect(result.report).toContain("Analysis Inconclusive"); + expect(result.report).not.toContain("All clear"); + }); + + it("is inconclusive when CPU exported but no samples match the app pid", async () => { + const dir = await mkdtemp(join(tmpdir(), "host-analyze-nomatch-")); + try { + const cpu = join(dir, "cpu.xml"); + await writeFile(cpu, HOST_CPU_XML, "utf8"); + const api = hostSession({ + traceFile: join(dir, "fake.trace"), + exportedFiles: { cpu, hangs: null, leaks: null }, + processFilterPid: "999", // nothing in the trace + }); + const result = await nativeProfilerAnalyzeTool.execute( + { session: api }, + { device_id: "TEST-DEVICE" } + ); + expect(result.status).toBe("inconclusive"); + expect(result.report).not.toContain("All clear"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("device-attach mode with all exports null is inconclusive", async () => { + const api = hostSession({ + recordingMode: "device-attach", + processFilterPid: null, + exportedFiles: { cpu: null, hangs: null, leaks: null }, + }); + const result = await nativeProfilerAnalyzeTool.execute( + { session: api }, + { device_id: "TEST-DEVICE" } + ); + expect(result.status).toBe("inconclusive"); + }); +}); diff --git a/packages/tool-server/test/ios-instruments/stop-recovery.test.ts b/packages/tool-server/test/ios-instruments/stop-recovery.test.ts index 8b90de83..89ec7973 100644 --- a/packages/tool-server/test/ios-instruments/stop-recovery.test.ts +++ b/packages/tool-server/test/ios-instruments/stop-recovery.test.ts @@ -116,7 +116,8 @@ describe("native-profiler-stop recovery branch", () => { device_id: "DEVICE-UDID", }); - expect(mockedExport).toHaveBeenCalledWith(FAKE_TRACE); + // device-attach recording (recordingMode unset) → export all tables (default opts). + expect(mockedExport).toHaveBeenCalledWith(FAKE_TRACE, {}); expect(result.traceFile).toBe(FAKE_TRACE); expect(result.exportedFiles).toEqual(FAKE_EXPORT_RESULT.files); expect(result.exportDiagnostics).toEqual(FAKE_EXPORT_RESULT.diagnostics); diff --git a/packages/tool-server/test/native-profiler-missing-trace.test.ts b/packages/tool-server/test/native-profiler-missing-trace.test.ts index 6b3f7103..4fbf9461 100644 --- a/packages/tool-server/test/native-profiler-missing-trace.test.ts +++ b/packages/tool-server/test/native-profiler-missing-trace.test.ts @@ -47,6 +47,8 @@ describe("native-profiler-analyze: missing trace file", () => { recordingTimedOut: false, recordingExitedUnexpectedly: false, lastExitInfo: null, + recordingMode: null, + processFilterPid: null, }; const result = await nativeProfilerAnalyzeTool.execute(