mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
82d0209b9e
Fixes #629. ## Reproduced, then diagnosed harder than the report Pixel_9 / API 36, `com.android.settings`, real interaction — all three symptoms reproduced: ``` native-profiler-analyze → 70 hangs + RSS populated, NO CPU section, "showing top 0 CPU hotspots", exportErrors: {} thread_breakdown → 296 samples across 9 threads function_callers epoll_wait → not found ``` The report infers "unsymbolized frames". It's stronger than that — I queried the `.pftrace` directly: | | value | |---|---| | samples for the process | 296 | | samples with a `callsite_id` | **0** | | `stack_profile_frame` rows (trace-wide) | **0** | | `stats.perf_samples_skipped` | **296** | | `unwind_error` | NULL on every row | **The unwinder never ran.** There are no frames to symbolize. That's why `thread_breakdown` — which counts `perf_sample` rows and never joins the symbol tables — reports 296 while every stack-dependent view is empty. ## Positive control: same emulator, debuggable app ``` adb shell getprop ro.build.type → user dumpsys package com.android.settings | grep pkgFlags → [ SYSTEM HAS_CODE … ] ← no DEBUGGABLE dumpsys package com.anonymous.myapp | grep pkgFlags → [ DEBUGGABLE HAS_CODE … ] ``` | | com.android.settings | com.anonymous.myapp (debuggable) | |---|---|---| | samples / with callsite / named leaf | 296 / **0** / **0** | 86 / **86** / **78** | | frames in trace | **0** | **723** | | `perf_samples_skipped` | **296** | **0** | | analyze | no CPU section | `writel` 48.72%, `internal_get_user_pages_fast` 6.41% | So this is **not** an emulator limitation and **not** symbolization — it's target profileability. The remedy the message gives is verified to work on the very device it describes. ## The fix The pipeline detects "samples exist, none carry a usable stack" and says so. It names the count and the package, and distinguishes two causes using **`leaf_mapping`, already on the row** — no new query needed. It comes from the same join chain as `leaf_function`, so a mapping with no name means the stack *was* unwound and only the symbol is missing (stripped `.so`s), while neither means nothing was unwound (not profileable). Different problems, different fixes. Live output on the bad trace: > ⚠️ **CPU sampling:** 296 CPU samples were captured for `com.android.settings`, but none carry a usable call stack, so no CPU hotspots could be computed and `profiler-stack-query` mode=`function_callers` / mode=`hang_stacks` will find nothing. The app was running — this is a capture limitation, not an idle app. mode=`thread_breakdown` still works, since it counts samples and needs no stacks. No stack was unwound at all: Perfetto only unwinds a process it can read `/proc/<pid>/mem` for. Profile a debug build, or add `<profileable android:shell="true"/>` to `<application>` and rebuild — verify with `adb shell dumpsys package com.android.settings`, which shows `DEBUGGABLE` for a profileable target. **Not routed through `exportErrors`.** That looked free — it renders above the Summary with no plumbing — but `renderAllClear` counts its entries as failed queries and would print *"1 of 3 queries errored"* about three queries that all succeeded. It goes through a `cpuDiagnostic` note instead, alongside the existing `freshnessNote`, and `status` stays `ok`. **Also fixed:** the footer no longer says "showing top 0 CPU hotspots" — the literal artifact in the report title. ## What I deliberately did NOT touch The existing no-samples-at-all manifest hint stays gated on `hangRows.length === 0`. That gate is intentional and pinned by `manifest-hint.test.ts:52` ("does NOT emit a manifest hint when hangs were found but CPU was empty") — once hangs come back the app clearly *was* traced, so blaming the manifest is wrong advice. It also wouldn't have helped: in the reported case `cpuRows.length` is **9, not 0**, so state A is never reached. Ungating it would have fired the hint on ~6 more existing fixtures and flipped their `status`, for no benefit. I only corrected its text, which claimed "Perfetto silently drops the linux.perf data source" — this issue proves a non-profileable app still yields samples. ## Verified live, both directions | trace | CPU warning | CPU section | footer | status | |---|---|---|---|---| | stackless (`com.android.settings`) | ✅ shown | absent | `showing top 3 hangs` | `ok`, `exportErrors: {}` | | healthy (debuggable app) | ✅ absent | present | `showing top 2 CPU hotspots and top 3 hangs` | `ok` | ## Deliberately deferred (filed separately) - `thread_breakdown` / `function_callers` notes — the issue's "and ideally" half. Needs a new `thread-breakdown.sql` aggregate plus fixture updates in two test files. - A partially-stackless trace renders percentages that **sum to 100%** while describing only the surviving fraction — arguably more misleading than the empty case here, and a distinct defect. - Repo docs assert the behaviour this PR only now implements. ## Checks - 3093 tests pass; 7 new. **All 200 existing android-perfetto tests pass unmodified**, including the manifest-hint gate. - `tool-description-quality.yml` doesn't run (path-filtered to `src/tools/**`); prettier, eslint, both typechecks clean; lock untouched.
166 lines
5.9 KiB
TypeScript
166 lines
5.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
const queryResponses: Array<{ name: string; rows: unknown[] }> = [];
|
|
|
|
vi.mock("@argent/native-devtools-android", () => {
|
|
const path = require("node:path");
|
|
return {
|
|
ensureTraceProcessorReady: vi.fn(async () => {}),
|
|
traceProcessorQueriesDir: () =>
|
|
path.resolve(__dirname, "../../../native-devtools-android/assets/queries"),
|
|
};
|
|
});
|
|
vi.mock("../../src/utils/android-profiler/pipeline/run-tp", () => ({
|
|
runTpQuery: vi.fn(async (opts: { query: string }) => {
|
|
const next = queryResponses.shift();
|
|
if (!next) throw new Error(`runTpQuery called for "${opts.query}" with no queued response`);
|
|
return next.rows;
|
|
}),
|
|
runTpInline: vi.fn(async () => []),
|
|
}));
|
|
|
|
import { runAndroidProfilerPipeline } from "../../src/utils/android-profiler/pipeline/index";
|
|
|
|
/**
|
|
* A cpu-hotspots.sql row for a thread whose samples carried no usable stack.
|
|
* `leaf_mapping` is what separates "nothing was ever unwound" (null) from
|
|
* "unwound, but no symbol" (a module path).
|
|
*/
|
|
function stacklessRow(threadName: string, sampleCount: number, leafMapping: string | null = null) {
|
|
return {
|
|
thread_name: threadName,
|
|
is_main_thread: 0,
|
|
leaf_function: null,
|
|
leaf_mapping: leafMapping,
|
|
sample_count: sampleCount,
|
|
first_ts_ns: 0,
|
|
last_ts_ns: 1_000_000,
|
|
total_samples: sampleCount,
|
|
burst_windows: "0:1:1",
|
|
};
|
|
}
|
|
|
|
function namedRow(fn: string, sampleCount: number) {
|
|
return {
|
|
thread_name: "RenderThread",
|
|
is_main_thread: 0,
|
|
leaf_function: fn,
|
|
leaf_mapping: "/system/lib64/libhwui.so",
|
|
sample_count: sampleCount,
|
|
first_ts_ns: 0,
|
|
last_ts_ns: 1_000_000,
|
|
total_samples: sampleCount,
|
|
burst_windows: "0:1:1",
|
|
};
|
|
}
|
|
|
|
function queue(cpuRows: unknown[], hangRows: unknown[] = []) {
|
|
queryResponses.push(
|
|
{ name: "trace-bounds.sql", rows: [{ start_ts: 0 }] },
|
|
{ name: "cpu-hotspots.sql", rows: cpuRows },
|
|
{ name: "ui-hangs.sql", rows: hangRows },
|
|
{ name: "memory-rss.sql", rows: [] }
|
|
);
|
|
}
|
|
|
|
describe("Android pipeline — samples captured but no call stacks", () => {
|
|
beforeEach(() => {
|
|
queryResponses.length = 0;
|
|
});
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("explains an empty CPU section instead of leaving it silently absent", async () => {
|
|
// The reported case: perf samples exist for the app (thread_breakdown counts
|
|
// them) but every one is dropped for having no leaf function, so the report
|
|
// read as though the app did no CPU work.
|
|
queue([stacklessRow("RenderThread", 238), stacklessRow("main", 44)]);
|
|
|
|
const result = await runAndroidProfilerPipeline("/fake.pftrace", "com.example.app");
|
|
|
|
expect(result.cpuHotspots).toHaveLength(0);
|
|
expect(result.cpuDiagnostic).toBeDefined();
|
|
expect(result.cpuDiagnostic).toContain("282 CPU samples");
|
|
expect(result.cpuDiagnostic).toContain("com.example.app");
|
|
expect(result.cpuDiagnostic).toContain("not an idle app");
|
|
});
|
|
|
|
it("is not an exportErrors entry, so the report cannot claim a query errored", async () => {
|
|
// exportErrors entries are counted as failed queries by the all-clear
|
|
// banner ("N of 3 queries errored"). Nothing errored here.
|
|
queue([stacklessRow("RenderThread", 100)]);
|
|
|
|
const result = await runAndroidProfilerPipeline("/fake.pftrace", "com.example.app");
|
|
|
|
expect(result.cpuDiagnostic).toBeDefined();
|
|
expect(result.exportErrors).toEqual({});
|
|
});
|
|
|
|
it("blames profileability when no frame was unwound at all", async () => {
|
|
queue([stacklessRow("RenderThread", 296, null)]);
|
|
|
|
const result = await runAndroidProfilerPipeline("/fake.pftrace", "com.example.app");
|
|
|
|
expect(result.cpuDiagnostic).toContain("profileable");
|
|
expect(result.cpuDiagnostic).toContain("dumpsys package com.example.app");
|
|
expect(result.cpuDiagnostic).not.toContain("stripped");
|
|
});
|
|
|
|
it("blames stripped symbols when frames were unwound but unnamed", async () => {
|
|
// A mapping without a name means the stack WAS unwound — only the symbol is
|
|
// missing, which is a different problem with a different fix.
|
|
queue([stacklessRow("RenderThread", 296, "/data/app/lib/arm64/libapp.so")]);
|
|
|
|
const result = await runAndroidProfilerPipeline("/fake.pftrace", "com.example.app");
|
|
|
|
expect(result.cpuDiagnostic).toContain("stripped");
|
|
expect(result.cpuDiagnostic).not.toContain("profileable");
|
|
});
|
|
|
|
it("says nothing when any sample carried a usable stack", async () => {
|
|
// Healthy traces must be untouched — including partially stackless ones,
|
|
// where a kernel leaf routinely fails to resolve.
|
|
queue([namedRow("writel", 38), stacklessRow("main", 5)]);
|
|
|
|
const result = await runAndroidProfilerPipeline("/fake.pftrace", "com.example.app");
|
|
|
|
expect(result.cpuHotspots.length).toBeGreaterThan(0);
|
|
expect(result.cpuDiagnostic).toBeUndefined();
|
|
});
|
|
|
|
it("leaves the no-samples-at-all case to the existing manifest hint", async () => {
|
|
queue([], []);
|
|
|
|
const result = await runAndroidProfilerPipeline("/fake.pftrace", "com.example.app");
|
|
|
|
expect(result.cpuDiagnostic).toBeUndefined();
|
|
expect(result.exportErrors.cpu).toContain("profileable");
|
|
});
|
|
|
|
it("still fires when the trace also has hangs", async () => {
|
|
// The manifest hint is deliberately suppressed once hangs exist, because
|
|
// "your app is not profileable" would be wrong advice for a traced app.
|
|
// This diagnostic describes a different state and must not inherit that gate
|
|
// — a real capture almost always has hangs.
|
|
queue(
|
|
[stacklessRow("RenderThread", 296)],
|
|
[
|
|
{
|
|
kind: "jank",
|
|
ts_ns: 1_000_000_000,
|
|
dur_ns: 40_000_000,
|
|
process_name: "com.example.app",
|
|
reason: "App Deadline Missed",
|
|
error_id: null,
|
|
},
|
|
]
|
|
);
|
|
|
|
const result = await runAndroidProfilerPipeline("/fake.pftrace", "com.example.app");
|
|
|
|
expect(result.cpuDiagnostic).toBeDefined();
|
|
expect(result.exportErrors.cpu).toBeUndefined();
|
|
});
|
|
});
|