Files
software-mansion__argent/packages/tool-server/test/profiler-combined-leaks.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

168 lines
7.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Combined-report leak honesty.
*
* The combined report (React × native cross-correlation) must apply the SAME
* attribution split as the standalone iOS analyze report: unattributed leaks
* (`<Call stack limit reached>` under `xctrace --attach`) are benign system
* noise and must NOT be rendered as ordinary per-leak rows. They are collapsed
* into a single low-confidence YELLOW caveat; only attributed leaks (with a
* resolved responsible frame) appear individually.
*
* Regression guard for the flat "wall of leaks" the combined report used to
* emit, which contradicted the analyze report and masqueraded benign
* allocations as confirmed app leaks.
*/
import { describe, it, expect } from "vitest";
import { renderCombinedMemoryLeaks } from "../src/tools/profiler/combined/profiler-combined-report";
import type { MemoryLeak } from "../src/utils/profiler-shared/types";
function leak(partial: Partial<MemoryLeak> & Pick<MemoryLeak, "objectType">): MemoryLeak {
const attributed = partial.attributed ?? false;
return {
type: "memory_leak",
platform: "ios",
objectType: partial.objectType,
totalSizeBytes: partial.totalSizeBytes ?? 16,
count: partial.count ?? 1,
responsibleFrame: partial.responsibleFrame ?? "<Call stack limit reached>",
responsibleLibrary: partial.responsibleLibrary ?? "",
attributed,
severity: partial.severity ?? (attributed ? "RED" : "YELLOW"),
};
}
const ATTRIBUTED = leak({
objectType: "MyModel",
totalSizeBytes: 384,
count: 3,
responsibleFrame: "-[MyViewController loadData]",
responsibleLibrary: "MyApp",
attributed: true,
severity: "RED",
});
const UNATTRIBUTED = leak({
objectType: "dispatch_mach_msg_t",
totalSizeBytes: 1024,
count: 2,
responsibleFrame: "<Call stack limit reached>",
attributed: false,
severity: "YELLOW",
});
describe("renderCombinedMemoryLeaks", () => {
it("collapses an unattributed leak group into a YELLOW caveat — never a per-leak row", () => {
const out = renderCombinedMemoryLeaks([UNATTRIBUTED], new Set()).join("\n");
// It must NOT render the unattributed leak as a normal leak-table row.
expect(out).not.toContain("**`dispatch_mach_msg_t`**");
// Instead it shows the single low-confidence YELLOW caveat.
expect(out).toContain("🟡");
expect(out).toContain("1 unattributed leak group(s)");
expect(out).toContain("`<Call stack limit reached>`");
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");
});
it("lists an attributed leak as its own row, with the unattributed ones still collapsed", () => {
const out = renderCombinedMemoryLeaks([ATTRIBUTED, UNATTRIBUTED], new Set()).join("\n");
// Attributed leak is listed individually.
expect(out).toContain("**`MyModel`**");
expect(out).toContain("-[MyViewController loadData]");
// Unattributed leak is collapsed, not a per-leak row.
expect(out).not.toContain("**`dispatch_mach_msg_t`**");
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");
});
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");
});
it("lists ALL attributed leaks individually — no cap — matching the analyze report", () => {
// 12 distinct attributed (RED) leaks: more than the old hardcoded cap of 10.
const attributed = Array.from({ length: 12 }, (_, i) =>
leak({
objectType: `LeakModel${i}`,
totalSizeBytes: 128 + i,
count: i + 1,
responsibleFrame: `-[Leaker${i} retainSomething]`,
responsibleLibrary: "MyApp",
attributed: true,
severity: "RED",
})
);
const out = renderCombinedMemoryLeaks(attributed, new Set()).join("\n");
// Every one of the 12 attributed leaks must be rendered as its own row.
for (let i = 0; i < 12; i++) {
expect(out).toContain(`**\`LeakModel${i}\`**`);
expect(out).toContain(`-[Leaker${i} retainSomething]`);
}
// Count the rendered per-leak rows: exactly 12, not capped at 10.
const rowCount = out.split("\n").filter((l) => /^- \*\*`LeakModel\d+`\*\*/.test(l)).length;
expect(rowCount).toBe(12);
});
it("returns nothing for an empty leak list", () => {
expect(renderCombinedMemoryLeaks([], new Set())).toEqual([]);
});
});