mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
b773bad5e2
## Problem
The Perfetto WASM decoder in `packages/native-devtools-android` returns
integer columns as `bigint` and only down-converts to `Number` when the
value is safe-integer-sized. From `readCell` in
`wasm-trace-processor.ts`:
```ts
if (typeof v === "bigint") {
// ... keep the bigint only for the rare unsafe-range value.
const n = Number(v);
return Number.isSafeInteger(n) ? n : v;
}
```
Native `CLOCK_MONOTONIC` timestamps exceed `2^53` ns after roughly 104
days of device/emulator uptime, so the absolute-time columns (`ts_ns`,
`first_ts_ns`, `last_ts_ns`) then arrive as `bigint` instead of
`number`. Meanwhile `getTraceStartNs()` already coerces
`trace_bounds.start_ts` to a plain `Number`.
The Android profiler pipeline then mixes the two — e.g. `row.first_ts_ns
- traceStartNs` and `Math.round((row.first_ts_ns - traceStartNs) /
1_000_000)` — which throws:
```
TypeError: Cannot mix BigInt and other types, use explicit conversions
```
The row TypeScript types annotate these columns as `number`, so the
compiler can't catch it, and `analyzeNativeProfilerAndroid`'s catch only
handles `TraceProcessorUnavailableError`. The result: a freshly captured
trace on a long-uptime device fails with a cryptic error instead of
producing a report.
## Repro
Feeding `start_ts` (Number-coerced) and
`ts_ns`/`first_ts_ns`/`last_ts_ns` as `bigint` greater than `2^53`
through `runAndroidProfilerPipeline` throws at `pipeline/index.ts:535`
(`cpuRowsToAggregatorRows`) / `:546` (`hangRowsToBottlenecks`). See the
new regression test, which fails before this change with the `TypeError`
above.
## Fix
Coerce the decoded absolute native-ns row fields with `Number(...)`
before any arithmetic that involves the already-Number `traceStartNs`
(matching what `getTraceStartNs` does). The downstream
`Math.round`/format logic is unchanged, and safe-range traces behave
identically. Sites fixed in
`packages/tool-server/src/utils/android-profiler/pipeline/index.ts`:
- `cpuRowsToAggregatorRows` — `first_ts_ns` / `last_ts_ns` offset
against `traceStartNs`.
- `hangRowsToBottlenecks` — `ts_ns` offset against `traceStartNs`, plus
`dur_ns` in `durationMs` and `endNs`.
- `renderHangStacksAndroid` (hang drill-down) — `ts_ns + dur_ns` and the
`durationMs` round.
Duration-only columns that never approach `2^53` ns (state/GC durations)
are left as-is; the GC fold path already normalises through
`toFiniteNumber`.
## Test
New `packages/tool-server/test/android-profiler-bigint.test.ts` drives
the real pipeline with `bigint` `ts_ns`/`first_ts_ns`/`last_ts_ns`
values greater than `2^53` (mocking only the WASM query layer, same
pattern as `pipeline-offset.test.ts`). It fails before the fix (the
documented `TypeError`) and passes after. Full tool-server suite: 1622
tests pass; `tsc --build` and `eslint` clean.
253 lines
10 KiB
TypeScript
253 lines
10 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
const queryResponses: Array<{ name: string; rows: unknown[] }> = [];
|
|
let inlineResponse: 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", async (importActual) => ({
|
|
...(await importActual<typeof import("../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`);
|
|
if (next.name !== opts.query) throw new Error(`expected "${next.name}" got "${opts.query}"`);
|
|
return next.rows;
|
|
}),
|
|
runTpInline: vi.fn(async () => inlineResponse),
|
|
}));
|
|
import {
|
|
runAndroidProfilerPipeline,
|
|
runAndroidStackQuery,
|
|
} from "../src/utils/android-profiler/pipeline/index";
|
|
const TRACE_START_NS = 9_100_000_000_000_000n; // ~105 days uptime, > 2^53
|
|
describe("android profiler pipeline handles bigint native-ns columns", () => {
|
|
beforeEach(() => {
|
|
queryResponses.length = 0;
|
|
inlineResponse = [];
|
|
});
|
|
it("does not throw on a long-uptime (bigint ts) trace", async () => {
|
|
expect(Number.isSafeInteger(Number(TRACE_START_NS))).toBe(false);
|
|
queryResponses.push(
|
|
{ name: "trace-bounds.sql", rows: [{ start_ts: TRACE_START_NS }] },
|
|
{
|
|
name: "cpu-hotspots.sql",
|
|
rows: [
|
|
{
|
|
thread_name: "main",
|
|
is_main_thread: 1,
|
|
leaf_function: "doFrame",
|
|
leaf_mapping: "/system/lib64/libhwui.so",
|
|
sample_count: 50,
|
|
first_ts_ns: TRACE_START_NS + 1_000_000_000n,
|
|
last_ts_ns: TRACE_START_NS + 2_000_000_000n,
|
|
burst_windows: null,
|
|
total_samples: 50,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "ui-hangs.sql",
|
|
rows: [
|
|
{
|
|
kind: "jank",
|
|
ts_ns: TRACE_START_NS + 1_000_000_000n,
|
|
dur_ns: 500_000_000,
|
|
process_name: "com.example.app",
|
|
reason: "App Deadline Missed",
|
|
error_id: null,
|
|
},
|
|
],
|
|
},
|
|
{ name: "memory-rss.sql", rows: [] }
|
|
);
|
|
const result = await runAndroidProfilerPipeline("/fake.pftrace", "com.example.app");
|
|
expect(result.uiHangs).toHaveLength(1);
|
|
expect(result.cpuHotspots.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("does not throw classifying severity for a bigint dur_ns hang with a combined reason", async () => {
|
|
// classifyAndroidHangSeverity's own `row.dur_ns / 1_000_000` division is
|
|
// only reached when `reason` isn't the exact "App Deadline Missed" string
|
|
// (that branch short-circuits earlier) and `kind` isn't "anr" — a combined
|
|
// reason like Perfetto's comma-joined form exercises it.
|
|
queryResponses.push(
|
|
{ name: "trace-bounds.sql", rows: [{ start_ts: TRACE_START_NS }] },
|
|
{ name: "cpu-hotspots.sql", rows: [] },
|
|
{
|
|
name: "ui-hangs.sql",
|
|
rows: [
|
|
{
|
|
kind: "jank",
|
|
ts_ns: TRACE_START_NS + 1_000_000_000n,
|
|
// Forced > 2^53 bigint (mechanical guard). dur_ns is a bounded
|
|
// duration, not uptime-scaled — TRACE_START_NS is reused here only
|
|
// as a convenient > 2^53 value to exercise the coercion.
|
|
dur_ns: TRACE_START_NS,
|
|
process_name: "com.example.app",
|
|
reason: "Prediction Error, App Deadline Missed",
|
|
error_id: null,
|
|
},
|
|
],
|
|
},
|
|
{ name: "memory-rss.sql", rows: [] }
|
|
);
|
|
const result = await runAndroidProfilerPipeline("/fake.pftrace", "com.example.app");
|
|
expect(result.uiHangs).toHaveLength(1);
|
|
expect(result.uiHangs[0]!.severity).toBe("RED");
|
|
});
|
|
});
|
|
|
|
describe("android stack drill-down handles bigint native-ns columns", () => {
|
|
beforeEach(() => {
|
|
queryResponses.length = 0;
|
|
inlineResponse = [];
|
|
});
|
|
|
|
it("renders the hang-stacks drill-down for a bigint ts_ns/dur_ns hang without throwing", async () => {
|
|
// renderHangStacksAndroid reads the hang's own ts_ns/dur_ns and does
|
|
// `startNs + dur_ns` and `dur_ns / 1_000_000`. ts_ns is an absolute
|
|
// CLOCK_MONOTONIC value that crosses 2^53 after ~104 days of uptime, so it
|
|
// decodes as bigint on a long-uptime device (see readCell). dur_ns is a
|
|
// bounded duration (the ANR length or longest janky-frame slice), NOT
|
|
// uptime-scaled — it would exceed 2^53 only if a single hang lasted ~104
|
|
// days, so the bigint dur_ns forced below is a mechanical guard, not a
|
|
// realistic value. readCell keeps any > 2^53 cell as bigint regardless, so
|
|
// without the Number() coercions the arithmetic throws "Cannot mix BigInt
|
|
// and other types". state total_dur_ns is a hang-window-clipped duration sum
|
|
// (hang-state-breakdown.sql) that stays well under 2^53 in practice, but is
|
|
// now coerced defensively too — the second state row below forces a bigint
|
|
// to prove that read is robust.
|
|
const HANG_TS_NS = TRACE_START_NS + 1_000_000_000n; // absolute, > 2^53
|
|
// Mechanical guard: forced > 2^53 so it decodes as bigint. A real hang dur
|
|
// is ms-scale; this value is not physically realistic.
|
|
const HANG_DUR_NS = 9_500_000_000_000_000n;
|
|
expect(Number.isSafeInteger(Number(HANG_TS_NS))).toBe(false);
|
|
expect(Number.isSafeInteger(Number(HANG_DUR_NS))).toBe(false);
|
|
const expectedDurationMs = Math.round(Number(HANG_DUR_NS) / 1_000_000);
|
|
|
|
queryResponses.push(
|
|
{
|
|
name: "ui-hangs.sql",
|
|
rows: [
|
|
{
|
|
kind: "jank",
|
|
ts_ns: HANG_TS_NS,
|
|
dur_ns: HANG_DUR_NS,
|
|
process_name: "com.example.app",
|
|
reason: "App Deadline Missed",
|
|
error_id: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "hang-state-breakdown.sql",
|
|
rows: [
|
|
{
|
|
state: "Uninterruptible Sleep",
|
|
blocked_function: "do_page_fault",
|
|
total_dur_ns: 400_000_000, // Number: clipped duration sum, < 2^53
|
|
occurrences: 3,
|
|
},
|
|
{
|
|
// Defensive: force a bigint total_dur_ns (> 2^53). It can't happen
|
|
// for a clipped sum in practice, but the read is coerced anyway, so
|
|
// this must render rather than throw "Cannot mix BigInt".
|
|
state: "Runnable",
|
|
blocked_function: null,
|
|
total_dur_ns: 9_600_000_000_000_000n,
|
|
occurrences: 1,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "hang-main-thread-samples.sql",
|
|
rows: [{ ts_ns: HANG_TS_NS + 500_000_000n, callstack_text: "main <- onDraw <- inflate" }],
|
|
}
|
|
);
|
|
|
|
const out = await runAndroidStackQuery({
|
|
tracePath: "/fake.pftrace",
|
|
mode: "hang_stacks",
|
|
appPackage: "com.example.app",
|
|
hangIndex: 0,
|
|
topN: 15,
|
|
});
|
|
|
|
// (b) correct millisecond math from the bigint dur_ns in the header, and the
|
|
// state row's Number ns → ms conversion, both survive the coercion.
|
|
expect(out).toContain(`## Hang #0 — jank (${expectedDurationMs}ms)`);
|
|
expect(out).toContain("reason: `App Deadline Missed`");
|
|
expect(out).toContain("| Uninterruptible Sleep | `do_page_fault` | 400ms |");
|
|
// The bigint total_dur_ns row rendered (coerced) instead of throwing.
|
|
expect(out).toContain(
|
|
`| Runnable | — | ${Math.round(Number(9_600_000_000_000_000n) / 1_000_000)}ms |`
|
|
);
|
|
expect(out).toContain("### Main-thread Samples During Hang");
|
|
expect(out).toContain("main <- onDraw <- inflate");
|
|
});
|
|
|
|
it("renders the no-samples blocking fallback with a bigint state total_dur_ns without throwing", async () => {
|
|
// The `uniqueStacks.size === 0` fallback (main thread off-CPU: no on-CPU
|
|
// stack samples) reuses the same coerced stateBreakdown as the table, and
|
|
// summarizeHangBlocking sorts it by durationMs. With no samples, the
|
|
// hang-main-thread-samples.sql result is empty, so the fallback branch runs
|
|
// — the one previously uncovered by the drill-down test above, which always
|
|
// supplied a sample. Force a bigint state total_dur_ns to prove the fallback
|
|
// path coerces it instead of throwing "Cannot mix BigInt and other types".
|
|
const HANG_TS_NS = TRACE_START_NS + 1_000_000_000n; // absolute, > 2^53
|
|
const STATE_DUR_NS = 9_600_000_000_000_000n; // mechanical > 2^53 guard
|
|
expect(Number.isSafeInteger(Number(STATE_DUR_NS))).toBe(false);
|
|
const expectedStateMs = Math.round(Number(STATE_DUR_NS) / 1_000_000);
|
|
|
|
queryResponses.push(
|
|
{
|
|
name: "ui-hangs.sql",
|
|
rows: [
|
|
{
|
|
kind: "jank",
|
|
ts_ns: HANG_TS_NS,
|
|
dur_ns: 500_000_000, // realistic ms-scale hang duration
|
|
process_name: "com.example.app",
|
|
reason: "App Deadline Missed",
|
|
error_id: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "hang-state-breakdown.sql",
|
|
rows: [
|
|
{
|
|
state: "Uninterruptible Sleep",
|
|
blocked_function: "do_page_fault",
|
|
total_dur_ns: STATE_DUR_NS,
|
|
occurrences: 3,
|
|
},
|
|
],
|
|
},
|
|
// No usable main-thread samples → uniqueStacks stays empty → the off-CPU
|
|
// "blocked" fallback branch runs.
|
|
{ name: "hang-main-thread-samples.sql", rows: [] }
|
|
);
|
|
|
|
const out = await runAndroidStackQuery({
|
|
tracePath: "/fake.pftrace",
|
|
mode: "hang_stacks",
|
|
appPackage: "com.example.app",
|
|
hangIndex: 0,
|
|
topN: 15,
|
|
});
|
|
|
|
// The state table rendered the bigint total_dur_ns (coerced), and the
|
|
// off-CPU fallback message names the dominant blocking state that
|
|
// summarizeHangBlocking derived from the same coerced breakdown.
|
|
expect(out).toContain(`| Uninterruptible Sleep | \`do_page_fault\` | ${expectedStateMs}ms |`);
|
|
expect(out).toContain("(state `Uninterruptible Sleep`, sleeping/blocked)");
|
|
// Fallback path, not the sample-stacks path: no folded stack blocks.
|
|
expect(out).not.toContain("main <- onDraw <- inflate");
|
|
});
|
|
});
|