Files
software-mansion__argent/packages/tool-server/test/native-profiler-analyze-failure.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

291 lines
11 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.
/**
* Bug 4 — `native-profiler-analyze` must not return "All clear" when every
* trace_processor query failed. The result envelope must carry a machine-
* readable `status` field (`"ok"` vs `"analysis_failed"`) plus the per-query
* `exportErrors` so MCP / CLI callers can tell a truly clean trace apart from
* a run where the analyzer itself blew up.
*
* Shape mirrors `native-profiler-missing-trace.test.ts`: stub the lowest-level
* driver (`runTpQuery`) at the module boundary, then drive the analyze tool
* with a synthetic session whose `exportedFiles.pftrace` points at a real
* (empty-but-readable) file so the existence guard passes.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ArtifactStore } from "@argent/registry";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
// Mocking runTpQuery is enough to control the entire Android pipeline output —
// `runAndroidProfilerPipeline` is just `Promise.allSettled` over three calls
// plus per-hang follow-ups. Any rejected promise becomes an `exportErrors`
// entry; resolved rows feed into the bottleneck transformers.
vi.mock("../src/utils/android-profiler/pipeline/run-tp", () => ({
runTpQuery: vi.fn(),
}));
// The Android pipeline pre-warms the in-process WASM engine up front via
// ensureTraceProcessorReady(). Keep every real export (notably the
// TraceProcessorUnavailableError class the analyze path branches on with
// `instanceof`) but stub the probe so it doesn't boot a real engine —
// individual tests then drive it to resolve (engine ready) or reject
// (wasm-load failure) the banner branch.
vi.mock("@argent/native-devtools-android", async (importOriginal) => {
const actual = await importOriginal<typeof import("@argent/native-devtools-android")>();
return {
...actual,
ensureTraceProcessorReady: vi.fn().mockResolvedValue(undefined),
};
});
import { runTpQuery } from "../src/utils/android-profiler/pipeline/run-tp";
import {
ensureTraceProcessorReady,
TraceProcessorUnavailableError,
} from "@argent/native-devtools-android";
import { nativeProfilerAnalyzeTool } from "../src/tools/profiler/native-profiler/native-profiler-analyze";
import type { NativeProfilerSessionApi } from "../src/blueprints/native-profiler-session";
const runTpQueryMock = runTpQuery as unknown as ReturnType<typeof vi.fn>;
const ensureReadyMock = ensureTraceProcessorReady as unknown as ReturnType<typeof vi.fn>;
interface RunTpQueryOpts {
tracePath: string;
query: string;
substitutions: Record<string, string>;
}
interface QueryRouter {
"trace-bounds.sql"?: unknown[] | (() => Promise<unknown[]>);
"cpu-hotspots.sql"?: unknown[] | (() => Promise<unknown[]>);
"ui-hangs.sql"?: unknown[] | (() => Promise<unknown[]>);
"memory-rss.sql"?: unknown[] | (() => Promise<unknown[]>);
}
function routeQueries(router: QueryRouter): void {
runTpQueryMock.mockImplementation(async (opts: RunTpQueryOpts) => {
const entry = router[opts.query as keyof QueryRouter];
if (entry === undefined) {
throw new Error(`unrecognized option: ${opts.query}`);
}
if (typeof entry === "function") return entry();
return entry;
});
}
async function buildSessionWithTrace(): Promise<{
session: NativeProfilerSessionApi;
cleanup: () => Promise<void>;
}> {
const dir = await mkdtemp(join(tmpdir(), "native-profiler-failure-"));
const tracePath = join(dir, "fake.pftrace");
// Just needs to exist — runTpQuery is mocked, no real parsing happens.
await writeFile(tracePath, "", "utf8");
const session: NativeProfilerSessionApi = {
deviceId: "emulator-5554",
platform: "android",
appProcess: "com.example.app",
capturePid: null,
captureProcess: null,
traceFile: tracePath,
exportedFiles: { pftrace: tracePath },
profilingActive: false,
wallClockStartMs: null,
parsedData: null,
cpuFilterPid: null,
recordingMallocStackLogging: null,
mallocStackLogging: null,
recordingTimeout: null,
recordingTimedOut: false,
recordingExitedUnexpectedly: false,
lastExitInfo: null,
androidOnDeviceTracePath: null,
};
return {
session,
cleanup: () => rm(dir, { recursive: true, force: true }),
};
}
describe("native-profiler-analyze: status + exportErrors envelope (Bug 4)", () => {
beforeEach(() => {
runTpQueryMock.mockReset();
// Default: the engine loads fine. The banner test overrides this.
ensureReadyMock.mockReset();
ensureReadyMock.mockResolvedValue(undefined);
});
it("status=ok with empty exportErrors when all queries succeed and find nothing", async () => {
const { session, cleanup } = await buildSessionWithTrace();
try {
// Pipeline guards against a "blank" trace by synthesising a
// `no CPU samples` exportError when BOTH cpu and hang queries return
// zero rows (see pipeline/index.ts: the `<profileable>` hint). To test
// the truly-clean case we feed one CPU row whose leaf_function is
// null — the aggregator drops it, so we get bottlenecksTotal === 0
// without tripping the fallback.
routeQueries({
"trace-bounds.sql": [{ start_ts: 0 }],
"cpu-hotspots.sql": [
{
thread_name: "main",
is_main_thread: 1,
leaf_function: null,
sample_count: 0,
first_ts_ns: 0,
last_ts_ns: 0,
burst_windows: null,
total_samples: 0,
},
],
"ui-hangs.sql": [],
"memory-rss.sql": [],
});
const result = await nativeProfilerAnalyzeTool.execute(
{ session },
{ device_id: "emulator-5554" },
{ artifacts: new ArtifactStore() }
);
expect(result.status).toBe("ok");
expect(result.exportErrors).toEqual({});
expect(result.bottlenecksTotal).toBe(0);
// Renderer keeps the verbatim "All clear" sentence on the happy path.
expect(result.report).toContain(
"All clear — no CPU hotspots, UI hangs, or memory issues detected."
);
expect(result.report).not.toContain("Analysis failed");
} finally {
await cleanup();
}
});
it("status=analysis_failed with per-query errors when every query rejects, and no 'All clear'", async () => {
const { session, cleanup } = await buildSessionWithTrace();
try {
// trace-bounds.sql failure is swallowed by `getTraceStartNs` (returns 0)
// so it does NOT land in exportErrors — that's by design. The three
// pipeline queries are the ones the user-facing failure banner tracks.
const reject = (q: string) => () =>
Promise.reject(new Error(`unrecognized option: --query-output=json (${q})`));
routeQueries({
"trace-bounds.sql": reject("trace-bounds.sql"),
"cpu-hotspots.sql": reject("cpu-hotspots.sql"),
"ui-hangs.sql": reject("ui-hangs.sql"),
"memory-rss.sql": reject("memory-rss.sql"),
});
const result = await nativeProfilerAnalyzeTool.execute(
{ session },
{ device_id: "emulator-5554" },
{ artifacts: new ArtifactStore() }
);
expect(result.status).toBe("analysis_failed");
expect(result.bottlenecksTotal).toBe(0);
expect(Object.keys(result.exportErrors).sort()).toEqual(["cpu", "hangs", "rss"]);
expect(result.exportErrors.cpu).toMatch(/unrecognized option/);
expect(result.exportErrors.hangs).toMatch(/unrecognized option/);
expect(result.exportErrors.rss).toMatch(/unrecognized option/);
// The misleading "All clear" line must not appear.
expect(result.report).not.toContain(
"All clear — no CPU hotspots, UI hangs, or memory issues detected."
);
// The new banner must appear, plus the existing Export-warnings block.
expect(result.report).toContain("Analysis failed");
expect(result.report).toContain("Export warnings");
} finally {
await cleanup();
}
});
it("status=analysis_failed (partial) when only some queries fail but others return data", async () => {
const { session, cleanup } = await buildSessionWithTrace();
try {
// CPU + hangs succeed, rss fails. The renderer's full-report path will
// still produce a `## Summary` table because bottlenecksTotal > 0; the
// envelope's `status` flips to analysis_failed regardless.
routeQueries({
"trace-bounds.sql": [{ start_ts: 0 }],
"cpu-hotspots.sql": [
{
thread_name: "main",
is_main_thread: 1,
leaf_function: "doFrame",
sample_count: 50,
first_ts_ns: 0,
last_ts_ns: 500_000_000,
// SQL-side burst windows: one burst, ms 0–20, 3 samples.
burst_windows: "0:20:3",
total_samples: 500,
},
],
"ui-hangs.sql": [],
"memory-rss.sql": () =>
Promise.reject(new Error("unrecognized option: --query-output=json")),
});
const result = await nativeProfilerAnalyzeTool.execute(
{ session },
{ device_id: "emulator-5554" },
{ artifacts: new ArtifactStore() }
);
expect(result.status).toBe("analysis_failed");
expect(Object.keys(result.exportErrors)).toEqual(["rss"]);
expect(result.bottlenecksTotal).toBeGreaterThan(0);
// Both the Export-warnings block AND the normal Summary table must
// appear — the report is informative, not just a banner.
expect(result.report).toContain("Export warnings");
expect(result.report).toContain("## Summary");
// No misleading "All clear" + no full-failure banner (because we have
// real bottlenecks, the renderer never reaches the all-clear branch).
expect(result.report).not.toContain(
"All clear — no CPU hotspots, UI hangs, or memory issues detected."
);
expect(result.report).not.toContain("Analysis failed");
} finally {
await cleanup();
}
});
it("renders the engine-load banner when the bundled WASM engine fails to load", async () => {
const { session, cleanup } = await buildSessionWithTrace();
try {
// Probe fails: the bundled trace_processor.wasm couldn't load. This is a
// SEPARATE branch from per-query failures — it must surface as a prominent
// banner, NOT folded into exportErrors / "Export warnings".
ensureReadyMock.mockRejectedValue(
new TraceProcessorUnavailableError("wasm_load_failed", {
version: "v55.3",
})
);
const result = await nativeProfilerAnalyzeTool.execute(
{ session },
{ device_id: "emulator-5554" },
{ artifacts: new ArtifactStore() }
);
expect(result.status).toBe("analysis_failed");
expect(result.bottlenecksTotal).toBe(0);
// Empty exportErrors → the banner is the whole story, no per-query noise.
expect(result.exportErrors).toEqual({});
// The actionable recovery guidance must appear front-and-centre…
expect(result.report).toContain("Cannot Run");
expect(result.report).toContain("ARGENT_TRACE_PROCESSOR_WASM");
// …and the per-query "Export warnings" block must NOT.
expect(result.report).not.toContain("Export warnings");
// The queries are never even attempted once the probe rejects.
expect(runTpQueryMock).not.toHaveBeenCalled();
} finally {
await cleanup();
}
});
});