mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
a5c6730d05
## 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.
168 lines
7.1 KiB
TypeScript
168 lines
7.1 KiB
TypeScript
/**
|
||
* 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([]);
|
||
});
|
||
});
|