Files
software-mansion__argent/packages/tool-server/test/ios-instruments/leak-attribution-render.test.ts
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

165 lines
7.6 KiB
TypeScript

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. Attribution is driven by the leak's
// `attributed` flag (set in the pipeline via isLeakAttributed): captures via
// `xctrace --attach` have no malloc-stack history, so a leak with no resolved
// responsible frame comes back unattributed and is demoted to a low-confidence
// note rather than listed like a real app bug.
function leak(attributed: boolean, frame: string, library = ""): MemoryLeak {
return {
type: "memory_leak",
platform: "ios",
objectType: "Malloc 1008 Bytes",
totalSizeBytes: 1008,
count: 1,
responsibleFrame: frame,
responsibleLibrary: library,
attributed,
severity: attributed ? "RED" : "YELLOW",
};
}
function payload(leaks: MemoryLeak[]): ProfilerPayload {
return {
metadata: { traceFile: null, platform: "iOS", timestamp: "2026-06-16T00:00:00Z" },
bottlenecks: leaks,
};
}
describe("leak attribution rendering", () => {
it("collapses unattributed leaks into a low-confidence note pointing at malloc stack logging", async () => {
const res = await renderNativeProfilerReport({
payload: payload([leak(false, "<Call stack limit reached>")]),
traceFile: null,
});
expect(res.report).toContain("unattributed leak group");
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, "<Call stack limit reached>"),
]),
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("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, "<Call stack limit reached>")]),
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("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, "<Call stack limit reached>"),
]),
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, "<Call stack limit reached>")]),
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, "<Call stack limit reached>"),
]),
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;
// a non-sentinel frame like "(null)" classifies as attributed (RED).
const res = await renderNativeProfilerReport({
payload: payload([leak(false, "")]),
traceFile: null,
});
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<char const*>, folly::Range<char const*>)";
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(/(?<!\\)\|/).length;
expect(unescapedPipes(row!)).toBe(unescapedPipes(header!));
});
it("shows the real frame + library for an attributed leak", async () => {
const frame = "hermes::vm::JSTypedArrayBase::createBuffer(...)";
const res = await renderNativeProfilerReport({
payload: payload([leak(true, frame, "hermes")]),
traceFile: null,
});
expect(res.report).toContain(frame);
expect(res.report).toContain("hermes");
expect(res.report).not.toContain("unattributed leak group");
});
});