Files
filip131311 82d0209b9e fix(android-profiler): explain an empty CPU section instead of rendering it as an idle app (#669)
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.
2026-08-18 16:58:35 +02:00

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();
});
});