Files
Ignacy Łątka a5c6730d05 feat(ios-profiler): attributable memory leaks via malloc_stack_logging (#351)
## What

Opt-in `malloc_stack_logging` flag on `native-profiler-start` that makes
iOS memory leaks **attributable** — they come back with a real
responsible frame + library instead of `<Call stack limit reached>`.

## Why

`native-profiler-start` attaches to the already-running app. iOS records
malloc allocation backtraces only when `MallocStackLogging` is set **at
process launch**, so an attached app has none: Instruments' Leaks
scanner finds the leaked blocks but can't attribute them and emits the
placeholder `<Call stack limit reached>` (that string is Instruments',
not ours).

## How

When `malloc_stack_logging: true`, the profiler cold-launches the app
**under** xctrace instead of attaching:

```
xctrace record --template <Argent.tracetemplate> --device <udid> \
  --env MallocStackLogging=1 --output <…> --no-prompt \
  --launch -- <App.app>
```

- `.app` path resolved via `simctl get_app_container`; the running
instance is terminated first for a clean cold start.
- The existing Leaks export / parser / render path is reused
**unchanged** — no parser changes.
- Default behaviour is untouched: attach to the running app (no
relaunch, no overhead). The flag is opt-in because it restarts the app
(loses state) and adds allocator overhead — for leak-attribution passes,
not CPU/hang work.
- The report relabels unattributable leaks with a hint to re-run with
`malloc_stack_logging: true`, instead of surfacing the raw placeholder.

## Evidence (real iPhone 16 sim, same scroll workload)

Before (attach) — every leak row:
```
responsible-frame="<Call stack limit reached>" responsible-library=""
```
After (`--env MallocStackLogging=1 --launch`) — same Leaks export:

| leaked-object | responsible-frame | library |
|---|---|---|
| Malloc 1008 B ×3 | `itanium_demangle::OutputBuffer::grow(...)` |
libc++abi |
| Malloc 48 B ×1 | `hermes::vm::JSTypedArrayBase::createBuffer(...)` |
hermes |

## Tests

- `test/ios-instruments/malloc-stack-logging.test.ts` — the malloc
launch argv (`--env MallocStackLogging=1`, `--launch -- <app>` last,
with and without `--notify-tracing-started`, `--no-prompt`) vs default
`--attach`; terminate-first / get_app_container ordering; failed-start
behavior; per-test `ARGENT_IOS_CAPTURE` scrubbing.
- `test/ios-instruments/leak-attribution-render.test.ts` —
unattributable vs attributed leak rendering, capture-mode-aware hints.
- `test/ios-instruments/load-freshness.test.ts` — per-capture residue
clearing on `profiler-load`, live/recovery-pending load refusals.
- `test/ios-instruments/stop-recovery.test.ts` — capture-mode pairing at
stop (clean and recovery paths).
- `test/profiler-combined-leaks.test.ts`,
`test/profiler-leak-stacks.test.ts`, `test/ios-leak-pipeline.test.ts` —
shared caveat line, evidence-first hints, attribution-normalized
grouping.
- Full `test/ios-instruments/` suite green (9 files / 70 tests); full
tool-server suite green; `tsc --noEmit` + `tsc -p tsconfig.test.json`
clean.

## Notes

E2E'd live twice during review (real iPhone 16 Pro / iPhone 17 Pro sims
on Xcode 26.6): guard refusals fire before the app is touched, the
`--device` cold launch's deadlock on degraded Xcode is confirmed (so the
guard is warranted), and the malloc argv/terminate/relaunch behavior
checked out on-device. Docs updated: `IOS_PROFILER_REFERENCE.md` and the
`argent-native-profiler` skill.

## Review follow-ups (post-approval hardening)

Later commits address the remaining review threads and several
subsequent multi-lens review passes:

- **Capture-mode is a real signal, end to end.** The session records the
mode of the in-flight recording at start
(`recordingMallocStackLogging`), pairs it with `exportedFiles` at stop
(`mallocStackLogging`), and freezes it into `parsedData` at analyze/load
— so the analyze report, the combined report, and the `leak_stacks`
drill-down all name the capture mode of the data they actually render
instead of inferring it (or mislabeling it after a newer capture
starts). One shared `renderUnattributedLeaksNote()` keeps the wording
identical across reports, and attribution evidence outranks the flag: a
resolved frame proves the target ran under malloc stack logging however
it was launched.
- **`profiler-load` restores a clean slate.** Loading clears all
per-capture residue (capture mode, `cpuFilterPid`, `wallClockStartMs`,
`traceFile`) so a loaded trace is never analyzed with a previous live
capture's PID filter, freshness anchor, or report path — and refuses to
load while a recording is in flight or a crashed capture awaits
`native-profiler-stop`'s partial-trace recovery, which the load would
otherwise orphan.
- **Failed starts are non-destructive.** Per-capture descriptors — and
the recovery flags that gate `native-profiler-stop`'s partial-trace
export — are stamped/reset only on a successful start, so a failed
attempt leaves the previous capture's still-loaded exports fully
described and a pending abnormal-end recovery intact.
- **Guards keep session state coherent.** `profiler-load`,
`native-profiler-analyze`, and `profiler-combined-report` all refuse
while a recording is in flight or a crashed capture awaits recovery, via
one shared `isCaptureInFlight`/`inFlightGuardMessage` helper whose
message distinguishes recording vs 10-min-cap timeout vs unexpected exit
(so the stated cause matches what `native-profiler-stop` reports next).
Proceeding would otherwise wedge the session (load) or render one
capture's frozen data under another's live fields (analyze/combined).
- **The combined report anchors on a frozen clock.** The recording's
start time is frozen into `parsedData` at analyze, and the iOS combined
report anchors its hangs to that frozen value rather than the live
session field — so a capture started after analyze can never shift a
real hang↔commit correlation into "unmatched". Android keeps its live
anchor (it re-derives hangs from the live trace). The combined-report
guard's recovery advice routes through `native-profiler-analyze`, the
only step that rebuilds `parsedData`.
- **Leak grouping is attribution-normalized.** `aggregateLeaks` keys on
`(objectType, frame-normalized-the-way-isLeakAttributed-matches)`, so
mixed unattributed spellings (`Unknown`, the sentinel, whitespace
variants) can't split one group per type.
- **The malloc launch target is unambiguous.** Since the cold launch
terminates the resolved app, `app_process` resolution prefers an exact
`CFBundleExecutable` match and, when builds still tie (dev + prod
sharing an executable/display name), refuses and points at the
globally-unique `CFBundleIdentifier` — which it also accepts as an
`app_process` — instead of killing whichever app the plist enumerates
first.
- **Report tables escape `|`.** Demangled C++ frames (`operator|`
included) are real headline content under malloc mode; every free-form
cell across the leak, CPU-hotspot, function caller/callee, and
thread-breakdown tables — symbol *and* thread/queue-name columns — is
escaped so a stray pipe can't shift the row's columns.
- **Android start is non-destructive too.** `startNativeProfilerAndroid`
now starts perfetto before mutating session state, mirroring iOS, so a
failed start no longer burns a prior capped capture's pending
partial-trace recovery.
2026-07-12 13:42:23 +02:00

268 lines
12 KiB
TypeScript

/**
* Proof for PR #340 review comment 2 (hubgan, ios.ts:464):
*
* The iOS analyze path forwards `formatTraceFreshness(api.wallClockStartMs, …)`
* exactly like Android, but `wallClockStartMs` is only ever set in-memory at
* `native-profiler-start` (ios.ts) — iOS has NO on-disk metadata sidecar, and
* the iOS branch of `profiler-load` never restores it. So when an old iOS
* session is loaded from disk, `wallClockStartMs` stays `null` and the
* stale-trace warning can never fire — the wiring is effective only for a live
* session, which is fresh by construction.
*
* These tests pin that behavior: it must degrade cleanly (no crash, no
* misleading output), NOT silently emit or suppress something wrong. If iOS
* later grows real start-time persistence, the `wallClockStartMs` assertion
* here is the canary that should be updated alongside it.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
nativeProfilerSessionBlueprint,
type NativeProfilerSessionApi,
} from "../../src/blueprints/native-profiler-session";
vi.mock("../../src/utils/react-profiler/debug/dump", () => ({
getDebugDir: vi.fn(),
readCommitTree: vi.fn(),
}));
// The iOS load + analyze paths both parse xctrace XML off disk; stub the
// pipeline so this runs deterministically and isolates the freshness wiring.
vi.mock("../../src/utils/ios-profiler/pipeline/index", () => ({
runIosProfilerPipeline: vi.fn(async () => ({
bottlenecks: [],
cpuSamples: [],
uiHangs: [],
cpuHotspots: [],
memoryLeaks: [],
})),
}));
import { getDebugDir } from "../../src/utils/react-profiler/debug/dump";
import { profilerLoadTool } from "../../src/tools/profiler/query/profiler-load";
import { analyzeNativeProfilerIos } from "../../src/tools/profiler/native-profiler/platforms/ios";
const mockedGetDebugDir = vi.mocked(getDebugDir);
const SESSION_ID = "20200101-000000"; // deliberately ancient
const DAY_MS = 24 * 60 * 60 * 1000;
async function buildIosSession(): Promise<NativeProfilerSessionApi> {
const device = { id: "ios-sim", platform: "ios" as const, kind: "simulator" as const };
const instance = await nativeProfilerSessionBlueprint.factory({}, device, { device });
return instance.api;
}
describe("iOS profiler-load → analyze freshness wiring (PR #340 comment 2)", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "argent-ios-load-freshness-"));
mockedGetDebugDir.mockResolvedValue(tempDir);
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
vi.clearAllMocks();
});
it("leaves wallClockStartMs null after restoring an old iOS session (no sidecar exists)", async () => {
const api = await buildIosSession();
// The session API initializes wallClockStartMs to null; a real old session
// captured days ago would be loaded fresh into this state.
expect(api.wallClockStartMs).toBeNull();
// iOS sessions persist only raw_*.xml — there is NO metadata.json sidecar to
// carry a start time, unlike Android's .pftrace.metadata.json.
const cpuXml = join(tempDir, `native-profiler-${SESSION_ID}_raw_cpu.xml`);
await writeFile(cpuXml, "<trace-query-result></trace-query-result>", "utf8");
await profilerLoadTool.execute({ session: api } as never, {
mode: "load_native",
session_id: SESSION_ID,
port: 8081,
device_id: "ios-sim",
});
// The load succeeded and parsed the XML into memory…
expect(api.exportedFiles).toMatchObject({ cpu: cpuXml });
// …but the start time was never restored: still null. THIS is why the
// freshness note is dead on a loaded iOS session.
expect(api.wallClockStartMs).toBeNull();
// Drive analyze on the just-loaded session: the trace is "old" (loaded from
// a 2020 session id) yet NO stale-trace warning is emitted, because the
// start time the helper needs was never persisted.
const { report } = await analyzeNativeProfilerIos(api);
expect(report).not.toContain("Stale trace");
// Sanity: analyze did produce a real report (degrades cleanly, no crash).
expect(report).toContain("iOS Instruments Analysis");
});
it("a live iOS session WITH a start time still flags staleness (wiring is correct, just unreachable via load)", async () => {
const api = await buildIosSession();
// Simulate what native-profiler-start does in-process: stamp the start time.
api.wallClockStartMs = Date.now() - 2 * DAY_MS;
api.traceFile = join(tempDir, "live.trace");
api.exportedFiles = { cpu: null, hangs: null, leaks: null };
const { report } = await analyzeNativeProfilerIos(api);
expect(report).toContain("Stale trace");
});
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;
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, "<trace-query-result></trace-query-result>", "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();
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();
});
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");
// 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 () => {
// 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, "<trace-query-result></trace-query-result>", "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, "<trace-query-result></trace-query-result>", "utf8");
await expect(
profilerLoadTool.execute({ session: api } as never, {
mode: "load_native",
session_id: SESSION_ID,
port: 8081,
device_id: "ios-sim",
})
).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, "<trace-query-result></trace-query-result>", "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);
});
});