From 13771fb7c6d3aa0b2597c8d7508702517a9feb66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Tue, 16 Jun 2026 13:39:50 +0200 Subject: [PATCH 01/28] feat(ios-profiler): attributable memory leaks via malloc_stack_logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit native-profiler-start gains an opt-in `malloc_stack_logging` flag. When set, it cold-launches the target app under xctrace with `--env MallocStackLogging=1` instead of attaching, so Instruments records allocation backtraces and leaks carry a real responsible frame + library. Without it leaks are detected but unattributable — Instruments reports "". Default behaviour is unchanged: attach to the running app, no relaunch, no overhead. The report now relabels unattributable leaks with a hint to re-run with malloc_stack_logging rather than surfacing the raw placeholder. - split detectRunningApp into reusable AppInfo helpers - resolve the .app bundle path via `simctl get_app_container` for --launch - terminate the running instance first for a clean cold start - tests: launch+env vs attach argv, and the unattributable-leak render --- .../native-profiler/native-profiler-start.ts | 10 ++ .../profiler/native-profiler/platforms/ios.ts | 148 ++++++++++++++--- .../src/utils/ios-profiler/render.ts | 31 +++- .../leak-attribution-render.test.ts | 56 +++++++ .../malloc-stack-logging.test.ts | 154 ++++++++++++++++++ 5 files changed, 376 insertions(+), 23 deletions(-) create mode 100644 packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts create mode 100644 packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts 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 bdde7fdfb..b5160c9b2 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 @@ -29,6 +29,16 @@ const zodSchema = z.object({ "iOS-only: path to an Instruments .tracetemplate file (defaults to bundled Argent template). " + "Ignored on Android." ), + malloc_stack_logging: z + .boolean() + .optional() + .describe( + "iOS-only. When true, cold-launches the app under the profiler with Malloc Stack Logging " + + "enabled so memory leaks carry an allocation backtrace (responsible frame + library). " + + "Without it, leaks are still detected but unattributable — Instruments reports " + + "''. Trade-offs: this RESTARTS the app (current state is lost) " + + "and adds memory/CPU overhead, so leave it off for pure CPU/hang profiling. Ignored on Android." + ), }); const capability = { diff --git a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts index 4386cb0cf..b5c0be446 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts @@ -189,6 +189,78 @@ function resolveExplicitApp(udid: string, name: string): DetectedApp { return { executable: name, pid: null }; } +function getInstalledApps(udid: string): Record { + let listAppsOutput: string; + try { + listAppsOutput = execSync(`xcrun simctl listapps ${udid} | plutil -convert json -o - -`, { + encoding: "utf-8", + timeout: DETECT_RUNNING_APP_TIMEOUT_MS, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error( + `Failed to list installed apps on simulator ${udid} within ${DETECT_RUNNING_APP_TIMEOUT_MS} ms. ` + + `Verify the simulator is booted and responsive, then retry. Underlying error: ${msg}`, + { cause: err } + ); + } + return JSON.parse(listAppsOutput); +} + +/** Resolve the .app bundle path xctrace's `--launch` needs (malloc_stack_logging mode). */ +function getAppBundlePath(udid: string, bundleId: string): string { + try { + return execSync(`xcrun simctl get_app_container ${udid} ${bundleId} app`, { + encoding: "utf-8", + timeout: DETECT_RUNNING_APP_TIMEOUT_MS, + }).trim(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error( + `Failed to resolve the .app bundle path for "${bundleId}" on simulator ${udid} ` + + `(required to cold-launch with malloc_stack_logging). Underlying error: ${msg}`, + { cause: err } + ); + } +} + +/** + * malloc_stack_logging cold-launches the app by .app path, which needs the bundle + * id. Resolve the target AppInfo: an explicit app_process is matched against + * installed user apps by CFBundleExecutable or display name; otherwise fall back + * to the single running user app (same disambiguation as detectRunningApp). + */ +function resolveAppForLaunch(udid: string, appProcess?: string): AppInfo { + if (appProcess) { + const installed = getInstalledApps(udid); + for (const [, info] of Object.entries(installed)) { + if ( + info.ApplicationType === "User" && + (info.CFBundleExecutable === appProcess || info.CFBundleDisplayName === appProcess) + ) { + return info; + } + } + throw new Error( + `No installed user app matching "${appProcess}" found on simulator ${udid}. ` + + `Pass the exact CFBundleExecutable or display name, or omit app_process to auto-detect the running app.` + ); + } + const runningUserApps = enumerateRunningUserApps(udid); + if (runningUserApps.length > 1) { + const appList = runningUserApps + .map( + ({ info }) => + ` - ${info.CFBundleExecutable} (${info.CFBundleIdentifier}${info.CFBundleDisplayName ? `, "${info.CFBundleDisplayName}"` : ""})` + ) + .join("\n"); + throw new Error( + `Multiple user apps are running on the simulator:\n${appList}\nSpecify \`app_process\` with the CFBundleExecutable or display name of the app you want to profile.` + ); + } + return runningUserApps[0].info; +} + async function registerStartupNotify(name: string): Promise { let handle: NotifyHandle; try { @@ -245,6 +317,7 @@ export interface IosStartParams { device_id: string; app_process?: string; template_path?: string; + malloc_stack_logging?: boolean; } export async function startNativeProfilerIos( @@ -256,13 +329,40 @@ export async function startNativeProfilerIos( } const templatePath = params.template_path ?? resolveDefaultTemplatePath(); - const detected = params.app_process - ? resolveExplicitApp(params.device_id, params.app_process) - : detectRunningApp(params.device_id); - const appProcess = detected.executable; - // Attach by PID when we know it (immune to Xcode 26.5's display-name `--attach` - // matching); fall back to the name when the target isn't running yet. - const attachTarget = detected.pid != null ? String(detected.pid) : appProcess; + + // Default flow attaches to the running app (preserves state, no overhead). + // malloc_stack_logging mode instead cold-launches the app *under* xctrace with + // MallocStackLogging=1 so the malloc library records allocation backtraces from + // the first allocation — without that, leaks are detected but unattributable + // (""). `--env` is only honoured with `--launch`, + // which needs the .app path rather than the executable name or PID. + const useMallocStackLogging = params.malloc_stack_logging === true; + let appProcess: string; + let attachTarget: string | null = null; + let launchBundlePath: string | null = null; + if (useMallocStackLogging) { + const info = resolveAppForLaunch(params.device_id, params.app_process); + appProcess = info.CFBundleExecutable; + launchBundlePath = getAppBundlePath(params.device_id, info.CFBundleIdentifier); + // Terminate any running instance so xctrace owns a clean cold launch with the + // env var set from process start (best-effort; not-running is fine). + try { + execSync(`xcrun simctl terminate ${params.device_id} ${info.CFBundleIdentifier}`, { + timeout: DETECT_RUNNING_APP_TIMEOUT_MS, + stdio: "ignore", + }); + } catch { + // app was not running — nothing to terminate + } + } else { + const detected = params.app_process + ? resolveExplicitApp(params.device_id, params.app_process) + : detectRunningApp(params.device_id); + appProcess = detected.executable; + // Attach by PID when we know it (immune to Xcode 26.5's display-name `--attach` + // matching); fall back to the name when the target isn't running yet. + attachTarget = detected.pid != null ? String(detected.pid) : appProcess; + } const debugDir = await getDebugDir(); const timestamp = new Date() @@ -282,20 +382,26 @@ export async function startNativeProfilerIos( 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", - attachTarget, - "--output", - outputFile, - "--no-prompt", - ]; - if (notify) { - xctraceArgs.push("--notify-tracing-started", notifyName); + const xctraceArgs = ["record", "--template", templatePath, "--device", params.device_id]; + if (launchBundlePath) { + // `--env` only applies to `--launch`, and the launched command must be the + // final argument (everything after `--` is the target plus its args). + xctraceArgs.push("--output", outputFile, "--no-prompt", "--env", "MallocStackLogging=1"); + if (notify) { + xctraceArgs.push("--notify-tracing-started", notifyName); + } + xctraceArgs.push("--launch", "--", launchBundlePath); + } else { + xctraceArgs.push( + "--attach", + attachTarget ?? appProcess, + "--output", + outputFile, + "--no-prompt" + ); + if (notify) { + xctraceArgs.push("--notify-tracing-started", notifyName); + } } const xctraceProcess = spawn("xctrace", xctraceArgs, { diff --git a/packages/tool-server/src/utils/ios-profiler/render.ts b/packages/tool-server/src/utils/ios-profiler/render.ts index fa7aafdc4..08bca1fed 100644 --- a/packages/tool-server/src/utils/ios-profiler/render.ts +++ b/packages/tool-server/src/utils/ios-profiler/render.ts @@ -25,6 +25,17 @@ interface InlineCap { hangLimit: number; } +/** + * A leak whose allocation backtrace was never captured: Instruments emits + * `` (malloc stack logging off) or `(null)`. Used to + * swap the raw placeholder for an actionable hint instead of surfacing it like a + * real frame. + */ +function isUnattributableLeakFrame(frame: string): boolean { + const f = (frame ?? "").trim().toLowerCase(); + return f === "" || f === "(null)" || f === "unknown" || f.includes("call stack limit reached"); +} + /** * Render a native profiler analysis report for iOS or Android payloads — * bottleneck rows are platform-agnostic, branching on `b.platform`/`b.type` @@ -354,10 +365,23 @@ function renderFullReport( `|---|---|---|---|---|---|---|` ); memoryLeaks.forEach((b, i) => { + const unattributed = isUnattributableLeakFrame(b.responsibleFrame); + const frameCell = unattributed ? "_no allocation stack_" : `\`${b.responsibleFrame}\``; + const libCell = unattributed ? "—" : b.responsibleLibrary || "—"; lines.push( - `| ${i + 1} | \`${b.objectType}\` | ${b.count} | ${formatBytes(b.totalSizeBytes)} | \`${b.responsibleFrame}\` | ${b.responsibleLibrary || "—"} | ${severityEmoji(b.severity)} |` + `| ${i + 1} | \`${b.objectType}\` | ${b.count} | ${formatBytes(b.totalSizeBytes)} | ${frameCell} | ${libCell} | ${severityEmoji(b.severity)} |` ); }); + if (memoryLeaks.some((b) => isUnattributableLeakFrame(b.responsibleFrame))) { + lines.push( + ``, + `> ⚠️ Leaks marked _no allocation stack_ were detected but can't be attributed: the ` + + `profiler attached to an already-running app, so Instruments has no allocation backtrace ` + + `(it reports \`\`). Re-run \`native-profiler-start\` with ` + + `\`malloc_stack_logging: true\` to cold-launch the app with Malloc Stack Logging — leaks ` + + `will then carry the responsible frame and library.` + ); + } } // RSS Growth section (Android-only weak signal) @@ -406,8 +430,11 @@ function renderFullReport( if (memoryLeaks.length > 0) { lines.push(`### Memory Leaks`, ``); for (const b of memoryLeaks) { + const via = isUnattributableLeakFrame(b.responsibleFrame) + ? `(no allocation stack — re-run with \`malloc_stack_logging: true\` to attribute)` + : `via \`${b.responsibleFrame}\``; lines.push( - `- ${severityEmoji(b.severity)} \`${b.objectType}\` x${b.count} (${formatBytes(b.totalSizeBytes)}) via \`${b.responsibleFrame}\`: Check for retain cycles or strong delegate references.` + `- ${severityEmoji(b.severity)} \`${b.objectType}\` x${b.count} (${formatBytes(b.totalSizeBytes)}) ${via}: Check for retain cycles or strong delegate references.` ); } lines.push(``); diff --git a/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts b/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts new file mode 100644 index 000000000..16fc51212 --- /dev/null +++ b/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { renderNativeProfilerReport } from "../../src/utils/ios-profiler/render"; +import type { MemoryLeak, ProfilerPayload } from "../../src/utils/ios-profiler/types"; + +// traceFile: null → no report file is written (pure, side-effect-free render), +// so we can assert the markdown directly. + +function leak(frame: string, library = ""): MemoryLeak { + return { + type: "memory_leak", + platform: "ios", + objectType: "Malloc 1008 Bytes", + totalSizeBytes: 1008, + count: 1, + responsibleFrame: frame, + responsibleLibrary: library, + severity: "RED", + }; +} + +function payload(leaks: MemoryLeak[]): ProfilerPayload { + return { + metadata: { traceFile: null, platform: "iOS", timestamp: "2026-06-16T00:00:00Z" }, + bottlenecks: leaks, + }; +} + +describe("leak attribution rendering", () => { + it("flags unattributable leaks and points at malloc_stack_logging", async () => { + const res = await renderNativeProfilerReport({ + payload: payload([leak("")]), + traceFile: null, + }); + expect(res.report).toContain("no allocation stack"); + expect(res.report).toContain("malloc_stack_logging: true"); + }); + + it("treats (null) as unattributable too", async () => { + const res = await renderNativeProfilerReport({ + payload: payload([leak("(null)", "(null)")]), + traceFile: null, + }); + expect(res.report).toContain("no allocation stack"); + }); + + it("shows the real frame + library for an attributed leak", async () => { + const frame = "hermes::vm::JSTypedArrayBase::createBuffer(...)"; + const res = await renderNativeProfilerReport({ + payload: payload([leak(frame, "hermes")]), + traceFile: null, + }); + expect(res.report).toContain(frame); + expect(res.report).toContain("hermes"); + expect(res.report).not.toContain("no allocation stack"); + }); +}); diff --git a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts new file mode 100644 index 000000000..6bbcc0a2c --- /dev/null +++ b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { EventEmitter } from "events"; +import type { NativeProfilerSessionApi } from "../../src/blueprints/native-profiler-session"; + +// Exercises the malloc_stack_logging launch path in startNativeProfilerIos +// directly (no blueprint/native-devtools imports) so we can assert the exact +// xctrace argv. Default mode must still `--attach`; malloc_stack_logging mode +// must cold-`--launch` the resolved .app with `--env MallocStackLogging=1`. + +class StartFakeChild extends EventEmitter { + pid = 4242; + stdout = new EventEmitter(); + stderr = new EventEmitter(); + kill = vi.fn(); +} + +const LISTAPPS_JSON = JSON.stringify({ + "com.example.myapp": { + CFBundleExecutable: "MyApp", + CFBundleIdentifier: "com.example.myapp", + ApplicationType: "User", + }, +}); + +function fakeApi(): NativeProfilerSessionApi { + return { + deviceId: "DEVICE-UDID", + platform: "ios", + appProcess: null, + capturePid: null, + captureProcess: null, + traceFile: null, + exportedFiles: null, + profilingActive: false, + wallClockStartMs: null, + parsedData: null, + recordingTimeout: null, + recordingTimedOut: false, + recordingExitedUnexpectedly: false, + lastExitInfo: null, + androidOnDeviceTracePath: null, + }; +} + +function mockChildProcess() { + const spawnFn = vi.fn(() => new StartFakeChild()); + const execSyncFn = vi.fn((cmd: string) => { + if (cmd.includes("listapps")) return LISTAPPS_JSON; + if (cmd.includes("launchctl list")) + return "1\t0\tUIKitApplication:com.example.myapp[abcd][rb-legacy]\n"; + if (cmd.includes("get_app_container")) return "/Users/x/Library/.../MyApp.app\n"; + if (cmd.includes("terminate")) return ""; + return ""; + }); + return { spawnFn, execSyncFn }; +} + +async function importStart() { + const mod = await import("../../src/tools/profiler/native-profiler/platforms/ios"); + return mod.startNativeProfilerIos; +} + +describe("native-profiler-start malloc_stack_logging", () => { + beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.doUnmock("child_process"); + vi.doUnmock("../../src/utils/react-profiler/debug/dump"); + vi.doUnmock("../../src/utils/ios-profiler/notify"); + vi.doUnmock("../../src/utils/ios-profiler/startup"); + }); + + function applyCommonMocks(spawnFn: unknown, execSyncFn: unknown) { + vi.doMock("child_process", () => ({ + spawn: spawnFn, + execSync: execSyncFn, + execFile: vi.fn(), + })); + vi.doMock("../../src/utils/react-profiler/debug/dump", () => ({ + getDebugDir: vi.fn(async () => "/tmp/argent-profiler-cwd"), + })); + vi.doMock("../../src/utils/ios-profiler/notify", () => ({ + listenForDarwinNotification: vi.fn(() => { + throw new Error("notifyutil unavailable in tests"); + }), + })); + vi.doMock("../../src/utils/ios-profiler/startup", () => ({ + waitForXctraceReady: vi.fn(async () => ({ stderrBuffer: "" })), + })); + } + + it("cold-launches the .app with MallocStackLogging when malloc_stack_logging is true", async () => { + const { spawnFn, execSyncFn } = mockChildProcess(); + applyCommonMocks(spawnFn, execSyncFn); + + const startNativeProfilerIos = await importStart(); + const api = fakeApi(); + const result = await startNativeProfilerIos(api, { + device_id: "DEVICE-UDID", + app_process: "MyApp", + malloc_stack_logging: true, + }); + + expect(result.status).toBe("recording"); + expect(spawnFn).toHaveBeenCalledTimes(1); + const [bin, args] = spawnFn.mock.calls[0] as unknown as [string, string[]]; + expect(bin).toBe("xctrace"); + + // launch + env, not attach + expect(args).toContain("--launch"); + expect(args).toContain("--env"); + expect(args[args.indexOf("--env") + 1]).toBe("MallocStackLogging=1"); + expect(args).not.toContain("--attach"); + // the launched target is the resolved .app bundle, and must be the LAST args + // (everything after `--` is the launched command). + const dashIdx = args.indexOf("--"); + expect(dashIdx).toBeGreaterThan(args.indexOf("--launch")); + expect(args[dashIdx + 1]).toBe("/Users/x/Library/.../MyApp.app"); + expect(dashIdx + 1).toBe(args.length - 1); + + // the running instance is terminated first for a clean cold start + const execCmds = execSyncFn.mock.calls.map((c) => String(c[0])); + expect( + execCmds.some((c) => c.includes("simctl terminate") && c.includes("com.example.myapp")) + ).toBe(true); + expect(execCmds.some((c) => c.includes("get_app_container"))).toBe(true); + }); + + it("attaches (no launch/env) by default", async () => { + const { spawnFn, execSyncFn } = mockChildProcess(); + applyCommonMocks(spawnFn, execSyncFn); + + const startNativeProfilerIos = await importStart(); + const api = fakeApi(); + await startNativeProfilerIos(api, { + device_id: "DEVICE-UDID", + app_process: "MyApp", + }); + + const [, args] = spawnFn.mock.calls[0] as unknown as [string, string[]]; + expect(args).toContain("--attach"); + // default mode attaches by host PID (Xcode 26.5 compat); the mock's launchctl yields pid 1 + expect(args[args.indexOf("--attach") + 1]).toBe("1"); + expect(args).not.toContain("--launch"); + expect(args).not.toContain("--env"); + // default attach mode never terminates the running app + const execCmds = execSyncFn.mock.calls.map((c) => String(c[0])); + expect(execCmds.some((c) => c.includes("simctl terminate"))).toBe(false); + }); +}); From 58a8d62c7e31ef39e1d942d20e1ff4748a1c3494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Tue, 16 Jun 2026 13:41:15 +0200 Subject: [PATCH 02/28] docs(ios-profiler): document malloc_stack_logging for attributable leaks Reference + native-profiler skill now explain the attach-vs-cold-launch trade-off and how to get attributable leaks. --- .../skills/argent-native-profiler/SKILL.md | 1 + .../ios-profiler/IOS_PROFILER_REFERENCE.md | 33 +++++++++++++------ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/skills/skills/argent-native-profiler/SKILL.md b/packages/skills/skills/argent-native-profiler/SKILL.md index b60fbe38a..40c01941f 100644 --- a/packages/skills/skills/argent-native-profiler/SKILL.md +++ b/packages/skills/skills/argent-native-profiler/SKILL.md @@ -27,6 +27,7 @@ After `native-profiler-analyze` surfaces findings, use `profiler-stack-query` to - **Hang detected** → `profiler-stack-query` mode=`hang_stacks` for full native call chains → mode=`function_callers` for the suspected function → read native source. - **CPU hotspot** → `profiler-stack-query` mode=`thread_breakdown` for per-thread distribution → mode=`function_callers` for the dominant function. - **Memory leak** → `profiler-stack-query` mode=`leak_stacks` filtered by `object_type` for responsible frames and libraries. + - iOS: if leaks come back unattributed (responsible frame ``), re-run `native-profiler-start` with `malloc_stack_logging: true`. This cold-launches the app with Malloc Stack Logging so leaks carry a real allocation backtrace (responsible frame + library). It restarts the app and adds overhead, so use it only when you need leak attribution — not for CPU/hang passes. After presenting findings, ask the user whether to investigate further, implement fixes, or stop. After applying fixes, always re-profile the same scenario and compare with `profiler-load`. Report honestly whether the target metric improved, regressed, or stayed flat. If the fix showed no net benefit or introduced regressions elsewhere, say so and reconsider. diff --git a/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md b/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md index 5d2aefb33..61208beb2 100644 --- a/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md +++ b/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md @@ -35,14 +35,14 @@ Three concerns are captured: **CPU hotspots**, **UI hangs**, and **memory leaks* `Argent.tracetemplate` enables the following Instruments. Identifiers are taken from the binary plist (`com.apple.xray.instrument-type.*` / `com.apple.dt-perfteam.*`): -| Instrument identifier | Common name | Used by Argent's pipeline? | -| ----------------------------------------------------- | --------------------------------------------------- | -------------------------------------------------------------- | -| `com.apple.xray.instrument-type.coresampler2` | **Time Profiler** (kernel-driven sampling profiler) | **Yes** — exported as the `time-profile` table → CPU hotspots. | -| `com.apple.dt-perfteam.hangs` | **Hangs** (main-thread responsiveness detector) | **Yes** — exported as the `potential-hangs` table → UI hangs. | -| `com.apple.xray.instrument-type.homeleaks` | **Leaks** (periodic leak scan) | **Yes** — exported via the `Leaks` track → memory leaks. | -| `com.apple.xray.instrument-type.oa` | **Allocations** (object lifetime + alloc tree) | Recorded but currently not consumed. | -| `com.apple.xray.instrument-type.poi` | **Points of Interest** (`os_signpost`) | Recorded but not consumed. | -| `com.apple.xray.instrument-type.device-thermal-state` | **Thermal State** | Recorded but not consumed. | +| Instrument identifier | Common name | Used by Argent's pipeline? | +| ----------------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `com.apple.xray.instrument-type.coresampler2` | **Time Profiler** (kernel-driven sampling profiler) | **Yes** — exported as the `time-profile` table → CPU hotspots. | +| `com.apple.dt-perfteam.hangs` | **Hangs** (main-thread responsiveness detector) | **Yes** — exported as the `potential-hangs` table → UI hangs. | +| `com.apple.xray.instrument-type.homeleaks` | **Leaks** (periodic leak scan) | **Yes** — exported via the `Leaks` track → memory leaks. Responsible frame/library is populated only when recorded with `malloc_stack_logging` (else ``). | +| `com.apple.xray.instrument-type.oa` | **Allocations** (object lifetime + alloc tree) | Recorded but not consumed directly; with `malloc_stack_logging` it supplies the allocation backtraces that make leaks attributable. | +| `com.apple.xray.instrument-type.poi` | **Points of Interest** (`os_signpost`) | Recorded but not consumed. | +| `com.apple.xray.instrument-type.device-thermal-state` | **Thermal State** | Recorded but not consumed. | > The hangs Instrument is the same engine that powers Xcode's "hang reports" — Apple's term for any stretch of time the main thread fails to service the run loop. @@ -56,14 +56,27 @@ All three are wired through `native-profiler-session` (per-device service, keyed 1. **Detect the target app** — runs `xcrun simctl spawn launchctl list`, parses `UIKitApplication:` lines, cross-references with `simctl listapps` to keep only `User` apps. Fails fast if zero or more than one app match. 2. **Resolve the template** — defaults to bundled `Argent.tracetemplate`, override via `template_path`. -3. **Spawn `xctrace record`**: +3. **Spawn `xctrace record`** — by default **attaches** to the running app: ``` xctrace record \ --template \ --device \ --attach \ - --output /argent-profiler-cwd/ios-profiler-.trace + --output /argent-profiler-cwd/native-profiler-.trace ``` + With `malloc_stack_logging: true`, it instead **cold-launches** the app so the + malloc library records allocation backtraces from the first allocation — + required for attributable leaks. `--env` is only honoured with `--launch`, and + the launched target must be the final argument: + ``` + xctrace record --template <…> --device \ + --env MallocStackLogging=1 --output <…> --no-prompt \ + --launch -- + ``` + The `.app` path is resolved via `simctl get_app_container`, and any running + instance is terminated first for a clean cold start. Trade-off: this restarts + the app and adds allocator overhead, so it stays opt-in — leave it off for + pure CPU/hang work, where attach (no relaunch, no overhead) is preferable. 4. **Start gating** — only resolves the tool call once `xctrace` prints `Starting recording` / `Ctrl-C to stop` on stdout. At that point Argent records `Date.now()` (`wallClockStartMs`) — the anchor used later for cross-tool time alignment. 5. **Safety timeout** — auto-SIGINTs after 10 minutes if `stop` is never called. From 9140f0fb4edb80f9725623cf42a64e2165f93d9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Thu, 18 Jun 2026 18:11:02 +0200 Subject: [PATCH 03/28] refactor(ios-profiler): dedup installed-apps listing in enumerateRunningUserApps enumerateRunningUserApps inlined the same simctl listapps | plutil | JSON.parse block that the new getInstalledApps helper already provides. Route it through the helper so the two can't drift. --- .../profiler/native-profiler/platforms/ios.ts | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts index b5c0be446..d75c754f9 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts @@ -113,22 +113,7 @@ function enumerateRunningUserApps(udid: string): { info: AppInfo; pid: number }[ ); } - let listAppsOutput: string; - try { - listAppsOutput = execSync(`xcrun simctl listapps ${udid} | plutil -convert json -o - -`, { - encoding: "utf-8", - timeout: DETECT_RUNNING_APP_TIMEOUT_MS, - }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - throw new Error( - `Failed to list installed apps on simulator ${udid} within ${DETECT_RUNNING_APP_TIMEOUT_MS} ms. ` + - `Verify the simulator is booted and responsive, then retry. Underlying error: ${msg}`, - { cause: err } - ); - } - - const installedApps: Record = JSON.parse(listAppsOutput); + const installedApps = getInstalledApps(udid); const runningUserApps: { info: AppInfo; pid: number }[] = []; for (const [, info] of Object.entries(installedApps)) { From 9e12335964aac639314edf5115ded0f65b471b7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Tue, 30 Jun 2026 15:27:16 +0200 Subject: [PATCH 04/28] fix(ios-profiler): guard malloc_stack_logging against degraded Xcode; FailureError consistency malloc_stack_logging cold-launches the app under `xctrace --device`, which is broken on Xcode 26.4-27.0 (the --device recording-start handshake records an empty trace). The path hardcoded `--device` and never consulted the capture strategy selector, so on those versions it terminated the running app and then captured nothing - surfaced only as a downstream "Analysis failed". - Refuse the malloc path up front (before terminating the app) when the selected capture strategy is not "device", honoring ARGENT_IOS_CAPTURE=device as an override. - Best-effort relaunch the app if the malloc start fails after terminate. - Wrap getAppBundlePath / resolveAppForLaunch plain Errors in FailureError so these reachable failures carry native-profiler error codes for telemetry. - Gate the cold-launch argv on the malloc flag, not launchBundlePath truthiness (removes a latent NPE); reject an empty resolved bundle path. - Fix the leak-attribution test fixture to use a real classifier sentinel. - Document the slow launch and the degraded-Xcode guard. Adds deterministic tests: a degraded Xcode refuses before terminate, and the ARGENT_IOS_CAPTURE=device override forces the cold launch through. --- packages/registry/src/failure-codes.ts | 3 + .../native-profiler/native-profiler-start.ts | 8 +- .../profiler/native-profiler/platforms/ios.ts | 104 ++++++++++++++++-- .../ios-profiler/IOS_PROFILER_REFERENCE.md | 13 ++- .../leak-attribution-render.test.ts | 5 +- .../malloc-stack-logging.test.ts | 81 ++++++++++++++ 6 files changed, 198 insertions(+), 16 deletions(-) diff --git a/packages/registry/src/failure-codes.ts b/packages/registry/src/failure-codes.ts index 5e166c9e8..af37d7460 100644 --- a/packages/registry/src/failure-codes.ts +++ b/packages/registry/src/failure-codes.ts @@ -160,6 +160,9 @@ export const FAILURE_CODES = { NATIVE_PROFILER_NO_RUNNING_APPS: "NATIVE_PROFILER_NO_RUNNING_APPS", NATIVE_PROFILER_NO_RUNNING_USER_APPS: "NATIVE_PROFILER_NO_RUNNING_USER_APPS", NATIVE_PROFILER_MULTIPLE_RUNNING_USER_APPS: "NATIVE_PROFILER_MULTIPLE_RUNNING_USER_APPS", + NATIVE_PROFILER_MALLOC_DEGRADED_XCODE: "NATIVE_PROFILER_MALLOC_DEGRADED_XCODE", + NATIVE_PROFILER_APP_BUNDLE_PATH_FAILED: "NATIVE_PROFILER_APP_BUNDLE_PATH_FAILED", + NATIVE_PROFILER_LAUNCH_APP_NOT_FOUND: "NATIVE_PROFILER_LAUNCH_APP_NOT_FOUND", NATIVE_PROFILER_SESSION_ALREADY_RUNNING: "NATIVE_PROFILER_SESSION_ALREADY_RUNNING", NATIVE_PROFILER_XCTRACE_NO_PID: "NATIVE_PROFILER_XCTRACE_NO_PID", NATIVE_PROFILER_XCTRACE_PROCESS_NOT_FOUND: "NATIVE_PROFILER_XCTRACE_PROCESS_NOT_FOUND", 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 b5160c9b2..818db4f35 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 @@ -36,8 +36,12 @@ const zodSchema = z.object({ "iOS-only. When true, cold-launches the app under the profiler with Malloc Stack Logging " + "enabled so memory leaks carry an allocation backtrace (responsible frame + library). " + "Without it, leaks are still detected but unattributable — Instruments reports " + - "''. Trade-offs: this RESTARTS the app (current state is lost) " + - "and adds memory/CPU overhead, so leave it off for pure CPU/hang profiling. Ignored on Android." + "''. Trade-offs: this RESTARTS the app (current state is lost), " + + "adds memory/CPU overhead, and makes the app noticeably slow to launch (every startup " + + "allocation records a backtrace), so leave it off for pure CPU/hang profiling. Requires a " + + "non-degraded Xcode: on Xcode 26.4–27.0 the cold-launch path is broken, so the call is " + + "rejected up front (re-run without the flag, or set ARGENT_IOS_CAPTURE=device to override). " + + "Ignored on Android." ), }); diff --git a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts index d783e70ae..1c8b001c9 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts @@ -1,4 +1,4 @@ -import { spawn, execSync, type ChildProcess } from "child_process"; +import { spawn, execSync, execFileSync, type ChildProcess } from "child_process"; import { FAILURE_CODES, FailureError, subprocessFailureMetadata } from "@argent/registry"; import { promises as fs } from "fs"; import { existsSync } from "node:fs"; @@ -232,19 +232,40 @@ function getInstalledApps(udid: string): Record { /** Resolve the .app bundle path xctrace's `--launch` needs (malloc_stack_logging mode). */ function getAppBundlePath(udid: string, bundleId: string): string { + let appPath: string; try { - return execSync(`xcrun simctl get_app_container ${udid} ${bundleId} app`, { + appPath = execSync(`xcrun simctl get_app_container ${udid} ${bundleId} app`, { encoding: "utf-8", timeout: DETECT_RUNNING_APP_TIMEOUT_MS, }).trim(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - throw new Error( + throw new FailureError( `Failed to resolve the .app bundle path for "${bundleId}" on simulator ${udid} ` + `(required to cold-launch with malloc_stack_logging). Underlying error: ${msg}`, - { cause: err } + { + error_code: FAILURE_CODES.NATIVE_PROFILER_APP_BUNDLE_PATH_FAILED, + failure_stage: "native_profiler_resolve_app_bundle_path", + failure_area: "tool_server", + error_kind: "subprocess", + ...subprocessFailureMetadata(err, "xcrun_simctl"), + }, + { cause: err instanceof Error ? err : new Error(String(err)) } + ); + } + if (!appPath) { + throw new FailureError( + `simctl resolved an empty .app bundle path for "${bundleId}" on simulator ${udid} ` + + `(required to cold-launch with malloc_stack_logging). Verify the app is installed.`, + { + error_code: FAILURE_CODES.NATIVE_PROFILER_APP_BUNDLE_PATH_FAILED, + failure_stage: "native_profiler_resolve_app_bundle_path", + failure_area: "tool_server", + error_kind: "not_found", + } ); } + return appPath; } /** @@ -264,9 +285,15 @@ function resolveAppForLaunch(udid: string, appProcess?: string): AppInfo { return info; } } - throw new Error( + throw new FailureError( `No installed user app matching "${appProcess}" found on simulator ${udid}. ` + - `Pass the exact CFBundleExecutable or display name, or omit app_process to auto-detect the running app.` + `Pass the exact CFBundleExecutable or display name, or omit app_process to auto-detect the running app.`, + { + error_code: FAILURE_CODES.NATIVE_PROFILER_LAUNCH_APP_NOT_FOUND, + failure_stage: "native_profiler_resolve_app_for_launch", + failure_area: "tool_server", + error_kind: "not_found", + } ); } const runningUserApps = enumerateRunningUserApps(udid); @@ -277,8 +304,14 @@ function resolveAppForLaunch(udid: string, appProcess?: string): AppInfo { ` - ${info.CFBundleExecutable} (${info.CFBundleIdentifier}${info.CFBundleDisplayName ? `, "${info.CFBundleDisplayName}"` : ""})` ) .join("\n"); - throw new Error( - `Multiple user apps are running on the simulator:\n${appList}\nSpecify \`app_process\` with the CFBundleExecutable or display name of the app you want to profile.` + throw new FailureError( + `Multiple user apps are running on the simulator:\n${appList}\nSpecify \`app_process\` with the CFBundleExecutable or display name of the app you want to profile.`, + { + error_code: FAILURE_CODES.NATIVE_PROFILER_MULTIPLE_RUNNING_USER_APPS, + failure_stage: "native_profiler_resolve_app_for_launch", + failure_area: "tool_server", + error_kind: "validation", + } ); } return runningUserApps[0].info; @@ -371,15 +404,42 @@ export async function startNativeProfilerIos( const useMallocStackLogging = params.malloc_stack_logging === true; let appProcess: string; let launchBundlePath: string | null = null; + // Bundle id of the app the malloc path terminated for its clean cold start, so a + // failed start can best-effort relaunch it instead of leaving the user's app dead. + let mallocRelaunchBundleId: string | null = null; // Normal (attach / all-processes) flow only — both stay null in // malloc_stack_logging mode, which cold-launches by .app path under `--device` // and is therefore already scoped without a capture strategy or detected PID. let detected: DetectedApp | null = null; let strategy: IosCaptureStrategy | null = null; if (useMallocStackLogging) { + // malloc_stack_logging must cold-launch the app under `xctrace --device --launch` + // (only `--launch` honours `--env MallocStackLogging=1`). On Xcode 26.4–27.0 the + // `--device` recording-start handshake is broken (see capture-strategy), so this + // would terminate the running app and then capture an empty trace — the opposite + // of the feature's purpose, surfaced only as a downstream "Analysis failed". Refuse + // up front, BEFORE touching the running app, unless the operator forces the device + // path via ARGENT_IOS_CAPTURE=device. selectIosCaptureStrategy() encodes exactly + // this decision (env override → degraded-version detection → device default). + if (selectIosCaptureStrategy().name !== "device") { + throw new FailureError( + `malloc_stack_logging needs to cold-launch the app under \`xctrace --device\`, but the ` + + `active Xcode has the --device recording-start deadlock (26.4–27.0), so it would ` + + `terminate your app and then capture an empty trace. Re-run without malloc_stack_logging ` + + `(leaks are still detected, just unattributed), profile on a non-degraded Xcode, or set ` + + `ARGENT_IOS_CAPTURE=device to force the device path if you know it works on your host.`, + { + error_code: FAILURE_CODES.NATIVE_PROFILER_MALLOC_DEGRADED_XCODE, + failure_stage: "native_profiler_start_malloc_capability", + failure_area: "tool_server", + error_kind: "validation", + } + ); + } const info = resolveAppForLaunch(params.device_id, params.app_process); appProcess = info.CFBundleExecutable; launchBundlePath = getAppBundlePath(params.device_id, info.CFBundleIdentifier); + mallocRelaunchBundleId = info.CFBundleIdentifier; // Terminate any running instance so xctrace owns a clean cold launch with the // env var set from process start (best-effort; not-running is fine). try { @@ -442,10 +502,11 @@ export async function startNativeProfilerIos( const notify = await registerStartupNotify(notifyName); let xctraceArgs: string[]; - if (launchBundlePath) { + if (useMallocStackLogging) { // malloc_stack_logging cold launch: `--env` only applies to `--launch`, and // the launched command must be the final argument (everything after `--` is - // the target plus its args). + // the target plus its args). The degraded-Xcode guard above guarantees the + // `--device` path is viable here (or ARGENT_IOS_CAPTURE=device forced it). xctraceArgs = [ "record", "--template", @@ -461,7 +522,7 @@ export async function startNativeProfilerIos( if (notify) { xctraceArgs.push("--notify-tracing-started", notifyName); } - xctraceArgs.push("--launch", "--", launchBundlePath); + xctraceArgs.push("--launch", "--", launchBundlePath!); } else { // Normal flow: let the selected capture strategy (device --attach by PID, or // host-wide --all-processes) build the argv. @@ -542,7 +603,26 @@ export async function startNativeProfilerIos( ); }; - const { child: xctraceProcess, pid: xctracePid } = await startWithRetry(); + let started: { child: ChildProcess; pid: number }; + try { + started = await startWithRetry(); + } catch (err) { + // malloc_stack_logging terminated the running app for a clean cold start. If the + // capture never started, best-effort relaunch it so we don't leave the user with a + // dead app — the default attach path never terminates, so only this path needs it. + if (mallocRelaunchBundleId) { + try { + execFileSync("xcrun", ["simctl", "launch", params.device_id, mallocRelaunchBundleId], { + timeout: DETECT_RUNNING_APP_TIMEOUT_MS, + stdio: "ignore", + }); + } catch { + // best-effort restore; surface the original start failure regardless + } + } + throw err; + } + const { child: xctraceProcess, pid: xctracePid } = started; api.profilingActive = true; api.wallClockStartMs = Date.now(); diff --git a/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md b/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md index e808939ca..65cd8172f 100644 --- a/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md +++ b/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md @@ -75,8 +75,19 @@ All three are wired through `native-profiler-session` (per-device service, keyed ``` The `.app` path is resolved via `simctl get_app_container`, and any running instance is terminated first for a clean cold start. Trade-off: this restarts - the app and adds allocator overhead, so it stays opt-in — leave it off for + the app, adds allocator overhead, and makes the app slow to launch (every + startup allocation records a backtrace), so it stays opt-in — leave it off for pure CPU/hang work, where attach (no relaunch, no overhead) is preferable. + **Degraded-Xcode guard:** the cold launch needs `xctrace --device`, which is + broken on Xcode 26.4–27.0 (the `--device` recording-start handshake records an + empty trace). Because the malloc path terminates the running app before + launching, `native-profiler-start { malloc_stack_logging: true }` is **rejected + up front** on those versions (before the app is touched) rather than silently + capturing nothing — re-run without the flag (leaks are still detected, just + unattributed) or set `ARGENT_IOS_CAPTURE=device` to force it on a known-good + host. The non-malloc path already routes around this via the capture-strategy + selector (`--all-processes` fallback), but that fallback cannot `--launch`, so + it cannot substitute for the malloc cold start. 4. **Start gating** — only resolves the tool call once `xctrace` prints `Starting recording` / `Ctrl-C to stop` on stdout. At that point Argent records `Date.now()` (`wallClockStartMs`) — the anchor used later for cross-tool time alignment. 5. **Safety timeout** — auto-SIGINTs after 10 minutes if `stop` is never called. diff --git a/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts b/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts index 67e948d38..715cdd880 100644 --- a/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts +++ b/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts @@ -41,8 +41,11 @@ describe("leak attribution rendering", () => { }); it("does not surface an unattributed leak as an attributed one", async () => { + // Use a real classifier sentinel ("" → isLeakAttributed === false) so the + // fixture's attributed:false matches what the pipeline would actually assign; + // a non-sentinel frame like "(null)" classifies as attributed (RED). const res = await renderNativeProfilerReport({ - payload: payload([leak(false, "(null)", "(null)")]), + payload: payload([leak(false, "")]), traceFile: null, }); expect(res.report).toContain("No attributed leaks"); diff --git a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts index 761b74e9f..adfdf19bc 100644 --- a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts +++ b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { EventEmitter } from "events"; +import { FAILURE_CODES, getFailureSignal } from "@argent/registry"; import type { NativeProfilerSessionApi } from "../../src/blueprints/native-profiler-session"; // Exercises the malloc_stack_logging launch path in startNativeProfilerIos @@ -80,6 +81,7 @@ describe("native-profiler-start malloc_stack_logging", () => { spawn: spawnFn, execSync: execSyncFn, execFile: vi.fn(), + execFileSync: vi.fn(), })); vi.doMock("../../src/utils/react-profiler/debug/dump", () => ({ getDebugDir: vi.fn(async () => "/tmp/argent-profiler-cwd"), @@ -152,4 +154,83 @@ describe("native-profiler-start malloc_stack_logging", () => { const execCmds = execSyncFn.mock.calls.map((c) => String(c[0])); expect(execCmds.some((c) => c.includes("simctl terminate"))).toBe(false); }); + + // A degraded Xcode (26.4–27.0) is reported by `xcodebuild -version`, which the + // capture-strategy selector reads. The malloc cold launch needs `--device`, + // which is broken on those versions, so the start must be refused UP FRONT — + // before the running app is terminated and before any xctrace spawn. + function mockChildProcessDegraded() { + const spawnFn = vi.fn(() => new StartFakeChild()); + const execSyncFn = vi.fn((cmd: string) => { + if (cmd.includes("xcodebuild")) return "Xcode 26.5\nBuild version 17F42"; + if (cmd.includes("listapps")) return LISTAPPS_JSON; + if (cmd.includes("launchctl list")) + return "1\t0\tUIKitApplication:com.example.myapp[abcd][rb-legacy]\n"; + if (cmd.includes("get_app_container")) return "/Users/x/Library/.../MyApp.app\n"; + if (cmd.includes("terminate")) return ""; + return ""; + }); + return { spawnFn, execSyncFn }; + } + + it("refuses malloc_stack_logging on a degraded Xcode before terminating the app", async () => { + const prev = process.env.ARGENT_IOS_CAPTURE; + delete process.env.ARGENT_IOS_CAPTURE; + try { + const { spawnFn, execSyncFn } = mockChildProcessDegraded(); + applyCommonMocks(spawnFn, execSyncFn); + + const startNativeProfilerIos = await importStart(); + const api = fakeApi(); + const err = await startNativeProfilerIos(api, { + device_id: "DEVICE-UDID", + app_process: "MyApp", + malloc_stack_logging: true, + }).then( + () => null, + (e: unknown) => e + ); + + // It must fail, with the degraded-Xcode failure code (telemetry-classified). + expect(err).toBeTruthy(); + expect(getFailureSignal(err)?.error_code).toBe( + FAILURE_CODES.NATIVE_PROFILER_MALLOC_DEGRADED_XCODE + ); + // And critically: it never touched the app or xctrace — no terminate, no + // bundle-path resolution, no spawn. The app the user had running is intact. + const execCmds = execSyncFn.mock.calls.map((c) => String(c[0])); + expect(execCmds.some((c) => c.includes("simctl terminate"))).toBe(false); + expect(execCmds.some((c) => c.includes("get_app_container"))).toBe(false); + expect(spawnFn).not.toHaveBeenCalled(); + } finally { + if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE; + else process.env.ARGENT_IOS_CAPTURE = prev; + } + }); + + it("ARGENT_IOS_CAPTURE=device forces the malloc cold launch through on a degraded Xcode", async () => { + const prev = process.env.ARGENT_IOS_CAPTURE; + process.env.ARGENT_IOS_CAPTURE = "device"; + try { + const { spawnFn, execSyncFn } = mockChildProcessDegraded(); + applyCommonMocks(spawnFn, execSyncFn); + + const startNativeProfilerIos = await importStart(); + const api = fakeApi(); + const result = await startNativeProfilerIos(api, { + device_id: "DEVICE-UDID", + app_process: "MyApp", + malloc_stack_logging: true, + }); + + // The override bypasses the guard, so the cold launch proceeds as usual. + expect(result.status).toBe("recording"); + const [, args] = spawnFn.mock.calls[0] as unknown as [string, string[]]; + expect(args).toContain("--launch"); + expect(args).toContain("--env"); + } finally { + if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE; + else process.env.ARGENT_IOS_CAPTURE = prev; + } + }); }); From 0c1b27460b7a0c0dafa70f0fe125d9405b14ee8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Tue, 30 Jun 2026 15:37:25 +0200 Subject: [PATCH 05/28] test(ios-profiler): cover best-effort relaunch when malloc cold launch fails after terminate --- .../malloc-stack-logging.test.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts index adfdf19bc..580d65f7a 100644 --- a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts +++ b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts @@ -233,4 +233,65 @@ describe("native-profiler-start malloc_stack_logging", () => { else process.env.ARGENT_IOS_CAPTURE = prev; } }); + + it("best-effort relaunches the app when the malloc cold launch fails after terminate", async () => { + const prev = process.env.ARGENT_IOS_CAPTURE; + delete process.env.ARGENT_IOS_CAPTURE; + try { + const spawnFn = vi.fn(() => new StartFakeChild()); + // Non-degraded Xcode → the guard passes and the path reaches the cold launch. + const execSyncFn = vi.fn((cmd: string) => { + if (cmd.includes("xcodebuild")) return "Xcode 16.4\nBuild version 16F6"; + if (cmd.includes("listapps")) return LISTAPPS_JSON; + if (cmd.includes("launchctl list")) + return "1\t0\tUIKitApplication:com.example.myapp[abcd][rb-legacy]\n"; + if (cmd.includes("get_app_container")) return "/Users/x/Library/.../MyApp.app\n"; + if (cmd.includes("terminate")) return ""; + return ""; + }); + const execFileSyncFn = vi.fn(); + vi.doMock("child_process", () => ({ + spawn: spawnFn, + execSync: execSyncFn, + execFile: vi.fn(), + execFileSync: execFileSyncFn, + })); + vi.doMock("../../src/utils/react-profiler/debug/dump", () => ({ + getDebugDir: vi.fn(async () => "/tmp/argent-profiler-cwd"), + })); + vi.doMock("../../src/utils/ios-profiler/notify", () => ({ + listenForDarwinNotification: vi.fn(() => { + throw new Error("notifyutil unavailable in tests"); + }), + })); + // Force the capture start to fail *after* the app has been terminated. + vi.doMock("../../src/utils/ios-profiler/startup", () => ({ + waitForXctraceReady: vi.fn(async () => { + throw new Error("xctrace exited before recording started"); + }), + })); + + const startNativeProfilerIos = await importStart(); + const api = fakeApi(); + const err = await startNativeProfilerIos(api, { + device_id: "DEVICE-UDID", + app_process: "MyApp", + malloc_stack_logging: true, + }).then( + () => null, + (e: unknown) => e + ); + + // The start failed (error surfaced) AND the terminated app was relaunched. + expect(err).toBeTruthy(); + const relaunch = execFileSyncFn.mock.calls.find( + (c) => Array.isArray(c[1]) && (c[1] as string[]).includes("launch") + ); + expect(relaunch).toBeTruthy(); + expect(relaunch![1]).toEqual(["simctl", "launch", "DEVICE-UDID", "com.example.myapp"]); + } finally { + if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE; + else process.env.ARGENT_IOS_CAPTURE = prev; + } + }); }); From 90956cde316c74e32dd448c53df6a9f0123692eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Wed, 1 Jul 2026 13:33:58 +0200 Subject: [PATCH 06/28] fix(ios-profiler): resolve the debug dir before terminating the app for a malloc cold start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getDebugDir() (which mkdir's the debug dir) ran after the malloc path terminated the running app but before the relaunch-protected start attempt. If that mkdir threw (ENOSPC / EACCES), the app was left killed with no relaunch — the best-effort relaunch only wraps startWithRetry. Hoist the debug-dir/output-path resolution above the branch so any mkdir failure happens before the app is touched. Adds a regression test asserting a getDebugDir failure never terminates the app. --- .../profiler/native-profiler/platforms/ios.ts | 20 ++++++---- .../malloc-stack-logging.test.ts | 39 +++++++++++++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts index 1c8b001c9..2bae96731 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts @@ -412,6 +412,19 @@ export async function startNativeProfilerIos( // and is therefore already scoped without a capture strategy or detected PID. let detected: DetectedApp | null = null; let strategy: IosCaptureStrategy | null = null; + + // Resolve the trace output path (which creates the debug dir) BEFORE the branch + // below. The malloc path terminates the running app for a clean cold start; if + // getDebugDir()'s mkdir failed AFTER that terminate, the app would be left dead + // with no relaunch (the best-effort relaunch only guards the start attempt). + // Doing it here means any mkdir failure happens before the app is touched. + const debugDir = await getDebugDir(); + const timestamp = new Date() + .toISOString() + .replace(/[-:T]/g, (m) => (m === "T" ? "-" : "")) + .slice(0, 15); + const outputFile = path.join(debugDir, `native-profiler-${timestamp}.trace`); + if (useMallocStackLogging) { // malloc_stack_logging must cold-launch the app under `xctrace --device --launch` // (only `--launch` honours `--env MallocStackLogging=1`). On Xcode 26.4–27.0 the @@ -479,13 +492,6 @@ export async function startNativeProfilerIos( } } - const debugDir = await getDebugDir(); - const timestamp = new Date() - .toISOString() - .replace(/[-:T]/g, (m) => (m === "T" ? "-" : "")) - .slice(0, 15); - const outputFile = path.join(debugDir, `native-profiler-${timestamp}.trace`); - api.recordingTimedOut = false; api.recordingExitedUnexpectedly = false; api.lastExitInfo = null; diff --git a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts index 580d65f7a..89d490907 100644 --- a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts +++ b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts @@ -133,6 +133,45 @@ describe("native-profiler-start malloc_stack_logging", () => { expect(execCmds.some((c) => c.includes("get_app_container"))).toBe(true); }); + it("does not terminate the app if the debug dir can't be created (malloc mode)", async () => { + // getDebugDir()'s mkdir runs BEFORE the terminate, so a failure (e.g. ENOSPC) + // must leave the running app untouched — never killed-without-relaunch. + const { spawnFn, execSyncFn } = mockChildProcess(); + vi.doMock("child_process", () => ({ + spawn: spawnFn, + execSync: execSyncFn, + execFile: vi.fn(), + execFileSync: vi.fn(), + })); + vi.doMock("../../src/utils/react-profiler/debug/dump", () => ({ + getDebugDir: vi.fn(async () => { + throw new Error("ENOSPC: no space left on device"); + }), + })); + vi.doMock("../../src/utils/ios-profiler/notify", () => ({ + listenForDarwinNotification: vi.fn(() => { + throw new Error("notifyutil unavailable in tests"); + }), + })); + vi.doMock("../../src/utils/ios-profiler/startup", () => ({ + waitForXctraceReady: vi.fn(async () => ({ stderrBuffer: "" })), + })); + + const startNativeProfilerIos = await importStart(); + const api = fakeApi(); + await expect( + startNativeProfilerIos(api, { + device_id: "DEVICE-UDID", + app_process: "MyApp", + malloc_stack_logging: true, + }) + ).rejects.toThrow(/ENOSPC/); + + const execCmds = execSyncFn.mock.calls.map((c) => String(c[0])); + expect(execCmds.some((c) => c.includes("simctl terminate"))).toBe(false); + expect(spawnFn).not.toHaveBeenCalled(); + }); + it("attaches (no launch/env) by default", async () => { const { spawnFn, execSyncFn } = mockChildProcess(); applyCommonMocks(spawnFn, execSyncFn); From 5aebc2199726cfcbb192d56a4ddd8d5ed2425c29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Wed, 1 Jul 2026 22:17:46 +0200 Subject: [PATCH 07/28] fix(ios-profiler): attribute malloc refusal accurately, restore only killed apps Address review feedback on the malloc_stack_logging path: - Add a side-effect-free resolveIosCaptureStrategy() that returns the chosen strategy plus WHY (env-override vs degraded-xcode vs default). The malloc degraded-Xcode guard now uses it, so it no longer emits selectIosCaptureStrategy's "using the all-processes capture fallback" stderr line immediately before throwing. - When the strategy isn't `device` because the operator forced ARGENT_IOS_CAPTURE=all-processes on a healthy host, refuse with a distinct message and a new NATIVE_PROFILER_MALLOC_STRATEGY_OVERRIDE code instead of blaming a degraded Xcode that isn't present (fixes misleading telemetry). - Only mark the terminated app for best-effort relaunch after the simctl terminate actually succeeds. An installed-but-not-running named app is no longer foregrounded on a failed cold launch (restore only what we killed). - Tests: cover the LAUNCH_APP_NOT_FOUND and MULTIPLE_RUNNING_USER_APPS malloc branches, assert --device is threaded into the cold-launch argv, assert the override-vs-degraded attribution, and assert the degraded refusal emits no "capture fallback" stderr line. - Docs: note the forced-override refusal in IOS_PROFILER_REFERENCE.md. --- packages/registry/src/failure-codes.ts | 1 + .../profiler/native-profiler/platforms/ios.ts | 75 ++++-- .../ios-profiler/IOS_PROFILER_REFERENCE.md | 5 +- .../ios-profiler/capture-strategy/index.ts | 7 +- .../ios-profiler/capture-strategy/select.ts | 112 +++++++-- .../malloc-stack-logging.test.ts | 218 ++++++++++++++++++ 6 files changed, 374 insertions(+), 44 deletions(-) diff --git a/packages/registry/src/failure-codes.ts b/packages/registry/src/failure-codes.ts index af37d7460..23142cfc4 100644 --- a/packages/registry/src/failure-codes.ts +++ b/packages/registry/src/failure-codes.ts @@ -161,6 +161,7 @@ export const FAILURE_CODES = { NATIVE_PROFILER_NO_RUNNING_USER_APPS: "NATIVE_PROFILER_NO_RUNNING_USER_APPS", NATIVE_PROFILER_MULTIPLE_RUNNING_USER_APPS: "NATIVE_PROFILER_MULTIPLE_RUNNING_USER_APPS", NATIVE_PROFILER_MALLOC_DEGRADED_XCODE: "NATIVE_PROFILER_MALLOC_DEGRADED_XCODE", + NATIVE_PROFILER_MALLOC_STRATEGY_OVERRIDE: "NATIVE_PROFILER_MALLOC_STRATEGY_OVERRIDE", NATIVE_PROFILER_APP_BUNDLE_PATH_FAILED: "NATIVE_PROFILER_APP_BUNDLE_PATH_FAILED", NATIVE_PROFILER_LAUNCH_APP_NOT_FOUND: "NATIVE_PROFILER_LAUNCH_APP_NOT_FOUND", NATIVE_PROFILER_SESSION_ALREADY_RUNNING: "NATIVE_PROFILER_SESSION_ALREADY_RUNNING", diff --git a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts index 2bae96731..5cc0f8436 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts @@ -16,7 +16,9 @@ import { shutdownChild } from "../../../../utils/profiler-shared/lifecycle"; import { runIosProfilerPipeline } from "../../../../utils/ios-profiler/pipeline/index"; import { selectIosCaptureStrategy, + resolveIosCaptureStrategy, type IosCaptureStrategy, + type CaptureStrategyReason, } from "../../../../utils/ios-profiler/capture-strategy"; import type { NativeProfilerAnalyzeResult } from "../../../../utils/ios-profiler/types"; import { renderNativeProfilerReport } from "../../../../utils/ios-profiler/render"; @@ -370,6 +372,47 @@ export function handleXctraceExit( api.lastExitInfo = { code, signal }; } +/** + * malloc_stack_logging must cold-launch the app under `xctrace --device --launch`. + * When the resolved capture strategy is NOT `device`, the cold launch can't run, so + * we refuse — but attribute the refusal to the ACTUAL cause so the message and the + * telemetry `error_code` don't blame a degraded Xcode that may not be present: + * - `env-override` → the operator forced `ARGENT_IOS_CAPTURE=all-processes`; + * - `degraded-xcode` → the active Xcode has the `--device` recording-start deadlock. + */ +function mallocNonDeviceStrategyError(reason: CaptureStrategyReason): FailureError { + if (reason.kind === "env-override") { + return new FailureError( + `malloc_stack_logging must cold-launch the app under \`xctrace --device\`, but ` + + `ARGENT_IOS_CAPTURE="${reason.strategyName}" forces the "${reason.strategyName}" capture ` + + `strategy, which attaches host-wide and cannot \`--launch\` a cold start. Unset ` + + `ARGENT_IOS_CAPTURE (or set it to "device") to use malloc_stack_logging, or re-run without ` + + `malloc_stack_logging (leaks are still detected, just unattributed).`, + { + error_code: FAILURE_CODES.NATIVE_PROFILER_MALLOC_STRATEGY_OVERRIDE, + failure_stage: "native_profiler_start_malloc_capability", + failure_area: "tool_server", + error_kind: "validation", + } + ); + } + const versionNote = + reason.kind === "degraded-xcode" ? `Xcode ${reason.major}.${reason.minor}` : "the active Xcode"; + return new FailureError( + `malloc_stack_logging needs to cold-launch the app under \`xctrace --device\`, but ` + + `${versionNote} has the --device recording-start deadlock (26.4–27.0), so it would ` + + `terminate your app and then capture an empty trace. Re-run without malloc_stack_logging ` + + `(leaks are still detected, just unattributed), profile on a non-degraded Xcode, or set ` + + `ARGENT_IOS_CAPTURE=device to force the device path if you know it works on your host.`, + { + error_code: FAILURE_CODES.NATIVE_PROFILER_MALLOC_DEGRADED_XCODE, + failure_stage: "native_profiler_start_malloc_capability", + failure_area: "tool_server", + error_kind: "validation", + } + ); +} + export interface IosStartParams { device_id: string; app_process?: string; @@ -432,27 +475,18 @@ export async function startNativeProfilerIos( // would terminate the running app and then capture an empty trace — the opposite // of the feature's purpose, surfaced only as a downstream "Analysis failed". Refuse // up front, BEFORE touching the running app, unless the operator forces the device - // path via ARGENT_IOS_CAPTURE=device. selectIosCaptureStrategy() encodes exactly - // this decision (env override → degraded-version detection → device default). - if (selectIosCaptureStrategy().name !== "device") { - throw new FailureError( - `malloc_stack_logging needs to cold-launch the app under \`xctrace --device\`, but the ` + - `active Xcode has the --device recording-start deadlock (26.4–27.0), so it would ` + - `terminate your app and then capture an empty trace. Re-run without malloc_stack_logging ` + - `(leaks are still detected, just unattributed), profile on a non-degraded Xcode, or set ` + - `ARGENT_IOS_CAPTURE=device to force the device path if you know it works on your host.`, - { - error_code: FAILURE_CODES.NATIVE_PROFILER_MALLOC_DEGRADED_XCODE, - failure_stage: "native_profiler_start_malloc_capability", - failure_area: "tool_server", - error_kind: "validation", - } - ); + // path via ARGENT_IOS_CAPTURE=device. Use the SIDE-EFFECT-FREE resolver so this + // guard doesn't emit selectIosCaptureStrategy()'s "using the all-processes + // fallback" stderr line immediately before throwing (that fallback never runs + // here); the reason it returns also lets the refusal name its actual cause + // (forced override vs. degraded Xcode) rather than always blaming the Xcode. + const captureDecision = resolveIosCaptureStrategy(); + if (captureDecision.strategy.name !== "device") { + throw mallocNonDeviceStrategyError(captureDecision.reason); } const info = resolveAppForLaunch(params.device_id, params.app_process); appProcess = info.CFBundleExecutable; launchBundlePath = getAppBundlePath(params.device_id, info.CFBundleIdentifier); - mallocRelaunchBundleId = info.CFBundleIdentifier; // Terminate any running instance so xctrace owns a clean cold launch with the // env var set from process start (best-effort; not-running is fine). try { @@ -460,8 +494,13 @@ export async function startNativeProfilerIos( timeout: DETECT_RUNNING_APP_TIMEOUT_MS, stdio: "ignore", }); + // The terminate SUCCEEDED, so the app was actually running and we own killing + // it. Only now mark it for best-effort relaunch on a later start failure — if + // the app was NOT running (terminate throws below), relaunching would foreground + // an app the user never had open, the opposite of "restore what we killed". + mallocRelaunchBundleId = info.CFBundleIdentifier; } catch { - // app was not running — nothing to terminate + // app was not running — nothing to terminate, and nothing to restore } } else { detected = params.app_process diff --git a/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md b/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md index 65cd8172f..ebc84a7d5 100644 --- a/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md +++ b/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md @@ -87,7 +87,10 @@ All three are wired through `native-profiler-session` (per-device service, keyed unattributed) or set `ARGENT_IOS_CAPTURE=device` to force it on a known-good host. The non-malloc path already routes around this via the capture-strategy selector (`--all-processes` fallback), but that fallback cannot `--launch`, so - it cannot substitute for the malloc cold start. + it cannot substitute for the malloc cold start. Forcing + `ARGENT_IOS_CAPTURE=all-processes` on a healthy host is refused the same way, but + the error names the forced override as the cause (its own failure code) rather + than blaming a degraded Xcode that isn't present. 4. **Start gating** — only resolves the tool call once `xctrace` prints `Starting recording` / `Ctrl-C to stop` on stdout. At that point Argent records `Date.now()` (`wallClockStartMs`) — the anchor used later for cross-tool time alignment. 5. **Safety timeout** — auto-SIGINTs after 10 minutes if `stop` is never called. diff --git a/packages/tool-server/src/utils/ios-profiler/capture-strategy/index.ts b/packages/tool-server/src/utils/ios-profiler/capture-strategy/index.ts index f92aa8596..b9949bbba 100644 --- a/packages/tool-server/src/utils/ios-profiler/capture-strategy/index.ts +++ b/packages/tool-server/src/utils/ios-profiler/capture-strategy/index.ts @@ -1,4 +1,9 @@ export type { IosCaptureStrategy, CaptureTarget, RecordArgsInput } from "./types"; export { deviceStrategy } from "./device"; export { allProcessesStrategy } from "./all-processes"; -export { selectIosCaptureStrategy } from "./select"; +export { + selectIosCaptureStrategy, + resolveIosCaptureStrategy, + type CaptureStrategyDecision, + type CaptureStrategyReason, +} from "./select"; diff --git a/packages/tool-server/src/utils/ios-profiler/capture-strategy/select.ts b/packages/tool-server/src/utils/ios-profiler/capture-strategy/select.ts index 64e94485c..e7bbde09c 100644 --- a/packages/tool-server/src/utils/ios-profiler/capture-strategy/select.ts +++ b/packages/tool-server/src/utils/ios-profiler/capture-strategy/select.ts @@ -23,6 +23,40 @@ interface XcodeVersion { minor: number; } +/** + * Why a given strategy was chosen. Callers that need to explain or gate on the + * decision (e.g. the malloc_stack_logging guard, which rejects when the strategy + * isn't `device`) can attribute the outcome accurately instead of assuming it is + * always a degraded Xcode. + */ +export type CaptureStrategyReason = + | { kind: "env-override"; strategyName: IosCaptureStrategy["name"] } + | { kind: "degraded-xcode"; major: number; minor: number } + | { kind: "default" }; + +export interface CaptureStrategyDecision { + strategy: IosCaptureStrategy; + reason: CaptureStrategyReason; + /** Set when ARGENT_IOS_CAPTURE held an unrecognised value that was ignored. */ + invalidOverride?: string; +} + +type OverrideParse = + | { kind: "device" } + | { kind: "all-processes" } + | { kind: "none" } + | { kind: "invalid"; raw: string }; + +function parseEnvOverride(): OverrideParse { + const raw = process.env[ENV_OVERRIDE]?.trim().toLowerCase(); + if (!raw) return { kind: "none" }; + if (raw === "device") return { kind: "device" }; + if (raw === "all-processes" || raw === "all_processes" || raw === "allprocesses") { + return { kind: "all-processes" }; + } + return { kind: "invalid", raw }; +} + function readActiveXcodeVersion(): XcodeVersion | null { try { // `xcodebuild -version` honours DEVELOPER_DIR / xcode-select and prints @@ -52,38 +86,68 @@ function isDegraded({ major, minor }: XcodeVersion): boolean { return major >= 27; } -function fromEnv(): IosCaptureStrategy | null { - const raw = process.env[ENV_OVERRIDE]?.trim().toLowerCase(); - if (!raw) return null; - if (raw === "device") return deviceStrategy; - if (raw === "all-processes" || raw === "all_processes" || raw === "allprocesses") { - return allProcessesStrategy; +/** + * Resolve the capture strategy **and why** it was chosen, with **no side effects** + * — nothing is written to stderr. Callers that log the decision (the normal record + * flow, via {@link selectIosCaptureStrategy}) can do so; callers that may reject + * the decision outright (the malloc_stack_logging guard) get the reason without a + * misleading "using the all-processes fallback" line that never actually happens. + */ +export function resolveIosCaptureStrategy(): CaptureStrategyDecision { + const override = parseEnvOverride(); + if (override.kind === "device") { + return { + strategy: deviceStrategy, + reason: { kind: "env-override", strategyName: deviceStrategy.name }, + }; + } + if (override.kind === "all-processes") { + return { + strategy: allProcessesStrategy, + reason: { kind: "env-override", strategyName: allProcessesStrategy.name }, + }; + } + + const invalidOverride = override.kind === "invalid" ? override.raw : undefined; + + const version = readActiveXcodeVersion(); + if (version && isDegraded(version)) { + return { + strategy: allProcessesStrategy, + reason: { kind: "degraded-xcode", major: version.major, minor: version.minor }, + invalidOverride, + }; } - process.stderr.write( - `[native-profiler] ignoring unrecognised ${ENV_OVERRIDE}="${raw}" ` + - `(expected "device" or "all-processes"); falling back to auto-detection.\n` - ); - return null; + + return { strategy: deviceStrategy, reason: { kind: "default" }, invalidOverride }; } export function selectIosCaptureStrategy(): IosCaptureStrategy { - const override = fromEnv(); - if (override) { + const decision = resolveIosCaptureStrategy(); + + if (decision.invalidOverride) { process.stderr.write( - `[native-profiler] using "${override.name}" capture (forced via ${ENV_OVERRIDE}).\n` + `[native-profiler] ignoring unrecognised ${ENV_OVERRIDE}="${decision.invalidOverride}" ` + + `(expected "device" or "all-processes"); falling back to auto-detection.\n` ); - return override; } - const version = readActiveXcodeVersion(); - if (version && isDegraded(version)) { - process.stderr.write( - `[native-profiler] Xcode ${version.major}.${version.minor} has the xctrace ` + - `--device recording-start deadlock; using the "${allProcessesStrategy.name}" ` + - `capture fallback. Override with ${ENV_OVERRIDE}=device.\n` - ); - return allProcessesStrategy; + switch (decision.reason.kind) { + case "env-override": + process.stderr.write( + `[native-profiler] using "${decision.strategy.name}" capture (forced via ${ENV_OVERRIDE}).\n` + ); + break; + case "degraded-xcode": + process.stderr.write( + `[native-profiler] Xcode ${decision.reason.major}.${decision.reason.minor} has the xctrace ` + + `--device recording-start deadlock; using the "${allProcessesStrategy.name}" ` + + `capture fallback. Override with ${ENV_OVERRIDE}=device.\n` + ); + break; + case "default": + break; } - return deviceStrategy; + return decision.strategy; } diff --git a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts index 89d490907..f184b8d26 100644 --- a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts +++ b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts @@ -118,6 +118,12 @@ describe("native-profiler-start malloc_stack_logging", () => { expect(args).toContain("--env"); expect(args[args.indexOf("--env") + 1]).toBe("MallocStackLogging=1"); expect(args).not.toContain("--attach"); + // --device with the correct udid is the whole premise of the degraded-Xcode + // guard (the cold launch can only run on the device path), so assert it is + // present and threaded through — a regression that dropped it or passed the + // wrong device would otherwise slip past. + expect(args).toContain("--device"); + expect(args[args.indexOf("--device") + 1]).toBe("DEVICE-UDID"); // the launched target is the resolved .app bundle, and must be the LAST args // (everything after `--` is the launched command). const dashIdx = args.indexOf("--"); @@ -215,6 +221,16 @@ describe("native-profiler-start malloc_stack_logging", () => { it("refuses malloc_stack_logging on a degraded Xcode before terminating the app", async () => { const prev = process.env.ARGENT_IOS_CAPTURE; delete process.env.ARGENT_IOS_CAPTURE; + // The guard must not first emit the "using the all-processes capture fallback" + // stderr line (that fallback never runs here — the block throws instead), which + // would read as though the fallback is about to run, directly followed by an abort. + const stderrWrites: string[] = []; + const stderrSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation((chunk: string | Uint8Array) => { + stderrWrites.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString()); + return true; + }); try { const { spawnFn, execSyncFn } = mockChildProcessDegraded(); applyCommonMocks(spawnFn, execSyncFn); @@ -241,7 +257,10 @@ describe("native-profiler-start malloc_stack_logging", () => { expect(execCmds.some((c) => c.includes("simctl terminate"))).toBe(false); expect(execCmds.some((c) => c.includes("get_app_container"))).toBe(false); expect(spawnFn).not.toHaveBeenCalled(); + // No misleading "fallback is about to run" log preceded the refusal. + expect(stderrWrites.some((w) => w.includes("capture fallback"))).toBe(false); } finally { + stderrSpy.mockRestore(); if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE; else process.env.ARGENT_IOS_CAPTURE = prev; } @@ -333,4 +352,203 @@ describe("native-profiler-start malloc_stack_logging", () => { else process.env.ARGENT_IOS_CAPTURE = prev; } }); + + it("does NOT relaunch a not-running named app when the malloc cold launch fails", async () => { + // resolveAppForLaunch accepts an installed-but-not-running app_process, so the + // preceding `simctl terminate` is a no-op. A failed start must then NOT foreground + // an app the user never had open — only an app we actually killed gets restored. + const prev = process.env.ARGENT_IOS_CAPTURE; + delete process.env.ARGENT_IOS_CAPTURE; + try { + const spawnFn = vi.fn(() => new StartFakeChild()); + const execSyncFn = vi.fn((cmd: string) => { + if (cmd.includes("xcodebuild")) return "Xcode 16.4\nBuild version 16F6"; + if (cmd.includes("listapps")) return LISTAPPS_JSON; + // Installed but NOT running → terminate fails exactly like real simctl does. + if (cmd.includes("terminate")) throw new Error("found nothing to terminate"); + if (cmd.includes("get_app_container")) return "/Users/x/Library/.../MyApp.app\n"; + return ""; + }); + const execFileSyncFn = vi.fn(); + vi.doMock("child_process", () => ({ + spawn: spawnFn, + execSync: execSyncFn, + execFile: vi.fn(), + execFileSync: execFileSyncFn, + })); + vi.doMock("../../src/utils/react-profiler/debug/dump", () => ({ + getDebugDir: vi.fn(async () => "/tmp/argent-profiler-cwd"), + })); + vi.doMock("../../src/utils/ios-profiler/notify", () => ({ + listenForDarwinNotification: vi.fn(() => { + throw new Error("notifyutil unavailable in tests"); + }), + })); + // Cold launch fails AFTER the (no-op) terminate. + vi.doMock("../../src/utils/ios-profiler/startup", () => ({ + waitForXctraceReady: vi.fn(async () => { + throw new Error("xctrace exited before recording started"); + }), + })); + + const startNativeProfilerIos = await importStart(); + const api = fakeApi(); + const err = await startNativeProfilerIos(api, { + device_id: "DEVICE-UDID", + app_process: "MyApp", + malloc_stack_logging: true, + }).then( + () => null, + (e: unknown) => e + ); + + // The start failure still surfaces... + expect(err).toBeTruthy(); + // ...but no best-effort relaunch fired, because we never actually killed it. + const relaunched = execFileSyncFn.mock.calls.some( + (c) => Array.isArray(c[1]) && (c[1] as string[]).includes("launch") + ); + expect(relaunched).toBe(false); + } finally { + if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE; + else process.env.ARGENT_IOS_CAPTURE = prev; + } + }); + + it("attributes the malloc refusal to a forced override, not a degraded Xcode, under ARGENT_IOS_CAPTURE=all-processes", async () => { + // On a perfectly healthy Xcode, the only reason the strategy isn't `device` is + // the forced override — so the refusal must say so (and carry a distinct code), + // not blame a --device deadlock that isn't present. + const prev = process.env.ARGENT_IOS_CAPTURE; + process.env.ARGENT_IOS_CAPTURE = "all-processes"; + try { + const spawnFn = vi.fn(() => new StartFakeChild()); + const execSyncFn = vi.fn((cmd: string) => { + if (cmd.includes("xcodebuild")) return "Xcode 16.4\nBuild version 16F6"; + if (cmd.includes("listapps")) return LISTAPPS_JSON; + if (cmd.includes("launchctl list")) + return "1\t0\tUIKitApplication:com.example.myapp[abcd][rb-legacy]\n"; + if (cmd.includes("get_app_container")) return "/Users/x/Library/.../MyApp.app\n"; + if (cmd.includes("terminate")) return ""; + return ""; + }); + applyCommonMocks(spawnFn, execSyncFn); + + const startNativeProfilerIos = await importStart(); + const api = fakeApi(); + const err = await startNativeProfilerIos(api, { + device_id: "DEVICE-UDID", + app_process: "MyApp", + malloc_stack_logging: true, + }).then( + () => null, + (e: unknown) => e + ); + + expect(err).toBeTruthy(); + // Distinct telemetry code for the forced-override cause, not degraded-Xcode. + expect(getFailureSignal(err)?.error_code).toBe( + FAILURE_CODES.NATIVE_PROFILER_MALLOC_STRATEGY_OVERRIDE + ); + const msg = err instanceof Error ? err.message : String(err); + expect(msg).toContain("ARGENT_IOS_CAPTURE"); + expect(msg).not.toMatch(/deadlock|26\.4/); + // Refused up front — the healthy app is untouched. + const execCmds = execSyncFn.mock.calls.map((c) => String(c[0])); + expect(execCmds.some((c) => c.includes("simctl terminate"))).toBe(false); + expect(execCmds.some((c) => c.includes("get_app_container"))).toBe(false); + expect(spawnFn).not.toHaveBeenCalled(); + } finally { + if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE; + else process.env.ARGENT_IOS_CAPTURE = prev; + } + }); + + it("throws LAUNCH_APP_NOT_FOUND when a malloc app_process matches no installed user app", async () => { + const prev = process.env.ARGENT_IOS_CAPTURE; + delete process.env.ARGENT_IOS_CAPTURE; + try { + // Default mock leaves `xcodebuild` unmocked → version undetermined → device + // strategy, so the degraded-Xcode guard passes and we reach app resolution. + const { spawnFn, execSyncFn } = mockChildProcess(); + applyCommonMocks(spawnFn, execSyncFn); + + const startNativeProfilerIos = await importStart(); + const api = fakeApi(); + const err = await startNativeProfilerIos(api, { + device_id: "DEVICE-UDID", + app_process: "GhostApp", // not present in LISTAPPS_JSON + malloc_stack_logging: true, + }).then( + () => null, + (e: unknown) => e + ); + + expect(err).toBeTruthy(); + expect(getFailureSignal(err)?.error_code).toBe( + FAILURE_CODES.NATIVE_PROFILER_LAUNCH_APP_NOT_FOUND + ); + // Resolution failed before any destructive/side-effecting action. + const execCmds = execSyncFn.mock.calls.map((c) => String(c[0])); + expect(execCmds.some((c) => c.includes("simctl terminate"))).toBe(false); + expect(execCmds.some((c) => c.includes("get_app_container"))).toBe(false); + expect(spawnFn).not.toHaveBeenCalled(); + } finally { + if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE; + else process.env.ARGENT_IOS_CAPTURE = prev; + } + }); + + it("throws MULTIPLE_RUNNING_USER_APPS in malloc mode when no app_process and several apps run", async () => { + const prev = process.env.ARGENT_IOS_CAPTURE; + delete process.env.ARGENT_IOS_CAPTURE; + try { + const twoApps = JSON.stringify({ + "com.example.myapp": { + CFBundleExecutable: "MyApp", + CFBundleIdentifier: "com.example.myapp", + ApplicationType: "User", + }, + "com.example.other": { + CFBundleExecutable: "OtherApp", + CFBundleIdentifier: "com.example.other", + ApplicationType: "User", + }, + }); + const spawnFn = vi.fn(() => new StartFakeChild()); + const execSyncFn = vi.fn((cmd: string) => { + if (cmd.includes("listapps")) return twoApps; + if (cmd.includes("launchctl list")) + return ( + "1\t0\tUIKitApplication:com.example.myapp[a][rb]\n" + + "2\t0\tUIKitApplication:com.example.other[b][rb]\n" + ); + if (cmd.includes("get_app_container")) return "/Users/x/Library/.../MyApp.app\n"; + if (cmd.includes("terminate")) return ""; + return ""; + }); + applyCommonMocks(spawnFn, execSyncFn); + + const startNativeProfilerIos = await importStart(); + const api = fakeApi(); + const err = await startNativeProfilerIos(api, { + device_id: "DEVICE-UDID", + malloc_stack_logging: true, // no app_process → auto-detect → ambiguous + }).then( + () => null, + (e: unknown) => e + ); + + expect(err).toBeTruthy(); + expect(getFailureSignal(err)?.error_code).toBe( + FAILURE_CODES.NATIVE_PROFILER_MULTIPLE_RUNNING_USER_APPS + ); + const execCmds = execSyncFn.mock.calls.map((c) => String(c[0])); + expect(execCmds.some((c) => c.includes("simctl terminate"))).toBe(false); + expect(spawnFn).not.toHaveBeenCalled(); + } finally { + if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE; + else process.env.ARGENT_IOS_CAPTURE = prev; + } + }); }); From 73fafa4925357f44e19aabdca17b3a9718aba5da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Thu, 2 Jul 2026 00:15:32 +0200 Subject: [PATCH 08/28] refactor(ios-profiler): use execFileSync for get_app_container and terminate --- .../profiler/native-profiler/platforms/ios.ts | 4 +- .../malloc-stack-logging.test.ts | 112 ++++++++++-------- 2 files changed, 62 insertions(+), 54 deletions(-) diff --git a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts index 5cc0f8436..5e1755c4b 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts @@ -236,7 +236,7 @@ function getInstalledApps(udid: string): Record { function getAppBundlePath(udid: string, bundleId: string): string { let appPath: string; try { - appPath = execSync(`xcrun simctl get_app_container ${udid} ${bundleId} app`, { + appPath = execFileSync("xcrun", ["simctl", "get_app_container", udid, bundleId, "app"], { encoding: "utf-8", timeout: DETECT_RUNNING_APP_TIMEOUT_MS, }).trim(); @@ -490,7 +490,7 @@ export async function startNativeProfilerIos( // Terminate any running instance so xctrace owns a clean cold launch with the // env var set from process start (best-effort; not-running is fine). try { - execSync(`xcrun simctl terminate ${params.device_id} ${info.CFBundleIdentifier}`, { + execFileSync("xcrun", ["simctl", "terminate", params.device_id, info.CFBundleIdentifier], { timeout: DETECT_RUNNING_APP_TIMEOUT_MS, stdio: "ignore", }); diff --git a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts index f184b8d26..b245e72b6 100644 --- a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts +++ b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts @@ -44,17 +44,27 @@ function fakeApi(): NativeProfilerSessionApi { }; } +// get_app_container and terminate go through execFileSync("xcrun", [...args]) +// (shell-injection-hardened, mirroring the best-effort relaunch), so the mock keys +// off the argv array rather than a command string. +function makeExecFileSyncFn() { + return vi.fn((_bin: string, args: string[] = []) => { + if (args.includes("get_app_container")) return "/Users/x/Library/.../MyApp.app\n"; + if (args.includes("terminate")) return ""; + return ""; + }); +} + function mockChildProcess() { const spawnFn = vi.fn(() => new StartFakeChild()); const execSyncFn = vi.fn((cmd: string) => { if (cmd.includes("listapps")) return LISTAPPS_JSON; if (cmd.includes("launchctl list")) return "1\t0\tUIKitApplication:com.example.myapp[abcd][rb-legacy]\n"; - if (cmd.includes("get_app_container")) return "/Users/x/Library/.../MyApp.app\n"; - if (cmd.includes("terminate")) return ""; return ""; }); - return { spawnFn, execSyncFn }; + const execFileSyncFn = makeExecFileSyncFn(); + return { spawnFn, execSyncFn, execFileSyncFn }; } async function importStart() { @@ -76,12 +86,12 @@ describe("native-profiler-start malloc_stack_logging", () => { vi.doUnmock("../../src/utils/ios-profiler/startup"); }); - function applyCommonMocks(spawnFn: unknown, execSyncFn: unknown) { + function applyCommonMocks(spawnFn: unknown, execSyncFn: unknown, execFileSyncFn: unknown) { vi.doMock("child_process", () => ({ spawn: spawnFn, execSync: execSyncFn, execFile: vi.fn(), - execFileSync: vi.fn(), + execFileSync: execFileSyncFn, })); vi.doMock("../../src/utils/react-profiler/debug/dump", () => ({ getDebugDir: vi.fn(async () => "/tmp/argent-profiler-cwd"), @@ -97,8 +107,8 @@ describe("native-profiler-start malloc_stack_logging", () => { } it("cold-launches the .app with MallocStackLogging when malloc_stack_logging is true", async () => { - const { spawnFn, execSyncFn } = mockChildProcess(); - applyCommonMocks(spawnFn, execSyncFn); + const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcess(); + applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn); const startNativeProfilerIos = await importStart(); const api = fakeApi(); @@ -132,22 +142,22 @@ describe("native-profiler-start malloc_stack_logging", () => { expect(dashIdx + 1).toBe(args.length - 1); // the running instance is terminated first for a clean cold start - const execCmds = execSyncFn.mock.calls.map((c) => String(c[0])); - expect( - execCmds.some((c) => c.includes("simctl terminate") && c.includes("com.example.myapp")) - ).toBe(true); - expect(execCmds.some((c) => c.includes("get_app_container"))).toBe(true); + const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []); + expect(efsArgs.some((a) => a.includes("terminate") && a.includes("com.example.myapp"))).toBe( + true + ); + expect(efsArgs.some((a) => a.includes("get_app_container"))).toBe(true); }); it("does not terminate the app if the debug dir can't be created (malloc mode)", async () => { // getDebugDir()'s mkdir runs BEFORE the terminate, so a failure (e.g. ENOSPC) // must leave the running app untouched — never killed-without-relaunch. - const { spawnFn, execSyncFn } = mockChildProcess(); + const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcess(); vi.doMock("child_process", () => ({ spawn: spawnFn, execSync: execSyncFn, execFile: vi.fn(), - execFileSync: vi.fn(), + execFileSync: execFileSyncFn, })); vi.doMock("../../src/utils/react-profiler/debug/dump", () => ({ getDebugDir: vi.fn(async () => { @@ -173,14 +183,14 @@ describe("native-profiler-start malloc_stack_logging", () => { }) ).rejects.toThrow(/ENOSPC/); - const execCmds = execSyncFn.mock.calls.map((c) => String(c[0])); - expect(execCmds.some((c) => c.includes("simctl terminate"))).toBe(false); + const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []); + expect(efsArgs.some((a) => a.includes("terminate"))).toBe(false); expect(spawnFn).not.toHaveBeenCalled(); }); it("attaches (no launch/env) by default", async () => { - const { spawnFn, execSyncFn } = mockChildProcess(); - applyCommonMocks(spawnFn, execSyncFn); + const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcess(); + applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn); const startNativeProfilerIos = await importStart(); const api = fakeApi(); @@ -196,8 +206,8 @@ describe("native-profiler-start malloc_stack_logging", () => { expect(args).not.toContain("--launch"); expect(args).not.toContain("--env"); // default attach mode never terminates the running app - const execCmds = execSyncFn.mock.calls.map((c) => String(c[0])); - expect(execCmds.some((c) => c.includes("simctl terminate"))).toBe(false); + const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []); + expect(efsArgs.some((a) => a.includes("terminate"))).toBe(false); }); // A degraded Xcode (26.4–27.0) is reported by `xcodebuild -version`, which the @@ -211,11 +221,10 @@ describe("native-profiler-start malloc_stack_logging", () => { if (cmd.includes("listapps")) return LISTAPPS_JSON; if (cmd.includes("launchctl list")) return "1\t0\tUIKitApplication:com.example.myapp[abcd][rb-legacy]\n"; - if (cmd.includes("get_app_container")) return "/Users/x/Library/.../MyApp.app\n"; - if (cmd.includes("terminate")) return ""; return ""; }); - return { spawnFn, execSyncFn }; + const execFileSyncFn = makeExecFileSyncFn(); + return { spawnFn, execSyncFn, execFileSyncFn }; } it("refuses malloc_stack_logging on a degraded Xcode before terminating the app", async () => { @@ -232,8 +241,8 @@ describe("native-profiler-start malloc_stack_logging", () => { return true; }); try { - const { spawnFn, execSyncFn } = mockChildProcessDegraded(); - applyCommonMocks(spawnFn, execSyncFn); + const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcessDegraded(); + applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn); const startNativeProfilerIos = await importStart(); const api = fakeApi(); @@ -253,9 +262,9 @@ describe("native-profiler-start malloc_stack_logging", () => { ); // And critically: it never touched the app or xctrace — no terminate, no // bundle-path resolution, no spawn. The app the user had running is intact. - const execCmds = execSyncFn.mock.calls.map((c) => String(c[0])); - expect(execCmds.some((c) => c.includes("simctl terminate"))).toBe(false); - expect(execCmds.some((c) => c.includes("get_app_container"))).toBe(false); + const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []); + expect(efsArgs.some((a) => a.includes("terminate"))).toBe(false); + expect(efsArgs.some((a) => a.includes("get_app_container"))).toBe(false); expect(spawnFn).not.toHaveBeenCalled(); // No misleading "fallback is about to run" log preceded the refusal. expect(stderrWrites.some((w) => w.includes("capture fallback"))).toBe(false); @@ -270,8 +279,8 @@ describe("native-profiler-start malloc_stack_logging", () => { const prev = process.env.ARGENT_IOS_CAPTURE; process.env.ARGENT_IOS_CAPTURE = "device"; try { - const { spawnFn, execSyncFn } = mockChildProcessDegraded(); - applyCommonMocks(spawnFn, execSyncFn); + const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcessDegraded(); + applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn); const startNativeProfilerIos = await importStart(); const api = fakeApi(); @@ -303,11 +312,10 @@ describe("native-profiler-start malloc_stack_logging", () => { if (cmd.includes("listapps")) return LISTAPPS_JSON; if (cmd.includes("launchctl list")) return "1\t0\tUIKitApplication:com.example.myapp[abcd][rb-legacy]\n"; - if (cmd.includes("get_app_container")) return "/Users/x/Library/.../MyApp.app\n"; - if (cmd.includes("terminate")) return ""; return ""; }); - const execFileSyncFn = vi.fn(); + // terminate succeeds (empty return) → the app was running → relaunch is armed. + const execFileSyncFn = makeExecFileSyncFn(); vi.doMock("child_process", () => ({ spawn: spawnFn, execSync: execSyncFn, @@ -364,12 +372,14 @@ describe("native-profiler-start malloc_stack_logging", () => { const execSyncFn = vi.fn((cmd: string) => { if (cmd.includes("xcodebuild")) return "Xcode 16.4\nBuild version 16F6"; if (cmd.includes("listapps")) return LISTAPPS_JSON; + return ""; + }); + const execFileSyncFn = vi.fn((_bin: string, args: string[] = []) => { // Installed but NOT running → terminate fails exactly like real simctl does. - if (cmd.includes("terminate")) throw new Error("found nothing to terminate"); - if (cmd.includes("get_app_container")) return "/Users/x/Library/.../MyApp.app\n"; + if (args.includes("terminate")) throw new Error("found nothing to terminate"); + if (args.includes("get_app_container")) return "/Users/x/Library/.../MyApp.app\n"; return ""; }); - const execFileSyncFn = vi.fn(); vi.doMock("child_process", () => ({ spawn: spawnFn, execSync: execSyncFn, @@ -428,11 +438,10 @@ describe("native-profiler-start malloc_stack_logging", () => { if (cmd.includes("listapps")) return LISTAPPS_JSON; if (cmd.includes("launchctl list")) return "1\t0\tUIKitApplication:com.example.myapp[abcd][rb-legacy]\n"; - if (cmd.includes("get_app_container")) return "/Users/x/Library/.../MyApp.app\n"; - if (cmd.includes("terminate")) return ""; return ""; }); - applyCommonMocks(spawnFn, execSyncFn); + const execFileSyncFn = makeExecFileSyncFn(); + applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn); const startNativeProfilerIos = await importStart(); const api = fakeApi(); @@ -454,9 +463,9 @@ describe("native-profiler-start malloc_stack_logging", () => { expect(msg).toContain("ARGENT_IOS_CAPTURE"); expect(msg).not.toMatch(/deadlock|26\.4/); // Refused up front — the healthy app is untouched. - const execCmds = execSyncFn.mock.calls.map((c) => String(c[0])); - expect(execCmds.some((c) => c.includes("simctl terminate"))).toBe(false); - expect(execCmds.some((c) => c.includes("get_app_container"))).toBe(false); + const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []); + expect(efsArgs.some((a) => a.includes("terminate"))).toBe(false); + expect(efsArgs.some((a) => a.includes("get_app_container"))).toBe(false); expect(spawnFn).not.toHaveBeenCalled(); } finally { if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE; @@ -470,8 +479,8 @@ describe("native-profiler-start malloc_stack_logging", () => { try { // Default mock leaves `xcodebuild` unmocked → version undetermined → device // strategy, so the degraded-Xcode guard passes and we reach app resolution. - const { spawnFn, execSyncFn } = mockChildProcess(); - applyCommonMocks(spawnFn, execSyncFn); + const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcess(); + applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn); const startNativeProfilerIos = await importStart(); const api = fakeApi(); @@ -489,9 +498,9 @@ describe("native-profiler-start malloc_stack_logging", () => { FAILURE_CODES.NATIVE_PROFILER_LAUNCH_APP_NOT_FOUND ); // Resolution failed before any destructive/side-effecting action. - const execCmds = execSyncFn.mock.calls.map((c) => String(c[0])); - expect(execCmds.some((c) => c.includes("simctl terminate"))).toBe(false); - expect(execCmds.some((c) => c.includes("get_app_container"))).toBe(false); + const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []); + expect(efsArgs.some((a) => a.includes("terminate"))).toBe(false); + expect(efsArgs.some((a) => a.includes("get_app_container"))).toBe(false); expect(spawnFn).not.toHaveBeenCalled(); } finally { if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE; @@ -523,11 +532,10 @@ describe("native-profiler-start malloc_stack_logging", () => { "1\t0\tUIKitApplication:com.example.myapp[a][rb]\n" + "2\t0\tUIKitApplication:com.example.other[b][rb]\n" ); - if (cmd.includes("get_app_container")) return "/Users/x/Library/.../MyApp.app\n"; - if (cmd.includes("terminate")) return ""; return ""; }); - applyCommonMocks(spawnFn, execSyncFn); + const execFileSyncFn = makeExecFileSyncFn(); + applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn); const startNativeProfilerIos = await importStart(); const api = fakeApi(); @@ -543,8 +551,8 @@ describe("native-profiler-start malloc_stack_logging", () => { expect(getFailureSignal(err)?.error_code).toBe( FAILURE_CODES.NATIVE_PROFILER_MULTIPLE_RUNNING_USER_APPS ); - const execCmds = execSyncFn.mock.calls.map((c) => String(c[0])); - expect(execCmds.some((c) => c.includes("simctl terminate"))).toBe(false); + const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []); + expect(efsArgs.some((a) => a.includes("terminate"))).toBe(false); expect(spawnFn).not.toHaveBeenCalled(); } finally { if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE; From 37231e20239563c1a0fc2883a5844b6ebb90d8c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Thu, 2 Jul 2026 19:48:51 +0200 Subject: [PATCH 09/28] fix(ios-profiler): attribute leaks per call site, not per object type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `aggregateLeaks` grouped solely by object type, freezing the responsible frame/library at the first-seen row. That was harmless under `--attach` (every frame is the same `` sentinel), but this feature's whole point is giving leaks distinct real stacks via malloc_stack_logging — so the common case of one object type leaked from several call sites collapsed into a single row attributed to whichever was parsed first, hiding the other sites. Group by (object type, responsible frame) so each site is its own finding; the sentinel/`--attach` case still collapses to per-type since all frames are equal. Adds a repro test (distinct frames now stay separate; same frame still merges). --- .../ios-profiler/pipeline/01-correlate.ts | Bin 5906 -> 6393 bytes .../test/ios-leak-pipeline.test.ts | 49 ++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/packages/tool-server/src/utils/ios-profiler/pipeline/01-correlate.ts b/packages/tool-server/src/utils/ios-profiler/pipeline/01-correlate.ts index 7d1d4d1f4197effef20ce20a355af4d2ebc718e2..1fa29597edda0b85ec9cfb17b26e2dd1cbd9a265 100644 GIT binary patch delta 566 zcmY+Bv2GJV5QagbO6epW#a~c3l6?`aBMKx06;z}|@!sC#Zt>pku`|xeLY8?3Ox}P8 zK;judg2&)3P#R{>u@bi1-I?$I=bz8T->2Vxj}Bw`o~*oGbude`~)2JRljWX-gHci(!90{wS|t-G{;^H zG^?Qt7PP`M%%6PB$rX3|TR*scy0@N}c{}U*_WbbYCGq&= Z-5^%9rW=pz?ak5GM>EvL_v_>J!+&&H#%=%r delta 97 zcmexqI7x5ADWS>tgbJjS^Ye;J6tYt*6>Jr9QWLZF@{_VslS@J>3sNUbh#s5FEVf4_ sHLs*7GqqSlGnPvM2=sCj3p6yM5ejt_(qkuI5nD3(r>OAenc@kI002}YlK=n! diff --git a/packages/tool-server/test/ios-leak-pipeline.test.ts b/packages/tool-server/test/ios-leak-pipeline.test.ts index 9b09183a9..7424f6f0d 100644 --- a/packages/tool-server/test/ios-leak-pipeline.test.ts +++ b/packages/tool-server/test/ios-leak-pipeline.test.ts @@ -94,6 +94,55 @@ describe("aggregateLeaks", () => { expect(unattributed.every((l) => l.severity === "YELLOW")).toBe(true); }); + it("keeps distinct responsible frames as separate rows (same type, different call sites)", () => { + // Attribution is the feature's whole point: the same object type leaked from + // two different call sites must NOT collapse into one row attributed to + // whichever was parsed first (that hides the second site). Group by + // (type, frame). + const rows = aggregateLeaks([ + { + objectType: "Malloc 48 Bytes", + sizeBytes: 48, + responsibleFrame: "-[SiteA make]", + responsibleLibrary: "App", + count: 3, + }, + { + objectType: "Malloc 48 Bytes", + sizeBytes: 48, + responsibleFrame: "-[SiteB make]", + responsibleLibrary: "App", + count: 2, + }, + ]); + expect(rows).toHaveLength(2); + const bySite = new Map(rows.map((r) => [r.responsibleFrame, r])); + expect(bySite.get("-[SiteA make]")?.count).toBe(3); + expect(bySite.get("-[SiteB make]")?.count).toBe(2); + }); + + it("still merges same type + same frame (unattributed sentinel case unaffected)", () => { + const rows = aggregateLeaks([ + { + objectType: "Malloc 16 Bytes", + sizeBytes: 16, + responsibleFrame: "", + responsibleLibrary: "", + count: 2, + }, + { + objectType: "Malloc 16 Bytes", + sizeBytes: 16, + responsibleFrame: "", + responsibleLibrary: "", + count: 1, + }, + ]); + expect(rows).toHaveLength(1); + expect(rows[0]?.count).toBe(3); + expect(rows[0]?.attributed).toBe(false); + }); + it("returns [] for no leaks", () => { expect(aggregateLeaks([])).toEqual([]); }); From 88db2f6a7a076f7ed8d59214dbd3bb426c9693b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 3 Jul 2026 11:15:37 +0200 Subject: [PATCH 10/28] fix(ios-profiler): address review feedback on the malloc_stack_logging path - Stop the leak group key from making 01-correlate.ts a binary diff: the (objectType, responsibleFrame) delimiter was a raw NUL byte, which flips git to binary mode and hides the PR's core grouping change. Write it as a unicode escape (identical runtime string; file stays text) and pin the collision-safety with a regression test so nobody swaps it for a space. - Warn on an unrecognised ARGENT_IOS_CAPTURE in malloc mode. The guard resolves the strategy via the side-effect-free resolveIosCaptureStrategy(), which never emitted the "ignoring unrecognised ..." warning, so a typo'd override was dropped silently (and the degraded-Xcode refusal could even advise setting the var the user had already fumbled). Extract warnIfInvalidCaptureOverride() and call it from both the normal record flow and the malloc guard. - Harden getInstalledApps to two discrete-argv execFileSync calls (simctl listapps piped into plutil) instead of an execSync shell string, completing the execFileSync conversion the rest of this path already uses. - Make the degraded-Xcode refusal range accurate: isDegraded() blocks 26.4 and all of 27+, so the frozen "(26.4-27.0)" contradicted itself on e.g. 27.5. - Don't tell the user to re-run with malloc stack logging when the capture already attributed some leaks (malloc was clearly on): the unattributed-leak render note now infers capture mode from the attributed count. - Dedup the identical "multiple running user apps" FailureError into one builder. --- .../profiler/native-profiler/platforms/ios.ts | 78 +++++++++++------- .../ios-profiler/capture-strategy/index.ts | 1 + .../ios-profiler/capture-strategy/select.ts | 17 +++- .../ios-profiler/pipeline/01-correlate.ts | Bin 6393 -> 6715 bytes .../src/utils/ios-profiler/render.ts | 18 +++- .../leak-attribution-render.test.ts | 17 ++++ .../malloc-stack-logging.test.ts | 66 +++++++++++++-- .../test/ios-leak-pipeline.test.ts | 29 +++++++ 8 files changed, 180 insertions(+), 46 deletions(-) diff --git a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts index 5e1755c4b..38512f664 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts @@ -17,6 +17,7 @@ import { runIosProfilerPipeline } from "../../../../utils/ios-profiler/pipeline/ import { selectIosCaptureStrategy, resolveIosCaptureStrategy, + warnIfInvalidCaptureOverride, type IosCaptureStrategy, type CaptureStrategyReason, } from "../../../../utils/ios-profiler/capture-strategy"; @@ -159,26 +160,39 @@ function enumerateRunningUserApps(udid: string): { info: AppInfo; pid: number }[ return runningUserApps; } +/** + * Both the auto-detect (attach) and the malloc_stack_logging launch paths bail the + * same way when several user apps are running and no `app_process` disambiguates + * them — only the failure_stage differs. One builder keeps the message and app-list + * formatting in a single place. + */ +function multipleRunningUserAppsError( + runningUserApps: { info: AppInfo }[], + failureStage: string +): FailureError { + const appList = runningUserApps + .map( + ({ info }) => + ` - ${info.CFBundleExecutable} (${info.CFBundleIdentifier}${info.CFBundleDisplayName ? `, "${info.CFBundleDisplayName}"` : ""})` + ) + .join("\n"); + return new FailureError( + `Multiple user apps are running on the simulator:\n${appList}\nSpecify \`app_process\` with the CFBundleExecutable or display name of the app you want to profile.`, + { + error_code: FAILURE_CODES.NATIVE_PROFILER_MULTIPLE_RUNNING_USER_APPS, + failure_stage: failureStage, + failure_area: "tool_server", + error_kind: "validation", + } + ); +} + /** Auto-detect the single running user app to profile, with its host PID. */ function detectRunningApp(udid: string): DetectedApp { const runningUserApps = enumerateRunningUserApps(udid); if (runningUserApps.length > 1) { - const appList = runningUserApps - .map( - ({ info }) => - ` - ${info.CFBundleExecutable} (${info.CFBundleIdentifier}${info.CFBundleDisplayName ? `, "${info.CFBundleDisplayName}"` : ""})` - ) - .join("\n"); - throw new FailureError( - `Multiple user apps are running on the simulator:\n${appList}\nSpecify \`app_process\` with the CFBundleExecutable or display name of the app you want to profile.`, - { - error_code: FAILURE_CODES.NATIVE_PROFILER_MULTIPLE_RUNNING_USER_APPS, - failure_stage: "native_profiler_detect_running_user_app", - failure_area: "tool_server", - error_kind: "validation", - } - ); + throw multipleRunningUserAppsError(runningUserApps, "native_profiler_detect_running_user_app"); } const { info, pid } = runningUserApps[0]; @@ -210,7 +224,16 @@ function resolveExplicitApp(udid: string, name: string): DetectedApp { function getInstalledApps(udid: string): Record { let listAppsOutput: string; try { - listAppsOutput = execSync(`xcrun simctl listapps ${udid} | plutil -convert json -o - -`, { + // `simctl listapps` emits a plist; plutil converts it to JSON, reading the plist + // from stdin (the trailing `-`). Two discrete-argv execFileSync calls instead of a + // piped `execSync` shell string — matching getAppBundlePath / terminate / relaunch, + // so no value (device_id included) is ever interpolated into a shell. + const listAppsPlist = execFileSync("xcrun", ["simctl", "listapps", udid], { + encoding: "utf-8", + timeout: DETECT_RUNNING_APP_TIMEOUT_MS, + }); + listAppsOutput = execFileSync("plutil", ["-convert", "json", "-o", "-", "-"], { + input: listAppsPlist, encoding: "utf-8", timeout: DETECT_RUNNING_APP_TIMEOUT_MS, }); @@ -300,21 +323,7 @@ function resolveAppForLaunch(udid: string, appProcess?: string): AppInfo { } const runningUserApps = enumerateRunningUserApps(udid); if (runningUserApps.length > 1) { - const appList = runningUserApps - .map( - ({ info }) => - ` - ${info.CFBundleExecutable} (${info.CFBundleIdentifier}${info.CFBundleDisplayName ? `, "${info.CFBundleDisplayName}"` : ""})` - ) - .join("\n"); - throw new FailureError( - `Multiple user apps are running on the simulator:\n${appList}\nSpecify \`app_process\` with the CFBundleExecutable or display name of the app you want to profile.`, - { - error_code: FAILURE_CODES.NATIVE_PROFILER_MULTIPLE_RUNNING_USER_APPS, - failure_stage: "native_profiler_resolve_app_for_launch", - failure_area: "tool_server", - error_kind: "validation", - } - ); + throw multipleRunningUserAppsError(runningUserApps, "native_profiler_resolve_app_for_launch"); } return runningUserApps[0].info; } @@ -400,7 +409,7 @@ function mallocNonDeviceStrategyError(reason: CaptureStrategyReason): FailureErr reason.kind === "degraded-xcode" ? `Xcode ${reason.major}.${reason.minor}` : "the active Xcode"; return new FailureError( `malloc_stack_logging needs to cold-launch the app under \`xctrace --device\`, but ` + - `${versionNote} has the --device recording-start deadlock (26.4–27.0), so it would ` + + `${versionNote} has the --device recording-start deadlock (Xcode 26.4 and later), so it would ` + `terminate your app and then capture an empty trace. Re-run without malloc_stack_logging ` + `(leaks are still detected, just unattributed), profile on a non-degraded Xcode, or set ` + `ARGENT_IOS_CAPTURE=device to force the device path if you know it works on your host.`, @@ -481,6 +490,11 @@ export async function startNativeProfilerIos( // here); the reason it returns also lets the refusal name its actual cause // (forced override vs. degraded Xcode) rather than always blaming the Xcode. const captureDecision = resolveIosCaptureStrategy(); + // The side-effect-free resolver above stays silent, so a typo'd override would + // be dropped without a word here (unlike the normal record flow). Surface it — + // otherwise the degraded-Xcode refusal below can even tell the user to "set + // ARGENT_IOS_CAPTURE=device" while their fumbled value sits ignored. + warnIfInvalidCaptureOverride(captureDecision); if (captureDecision.strategy.name !== "device") { throw mallocNonDeviceStrategyError(captureDecision.reason); } diff --git a/packages/tool-server/src/utils/ios-profiler/capture-strategy/index.ts b/packages/tool-server/src/utils/ios-profiler/capture-strategy/index.ts index b9949bbba..62a5376b0 100644 --- a/packages/tool-server/src/utils/ios-profiler/capture-strategy/index.ts +++ b/packages/tool-server/src/utils/ios-profiler/capture-strategy/index.ts @@ -4,6 +4,7 @@ export { allProcessesStrategy } from "./all-processes"; export { selectIosCaptureStrategy, resolveIosCaptureStrategy, + warnIfInvalidCaptureOverride, type CaptureStrategyDecision, type CaptureStrategyReason, } from "./select"; diff --git a/packages/tool-server/src/utils/ios-profiler/capture-strategy/select.ts b/packages/tool-server/src/utils/ios-profiler/capture-strategy/select.ts index e7bbde09c..6ccf9d71f 100644 --- a/packages/tool-server/src/utils/ios-profiler/capture-strategy/select.ts +++ b/packages/tool-server/src/utils/ios-profiler/capture-strategy/select.ts @@ -122,15 +122,26 @@ export function resolveIosCaptureStrategy(): CaptureStrategyDecision { return { strategy: deviceStrategy, reason: { kind: "default" }, invalidOverride }; } -export function selectIosCaptureStrategy(): IosCaptureStrategy { - const decision = resolveIosCaptureStrategy(); - +/** + * Warn (once, to stderr) when `ARGENT_IOS_CAPTURE` held an unrecognised value that + * was ignored. Shared by the normal record flow ({@link selectIosCaptureStrategy}) + * and the malloc_stack_logging guard, which resolves the strategy directly via + * {@link resolveIosCaptureStrategy} (side-effect-free) and would otherwise swallow a + * typo'd override silently — leaving the user with no clue their value was dropped. + */ +export function warnIfInvalidCaptureOverride(decision: CaptureStrategyDecision): void { if (decision.invalidOverride) { process.stderr.write( `[native-profiler] ignoring unrecognised ${ENV_OVERRIDE}="${decision.invalidOverride}" ` + `(expected "device" or "all-processes"); falling back to auto-detection.\n` ); } +} + +export function selectIosCaptureStrategy(): IosCaptureStrategy { + const decision = resolveIosCaptureStrategy(); + + warnIfInvalidCaptureOverride(decision); switch (decision.reason.kind) { case "env-override": diff --git a/packages/tool-server/src/utils/ios-profiler/pipeline/01-correlate.ts b/packages/tool-server/src/utils/ios-profiler/pipeline/01-correlate.ts index 1fa29597edda0b85ec9cfb17b26e2dd1cbd9a265..f9b480feb55edee2b1912da939182c8874af807d 100644 GIT binary patch delta 337 zcmXw#F;2uV5Jd|l3hEqS8bpJ3L85^(AZV73Vkh=wGrNv$*$z>vJp>m)qU0o0oC6y8 z35pBL9{>O5kH4=!Zr;AC<9bb-R>+FePKzYcOiT|?_m?yzEk!(WrUJ?Kxxd2Nl4QeC zC^4!DPYlT!VS$x+n9Njy$CzL~ZZ#8Q*EL($6%qM6_eE7HyLEW{jUlnGr7`4%(N sK}Is{FA5yaq1h}Lc!V=11*D#0N@UC&CT{QS>t}U-`u`>GztzvlA3uY0AOHXW delta 18 acmdmO^3!m`eUZsBV)Bd(n;pe|F#`Zd=mx0( diff --git a/packages/tool-server/src/utils/ios-profiler/render.ts b/packages/tool-server/src/utils/ios-profiler/render.ts index e2a82e574..849122c5d 100644 --- a/packages/tool-server/src/utils/ios-profiler/render.ts +++ b/packages/tool-server/src/utils/ios-profiler/render.ts @@ -412,13 +412,25 @@ function renderFullReport( if (unattributedLeaks.length > 0) { const objs = unattributedLeaks.reduce((s, b) => s + b.count, 0); const bytes = unattributedLeaks.reduce((s, b) => s + b.totalSizeBytes, 0); + // When this same capture ALSO produced attributed leaks, the app was launched + // under malloc stack logging (the only way a responsible frame is recorded), so + // the unattributed remainder is pre-existing / freed-region reuse — telling the + // user to "re-run with malloc stack logging" would be advising the thing they + // just did. Only when nothing is attributed is the --attach hint apt. The render + // layer has no direct capture-mode signal, so it infers it from the attributed count. + const hint = + attributedLeaks.length > 0 + ? `Some leaks here were attributed, so malloc stack logging was active — these remaining ` + + `groups carry no allocation backtrace (freed-region reuse, or allocations from before ` + + `recording started) and are most likely benign system allocations rather than confirmed app leaks.` + : `Argent records via \`xctrace --attach\`, which has no malloc-stack history, so these are most likely ` + + `benign system allocations rather than confirmed app leaks. For attributed stacks, capture with malloc ` + + `stack logging enabled at launch.`; lines.push( ``, `> ${severityEmoji("YELLOW")} **${unattributedLeaks.length} unattributed leak group(s)** ` + `(${objs} object(s), ${formatBytes(bytes)}): responsible frame \`\`, no library. ` + - `Argent records via \`xctrace --attach\`, which has no malloc-stack history, so these are most likely ` + - `benign system allocations rather than confirmed app leaks. For attributed stacks, capture with malloc ` + - `stack logging enabled at launch.` + hint ); } } diff --git a/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts b/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts index 715cdd880..d30e092e3 100644 --- a/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts +++ b/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts @@ -40,6 +40,23 @@ describe("leak attribution rendering", () => { expect(res.report).toContain("malloc stack logging enabled at launch"); }); + it("does not advise re-running with malloc when the capture already attributed some leaks", async () => { + // A capture with BOTH attributed and unattributed leaks was clearly run under + // malloc stack logging (the only way a frame is recorded). The unattributed note + // must NOT tell the user to "capture with malloc stack logging enabled" — the + // thing they just did — and should instead frame the remainder as pre-existing. + const res = await renderNativeProfilerReport({ + payload: payload([ + leak(true, "hermes::vm::JSTypedArrayBase::createBuffer(...)", "hermes"), + leak(false, ""), + ]), + traceFile: null, + }); + expect(res.report).toContain("unattributed leak group"); + expect(res.report).not.toContain("capture with malloc stack logging enabled at launch"); + expect(res.report).toContain("malloc stack logging was active"); + }); + it("does not surface an unattributed leak as an attributed one", async () => { // Use a real classifier sentinel ("" → isLeakAttributed === false) so the // fixture's attributed:false matches what the pipeline would actually assign; diff --git a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts index b245e72b6..e9ba7b27d 100644 --- a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts +++ b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts @@ -44,11 +44,14 @@ function fakeApi(): NativeProfilerSessionApi { }; } -// get_app_container and terminate go through execFileSync("xcrun", [...args]) -// (shell-injection-hardened, mirroring the best-effort relaunch), so the mock keys -// off the argv array rather than a command string. +// listapps, get_app_container and terminate all go through execFileSync (discrete +// argv, shell-injection-hardened, mirroring the best-effort relaunch), so the mock +// keys off the bin/argv rather than a command string. `simctl listapps` returns a +// plist that `plutil` converts to JSON. function makeExecFileSyncFn() { - return vi.fn((_bin: string, args: string[] = []) => { + return vi.fn((bin: string, args: string[] = []) => { + if (bin === "plutil") return LISTAPPS_JSON; // plutil converts the listapps plist to JSON + if (args.includes("listapps")) return ""; // raw plist, piped into plutil if (args.includes("get_app_container")) return "/Users/x/Library/.../MyApp.app\n"; if (args.includes("terminate")) return ""; return ""; @@ -58,7 +61,6 @@ function makeExecFileSyncFn() { function mockChildProcess() { const spawnFn = vi.fn(() => new StartFakeChild()); const execSyncFn = vi.fn((cmd: string) => { - if (cmd.includes("listapps")) return LISTAPPS_JSON; if (cmd.includes("launchctl list")) return "1\t0\tUIKitApplication:com.example.myapp[abcd][rb-legacy]\n"; return ""; @@ -218,7 +220,6 @@ describe("native-profiler-start malloc_stack_logging", () => { const spawnFn = vi.fn(() => new StartFakeChild()); const execSyncFn = vi.fn((cmd: string) => { if (cmd.includes("xcodebuild")) return "Xcode 26.5\nBuild version 17F42"; - if (cmd.includes("listapps")) return LISTAPPS_JSON; if (cmd.includes("launchctl list")) return "1\t0\tUIKitApplication:com.example.myapp[abcd][rb-legacy]\n"; return ""; @@ -301,6 +302,48 @@ describe("native-profiler-start malloc_stack_logging", () => { } }); + it("warns about an unrecognised ARGENT_IOS_CAPTURE in malloc mode instead of dropping it silently", async () => { + // The malloc guard resolves the strategy via the side-effect-free resolver, so a + // typo'd override would previously vanish without a word — unlike the normal record + // flow, which warns. On a healthy Xcode the typo is ignored and malloc still runs, + // but the user must be told their value was dropped (otherwise a later degraded-Xcode + // refusal can even advise "set ARGENT_IOS_CAPTURE=device" while the typo sits ignored). + const prev = process.env.ARGENT_IOS_CAPTURE; + process.env.ARGENT_IOS_CAPTURE = "devise"; // typo for "device" + const stderrWrites: string[] = []; + const stderrSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation((chunk: string | Uint8Array) => { + stderrWrites.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString()); + return true; + }); + try { + // Default mock leaves `xcodebuild` unmocked → version undetermined → device + // strategy, so the guard proceeds (does not refuse) despite the bad override. + const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcess(); + applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn); + + const startNativeProfilerIos = await importStart(); + const api = fakeApi(); + const result = await startNativeProfilerIos(api, { + device_id: "DEVICE-UDID", + app_process: "MyApp", + malloc_stack_logging: true, + }); + + // The typo neither blocks the healthy device path nor passes silently. + expect(result.status).toBe("recording"); + expect(spawnFn).toHaveBeenCalledTimes(1); + expect( + stderrWrites.some((w) => w.includes('ignoring unrecognised ARGENT_IOS_CAPTURE="devise"')) + ).toBe(true); + } finally { + stderrSpy.mockRestore(); + if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE; + else process.env.ARGENT_IOS_CAPTURE = prev; + } + }); + it("best-effort relaunches the app when the malloc cold launch fails after terminate", async () => { const prev = process.env.ARGENT_IOS_CAPTURE; delete process.env.ARGENT_IOS_CAPTURE; @@ -526,7 +569,6 @@ describe("native-profiler-start malloc_stack_logging", () => { }); const spawnFn = vi.fn(() => new StartFakeChild()); const execSyncFn = vi.fn((cmd: string) => { - if (cmd.includes("listapps")) return twoApps; if (cmd.includes("launchctl list")) return ( "1\t0\tUIKitApplication:com.example.myapp[a][rb]\n" + @@ -534,7 +576,15 @@ describe("native-profiler-start malloc_stack_logging", () => { ); return ""; }); - const execFileSyncFn = makeExecFileSyncFn(); + // getInstalledApps resolves the installed set via execFileSync(listapps) piped + // through plutil, so the two-app plist is delivered by the plutil→JSON mock. + const execFileSyncFn = vi.fn((bin: string, args: string[] = []) => { + if (bin === "plutil") return twoApps; + if (args.includes("listapps")) return ""; + if (args.includes("get_app_container")) return "/Users/x/Library/.../MyApp.app\n"; + if (args.includes("terminate")) return ""; + return ""; + }); applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn); const startNativeProfilerIos = await importStart(); diff --git a/packages/tool-server/test/ios-leak-pipeline.test.ts b/packages/tool-server/test/ios-leak-pipeline.test.ts index 7424f6f0d..acfa21343 100644 --- a/packages/tool-server/test/ios-leak-pipeline.test.ts +++ b/packages/tool-server/test/ios-leak-pipeline.test.ts @@ -143,6 +143,35 @@ describe("aggregateLeaks", () => { expect(rows[0]?.attributed).toBe(false); }); + it("does not collide distinct (type, frame) pairs that share a space-joined key", () => { + // The group key joins objectType and responsibleFrame with a NUL delimiter + // precisely so it can't collide. These two leaks are distinct sites, but a + // space (or any character that can appear in the fields) as the delimiter + // would map both to the same key "Malloc 48 Bytes make" and silently merge + // them — hiding a real leak. Pin the collision-safety so nobody "simplifies" + // the delimiter back to a printable character. + const rows = aggregateLeaks([ + { + objectType: "Malloc 48", + sizeBytes: 48, + responsibleFrame: "Bytes make", + responsibleLibrary: "App", + count: 1, + }, + { + objectType: "Malloc 48 Bytes", + sizeBytes: 48, + responsibleFrame: "make", + responsibleLibrary: "App", + count: 1, + }, + ]); + expect(rows).toHaveLength(2); + expect(new Set(rows.map((r) => r.objectType))).toEqual( + new Set(["Malloc 48", "Malloc 48 Bytes"]) + ); + }); + it("returns [] for no leaks", () => { expect(aggregateLeaks([])).toEqual([]); }); From ac28cfdf50de15c10864a68e5fd076bcc288b29f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 3 Jul 2026 11:23:23 +0200 Subject: [PATCH 11/28] fix(ios-profiler): give getInstalledApps a generous execFileSync buffer The execFileSync hardening now captures the full `simctl listapps` plist into Node before piping it to plutil, whereas the old shell pipe only buffered plutil's (smaller) JSON output. The plist is ~1.5-2x larger than the JSON, and neither call set maxBuffer, so a well-populated simulator whose plist exceeds Node's 1 MiB default would throw ENOBUFS where the old code worked. Set a generous 64 MiB maxBuffer on both calls and add a regression test asserting the listapps capture requests a buffer well above 1 MiB. --- .../profiler/native-profiler/platforms/ios.ts | 8 +++++++ .../malloc-stack-logging.test.ts | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts index 38512f664..8e4443598 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts @@ -55,6 +55,12 @@ function resolveDefaultTemplatePath(): string { const STARTUP_TIMEOUT_MS = 10_000; const DETECT_RUNNING_APP_TIMEOUT_MS = 10_000; const NOTIFY_REGISTER_TIMEOUT_MS = 2_000; +// `simctl listapps` emits a verbose plist for every installed app (entitlements, +// group containers, ...); on a well-populated simulator it can run into several MB, +// larger than Node's 1 MiB default execFileSync buffer (which throws ENOBUFS, not +// truncates). Capture it generously so listing can't spuriously fail. 64 MiB is far +// above any realistic listapps output. +const LISTAPPS_MAX_BUFFER_BYTES = 64 * 1024 * 1024; const MAX_START_ATTEMPTS = 2; const RETRY_DELAY_MS = 1_200; const COLD_START_SIGNATURE = "Cannot find process matching name:"; @@ -231,11 +237,13 @@ function getInstalledApps(udid: string): Record { const listAppsPlist = execFileSync("xcrun", ["simctl", "listapps", udid], { encoding: "utf-8", timeout: DETECT_RUNNING_APP_TIMEOUT_MS, + maxBuffer: LISTAPPS_MAX_BUFFER_BYTES, }); listAppsOutput = execFileSync("plutil", ["-convert", "json", "-o", "-", "-"], { input: listAppsPlist, encoding: "utf-8", timeout: DETECT_RUNNING_APP_TIMEOUT_MS, + maxBuffer: LISTAPPS_MAX_BUFFER_BYTES, }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts index e9ba7b27d..641c723d7 100644 --- a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts +++ b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts @@ -151,6 +151,30 @@ describe("native-profiler-start malloc_stack_logging", () => { expect(efsArgs.some((a) => a.includes("get_app_container"))).toBe(true); }); + it("captures the listapps plist with a large buffer so a big simulator can't overflow it", async () => { + // getInstalledApps buffers the FULL plist into Node (it's larger than the JSON + // plutil emits), so the listapps capture must raise maxBuffer well above Node's + // 1 MiB default — otherwise a well-populated simulator throws ENOBUFS where the + // old shell pipe (which only buffered the smaller JSON) worked. + const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcess(); + applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn); + + const startNativeProfilerIos = await importStart(); + const api = fakeApi(); + await startNativeProfilerIos(api, { + device_id: "DEVICE-UDID", + app_process: "MyApp", + malloc_stack_logging: true, + }); + + const listappsCall = execFileSyncFn.mock.calls.find( + (c) => Array.isArray(c[1]) && (c[1] as string[]).includes("listapps") + ); + expect(listappsCall).toBeTruthy(); + const opts = listappsCall![2] as { maxBuffer?: number }; + expect(opts?.maxBuffer).toBeGreaterThan(8 * 1024 * 1024); + }); + it("does not terminate the app if the debug dir can't be created (malloc mode)", async () => { // getDebugDir()'s mkdir runs BEFORE the terminate, so a failure (e.g. ENOSPC) // must leave the running app untouched — never killed-without-relaunch. From d819b3a626f602de4da86bc154a3bf1ed8ca4c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 3 Jul 2026 11:26:44 +0200 Subject: [PATCH 12/28] test(ios-profiler): type the execFileSync mock options arg for typecheck:tests The maxBuffer regression test read mock.calls[i][2] (the options arg), but the mock fn was typed (bin, args) only, so tsconfig.test.json's stricter typecheck (CI 'Unit tests' job) rejected the missing tuple index. Declare an options param on the mock so the call tuple carries it, and drop the now-needless cast. --- .../ios-instruments/malloc-stack-logging.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts index 641c723d7..efcd48af5 100644 --- a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts +++ b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts @@ -48,8 +48,14 @@ function fakeApi(): NativeProfilerSessionApi { // argv, shell-injection-hardened, mirroring the best-effort relaunch), so the mock // keys off the bin/argv rather than a command string. `simctl listapps` returns a // plist that `plutil` converts to JSON. +interface ExecFileSyncOpts { + maxBuffer?: number; + input?: string; + encoding?: string; + timeout?: number; +} function makeExecFileSyncFn() { - return vi.fn((bin: string, args: string[] = []) => { + return vi.fn((bin: string, args: string[] = [], _opts?: ExecFileSyncOpts) => { if (bin === "plutil") return LISTAPPS_JSON; // plutil converts the listapps plist to JSON if (args.includes("listapps")) return ""; // raw plist, piped into plutil if (args.includes("get_app_container")) return "/Users/x/Library/.../MyApp.app\n"; @@ -168,11 +174,10 @@ describe("native-profiler-start malloc_stack_logging", () => { }); const listappsCall = execFileSyncFn.mock.calls.find( - (c) => Array.isArray(c[1]) && (c[1] as string[]).includes("listapps") + (c) => Array.isArray(c[1]) && c[1].includes("listapps") ); expect(listappsCall).toBeTruthy(); - const opts = listappsCall![2] as { maxBuffer?: number }; - expect(opts?.maxBuffer).toBeGreaterThan(8 * 1024 * 1024); + expect(listappsCall?.[2]?.maxBuffer).toBeGreaterThan(8 * 1024 * 1024); }); it("does not terminate the app if the debug dir can't be created (malloc mode)", async () => { From 7b87427a163563292cd4a3791c578217dedd2fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Tue, 7 Jul 2026 10:04:22 +0200 Subject: [PATCH 13/28] fix(ios-profiler): address review feedback on malloc_stack_logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Correct the degraded-Xcode range in every user/model-facing surface. isDegraded() treats 26.4 and ALL of 27+ as broken (no upper bound), but the malloc_stack_logging tool schema, IOS_PROFILER_REFERENCE.md and the capture-strategy docstrings said "26.4-27.0" — implying 27.1+ works while the call is actually refused up front and the runtime message already reads "26.4 and later". Reword to match the real bound. - Mirror render.ts's capture-mode-aware leak hint into renderCombinedMemoryLeaks. In a mixed report (some leaks attributed, some not) the combined report unconditionally advised "capture with malloc stack logging enabled at launch" — the exact thing the user just did, since attributed and unattributed leaks only coexist under malloc mode. It now names the active malloc capture instead; the combined-report test asserts both the attach-era and malloc-era hint wording. - Make the "does NOT relaunch a not-running named app" test actually exercise its scenario. Its mock returned "" for listapps/plutil, so getInstalledApps hit JSON.parse("") and threw SyntaxError before simctl terminate ever ran — the no-op-terminate -> no-relaunch path was never reached (both assertions were satisfied by that early throw). Feed a valid listapps plist so the path reaches terminate; assert terminate was attempted and the surfaced error is not a SyntaxError. - Drop the now-dead execSyncFn xcodebuild/listapps/launchctl branches in the relaunch and forced-override tests (those subprocesses go through execFileSync now, so the branches never fired and the "Xcode 16.4" comment misdescribed why the guard passed). Mock the Xcode version through execFileSync so "non-degraded Xcode -> guard passes" is real. --- .../combined/profiler-combined-report.ts | 24 +++++-- .../native-profiler/native-profiler-start.ts | 2 +- .../profiler/native-profiler/platforms/ios.ts | 6 +- .../ios-profiler/IOS_PROFILER_REFERENCE.md | 5 +- .../capture-strategy/all-processes.ts | 2 +- .../ios-profiler/capture-strategy/device.ts | 2 +- .../ios-profiler/capture-strategy/select.ts | 6 +- .../ios-profiler/capture-strategy/types.ts | 2 +- .../malloc-stack-logging.test.ts | 70 +++++++++++-------- .../test/profiler-combined-leaks.test.ts | 13 ++++ 10 files changed, 85 insertions(+), 47 deletions(-) diff --git a/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts b/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts index f992bef25..a3f38b9a2 100644 --- a/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts +++ b/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts @@ -371,7 +371,10 @@ Fails if either react-profiler-analyze or native-profiler-analyze has not been c * heuristically tied to recently-mounted React components; unattributed leaks * (`` under `xctrace --attach`) are collapsed into one * low-confidence YELLOW caveat so the simulator's benign system-allocation noise - * can't masquerade as a wall of confirmed leaks. Exported for unit testing. + * can't masquerade as a wall of confirmed leaks. The caveat's hint is capture-mode + * aware (mirroring render.ts): if some leaks WERE attributed, malloc stack logging + * was on, so it won't advise enabling the very thing the user just used. Exported + * for unit testing. */ export function renderCombinedMemoryLeaks( memoryLeaks: MemoryLeak[], @@ -404,13 +407,26 @@ export function renderCombinedMemoryLeaks( if (unattributedLeaks.length > 0) { const objs = unattributedLeaks.reduce((s, b) => s + b.count, 0); const bytes = unattributedLeaks.reduce((s, b) => s + b.totalSizeBytes, 0); + // Mirror render.ts's split: when this same capture ALSO produced attributed + // leaks, the app was launched under malloc stack logging (the only way a + // responsible frame is recorded), so the unattributed remainder is freed-region + // reuse / pre-recording noise — telling the user to "re-run with malloc stack + // logging" would be advising the thing they just did. Only when nothing is + // attributed is the --attach hint apt. Infer capture mode from the attributed + // count (the render layer has no direct capture-mode signal). + const hint = + attributedLeaks.length > 0 + ? `Some leaks here were attributed, so malloc stack logging was active — these remaining ` + + `groups carry no allocation backtrace (freed-region reuse, or allocations from before ` + + `recording started) and are most likely benign system allocations rather than confirmed app leaks.` + : `Argent records via \`xctrace --attach\`, which has no malloc-stack history, so these are most likely ` + + `benign system allocations rather than confirmed app leaks. For attributed stacks, capture with malloc ` + + `stack logging enabled at launch.`; lines.push( ``, `> 🟡 **${unattributedLeaks.length} unattributed leak group(s)** ` + `(${objs} object(s), ${formatBytes(bytes)}): responsible frame \`\`, no library. ` + - `Argent records via \`xctrace --attach\`, which has no malloc-stack history, so these are most likely ` + - `benign system allocations rather than confirmed app leaks. For attributed stacks, capture with malloc ` + - `stack logging enabled at launch.` + hint ); } 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 818db4f35..000b270e1 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 @@ -39,7 +39,7 @@ const zodSchema = z.object({ "''. Trade-offs: this RESTARTS the app (current state is lost), " + "adds memory/CPU overhead, and makes the app noticeably slow to launch (every startup " + "allocation records a backtrace), so leave it off for pure CPU/hang profiling. Requires a " + - "non-degraded Xcode: on Xcode 26.4–27.0 the cold-launch path is broken, so the call is " + + "non-degraded Xcode: on Xcode 26.4 and later the cold-launch path is broken, so the call is " + "rejected up front (re-run without the flag, or set ARGENT_IOS_CAPTURE=device to override). " + "Ignored on Android." ), diff --git a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts index 6e2cfa6ad..630465bbb 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts @@ -487,8 +487,8 @@ export async function startNativeProfilerIos( if (useMallocStackLogging) { // malloc_stack_logging must cold-launch the app under `xctrace --device --launch` - // (only `--launch` honours `--env MallocStackLogging=1`). On Xcode 26.4–27.0 the - // `--device` recording-start handshake is broken (see capture-strategy), so this + // (only `--launch` honours `--env MallocStackLogging=1`). On Xcode 26.4 and later + // the `--device` recording-start handshake is broken (see capture-strategy), so this // would terminate the running app and then capture an empty trace — the opposite // of the feature's purpose, surfaced only as a downstream "Analysis failed". Refuse // up front, BEFORE touching the running app, unless the operator forces the device @@ -533,7 +533,7 @@ export async function startNativeProfilerIos( // Pick the capture approach for this environment. On Xcode versions where // `xctrace --device` works this is the original device/attach path (which // attaches by PID — immune to Xcode 26.5's display-name `--attach` matching); - // on the 26.4–27.0 regression (where --device deadlocks) it is the host-wide + // on the 26.4-and-later regression (where --device deadlocks) it is the host-wide // --all-processes fallback, filtered to the app PID. See capture-strategy. strategy = selectIosCaptureStrategy(); // The all-processes fallback records host-wide and isolates the app by PID, so diff --git a/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md b/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md index ebc84a7d5..f7e70caf5 100644 --- a/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md +++ b/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md @@ -79,8 +79,9 @@ All three are wired through `native-profiler-session` (per-device service, keyed startup allocation records a backtrace), so it stays opt-in — leave it off for pure CPU/hang work, where attach (no relaunch, no overhead) is preferable. **Degraded-Xcode guard:** the cold launch needs `xctrace --device`, which is - broken on Xcode 26.4–27.0 (the `--device` recording-start handshake records an - empty trace). Because the malloc path terminates the running app before + broken on Xcode 26.4 and later (the `--device` recording-start handshake records + an empty trace — `isDegraded` treats 26.4 and every version from 27 up as broken, + with no upper bound). Because the malloc path terminates the running app before launching, `native-profiler-start { malloc_stack_logging: true }` is **rejected up front** on those versions (before the app is touched) rather than silently capturing nothing — re-run without the flag (leaks are still detected, just diff --git a/packages/tool-server/src/utils/ios-profiler/capture-strategy/all-processes.ts b/packages/tool-server/src/utils/ios-profiler/capture-strategy/all-processes.ts index dac5ab038..d55fc0f8c 100644 --- a/packages/tool-server/src/utils/ios-profiler/capture-strategy/all-processes.ts +++ b/packages/tool-server/src/utils/ios-profiler/capture-strategy/all-processes.ts @@ -1,7 +1,7 @@ import type { IosCaptureStrategy, RecordArgsInput, CaptureTarget } from "./types"; /** - * Degraded-environment fallback for Xcode 26.4–27.0, where `xctrace record + * Degraded-environment fallback for Xcode 26.4 and later, where `xctrace record * --device ` deadlocks at the recording-start handshake and never captures. * * Instead of targeting the simulator device, record the HOST with diff --git a/packages/tool-server/src/utils/ios-profiler/capture-strategy/device.ts b/packages/tool-server/src/utils/ios-profiler/capture-strategy/device.ts index d1b8be4f7..3dbe86d3f 100644 --- a/packages/tool-server/src/utils/ios-profiler/capture-strategy/device.ts +++ b/packages/tool-server/src/utils/ios-profiler/capture-strategy/device.ts @@ -7,7 +7,7 @@ import type { IosCaptureStrategy, RecordArgsInput, CaptureTarget } from "./types * Time-Profiler path with the simulator as the Instruments device. * * It is the correct choice on Xcode versions where the `--device` recording - * handshake works (≤ 26.3). On 26.4–27.0 it deadlocks at startup — see + * handshake works (≤ 26.3). On 26.4 and later it deadlocks at startup — see * ./all-processes and ./select. */ export const deviceStrategy: IosCaptureStrategy = { diff --git a/packages/tool-server/src/utils/ios-profiler/capture-strategy/select.ts b/packages/tool-server/src/utils/ios-profiler/capture-strategy/select.ts index 25562949d..ac6cdbd81 100644 --- a/packages/tool-server/src/utils/ios-profiler/capture-strategy/select.ts +++ b/packages/tool-server/src/utils/ios-profiler/capture-strategy/select.ts @@ -9,9 +9,9 @@ import { allProcessesStrategy } from "./all-processes"; * Order of precedence: * 1. The `ARGENT_IOS_CAPTURE` env override ("device" | "all-processes") — an * explicit escape hatch for both directions. - * 2. Active Xcode version: 26.4–27.0 are "degraded" (the `--device` recording - * handshake deadlocks) → use the all-processes fallback. Everything else - * (≤ 26.3, and whatever fixed version Apple ships) → use the device path. + * 2. Active Xcode version: 26.4 and later are "degraded" (the `--device` recording + * handshake deadlocks) → use the all-processes fallback. Only ≤ 26.3 uses the + * device path; when Apple ships a fixed build, narrow isDegraded() to re-enable it. * 3. If the version can't be determined, default to the device strategy so the * original behaviour is preserved; force the fallback via the env override. */ diff --git a/packages/tool-server/src/utils/ios-profiler/capture-strategy/types.ts b/packages/tool-server/src/utils/ios-profiler/capture-strategy/types.ts index ff32b1eaf..bd2c019da 100644 --- a/packages/tool-server/src/utils/ios-profiler/capture-strategy/types.ts +++ b/packages/tool-server/src/utils/ios-profiler/capture-strategy/types.ts @@ -1,7 +1,7 @@ // Capture-strategy abstraction for the iOS native profiler. // // Why this exists: Apple's `xctrace record --device ` path deadlocks during -// the recording-start handshake on Xcode 26.4–27.0 (a host-side xctrace +// the recording-start handshake on Xcode 26.4 and later (a host-side xctrace // regression — the in-sim DTServiceHub waits forever for a "recording-started" // reply the regressed xctrace never sends). The capture never starts, so there // is no data to recover. The hang-free fallback is to profile the HOST with diff --git a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts index 4a3b8fdcd..6d02ee51b 100644 --- a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts +++ b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts @@ -244,14 +244,14 @@ describe("native-profiler-start malloc_stack_logging", () => { expect(efsArgs.some((a) => a.includes("terminate"))).toBe(false); }); - // A degraded Xcode (26.4–27.0) is reported by `xcodebuild -version`, which the - // capture-strategy selector reads. The malloc cold launch needs `--device`, - // which is broken on those versions, so the start must be refused UP FRONT — - // before the running app is terminated and before any xctrace spawn. + // A degraded Xcode (26.4 and later) is reported by `xcodebuild -version`, which the + // capture-strategy selector reads. The malloc cold launch needs `--device`, which is + // broken on those versions, so the start must be refused UP FRONT — before the running + // app is terminated and before any xctrace spawn. function mockChildProcessDegraded() { const spawnFn = vi.fn(() => new StartFakeChild()); const execSyncFn = vi.fn(() => ""); - // A degraded Xcode (26.4–27.0) as reported by the hardened `xcodebuild -version` + // A degraded Xcode (26.4 and later) as reported by the hardened `xcodebuild -version` // read (execFileSync) that the capture-strategy selector performs. const execFileSyncFn = makeExecFileSyncFn({ xcodebuild: "Xcode 26.5\nBuild version 17F42", @@ -380,16 +380,13 @@ describe("native-profiler-start malloc_stack_logging", () => { delete process.env.ARGENT_IOS_CAPTURE; try { const spawnFn = vi.fn(() => new StartFakeChild()); - // Non-degraded Xcode → the guard passes and the path reaches the cold launch. - const execSyncFn = vi.fn((cmd: string) => { - if (cmd.includes("xcodebuild")) return "Xcode 16.4\nBuild version 16F6"; - if (cmd.includes("listapps")) return LISTAPPS_JSON; - if (cmd.includes("launchctl list")) - return "1\t0\tUIKitApplication:com.example.myapp[abcd][rb-legacy]\n"; - return ""; - }); - // terminate succeeds (empty return) → the app was running → relaunch is armed. - const execFileSyncFn = makeExecFileSyncFn(); + // Nothing routes through execSync anymore (xcodebuild/listapps/launchctl are all + // hardened to execFileSync); a bare stub is all that's needed. + const execSyncFn = vi.fn(() => ""); + // A non-degraded Xcode, read via the hardened execFileSync `xcodebuild -version` + // (NOT execSync), so the guard genuinely passes and the path reaches the cold + // launch. terminate succeeds (empty return) → the app was running → relaunch armed. + const execFileSyncFn = makeExecFileSyncFn({ xcodebuild: "Xcode 16.4\nBuild version 16F6" }); vi.doMock("child_process", () => ({ spawn: spawnFn, execSync: execSyncFn, @@ -443,15 +440,22 @@ describe("native-profiler-start malloc_stack_logging", () => { delete process.env.ARGENT_IOS_CAPTURE; try { const spawnFn = vi.fn(() => new StartFakeChild()); - const execSyncFn = vi.fn((cmd: string) => { - if (cmd.includes("xcodebuild")) return "Xcode 16.4\nBuild version 16F6"; - if (cmd.includes("listapps")) return LISTAPPS_JSON; - return ""; - }); - const execFileSyncFn = vi.fn((_bin: string, args: string[] = []) => { - // Installed but NOT running → terminate fails exactly like real simctl does. - if (args.includes("terminate")) throw new Error("found nothing to terminate"); + const execSyncFn = vi.fn(() => ""); + const execFileSyncFn = vi.fn((bin: string, args: string[] = []) => { + // Non-degraded Xcode (execFileSync `xcodebuild -version`) so the guard passes + // and the path actually reaches app resolution + terminate. + if (bin === "xcodebuild") return "Xcode 16.4\nBuild version 16F6"; + // getInstalledApps buffers the listapps plist and runs it through plutil → JSON, + // so resolveAppForLaunch finds the installed-but-not-running MyApp and the path + // REACHES terminate. Returning "" for listapps/plutil would make getInstalledApps + // hit JSON.parse("") and throw SyntaxError before terminate ever ran — which is + // exactly the illusory-coverage bug this test must avoid. + if (bin === "plutil") return LISTAPPS_JSON; + if (args.includes("listapps")) return ""; if (args.includes("get_app_container")) return "/Users/x/Library/.../MyApp.app\n"; + // Installed but NOT running → terminate fails exactly like real simctl does, so + // mallocRelaunchBundleId is never armed. + if (args.includes("terminate")) throw new Error("found nothing to terminate"); return ""; }); vi.doMock("child_process", () => ({ @@ -486,8 +490,15 @@ describe("native-profiler-start malloc_stack_logging", () => { (e: unknown) => e ); - // The start failure still surfaces... + // The start failure still surfaces — and it is the cold-launch failure, NOT an + // earlier resolution error. (With an empty listapps mock, getInstalledApps used to + // throw "SyntaxError: Unexpected end of JSON input" before terminate ran, so the + // no-op-terminate → no-relaunch scenario named here was never actually exercised.) expect(err).toBeTruthy(); + expect(err).not.toBeInstanceOf(SyntaxError); + // The path genuinely reached the (no-op) terminate for the not-running app... + const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []); + expect(efsArgs.some((a) => a.includes("terminate"))).toBe(true); // ...but no best-effort relaunch fired, because we never actually killed it. const relaunched = execFileSyncFn.mock.calls.some( (c) => Array.isArray(c[1]) && (c[1] as string[]).includes("launch") @@ -507,13 +518,10 @@ describe("native-profiler-start malloc_stack_logging", () => { process.env.ARGENT_IOS_CAPTURE = "all-processes"; try { const spawnFn = vi.fn(() => new StartFakeChild()); - const execSyncFn = vi.fn((cmd: string) => { - if (cmd.includes("xcodebuild")) return "Xcode 16.4\nBuild version 16F6"; - if (cmd.includes("listapps")) return LISTAPPS_JSON; - if (cmd.includes("launchctl list")) - return "1\t0\tUIKitApplication:com.example.myapp[abcd][rb-legacy]\n"; - return ""; - }); + // ARGENT_IOS_CAPTURE=all-processes short-circuits before any `xcodebuild -version` + // read, so the Xcode version is irrelevant here; execSync is unused (every + // subprocess goes through execFileSync), so a bare stub suffices. + const execSyncFn = vi.fn(() => ""); const execFileSyncFn = makeExecFileSyncFn(); applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn); diff --git a/packages/tool-server/test/profiler-combined-leaks.test.ts b/packages/tool-server/test/profiler-combined-leaks.test.ts index fa6226628..9b7fb192c 100644 --- a/packages/tool-server/test/profiler-combined-leaks.test.ts +++ b/packages/tool-server/test/profiler-combined-leaks.test.ts @@ -63,6 +63,11 @@ describe("renderCombinedMemoryLeaks", () => { expect(out).toContain("``"); expect(out).toContain("benign system allocations rather than confirmed app leaks"); + // Nothing attributed → the capture was `--attach` (no malloc history), so the + // hint DOES advise capturing with malloc stack logging. + expect(out).toContain("capture with malloc"); + expect(out).toContain("stack logging enabled at launch"); + // With no attributed leaks, it says so explicitly. expect(out).toContain("No attributed leaks"); }); @@ -79,6 +84,14 @@ describe("renderCombinedMemoryLeaks", () => { expect(out).toContain("🟡"); expect(out).toContain("1 unattributed leak group(s)"); + // Attributed + unattributed leaks coexist ONLY when malloc stack logging was + // active, so the caveat must NOT tell the user to enable the thing they just + // used. It names the active malloc capture and drops the "capture with malloc + // stack logging enabled at launch" advice. + expect(out).toContain("malloc stack logging was active"); + expect(out).not.toContain("capture with malloc"); + expect(out).not.toContain("stack logging enabled at launch"); + // No "no attributed leaks" line when an attributed leak exists. expect(out).not.toContain("No attributed leaks"); }); From e0aa2522a5efa670fd35fed4f904118d2f2f2ae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Tue, 7 Jul 2026 10:16:00 +0200 Subject: [PATCH 14/28] docs(ios-profiler): scrub the last "26.4-27.0" range from the isDegraded docstring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The isDegraded() docstring still carried "the regression has shipped across every 26.4-27.0 build tested" — the lone surviving bounded-range string after the schema, reference doc and sibling docstrings were switched to "26.4 and later". The surrounding text already says "from 26.4 onwards … ALL of 27+", so it wasn't a contradiction, but a hurried reader could momentarily read "26.4-27.0 build tested" as confining the bug to <=27.0 — the exact misread the fix set out to remove. Reword to "no upper bound … across every build tested so far, 26.4 through 27.x" so the file is uniform. --- .../src/utils/ios-profiler/capture-strategy/select.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/tool-server/src/utils/ios-profiler/capture-strategy/select.ts b/packages/tool-server/src/utils/ios-profiler/capture-strategy/select.ts index ac6cdbd81..b3083de26 100644 --- a/packages/tool-server/src/utils/ios-profiler/capture-strategy/select.ts +++ b/packages/tool-server/src/utils/ios-profiler/capture-strategy/select.ts @@ -77,10 +77,11 @@ function readActiveXcodeVersion(): XcodeVersion | null { /** * True for Xcode versions where `xctrace record --device ` deadlocks at the - * recording-start handshake. Known broken from 26.4 onwards; we conservatively - * treat ALL of 27+ as broken by default (the regression has shipped across every - * 26.4–27.0 build tested). When Apple fixes it, narrow this bound — until then, - * force the original path on a known-good version via ARGENT_IOS_CAPTURE=device. + * recording-start handshake. Known broken from 26.4 onwards with no upper bound; we + * conservatively treat ALL of 27+ as broken by default (the regression has shipped + * across every build tested so far, 26.4 through 27.x). When Apple fixes it, narrow + * this bound — until then, force the original path on a known-good version via + * ARGENT_IOS_CAPTURE=device. */ function isDegraded({ major, minor }: XcodeVersion): boolean { if (major === 26) return minor >= 4; From bbd2f4e21da867c3abe9e4d422cd0d8e79890c4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Tue, 7 Jul 2026 10:29:02 +0200 Subject: [PATCH 15/28] test(ios-profiler): pin getAppBundlePath-before-terminate ordering (malloc mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getAppBundlePath (simctl get_app_container) runs before the destructive simctl terminate on the malloc cold-launch path — the same "don't leave the app dead" invariant the debug-dir mkdir guard already has a test for, but this sibling ordering was unpinned. A regression that reordered bundle-path resolution after terminate (leaving the user's app killed on an unresolvable container), or that stopped rejecting an empty resolved path (`xctrace --launch -- ""`), would ship green. Add two malloc-mode tests: get_app_container throwing, and returning a blank path. Both assert NATIVE_PROFILER_APP_BUNDLE_PATH_FAILED, that terminate was NOT called, and that xctrace never spawned. Verified they fail when getAppBundlePath is moved below the terminate, and pass with the shipped ordering. --- .../malloc-stack-logging.test.ts | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts index 6d02ee51b..34f20337f 100644 --- a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts +++ b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts @@ -222,6 +222,94 @@ describe("native-profiler-start malloc_stack_logging", () => { expect(spawnFn).not.toHaveBeenCalled(); }); + it("does not terminate the app if the .app bundle path can't be resolved (malloc mode)", async () => { + // getAppBundlePath (simctl get_app_container) runs BEFORE the destructive terminate, + // exactly like the debug-dir mkdir guard above — so a failed container resolution must + // leave the running app untouched, never killed-without-relaunch. Also pins the + // FailureError code so a reachable user-facing failure stays telemetry-classified. + const prev = process.env.ARGENT_IOS_CAPTURE; + delete process.env.ARGENT_IOS_CAPTURE; + try { + const spawnFn = vi.fn(() => new StartFakeChild()); + const execSyncFn = vi.fn(() => ""); + const execFileSyncFn = vi.fn((bin: string, args: string[] = []) => { + // resolveAppForLaunch succeeds (valid listapps) so we reach getAppBundlePath... + if (bin === "plutil") return LISTAPPS_JSON; + if (args.includes("listapps")) return ""; + // ...where get_app_container fails BEFORE any terminate. + if (args.includes("get_app_container")) throw new Error("No such file or directory"); + if (args.includes("terminate")) return ""; + return ""; + }); + applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn); + + const startNativeProfilerIos = await importStart(); + const api = fakeApi(); + const err = await startNativeProfilerIos(api, { + device_id: "DEVICE-UDID", + app_process: "MyApp", + malloc_stack_logging: true, + }).then( + () => null, + (e: unknown) => e + ); + + expect(err).toBeTruthy(); + expect(getFailureSignal(err)?.error_code).toBe( + FAILURE_CODES.NATIVE_PROFILER_APP_BUNDLE_PATH_FAILED + ); + // Bundle-path resolution failed BEFORE the destructive terminate → app untouched. + const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []); + expect(efsArgs.some((a) => a.includes("terminate"))).toBe(false); + expect(spawnFn).not.toHaveBeenCalled(); + } finally { + if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE; + else process.env.ARGENT_IOS_CAPTURE = prev; + } + }); + + it("rejects an empty resolved .app bundle path before terminating (malloc mode)", async () => { + // simctl returning a blank container path must be refused up front (otherwise the argv + // becomes `xctrace --launch -- ""`), and — like the throw case — before the terminate. + const prev = process.env.ARGENT_IOS_CAPTURE; + delete process.env.ARGENT_IOS_CAPTURE; + try { + const spawnFn = vi.fn(() => new StartFakeChild()); + const execSyncFn = vi.fn(() => ""); + const execFileSyncFn = vi.fn((bin: string, args: string[] = []) => { + if (bin === "plutil") return LISTAPPS_JSON; + if (args.includes("listapps")) return ""; + // Whitespace-only container path → empty after the .trim() in getAppBundlePath. + if (args.includes("get_app_container")) return " \n"; + if (args.includes("terminate")) return ""; + return ""; + }); + applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn); + + const startNativeProfilerIos = await importStart(); + const api = fakeApi(); + const err = await startNativeProfilerIos(api, { + device_id: "DEVICE-UDID", + app_process: "MyApp", + malloc_stack_logging: true, + }).then( + () => null, + (e: unknown) => e + ); + + expect(err).toBeTruthy(); + expect(getFailureSignal(err)?.error_code).toBe( + FAILURE_CODES.NATIVE_PROFILER_APP_BUNDLE_PATH_FAILED + ); + const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []); + expect(efsArgs.some((a) => a.includes("terminate"))).toBe(false); + expect(spawnFn).not.toHaveBeenCalled(); + } finally { + if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE; + else process.env.ARGENT_IOS_CAPTURE = prev; + } + }); + it("attaches (no launch/env) by default", async () => { const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcess(); applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn); From acf0d76e535d207ae64d8ab829a2acdcd2e6b631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Tue, 7 Jul 2026 16:15:28 +0200 Subject: [PATCH 16/28] fix(ios-profiler): quote the operator's literal ARGENT_IOS_CAPTURE in the malloc refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forced-override refusal (mallocNonDeviceStrategyError) interpolated the canonical strategy name into `ARGENT_IOS_CAPTURE="…"`, framed as the env var's literal value. But parseEnvOverride accepts the `all_processes`/`allprocesses` aliases and lower-cases input, so a user who set e.g. `ARGENT_IOS_CAPTURE=all_processes` got a refusal claiming they set `all-processes` — telling them to fix a variable that reads differently from what they typed. The whole point of this refactor round was accurate attribution in these messages. - Thread the literal (trimmed, case-preserved) override value through the env-override CaptureStrategyReason as `rawValue`; the refusal now quotes it for the `ARGENT_IOS_CAPTURE="…"` part while still naming the canonical strategy it resolved to. parseEnvOverride keeps the original for the invalid branch too, so the "ignoring unrecognised …" warning likewise echoes the operator's exact value. - Regression test: set `ARGENT_IOS_CAPTURE=All_Processes` (alias + mixed case) and assert the refusal quotes `All_Processes` verbatim, names the `all-processes` strategy, and does NOT quote the canonical name as the env value. Verified it fails against the pre-fix message and passes after. - Docs: the malloc_stack_logging schema said "set ARGENT_IOS_CAPTURE=device to override" without the "if the device path works on your host" caveat that the runtime error and IOS_PROFILER_REFERENCE.md both carry; on a genuinely degraded host the override still yields an empty trace. Match the sibling wording so the model-facing contract doesn't imply the override is a guaranteed fix. --- .../native-profiler/native-profiler-start.ts | 3 +- .../profiler/native-profiler/platforms/ios.ts | 2 +- .../ios-profiler/capture-strategy/select.ts | 34 +++++++++----- .../malloc-stack-logging.test.ts | 45 +++++++++++++++++++ 4 files changed, 72 insertions(+), 12 deletions(-) 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 000b270e1..f53a90c5c 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 @@ -40,7 +40,8 @@ const zodSchema = z.object({ "adds memory/CPU overhead, and makes the app noticeably slow to launch (every startup " + "allocation records a backtrace), so leave it off for pure CPU/hang profiling. Requires a " + "non-degraded Xcode: on Xcode 26.4 and later the cold-launch path is broken, so the call is " + - "rejected up front (re-run without the flag, or set ARGENT_IOS_CAPTURE=device to override). " + + "rejected up front (re-run without the flag, or set ARGENT_IOS_CAPTURE=device to override if the " + + "device path works on your host). " + "Ignored on Android." ), }); diff --git a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts index 630465bbb..197c7d0e9 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts @@ -401,7 +401,7 @@ function mallocNonDeviceStrategyError(reason: CaptureStrategyReason): FailureErr if (reason.kind === "env-override") { return new FailureError( `malloc_stack_logging must cold-launch the app under \`xctrace --device\`, but ` + - `ARGENT_IOS_CAPTURE="${reason.strategyName}" forces the "${reason.strategyName}" capture ` + + `ARGENT_IOS_CAPTURE="${reason.rawValue}" forces the "${reason.strategyName}" capture ` + `strategy, which attaches host-wide and cannot \`--launch\` a cold start. Unset ` + `ARGENT_IOS_CAPTURE (or set it to "device") to use malloc_stack_logging, or re-run without ` + `malloc_stack_logging (leaks are still detected, just unattributed).`, diff --git a/packages/tool-server/src/utils/ios-profiler/capture-strategy/select.ts b/packages/tool-server/src/utils/ios-profiler/capture-strategy/select.ts index b3083de26..c281f19ed 100644 --- a/packages/tool-server/src/utils/ios-profiler/capture-strategy/select.ts +++ b/packages/tool-server/src/utils/ios-profiler/capture-strategy/select.ts @@ -30,7 +30,14 @@ interface XcodeVersion { * always a degraded Xcode. */ export type CaptureStrategyReason = - | { kind: "env-override"; strategyName: IosCaptureStrategy["name"] } + | { + kind: "env-override"; + strategyName: IosCaptureStrategy["name"]; + /** The literal ARGENT_IOS_CAPTURE value the operator set (trimmed, case preserved), + * which may be an alias/mixed-case form of `strategyName` — quote THIS when echoing + * the override back to the user so the message names what they actually set. */ + rawValue: string; + } | { kind: "degraded-xcode"; major: number; minor: number } | { kind: "default" }; @@ -42,19 +49,22 @@ export interface CaptureStrategyDecision { } type OverrideParse = - | { kind: "device" } - | { kind: "all-processes" } + | { kind: "device"; raw: string } + | { kind: "all-processes"; raw: string } | { kind: "none" } | { kind: "invalid"; raw: string }; function parseEnvOverride(): OverrideParse { - const raw = process.env[ENV_OVERRIDE]?.trim().toLowerCase(); - if (!raw) return { kind: "none" }; - if (raw === "device") return { kind: "device" }; + // Keep the original (trimmed, case-preserved) value so callers can echo exactly + // what the operator set; classify on a lower-cased copy so aliases/case still match. + const original = process.env[ENV_OVERRIDE]?.trim(); + if (!original) return { kind: "none" }; + const raw = original.toLowerCase(); + if (raw === "device") return { kind: "device", raw: original }; if (raw === "all-processes" || raw === "all_processes" || raw === "allprocesses") { - return { kind: "all-processes" }; + return { kind: "all-processes", raw: original }; } - return { kind: "invalid", raw }; + return { kind: "invalid", raw: original }; } function readActiveXcodeVersion(): XcodeVersion | null { @@ -100,13 +110,17 @@ export function resolveIosCaptureStrategy(): CaptureStrategyDecision { if (override.kind === "device") { return { strategy: deviceStrategy, - reason: { kind: "env-override", strategyName: deviceStrategy.name }, + reason: { kind: "env-override", strategyName: deviceStrategy.name, rawValue: override.raw }, }; } if (override.kind === "all-processes") { return { strategy: allProcessesStrategy, - reason: { kind: "env-override", strategyName: allProcessesStrategy.name }, + reason: { + kind: "env-override", + strategyName: allProcessesStrategy.name, + rawValue: override.raw, + }, }; } diff --git a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts index 34f20337f..e105617a0 100644 --- a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts +++ b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts @@ -643,6 +643,51 @@ describe("native-profiler-start malloc_stack_logging", () => { } }); + it("quotes the operator's literal ARGENT_IOS_CAPTURE value (alias/mixed-case) in the override refusal", async () => { + // parseEnvOverride accepts the `all_processes`/`allprocesses` aliases and lower-cases + // input, so the canonical strategy name ("all-processes") diverges from what the user + // set. The refusal must echo the LITERAL value they configured, not the canonical name, + // otherwise it tells them to fix a variable that reads differently from what they typed. + const prev = process.env.ARGENT_IOS_CAPTURE; + process.env.ARGENT_IOS_CAPTURE = "All_Processes"; // alias + mixed case + try { + const spawnFn = vi.fn(() => new StartFakeChild()); + const execSyncFn = vi.fn(() => ""); + const execFileSyncFn = makeExecFileSyncFn(); + applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn); + + const startNativeProfilerIos = await importStart(); + const api = fakeApi(); + const err = await startNativeProfilerIos(api, { + device_id: "DEVICE-UDID", + app_process: "MyApp", + malloc_stack_logging: true, + }).then( + () => null, + (e: unknown) => e + ); + + expect(err).toBeTruthy(); + expect(getFailureSignal(err)?.error_code).toBe( + FAILURE_CODES.NATIVE_PROFILER_MALLOC_STRATEGY_OVERRIDE + ); + const msg = err instanceof Error ? err.message : String(err); + // Echoes the operator's literal value (case preserved, trimmed)... + expect(msg).toContain(`ARGENT_IOS_CAPTURE="All_Processes"`); + // ...while still naming the canonical strategy it resolved to. + expect(msg).toContain(`forces the "all-processes" capture`); + // Regression guard: must NOT quote the canonical name as if it were the env value. + expect(msg).not.toContain(`ARGENT_IOS_CAPTURE="all-processes"`); + // Refused up front — the healthy app is untouched. + const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []); + expect(efsArgs.some((a) => a.includes("terminate"))).toBe(false); + expect(spawnFn).not.toHaveBeenCalled(); + } finally { + if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE; + else process.env.ARGENT_IOS_CAPTURE = prev; + } + }); + it("throws LAUNCH_APP_NOT_FOUND when a malloc app_process matches no installed user app", async () => { const prev = process.env.ARGENT_IOS_CAPTURE; delete process.env.ARGENT_IOS_CAPTURE; From d0b6700b7c8a61c43c8beb7eae2ee11a9e609e71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 10 Jul 2026 00:11:00 +0200 Subject: [PATCH 17/28] fix(ios-profiler): group leaks by attribution-normalized frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An export can spell "no stack" more than one way for the same object type — a missing responsible-frame attribute parses as "Unknown" while truncated stacks emit the "" sentinel, and isLeakAttributed() trims before matching. aggregateLeaks() keyed on the verbatim frame, so those spellings split one unattributed group per type into several, inflating the "N unattributed leak group(s)" count. Key on the frame normalized exactly the way isLeakAttributed() matches it: trimmed, with every unattributed spelling collapsed to one bucket. --- .../ios-profiler/pipeline/01-correlate.ts | 13 +++- .../test/ios-leak-pipeline.test.ts | 59 +++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/packages/tool-server/src/utils/ios-profiler/pipeline/01-correlate.ts b/packages/tool-server/src/utils/ios-profiler/pipeline/01-correlate.ts index f9b480feb..11ae0cb37 100644 --- a/packages/tool-server/src/utils/ios-profiler/pipeline/01-correlate.ts +++ b/packages/tool-server/src/utils/ios-profiler/pipeline/01-correlate.ts @@ -136,13 +136,20 @@ export function aggregateLeaks(rawLeaks: RawLeak[]): MemoryLeak[] { // Group by object type AND responsible frame: with malloc_stack_logging the // same object type can leak from several distinct call sites, and each site // is its own finding — merging on type alone would attribute them all to - // whichever was parsed first and hide the rest. Under `--attach` (no stacks) - // every frame is the same sentinel, so this collapses back to per-type. + // whichever was parsed first and hide the rest. Key on the frame NORMALIZED + // the way isLeakAttributed() matches it (trimmed, with every unattributed + // spelling — "", "Unknown", the "" sentinel — + // collapsed to one bucket), not verbatim: an export can mix those spellings + // for one object type (a missing responsible-frame attribute parses as + // "Unknown"), and keying verbatim would split what is a single unattributed + // group per type. Under `--attach` (no stacks) everything is unattributed, + // so this collapses back to per-type. // The delimiter is a NUL, written as the `\u0000` escape rather than a // raw NUL byte: a raw NUL makes git treat this whole source file as binary // and hides the diff. Object types and demangled frames never contain a NUL, // so the composite key can't collide the way a printable separator could. - const key = `${leak.objectType}\u0000${leak.responsibleFrame}`; + const frameKey = isLeakAttributed(leak.responsibleFrame) ? leak.responsibleFrame.trim() : ""; + const key = `${leak.objectType}\u0000${frameKey}`; const existing = groups.get(key); if (existing) { existing.totalSize += leak.sizeBytes * leak.count; diff --git a/packages/tool-server/test/ios-leak-pipeline.test.ts b/packages/tool-server/test/ios-leak-pipeline.test.ts index acfa21343..2dddd8c8c 100644 --- a/packages/tool-server/test/ios-leak-pipeline.test.ts +++ b/packages/tool-server/test/ios-leak-pipeline.test.ts @@ -172,6 +172,65 @@ describe("aggregateLeaks", () => { ); }); + it("merges every unattributed frame spelling of one object type into one group", () => { + // The exporter is not consistent about how "no stack" is spelled: a missing + // responsible-frame attribute parses as "Unknown" (xml-parser default) while + // recorded-but-truncated stacks emit the "" + // sentinel, and isLeakAttributed() trims before matching. All of those are + // one attribution state, so they must be one group — keying on the verbatim + // frame would split them and inflate the "N unattributed leak group(s)" + // count in the report. + const rows = aggregateLeaks([ + { + objectType: "Malloc 48 Bytes", + sizeBytes: 48, + responsibleFrame: "", + responsibleLibrary: "", + count: 2, + }, + { + objectType: "Malloc 48 Bytes", + sizeBytes: 48, + responsibleFrame: "Unknown", + responsibleLibrary: "", + count: 1, + }, + { + objectType: "Malloc 48 Bytes", + sizeBytes: 48, + responsibleFrame: " ", + responsibleLibrary: "", + count: 1, + }, + ]); + expect(rows).toHaveLength(1); + expect(rows[0]?.count).toBe(4); + expect(rows[0]?.totalSizeBytes).toBe(192); + expect(rows[0]?.attributed).toBe(false); + }); + + it("merges attributed frames that differ only by surrounding whitespace", () => { + const rows = aggregateLeaks([ + { + objectType: "MyModel", + sizeBytes: 128, + responsibleFrame: "-[SiteA make]", + responsibleLibrary: "App", + count: 1, + }, + { + objectType: "MyModel", + sizeBytes: 128, + responsibleFrame: "-[SiteA make] ", + responsibleLibrary: "App", + count: 1, + }, + ]); + expect(rows).toHaveLength(1); + expect(rows[0]?.count).toBe(2); + expect(rows[0]?.attributed).toBe(true); + }); + it("returns [] for no leaks", () => { expect(aggregateLeaks([])).toEqual([]); }); From 9aa05db1bc1fa21570c505ddd525deeeb66cd42f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 10 Jul 2026 00:11:07 +0200 Subject: [PATCH 18/28] docs(ios-profiler): note ARGENT_IOS_CAPTURE=all-processes rejects malloc_stack_logging The malloc_stack_logging description framed ARGENT_IOS_CAPTURE only as the =device escape hatch. An operator who keeps =all-processes exported globally for the normal capture path would hit the up-front rejection with no hint from the flag's own description; say so and name the remedy. --- .../tools/profiler/native-profiler/native-profiler-start.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 f53a90c5c..fbea0361e 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 @@ -41,7 +41,9 @@ const zodSchema = z.object({ "allocation records a backtrace), so leave it off for pure CPU/hang profiling. Requires a " + "non-degraded Xcode: on Xcode 26.4 and later the cold-launch path is broken, so the call is " + "rejected up front (re-run without the flag, or set ARGENT_IOS_CAPTURE=device to override if the " + - "device path works on your host). " + + "device path works on your host). ARGENT_IOS_CAPTURE=all-processes — e.g. exported globally " + + "for the normal capture path — also rejects this flag up front, since that fallback cannot " + + "cold-launch; unset it (or set it to device) first. " + "Ignored on Android." ), }); From 737e56b9771cc23152a385e56d3aa6d5271bdb59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 10 Jul 2026 00:11:36 +0200 Subject: [PATCH 19/28] fix(ios-profiler): thread the real capture mode into the leak reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unattributed-leaks hint inferred the capture mode from the attributed count, which is wrong in exactly one case: a malloc_stack_logging capture that attributed nothing (short capture, freed-region reuse, system-lib leaks) was told it ran via xctrace --attach and advised to enable the flag it just used. native-profiler-start now stamps the capture mode on the session (mallocStackLogging, null when unknown — Android, or a session restored from disk, which keeps the old inference as the fallback), and analyze threads it into the renderer. The hint block itself — previously a verbatim copy kept in sync by hand between render.ts and the combined report — is now a single shared renderUnattributedLeaksNote(), with a test pinning that both reports emit a byte-identical caveat line. --- .../src/blueprints/native-profiler-session.ts | 10 +++ .../combined/profiler-combined-report.ts | 38 +++------ .../profiler/native-profiler/platforms/ios.ts | 8 ++ .../src/utils/ios-profiler/render.ts | 80 +++++++++++++------ .../ios-instruments/analyze-freshness.test.ts | 1 + .../leak-attribution-render.test.ts | 44 ++++++++++ .../malloc-stack-logging.test.ts | 1 + .../native-profiler-analyze-failure.test.ts | 1 + .../native-profiler-missing-trace.test.ts | 1 + .../test/profiler-combined-leaks.test.ts | 34 ++++++++ 10 files changed, 169 insertions(+), 49 deletions(-) diff --git a/packages/tool-server/src/blueprints/native-profiler-session.ts b/packages/tool-server/src/blueprints/native-profiler-session.ts index 09e61237a..5259eee12 100644 --- a/packages/tool-server/src/blueprints/native-profiler-session.ts +++ b/packages/tool-server/src/blueprints/native-profiler-session.ts @@ -55,6 +55,15 @@ export interface NativeProfilerSessionApi { * scopes via --attach and leaves this null. See utils/ios-profiler/capture-strategy. */ cpuFilterPid: number | null; + /** + * iOS-only: whether the current capture cold-launched the app with + * MallocStackLogging=1 (native-profiler-start's malloc_stack_logging flag), + * so the report layer can name the capture mode instead of inferring it from + * the attributed-leak count. Null when unknown — before any start, on + * Android, or for a session restored from disk (profiler-load has no + * capture-mode sidecar). + */ + mallocStackLogging: boolean | null; recordingTimeout: NodeJS.Timeout | null; recordingTimedOut: boolean; recordingExitedUnexpectedly: boolean; @@ -127,6 +136,7 @@ export const nativeProfilerSessionBlueprint: ServiceBlueprint< wallClockStartMs: null, parsedData: null, cpuFilterPid: null, + mallocStackLogging: null, recordingTimeout: null, recordingTimedOut: false, recordingExitedUnexpectedly: false, diff --git a/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts b/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts index a3f38b9a2..8462790ec 100644 --- a/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts +++ b/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts @@ -18,6 +18,7 @@ import { import type { HotCommitSummary } from "../../../utils/react-profiler/types/output"; import type { UiHang, MemoryLeak } from "../../../utils/profiler-shared/types"; import { formatBytes } from "../../../utils/profiler-shared/format"; +import { renderUnattributedLeaksNote } from "../../../utils/ios-profiler/render"; import { loadAndroidCombinedData } from "../../../utils/android-profiler/pipeline/index"; import { buildHotCommitSummaries } from "../../../utils/react-profiler/pipeline/00-hot-commits"; import { preprocess } from "../../../utils/react-profiler/pipeline/00-preprocess"; @@ -330,7 +331,9 @@ Fails if either react-profiler-analyze or native-profiler-analyze has not been c .map((c) => c.componentName) ); - lines.push(...renderCombinedMemoryLeaks(memoryLeaks, mountComponents)); + lines.push( + ...renderCombinedMemoryLeaks(memoryLeaks, mountComponents, nativeApi.mallocStackLogging) + ); } // Summary of opportunities @@ -371,14 +374,16 @@ Fails if either react-profiler-analyze or native-profiler-analyze has not been c * heuristically tied to recently-mounted React components; unattributed leaks * (`` under `xctrace --attach`) are collapsed into one * low-confidence YELLOW caveat so the simulator's benign system-allocation noise - * can't masquerade as a wall of confirmed leaks. The caveat's hint is capture-mode - * aware (mirroring render.ts): if some leaks WERE attributed, malloc stack logging - * was on, so it won't advise enabling the very thing the user just used. Exported - * for unit testing. + * can't masquerade as a wall of confirmed leaks. The caveat line comes from + * render.ts's shared renderUnattributedLeaksNote, so its capture-mode handling + * (and wording) cannot drift from the analyze report. `mallocStackLogging` is + * the session's actual capture mode when known; null/undefined falls back to + * inferring it from the attributed count. Exported for unit testing. */ export function renderCombinedMemoryLeaks( memoryLeaks: MemoryLeak[], - mountComponents: Set + mountComponents: Set, + mallocStackLogging?: boolean | null ): string[] { if (memoryLeaks.length === 0) return []; @@ -405,28 +410,9 @@ export function renderCombinedMemoryLeaks( } if (unattributedLeaks.length > 0) { - const objs = unattributedLeaks.reduce((s, b) => s + b.count, 0); - const bytes = unattributedLeaks.reduce((s, b) => s + b.totalSizeBytes, 0); - // Mirror render.ts's split: when this same capture ALSO produced attributed - // leaks, the app was launched under malloc stack logging (the only way a - // responsible frame is recorded), so the unattributed remainder is freed-region - // reuse / pre-recording noise — telling the user to "re-run with malloc stack - // logging" would be advising the thing they just did. Only when nothing is - // attributed is the --attach hint apt. Infer capture mode from the attributed - // count (the render layer has no direct capture-mode signal). - const hint = - attributedLeaks.length > 0 - ? `Some leaks here were attributed, so malloc stack logging was active — these remaining ` + - `groups carry no allocation backtrace (freed-region reuse, or allocations from before ` + - `recording started) and are most likely benign system allocations rather than confirmed app leaks.` - : `Argent records via \`xctrace --attach\`, which has no malloc-stack history, so these are most likely ` + - `benign system allocations rather than confirmed app leaks. For attributed stacks, capture with malloc ` + - `stack logging enabled at launch.`; lines.push( ``, - `> 🟡 **${unattributedLeaks.length} unattributed leak group(s)** ` + - `(${objs} object(s), ${formatBytes(bytes)}): responsible frame \`\`, no library. ` + - hint + renderUnattributedLeaksNote(unattributedLeaks, attributedLeaks.length, mallocStackLogging) ); } diff --git a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts index 197c7d0e9..7f627d592 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts @@ -368,6 +368,7 @@ function resetStartState(api: NativeProfilerSessionApi): void { api.traceFile = null; api.appProcess = null; api.cpuFilterPid = null; + api.mallocStackLogging = null; } export function handleXctraceExit( @@ -560,6 +561,9 @@ export async function startNativeProfilerIos( const attemptStart = async (): Promise<{ child: ChildProcess; pid: number }> => { api.appProcess = appProcess; api.traceFile = outputFile; + // Record the capture mode on the session so the report layer can name it + // (its unattributed-leaks note) instead of inferring it from output. + api.mallocStackLogging = useMallocStackLogging; // Null for the device strategy (already scoped by --attach) and for a // malloc_stack_logging cold launch (scoped by --launch on --device); the app // PID only for the host-wide all-processes fallback, to filter the samples. @@ -881,5 +885,9 @@ export async function analyzeNativeProfilerIos( // iOS sidecar this Android-scoped change does not add; formatTraceFreshness // degrades cleanly to null in that case. See test/ios-instruments/load-freshness.test.ts. freshnessNote: formatTraceFreshness(api.wallClockStartMs, Date.now()) ?? undefined, + // Same live-session-only caveat as wallClockStartMs: profiler-load has no + // capture-mode sidecar, so a restored session renders with null (the + // unattributed-leaks note then falls back to inferring the mode). + mallocStackLogging: api.mallocStackLogging, }); } diff --git a/packages/tool-server/src/utils/ios-profiler/render.ts b/packages/tool-server/src/utils/ios-profiler/render.ts index 7cb8ee857..aab4d5d12 100644 --- a/packages/tool-server/src/utils/ios-profiler/render.ts +++ b/packages/tool-server/src/utils/ios-profiler/render.ts @@ -23,6 +23,14 @@ interface RenderInput { exportErrors?: Record; /** Optional markdown warning shown in the header when the trace is stale. */ freshnessNote?: string; + /** + * Whether the capture cold-launched the app with MallocStackLogging=1 + * (native-profiler-start's malloc_stack_logging flag). Null/undefined when + * unknown — a session restored from disk has no capture-mode sidecar — in + * which case the unattributed-leaks note infers the mode from the + * attributed-leak count. + */ + mallocStackLogging?: boolean | null; } interface InlineCap { @@ -38,7 +46,7 @@ interface InlineCap { export async function renderNativeProfilerReport( input: RenderInput ): Promise { - const { payload, traceFile, freshnessNote } = input; + const { payload, traceFile, freshnessNote, mallocStackLogging } = input; const exportErrors = input.exportErrors ?? {}; const bottlenecksTotal = payload.bottlenecks.length; const status: "ok" | "analysis_failed" = @@ -57,7 +65,8 @@ export async function renderNativeProfilerReport( hotspotLimit: Infinity, hangLimit: Infinity, }, - freshnessNote + freshnessNote, + mallocStackLogging ); const reportFile = traceFile ? deriveReportPath(traceFile) : null; @@ -73,7 +82,8 @@ export async function renderNativeProfilerReport( hotspotLimit: MAX_INLINE_HOTSPOTS, hangLimit: MAX_INLINE_HANGS, }, - freshnessNote + freshnessNote, + mallocStackLogging ); const shownHotspots = Math.min(MAX_INLINE_HOTSPOTS, cpuHotspotsCount); @@ -197,7 +207,8 @@ function renderFullReport( payload: ProfilerPayload, exportErrors?: Record, cap: InlineCap = { hotspotLimit: Infinity, hangLimit: Infinity }, - freshnessNote?: string + freshnessNote?: string, + mallocStackLogging?: boolean | null ): string { const traceName = payload.metadata.traceFile ? `\`${path.basename(payload.metadata.traceFile)}\`` @@ -411,27 +422,9 @@ function renderFullReport( } if (unattributedLeaks.length > 0) { - const objs = unattributedLeaks.reduce((s, b) => s + b.count, 0); - const bytes = unattributedLeaks.reduce((s, b) => s + b.totalSizeBytes, 0); - // When this same capture ALSO produced attributed leaks, the app was launched - // under malloc stack logging (the only way a responsible frame is recorded), so - // the unattributed remainder is pre-existing / freed-region reuse — telling the - // user to "re-run with malloc stack logging" would be advising the thing they - // just did. Only when nothing is attributed is the --attach hint apt. The render - // layer has no direct capture-mode signal, so it infers it from the attributed count. - const hint = - attributedLeaks.length > 0 - ? `Some leaks here were attributed, so malloc stack logging was active — these remaining ` + - `groups carry no allocation backtrace (freed-region reuse, or allocations from before ` + - `recording started) and are most likely benign system allocations rather than confirmed app leaks.` - : `Argent records via \`xctrace --attach\`, which has no malloc-stack history, so these are most likely ` + - `benign system allocations rather than confirmed app leaks. For attributed stacks, capture with malloc ` + - `stack logging enabled at launch.`; lines.push( ``, - `> ${severityEmoji("YELLOW")} **${unattributedLeaks.length} unattributed leak group(s)** ` + - `(${objs} object(s), ${formatBytes(bytes)}): responsible frame \`\`, no library. ` + - hint + renderUnattributedLeaksNote(unattributedLeaks, attributedLeaks.length, mallocStackLogging) ); } } @@ -542,6 +535,47 @@ function renderFullReport( // Helpers // --------------------------------------------------------------------------- +/** + * The single low-confidence caveat line for unattributed leaks, shared by the + * full analyze report above and the combined React × native report — one + * source so the wording can't silently diverge between the two. + * + * `mallocStackLogging` is the actual capture mode when known (a live session + * carries it from native-profiler-start). Pass null/undefined when unknown — + * a session restored from disk has no capture-mode sidecar — and the mode is + * inferred from the attributed count instead: a responsible frame is only + * ever recorded when the app was launched under malloc stack logging, so + * attributed leaks present ⇒ the flag was on. The inference is silent about + * the one case it cannot see (a malloc capture that attributed nothing), which + * is exactly why callers that know the mode must pass it. + */ +export function renderUnattributedLeaksNote( + unattributedLeaks: MemoryLeak[], + attributedCount: number, + mallocStackLogging?: boolean | null +): string { + const objs = unattributedLeaks.reduce((s, b) => s + b.count, 0); + const bytes = unattributedLeaks.reduce((s, b) => s + b.totalSizeBytes, 0); + const mallocWasOn = mallocStackLogging ?? attributedCount > 0; + const hint = !mallocWasOn + ? `Argent records via \`xctrace --attach\`, which has no malloc-stack history, so these are most likely ` + + `benign system allocations rather than confirmed app leaks. For attributed stacks, capture with malloc ` + + `stack logging enabled at launch.` + : attributedCount > 0 + ? `Some leaks here were attributed, so malloc stack logging was active — these remaining ` + + `groups carry no allocation backtrace (freed-region reuse, or allocations from before ` + + `recording started) and are most likely benign system allocations rather than confirmed app leaks.` + : `This capture ran with malloc stack logging enabled, yet none of these leaks carry an allocation ` + + `backtrace — the allocator recorded no usable stack for them (freed-region reuse, truncated stack ` + + `logs, or allocations outside the instrumented malloc zones). Most likely benign system allocations ` + + `rather than confirmed app leaks; re-running with malloc stack logging again is unlikely to attribute them.`; + return ( + `> ${severityEmoji("YELLOW")} **${unattributedLeaks.length} unattributed leak group(s)** ` + + `(${objs} object(s), ${formatBytes(bytes)}): responsible frame \`\`, no library. ` + + hint + ); +} + function renderExportErrors(exportErrors?: Record): string[] { if (!exportErrors || Object.keys(exportErrors).length === 0) return []; const lines: string[] = [`> **Export warnings:**`]; diff --git a/packages/tool-server/test/ios-instruments/analyze-freshness.test.ts b/packages/tool-server/test/ios-instruments/analyze-freshness.test.ts index f14415b91..729044b09 100644 --- a/packages/tool-server/test/ios-instruments/analyze-freshness.test.ts +++ b/packages/tool-server/test/ios-instruments/analyze-freshness.test.ts @@ -25,6 +25,7 @@ function makeApi(wallClockStartMs: number | null): NativeProfilerSessionApi { appProcess: "MyApp", capturePid: null, cpuFilterPid: null, + mallocStackLogging: null, captureProcess: null, traceFile: "/tmp/native-profiler-ios.trace", // null exporter paths → checkExportFileMissing short-circuits (no fs access); diff --git a/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts b/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts index d30e092e3..c6c53ba0f 100644 --- a/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts +++ b/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts @@ -57,6 +57,50 @@ describe("leak attribution rendering", () => { expect(res.report).toContain("malloc stack logging was active"); }); + it("names the malloc capture — never --attach — when malloc_stack_logging was on but nothing attributed", async () => { + // The one case the attributed-count inference gets wrong: a capture that ran + // with malloc_stack_logging: true yet attributed nothing (short capture, + // freed-region reuse, system-lib leaks). With the capture mode threaded in + // explicitly, the note must not claim the capture used `--attach` and must + // not advise enabling the flag the user just used. + const res = await renderNativeProfilerReport({ + payload: payload([leak(false, "")]), + traceFile: null, + mallocStackLogging: true, + }); + expect(res.report).toContain("unattributed leak group"); + expect(res.report).not.toContain("--attach"); + expect(res.report).not.toContain("stack logging enabled at launch"); + expect(res.report).toContain("ran with malloc stack logging enabled"); + }); + + it("keeps the --attach hint when the capture mode is explicitly attach", async () => { + const res = await renderNativeProfilerReport({ + payload: payload([leak(false, "")]), + traceFile: null, + mallocStackLogging: false, + }); + expect(res.report).toContain("unattributed leak group"); + expect(res.report).toContain("xctrace --attach"); + expect(res.report).toContain("malloc stack logging enabled at launch"); + }); + + it("falls back to the attributed-count inference when the capture mode is unknown", async () => { + // Sessions restored from disk (profiler-load) have no capture-mode sidecar: + // mallocStackLogging is null and the renderer infers the mode the old way — + // attributed leaks present ⇒ malloc logging must have been on. + const res = await renderNativeProfilerReport({ + payload: payload([ + leak(true, "hermes::vm::JSTypedArrayBase::createBuffer(...)", "hermes"), + leak(false, ""), + ]), + traceFile: null, + mallocStackLogging: null, + }); + expect(res.report).toContain("malloc stack logging was active"); + expect(res.report).not.toContain("stack logging enabled at launch"); + }); + it("does not surface an unattributed leak as an attributed one", async () => { // Use a real classifier sentinel ("" → isLeakAttributed === false) so the // fixture's attributed:false matches what the pipeline would actually assign; diff --git a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts index e105617a0..7d80e54b0 100644 --- a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts +++ b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts @@ -31,6 +31,7 @@ function fakeApi(): NativeProfilerSessionApi { capturePid: null, captureProcess: null, cpuFilterPid: null, + mallocStackLogging: null, traceFile: null, exportedFiles: null, profilingActive: false, diff --git a/packages/tool-server/test/native-profiler-analyze-failure.test.ts b/packages/tool-server/test/native-profiler-analyze-failure.test.ts index fd1954f8a..c6d1a53b9 100644 --- a/packages/tool-server/test/native-profiler-analyze-failure.test.ts +++ b/packages/tool-server/test/native-profiler-analyze-failure.test.ts @@ -94,6 +94,7 @@ async function buildSessionWithTrace(): Promise<{ wallClockStartMs: null, parsedData: null, cpuFilterPid: null, + mallocStackLogging: null, recordingTimeout: null, recordingTimedOut: false, recordingExitedUnexpectedly: false, 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 7da4f7a2a..7a505fabb 100644 --- a/packages/tool-server/test/native-profiler-missing-trace.test.ts +++ b/packages/tool-server/test/native-profiler-missing-trace.test.ts @@ -63,6 +63,7 @@ describe("native-profiler-analyze: missing trace file", () => { wallClockStartMs: null, parsedData: null, cpuFilterPid: null, + mallocStackLogging: null, recordingTimeout: null, recordingTimedOut: false, recordingExitedUnexpectedly: false, diff --git a/packages/tool-server/test/profiler-combined-leaks.test.ts b/packages/tool-server/test/profiler-combined-leaks.test.ts index 9b7fb192c..80a2287bf 100644 --- a/packages/tool-server/test/profiler-combined-leaks.test.ts +++ b/packages/tool-server/test/profiler-combined-leaks.test.ts @@ -96,6 +96,40 @@ describe("renderCombinedMemoryLeaks", () => { expect(out).not.toContain("No attributed leaks"); }); + it("names the malloc capture — never --attach — when malloc_stack_logging was on but nothing attributed", () => { + // Same inference gap as render.ts: with the capture mode passed in + // explicitly, a malloc capture that attributed nothing must not get the + // `--attach` framing or the advice to enable the flag that was already on. + const out = renderCombinedMemoryLeaks([UNATTRIBUTED], new Set(), true).join("\n"); + + expect(out).toContain("1 unattributed leak group(s)"); + expect(out).not.toContain("--attach"); + expect(out).not.toContain("stack logging enabled at launch"); + expect(out).toContain("ran with malloc stack logging enabled"); + }); + + it("renders the identical caveat line as the analyze report (shared helper, no drift)", async () => { + // The hint block used to be a verbatim copy in render.ts and here, kept in + // sync by hand. Both now delegate to one helper — pin that the emitted + // caveat line is byte-identical so a wording change can't silently diverge. + const { renderNativeProfilerReport } = await import("../src/utils/ios-profiler/render"); + const combinedLine = renderCombinedMemoryLeaks([UNATTRIBUTED], new Set(), true) + .join("\n") + .split("\n") + .find((l) => l.includes("unattributed leak group")); + const res = await renderNativeProfilerReport({ + payload: { + metadata: { traceFile: null, platform: "iOS", timestamp: "2026-06-16T00:00:00Z" }, + bottlenecks: [UNATTRIBUTED], + }, + traceFile: null, + mallocStackLogging: true, + }); + const renderLine = res.report.split("\n").find((l) => l.includes("unattributed leak group")); + expect(combinedLine).toBeDefined(); + expect(combinedLine).toBe(renderLine); + }); + it("ties an attributed leak to a recently-mounted React component when names overlap", () => { const out = renderCombinedMemoryLeaks([ATTRIBUTED], new Set(["MyModel"])).join("\n"); expect(out).toContain("may relate to `MyModel` mount/unmount"); From e2d9aff50fc7941baaa02c244c554fdd80011672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 10 Jul 2026 00:11:44 +0200 Subject: [PATCH 20/28] test(ios-profiler): pin the notify-present malloc argv and --no-prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every malloc-argv test let the notify mock throw, so registerStartupNotify returned null and --notify-tracing-started was never in the asserted argv — the `--launch -- `-is-last guarantee was never exercised with the notify pair sitting in between, the shape a real host produces. And --no-prompt was unasserted in the malloc branch, which hand-rolls its argv apart from the strategy builder. Cover both; the production argv was already correct. --- .../malloc-stack-logging.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts index 7d80e54b0..5f65d8a28 100644 --- a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts +++ b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts @@ -152,6 +152,10 @@ describe("native-profiler-start malloc_stack_logging", () => { expect(dashIdx).toBeGreaterThan(args.indexOf("--launch")); expect(args[dashIdx + 1]).toBe("/Users/x/Library/.../MyApp.app"); expect(dashIdx + 1).toBe(args.length - 1); + // the malloc branch hand-rolls its argv apart from the strategy builder, so + // --no-prompt must be asserted here separately or a regression dropping it + // (xctrace then blocks on an interactive prompt) would slip past. + expect(args).toContain("--no-prompt"); // the running instance is terminated first for a clean cold start const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []); @@ -161,6 +165,56 @@ describe("native-profiler-start malloc_stack_logging", () => { expect(efsArgs.some((a) => a.includes("get_app_container"))).toBe(true); }); + it("keeps `--launch -- ` last when --notify-tracing-started is present", async () => { + // Every other test lets the notify mock throw, so registerStartupNotify + // returns null and the argv never contains --notify-tracing-started — on a + // real host it IS present. Exercise that argv shape: the notify args must + // precede --launch (xctrace treats everything after `--` as the launched + // command plus ITS args, so a notify arg ordered after it would be handed + // to the app instead of xctrace). + const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcess(); + vi.doMock("child_process", () => ({ + spawn: spawnFn, + execSync: execSyncFn, + execFile: vi.fn(), + execFileSync: execFileSyncFn, + })); + vi.doMock("../../src/utils/react-profiler/debug/dump", () => ({ + getDebugDir: vi.fn(async () => "/tmp/argent-profiler-cwd"), + })); + vi.doMock("../../src/utils/ios-profiler/notify", () => ({ + listenForDarwinNotification: vi.fn(() => ({ + ready: Promise.resolve(), + fired: new Promise(() => {}), + cancel: vi.fn(), + })), + })); + vi.doMock("../../src/utils/ios-profiler/startup", () => ({ + waitForXctraceReady: vi.fn(async () => ({ stderrBuffer: "" })), + })); + + const startNativeProfilerIos = await importStart(); + const api = fakeApi(); + const result = await startNativeProfilerIos(api, { + device_id: "DEVICE-UDID", + app_process: "MyApp", + malloc_stack_logging: true, + }); + + expect(result.status).toBe("recording"); + const [bin, args] = spawnFn.mock.calls[0] as unknown as [string, string[]]; + expect(bin).toBe("xctrace"); + + const notifyIdx = args.indexOf("--notify-tracing-started"); + expect(notifyIdx).toBeGreaterThan(-1); + expect(args[notifyIdx + 1]).toMatch(/^com\.argent\.ios-profiler\.started\./); + expect(notifyIdx).toBeLessThan(args.indexOf("--launch")); + expect(args).toContain("--no-prompt"); + // `--launch -- ` must be the final three args even with the notify + // pair in the argv. + expect(args.slice(-3)).toEqual(["--launch", "--", "/Users/x/Library/.../MyApp.app"]); + }); + it("captures the listapps plist with a large buffer so a big simulator can't overflow it", async () => { // getInstalledApps buffers the FULL plist into Node (it's larger than the JSON // plutil emits), so the listapps capture must raise maxBuffer well above Node's From 284ccf8bc5295974e85b7eb7cfc5dedf0bac84d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 10 Jul 2026 04:55:04 +0200 Subject: [PATCH 21/28] fix(ios-profiler): clear stale mallocStackLogging when loading a session from disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live malloc_stack_logging capture stamps the session flag, and profiler-load (load_native) replaces exportedFiles/parsedData without touching it — so a session restored afterwards rendered the LOADED trace with the previous capture's mode, claiming "ran with malloc stack logging enabled" for a trace whose mode is unknown. The raw_*.xml carry no capture-mode sidecar, so null (unknown -> attributed-count inference) is the correct restored state. Repro'd via profiler-load on a session api with the flag set; regression test pins the reset. --- .../src/tools/profiler/query/profiler-load.ts | 4 ++++ .../ios-instruments/load-freshness.test.ts | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+) 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 1a5cac346..71ee4c81f 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-load.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-load.ts @@ -404,6 +404,10 @@ async function loadNativeSession( api.parsedData = { cpuSamples, uiHangs, cpuHotspots, memoryLeaks }; api.exportedFiles = files; + // The raw_*.xml carry no capture-mode sidecar, so the loaded trace's mode is + // unknown — clear any flag left by an earlier live capture in this process, + // or the report would attribute THIS trace to the previous session's mode. + api.mallocStackLogging = null; const lines: string[] = [ `Loaded native profiler session \`${sessionId}\`.`, diff --git a/packages/tool-server/test/ios-instruments/load-freshness.test.ts b/packages/tool-server/test/ios-instruments/load-freshness.test.ts index ed97aa991..dd2aef562 100644 --- a/packages/tool-server/test/ios-instruments/load-freshness.test.ts +++ b/packages/tool-server/test/ios-instruments/load-freshness.test.ts @@ -110,4 +110,25 @@ describe("iOS profiler-load → analyze freshness wiring (PR #340 comment 2)", ( const { report } = await analyzeNativeProfilerIos(api); expect(report).toContain("Stale trace"); }); + + it("clears a previous live capture's mallocStackLogging on load (the loaded trace's mode is unknown)", async () => { + // Same staleness class as wallClockStartMs, but this one IS resettable at + // load time: a live malloc_stack_logging capture stamps the session flag, + // and the raw_*.xml carry no capture-mode sidecar — so restoring an OLDER + // session must clear the flag, or analyze/combined-report would attribute + // the loaded trace to the previous session's capture mode. + const api = await buildIosSession(); + api.mallocStackLogging = true; // what native-profiler-start(malloc) leaves behind + + const cpuXml = join(tempDir, `native-profiler-${SESSION_ID}_raw_cpu.xml`); + await writeFile(cpuXml, "", "utf8"); + await profilerLoadTool.execute({ session: api } as never, { + mode: "load_native", + session_id: SESSION_ID, + port: 8081, + device_id: "ios-sim", + }); + + expect(api.mallocStackLogging).toBeNull(); + }); }); From 8c80cd11998430d290120b32769ca08347cc698b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 10 Jul 2026 04:58:39 +0200 Subject: [PATCH 22/28] fix(ios-profiler): make the leak_stacks drill-down capture-mode aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderLeakStacksIos carried the same unconditional "captured under xctrace --attach" framing the analyze report had: correct before malloc_stack_logging existed, wrong the moment a malloc capture leaves groups unattributed. Apply the same contract as the analyze/combined reports — use the session's real capture mode, fall back to inferring it from any attributed group in the FULL capture (deliberately not the object_type-filtered slice, so filtering out the attributed groups can't flip the note back to blaming --attach). --- .../profiler/query/profiler-stack-query.ts | 26 ++++++++++++--- .../test/profiler-leak-stacks.test.ts | 32 +++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts b/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts index 04143bf2b..c0c7fe31c 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts @@ -266,8 +266,17 @@ function renderThreadBreakdownIos( export function renderLeakStacksIos( memoryLeaks: MemoryLeak[], objectTypeFilter: string | undefined, - topN: number + topN: number, + mallocStackLogging?: boolean | null ): string { + // Same capture-mode contract as the analyze/combined reports: use the + // session's actual malloc_stack_logging flag when known; when unknown (a + // session restored from disk), infer it from the FULL capture — any + // attributed group means the app was launched under malloc stack logging + // (the only way a responsible frame is recorded). Inference deliberately + // ignores the object_type filter: filtering out every attributed group must + // not flip the note back to blaming `--attach`. + const mallocWasOn = mallocStackLogging ?? memoryLeaks.some((l) => l.attributed); let filtered = memoryLeaks; if (objectTypeFilter) { filtered = memoryLeaks.filter((l) => @@ -303,10 +312,14 @@ export function renderLeakStacksIos( ]; if (unattributedCount > 0) { + const cause = mallocWasOn + ? "the capture ran with malloc stack logging, but no allocation backtrace was recorded " + + "for these (freed-region reuse, or allocations from before recording started)" + : "captured under `xctrace --attach`, which has no malloc-stack history"; lines.push( `> 🟡 ${unattributedCount} of ${sorted.length} group(s) are unattributed ` + - "(``, no library) — captured under `xctrace --attach`, which has no " + - "malloc-stack history. Most likely benign system allocations, not confirmed app leaks.", + `(\`\`, no library) — ${cause}. ` + + "Most likely benign system allocations, not confirmed app leaks.", "" ); } @@ -358,7 +371,12 @@ async function executeIos(api: NativeProfilerSessionApi, params: z.infer { expect(rows[2]).toContain("Malloc"); }); }); + +describe("renderLeakStacksIos — capture-mode-aware unattributed note", () => { + // Same contract as the analyze/combined reports: the "captured under + // `xctrace --attach`" framing is only apt when the capture actually attached. + // A malloc_stack_logging capture (flag threaded from the session) or a + // capture that attributed ANY group (a frame is only ever recorded under + // malloc stack logging) must not blame --attach for the unattributed rest. + it("does not claim --attach for a malloc capture with zero attributed groups", () => { + const out = renderLeakStacksIos([noise(0)], undefined, 10, true); + expect(out).toContain("unattributed"); + expect(out).not.toContain("--attach"); + expect(out).toContain("malloc stack logging"); + }); + + it("infers malloc mode from attributed groups when the capture mode is unknown", () => { + const out = renderLeakStacksIos([ATTRIBUTED_SMALL, noise(0)], undefined, 10, null); + expect(out).not.toContain("--attach"); + }); + + it("infers from the FULL capture, not the filtered slice", () => { + // The filter matches only unattributed noise, but the capture DID attribute + // MyModel — so malloc logging was on and --attach must not be blamed. + const out = renderLeakStacksIos([ATTRIBUTED_SMALL, noise(0)], "Malloc", 10, null); + expect(out).toContain("unattributed"); + expect(out).not.toContain("--attach"); + }); + + it("keeps the --attach note for an attach capture", () => { + const out = renderLeakStacksIos([noise(0)], undefined, 10, false); + expect(out).toContain("xctrace --attach"); + }); +}); From 1c6b5bdf78ee9ac87636e2fefdb65703348d4eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 10 Jul 2026 05:22:15 +0200 Subject: [PATCH 23/28] fix(ios-profiler): pair the capture mode with the data it describes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session's mallocStackLogging was stamped at START, but exportedFiles is written at stop and parsedData at analyze — so a new recording re-labeled the previous capture's still-loaded data with its own mode. Concretely: analyze run mid-recording rendered a malloc capture's attributed table above a note claiming the capture used --attach, and leak_stacks/combined-report showed the inverse contradiction in the other ordering. Split the field: recordingMallocStackLogging is stamped at start (the in-flight capture), and mallocStackLogging flips only when stop writes exportedFiles (both the clean and the timed-out/unexpected-exit export paths). parsedData freezes the mode at analyze/load time so the drill-down consumers (leak_stacks, combined report) read the mode of the data they render, not the live session field. A failed start now leaves the previous capture's report-facing mode intact instead of nulling it. Also, per the same review pass: - profiler-load now clears ALL per-capture residue, not just the capture mode: a stale cpuFilterPid silently filtered the loaded trace's CPU samples by a dead PID, a stale wallClockStartMs fired the stale-trace note with the wrong timestamp, and a stale traceFile mislabeled the report and wrote the .md over the old trace's report. - The malloc-mode hint no longer lists 'allocations from before recording started' — impossible under a cold launch, where the process is created inside the recording; use the defensible causes (freed-region reuse, truncated stack logs, allocations outside the instrumented zones). - malloc-stack-logging.test.ts scrubs ARGENT_IOS_CAPTURE per test: four tests failed spuriously on hosts that export it globally — the exact setup the schema doc describes. --- .../src/blueprints/native-profiler-session.ts | 27 +++++++--- .../combined/profiler-combined-report.ts | 8 ++- .../profiler/native-profiler/platforms/ios.ts | 30 +++++++++-- .../src/tools/profiler/query/profiler-load.ts | 15 ++++-- .../profiler/query/profiler-stack-query.ts | 8 ++- .../src/utils/ios-profiler/render.ts | 5 +- .../ios-instruments/analyze-freshness.test.ts | 1 + .../ios-instruments/load-freshness.test.ts | 32 +++++++++--- .../malloc-stack-logging.test.ts | 37 ++++++++++++++ .../ios-instruments/stop-recovery.test.ts | 50 +++++++++++++++++++ .../native-profiler-analyze-failure.test.ts | 1 + .../native-profiler-missing-trace.test.ts | 1 + 12 files changed, 188 insertions(+), 27 deletions(-) diff --git a/packages/tool-server/src/blueprints/native-profiler-session.ts b/packages/tool-server/src/blueprints/native-profiler-session.ts index 5259eee12..49e062ebf 100644 --- a/packages/tool-server/src/blueprints/native-profiler-session.ts +++ b/packages/tool-server/src/blueprints/native-profiler-session.ts @@ -33,6 +33,13 @@ export interface NativeProfilerParsedData { uiHangs: UiHang[]; cpuHotspots: CpuHotspot[]; memoryLeaks: MemoryLeak[]; + /** + * Capture mode of THIS parsed data (see the session field of the same name), + * frozen at parse time so drill-down consumers (leak_stacks, the combined + * report) stay paired with the data even after a newer capture re-stamps the + * session. Null when unknown (session restored from disk). + */ + mallocStackLogging: boolean | null; } export interface NativeProfilerSessionApi { @@ -56,12 +63,19 @@ export interface NativeProfilerSessionApi { */ cpuFilterPid: number | null; /** - * iOS-only: whether the current capture cold-launched the app with - * MallocStackLogging=1 (native-profiler-start's malloc_stack_logging flag), - * so the report layer can name the capture mode instead of inferring it from - * the attributed-leak count. Null when unknown — before any start, on - * Android, or for a session restored from disk (profiler-load has no - * capture-mode sidecar). + * iOS-only: whether the IN-FLIGHT (or most recently attempted) recording was + * cold-launched with MallocStackLogging=1 (native-profiler-start's + * malloc_stack_logging flag). Stamped at start, copied into + * `mallocStackLogging` when stop writes `exportedFiles` — the split keeps a + * new start from re-labeling the previous capture's still-loaded data. + */ + recordingMallocStackLogging: boolean | null; + /** + * iOS-only: capture mode of the data currently in `exportedFiles` (and, via + * analyze, `parsedData`) — the report layer names it instead of inferring it + * from the attributed-leak count. Stamped at stop alongside `exportedFiles`; + * cleared by profiler-load (the raw_*.xml carry no capture-mode sidecar). + * Null when unknown — before any stop, on Android, or after a load. */ mallocStackLogging: boolean | null; recordingTimeout: NodeJS.Timeout | null; @@ -136,6 +150,7 @@ export const nativeProfilerSessionBlueprint: ServiceBlueprint< wallClockStartMs: null, parsedData: null, cpuFilterPid: null, + recordingMallocStackLogging: null, mallocStackLogging: null, recordingTimeout: null, recordingTimedOut: false, diff --git a/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts b/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts index 8462790ec..6a25c394b 100644 --- a/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts +++ b/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts @@ -332,7 +332,13 @@ Fails if either react-profiler-analyze or native-profiler-analyze has not been c ); lines.push( - ...renderCombinedMemoryLeaks(memoryLeaks, mountComponents, nativeApi.mallocStackLogging) + ...renderCombinedMemoryLeaks( + memoryLeaks, + mountComponents, + // From parsedData, not the live session field: a recording started + // after the analyze must not re-label the data being rendered here. + nativeApi.parsedData?.mallocStackLogging ?? null + ) ); } diff --git a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts index 7f627d592..2a2c15e5b 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts @@ -368,7 +368,9 @@ function resetStartState(api: NativeProfilerSessionApi): void { api.traceFile = null; api.appProcess = null; api.cpuFilterPid = null; - api.mallocStackLogging = null; + // Only the in-flight mode: api.mallocStackLogging still describes the + // previous capture's exportedFiles, which a failed start leaves in place. + api.recordingMallocStackLogging = null; } export function handleXctraceExit( @@ -561,9 +563,12 @@ export async function startNativeProfilerIos( const attemptStart = async (): Promise<{ child: ChildProcess; pid: number }> => { api.appProcess = appProcess; api.traceFile = outputFile; - // Record the capture mode on the session so the report layer can name it - // (its unattributed-leaks note) instead of inferring it from output. - api.mallocStackLogging = useMallocStackLogging; + // Record the in-flight capture's mode; stop copies it into + // api.mallocStackLogging when it writes exportedFiles, so the report layer + // names the mode of the data it actually renders — stamping the session's + // report-facing flag HERE would re-label the previous capture's + // still-loaded exports/parsedData with the new capture's mode. + api.recordingMallocStackLogging = useMallocStackLogging; // Null for the device strategy (already scoped by --attach) and for a // malloc_stack_logging cold launch (scoped by --launch on --device); the app // PID only for the host-wide all-processes fallback, to filter the samples. @@ -737,6 +742,9 @@ export async function stopNativeProfilerIos(api: NativeProfilerSessionApi): Prom const { files: exportedFiles, diagnostics } = await exportIosTraceData(traceFile); api.exportedFiles = exportedFiles; + // The exports now describe the recording that just ended — pair its + // capture mode with them for the report layer. + api.mallocStackLogging = api.recordingMallocStackLogging; const warning = wasTimeout ? "Recording timed out at 10 min cap; exported the partial trace. " + @@ -789,6 +797,9 @@ export async function stopNativeProfilerIos(api: NativeProfilerSessionApi): Prom const { files: exportedFiles, diagnostics } = await exportIosTraceData(api.traceFile); api.exportedFiles = exportedFiles; + // The exports now describe the recording that just ended — pair its capture + // mode with them for the report layer. + api.mallocStackLogging = api.recordingMallocStackLogging; const stopResult: IosStopResult = { traceFile: api.traceFile, @@ -833,7 +844,16 @@ export async function analyzeNativeProfilerIos( const { bottlenecks, cpuSamples, uiHangs, cpuHotspots, memoryLeaks } = await runIosProfilerPipeline(api.exportedFiles, { cpuFilterPid: api.cpuFilterPid }); - api.parsedData = { cpuSamples, uiHangs, cpuHotspots, memoryLeaks }; + api.parsedData = { + cpuSamples, + uiHangs, + cpuHotspots, + memoryLeaks, + // Freeze the exports' capture mode with the parsed data so drill-down + // consumers (leak_stacks, combined report) stay paired with it even after + // a newer capture re-stamps the session fields. + mallocStackLogging: api.mallocStackLogging, + }; const exportErrors: Record = {}; if (!api.exportedFiles.cpu) { 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 71ee4c81f..a5f588bff 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-load.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-load.ts @@ -402,12 +402,19 @@ async function loadNativeSession( const { cpuSamples, uiHangs, cpuHotspots, memoryLeaks } = await runIosProfilerPipeline(files); - api.parsedData = { cpuSamples, uiHangs, cpuHotspots, memoryLeaks }; + api.parsedData = { cpuSamples, uiHangs, cpuHotspots, memoryLeaks, mallocStackLogging: null }; api.exportedFiles = files; - // The raw_*.xml carry no capture-mode sidecar, so the loaded trace's mode is - // unknown — clear any flag left by an earlier live capture in this process, - // or the report would attribute THIS trace to the previous session's mode. + // The raw_*.xml carry no metadata sidecar, so nothing per-capture is known + // about the loaded trace — clear ALL the session residue an earlier live + // capture in this process left behind, or analyze would attribute it to the + // loaded trace: the capture mode (mis-labels the unattributed-leaks note), + // the CPU filter PID (silently drops the loaded trace's samples), the start + // time (fires the stale-trace note with the wrong timestamp), and the .trace + // path (mislabels the report and writes the .md over the OLD trace's report). api.mallocStackLogging = null; + api.cpuFilterPid = null; + api.wallClockStartMs = null; + api.traceFile = null; const lines: string[] = [ `Loaded native profiler session \`${sessionId}\`.`, diff --git a/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts b/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts index c0c7fe31c..f69d56ad0 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts @@ -314,7 +314,8 @@ export function renderLeakStacksIos( if (unattributedCount > 0) { const cause = mallocWasOn ? "the capture ran with malloc stack logging, but no allocation backtrace was recorded " + - "for these (freed-region reuse, or allocations from before recording started)" + "for these (freed-region reuse, truncated stack logs, or allocations outside the " + + "instrumented malloc zones)" : "captured under `xctrace --attach`, which has no malloc-stack history"; lines.push( `> 🟡 ${unattributedCount} of ${sorted.length} group(s) are unattributed ` + @@ -371,11 +372,14 @@ async function executeIos(api: NativeProfilerSessionApi, params: z.infer 0 ? `Some leaks here were attributed, so malloc stack logging was active — these remaining ` + - `groups carry no allocation backtrace (freed-region reuse, or allocations from before ` + - `recording started) and are most likely benign system allocations rather than confirmed app leaks.` + `groups carry no allocation backtrace (freed-region reuse, truncated stack logs, or ` + + `allocations outside the instrumented malloc zones) and are most likely benign system ` + + `allocations rather than confirmed app leaks.` : `This capture ran with malloc stack logging enabled, yet none of these leaks carry an allocation ` + `backtrace — the allocator recorded no usable stack for them (freed-region reuse, truncated stack ` + `logs, or allocations outside the instrumented malloc zones). Most likely benign system allocations ` + diff --git a/packages/tool-server/test/ios-instruments/analyze-freshness.test.ts b/packages/tool-server/test/ios-instruments/analyze-freshness.test.ts index 729044b09..f22b6d550 100644 --- a/packages/tool-server/test/ios-instruments/analyze-freshness.test.ts +++ b/packages/tool-server/test/ios-instruments/analyze-freshness.test.ts @@ -25,6 +25,7 @@ function makeApi(wallClockStartMs: number | null): NativeProfilerSessionApi { appProcess: "MyApp", capturePid: null, cpuFilterPid: null, + recordingMallocStackLogging: null, mallocStackLogging: null, captureProcess: null, traceFile: "/tmp/native-profiler-ios.trace", diff --git a/packages/tool-server/test/ios-instruments/load-freshness.test.ts b/packages/tool-server/test/ios-instruments/load-freshness.test.ts index dd2aef562..6dae86a00 100644 --- a/packages/tool-server/test/ios-instruments/load-freshness.test.ts +++ b/packages/tool-server/test/ios-instruments/load-freshness.test.ts @@ -111,14 +111,20 @@ describe("iOS profiler-load → analyze freshness wiring (PR #340 comment 2)", ( expect(report).toContain("Stale trace"); }); - it("clears a previous live capture's mallocStackLogging on load (the loaded trace's mode is unknown)", async () => { - // Same staleness class as wallClockStartMs, but this one IS resettable at - // load time: a live malloc_stack_logging capture stamps the session flag, - // and the raw_*.xml carry no capture-mode sidecar — so restoring an OLDER - // session must clear the flag, or analyze/combined-report would attribute - // the loaded trace to the previous session's capture mode. + it("clears ALL of a previous live capture's per-capture residue on load", async () => { + // The raw_*.xml carry no metadata sidecar, so nothing per-capture is known + // about a loaded trace. A live capture earlier in the same process leaves + // four fields behind, each of which would corrupt the loaded trace's + // analyze if it survived: mallocStackLogging mislabels the + // unattributed-leaks note, cpuFilterPid silently filters the loaded CPU + // samples by a dead PID, wallClockStartMs fires the stale-trace note with + // the wrong timestamp, and traceFile mislabels the report and writes the + // .md over the OLD trace's report. const api = await buildIosSession(); - api.mallocStackLogging = true; // what native-profiler-start(malloc) leaves behind + api.mallocStackLogging = true; + api.cpuFilterPid = 4242; + api.wallClockStartMs = Date.now() - 2 * DAY_MS; + api.traceFile = join(tempDir, "previous-live.trace"); const cpuXml = join(tempDir, `native-profiler-${SESSION_ID}_raw_cpu.xml`); await writeFile(cpuXml, "", "utf8"); @@ -130,5 +136,17 @@ describe("iOS profiler-load → analyze freshness wiring (PR #340 comment 2)", ( }); expect(api.mallocStackLogging).toBeNull(); + expect(api.parsedData?.mallocStackLogging).toBeNull(); + expect(api.cpuFilterPid).toBeNull(); + expect(api.wallClockStartMs).toBeNull(); + expect(api.traceFile).toBeNull(); + + // And analyze on the loaded session behaves like a fresh process: no + // stale-trace note from the old capture's start time, no report mislabeled + // with (or written next to) the old .trace. + const { report, reportFile } = await analyzeNativeProfilerIos(api); + expect(report).not.toContain("Stale trace"); + expect(report).not.toContain("previous-live.trace"); + expect(reportFile).toBeNull(); }); }); diff --git a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts index 5f65d8a28..3b969a04c 100644 --- a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts +++ b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts @@ -31,6 +31,7 @@ function fakeApi(): NativeProfilerSessionApi { capturePid: null, captureProcess: null, cpuFilterPid: null, + recordingMallocStackLogging: null, mallocStackLogging: null, traceFile: null, exportedFiles: null, @@ -84,13 +85,27 @@ async function importStart() { return mod.startNativeProfilerIos; } +// startNativeProfilerIos reads ARGENT_IOS_CAPTURE (via the capture-strategy +// resolver), and exporting it globally is a documented operator setup for +// degraded Xcodes — scrub it for every test so an ambient value can't flip the +// resolved strategy (=all-processes would refuse every malloc start here), and +// restore the ambient value afterwards. Tests that exercise the override still +// set it themselves after the scrub. +const AMBIENT_IOS_CAPTURE = process.env.ARGENT_IOS_CAPTURE; + describe("native-profiler-start malloc_stack_logging", () => { beforeEach(() => { + delete process.env.ARGENT_IOS_CAPTURE; vi.resetModules(); vi.useFakeTimers(); }); afterEach(() => { + if (AMBIENT_IOS_CAPTURE === undefined) { + delete process.env.ARGENT_IOS_CAPTURE; + } else { + process.env.ARGENT_IOS_CAPTURE = AMBIENT_IOS_CAPTURE; + } vi.useRealTimers(); vi.doUnmock("child_process"); vi.doUnmock("../../src/utils/react-profiler/debug/dump"); @@ -165,6 +180,28 @@ describe("native-profiler-start malloc_stack_logging", () => { expect(efsArgs.some((a) => a.includes("get_app_container"))).toBe(true); }); + it("stamps only the in-flight capture mode — never the report-facing flag", async () => { + // api.mallocStackLogging describes the data in exportedFiles/parsedData + // and is stamped at STOP; a new start stamping it directly would re-label + // the previous capture's still-loaded data with the new capture's mode + // (e.g. an attach start flipping a malloc capture's report to "--attach"). + const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcess(); + applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn); + + const startNativeProfilerIos = await importStart(); + const api = fakeApi(); + api.mallocStackLogging = true; // previous capture (stop-stamped) was malloc-mode + + await startNativeProfilerIos(api, { + device_id: "DEVICE-UDID", + app_process: "MyApp", + // default attach mode + }); + + expect(api.recordingMallocStackLogging).toBe(false); + expect(api.mallocStackLogging).toBe(true); // untouched until the next stop + }); + it("keeps `--launch -- ` last when --notify-tracing-started is present", async () => { // Every other test lets the notify mock throw, so registerStartupNotify // returns null and the argv never contains --notify-tracing-started — on a 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 f035a19d5..99e54395d 100644 --- a/packages/tool-server/test/ios-instruments/stop-recovery.test.ts +++ b/packages/tool-server/test/ios-instruments/stop-recovery.test.ts @@ -247,6 +247,56 @@ describe("native-profiler-stop recovery branch", () => { expect(api.recordingExitedUnexpectedly).toBe(false); expect(api.lastExitInfo).toBeNull(); }); + + it("pairs the exports with the in-flight recording's capture mode (happy path)", async () => { + // start stamps only recordingMallocStackLogging; the report-facing + // mallocStackLogging must flip when stop writes exportedFiles — never + // before (a new start must not re-label the previous capture's data). + vi.useFakeTimers(); + const api = await buildSession(); + const child = new FakeChild(); + child.respondsTo = new Set(["SIGINT"]); + child.respondAfterMs = 10; + child.on("exit", (code, signal) => handleXctraceExit(api, code, signal)); + + api.profilingActive = true; + api.capturePid = 4321; + api.captureProcess = asChild(child); + api.traceFile = FAKE_TRACE; + api.recordingMallocStackLogging = true; // in-flight malloc capture + api.mallocStackLogging = false; // previous capture was attach-mode + + const promise = nativeProfilerStopTool.execute( + { session: api } as never, + { + device_id: "DEVICE-UDID", + }, + STOP_CTX + ); + await vi.advanceTimersByTimeAsync(10); + await promise; + + expect(api.mallocStackLogging).toBe(true); + }); + + it("pairs the exports with the capture mode on the recovery branch too", async () => { + const api = await buildSession(); + api.traceFile = FAKE_TRACE; + api.recordingExitedUnexpectedly = true; + api.lastExitInfo = { code: 137, signal: "SIGKILL" }; + api.recordingMallocStackLogging = true; + api.mallocStackLogging = false; + + await nativeProfilerStopTool.execute( + { session: api } as never, + { + device_id: "DEVICE-UDID", + }, + STOP_CTX + ); + + expect(api.mallocStackLogging).toBe(true); + }); }); describe("native-profiler-start fresh-start reset", () => { diff --git a/packages/tool-server/test/native-profiler-analyze-failure.test.ts b/packages/tool-server/test/native-profiler-analyze-failure.test.ts index c6d1a53b9..41524063a 100644 --- a/packages/tool-server/test/native-profiler-analyze-failure.test.ts +++ b/packages/tool-server/test/native-profiler-analyze-failure.test.ts @@ -94,6 +94,7 @@ async function buildSessionWithTrace(): Promise<{ wallClockStartMs: null, parsedData: null, cpuFilterPid: null, + recordingMallocStackLogging: null, mallocStackLogging: null, recordingTimeout: null, recordingTimedOut: false, 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 7a505fabb..62d1fd145 100644 --- a/packages/tool-server/test/native-profiler-missing-trace.test.ts +++ b/packages/tool-server/test/native-profiler-missing-trace.test.ts @@ -63,6 +63,7 @@ describe("native-profiler-analyze: missing trace file", () => { wallClockStartMs: null, parsedData: null, cpuFilterPid: null, + recordingMallocStackLogging: null, mallocStackLogging: null, recordingTimeout: null, recordingTimedOut: false, From 0ea2bad9da4f085ec3bbdd6811dbe339568c30af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 10 Jul 2026 05:22:25 +0200 Subject: [PATCH 24/28] docs(ios-profiler): describe the (objectType, normalized frame) leak grouping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Stage-1 correlate section still said leaks are grouped by objectType alone — stale since the aggregation moved to a composite key so distinct malloc_stack_logging call sites stay distinct, with the frame half normalized the way isLeakAttributed matches it. --- .../src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md b/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md index f7e70caf5..1a430edc5 100644 --- a/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md +++ b/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md @@ -144,7 +144,7 @@ RawLeak = { objectType, sizeBytes, responsibleFrame, responsibleLibrary, count ### Stage 1 — Correlate (`pipeline/01-correlate.ts`) - **Hang ↔ CPU correlation.** For each hang, filter CPU samples whose `timestampNs` ∈ `[startNs, startNs + durationNs]`. Within that window, count the **dominant function** of every sample and the **app call chain** (system + hex frames stripped). Top 5 functions and top 3 chains attach to the hang. Their timestamps are also returned in `hangSampleTimestamps` so Stage 2 can flag overlapping CPU hotspots. -- **Leak aggregation.** Group raw leaks by `objectType`, summing `sizeBytes * count`. Attributed leaks (a resolved responsible frame) sort first, then by total size descending. (Leaks have no timestamps, so they cannot be correlated with CPU samples — they are reported independently.) Severity follows attribution: a resolved responsible frame ⇒ RED, the `` sentinel under `--attach` ⇒ a low-confidence YELLOW (see `isLeakAttributed`). +- **Leak aggregation.** Group raw leaks by `(objectType, responsible frame)`, summing `sizeBytes * count` — with `malloc_stack_logging` the same object type can leak from several distinct call sites, and each site is its own finding. The frame half of the key is normalized the way `isLeakAttributed` matches it: trimmed, with every unattributed spelling (`""`, `Unknown`, the `` sentinel) collapsed into one bucket, so an export that mixes those spellings still yields one unattributed group per type (and under `--attach`, where nothing is attributed, grouping degrades to per-type). Attributed leaks (a resolved responsible frame) sort first, then by total size descending. (Leaks have no timestamps, so they cannot be correlated with CPU samples — they are reported independently.) Severity follows attribution: a resolved responsible frame ⇒ RED, no recorded frame ⇒ a low-confidence YELLOW (see `isLeakAttributed`). - Hang severity is classified from the type string: contains `severe` or equals `hang` ⇒ RED; `microhang` ⇒ YELLOW. ### Stage 2 — Aggregate CPU (`pipeline/02-aggregate.ts`) From d016a8a9d84c02225ef3934bfafe4a03bc9d6b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 10 Jul 2026 05:46:14 +0200 Subject: [PATCH 25/28] fix(ios-profiler): guard load against live captures; evidence-first leak hints Three follow-ups from a second review pass over the branch: - profiler-load's new residue clearing nulled traceFile with no session-state guard, so load_native during an active recording wedged the session (stop threw NO_ACTIVE_SESSION while start threw SESSION_ALREADY_RUNNING) and orphaned the in-flight capture unexportable; a load after an unexpected xctrace exit likewise made stop's partial-trace recovery unreachable. load_native now refuses while profilingActive or a recovery flag is set, directing to native-profiler-stop first. - The unattributed-leaks hint let an explicit attach-mode flag override the attribution evidence: an app launched under MallocStackLogging externally (e.g. an Xcode scheme diagnostic) and then attached to rendered an attributed table directly above a 'no malloc-stack history' note. A recorded responsible frame proves the target ran under malloc stack logging regardless of how argent captured, so attributed>0 now decides first and the flag only disambiguates the zero-attributed case (both render.ts and the leak_stacks drill-down). - Per-capture descriptors (appProcess, traceFile, cpuFilterPid, capture mode) are now stamped only on a successful start: a failed attempt used to null them while the previous capture's exports stayed loaded, so a later analyze rendered a host-wide all-processes capture unfiltered and unlabeled. --- .../profiler/native-profiler/platforms/ios.ts | 38 ++++++------- .../src/tools/profiler/query/profiler-load.ts | 21 ++++++++ .../profiler/query/profiler-stack-query.ts | 15 +++--- .../src/utils/ios-profiler/render.ts | 42 ++++++++------- .../leak-attribution-render.test.ts | 19 +++++++ .../ios-instruments/load-freshness.test.ts | 54 +++++++++++++++++++ .../malloc-stack-logging.test.ts | 48 +++++++++++++++++ .../test/profiler-leak-stacks.test.ts | 10 ++++ 8 files changed, 202 insertions(+), 45 deletions(-) diff --git a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts index 2a2c15e5b..8c632feaf 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts @@ -362,15 +362,14 @@ async function registerStartupNotify(name: string): Promise return null; } +// Per-capture descriptors (appProcess, traceFile, cpuFilterPid, capture mode) +// are stamped only after a SUCCESSFUL start, so a failed attempt has nothing +// to reset beyond the spawned process handles — the previous capture's +// still-loaded exports stay fully described for analyze (trace name, +// all-processes filter PID, capture mode). function resetStartState(api: NativeProfilerSessionApi): void { api.capturePid = null; api.captureProcess = null; - api.traceFile = null; - api.appProcess = null; - api.cpuFilterPid = null; - // Only the in-flight mode: api.mallocStackLogging still describes the - // previous capture's exportedFiles, which a failed start leaves in place. - api.recordingMallocStackLogging = null; } export function handleXctraceExit( @@ -561,19 +560,6 @@ export async function startNativeProfilerIos( api.lastExitInfo = null; const attemptStart = async (): Promise<{ child: ChildProcess; pid: number }> => { - api.appProcess = appProcess; - api.traceFile = outputFile; - // Record the in-flight capture's mode; stop copies it into - // api.mallocStackLogging when it writes exportedFiles, so the report layer - // names the mode of the data it actually renders — stamping the session's - // report-facing flag HERE would re-label the previous capture's - // still-loaded exports/parsedData with the new capture's mode. - api.recordingMallocStackLogging = useMallocStackLogging; - // Null for the device strategy (already scoped by --attach) and for a - // malloc_stack_logging cold launch (scoped by --launch on --device); the app - // PID only for the host-wide all-processes fallback, to filter the samples. - api.cpuFilterPid = strategy ? strategy.cpuFilterPid(detected!) : null; - const notifyName = `com.argent.ios-profiler.started.${process.pid}.${Date.now()}`; const notify = await registerStartupNotify(notifyName); @@ -700,6 +686,20 @@ export async function startNativeProfilerIos( } const { child: xctraceProcess, pid: xctracePid } = started; + // Stamp the per-capture descriptors only now, on SUCCESS: a failed start + // must leave the previous capture's still-loaded exports fully described + // for analyze (trace name, all-processes filter PID, capture mode). + api.appProcess = appProcess; + api.traceFile = outputFile; + // The in-flight capture's mode; stop copies it into api.mallocStackLogging + // when it writes exportedFiles, so the report layer names the mode of the + // data it actually renders — stamping the report-facing flag here would + // re-label the previous capture's still-loaded exports/parsedData. + api.recordingMallocStackLogging = useMallocStackLogging; + // Null for the device strategy (already scoped by --attach) and for a + // malloc_stack_logging cold launch (scoped by --launch on --device); the app + // PID only for the host-wide all-processes fallback, to filter the samples. + api.cpuFilterPid = strategy ? strategy.cpuFilterPid(detected!) : null; api.profilingActive = true; api.wallClockStartMs = Date.now(); api.recordingTimeout = setTimeout(() => { 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 a5f588bff..ea157f073 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-load.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-load.ts @@ -317,6 +317,27 @@ async function loadNativeSession( appProcessOverride?: string ): Promise { assertSafeSessionId(sessionId); + // Loading replaces the per-device session's capture state (traceFile, + // exportedFiles, parsedData, …). With a recording in flight that would wedge + // the session — stop's gate needs traceFile (which the load nulls) while + // start refuses with SESSION_ALREADY_RUNNING — and orphan the live capture + // unexportable. With a timed-out/crashed capture awaiting recovery, it would + // make stop's partial-trace export unreachable (the .trace bundle cannot be + // re-ingested; only the raw_*.xml that export writes are loadable). Refuse + // until the capture is stopped. + if (api.profilingActive || api.recordingTimedOut || api.recordingExitedUnexpectedly) { + throw new FailureError( + api.profilingActive + ? `A native profiling session is recording on this device. Run native-profiler-stop first, then retry profiler-load.` + : `A native profiling capture on this device ended unexpectedly and its partial trace has not been exported yet. Run native-profiler-stop first (it recovers the partial trace), then retry profiler-load.`, + { + error_code: FAILURE_CODES.NATIVE_PROFILER_SESSION_ALREADY_RUNNING, + failure_stage: "profiler_load_native_session", + failure_area: "tool_server", + error_kind: "validation", + } + ); + } // Android .pftrace first — the platform field on the resolved session API // tells us which shape to load. If the platform is android but the .pftrace // is missing we fall through to the iOS XML path so the user gets the diff --git a/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts b/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts index f69d56ad0..aeb8d1c96 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts @@ -269,14 +269,13 @@ export function renderLeakStacksIos( topN: number, mallocStackLogging?: boolean | null ): string { - // Same capture-mode contract as the analyze/combined reports: use the - // session's actual malloc_stack_logging flag when known; when unknown (a - // session restored from disk), infer it from the FULL capture — any - // attributed group means the app was launched under malloc stack logging - // (the only way a responsible frame is recorded). Inference deliberately - // ignores the object_type filter: filtering out every attributed group must - // not flip the note back to blaming `--attach`. - const mallocWasOn = mallocStackLogging ?? memoryLeaks.some((l) => l.attributed); + // Same capture-mode contract as the analyze/combined reports: attribution + // evidence decides first — any attributed group in the FULL capture (never + // the object_type-filtered slice) proves the target process ran under + // malloc stack logging, however it was launched, so even an explicit + // attach-mode flag must not claim "no malloc-stack history" above a row + // with a resolved frame. The flag only lifts the zero-attributed case. + const mallocWasOn = memoryLeaks.some((l) => l.attributed) || mallocStackLogging === true; let filtered = memoryLeaks; if (objectTypeFilter) { filtered = memoryLeaks.filter((l) => diff --git a/packages/tool-server/src/utils/ios-profiler/render.ts b/packages/tool-server/src/utils/ios-profiler/render.ts index 41c7c5643..f334c433c 100644 --- a/packages/tool-server/src/utils/ios-profiler/render.ts +++ b/packages/tool-server/src/utils/ios-profiler/render.ts @@ -540,14 +540,14 @@ function renderFullReport( * full analyze report above and the combined React × native report — one * source so the wording can't silently diverge between the two. * - * `mallocStackLogging` is the actual capture mode when known (a live session - * carries it from native-profiler-start). Pass null/undefined when unknown — - * a session restored from disk has no capture-mode sidecar — and the mode is - * inferred from the attributed count instead: a responsible frame is only - * ever recorded when the app was launched under malloc stack logging, so - * attributed leaks present ⇒ the flag was on. The inference is silent about - * the one case it cannot see (a malloc capture that attributed nothing), which - * is exactly why callers that know the mode must pass it. + * `mallocStackLogging` is argent's capture mode when known (stamped at stop, + * frozen into parsedData); pass null/undefined when unknown (a session + * restored from disk has no capture-mode sidecar). Attributed leaks are + * stronger evidence than the flag — a responsible frame is only ever recorded + * when the target process ran under malloc stack logging, however it was + * launched — so the flag matters only for the zero-attributed case: explicit + * `true` names the malloc capture that recorded nothing, anything else gets + * the `--attach` framing. */ export function renderUnattributedLeaksNote( unattributedLeaks: MemoryLeak[], @@ -556,20 +556,26 @@ export function renderUnattributedLeaksNote( ): string { const objs = unattributedLeaks.reduce((s, b) => s + b.count, 0); const bytes = unattributedLeaks.reduce((s, b) => s + b.totalSizeBytes, 0); - const mallocWasOn = mallocStackLogging ?? attributedCount > 0; - const hint = !mallocWasOn - ? `Argent records via \`xctrace --attach\`, which has no malloc-stack history, so these are most likely ` + - `benign system allocations rather than confirmed app leaks. For attributed stacks, capture with malloc ` + - `stack logging enabled at launch.` - : attributedCount > 0 + // Attribution evidence decides FIRST: a responsible frame exists only if the + // target process itself ran under malloc stack logging — true even when + // argent attached (the app can be launched with the diagnostic externally, + // e.g. via an Xcode scheme), so an explicit `false` capture flag must not + // produce a "no malloc-stack history" claim right above an attributed table. + // The flag only disambiguates the zero-attributed case. + const hint = + attributedCount > 0 ? `Some leaks here were attributed, so malloc stack logging was active — these remaining ` + `groups carry no allocation backtrace (freed-region reuse, truncated stack logs, or ` + `allocations outside the instrumented malloc zones) and are most likely benign system ` + `allocations rather than confirmed app leaks.` - : `This capture ran with malloc stack logging enabled, yet none of these leaks carry an allocation ` + - `backtrace — the allocator recorded no usable stack for them (freed-region reuse, truncated stack ` + - `logs, or allocations outside the instrumented malloc zones). Most likely benign system allocations ` + - `rather than confirmed app leaks; re-running with malloc stack logging again is unlikely to attribute them.`; + : mallocStackLogging === true + ? `This capture ran with malloc stack logging enabled, yet none of these leaks carry an allocation ` + + `backtrace — the allocator recorded no usable stack for them (freed-region reuse, truncated stack ` + + `logs, or allocations outside the instrumented malloc zones). Most likely benign system allocations ` + + `rather than confirmed app leaks; re-running with malloc stack logging again is unlikely to attribute them.` + : `Argent records via \`xctrace --attach\`, which has no malloc-stack history, so these are most likely ` + + `benign system allocations rather than confirmed app leaks. For attributed stacks, capture with malloc ` + + `stack logging enabled at launch.`; return ( `> ${severityEmoji("YELLOW")} **${unattributedLeaks.length} unattributed leak group(s)** ` + `(${objs} object(s), ${formatBytes(bytes)}): responsible frame \`\`, no library. ` + diff --git a/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts b/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts index c6c53ba0f..89d96ca5c 100644 --- a/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts +++ b/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts @@ -74,6 +74,25 @@ describe("leak attribution rendering", () => { expect(res.report).toContain("ran with malloc stack logging enabled"); }); + it("attribution evidence outranks an explicit attach flag", async () => { + // A responsible frame exists ONLY if the target process ran under malloc + // stack logging — even when argent itself attached (the app can be launched + // with the diagnostic externally, e.g. an Xcode scheme). An attributed + // table above a "no malloc-stack history" note would contradict itself, so + // attributed>0 must win over mallocStackLogging: false. + const res = await renderNativeProfilerReport({ + payload: payload([ + leak(true, "hermes::vm::JSTypedArrayBase::createBuffer(...)", "hermes"), + leak(false, ""), + ]), + traceFile: null, + mallocStackLogging: false, + }); + expect(res.report).toContain("malloc stack logging was active"); + expect(res.report).not.toContain("--attach"); + expect(res.report).not.toContain("stack logging enabled at launch"); + }); + it("keeps the --attach hint when the capture mode is explicitly attach", async () => { const res = await renderNativeProfilerReport({ payload: payload([leak(false, "")]), diff --git a/packages/tool-server/test/ios-instruments/load-freshness.test.ts b/packages/tool-server/test/ios-instruments/load-freshness.test.ts index 6dae86a00..a0902d160 100644 --- a/packages/tool-server/test/ios-instruments/load-freshness.test.ts +++ b/packages/tool-server/test/ios-instruments/load-freshness.test.ts @@ -149,4 +149,58 @@ describe("iOS profiler-load → analyze freshness wiring (PR #340 comment 2)", ( expect(report).not.toContain("previous-live.trace"); expect(reportFile).toBeNull(); }); + + it("refuses load_native while a recording is in flight (residue clearing would wedge the session)", async () => { + // The residue clearing above nulls traceFile — with a live capture that + // would make native-profiler-stop throw NO_ACTIVE_SESSION (its gate needs + // traceFile) while native-profiler-start throws SESSION_ALREADY_RUNNING: + // wedged both ways, and the in-flight capture is orphaned unexportable. + // Loading must refuse up front and leave the live session untouched. + const api = await buildIosSession(); + api.profilingActive = true; + api.capturePid = 12345; + api.captureProcess = { kill: vi.fn(), pid: 12345 } as never; + api.traceFile = join(tempDir, "in-flight.trace"); + + const cpuXml = join(tempDir, `native-profiler-${SESSION_ID}_raw_cpu.xml`); + await writeFile(cpuXml, "", "utf8"); + + await expect( + profilerLoadTool.execute({ session: api } as never, { + mode: "load_native", + session_id: SESSION_ID, + port: 8081, + device_id: "ios-sim", + }) + ).rejects.toThrow(/native-profiler-stop first/); + + expect(api.traceFile).toBe(join(tempDir, "in-flight.trace")); + expect(api.profilingActive).toBe(true); + }); + + it("refuses load_native while a crashed/timed-out capture awaits recovery export", async () => { + // recordingTimedOut / recordingExitedUnexpectedly + traceFile is the state + // stop's recovery branch exists for (export the partial trace). A load + // nulling traceFile would make that export unreachable forever — the raw + // .trace bundle cannot be re-ingested by profiler-load. + const api = await buildIosSession(); + api.recordingExitedUnexpectedly = true; + api.lastExitInfo = { code: 137, signal: "SIGKILL" }; + api.traceFile = join(tempDir, "crashed.trace"); + + const cpuXml = join(tempDir, `native-profiler-${SESSION_ID}_raw_cpu.xml`); + await writeFile(cpuXml, "", "utf8"); + + await expect( + profilerLoadTool.execute({ session: api } as never, { + mode: "load_native", + session_id: SESSION_ID, + port: 8081, + device_id: "ios-sim", + }) + ).rejects.toThrow(/native-profiler-stop first/); + + expect(api.traceFile).toBe(join(tempDir, "crashed.trace")); + expect(api.recordingExitedUnexpectedly).toBe(true); + }); }); diff --git a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts index 3b969a04c..9ad12af26 100644 --- a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts +++ b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts @@ -180,6 +180,54 @@ describe("native-profiler-start malloc_stack_logging", () => { expect(efsArgs.some((a) => a.includes("get_app_container"))).toBe(true); }); + it("a failed start leaves the previous capture's descriptors fully intact", async () => { + // Per-capture descriptors (appProcess, traceFile, cpuFilterPid, capture + // mode) are stamped only on a SUCCESSFUL start. A failed attempt must not + // clobber them: the previous capture's exports are still loaded, and + // analyze pairs them with these fields — nulling cpuFilterPid would render + // a host-wide all-processes capture unfiltered, and nulling traceFile + // would strip the report's label and file. + const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcess(); + vi.doMock("child_process", () => ({ + spawn: spawnFn, + execSync: execSyncFn, + execFile: vi.fn(), + execFileSync: execFileSyncFn, + })); + vi.doMock("../../src/utils/react-profiler/debug/dump", () => ({ + getDebugDir: vi.fn(async () => "/tmp/argent-profiler-cwd"), + })); + vi.doMock("../../src/utils/ios-profiler/notify", () => ({ + listenForDarwinNotification: vi.fn(() => { + throw new Error("notifyutil unavailable in tests"); + }), + })); + vi.doMock("../../src/utils/ios-profiler/startup", () => ({ + waitForXctraceReady: vi.fn(async () => { + throw new Error("xctrace failed to start"); + }), + })); + + const startNativeProfilerIos = await importStart(); + const api = fakeApi(); + api.appProcess = "PrevApp"; + api.traceFile = "/tmp/argent-profiler-cwd/previous.trace"; + api.cpuFilterPid = 777; // previous capture ran via the all-processes fallback + api.mallocStackLogging = true; + api.exportedFiles = { cpu: "/tmp/prev_cpu.xml", hangs: null, leaks: null }; + + await expect( + startNativeProfilerIos(api, { device_id: "DEVICE-UDID", app_process: "MyApp" }) + ).rejects.toThrow(/xctrace failed to start/); + + expect(api.appProcess).toBe("PrevApp"); + expect(api.traceFile).toBe("/tmp/argent-profiler-cwd/previous.trace"); + expect(api.cpuFilterPid).toBe(777); + expect(api.mallocStackLogging).toBe(true); + expect(api.capturePid).toBeNull(); + expect(api.captureProcess).toBeNull(); + }); + it("stamps only the in-flight capture mode — never the report-facing flag", async () => { // api.mallocStackLogging describes the data in exportedFiles/parsedData // and is stamped at STOP; a new start stamping it directly would re-label diff --git a/packages/tool-server/test/profiler-leak-stacks.test.ts b/packages/tool-server/test/profiler-leak-stacks.test.ts index fdb19e1de..ff1b87224 100644 --- a/packages/tool-server/test/profiler-leak-stacks.test.ts +++ b/packages/tool-server/test/profiler-leak-stacks.test.ts @@ -120,4 +120,14 @@ describe("renderLeakStacksIos — capture-mode-aware unattributed note", () => { const out = renderLeakStacksIos([noise(0)], undefined, 10, false); expect(out).toContain("xctrace --attach"); }); + + it("attribution evidence outranks an explicit attach flag", () => { + // The flag says how ARGENT captured; a recorded frame proves the target + // process itself ran under malloc stack logging (e.g. launched with the + // Xcode scheme diagnostic, then attached to). The note must not claim "no + // malloc-stack history" right above a row with a full responsible frame. + const out = renderLeakStacksIos([ATTRIBUTED_SMALL, noise(0)], undefined, 10, false); + expect(out).toContain("unattributed"); + expect(out).not.toContain("--attach"); + }); }); From 07594b1d2dd16d40777c4955c9f5d32359f7250b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 10 Jul 2026 10:22:54 +0200 Subject: [PATCH 26/28] fix(ios-profiler): protect pending recoveries, disambiguate malloc launch target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third review-pass follow-ups, each reproduced before fixing: - A FAILED native-profiler-start cleared recordingTimedOut / recordingExitedUnexpectedly before the attempt, so a capture that ended abnormally lost its pending partial-trace recovery to a mere failed restart (stop then threw NO_ACTIVE_SESSION and the trace was unrecoverable — only the raw_*.xml that stop's export writes are loadable). Nothing in the attempt reads those flags; reset them with the other per-capture stamps, on success only. - native-profiler-analyze now refuses while a recording is in flight or a crashed capture awaits recovery, mirroring the profiler-load guard: the live session fields belong to the newer capture, so the old exports would render under the new trace's name, freshness anchor, and CPU filter PID. - The malloc cold launch TERMINATES the resolved app, so resolveAppForLaunch no longer takes the first installed app matching app_process in plist order: an exact CFBundleExecutable match wins over display-name matches, and a display-name-only ambiguity (dev + prod builds both shown as "MyApp") is refused up front naming the candidates (NATIVE_PROFILER_LAUNCH_APP_AMBIGUOUS). - Leak tables now escape '|' in object type / responsible frame / library cells: GFM splits cells on unescaped pipes even inside code spans, and malloc_stack_logging makes demangled C++ frames (operator| included) the headline content of exactly these tables. - Docs: REFERENCE.md no longer claims frames appear 'only when recorded with malloc_stack_logging' (an externally launched MallocStackLogging app that argent attaches to also attributes); PIPELINE_DESIGN.md now describes the (objectType, attribution-normalized frame) aggregation and the malloc cold-launch capture mode. --- packages/registry/src/failure-codes.ts | 1 + .../profiler/native-profiler/platforms/ios.ts | 63 +++++++-- .../profiler/query/profiler-stack-query.ts | 4 +- .../ios-profiler/IOS_PROFILER_REFERENCE.md | 16 +-- .../src/utils/ios-profiler/PIPELINE_DESIGN.md | 36 +++--- .../src/utils/ios-profiler/render.ts | 3 +- .../src/utils/profiler-shared/format.ts | 11 ++ .../leak-attribution-render.test.ts | 20 +++ .../ios-instruments/load-freshness.test.ts | 25 ++++ .../malloc-stack-logging.test.ts | 122 +++++++++++++++++- .../test/profiler-leak-stacks.test.ts | 19 +++ 11 files changed, 279 insertions(+), 41 deletions(-) diff --git a/packages/registry/src/failure-codes.ts b/packages/registry/src/failure-codes.ts index 23142cfc4..9184359ae 100644 --- a/packages/registry/src/failure-codes.ts +++ b/packages/registry/src/failure-codes.ts @@ -164,6 +164,7 @@ export const FAILURE_CODES = { NATIVE_PROFILER_MALLOC_STRATEGY_OVERRIDE: "NATIVE_PROFILER_MALLOC_STRATEGY_OVERRIDE", NATIVE_PROFILER_APP_BUNDLE_PATH_FAILED: "NATIVE_PROFILER_APP_BUNDLE_PATH_FAILED", NATIVE_PROFILER_LAUNCH_APP_NOT_FOUND: "NATIVE_PROFILER_LAUNCH_APP_NOT_FOUND", + NATIVE_PROFILER_LAUNCH_APP_AMBIGUOUS: "NATIVE_PROFILER_LAUNCH_APP_AMBIGUOUS", NATIVE_PROFILER_SESSION_ALREADY_RUNNING: "NATIVE_PROFILER_SESSION_ALREADY_RUNNING", NATIVE_PROFILER_XCTRACE_NO_PID: "NATIVE_PROFILER_XCTRACE_NO_PID", NATIVE_PROFILER_XCTRACE_PROCESS_NOT_FOUND: "NATIVE_PROFILER_XCTRACE_PROCESS_NOT_FOUND", diff --git a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts index 8c632feaf..eb5125c03 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts @@ -310,13 +310,36 @@ function getAppBundlePath(udid: string, bundleId: string): string { function resolveAppForLaunch(udid: string, appProcess?: string): AppInfo { if (appProcess) { const installed = getInstalledApps(udid); - for (const [, info] of Object.entries(installed)) { - if ( + const matches = Object.values(installed).filter( + (info) => info.ApplicationType === "User" && (info.CFBundleExecutable === appProcess || info.CFBundleDisplayName === appProcess) - ) { - return info; - } + ); + if (matches.length === 1) return matches[0]; + if (matches.length > 1) { + // The malloc path TERMINATES the resolved app before cold-launching it, + // so first-match-in-plist-order is not acceptable when several installed + // apps share a display name (dev + prod builds both shown as "MyApp"): + // an exact executable match wins; otherwise refuse before touching + // anything, naming the candidates. + const exact = matches.filter((info) => info.CFBundleExecutable === appProcess); + if (exact.length === 1) return exact[0]; + const appList = matches + .map( + (info) => + ` - ${info.CFBundleExecutable} (${info.CFBundleIdentifier}${info.CFBundleDisplayName ? `, "${info.CFBundleDisplayName}"` : ""})` + ) + .join("\n"); + throw new FailureError( + `app_process "${appProcess}" matches multiple installed user apps on simulator ${udid}:\n${appList}\n` + + `Pass the exact CFBundleExecutable of the app you want to cold-launch with malloc_stack_logging.`, + { + error_code: FAILURE_CODES.NATIVE_PROFILER_LAUNCH_APP_AMBIGUOUS, + failure_stage: "native_profiler_resolve_app_for_launch", + failure_area: "tool_server", + error_kind: "validation", + } + ); } throw new FailureError( `No installed user app matching "${appProcess}" found on simulator ${udid}. ` + @@ -555,10 +578,6 @@ export async function startNativeProfilerIos( } } - api.recordingTimedOut = false; - api.recordingExitedUnexpectedly = false; - api.lastExitInfo = null; - const attemptStart = async (): Promise<{ child: ChildProcess; pid: number }> => { const notifyName = `com.argent.ios-profiler.started.${process.pid}.${Date.now()}`; const notify = await registerStartupNotify(notifyName); @@ -688,7 +707,13 @@ export async function startNativeProfilerIos( // Stamp the per-capture descriptors only now, on SUCCESS: a failed start // must leave the previous capture's still-loaded exports fully described - // for analyze (trace name, all-processes filter PID, capture mode). + // for analyze (trace name, all-processes filter PID, capture mode). The + // stale recovery flags reset here too — nothing in the attempt reads them, + // and clearing them on a FAILED start would make stop's partial-trace + // recovery for the previous abnormal capture unreachable, forfeiting it. + api.recordingTimedOut = false; + api.recordingExitedUnexpectedly = false; + api.lastExitInfo = null; api.appProcess = appProcess; api.traceFile = outputFile; // The in-flight capture's mode; stop copies it into api.mallocStackLogging @@ -826,6 +851,24 @@ async function checkExportFileMissing(filePath: string | null): Promise { + // Mid-recording (or with a crashed capture pending recovery), the live + // session fields (traceFile, cpuFilterPid, wallClockStartMs) belong to the + // newer capture while exportedFiles still holds the previous one — analyze + // would render the old exports under the new trace's name, freshness anchor, + // and CPU filter PID. Same contract as the profiler-load guard: stop first. + if (api.profilingActive || api.recordingTimedOut || api.recordingExitedUnexpectedly) { + throw new FailureError( + api.profilingActive + ? `A native profiling session is recording on this device. Run native-profiler-stop first, then analyze.` + : `A native profiling capture on this device ended unexpectedly and its partial trace has not been exported yet. Run native-profiler-stop first (it recovers the partial trace), then analyze.`, + { + error_code: FAILURE_CODES.NATIVE_PROFILER_SESSION_ALREADY_RUNNING, + failure_stage: "native_profiler_analyze_session_state", + failure_area: "tool_server", + error_kind: "validation", + } + ); + } if (!api.exportedFiles) { throw new FailureError("No exported trace data found. Call native-profiler-stop first.", { error_code: FAILURE_CODES.PROFILER_NATIVE_TRACE_MISSING, diff --git a/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts b/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts index aeb8d1c96..e22b4f7c5 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts @@ -15,7 +15,7 @@ import { type AndroidStackQueryMode, } from "../../../utils/android-profiler/pipeline/index"; import { normalizeThreadName } from "../../../utils/profiler-shared/thread"; -import { formatBytes } from "../../../utils/profiler-shared/format"; +import { formatBytes, escapeMarkdownTableCell } from "../../../utils/profiler-shared/format"; import { demangleSymbol } from "../../../utils/profiler-shared/demangle"; const zodSchema = z.object({ @@ -331,7 +331,7 @@ export function renderLeakStacksIos( for (const l of sorted) { lines.push( - `| \`${l.objectType}\` | ${formatBytes(l.totalSizeBytes)} | ${l.count} | \`${demangleSymbol(l.responsibleFrame)}\` | ${l.responsibleLibrary || "—"} |` + `| \`${escapeMarkdownTableCell(l.objectType)}\` | ${formatBytes(l.totalSizeBytes)} | ${l.count} | \`${escapeMarkdownTableCell(demangleSymbol(l.responsibleFrame))}\` | ${escapeMarkdownTableCell(l.responsibleLibrary) || "—"} |` ); } diff --git a/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md b/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md index 1a430edc5..6445cc109 100644 --- a/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md +++ b/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md @@ -35,14 +35,14 @@ Three concerns are captured: **CPU hotspots**, **UI hangs**, and **memory leaks* `Argent.tracetemplate` enables the following Instruments. Identifiers are taken from the binary plist (`com.apple.xray.instrument-type.*` / `com.apple.dt-perfteam.*`): -| Instrument identifier | Common name | Used by Argent's pipeline? | -| ----------------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `com.apple.xray.instrument-type.coresampler2` | **Time Profiler** (kernel-driven sampling profiler) | **Yes** — exported as the `time-profile` table → CPU hotspots. | -| `com.apple.dt-perfteam.hangs` | **Hangs** (main-thread responsiveness detector) | **Yes** — exported as the `potential-hangs` table → UI hangs. | -| `com.apple.xray.instrument-type.homeleaks` | **Leaks** (periodic leak scan) | **Yes** — exported via the `Leaks` track → memory leaks. Responsible frame/library is populated only when recorded with `malloc_stack_logging` (else ``). | -| `com.apple.xray.instrument-type.oa` | **Allocations** (object lifetime + alloc tree) | Recorded but not consumed directly; with `malloc_stack_logging` it supplies the allocation backtraces that make leaks attributable. | -| `com.apple.xray.instrument-type.poi` | **Points of Interest** (`os_signpost`) | Recorded but not consumed. | -| `com.apple.xray.instrument-type.device-thermal-state` | **Thermal State** | Recorded but not consumed. | +| Instrument identifier | Common name | Used by Argent's pipeline? | +| ----------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `com.apple.xray.instrument-type.coresampler2` | **Time Profiler** (kernel-driven sampling profiler) | **Yes** — exported as the `time-profile` table → CPU hotspots. | +| `com.apple.dt-perfteam.hangs` | **Hangs** (main-thread responsiveness detector) | **Yes** — exported as the `potential-hangs` table → UI hangs. | +| `com.apple.xray.instrument-type.homeleaks` | **Leaks** (periodic leak scan) | **Yes** — exported via the `Leaks` track → memory leaks. Responsible frame/library is populated only when the target ran under Malloc Stack Logging — argent's `malloc_stack_logging` cold launch, or an app launched with the diagnostic externally (e.g. an Xcode scheme) and then attached to (else ``). | +| `com.apple.xray.instrument-type.oa` | **Allocations** (object lifetime + alloc tree) | Recorded but not consumed directly; with `malloc_stack_logging` it supplies the allocation backtraces that make leaks attributable. | +| `com.apple.xray.instrument-type.poi` | **Points of Interest** (`os_signpost`) | Recorded but not consumed. | +| `com.apple.xray.instrument-type.device-thermal-state` | **Thermal State** | Recorded but not consumed. | > The hangs Instrument is the same engine that powers Xcode's "hang reports" — Apple's term for any stretch of time the main thread fails to service the run loop. diff --git a/packages/tool-server/src/utils/ios-profiler/PIPELINE_DESIGN.md b/packages/tool-server/src/utils/ios-profiler/PIPELINE_DESIGN.md index 717ece129..a8d34cb56 100644 --- a/packages/tool-server/src/utils/ios-profiler/PIPELINE_DESIGN.md +++ b/packages/tool-server/src/utils/ios-profiler/PIPELINE_DESIGN.md @@ -6,13 +6,13 @@ Living document tracking the reasoning behind pipeline architecture decisions. **3-tool flow**: `native-profiler-start` → `native-profiler-stop` → `native-profiler-analyze` -1. **Start** — Detects the running app process on the simulator, spawns `xctrace record` attached to it. +1. **Start** — Detects the running app process on the simulator, spawns `xctrace record` attached to it. With `malloc_stack_logging: true` it instead terminates any running instance and cold-launches the `.app` under `xctrace --device --launch` with `MallocStackLogging=1`, so leaks carry allocation backtraces (see IOS_PROFILER_REFERENCE.md). 2. **Stop** — Sends SIGINT to xctrace, waits for process exit, exports the `.trace` bundle to 3 XML files (CPU time-profile, potential-hangs, leaks). 3. **Analyze** — Parses the 3 XMLs, runs a 2-stage pipeline, renders a markdown report. **2-stage processing** (after XML parsing): -- **Stage 1 — Correlate**: Hang–CPU time-window correlation, leak aggregation by object type. +- **Stage 1 — Correlate**: Hang–CPU time-window correlation, leak aggregation by (object type, attribution-normalized responsible frame). - **Stage 2 — Aggregate**: CPU hotspot grouping by dominant function + normalized thread, min-weight filtering, hang-overlap flagging. --- @@ -57,17 +57,17 @@ Leak data from xctrace is a static summary — total count and size by object ty ### Severity thresholds -| Category | Condition | Severity | -| ----------- | ------------------------------------------------------------ | ------------ | -| CPU Hotspot | weight > 15% of total | RED | -| CPU Hotspot | weight 3–15% | YELLOW | -| CPU Hotspot | weight < 3% | filtered out | -| UI Hang | type contains "severe" or equals "hang" | RED | -| UI Hang | type is "microhang" | YELLOW | -| Memory Leak | attributed (resolved responsible frame) | RED | -| Memory Leak | unattributed (`` under `--attach`) | YELLOW | +| Category | Condition | Severity | +| ----------- | ------------------------------------------------------------------- | ------------ | +| CPU Hotspot | weight > 15% of total | RED | +| CPU Hotspot | weight 3–15% | YELLOW | +| CPU Hotspot | weight < 3% | filtered out | +| UI Hang | type contains "severe" or equals "hang" | RED | +| UI Hang | type is "microhang" | YELLOW | +| Memory Leak | attributed (resolved responsible frame) | RED | +| Memory Leak | unattributed (no recorded frame, e.g. ``) | YELLOW | -The 3% minimum weight filter prevents noise — xctrace captures thousands of samples and most functions appear briefly. 15% RED threshold flags functions consuming significant wall time. Leak severity follows attribution: a leak with a resolved responsible frame is a confident RED, but under `xctrace --attach` (what Argent does) most simulator leaks have no malloc-stack history and come back with the `` sentinel and an empty library — benign system noise, demoted to a low-confidence YELLOW rather than flagged as a confirmed app bug. +The 3% minimum weight filter prevents noise — xctrace captures thousands of samples and most functions appear briefly. 15% RED threshold flags functions consuming significant wall time. Leak severity follows attribution: a leak with a resolved responsible frame is a confident RED, but under `xctrace --attach` (argent's default capture mode) most simulator leaks have no malloc-stack history and come back with the `` sentinel and an empty library — benign system noise, demoted to a low-confidence YELLOW rather than flagged as a confirmed app bug. The opt-in `malloc_stack_logging` cold launch records allocation backtraces from process start, so those leaks come back attributed (RED, with a real frame + library). ### Thread normalization @@ -91,9 +91,9 @@ If zero bottlenecks survive filtering, the report says "All clear" rather than m ## Stage Summary -| Stage | File | Purpose | -| ----- | -------------------------- | ----------------------------------------------------------------------------------------- | -| 0 | `pipeline/xml-parser.ts` | Parse 3 XMLs in parallel — id/ref deduplication, frame/binary resolution | -| 1 | `pipeline/01-correlate.ts` | Hang–CPU time-window correlation, leak aggregation by object type | -| 2 | `pipeline/02-aggregate.ts` | CPU hotspot grouping by dominant function + thread, min-weight filter, hang-overlap flags | -| — | `render.ts` | Render bottlenecks to markdown with summary table, per-category sections, suggestions | +| Stage | File | Purpose | +| ----- | -------------------------- | ------------------------------------------------------------------------------------------ | +| 0 | `pipeline/xml-parser.ts` | Parse 3 XMLs in parallel — id/ref deduplication, frame/binary resolution | +| 1 | `pipeline/01-correlate.ts` | Hang–CPU time-window correlation, leak aggregation by (type, attribution-normalized frame) | +| 2 | `pipeline/02-aggregate.ts` | CPU hotspot grouping by dominant function + thread, min-weight filter, hang-overlap flags | +| — | `render.ts` | Render bottlenecks to markdown with summary table, per-category sections, suggestions | diff --git a/packages/tool-server/src/utils/ios-profiler/render.ts b/packages/tool-server/src/utils/ios-profiler/render.ts index f334c433c..a675ba3db 100644 --- a/packages/tool-server/src/utils/ios-profiler/render.ts +++ b/packages/tool-server/src/utils/ios-profiler/render.ts @@ -3,6 +3,7 @@ import * as path from "path"; import bytesUtil from "bytes"; import type { TraceProcessorUnavailableError } from "@argent/native-devtools-android"; import { demangleSymbol } from "../profiler-shared/demangle"; +import { escapeMarkdownTableCell } from "../profiler-shared/format"; import type { ProfilerPayload, Bottleneck, @@ -414,7 +415,7 @@ function renderFullReport( ); attributedLeaks.forEach((b, i) => { lines.push( - `| ${i + 1} | \`${b.objectType}\` | ${b.count} | ${formatBytes(b.totalSizeBytes)} | \`${demangleSymbol(b.responsibleFrame)}\` | ${b.responsibleLibrary || "—"} | ${severityEmoji(b.severity)} |` + `| ${i + 1} | \`${escapeMarkdownTableCell(b.objectType)}\` | ${b.count} | ${formatBytes(b.totalSizeBytes)} | \`${escapeMarkdownTableCell(demangleSymbol(b.responsibleFrame))}\` | ${escapeMarkdownTableCell(b.responsibleLibrary) || "—"} | ${severityEmoji(b.severity)} |` ); }); } else { diff --git a/packages/tool-server/src/utils/profiler-shared/format.ts b/packages/tool-server/src/utils/profiler-shared/format.ts index 9a0b31157..72eb9218c 100644 --- a/packages/tool-server/src/utils/profiler-shared/format.ts +++ b/packages/tool-server/src/utils/profiler-shared/format.ts @@ -13,3 +13,14 @@ import bytesUtil from "bytes"; export function formatBytes(bytes: number): string { return bytesUtil(bytes, { decimalPlaces: 1 }) ?? `${bytes}B`; } + +/** + * Escape a value for interpolation into a GFM table cell: GFM splits cells on + * unescaped `|` even inside code spans, so a demangled C++ frame such as + * `folly::operator|(...)` would shift every column after it. Used by the leak + * tables, where malloc_stack_logging makes real demangled frames (operators + * included) the headline content. + */ +export function escapeMarkdownTableCell(value: string): string { + return value.replace(/\|/g, "\\|"); +} diff --git a/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts b/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts index 89d96ca5c..e5d138371 100644 --- a/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts +++ b/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts @@ -131,6 +131,26 @@ describe("leak attribution rendering", () => { expect(res.report).toContain("No attributed leaks"); }); + it("escapes '|' in leak table cells so demangled operator frames can't break the row", async () => { + // GFM splits table cells on unescaped pipes even inside code spans. A + // demangled C++ frame like `operator|` was unreachable in argent's own + // captures before malloc_stack_logging (attach mode never attributed), so + // the leak table must escape it now that real frames are the headline. + const frame = "folly::operator|(folly::Range, folly::Range)"; + const res = await renderNativeProfilerReport({ + payload: payload([leak(true, frame, "folly")]), + traceFile: null, + }); + const header = res.report.split("\n").find((l) => l.includes("| # | Object Type")); + const row = res.report.split("\n").find((l) => l.includes("folly::operator")); + expect(row).toBeDefined(); + expect(row).toContain("operator\\|"); + // Splitting on unescaped pipes yields the same cell count as the header — + // the row's columns stay aligned. + const unescapedPipes = (s: string) => s.split(/(? { const frame = "hermes::vm::JSTypedArrayBase::createBuffer(...)"; const res = await renderNativeProfilerReport({ diff --git a/packages/tool-server/test/ios-instruments/load-freshness.test.ts b/packages/tool-server/test/ios-instruments/load-freshness.test.ts index a0902d160..130b2b602 100644 --- a/packages/tool-server/test/ios-instruments/load-freshness.test.ts +++ b/packages/tool-server/test/ios-instruments/load-freshness.test.ts @@ -150,6 +150,31 @@ describe("iOS profiler-load → analyze freshness wiring (PR #340 comment 2)", ( expect(reportFile).toBeNull(); }); + it("analyze refuses while a newer recording is in flight (would re-label the old exports)", async () => { + // analyze re-runs the pipeline from api.exportedFiles using the LIVE + // session fields — mid-recording those belong to the newer capture, so + // the old exports would render under the new trace's name, freshness + // anchor, and (on a degraded Xcode) the new capture's CPU filter PID. + // Same contract as the profiler-load guard: stop first. + const api = await buildIosSession(); + api.exportedFiles = { cpu: null, hangs: null, leaks: null }; + api.profilingActive = true; + api.capturePid = 12345; + api.traceFile = join(tempDir, "in-flight.trace"); + + await expect(analyzeNativeProfilerIos(api)).rejects.toThrow(/native-profiler-stop first/); + }); + + it("analyze refuses while a crashed capture awaits its recovery export", async () => { + const api = await buildIosSession(); + api.exportedFiles = { cpu: null, hangs: null, leaks: null }; + api.recordingExitedUnexpectedly = true; + api.lastExitInfo = { code: 137, signal: "SIGKILL" }; + api.traceFile = join(tempDir, "crashed.trace"); + + await expect(analyzeNativeProfilerIos(api)).rejects.toThrow(/native-profiler-stop first/); + }); + it("refuses load_native while a recording is in flight (residue clearing would wedge the session)", async () => { // The residue clearing above nulls traceFile — with a live capture that // would make native-profiler-stop throw NO_ACTIVE_SESSION (its gate needs diff --git a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts index 9ad12af26..06e95b9d6 100644 --- a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts +++ b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts @@ -58,10 +58,10 @@ interface ExecFileSyncOpts { encoding?: string; timeout?: number; } -function makeExecFileSyncFn(opts?: { xcodebuild?: string }) { +function makeExecFileSyncFn(opts?: { xcodebuild?: string; listappsJson?: string }) { return vi.fn((bin: string, args: string[] = [], _opts?: ExecFileSyncOpts) => { if (bin === "xcodebuild") return opts?.xcodebuild ?? ""; // capture-strategy reads `xcodebuild -version` - if (bin === "plutil") return LISTAPPS_JSON; // plutil converts the listapps plist to JSON + if (bin === "plutil") return opts?.listappsJson ?? LISTAPPS_JSON; // plutil converts the listapps plist to JSON if (args.includes("listapps")) return ""; // raw plist, piped into plutil if (args.includes("launchctl")) return "1\t0\tUIKitApplication:com.example.myapp[abcd][rb-legacy]\n"; // `simctl spawn launchctl list` @@ -228,6 +228,124 @@ describe("native-profiler-start malloc_stack_logging", () => { expect(api.captureProcess).toBeNull(); }); + it("a failed start leaves a pending partial-trace recovery intact", async () => { + // Capture A ended abnormally (timeout / unexpected xctrace exit): stop's + // recovery branch gates on `(recovery flags) && traceFile` to export the + // partial trace. Nothing in a start attempt reads those flags, so a FAILED + // restart must not burn them — clearing them forfeits up to 10 minutes of + // captured data (the .trace bundle cannot be re-ingested by profiler-load). + const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcess(); + vi.doMock("child_process", () => ({ + spawn: spawnFn, + execSync: execSyncFn, + execFile: vi.fn(), + execFileSync: execFileSyncFn, + })); + vi.doMock("../../src/utils/react-profiler/debug/dump", () => ({ + getDebugDir: vi.fn(async () => "/tmp/argent-profiler-cwd"), + })); + vi.doMock("../../src/utils/ios-profiler/notify", () => ({ + listenForDarwinNotification: vi.fn(() => { + throw new Error("notifyutil unavailable in tests"); + }), + })); + vi.doMock("../../src/utils/ios-profiler/startup", () => ({ + waitForXctraceReady: vi.fn(async () => { + throw new Error("xctrace failed to start"); + }), + })); + + const startNativeProfilerIos = await importStart(); + const api = fakeApi(); + api.recordingExitedUnexpectedly = true; + api.lastExitInfo = { code: 1, signal: null }; + api.traceFile = "/tmp/argent-profiler-cwd/native-profiler-A.trace"; + + await expect( + startNativeProfilerIos(api, { device_id: "DEVICE-UDID", app_process: "MyApp" }) + ).rejects.toThrow(/xctrace failed to start/); + + expect(api.recordingExitedUnexpectedly).toBe(true); + expect(api.lastExitInfo).toEqual({ code: 1, signal: null }); + expect(api.traceFile).toBe("/tmp/argent-profiler-cwd/native-profiler-A.trace"); + }); + + it("malloc mode prefers the exact CFBundleExecutable match over an earlier display-name match", async () => { + // Two installed apps can share a display name (dev + prod builds both + // shown as "MyApp"). The malloc path TERMINATES the resolved app before + // cold-launching it, so first-match-in-plist-order is not acceptable: an + // exact executable match must win regardless of enumeration order. + const twoApps = JSON.stringify({ + "com.example.staging": { + CFBundleExecutable: "MyAppStaging", + CFBundleIdentifier: "com.example.staging", + CFBundleDisplayName: "MyApp", + ApplicationType: "User", + }, + "com.example.myapp": { + CFBundleExecutable: "MyApp", + CFBundleIdentifier: "com.example.myapp", + ApplicationType: "User", + }, + }); + const spawnFn = vi.fn(() => new StartFakeChild()); + const execSyncFn = vi.fn(() => ""); + const execFileSyncFn = makeExecFileSyncFn({ listappsJson: twoApps }); + applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn); + + const startNativeProfilerIos = await importStart(); + const result = await startNativeProfilerIos(fakeApi(), { + device_id: "DEVICE-UDID", + app_process: "MyApp", + malloc_stack_logging: true, + }); + + expect(result.status).toBe("recording"); + const terminateCalls = execFileSyncFn.mock.calls + .map((c) => (c[1] as string[]) ?? []) + .filter((a) => a.includes("terminate")); + expect(terminateCalls).toHaveLength(1); + expect(terminateCalls[0]).toContain("com.example.myapp"); + expect(terminateCalls[0]).not.toContain("com.example.staging"); + }); + + it("malloc mode refuses an app_process that matches several installed apps ambiguously", async () => { + // Display-name-only ambiguity (no exact executable match): terminating an + // arbitrary one of them would kill the wrong app. Refuse before touching + // anything, naming the candidates. + const twoApps = JSON.stringify({ + "com.example.staging": { + CFBundleExecutable: "MyAppStaging", + CFBundleIdentifier: "com.example.staging", + CFBundleDisplayName: "MyApp", + ApplicationType: "User", + }, + "com.example.prod": { + CFBundleExecutable: "MyAppProd", + CFBundleIdentifier: "com.example.prod", + CFBundleDisplayName: "MyApp", + ApplicationType: "User", + }, + }); + const spawnFn = vi.fn(() => new StartFakeChild()); + const execSyncFn = vi.fn(() => ""); + const execFileSyncFn = makeExecFileSyncFn({ listappsJson: twoApps }); + applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn); + + const startNativeProfilerIos = await importStart(); + await expect( + startNativeProfilerIos(fakeApi(), { + device_id: "DEVICE-UDID", + app_process: "MyApp", + malloc_stack_logging: true, + }) + ).rejects.toThrow(/matches multiple installed user apps/); + + const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []); + expect(efsArgs.some((a) => a.includes("terminate"))).toBe(false); + expect(spawnFn).not.toHaveBeenCalled(); + }); + it("stamps only the in-flight capture mode — never the report-facing flag", async () => { // api.mallocStackLogging describes the data in exportedFiles/parsedData // and is stamped at STOP; a new start stamping it directly would re-label diff --git a/packages/tool-server/test/profiler-leak-stacks.test.ts b/packages/tool-server/test/profiler-leak-stacks.test.ts index ff1b87224..c5e6e6e30 100644 --- a/packages/tool-server/test/profiler-leak-stacks.test.ts +++ b/packages/tool-server/test/profiler-leak-stacks.test.ts @@ -130,4 +130,23 @@ describe("renderLeakStacksIos — capture-mode-aware unattributed note", () => { expect(out).toContain("unattributed"); expect(out).not.toContain("--attach"); }); + + it("escapes '|' in table cells so demangled operator frames can't break the row", () => { + const pipeLeak = leak({ + objectType: "LeakySpan", + totalSizeBytes: 3072, + count: 3, + responsibleFrame: "folly::operator|(folly::Range, folly::Range)", + responsibleLibrary: "folly", + attributed: true, + severity: "RED", + }); + const out = renderLeakStacksIos([pipeLeak], undefined, 10, true); + const header = out.split("\n").find((l) => l.startsWith("| Object Type")); + const row = out.split("\n").find((l) => l.includes("folly::operator")); + expect(row).toBeDefined(); + expect(row).toContain("operator\\|"); + const unescapedPipes = (s: string) => s.split(/(? Date: Fri, 10 Jul 2026 12:36:26 +0200 Subject: [PATCH 27/28] fix(ios-profiler): guard combined-report mid-capture, unify in-flight refusals, widen table escaping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass-4 hardening of the malloc-attributable-leaks work: - profiler-combined-report now refuses while a capture is recording or a partial trace is pending recovery. It anchored the frozen parsedData hangs to the LIVE wallClockStartMs, so a capture started after analyze would shift every correlation by the gap between the two recordings' starts. Same contract analyze and profiler-load already enforce. - Factor the three in-flight refusals into a shared capture-guard helper that distinguishes recording vs 10-min-cap timeout vs unexpected exit, so the stated cause matches what native-profiler-stop reports next (a timeout is no longer mislabelled "ended unexpectedly"). - resolveAppForLaunch accepts a CFBundleIdentifier and the ambiguity refusal points at it: dev+prod builds sharing a CFBundleExecutable were an unresolvable dead end (the advice named the executable the user just passed). - startNativeProfilerAndroid starts perfetto before mutating session state, so a failed start no longer burns a prior capture's pending partial-trace recovery (the iOS start path already did this). - Escape '|' in the CPU-hotspot, function caller/callee, and thread-breakdown tables: dominantFunction is a real demangled frame in every capture mode, so a C++ operator| overload broke those rows regardless of malloc mode. - IOS_PROFILER_REFERENCE.md: the degraded-Xcode bound covers every 26.x from 26.4 up (26.5, 26.6, …), not only 26.4 and 27+. --- .../combined/profiler-combined-report.ts | 19 ++++ .../native-profiler/platforms/android.ts | 20 +++- .../profiler/native-profiler/platforms/ios.ts | 42 ++++--- .../src/tools/profiler/query/profiler-load.ts | 23 ++-- .../profiler/query/profiler-stack-query.ts | 10 +- .../ios-profiler/IOS_PROFILER_REFERENCE.md | 4 +- .../src/utils/ios-profiler/render.ts | 2 +- .../utils/profiler-shared/capture-guard.ts | 49 +++++++++ .../src/utils/profiler-shared/format.ts | 8 +- .../start-validate-dispatch.test.ts | 30 +++++ .../combined-report-guard.test.ts | 103 ++++++++++++++++++ .../degraded-xcode-doc-consistency.test.ts | 46 ++++++++ .../ios-instruments/load-freshness.test.ts | 40 ++++++- .../malloc-stack-logging.test.ts | 63 +++++++++++ .../report-table-pipe-escape.test.ts | 96 ++++++++++++++++ 15 files changed, 507 insertions(+), 48 deletions(-) create mode 100644 packages/tool-server/src/utils/profiler-shared/capture-guard.ts create mode 100644 packages/tool-server/test/ios-instruments/combined-report-guard.test.ts create mode 100644 packages/tool-server/test/ios-instruments/degraded-xcode-doc-consistency.test.ts create mode 100644 packages/tool-server/test/ios-instruments/report-table-pipe-escape.test.ts diff --git a/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts b/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts index 6a25c394b..a41dba272 100644 --- a/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts +++ b/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts @@ -18,6 +18,10 @@ import { import type { HotCommitSummary } from "../../../utils/react-profiler/types/output"; import type { UiHang, MemoryLeak } from "../../../utils/profiler-shared/types"; import { formatBytes } from "../../../utils/profiler-shared/format"; +import { + isCaptureInFlight, + inFlightGuardMessage, +} from "../../../utils/profiler-shared/capture-guard"; import { renderUnattributedLeaksNote } from "../../../utils/ios-profiler/render"; import { loadAndroidCombinedData } from "../../../utils/android-profiler/pipeline/index"; import { buildHotCommitSummaries } from "../../../utils/react-profiler/pipeline/00-hot-commits"; @@ -62,6 +66,21 @@ Fails if either react-profiler-analyze or native-profiler-analyze has not been c async execute(services, params) { const nativeApi = services.nativeSession as NativeProfilerSessionApi; + // A capture started after analyze re-stamps the live session fields + // (wallClockStartMs, traceFile) while parsedData stays frozen on the earlier + // capture. This report anchors the frozen hangs to the LIVE wallClockStartMs + // (below), so a new/pending capture would shift every hang by the gap + // between the two recordings' starts and mislabel real correlations. Refuse + // until the newer capture is stopped — same contract as analyze/profiler-load. + if (isCaptureInFlight(nativeApi)) { + throw new FailureError(inFlightGuardMessage(nativeApi, "re-run profiler-combined-report"), { + error_code: FAILURE_CODES.NATIVE_PROFILER_SESSION_ALREADY_RUNNING, + failure_stage: "profiler_combined_report_session_state", + failure_area: "tool_server", + error_kind: "validation", + }); + } + // For iOS, the analyze step cached uiHangs + memoryLeaks in parsedData. // For Android, drill-down re-queries the .pftrace, so we load the same // shape on demand here. diff --git a/packages/tool-server/src/tools/profiler/native-profiler/platforms/android.ts b/packages/tool-server/src/tools/profiler/native-profiler/platforms/android.ts index b5c105042..384042679 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/platforms/android.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/platforms/android.ts @@ -48,18 +48,26 @@ export async function startNativeProfilerAndroid( .slice(0, 15); const hostTracePath = path.join(debugDir, `native-profiler-${timestamp}.pftrace`); - api.recordingTimedOut = false; - api.recordingExitedUnexpectedly = false; - api.lastExitInfo = null; - api.appProcess = appPackage; - api.traceFile = hostTracePath; - + // Start perfetto BEFORE mutating any session state: a failed start (adb + // error, device offline, spawn failure) must be non-destructive. If a prior + // capture hit the 10-min cap or exited early, its partial trace is still + // recoverable via native-profiler-stop, and its recordingTimedOut/ + // recordingExitedUnexpectedly/traceFile fields must survive an unrelated + // failed start attempt — otherwise the pending recovery is silently burned. + // (Same contract as the iOS start path.) const { pid, onDeviceTracePath, child } = await startPerfetto({ serial: params.device_id, appPackage, timestamp, }); + // Perfetto is up — this capture now owns the session; stamp its descriptors + // and clear any prior capture's recovery flags (superseded on success only). + api.recordingTimedOut = false; + api.recordingExitedUnexpectedly = false; + api.lastExitInfo = null; + api.appProcess = appPackage; + api.traceFile = hostTracePath; api.capturePid = pid; api.captureProcess = child; api.androidOnDeviceTracePath = onDeviceTracePath; diff --git a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts index eb5125c03..27c6568fe 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts @@ -25,6 +25,10 @@ import { import type { NativeProfilerAnalyzeResult } from "../../../../utils/ios-profiler/types"; import { renderNativeProfilerReport } from "../../../../utils/ios-profiler/render"; import { formatTraceFreshness } from "../../../../utils/profiler-shared/freshness"; +import { + isCaptureInFlight, + inFlightGuardMessage, +} from "../../../../utils/profiler-shared/capture-guard"; import { RECORDING_CAP_MS } from "../../../../utils/profiler-shared/types"; // Two candidates because __dirname differs by runtime: bundled it's argent/dist/ @@ -310,10 +314,15 @@ function getAppBundlePath(udid: string, bundleId: string): string { function resolveAppForLaunch(udid: string, appProcess?: string): AppInfo { if (appProcess) { const installed = getInstalledApps(udid); + // CFBundleIdentifier is included so a bundle id is always a unique escape + // hatch: it is globally unique, so passing one narrows to exactly one app + // even when several builds share a CFBundleExecutable/CFBundleDisplayName. const matches = Object.values(installed).filter( (info) => info.ApplicationType === "User" && - (info.CFBundleExecutable === appProcess || info.CFBundleDisplayName === appProcess) + (info.CFBundleExecutable === appProcess || + info.CFBundleDisplayName === appProcess || + info.CFBundleIdentifier === appProcess) ); if (matches.length === 1) return matches[0]; if (matches.length > 1) { @@ -321,18 +330,21 @@ function resolveAppForLaunch(udid: string, appProcess?: string): AppInfo { // so first-match-in-plist-order is not acceptable when several installed // apps share a display name (dev + prod builds both shown as "MyApp"): // an exact executable match wins; otherwise refuse before touching - // anything, naming the candidates. + // anything, naming the candidates. (A bundle-id argument can never reach + // here — it uniquely matches one app above — so the tie is always on + // executable/display name, and the bundle id is the guaranteed escape.) const exact = matches.filter((info) => info.CFBundleExecutable === appProcess); if (exact.length === 1) return exact[0]; const appList = matches .map( (info) => - ` - ${info.CFBundleExecutable} (${info.CFBundleIdentifier}${info.CFBundleDisplayName ? `, "${info.CFBundleDisplayName}"` : ""})` + ` - ${info.CFBundleIdentifier} (CFBundleExecutable ${info.CFBundleExecutable}${info.CFBundleDisplayName ? `, "${info.CFBundleDisplayName}"` : ""})` ) .join("\n"); throw new FailureError( `app_process "${appProcess}" matches multiple installed user apps on simulator ${udid}:\n${appList}\n` + - `Pass the exact CFBundleExecutable of the app you want to cold-launch with malloc_stack_logging.`, + `Pass the exact CFBundleIdentifier (the first column above) to select one — the ` + + `CFBundleExecutable/display name you passed is shared by these builds.`, { error_code: FAILURE_CODES.NATIVE_PROFILER_LAUNCH_APP_AMBIGUOUS, failure_stage: "native_profiler_resolve_app_for_launch", @@ -343,7 +355,8 @@ function resolveAppForLaunch(udid: string, appProcess?: string): AppInfo { } throw new FailureError( `No installed user app matching "${appProcess}" found on simulator ${udid}. ` + - `Pass the exact CFBundleExecutable or display name, or omit app_process to auto-detect the running app.`, + `Pass the exact CFBundleIdentifier, CFBundleExecutable, or display name, or omit ` + + `app_process to auto-detect the running app.`, { error_code: FAILURE_CODES.NATIVE_PROFILER_LAUNCH_APP_NOT_FOUND, failure_stage: "native_profiler_resolve_app_for_launch", @@ -856,18 +869,13 @@ export async function analyzeNativeProfilerIos( // newer capture while exportedFiles still holds the previous one — analyze // would render the old exports under the new trace's name, freshness anchor, // and CPU filter PID. Same contract as the profiler-load guard: stop first. - if (api.profilingActive || api.recordingTimedOut || api.recordingExitedUnexpectedly) { - throw new FailureError( - api.profilingActive - ? `A native profiling session is recording on this device. Run native-profiler-stop first, then analyze.` - : `A native profiling capture on this device ended unexpectedly and its partial trace has not been exported yet. Run native-profiler-stop first (it recovers the partial trace), then analyze.`, - { - error_code: FAILURE_CODES.NATIVE_PROFILER_SESSION_ALREADY_RUNNING, - failure_stage: "native_profiler_analyze_session_state", - failure_area: "tool_server", - error_kind: "validation", - } - ); + if (isCaptureInFlight(api)) { + throw new FailureError(inFlightGuardMessage(api, "analyze"), { + error_code: FAILURE_CODES.NATIVE_PROFILER_SESSION_ALREADY_RUNNING, + failure_stage: "native_profiler_analyze_session_state", + failure_area: "tool_server", + error_kind: "validation", + }); } if (!api.exportedFiles) { throw new FailureError("No exported trace data found. Call native-profiler-stop first.", { 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 ea157f073..e69d8794c 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-load.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-load.ts @@ -21,6 +21,10 @@ import { readCommitTree } from "../../../utils/react-profiler/debug/dump"; import { runIosProfilerPipeline } from "../../../utils/ios-profiler/pipeline/index"; import { getDebugDir } from "../../../utils/react-profiler/debug/dump"; import { readAndroidNativeProfilerMetadata } from "../../../utils/android-profiler/session-metadata"; +import { + isCaptureInFlight, + inFlightGuardMessage, +} from "../../../utils/profiler-shared/capture-guard"; // session_id is interpolated into on-disk file paths // (`react-profiler-${id}_cpu.json`, `native-profiler-${id}_raw_cpu.xml`, …). @@ -325,18 +329,13 @@ async function loadNativeSession( // make stop's partial-trace export unreachable (the .trace bundle cannot be // re-ingested; only the raw_*.xml that export writes are loadable). Refuse // until the capture is stopped. - if (api.profilingActive || api.recordingTimedOut || api.recordingExitedUnexpectedly) { - throw new FailureError( - api.profilingActive - ? `A native profiling session is recording on this device. Run native-profiler-stop first, then retry profiler-load.` - : `A native profiling capture on this device ended unexpectedly and its partial trace has not been exported yet. Run native-profiler-stop first (it recovers the partial trace), then retry profiler-load.`, - { - error_code: FAILURE_CODES.NATIVE_PROFILER_SESSION_ALREADY_RUNNING, - failure_stage: "profiler_load_native_session", - failure_area: "tool_server", - error_kind: "validation", - } - ); + if (isCaptureInFlight(api)) { + throw new FailureError(inFlightGuardMessage(api, "retry profiler-load"), { + error_code: FAILURE_CODES.NATIVE_PROFILER_SESSION_ALREADY_RUNNING, + failure_stage: "profiler_load_native_session", + failure_area: "tool_server", + error_kind: "validation", + }); } // Android .pftrace first — the platform field on the resolved session API // tells us which shape to load. If the platform is android but the .pftrace diff --git a/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts b/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts index e22b4f7c5..c431106d2 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts @@ -135,7 +135,7 @@ function renderHangStacksIos( return lines.join("\n"); } -function renderFunctionCallersIos( +export function renderFunctionCallersIos( cpuSamples: CpuSample[], functionName: string, topN: number @@ -182,7 +182,7 @@ function renderFunctionCallersIos( lines.push("| Function | Samples |"); lines.push("|---|---|"); for (const [name, count] of sortedCallers) { - lines.push(`| \`${demangleSymbol(name)}\` | ${count} |`); + lines.push(`| \`${escapeMarkdownTableCell(demangleSymbol(name))}\` | ${count} |`); } lines.push(""); } @@ -194,7 +194,7 @@ function renderFunctionCallersIos( lines.push("| Function | Samples |"); lines.push("|---|---|"); for (const [name, count] of sortedCallees) { - lines.push(`| \`${demangleSymbol(name)}\` | ${count} |`); + lines.push(`| \`${escapeMarkdownTableCell(demangleSymbol(name))}\` | ${count} |`); } lines.push(""); } @@ -202,7 +202,7 @@ function renderFunctionCallersIos( return lines.join("\n"); } -function renderThreadBreakdownIos( +export function renderThreadBreakdownIos( cpuSamples: CpuSample[], cpuHotspots: CpuHotspot[], threadFilter: string | undefined, @@ -254,7 +254,7 @@ function renderThreadBreakdownIos( lines.push("|---|---|---|---|"); for (const h of threadHotspots.slice(0, topN)) { lines.push( - `| \`${demangleSymbol(h.dominantFunction)}\` | ${h.totalWeightMs} | ${h.weightPercentage}% | ${h.duringHang ? "Yes" : "No"} |` + `| \`${escapeMarkdownTableCell(demangleSymbol(h.dominantFunction))}\` | ${h.totalWeightMs} | ${h.weightPercentage}% | ${h.duringHang ? "Yes" : "No"} |` ); } } diff --git a/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md b/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md index 6445cc109..65ae94206 100644 --- a/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md +++ b/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md @@ -80,8 +80,8 @@ All three are wired through `native-profiler-session` (per-device service, keyed pure CPU/hang work, where attach (no relaunch, no overhead) is preferable. **Degraded-Xcode guard:** the cold launch needs `xctrace --device`, which is broken on Xcode 26.4 and later (the `--device` recording-start handshake records - an empty trace — `isDegraded` treats 26.4 and every version from 27 up as broken, - with no upper bound). Because the malloc path terminates the running app before + an empty trace — `isDegraded` treats every 26.x from 26.4 up (26.4, 26.5, 26.6, …) + and all of 27+ as broken, with no upper bound). Because the malloc path terminates the running app before launching, `native-profiler-start { malloc_stack_logging: true }` is **rejected up front** on those versions (before the app is touched) rather than silently capturing nothing — re-run without the flag (leaks are still detected, just diff --git a/packages/tool-server/src/utils/ios-profiler/render.ts b/packages/tool-server/src/utils/ios-profiler/render.ts index a675ba3db..30b2a63ce 100644 --- a/packages/tool-server/src/utils/ios-profiler/render.ts +++ b/packages/tool-server/src/utils/ios-profiler/render.ts @@ -263,7 +263,7 @@ function renderFullReport( cpuHotspots.forEach((b, i) => { const hangFlag = b.duringHang ? "Yes" : "—"; lines.push( - `| ${i + 1} | \`${demangleSymbol(b.dominantFunction)}\` | ${b.thread} | ${b.totalWeightMs} | ${b.weightPercentage}% | ${b.sampleCount} | ${hangFlag} | ${severityEmoji(b.severity)} |` + `| ${i + 1} | \`${escapeMarkdownTableCell(demangleSymbol(b.dominantFunction))}\` | ${b.thread} | ${b.totalWeightMs} | ${b.weightPercentage}% | ${b.sampleCount} | ${hangFlag} | ${severityEmoji(b.severity)} |` ); }); diff --git a/packages/tool-server/src/utils/profiler-shared/capture-guard.ts b/packages/tool-server/src/utils/profiler-shared/capture-guard.ts new file mode 100644 index 000000000..c17dedc69 --- /dev/null +++ b/packages/tool-server/src/utils/profiler-shared/capture-guard.ts @@ -0,0 +1,49 @@ +/** + * Shared "a capture still holds this session" guard for the native-profiler + * drill-down consumers (analyze, profiler-load, combined-report). All three + * render or re-label data keyed off the live session fields (traceFile, CPU + * filter PID, wall-clock anchor, exportedFiles), so none may run while a capture + * is mid-recording or has ended (cap/crash) with a partial trace that + * native-profiler-stop has not yet exported — otherwise they pair one capture's + * frozen data with another capture's live fields. + */ + +/** The three session flags that mean a capture is not yet settled. */ +export interface CaptureRecoveryState { + profilingActive: boolean; + recordingTimedOut: boolean; + recordingExitedUnexpectedly: boolean; +} + +/** + * True while a capture holds the session: actively recording, or ended (10-min + * cap / unexpected exit) with a partial trace still awaiting stop's recovery + * export. Consumers that render or re-label a capture must refuse in this window. + */ +export function isCaptureInFlight(state: CaptureRecoveryState): boolean { + return state.profilingActive || state.recordingTimedOut || state.recordingExitedUnexpectedly; +} + +/** + * Human-facing refusal message for {@link isCaptureInFlight}, distinguishing the + * three states so the stated cause matches what `native-profiler-stop` itself + * reports next (a 10-min cap is not an unexpected exit). `retryAction` names the + * step to repeat after stop, e.g. `"analyze"` or `"retry profiler-load"`. + */ +export function inFlightGuardMessage(state: CaptureRecoveryState, retryAction: string): string { + if (state.profilingActive) { + return `A native profiling session is recording on this device. Run native-profiler-stop first, then ${retryAction}.`; + } + if (state.recordingTimedOut) { + return ( + `A native profiling capture on this device hit the 10-minute recording cap and its ` + + `partial trace has not been exported yet. Run native-profiler-stop first (it recovers ` + + `the partial trace), then ${retryAction}.` + ); + } + return ( + `A native profiling capture on this device ended unexpectedly and its partial trace has ` + + `not been exported yet. Run native-profiler-stop first (it recovers the partial trace), ` + + `then ${retryAction}.` + ); +} diff --git a/packages/tool-server/src/utils/profiler-shared/format.ts b/packages/tool-server/src/utils/profiler-shared/format.ts index 72eb9218c..ae34ec837 100644 --- a/packages/tool-server/src/utils/profiler-shared/format.ts +++ b/packages/tool-server/src/utils/profiler-shared/format.ts @@ -17,9 +17,11 @@ export function formatBytes(bytes: number): string { /** * Escape a value for interpolation into a GFM table cell: GFM splits cells on * unescaped `|` even inside code spans, so a demangled C++ frame such as - * `folly::operator|(...)` would shift every column after it. Used by the leak - * tables, where malloc_stack_logging makes real demangled frames (operators - * included) the headline content. + * `folly::operator|(...)` would shift every column after it. Used by every + * profiler report table that interpolates a demangled symbol — the leak tables + * (analyze + leak_stacks), the CPU-hotspot summary, and the function + * caller/callee and thread-breakdown tables — since `dominantFunction` is a real + * demangled frame in every capture mode, not only under malloc_stack_logging. */ export function escapeMarkdownTableCell(value: string): string { return value.replace(/\|/g, "\\|"); diff --git a/packages/tool-server/test/android-perfetto/start-validate-dispatch.test.ts b/packages/tool-server/test/android-perfetto/start-validate-dispatch.test.ts index 8c63608aa..061f59c41 100644 --- a/packages/tool-server/test/android-perfetto/start-validate-dispatch.test.ts +++ b/packages/tool-server/test/android-perfetto/start-validate-dispatch.test.ts @@ -122,4 +122,34 @@ describe("startNativeProfilerAndroid — app_process dispatch (fail-fast validat expect(detectAndroidRunningApp).toHaveBeenCalledOnce(); if (api.recordingTimeout) clearTimeout(api.recordingTimeout); }); + + it("a failed startPerfetto does not burn a prior capture's pending partial-trace recovery", async () => { + // Pass-4 finding 2 (same class the iOS start path already guards): a prior + // capture hit the 10-min cap — recordingTimedOut=true with its partial trace + // still on the device, recoverable by native-profiler-stop. A new start whose + // startPerfetto then fails (adb offline, spawn failure) must NOT have already + // cleared recordingTimedOut or overwritten traceFile, or that ~10 min of data + // becomes unrecoverable (stop would throw "No active session"). + const api = await buildAndroidSession(); + api.recordingTimedOut = true; // prior capped capture awaiting recovery + api.traceFile = "/prior/partial.pftrace"; + api.appProcess = "com.prior.app"; + api.capturePid = 9999; + api.androidOnDeviceTracePath = "/data/misc/perfetto-traces/prior.pftrace"; + + detectAndroidRunningApp.mockResolvedValueOnce("com.new.app"); + startPerfetto.mockRejectedValueOnce(new Error("adb: device offline")); + + await expect(startNativeProfilerAndroid(api, { device_id: "emulator-5554" })).rejects.toThrow( + /device offline/ + ); + + // Recovery state for the prior capture must survive the failed start. + expect(api.recordingTimedOut).toBe(true); + expect(api.traceFile).toBe("/prior/partial.pftrace"); + expect(api.androidOnDeviceTracePath).toBe("/data/misc/perfetto-traces/prior.pftrace"); + expect(api.capturePid).toBe(9999); + expect(api.profilingActive).toBe(false); + if (api.recordingTimeout) clearTimeout(api.recordingTimeout); + }); }); diff --git a/packages/tool-server/test/ios-instruments/combined-report-guard.test.ts b/packages/tool-server/test/ios-instruments/combined-report-guard.test.ts new file mode 100644 index 000000000..d96a6c357 --- /dev/null +++ b/packages/tool-server/test/ios-instruments/combined-report-guard.test.ts @@ -0,0 +1,103 @@ +/** + * Finding 0 (pass-4): profiler-combined-report anchors the FROZEN + * parsedData.uiHangs to the LIVE nativeApi.wallClockStartMs. A capture started + * after analyze re-stamps wallClockStartMs (and traceFile) to the new + * recording, so the report would shift every frozen hang by the gap between the + * two recordings' starts — real hang↔commit correlations silently render as + * "Hangs Without React Commit Match" and the "Clock offset" line is wrong. + * + * analyze and profiler-load already refuse this exact state (a capture in + * flight or a partial trace pending recovery); combined-report must too. The + * guard fires before any data is loaded, so these tests need no react/native + * fixtures — reaching data-loading at all would be the bug. + */ +import { describe, it, expect } from "vitest"; +import { getFailureSignal } from "@argent/registry"; +import { + nativeProfilerSessionBlueprint, + type NativeProfilerSessionApi, + type NativeProfilerParsedData, +} from "../../src/blueprints/native-profiler-session"; +import { profilerCombinedReportTool } from "../../src/tools/profiler/combined/profiler-combined-report"; + +async function buildIosSession(): Promise { + const device = { id: "ios-sim", platform: "ios" as const, kind: "simulator" as const }; + const instance = await nativeProfilerSessionBlueprint.factory({}, device, { device }); + return instance.api; +} + +// A non-empty frozen parsedData the report would consume if the guard let it +// through — its identity is asserted untouched after each refusal. +function frozenParsedData(): NativeProfilerParsedData { + return { + cpuSamples: [], + uiHangs: [ + { + type: "ui_hang", + platform: "ios", + hangType: "Hang", + startNs: 1_000_000, + endNs: 2_000_000, + durationMs: 1, + startTimeFormatted: "0:01", + severity: "RED", + suspectedFunctions: [], + appCallChains: [], + }, + ], + cpuHotspots: [], + memoryLeaks: [], + mallocStackLogging: true, + }; +} + +async function expectRefusal(api: NativeProfilerSessionApi, messagePattern: RegExp) { + const frozen = api.parsedData; + let error: unknown; + try { + await profilerCombinedReportTool.execute({ nativeSession: api } as never, { + port: 8081, + device_id: "ios-sim", + }); + throw new Error("expected profiler-combined-report to refuse, but it resolved"); + } catch (err) { + error = err; + } + expect((error as Error).message).toMatch(messagePattern); + expect(getFailureSignal(error)?.error_code).toBe("NATIVE_PROFILER_SESSION_ALREADY_RUNNING"); + // Guard fired before any rendering — the frozen capture data is untouched. + expect(api.parsedData).toBe(frozen); +} + +describe("profiler-combined-report in-flight guard (pass-4 finding 0)", () => { + it("refuses while a newer recording is active, distinguishing it as recording", async () => { + const api = await buildIosSession(); + api.parsedData = frozenParsedData(); + api.wallClockStartMs = 5_000_000; // re-stamped by the NEW capture + api.traceFile = "/tmp/new-capture.trace"; + api.profilingActive = true; + + await expectRefusal(api, /is recording on this device/); + }); + + it("refuses while a timed-out capture awaits recovery, naming the 10-minute cap", async () => { + const api = await buildIosSession(); + api.parsedData = frozenParsedData(); + api.wallClockStartMs = 5_000_000; + api.traceFile = "/tmp/timed-out.trace"; + api.recordingTimedOut = true; + + await expectRefusal(api, /10-minute recording cap/); + }); + + it("refuses while a crashed capture awaits recovery, naming the unexpected exit", async () => { + const api = await buildIosSession(); + api.parsedData = frozenParsedData(); + api.wallClockStartMs = 5_000_000; + api.traceFile = "/tmp/crashed.trace"; + api.recordingExitedUnexpectedly = true; + api.lastExitInfo = { code: 137, signal: "SIGKILL" }; + + await expectRefusal(api, /ended unexpectedly/); + }); +}); diff --git a/packages/tool-server/test/ios-instruments/degraded-xcode-doc-consistency.test.ts b/packages/tool-server/test/ios-instruments/degraded-xcode-doc-consistency.test.ts new file mode 100644 index 000000000..9a1548666 --- /dev/null +++ b/packages/tool-server/test/ios-instruments/degraded-xcode-doc-consistency.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Pass-4 finding 4: keep IOS_PROFILER_REFERENCE.md's description of the + * degraded-Xcode bound in agreement with `isDegraded`. The code treats EVERY + * 26.x from 26.4 up (26.4, 26.5, 26.6, …) and all of 27+ as broken. The doc + * previously said "26.4 and every version from 27 up", which omits 26.5/26.6 — + * a maintainer on Xcode 26.5 would expect the device/malloc path to work, but + * the selector refuses it. This test binds the doc claim to real behavior so + * the two can't drift again. + */ +vi.mock("child_process", () => ({ + execFileSync: () => "Xcode 26.5\nBuild version 17F42\n", + execSync: () => { + throw new Error("execSync must not be used (shell risk)"); + }, +})); + +import { resolveIosCaptureStrategy } from "../../src/utils/ios-profiler/capture-strategy/select"; + +const REFERENCE_MD = join(__dirname, "../../src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md"); + +describe("degraded-Xcode bound: doc matches isDegraded (pass-4 finding 4)", () => { + beforeEach(() => { + delete process.env.ARGENT_IOS_CAPTURE; + }); + afterEach(() => { + delete process.env.ARGENT_IOS_CAPTURE; + }); + + it("treats Xcode 26.5 as degraded (a mid-band 26.x, not just 26.4 and 27+)", () => { + const decision = resolveIosCaptureStrategy(); + expect(decision.reason).toEqual({ kind: "degraded-xcode", major: 26, minor: 5 }); + expect(decision.strategy.name).toBe("all-processes"); + }); + + it("REFERENCE.md no longer implies only 26.4 and 27+ are broken", () => { + const md = readFileSync(REFERENCE_MD, "utf8"); + // The old phrasing skipped the 26.5+ band; it must be gone. + expect(md).not.toContain("26.4 and every version from 27 up"); + // And the corrected text must acknowledge the mid-band 26.x versions. + expect(md).toContain("26.5"); + }); +}); diff --git a/packages/tool-server/test/ios-instruments/load-freshness.test.ts b/packages/tool-server/test/ios-instruments/load-freshness.test.ts index 130b2b602..7da1f3f76 100644 --- a/packages/tool-server/test/ios-instruments/load-freshness.test.ts +++ b/packages/tool-server/test/ios-instruments/load-freshness.test.ts @@ -172,7 +172,21 @@ describe("iOS profiler-load → analyze freshness wiring (PR #340 comment 2)", ( api.lastExitInfo = { code: 137, signal: "SIGKILL" }; api.traceFile = join(tempDir, "crashed.trace"); - await expect(analyzeNativeProfilerIos(api)).rejects.toThrow(/native-profiler-stop first/); + // The stated cause must match the crash — not a timeout. + await expect(analyzeNativeProfilerIos(api)).rejects.toThrow(/ended unexpectedly/); + }); + + it("analyze names the 10-minute cap (not 'ended unexpectedly') for a timed-out capture", async () => { + // A 10-min-cap timeout sets recordingTimedOut (NOT recordingExitedUnexpectedly). + // stop itself distinguishes the two ("timed out at 10 min cap" vs "exited + // before stop"); the guard's stated cause must not contradict it. + const api = await buildIosSession(); + api.exportedFiles = { cpu: null, hangs: null, leaks: null }; + api.recordingTimedOut = true; + api.traceFile = join(tempDir, "timed-out.trace"); + + await expect(analyzeNativeProfilerIos(api)).rejects.toThrow(/10-minute recording cap/); + await expect(analyzeNativeProfilerIos(api)).rejects.not.toThrow(/ended unexpectedly/); }); it("refuses load_native while a recording is in flight (residue clearing would wedge the session)", async () => { @@ -223,9 +237,31 @@ describe("iOS profiler-load → analyze freshness wiring (PR #340 comment 2)", ( port: 8081, device_id: "ios-sim", }) - ).rejects.toThrow(/native-profiler-stop first/); + ).rejects.toThrow(/ended unexpectedly/); expect(api.traceFile).toBe(join(tempDir, "crashed.trace")); expect(api.recordingExitedUnexpectedly).toBe(true); }); + + it("load names the 10-minute cap (not 'ended unexpectedly') for a timed-out capture", async () => { + const api = await buildIosSession(); + api.recordingTimedOut = true; + api.traceFile = join(tempDir, "timed-out.trace"); + + const cpuXml = join(tempDir, `native-profiler-${SESSION_ID}_raw_cpu.xml`); + await writeFile(cpuXml, "", "utf8"); + + await expect( + profilerLoadTool.execute({ session: api } as never, { + mode: "load_native", + session_id: SESSION_ID, + port: 8081, + device_id: "ios-sim", + }) + ).rejects.toThrow(/10-minute recording cap/); + + // Recovery state untouched — the partial trace stays reachable by stop. + expect(api.traceFile).toBe(join(tempDir, "timed-out.trace")); + expect(api.recordingTimedOut).toBe(true); + }); }); diff --git a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts index 06e95b9d6..0337cca2d 100644 --- a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts +++ b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts @@ -346,6 +346,69 @@ describe("native-profiler-start malloc_stack_logging", () => { expect(spawnFn).not.toHaveBeenCalled(); }); + // Dev + prod builds of one Xcode target share CFBundleExecutable "MyApp" + // (exact.length === 2). Advising "pass the exact CFBundleExecutable" would be a + // dead end — the user already passed it. The refusal must point at the + // globally-unique CFBundleIdentifier, and passing one must actually resolve. + const SHARED_EXECUTABLE_BUILDS = JSON.stringify({ + "com.example.dev": { + CFBundleExecutable: "MyApp", + CFBundleIdentifier: "com.example.dev", + CFBundleDisplayName: "MyApp Dev", + ApplicationType: "User", + }, + "com.example.prod": { + CFBundleExecutable: "MyApp", + CFBundleIdentifier: "com.example.prod", + CFBundleDisplayName: "MyApp", + ApplicationType: "User", + }, + }); + + it("refusal for a shared CFBundleExecutable names the bundle id as the disambiguator", async () => { + const spawnFn = vi.fn(() => new StartFakeChild()); + const execFileSyncFn = makeExecFileSyncFn({ listappsJson: SHARED_EXECUTABLE_BUILDS }); + applyCommonMocks( + spawnFn, + vi.fn(() => ""), + execFileSyncFn + ); + const startNativeProfilerIos = await importStart(); + + await expect( + startNativeProfilerIos(fakeApi(), { + device_id: "DEVICE-UDID", + app_process: "MyApp", + malloc_stack_logging: true, + }) + ).rejects.toThrow(/CFBundleIdentifier/); + expect(spawnFn).not.toHaveBeenCalled(); + }); + + it("passing the exact CFBundleIdentifier resolves and cold-launches that specific build", async () => { + const spawnFn = vi.fn(() => new StartFakeChild()); + const execFileSyncFn = makeExecFileSyncFn({ listappsJson: SHARED_EXECUTABLE_BUILDS }); + applyCommonMocks( + spawnFn, + vi.fn(() => ""), + execFileSyncFn + ); + const startNativeProfilerIos = await importStart(); + + const result = await startNativeProfilerIos(fakeApi(), { + device_id: "DEVICE-UDID", + app_process: "com.example.prod", + malloc_stack_logging: true, + }); + expect(result.status).toBe("recording"); + const terminateCalls = execFileSyncFn.mock.calls + .map((c) => (c[1] as string[]) ?? []) + .filter((a) => a.includes("terminate")); + expect(terminateCalls).toHaveLength(1); + expect(terminateCalls[0]).toContain("com.example.prod"); + expect(terminateCalls[0]).not.toContain("com.example.dev"); + }); + it("stamps only the in-flight capture mode — never the report-facing flag", async () => { // api.mallocStackLogging describes the data in exportedFiles/parsedData // and is stamped at STOP; a new start stamping it directly would re-label diff --git a/packages/tool-server/test/ios-instruments/report-table-pipe-escape.test.ts b/packages/tool-server/test/ios-instruments/report-table-pipe-escape.test.ts new file mode 100644 index 000000000..959a6c923 --- /dev/null +++ b/packages/tool-server/test/ios-instruments/report-table-pipe-escape.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { renderNativeProfilerReport } from "../../src/utils/ios-profiler/render"; +import { + renderFunctionCallersIos, + renderThreadBreakdownIos, +} from "../../src/tools/profiler/query/profiler-stack-query"; +import type { ProfilerPayload, CpuSample } from "../../src/utils/ios-profiler/types"; +import type { CpuHotspot } from "../../src/utils/profiler-shared/types"; + +// GFM splits a table row on every unescaped `|`, even inside a code span, so a +// demangled C++ frame such as `folly::operator|(...)` injects phantom columns +// and misaligns every cell after it. escapeMarkdownTableCell fixes this, but the +// leak tables were the only cells escaped initially — these tables interpolate +// the SAME demangleSymbol() output. dominantFunction is a real demangled frame +// in every capture mode (unlike leak frames, which need malloc_stack_logging), +// so a C++ operator| hotspot reaches all of them without any special setup. +const OP_FRAME = "folly::operator|(folly::Range, folly::Range)"; + +// Splitting on unescaped pipes must give the row the same column count as its +// header — the whole point of escaping. +const unescapedCells = (s: string) => s.split(/(? ({ name, isSystemLibrary: false })), + }; +} + +describe("report table pipe-escaping (finding 5: beyond the leak tables)", () => { + it("escapes a demangled operator| frame in the CPU Hotspots summary table", async () => { + const payload: ProfilerPayload = { + metadata: { traceFile: null, platform: "iOS", timestamp: "2026-07-09T00:00:00Z" }, + bottlenecks: [hotspot(OP_FRAME)], + }; + const res = await renderNativeProfilerReport({ payload, traceFile: null }); + + const header = res.report.split("\n").find((l) => l.startsWith("| # | Function | Thread")); + const row = res.report.split("\n").find((l) => l.includes("folly::operator")); + expect(header).toBeDefined(); + expect(row).toBeDefined(); + expect(row).toContain("operator\\|"); + expect(unescapedCells(row!)).toBe(unescapedCells(header!)); + }); + + it("escapes operator| in the function Called By / Calls Into tables", () => { + // "target" is called by op| and calls into op| — both caller and callee + // cells carry the pipe. + const samples = [ + sample("Main Thread", ["callee", "target", OP_FRAME]), + sample("Main Thread", [OP_FRAME, "target", "callee2"]), + ]; + const out = renderFunctionCallersIos(samples, "target", 10); + + const header = out.split("\n").find((l) => l === "| Function | Samples |"); + expect(header).toBeDefined(); + const rows = out.split("\n").filter((l) => l.includes("folly::operator")); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row).toContain("operator\\|"); + expect(unescapedCells(row)).toBe(unescapedCells(header!)); + } + }); + + it("escapes operator| in the per-thread Hotspots table", () => { + const samples = [sample("Main Thread", ["target"])]; + const out = renderThreadBreakdownIos(samples, [hotspot(OP_FRAME, "Main Thread")], "Main", 10); + + const header = out.split("\n").find((l) => l.startsWith("| Function | Weight (ms)")); + const row = out.split("\n").find((l) => l.includes("folly::operator")); + expect(header).toBeDefined(); + expect(row).toBeDefined(); + expect(row).toContain("operator\\|"); + expect(unescapedCells(row!)).toBe(unescapedCells(header!)); + }); +}); From 8dcca013865d66d116890aedb6bfbacaf1e11fcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 10 Jul 2026 13:03:26 +0200 Subject: [PATCH 28/28] fix(ios-profiler): freeze combined-report anchor at analyze; escape thread cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to the pass-4 in-flight guard: - The iOS combined report anchored its FROZEN parsedData hangs to the LIVE session wallClockStartMs. A capture started after analyze re-stamps that live field while parsedData stays on the earlier capture, and native-profiler-stop refreshes neither — so re-running the report (including the path the guard's own recovery advice led to) shifted every hang by the gap between the two recordings' starts, silently dropping real correlations into "Hangs Without React Commit Match". Freeze the recording start into parsedData at analyze and anchor iOS off that frozen value; Android keeps its live anchor (it re-derives hangs from the live trace). The guard's recovery message now routes through native-profiler-analyze, which is the only step that rebuilds parsedData. - Escape '|' in the Thread column of the CPU-hotspot and thread-breakdown tables — the sibling Function column was escaped but a pipe in a dispatch-queue / thread name still misaligned the row. --- .../src/blueprints/native-profiler-session.ts | 8 + .../combined/profiler-combined-report.ts | 46 ++++-- .../profiler/native-profiler/platforms/ios.ts | 4 + .../src/tools/profiler/query/profiler-load.ts | 12 +- .../profiler/query/profiler-stack-query.ts | 2 +- .../src/utils/ios-profiler/render.ts | 2 +- .../combined-report-frozen-anchor.test.ts | 138 ++++++++++++++++++ .../combined-report-guard.test.ts | 22 +++ .../report-table-pipe-escape.test.ts | 32 ++++ 9 files changed, 250 insertions(+), 16 deletions(-) create mode 100644 packages/tool-server/test/ios-instruments/combined-report-frozen-anchor.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 49e062ebf..d58d5b3ec 100644 --- a/packages/tool-server/src/blueprints/native-profiler-session.ts +++ b/packages/tool-server/src/blueprints/native-profiler-session.ts @@ -40,6 +40,14 @@ export interface NativeProfilerParsedData { * session. Null when unknown (session restored from disk). */ mallocStackLogging: boolean | null; + /** + * Recording start (wall-clock ms) of THIS parsed data, frozen at parse time. + * The combined report anchors these hangs to wall-clock time; reading the live + * session `wallClockStartMs` instead would pair frozen hangs with a NEWER + * capture's start once a second recording re-stamps the session, shifting every + * hang. Null for iOS sessions restored from disk (no start-time sidecar). + */ + wallClockStartMs: number | null; } export interface NativeProfilerSessionApi { diff --git a/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts b/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts index a41dba272..f30ac148b 100644 --- a/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts +++ b/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts @@ -66,19 +66,31 @@ Fails if either react-profiler-analyze or native-profiler-analyze has not been c async execute(services, params) { const nativeApi = services.nativeSession as NativeProfilerSessionApi; - // A capture started after analyze re-stamps the live session fields - // (wallClockStartMs, traceFile) while parsedData stays frozen on the earlier - // capture. This report anchors the frozen hangs to the LIVE wallClockStartMs - // (below), so a new/pending capture would shift every hang by the gap - // between the two recordings' starts and mislabel real correlations. Refuse - // until the newer capture is stopped — same contract as analyze/profiler-load. + // Freshness gate: once a newer capture is recording (or ended and pending + // recovery), the frozen iOS parsedData this report renders is stale relative + // to what the user is now capturing. Refuse and point them at a fresh analyze + // so they get the current capture's correlations, not the previous one's. + // (Numeric correctness is handled independently by freezing the wall-clock + // anchor into parsedData at analyze — see nativeWallStart below — so even if + // this gate is bypassed the report stays self-consistent rather than mixing + // one capture's hangs with another's clock.) The retryAction must include + // native-profiler-analyze: unlike analyze/profiler-load, which re-derive + // their data on retry, this report consumes the frozen parsedData that ONLY + // native-profiler-analyze rewrites, so "stop then re-run" alone would render + // the previous capture again. if (isCaptureInFlight(nativeApi)) { - throw new FailureError(inFlightGuardMessage(nativeApi, "re-run profiler-combined-report"), { - error_code: FAILURE_CODES.NATIVE_PROFILER_SESSION_ALREADY_RUNNING, - failure_stage: "profiler_combined_report_session_state", - failure_area: "tool_server", - error_kind: "validation", - }); + throw new FailureError( + inFlightGuardMessage( + nativeApi, + "run native-profiler-analyze, then re-run profiler-combined-report" + ), + { + error_code: FAILURE_CODES.NATIVE_PROFILER_SESSION_ALREADY_RUNNING, + failure_stage: "profiler_combined_report_session_state", + failure_area: "tool_server", + error_kind: "validation", + } + ); } // For iOS, the analyze step cached uiHangs + memoryLeaks in parsedData. @@ -146,7 +158,15 @@ Fails if either react-profiler-analyze or native-profiler-analyze has not been c } const reactWallStart = onDisk.meta?.profileStartWallMs ?? null; - const nativeWallStart = nativeApi.wallClockStartMs; + // iOS anchors the FROZEN parsedData hangs, so it must use the anchor frozen + // WITH them (at analyze), not the live session field — a later + // native-profiler-start re-stamps the live field to a different capture and + // would shift every hang. Android re-derives hangs from the live traceFile + // (loadAndroidCombinedData above), so its live anchor stays consistent. + const nativeWallStart = + nativeApi.platform === "android" + ? nativeApi.wallClockStartMs + : (nativeApi.parsedData?.wallClockStartMs ?? null); if (!reactWallStart && !nativeWallStart) { throw new FailureError( diff --git a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts index 27c6568fe..b3f7f549f 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts @@ -904,6 +904,10 @@ export async function analyzeNativeProfilerIos( // consumers (leak_stacks, combined report) stay paired with it even after // a newer capture re-stamps the session fields. mallocStackLogging: api.mallocStackLogging, + // Freeze the recording's start time too — the combined report anchors these + // hangs to wall-clock time, and must use THIS capture's start, not whatever + // a later native-profiler-start re-stamps onto the live session field. + wallClockStartMs: api.wallClockStartMs, }; const exportErrors: Record = {}; 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 e69d8794c..0f31080f6 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-load.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-load.ts @@ -422,7 +422,17 @@ async function loadNativeSession( const { cpuSamples, uiHangs, cpuHotspots, memoryLeaks } = await runIosProfilerPipeline(files); - api.parsedData = { cpuSamples, uiHangs, cpuHotspots, memoryLeaks, mallocStackLogging: null }; + // A loaded iOS trace has no start-time sidecar (raw_*.xml only), so its frozen + // anchor is null — the combined report will then report a missing native anchor + // rather than mis-anchoring to whatever residue the session held. + api.parsedData = { + cpuSamples, + uiHangs, + cpuHotspots, + memoryLeaks, + mallocStackLogging: null, + wallClockStartMs: null, + }; api.exportedFiles = files; // The raw_*.xml carry no metadata sidecar, so nothing per-capture is known // about the loaded trace — clear ALL the session residue an earlier live diff --git a/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts b/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts index c431106d2..c0fe5875e 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts @@ -239,7 +239,7 @@ export function renderThreadBreakdownIos( const weightMs = Math.round(weight / 1_000_000); const pct = totalWeight > 0 ? ((weight / totalWeight) * 100).toFixed(1) : "0"; const samples = threadSamples.get(thread) ?? 0; - lines.push(`| ${thread} | ${weightMs} | ${pct}% | ${samples} |`); + lines.push(`| ${escapeMarkdownTableCell(thread)} | ${weightMs} | ${pct}% | ${samples} |`); } if (threadFilter) { diff --git a/packages/tool-server/src/utils/ios-profiler/render.ts b/packages/tool-server/src/utils/ios-profiler/render.ts index 30b2a63ce..a6d35e357 100644 --- a/packages/tool-server/src/utils/ios-profiler/render.ts +++ b/packages/tool-server/src/utils/ios-profiler/render.ts @@ -263,7 +263,7 @@ function renderFullReport( cpuHotspots.forEach((b, i) => { const hangFlag = b.duringHang ? "Yes" : "—"; lines.push( - `| ${i + 1} | \`${escapeMarkdownTableCell(demangleSymbol(b.dominantFunction))}\` | ${b.thread} | ${b.totalWeightMs} | ${b.weightPercentage}% | ${b.sampleCount} | ${hangFlag} | ${severityEmoji(b.severity)} |` + `| ${i + 1} | \`${escapeMarkdownTableCell(demangleSymbol(b.dominantFunction))}\` | ${escapeMarkdownTableCell(b.thread)} | ${b.totalWeightMs} | ${b.weightPercentage}% | ${b.sampleCount} | ${hangFlag} | ${severityEmoji(b.severity)} |` ); }); diff --git a/packages/tool-server/test/ios-instruments/combined-report-frozen-anchor.test.ts b/packages/tool-server/test/ios-instruments/combined-report-frozen-anchor.test.ts new file mode 100644 index 000000000..e3228e007 --- /dev/null +++ b/packages/tool-server/test/ios-instruments/combined-report-frozen-anchor.test.ts @@ -0,0 +1,138 @@ +/** + * Pass-5 finding 0: the iOS combined report used to anchor its FROZEN + * parsedData hangs to the LIVE session wallClockStartMs. A capture started after + * analyze re-stamps that live field, so re-running the report (e.g. the path the + * in-flight guard's own recovery advice leads to) shifted every hang by the gap + * between the two recordings' starts — a correlated hang silently became + * "Hangs Without React Commit Match" and the clock offset was wrong. + * + * The fix freezes the recording's start time INTO parsedData at analyze, and the + * report reads that frozen anchor for iOS. This test pins the invariant that + * makes the whole class impossible: mutating the live wallClockStartMs (what a + * later native-profiler-start does) must NOT change the correlation, because the + * report no longer reads the live field. + */ +import { describe, it, expect, afterEach } from "vitest"; +import { promises as fs } from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + nativeProfilerSessionBlueprint, + type NativeProfilerSessionApi, + type NativeProfilerParsedData, +} from "../../src/blueprints/native-profiler-session"; +import { + cacheProfilerPaths, + clearCachedProfilerPaths, + type ProfilerSessionPaths, +} from "../../src/blueprints/react-profiler-session"; +import { profilerCombinedReportTool } from "../../src/tools/profiler/combined/profiler-combined-report"; + +const DEVICE = "ios-sim"; +const PORT = 8081; +const A_START = 1_000_000; // capture A's start wall-ms (== react start → 0.0s offset) +const GAP_MS = 120_000; // a later capture B started 120s after A + +async function buildIosSession(): Promise { + const device = { id: DEVICE, platform: "ios" as const, kind: "simulator" as const }; + const instance = await nativeProfilerSessionBlueprint.factory({}, device, { device }); + return instance.api; +} + +// A hang at trace-relative 1000ms..2000ms, frozen with capture A's start. +function parsedDataA(): NativeProfilerParsedData { + return { + cpuSamples: [], + uiHangs: [ + { + type: "ui_hang", + platform: "ios", + hangType: "Hang", + startNs: 1_000_000_000, + endNs: 2_000_000_000, + durationMs: 1000, + startTimeFormatted: "0:01", + severity: "RED", + suspectedFunctions: [], + appCallChains: [], + }, + ], + cpuHotspots: [], + memoryLeaks: [], + mallocStackLogging: true, + wallClockStartMs: A_START, // frozen at analyze — the anchor that must be used + }; +} + +async function writeReactCommits(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "p5-frozen-anchor-")); + const commitsPath = path.join(dir, "commits.json"); + const commit = { + commitIndex: 0, + timestamp: 1000, // react-clock ms; overlaps A's hang when anchored at A_START + componentName: "MyComp", + actualDuration: 500, + selfDuration: 500, + commitDuration: 500, + didRender: true, + changeDescription: null, + }; + await fs.writeFile( + commitsPath, + JSON.stringify({ commits: [commit], meta: { profileStartWallMs: A_START } }) + ); + return commitsPath; +} + +function cachePaths(commitsPath: string): void { + const paths: ProfilerSessionPaths = { + sessionId: "s", + debugDir: path.dirname(commitsPath), + cpuProfilePath: null, + commitsPath, + cpuSampleIndexPath: null, + detectedArchitecture: null, + anyCompilerOptimized: null, + hotCommitIndices: [0], + totalReactCommits: 1, + }; + cacheProfilerPaths(PORT, paths, DEVICE); +} + +async function run(api: NativeProfilerSessionApi): Promise { + return (await profilerCombinedReportTool.execute({ nativeSession: api } as never, { + port: PORT, + device_id: DEVICE, + })) as string; +} + +const clockOffset = (s: string) => s.split("\n").find((l) => l.includes("Clock offset")); + +describe("iOS combined-report frozen anchor (pass-5 finding 0)", () => { + afterEach(() => clearCachedProfilerPaths(PORT, DEVICE)); + + it("uses the anchor frozen in parsedData, so a re-stamped live wallClockStartMs cannot shift correlations", async () => { + const commitsPath = await writeReactCommits(); + cachePaths(commitsPath); + + const api = await buildIosSession(); + api.parsedData = parsedDataA(); + api.wallClockStartMs = A_START; + + const before = await run(api); + expect(before).toContain("↔ Commit #0"); + expect(before).not.toContain("Hangs Without React Commit Match"); + + // Simulate the exact post-"start B then stop B" state: the live field is + // re-stamped to B's start while parsedData (and its frozen anchor) stay on A. + // Pre-fix this shifted the hang out of every commit window; post-fix the + // frozen anchor makes the report byte-identical. + api.wallClockStartMs = A_START + GAP_MS; + const after = await run(api); + + expect(after).toContain("↔ Commit #0"); + expect(after).not.toContain("Hangs Without React Commit Match"); + expect(clockOffset(after)).toBe(clockOffset(before)); + expect(after).toBe(before); + }); +}); diff --git a/packages/tool-server/test/ios-instruments/combined-report-guard.test.ts b/packages/tool-server/test/ios-instruments/combined-report-guard.test.ts index d96a6c357..25ab79c5a 100644 --- a/packages/tool-server/test/ios-instruments/combined-report-guard.test.ts +++ b/packages/tool-server/test/ios-instruments/combined-report-guard.test.ts @@ -48,6 +48,7 @@ function frozenParsedData(): NativeProfilerParsedData { cpuHotspots: [], memoryLeaks: [], mallocStackLogging: true, + wallClockStartMs: 1_000_000, }; } @@ -100,4 +101,25 @@ describe("profiler-combined-report in-flight guard (pass-4 finding 0)", () => { await expectRefusal(api, /ended unexpectedly/); }); + + it("recovery advice routes through native-profiler-analyze, not just stop+re-run", async () => { + // combined-report consumes the FROZEN parsedData, which only + // native-profiler-analyze rewrites. "stop then re-run" alone would render + // the previous capture; the advice must name analyze. + const api = await buildIosSession(); + api.parsedData = frozenParsedData(); + api.profilingActive = true; + + let message = ""; + try { + await profilerCombinedReportTool.execute({ nativeSession: api } as never, { + port: 8081, + device_id: "ios-sim", + }); + } catch (err) { + message = (err as Error).message; + } + expect(message).toContain("native-profiler-analyze"); + expect(message).toContain("re-run profiler-combined-report"); + }); }); diff --git a/packages/tool-server/test/ios-instruments/report-table-pipe-escape.test.ts b/packages/tool-server/test/ios-instruments/report-table-pipe-escape.test.ts index 959a6c923..5b43a4f91 100644 --- a/packages/tool-server/test/ios-instruments/report-table-pipe-escape.test.ts +++ b/packages/tool-server/test/ios-instruments/report-table-pipe-escape.test.ts @@ -93,4 +93,36 @@ describe("report table pipe-escaping (finding 5: beyond the leak tables)", () => expect(row).toContain("operator\\|"); expect(unescapedCells(row!)).toBe(unescapedCells(header!)); }); + + // Pass-5 finding 1: the Function column was escaped but the sibling Thread + // column in the same tables was not. Dispatch-queue / thread names are + // developer-controlled strings and can contain '|'. + const PIPE_THREAD = "com.example|worker"; + + it("escapes a '|' in the CPU-Hotspots table Thread column", async () => { + const payload: ProfilerPayload = { + metadata: { traceFile: null, platform: "iOS", timestamp: "2026-07-09T00:00:00Z" }, + bottlenecks: [hotspot("plainFunc", PIPE_THREAD)], + }; + const res = await renderNativeProfilerReport({ payload, traceFile: null }); + + const header = res.report.split("\n").find((l) => l.startsWith("| # | Function | Thread")); + const row = res.report.split("\n").find((l) => l.includes("com.example")); + expect(header).toBeDefined(); + expect(row).toBeDefined(); + expect(row).toContain("com.example\\|worker"); + expect(unescapedCells(row!)).toBe(unescapedCells(header!)); + }); + + it("escapes a '|' in the Thread CPU Breakdown table Thread column", () => { + const samples = [sample(PIPE_THREAD, ["target"])]; + const out = renderThreadBreakdownIos(samples, [], undefined, 10); + + const header = out.split("\n").find((l) => l.startsWith("| Thread | Weight (ms)")); + const row = out.split("\n").find((l) => l.includes("com.example")); + expect(header).toBeDefined(); + expect(row).toBeDefined(); + expect(row).toContain("com.example\\|worker"); + expect(unescapedCells(row!)).toBe(unescapedCells(header!)); + }); });