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.
291 lines
11 KiB
TypeScript
291 lines
11 KiB
TypeScript
/**
|
||
* 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();
|
||
}
|
||
});
|
||
});
|