ui: Heap Dump Explorer flamegraph diff with pprof-style coloring Add baseline / diff mode to the Heap Dump Explorer. Load a second trace (or pick a second dump from the same trace) as a baseline and compare it against the current dump across every tab: - Overview diff, and a Flamegraph diff coloured pprof-style on the signed, normalised delta (green = smaller, grey = unchanged, red = larger; absolute or relative basis per metric) with a matching legend. - Diff grids for Classes, Objects, Dominators, Bitmaps, Strings and Arrays, plus a per-object diff view. - A top-bar RadioGroup toggles Diff / Current / Baseline. Same-trace diffs run as a single SQL JOIN against android_heap_graph_*; cross-trace diffs pair objects in JS across the two trace_processor engines (NUL-free dense path ids, so the keys are portable to the native engine) and write the paired rows back into the current engine. Pooled baseline engines are torn down via the trace's trash on close. Rebased onto current upstream/main, which moved the explorer onto a store-backed session with a persisted_state permalink model after this work was first written. The diff state is re-ported onto that model: object tabs serialize their (currentId, baselineId) pair, the page keeps a content-keyed tab map plus an (engine, filter)-keyed overview cache for the baseline and current sides, and all page-level caches reset per trace load. Adds a persisted_state round-trip unit test and fixes stale mode-toggle selectors in the e2e tests (the widget is a RadioGroup, not SegmentedButtons).
diff --git a/docs/heap_diff/README.md b/docs/heap_diff/README.md new file mode 100644 index 0000000..86b3b37 --- /dev/null +++ b/docs/heap_diff/README.md
@@ -0,0 +1,142 @@ +# Heap Dump Explorer — Diff feature + +End-to-end screenshots of every diff view in the Heap Dump Explorer. + +## Two diff modes + +The Heap Dump Explorer supports two diff workflows: + +1. **Same-trace diff** — two heap dumps in one trace. Activates the + *flamegraph diff* path with palette-modulated colouring (the + flamegraph is rendered with the standard per-name palette hues, but + each node is darkened / saturated for *grew*, lightened / desaturated + for *shrank*, kept neutral for *unchanged*, and pop-saturated for + *new*). + +2. **Cross-trace diff** — two separate traces (or hprofs) loaded as + primary + baseline. The Overview, Classes, Objects, Dominators, + Bitmaps, Strings and Arrays tabs show diff columns; the flamegraph + stays in single-engine mode because each trace has its own SQLite + engine. + +Status of each row is colour-coded: <span style="color:#c62828">**GREW +/ NEW**</span> (red), <span style="color:#1565c0">**SHRANK / GONE**</span> +(blue), gray for unchanged. + +## Same-trace fixture (synthetic, two snapshots in one pftrace) + +Built by `tools/heap_dump_diff_test_app/build_rich_fixture.py` → +`test/data/heap_diff_multi.pftrace`. ~1400 objects across two +Android-app-shaped snapshots: dump 1 has a busy UI (4 activities × 3 +fragments × …); dump 2 has a quieter UI but heavier background services +and network connections. Some classes appear (`NewlyAddedClass`), +disappear (`RemovedClass`), grow, shrink or stay flat. + +### Overview tab + +Reachable instances and bytes-retained-by-heap with Δ columns. + + + +### Flamegraph tab + +Same-trace flamegraph diff, palette colours preserved per class, +saturation / lightness modulated by Δ direction. UI sub-tree (left: +Application → Activity → Fragment → ViewHolder → TextView / ImageView → +String / Bitmap) reads as vivid (grew). Service sub-tree (right: +ServiceManager → BackgroundService → Worker → Task) reads as faded +(shrank). + + + +### Classes tab + +Per-class diff breakdown. Status pills (GREW / SHRANK / NEW / GONE) and +signed deltas next to baseline / current values. + + + +### Objects tab + + + +### Dominators tab + +Dominator-tree retained-size diff per root class (size, count, native +size, retained obj count — all four columns are diffable). + + + +### Bitmaps tab + + + +### Strings tab + + + +### Arrays tab + + + +### Primary-dump popup + +The primary-dump selector showing the "Diff against this dump" section. +Selecting another dump from the same trace activates the same-trace +flamegraph diff. + + + +## Cross-trace fixture (real JVM hprofs) + +Built by running the Java app at +`tools/heap_dump_diff_test_app/HeapDumpDiffTest.java` against a 1-GB +JVM. The app constructs an object graph with deep reference chains +(Application → ActivityManager → … → byte[]), then calls +`HotSpotDiagnosticMXBean.dumpHeap` twice with different scales between +the two dumps. The resulting `current.hprof` (smaller, services-heavy) +is opened as the primary trace and `baseline.hprof` (larger, UI-heavy) +is loaded as the cross-trace baseline. + +### Overview (cross-trace) + + + +### Classes (cross-trace) + + + +### Dominators (cross-trace) + + + +### Strings (cross-trace) + + + +### Arrays (cross-trace) + + + +## Reproducing locally + +```sh +# Capture two real hprofs. +cd tools/heap_dump_diff_test_app +javac HeapDumpDiffTest.java +java -Xmx1g HeapDumpDiffTest baseline.hprof current.hprof + +# Build the same-trace synthetic fixture. +python3 build_rich_fixture.py +out/ui/protoc --encode=perfetto.protos.Trace -I . protos/perfetto/trace/trace.proto \ + < /tmp/hprof_test/multi_dump_rich.textproto \ + > test/data/heap_diff_multi.pftrace + +# Serve the UI. +cd ui && pnpm install && node build.js --serve --watch + +# In another shell, run the playwright e2e suites. +cd ui && pnpm exec playwright test src/test/heap_dump_flamegraph_diff.test.ts +cd ui && pnpm exec playwright test src/test/heap_dump_diff.test.ts +cd ui && pnpm exec playwright test src/test/heap_dump_diff_hprof_primary.test.ts +```
diff --git a/docs/heap_diff/screenshots/01_overview_diff.png b/docs/heap_diff/screenshots/01_overview_diff.png new file mode 100644 index 0000000..4d235eb --- /dev/null +++ b/docs/heap_diff/screenshots/01_overview_diff.png Binary files differ
diff --git a/docs/heap_diff/screenshots/02_flamegraph_diff.png b/docs/heap_diff/screenshots/02_flamegraph_diff.png new file mode 100644 index 0000000..e6cd4d2 --- /dev/null +++ b/docs/heap_diff/screenshots/02_flamegraph_diff.png Binary files differ
diff --git a/docs/heap_diff/screenshots/03_classes_diff.png b/docs/heap_diff/screenshots/03_classes_diff.png new file mode 100644 index 0000000..3be457b --- /dev/null +++ b/docs/heap_diff/screenshots/03_classes_diff.png Binary files differ
diff --git a/docs/heap_diff/screenshots/04_objects_diff.png b/docs/heap_diff/screenshots/04_objects_diff.png new file mode 100644 index 0000000..2d0fa5c --- /dev/null +++ b/docs/heap_diff/screenshots/04_objects_diff.png Binary files differ
diff --git a/docs/heap_diff/screenshots/05_dominators_diff.png b/docs/heap_diff/screenshots/05_dominators_diff.png new file mode 100644 index 0000000..1f5a37c --- /dev/null +++ b/docs/heap_diff/screenshots/05_dominators_diff.png Binary files differ
diff --git a/docs/heap_diff/screenshots/06_bitmaps_diff.png b/docs/heap_diff/screenshots/06_bitmaps_diff.png new file mode 100644 index 0000000..0fd759c --- /dev/null +++ b/docs/heap_diff/screenshots/06_bitmaps_diff.png Binary files differ
diff --git a/docs/heap_diff/screenshots/07_strings_diff.png b/docs/heap_diff/screenshots/07_strings_diff.png new file mode 100644 index 0000000..d6d6dc0 --- /dev/null +++ b/docs/heap_diff/screenshots/07_strings_diff.png Binary files differ
diff --git a/docs/heap_diff/screenshots/08_arrays_diff.png b/docs/heap_diff/screenshots/08_arrays_diff.png new file mode 100644 index 0000000..4957e71 --- /dev/null +++ b/docs/heap_diff/screenshots/08_arrays_diff.png Binary files differ
diff --git a/docs/heap_diff/screenshots/09_popup.png b/docs/heap_diff/screenshots/09_popup.png new file mode 100644 index 0000000..64b23bd --- /dev/null +++ b/docs/heap_diff/screenshots/09_popup.png Binary files differ
diff --git a/docs/heap_diff/screenshots/cross_trace/01_overview_diff.png b/docs/heap_diff/screenshots/cross_trace/01_overview_diff.png new file mode 100644 index 0000000..6aadc0e --- /dev/null +++ b/docs/heap_diff/screenshots/cross_trace/01_overview_diff.png Binary files differ
diff --git a/docs/heap_diff/screenshots/cross_trace/02_flamegraph_diff.png b/docs/heap_diff/screenshots/cross_trace/02_flamegraph_diff.png new file mode 100644 index 0000000..0bf7bb8 --- /dev/null +++ b/docs/heap_diff/screenshots/cross_trace/02_flamegraph_diff.png Binary files differ
diff --git a/docs/heap_diff/screenshots/cross_trace/03_classes_diff.png b/docs/heap_diff/screenshots/cross_trace/03_classes_diff.png new file mode 100644 index 0000000..c71b408 --- /dev/null +++ b/docs/heap_diff/screenshots/cross_trace/03_classes_diff.png Binary files differ
diff --git a/docs/heap_diff/screenshots/cross_trace/04_objects_diff.png b/docs/heap_diff/screenshots/cross_trace/04_objects_diff.png new file mode 100644 index 0000000..8d4b487 --- /dev/null +++ b/docs/heap_diff/screenshots/cross_trace/04_objects_diff.png Binary files differ
diff --git a/docs/heap_diff/screenshots/cross_trace/05_dominators_diff.png b/docs/heap_diff/screenshots/cross_trace/05_dominators_diff.png new file mode 100644 index 0000000..a95c087 --- /dev/null +++ b/docs/heap_diff/screenshots/cross_trace/05_dominators_diff.png Binary files differ
diff --git a/docs/heap_diff/screenshots/cross_trace/06_bitmaps_diff.png b/docs/heap_diff/screenshots/cross_trace/06_bitmaps_diff.png new file mode 100644 index 0000000..bcfa2c0 --- /dev/null +++ b/docs/heap_diff/screenshots/cross_trace/06_bitmaps_diff.png Binary files differ
diff --git a/docs/heap_diff/screenshots/cross_trace/07_strings_diff.png b/docs/heap_diff/screenshots/cross_trace/07_strings_diff.png new file mode 100644 index 0000000..794901a --- /dev/null +++ b/docs/heap_diff/screenshots/cross_trace/07_strings_diff.png Binary files differ
diff --git a/docs/heap_diff/screenshots/cross_trace/08_arrays_diff.png b/docs/heap_diff/screenshots/cross_trace/08_arrays_diff.png new file mode 100644 index 0000000..62e8bae --- /dev/null +++ b/docs/heap_diff/screenshots/cross_trace/08_arrays_diff.png Binary files differ
diff --git a/tools/heap_dump_diff_test_app/HeapDumpDiffTest.java b/tools/heap_dump_diff_test_app/HeapDumpDiffTest.java new file mode 100644 index 0000000..47f7c6b --- /dev/null +++ b/tools/heap_dump_diff_test_app/HeapDumpDiffTest.java
@@ -0,0 +1,357 @@ +import com.sun.management.HotSpotDiagnosticMXBean; +import java.io.File; +import java.io.IOException; +import java.lang.management.ManagementFactory; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import javax.management.MBeanServer; + +/** + * Java app that builds a rich, layered heap (Android-app-shaped) and + * captures two hprof snapshots — one of the "busy UI" state, one of the + * "background-services-heavy" state. + * + * Usage: + * javac HeapDumpDiffTest.java + * java -Xmx512m HeapDumpDiffTest baseline.hprof current.hprof + * + * The app builds: + * Application + * ActivityManager + * Activity* + * FragmentManager + * Fragment* + * ViewHolder* + * TextView* -> String* + * ImageView* -> Bitmap* + * ViewModel + * DataCache -> CacheEntry -> byte[] + * ApiClient -> HttpResponse -> byte[] + * BackStack -> BackStackEntry* + * ServiceManager + * BackgroundService* + * Worker -> Task -> TaskData -> byte[] + * -> Scheduler -> ScheduledTask* + * EventBus -> EventListener* + * ConnectivityService -> ConnectionPool -> Connection -> SocketBuffer + * + * Between dumps, the UI shrinks (user closed activities) while background + * services and network connections grow (long-running jobs accumulated). + */ +public class HeapDumpDiffTest { + + // Root reference holder so nothing is reclaimed before dumpHeap runs. + static Application app; + + public static void main(String[] args) throws IOException, InterruptedException { + if (args.length < 2) { + System.err.println( + "Usage: HeapDumpDiffTest <baseline.hprof> <current.hprof>"); + System.exit(1); + } + + // ---- Phase 1: busy UI, light background, light network --------- + app = new Application(); + app.buildState(/*activities=*/ 4, /*fragmentsPerActivity=*/ 3, + /*holdersPerFragment=*/ 4, + /*textviewsPerHolder=*/ 2, /*stringsPerTextview=*/ 2, + /*imageviewsPerHolder=*/ 1, /*bitmapsPerImageview=*/ 2, + /*cachesPerVm=*/ 2, /*entriesPerCache=*/ 4, + /*apisPerVm=*/ 1, /*respsPerApi=*/ 2, + /*backstackEntries=*/ 3, + /*bgServices=*/ 2, /*workersPerService=*/ 2, + /*tasksPerWorker=*/ 2, /*scheduledPerScheduler=*/ 2, + /*listenersPerEventBus=*/ 3, + /*connections=*/ 2, /*buffersPerConnection=*/ 2); + // Add some RemovedClass instances that go away in dump 2. + for (int i = 0; i < 8; i++) { + app.removed.add(new RemovedClass(i)); + } + System.gc(); + Thread.sleep(500); + dumpHeap(args[0]); + System.out.println("Wrote baseline: " + args[0]); + + // ---- Phase 2: UI mostly closed, services accumulated ------------ + // Reset to a fresh graph with the second profile. Building a fresh + // graph keeps each dump's structure clean and avoids leftover + // sub-trees from phase 1 distorting the diff. + app = new Application(); + app.buildState(/*activities=*/ 2, /*fragmentsPerActivity=*/ 2, + /*holdersPerFragment=*/ 4, + /*textviewsPerHolder=*/ 2, /*stringsPerTextview=*/ 2, + /*imageviewsPerHolder=*/ 1, /*bitmapsPerImageview=*/ 4, + /*cachesPerVm=*/ 1, /*entriesPerCache=*/ 2, + /*apisPerVm=*/ 2, /*respsPerApi=*/ 4, + /*backstackEntries=*/ 2, + /*bgServices=*/ 3, /*workersPerService=*/ 4, + /*tasksPerWorker=*/ 3, /*scheduledPerScheduler=*/ 4, + /*listenersPerEventBus=*/ 5, + /*connections=*/ 4, /*buffersPerConnection=*/ 4); + // RemovedClass is gone; NewlyAddedClass is here. + for (int i = 0; i < 6; i++) { + app.newlyAdded.add(new NewlyAddedClass(i)); + } + System.gc(); + Thread.sleep(500); + dumpHeap(args[1]); + System.out.println("Wrote current: " + args[1]); + } + + static void dumpHeap(String path) throws IOException { + File f = new File(path); + if (f.exists() && !f.delete()) { + throw new IOException("Could not delete existing " + path); + } + MBeanServer server = ManagementFactory.getPlatformMBeanServer(); + HotSpotDiagnosticMXBean bean = ManagementFactory.newPlatformMXBeanProxy( + server, + "com.sun.management:type=HotSpotDiagnostic", + HotSpotDiagnosticMXBean.class); + bean.dumpHeap(path, /*live=*/ true); + } + + // ----- Object graph nodes -------------------------------------------- + // + // Each class is a real Java type so it shows up in the hprof with its + // own classloader entry. Field references hold children alive between + // dumps so the dominator tree has interesting structure. + + static class Application { + ActivityManager activityManager = new ActivityManager(); + ServiceManager serviceManager = new ServiceManager(); + List<RemovedClass> removed = new ArrayList<>(); + List<NewlyAddedClass> newlyAdded = new ArrayList<>(); + + void buildState(int activities, int fragmentsPerActivity, + int holdersPerFragment, int textviewsPerHolder, + int stringsPerTextview, int imageviewsPerHolder, + int bitmapsPerImageview, int cachesPerVm, + int entriesPerCache, int apisPerVm, int respsPerApi, + int backstackEntries, int bgServices, + int workersPerService, int tasksPerWorker, + int scheduledPerScheduler, int listenersPerEventBus, + int connections, int buffersPerConnection) { + for (int a = 0; a < activities; a++) { + Activity act = new Activity("activity-" + a); + activityManager.activities.add(act); + for (int f = 0; f < fragmentsPerActivity; f++) { + Fragment frag = new Fragment("fragment-" + a + "-" + f); + act.fragmentManager.fragments.add(frag); + for (int h = 0; h < holdersPerFragment; h++) { + ViewHolder vh = new ViewHolder(); + frag.holders.add(vh); + for (int t = 0; t < textviewsPerHolder; t++) { + TextView tv = new TextView(); + vh.textViews.add(tv); + for (int s = 0; s < stringsPerTextview; s++) { + tv.strings.add(UUID.randomUUID().toString()); + } + } + for (int i = 0; i < imageviewsPerHolder; i++) { + ImageView iv = new ImageView(); + vh.imageViews.add(iv); + for (int b = 0; b < bitmapsPerImageview; b++) { + iv.bitmaps.add(new Bitmap(64 * 1024)); + } + } + } + for (int c = 0; c < cachesPerVm; c++) { + DataCache dc = new DataCache(); + frag.viewModel.caches.add(dc); + for (int e = 0; e < entriesPerCache; e++) { + CacheEntry ce = new CacheEntry(); + ce.payload = new byte[8 * 1024]; + dc.entries.add(ce); + } + } + for (int p = 0; p < apisPerVm; p++) { + ApiClient api = new ApiClient(); + frag.viewModel.apis.add(api); + for (int r = 0; r < respsPerApi; r++) { + HttpResponse resp = new HttpResponse(); + resp.body = new byte[16 * 1024]; + api.responses.add(resp); + } + } + for (int e = 0; e < backstackEntries; e++) { + act.fragmentManager.backStack.entries.add( + new BackStackEntry("entry-" + e)); + } + } + } + for (int s = 0; s < bgServices; s++) { + BackgroundService svc = new BackgroundService("svc-" + s); + serviceManager.services.add(svc); + for (int w = 0; w < workersPerService; w++) { + Worker worker = new Worker(); + svc.workers.add(worker); + for (int t = 0; t < tasksPerWorker; t++) { + Task task = new Task(); + task.data = new TaskData(); + task.data.payload = new byte[2 * 1024]; + worker.tasks.add(task); + } + for (int sc = 0; sc < scheduledPerScheduler; sc++) { + worker.scheduler.scheduled.add( + new ScheduledTask(sc)); + } + } + for (int l = 0; l < listenersPerEventBus; l++) { + svc.eventBus.listeners.add(new EventListener(l)); + } + } + for (int c = 0; c < connections; c++) { + Connection conn = new Connection(); + serviceManager.connectivity.connectionPool.connections.add(conn); + for (int b = 0; b < buffersPerConnection; b++) { + SocketBuffer sb = new SocketBuffer(); + sb.payload = new byte[32 * 1024]; + conn.buffers.add(sb); + } + } + } + } + + static class ActivityManager { + List<Activity> activities = new ArrayList<>(); + } + + static class Activity { + String name; + FragmentManager fragmentManager = new FragmentManager(); + Activity(String n) { this.name = n; } + } + + static class FragmentManager { + List<Fragment> fragments = new ArrayList<>(); + BackStack backStack = new BackStack(); + } + + static class Fragment { + String tag; + List<ViewHolder> holders = new ArrayList<>(); + ViewModel viewModel = new ViewModel(); + Fragment(String t) { this.tag = t; } + } + + static class BackStack { + List<BackStackEntry> entries = new ArrayList<>(); + } + + static class BackStackEntry { + String name; + BackStackEntry(String n) { this.name = n; } + } + + static class ViewHolder { + List<TextView> textViews = new ArrayList<>(); + List<ImageView> imageViews = new ArrayList<>(); + } + + static class TextView { + List<String> strings = new ArrayList<>(); + } + + static class ImageView { + List<Bitmap> bitmaps = new ArrayList<>(); + } + + static class Bitmap { + byte[] pixels; + Bitmap(int n) { this.pixels = new byte[n]; } + } + + static class ViewModel { + List<DataCache> caches = new ArrayList<>(); + List<ApiClient> apis = new ArrayList<>(); + } + + static class DataCache { + List<CacheEntry> entries = new ArrayList<>(); + } + + static class CacheEntry { + byte[] payload; + } + + static class ApiClient { + List<HttpResponse> responses = new ArrayList<>(); + } + + static class HttpResponse { + byte[] body; + } + + static class ServiceManager { + List<BackgroundService> services = new ArrayList<>(); + ConnectivityService connectivity = new ConnectivityService(); + } + + static class BackgroundService { + String name; + List<Worker> workers = new ArrayList<>(); + EventBus eventBus = new EventBus(); + BackgroundService(String n) { this.name = n; } + } + + static class Worker { + List<Task> tasks = new ArrayList<>(); + Scheduler scheduler = new Scheduler(); + } + + static class Task { + TaskData data; + } + + static class TaskData { + byte[] payload; + } + + static class Scheduler { + List<ScheduledTask> scheduled = new ArrayList<>(); + } + + static class ScheduledTask { + int delayMs; + ScheduledTask(int s) { this.delayMs = s; } + } + + static class EventBus { + List<EventListener> listeners = new ArrayList<>(); + } + + static class EventListener { + int id; + EventListener(int i) { this.id = i; } + } + + static class ConnectivityService { + ConnectionPool connectionPool = new ConnectionPool(); + } + + static class ConnectionPool { + List<Connection> connections = new ArrayList<>(); + } + + static class Connection { + List<SocketBuffer> buffers = new ArrayList<>(); + } + + static class SocketBuffer { + byte[] payload; + } + + static class RemovedClass { + int seed; + RemovedClass(int s) { this.seed = s; } + } + + static class NewlyAddedClass { + int seed; + NewlyAddedClass(int s) { this.seed = s; } + } +}
diff --git a/tools/heap_dump_diff_test_app/README.md b/tools/heap_dump_diff_test_app/README.md new file mode 100644 index 0000000..f765283 --- /dev/null +++ b/tools/heap_dump_diff_test_app/README.md
@@ -0,0 +1,47 @@ +# Heap Dump Explorer test fixtures + +Two ways to produce a rich heap-dump fixture for the Heap Dump Explorer +diff feature. + +## 1. Java app + JVM hprof + +`HeapDumpDiffTest.java` builds an Android-app-shaped object graph (deep, +branchy: Application → ActivityManager → Activity → … → byte[]) and +captures two `.hprof` snapshots via `HotSpotDiagnosticMXBean.dumpHeap`. + +Between dumps the UI shrinks (user closed activities) and background +services / network connections grow, so most class branches show up +either as GROW or SHRANK in the diff view. Two classes appear and +disappear (`NewlyAddedClass` / `RemovedClass`) for the NEW / GONE +states. + +```sh +cd tools/heap_dump_diff_test_app +javac HeapDumpDiffTest.java +java -Xmx1g HeapDumpDiffTest baseline.hprof current.hprof +``` + +Both `.hprof` files load directly in Perfetto UI (open one as the +primary trace, the other as the baseline via the Heap Dump Explorer's +"Diff against another trace…" CTA). Cross-trace diff exercises the +Overview, Classes, Objects, Dominators, Bitmaps, Strings and Arrays tab +diffs. + +## 2. Synthetic same-trace fixture + +The same-trace flamegraph diff path (the one that shows palette-modulated +red / blue colours on the flamegraph nodes) requires *two heap dumps in +one trace*. JVM hprofs always carry a single dump, so for that path we +build a synthetic two-snapshot pftrace from a textproto. + +```sh +cd tools/heap_dump_diff_test_app +python3 build_rich_fixture.py +out/ui/protoc --encode=perfetto.protos.Trace -I . protos/perfetto/trace/trace.proto \ + < /tmp/hprof_test/multi_dump_rich.textproto \ + > test/data/heap_diff_multi.pftrace +``` + +The resulting `heap_diff_multi.pftrace` has the same Android-app-shaped +graph at two different times (busy UI → background-services-heavy) so +the same-trace flamegraph diff fires.
diff --git a/tools/heap_dump_diff_test_app/build_rich_fixture.py b/tools/heap_dump_diff_test_app/build_rich_fixture.py new file mode 100644 index 0000000..bafbb1c --- /dev/null +++ b/tools/heap_dump_diff_test_app/build_rich_fixture.py
@@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +""" +Build a rich multi-dump heap graph fixture with deep reference chains so +the flamegraph diff exposes interesting structure across many levels. + +Two snapshots of the same process (pid 2, "system_server") at ts=100 and +ts=2_000_000_100. Between snapshots: + + * Activity / Fragment counts shrink (user closed UI). + * Background-service Workers grow (long-running jobs accumulated). + * DataCache / CacheEntry shrink (eviction). + * Bitmap / SocketBuffer grow (more in-flight network responses). + * NewlyAddedClass appears in dump 2. + * RemovedClass disappears. + +The class tree is ~10 levels deep, with branching across UI and Service +sub-trees, so the flamegraph has plenty of stacks at every metric (Object +Size, Object Count, Dominated Object Size, Dominated Object Count). +""" + +import os +import textwrap + +# Class catalogue. id -> (class_name, object_size). Stable across dumps. +CLASSES = [ + (1, 'java.lang.Object', 16), + (2, 'android.app.Application', 64), + (3, 'android.app.ActivityManager', 64), + (4, 'android.app.Activity', 128), + (5, 'android.app.FragmentManager', 64), + (6, 'android.app.Fragment', 96), + (7, 'android.app.BackStack', 32), + (8, 'android.app.BackStackEntry', 48), + (9, 'android.view.ViewHolder', 64), + (10, 'android.widget.TextView', 96), + (11, 'android.widget.ImageView', 96), + (12, 'android.graphics.Bitmap', 256), + (13, 'java.lang.String', 40), + (14, 'androidx.lifecycle.ViewModel', 80), + (15, 'com.example.DataCache', 64), + (16, 'com.example.CacheEntry', 48), + (17, 'byte[]', 32), # array; size is metadata, real bytes per-instance + (18, 'com.example.ApiClient', 64), + (19, 'com.example.HttpResponse', 64), + (20, 'android.app.ServiceManager', 64), + (21, 'com.example.BackgroundService', 64), + (22, 'com.example.Worker', 64), + (23, 'com.example.Task', 48), + (24, 'com.example.TaskData', 48), + (25, 'com.example.Scheduler', 32), + (26, 'com.example.ScheduledTask', 48), + (27, 'com.example.EventBus', 32), + (28, 'com.example.EventListener', 48), + (29, 'android.net.ConnectivityService', 64), + (30, 'android.net.ConnectionPool', 32), + (31, 'android.net.Connection', 64), + (32, 'android.net.SocketBuffer', 192), + (33, 'com.example.RemovedClass', 64), # only dump 1 + (34, 'com.example.NewlyAddedClass', 64), # only dump 2 +] + +# Class-name → id reverse for ergonomic node creation. +CLS = {name: id_ for id_, name, _ in CLASSES} +# Self-size lookup. +SIZE = {id_: sz for id_, _, sz in CLASSES} + + +class Graph: + """Builds an object graph for one heap dump. + + Each node has a unique numeric id. Edges are labeled with field name + "ref" (field_name_id=1). The roots list holds the ROOT_JAVA_FRAME + object ids.""" + + def __init__(self, base): + self.base = base + self.next_id = base + self.objects = [] # list of (id, type_id, self_size, [child_ids]) + self.roots = [] + + def new(self, type_id, self_size=None, root=False): + oid = self.next_id + self.next_id += 1 + self.objects.append([oid, type_id, self_size or SIZE[type_id], []]) + if root: + self.roots.append(oid) + return oid + + def link(self, parent_id, child_id): + for o in self.objects: + if o[0] == parent_id: + o[3].append(child_id) + return + raise KeyError(parent_id) + + +def build_app_graph(g, scale): + """Build a layered Android-app-style object graph rooted at Application. + + `scale` is a dict that lets each named branch grow/shrink between + dumps without touching this code. Higher numbers → more instances + (and therefore wider flamegraph for that branch). + """ + app = g.new(CLS['android.app.Application'], root=True) + + # ---- UI subtree ------------------------------------------------------- + am = g.new(CLS['android.app.ActivityManager']) + g.link(app, am) + for _ in range(scale['activities']): + act = g.new(CLS['android.app.Activity']) + g.link(am, act) + fm = g.new(CLS['android.app.FragmentManager']) + g.link(act, fm) + for _ in range(scale['fragments_per_activity']): + frag = g.new(CLS['android.app.Fragment']) + g.link(fm, frag) + for _ in range(scale['holders_per_fragment']): + vh = g.new(CLS['android.view.ViewHolder']) + g.link(frag, vh) + for _ in range(scale['textviews_per_holder']): + tv = g.new(CLS['android.widget.TextView']) + g.link(vh, tv) + for _ in range(scale['strings_per_textview']): + s = g.new(CLS['java.lang.String']) + g.link(tv, s) + for _ in range(scale['imageviews_per_holder']): + iv = g.new(CLS['android.widget.ImageView']) + g.link(vh, iv) + for _ in range(scale['bitmaps_per_imageview']): + b = g.new(CLS['android.graphics.Bitmap']) + g.link(iv, b) + vm = g.new(CLS['androidx.lifecycle.ViewModel']) + g.link(frag, vm) + for _ in range(scale['caches_per_viewmodel']): + dc = g.new(CLS['com.example.DataCache']) + g.link(vm, dc) + for _ in range(scale['entries_per_cache']): + ce = g.new(CLS['com.example.CacheEntry']) + g.link(dc, ce) + ba = g.new(CLS['byte[]'], self_size=128) + g.link(ce, ba) + for _ in range(scale['apis_per_viewmodel']): + api = g.new(CLS['com.example.ApiClient']) + g.link(vm, api) + for _ in range(scale['resps_per_api']): + r = g.new(CLS['com.example.HttpResponse']) + g.link(api, r) + ba = g.new(CLS['byte[]'], self_size=512) + g.link(r, ba) + bs = g.new(CLS['android.app.BackStack']) + g.link(fm, bs) + for _ in range(scale['backstack_entries']): + bse = g.new(CLS['android.app.BackStackEntry']) + g.link(bs, bse) + + # ---- Service subtree ------------------------------------------------- + sm = g.new(CLS['android.app.ServiceManager']) + g.link(app, sm) + for _ in range(scale['bgservices']): + svc = g.new(CLS['com.example.BackgroundService']) + g.link(sm, svc) + for _ in range(scale['workers_per_service']): + w = g.new(CLS['com.example.Worker']) + g.link(svc, w) + for _ in range(scale['tasks_per_worker']): + t = g.new(CLS['com.example.Task']) + g.link(w, t) + td = g.new(CLS['com.example.TaskData']) + g.link(t, td) + ba = g.new(CLS['byte[]'], self_size=64) + g.link(td, ba) + sched = g.new(CLS['com.example.Scheduler']) + g.link(w, sched) + for _ in range(scale['scheduled_per_scheduler']): + st = g.new(CLS['com.example.ScheduledTask']) + g.link(sched, st) + eb = g.new(CLS['com.example.EventBus']) + g.link(svc, eb) + for _ in range(scale['listeners_per_eventbus']): + el = g.new(CLS['com.example.EventListener']) + g.link(eb, el) + cs = g.new(CLS['android.net.ConnectivityService']) + g.link(sm, cs) + cp = g.new(CLS['android.net.ConnectionPool']) + g.link(cs, cp) + for _ in range(scale['connections']): + c = g.new(CLS['android.net.Connection']) + g.link(cp, c) + for _ in range(scale['buffers_per_connection']): + sb = g.new(CLS['android.net.SocketBuffer']) + g.link(c, sb) + + +# Profile for dump 1 (busy UI, lighter network). +SCALE_1 = dict( + activities=4, + fragments_per_activity=3, + holders_per_fragment=4, + textviews_per_holder=2, + strings_per_textview=2, + imageviews_per_holder=1, + bitmaps_per_imageview=2, + caches_per_viewmodel=2, + entries_per_cache=4, + apis_per_viewmodel=1, + resps_per_api=2, + backstack_entries=3, + bgservices=2, + workers_per_service=2, + tasks_per_worker=2, + scheduled_per_scheduler=2, + listeners_per_eventbus=3, + connections=2, + buffers_per_connection=2, +) +# Profile for dump 2 — UI mostly closed, services accumulated, network heavy. +SCALE_2 = dict( + activities=2, # ↓ user closed two activities + fragments_per_activity=2, # ↓ fewer fragments + holders_per_fragment=4, + textviews_per_holder=2, + strings_per_textview=2, + imageviews_per_holder=1, + bitmaps_per_imageview=4, # ↑ bigger image cache held longer + caches_per_viewmodel=1, # ↓ caches evicted + entries_per_cache=2, # ↓ evicted further + apis_per_viewmodel=2, # ↑ more api clients + resps_per_api=4, # ↑ many in-flight responses + backstack_entries=2, + bgservices=3, # ↑ extra service started + workers_per_service=4, # ↑ workers piled up + tasks_per_worker=3, # ↑ tasks queued + scheduled_per_scheduler=4, # ↑ schedule grew + listeners_per_eventbus=5, # ↑ more subscribers + connections=4, # ↑ extra outgoing connections + buffers_per_connection=4, # ↑ traffic up +) + + +def emit_classes(out): + out.append( + ' location_names {\n iid: 1\n str: "/system/framework/test.apk"\n }' + ) + out.append(' field_names {\n iid: 1\n str: "ref"\n }') + for id_, name, size in CLASSES: + # Make every concrete class extend Object for a one-deep type + # hierarchy — keeps trace_processor happy. + sup = '\n superclass_id: 1' if id_ != 1 else '' + out.append( + textwrap.dedent(f''' + types {{ + id: {id_} + class_name: "{name}" + location_id: 1 + object_size: {size}{sup} + }}''').strip()) + + +def emit_dump(g, ts, dump_label): + """Emit one heap_graph packet.""" + out = [] + out.append('# === ' + dump_label + ' ===') + out.append('packet {') + out.append(f' trusted_packet_sequence_id: 999') + out.append(f' timestamp: {ts}') + out.append(' incremental_state_cleared: true') + out.append(' heap_graph {') + out.append(' pid: 2') + cls_lines = [] + emit_classes(cls_lines) + out.append(textwrap.indent('\n'.join(cls_lines), ' ')) + # Roots + out.append(' roots {') + out.append(' root_type: ROOT_JAVA_FRAME') + for rid in g.roots: + out.append(f' object_ids: {rid}') + out.append(' }') + # Objects + for oid, type_id, self_size, kids in g.objects: + out.append(' objects {') + out.append(f' id: {oid}') + out.append(f' type_id: {type_id}') + out.append(f' self_size: {self_size}') + for _ in kids: + out.append(f' reference_field_id: 1') + for kid in kids: + out.append(f' reference_object_id: {kid}') + out.append(' }') + out.append(' continued: false') + out.append(' index: 0') + out.append(' }') + out.append('}') + return '\n'.join(out) + + +def main(): + out = [] + out.append('packet {') + out.append(' process_tree {') + out.append(' processes {') + out.append(' pid: 2') + out.append(' ppid: 1') + out.append(' cmdline: "system_server"') + out.append(' uid: 1000') + out.append(' }') + out.append(' }') + out.append('}') + + # --- Dump 1 --- + g1 = Graph(base=0x1000) + build_app_graph(g1, SCALE_1) + # Add RemovedClass instances dangling off the app for "GONE in dump 2" demo. + app1 = g1.roots[0] + for _ in range(8): + rc = g1.new(CLS['com.example.RemovedClass']) + g1.link(app1, rc) + out.append(emit_dump(g1, ts=100, dump_label='DUMP 1 (initial)')) + + # --- Dump 2 --- + g2 = Graph(base=0x10000) + build_app_graph(g2, SCALE_2) + # Add NewlyAddedClass instances for "NEW in dump 2". + app2 = g2.roots[0] + for _ in range(6): + nc = g2.new(CLS['com.example.NewlyAddedClass']) + g2.link(app2, nc) + out.append(emit_dump(g2, ts=2_000_000_100, dump_label='DUMP 2 (later)')) + + txt = '\n'.join(out) + '\n' + + out_path = '/tmp/hprof_test/multi_dump_rich.textproto' + with open(out_path, 'w') as f: + f.write(txt) + print( + f'wrote {out_path} ({len(txt)} bytes, {len(g1.objects)+len(g2.objects)} objects)' + ) + + +if __name__ == '__main__': + main()
diff --git a/ui/src/components/query_flamegraph.ts b/ui/src/components/query_flamegraph.ts index e891113..7701284 100644 --- a/ui/src/components/query_flamegraph.ts +++ b/ui/src/components/query_flamegraph.ts
@@ -118,6 +118,12 @@ // // Examples include marking inlined functions, optimized code, etc. readonly optionalMarker?: FlamegraphOptionalMarker; + + // When true, the metric's SQL is expected to project a `color_hint` + // column (CSS color string — `hsl(...)`, `#rgb`, `#rrggbb`). The widget + // uses it instead of the default name-hash palette, with intensity-coded + // colors. Used by diff flamegraphs to color nodes by delta direction. + readonly colorHint?: boolean; } export interface MetricsFromTableOrSubqueryOptions { @@ -263,6 +269,7 @@ optionalNodeActions, optionalRootActions, optionalMarker, + colorHint: colorHintEnabled, }: QueryFlamegraphMetric, {filters, view}: FlamegraphState, ): Promise<FlamegraphQueryData> { @@ -287,7 +294,22 @@ const agg = aggregatableProperties ?? []; const aggCols = agg.map((x) => x.name); - const unagg = unaggregatableProperties ?? []; + // When the metric opts into colorHint, auto-add `color_hint` as a + // hidden unaggregatable property so the layout macro projects it + // through. The render path lifts it out of `properties` onto + // FlamegraphNode.colorHint and never shows it in the tooltip. + const unagg: QueryFlamegraphColumn[] = [ + ...(unaggregatableProperties ?? []), + ...(colorHintEnabled + ? [ + { + name: 'color_hint', + displayName: 'Color', + isVisible: () => false, + } as QueryFlamegraphColumn, + ] + : []), + ]; const unaggCols = unagg.map((x) => x.name); const matchingColumns = ['name', ...unaggCols]; @@ -557,6 +579,12 @@ marker = optionalMarker.name; } + let colorHint: string | undefined; + if (colorHintEnabled) { + const p = properties.get('color_hint'); + if (p !== undefined) colorHint = p.value; + properties.delete('color_hint'); + } nodes.push({ id: it.id, parentId: it.parentId, @@ -569,6 +597,7 @@ xEnd: it.xEnd, properties, marker, + colorHint, }); if (it.depth === 1) { postiveRootsValue += it.cumulativeValue;
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/baseline/load_action.ts b/ui/src/plugins/com.android.HeapDumpExplorer/baseline/load_action.ts new file mode 100644 index 0000000..5247aca --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/baseline/load_action.ts
@@ -0,0 +1,98 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Module-level load state shared between the top-bar header and the Overview +// "Load baseline" button. + +import m from 'mithril'; +import type {Raf} from '../../../public/raf'; +import {BaselineLoadError, loadBaseline} from './loader'; +import {addBaselineTrace, getActiveBaseline, setActiveBaseline} from './state'; + +interface LoadState { + loading: boolean; + progressBytes: number; + progressTotal: number; + error: string | null; + /** Counter to make engine ids unique within the page. */ + nextId: number; +} + +const state: LoadState = { + loading: false, + progressBytes: 0, + progressTotal: 0, + error: null, + nextId: 1, +}; + +export interface LoadStateView { + readonly loading: boolean; + readonly progressPct: number; + readonly error: string | null; +} + +export function getLoadState(): LoadStateView { + const pct = + state.progressTotal > 0 + ? Math.round((state.progressBytes / state.progressTotal) * 100) + : 0; + return {loading: state.loading, progressPct: pct, error: state.error}; +} + +export function clearLoadError(): void { + if (state.error !== null) { + state.error = null; + m.redraw(); + } +} + +export async function triggerFileLoad(raf: Raf, file: File): Promise<void> { + state.loading = true; + state.progressBytes = 0; + state.progressTotal = file.size; + state.error = null; + m.redraw(); + try { + const result = await loadBaseline({ + file, + raf, + engineId: `heapdump-baseline-${state.nextId++}`, + onProgress: (p) => { + state.progressBytes = p.bytesRead; + state.progressTotal = p.bytesTotal; + m.redraw(); + }, + }); + const trace = addBaselineTrace( + result.engine, + result.filename, + result.dumps, + ); + // Auto-pick when the trace has a single dump and nothing's selected yet. + if (getActiveBaseline() === null && trace.dumps.length === 1) { + setActiveBaseline({trace, dump: trace.dumps[0]}); + } + } catch (err) { + if (err instanceof BaselineLoadError) { + state.error = err.message; + } else { + state.error = `Unexpected error: ${err instanceof Error ? err.message : String(err)}`; + console.error('Baseline load failed:', err); + } + } finally { + state.loading = false; + m.redraw(); + } +}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/baseline/loader.ts b/ui/src/plugins/com.android.HeapDumpExplorer/baseline/loader.ts new file mode 100644 index 0000000..f5b5b98 --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/baseline/loader.ts
@@ -0,0 +1,207 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Loads a baseline trace into a fresh plugin-owned WasmEngineProxy. +// Minimal subset of core/load_trace.ts: construct engine, reset, stream +// the file, probe for heap_graph_object rows; reject non-heap traces. +// Skips track/timeline machinery — heap SQL only. + +import { + LONG, + NUM, + NUM_NULL, + STR_NULL, +} from '../../../trace_processor/query_result'; +import {WasmEngineProxy} from '../../../trace_processor/wasm_engine_proxy'; +import type {Raf} from '../../../public/raf'; +import type {HeapDump} from '../queries'; + +// Mirrors core/trace_stream.ts:TRACE_SLICE_SIZE. +const CHUNK_SIZE = 32 * 1024 * 1024; + +export interface LoadedBaseline { + readonly engine: WasmEngineProxy; + readonly filename: string; + // Sorted ascending by graph_sample_ts. + readonly dumps: ReadonlyArray<HeapDump>; +} + +export interface LoadProgress { + readonly bytesRead: number; + readonly bytesTotal: number; +} + +export interface LoadOptions { + readonly file: File; + readonly raf: Raf; + readonly engineId: string; + readonly onProgress?: (p: LoadProgress) => void; +} + +/** + * Errors raised by `loadBaseline` when the input file is unsuitable. Caught + * and rendered inline by header.ts; the engine has been disposed before the + * throw so no worker leaks. + */ +export class BaselineLoadError extends Error { + constructor( + message: string, + /** True for "user picked the wrong file" — render gently. */ + readonly userFacing: boolean = true, + ) { + super(message); + this.name = 'BaselineLoadError'; + } +} + +// On any failure the engine is disposed before this returns / throws. +export async function loadBaseline(opts: LoadOptions): Promise<LoadedBaseline> { + const {file, raf, engineId, onProgress} = opts; + + const engine = new WasmEngineProxy(engineId); + engine.onResponseReceived = () => raf.scheduleFullRedraw(); + + try { + engine.resetTraceProcessor({ + tokenizeOnly: false, + cropTrackEvents: false, + ingestFtraceInRawTable: false, + analyzeTraceProtoContent: false, + ftraceDropUntilAllCpusValid: false, + extraParsingDescriptors: [], + forceFullSort: false, + }); + + await streamFileIntoEngine(file, engine, onProgress); + await engine.notifyEof(); + + const probe = await engine.query( + 'SELECT count(*) AS cnt FROM heap_graph_object LIMIT 1', + ); + const cnt = probe.firstRow({cnt: NUM}).cnt; + if (cnt === 0) { + throw new BaselineLoadError( + 'Selected file has no Java heap data. Diff mode requires a trace ' + + 'with an android.java_hprof or hprof heap dump.', + ); + } + + const dumps = await enumerateDumps(engine); + if (dumps.length === 0) { + throw new BaselineLoadError( + 'Selected file has heap data but no associated process metadata.', + ); + } + + // Pre-warm the stdlib materializations the cross-trace diff tabs + // need. The first query into these heavy `CREATE PERFETTO TABLE` + // views pays an O(heap) build cost (~1.5–2 s on a 60 MB hprof); + // subsequent queries are instant filters. Fire-and-forget here so + // the user can navigate Overview / Flamegraph while the build runs + // in the worker — by the time they click Classes or Dominators + // it's already materialized. + void prewarmDiffMaterializations(engine); + + return {engine, filename: file.name, dumps}; + } catch (err) { + try { + engine[Symbol.dispose](); + } catch { + // ignore + } + if (err instanceof BaselineLoadError) throw err; + throw new BaselineLoadError( + `Failed to load baseline trace: ${err instanceof Error ? err.message : String(err)}`, + false, + ); + } +} + +// Trigger the heavy stdlib `CREATE PERFETTO TABLE`s the diff views +// rely on. Errors are swallowed — pre-warm is opportunistic; the diff +// view will retry on user navigation if this fails. +async function prewarmDiffMaterializations( + engine: WasmEngineProxy, +): Promise<void> { + try { + await engine.query(` + INCLUDE PERFETTO MODULE android.memory.heap_graph.heap_graph_class_aggregation; + SELECT 1 FROM android_heap_graph_class_aggregation LIMIT 1; + `); + } catch { + // Engine may have been disposed before the prewarm finished — fine. + } +} + +// Mirrors `dumps/loader.ts:loadDumps` for the primary engine, but +// returns the list instead of mutating module state. +async function enumerateDumps( + engine: WasmEngineProxy, +): Promise<ReadonlyArray<HeapDump>> { + const res = await engine.query(` + SELECT + o.upid AS upid, + o.graph_sample_ts AS ts, + coalesce(p.cmdline, p.name) AS pname, + p.pid AS pid + FROM heap_graph_object o + JOIN process p USING (upid) + GROUP BY o.upid, o.graph_sample_ts + ORDER BY o.graph_sample_ts ASC + `); + const out: HeapDump[] = []; + for ( + const it = res.iter({ + upid: NUM, + ts: LONG, + pname: STR_NULL, + pid: NUM_NULL, + }); + it.valid(); + it.next() + ) { + // hprof captures often have no associated process row in the trace, + // so `pid` may be NULL. Preserve the absence — `pid: 0` would render + // as a real PID and mislead the user. Downstream label helpers know + // how to render a null pid. + out.push({ + upid: it.upid, + ts: it.ts, + processName: it.pname, + pid: it.pid, + }); + } + return out; +} + +/** + * Reads the file in CHUNK_SIZE slices and feeds each into engine.parse(). + * Mirrors `core/load_trace.ts:206-220` behavior with progress callbacks. + */ +async function streamFileIntoEngine( + file: File, + engine: WasmEngineProxy, + onProgress?: (p: LoadProgress) => void, +): Promise<void> { + const total = file.size; + let offset = 0; + while (offset < total) { + const end = Math.min(offset + CHUNK_SIZE, total); + const slice = file.slice(offset, end); + const buf = await slice.arrayBuffer(); + await engine.parse(new Uint8Array(buf)); + offset = end; + onProgress?.({bytesRead: offset, bytesTotal: total}); + } +}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/baseline/state.ts b/ui/src/plugins/com.android.HeapDumpExplorer/baseline/state.ts new file mode 100644 index 0000000..39afa9c --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/baseline/state.ts
@@ -0,0 +1,217 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Multi-trace baseline pool. Each pooled trace owns its own +// WasmEngineProxy. The active baseline is one (trace, dump) ref; +// diff views read it via `getActiveBaseline()` and re-render on change. + +import m from 'mithril'; +import type {Engine} from '../../../trace_processor/engine'; +import type {HeapDump} from '../queries'; +import {clearDiffRows} from '../diff/diff_debug'; + +export interface BaselineTrace { + readonly id: string; + readonly engine: Engine; + readonly title: string; + readonly dumps: ReadonlyArray<HeapDump>; + // False for the synthetic "self" entry whose engine is the primary + // trace's own — disposing it would tear down the primary. Defaults to + // true (real pooled baselines own their workers). + readonly disposable?: boolean; +} + +export interface BaselineDumpRef { + readonly trace: BaselineTrace; + readonly dump: HeapDump; +} + +export type DiffMode = 'diff' | 'current' | 'baseline'; + +let traces: BaselineTrace[] = []; +let active: BaselineDumpRef | null = null; +let mode: DiffMode = 'diff'; +let nextTraceId = 1; + +export function getBaselineTraces(): ReadonlyArray<BaselineTrace> { + return traces; +} + +export function getActiveBaseline(): BaselineDumpRef | null { + return active; +} + +export function getMode(): DiffMode { + return mode; +} + +export function setMode(m: DiffMode): void { + mode = m; + redraw(); +} + +export function isDiffActive(): boolean { + return active !== null && mode === 'diff'; +} + +// True when the active baseline lives in the primary trace's own +// engine — the same-trace ("self") case. Same-trace diffs can run +// the whole computation as one SQL JOIN against `android_heap_graph_*` +// instead of two filter queries + a JS merge, since both sides are +// served by the same SQLite. `disposable === false` is the singleton +// signal we set in setSelfTraceBaseline. +export function isSelfTraceDiff(): boolean { + return ( + active !== null && mode === 'diff' && active.trace.disposable === false + ); +} + +// "1=1" when no active baseline so callers can splice unconditionally +// into a WHERE clause. +export function baselineDumpFilterSql(alias: string = 'o'): string { + if (!active) return '1=1'; + const d = active.dump; + return `${alias}.upid = ${d.upid} AND ${alias}.graph_sample_ts = ${d.ts}`; +} + +export function addBaselineTrace( + engine: Engine, + title: string, + dumps: ReadonlyArray<HeapDump>, +): BaselineTrace { + const t: BaselineTrace = { + id: `btrace-${nextTraceId++}`, + engine, + title, + dumps, + }; + traces = [...traces, t]; + redraw(); + return t; +} + +// Pick a dump from the primary trace itself as the baseline. Lazily +// registers a singleton self-baseline entry per primary engine — same +// engine is reused for both sides; queries serialize on its single worker +// but independent filter SQL still gives correct (different) results. +// +// The engine here is a per-plugin proxy and is freshly minted on every +// access from `trace.engine`, so we cannot use reference equality to +// dedupe — `disposable === false` is the singleton signal we own. +export function setSelfTraceBaseline( + engine: Engine, + title: string, + dumps: ReadonlyArray<HeapDump>, + dump: HeapDump, +): void { + let self = traces.find((t) => t.disposable === false); + if (!self) { + self = { + id: `btrace-${nextTraceId++}`, + engine, + title, + dumps, + disposable: false, + }; + traces = [...traces, self]; + } + setActiveBaseline({trace: self, dump}); +} + +// Picking a dump flips back into 'diff' mode. To deselect call +// clearActiveBaseline; null is intentionally not accepted here so callers +// can't bypass clearDiffRows(). +export function setActiveBaseline(b: BaselineDumpRef): void { + if (active !== null && active.trace === b.trace && active.dump === b.dump) { + return; + } + active = b; + mode = 'diff'; + redraw(); +} + +export function clearActiveBaseline(): void { + if (!active) return; + active = null; + clearDiffRows(); + redraw(); +} + +export function removeBaselineTrace(traceId: string): void { + const t = traces.find((x) => x.id === traceId); + if (!t) return; + if (active && active.trace === t) { + active = null; + clearDiffRows(); + } + traces = traces.filter((x) => x.id !== traceId); + disposeEngine(t); + redraw(); +} + +// Clears state BEFORE disposing engines so any in-flight fetch sees +// active === null after its await and abandons the merge. +export function dispose(): void { + if (traces.length === 0 && !active) return; + const old = traces; + active = null; + traces = []; + clearDiffRows(); + for (const t of old) disposeEngine(t); + redraw(); +} + +function disposeEngine(t: BaselineTrace): void { + if (t.disposable === false) return; + try { + (t.engine as unknown as Disposable)[Symbol.dispose](); + } catch (e) { + console.error('Error disposing baseline engine:', e); + } +} + +function redraw(): void { + m.redraw(); +} + +// window.__heapdumpDebug — Playwright surface, not consumed by app code. + +export interface HeapdumpDebugApi { + hasBaseline(): boolean; + baselineFilename(): string | null; + mode(): DiffMode; + poolSize(): number; + pickBaseline(title: string): boolean; +} + +declare global { + interface Window { + __heapdumpDebug?: HeapdumpDebugApi; + } +} + +if (typeof window !== 'undefined') { + window.__heapdumpDebug = { + hasBaseline: () => active !== null, + baselineFilename: () => active?.trace.title ?? null, + mode: () => mode, + poolSize: () => traces.length, + pickBaseline: (title) => { + const t = traces.find((x) => x.title === title); + if (!t || t.dumps.length === 0) return false; + setActiveBaseline({trace: t, dump: t.dumps[0]}); + return true; + }, + }; +}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/diff/cross_trace_diff.ts b/ui/src/plugins/com.android.HeapDumpExplorer/diff/cross_trace_diff.ts new file mode 100644 index 0000000..5284c23 --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/diff/cross_trace_diff.ts
@@ -0,0 +1,291 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Cross-trace flamegraph diff — runs purely in the UI. +// +// The same-trace flamegraph diff JOINs cur and base class-tree nodes in SQL +// via _graph_scan path hashes (see ../views/flamegraph_view.ts buildDiffMetric). +// Cross-trace can't JOIN across two trace_processor engines, so this module +// queries both engines, pairs by stable name-path in JS, and writes the +// paired rows back into the cur engine as a TEMP TABLE the flamegraph SQL +// can SELECT from. The downstream value / colour-hint SQL is identical to +// the same-trace path — only the source of `joined` differs. + +import type {Engine} from '../../../trace_processor/engine'; +import { + NUM, + NUM_NULL, + STR, + STR_NULL, +} from '../../../trace_processor/query_result'; + +export interface RawTreeRow { + readonly id: number; + readonly parent_id: number | null; + readonly name: string; + readonly root_type: string | null; + readonly heap_type: string | null; + readonly self_size: number; + readonly self_count: number; +} + +export interface PairedRow extends RawTreeRow { + readonly c_self_size: number; + readonly c_self_count: number; + readonly b_self_size: number; + readonly b_self_count: number; + readonly delta_size: number; + readonly delta_count: number; + readonly is_new: 0 | 1; + readonly path_hash_stable: string; +} + +// Build the chunk of the stable path key contributed by one node. +function nodeKeyPart(name: string, heapType: string | null): string { + // Use \x00 between (name, heap_type) and \x01 between levels — both are + // illegal inside JVM class names and heap-type names, so collisions are + // impossible. + return `${name}\x00${heapType ?? ''}`; +} + +// BFS over a single tree, assigning each node a stable name-path key. +// The key has the same shape as the _graph_scan path hash used in the +// same-trace SQL path, but kept as a string so pairing is exact. +export function computePathKeys( + rows: ReadonlyArray<RawTreeRow>, +): Map<number, string> { + const childrenByParent = new Map<number | null, RawTreeRow[]>(); + for (const r of rows) { + const arr = childrenByParent.get(r.parent_id); + if (arr === undefined) { + childrenByParent.set(r.parent_id, [r]); + } else { + arr.push(r); + } + } + const keys = new Map<number, string>(); + const queue: Array<readonly [number, string]> = []; + for (const root of childrenByParent.get(null) ?? []) { + // Roots also fold in `root_type` so e.g. ROOT_STICKY_CLASS vs + // ROOT_JNI_GLOBAL subtrees with the same name/heap_type get distinct + // path identities — without this they collapse and the same-trace + // LEFT JOIN ON path_h cross-products them. + const k = `${nodeKeyPart(root.name, root.heap_type)}\x00${root.root_type ?? ''}`; + keys.set(root.id, k); + queue.push([root.id, k]); + } + let head = 0; + while (head < queue.length) { + const [id, parentKey] = queue[head++]; + const kids = childrenByParent.get(id); + if (kids === undefined) continue; + for (const k of kids) { + const ck = `${parentKey}\x01${nodeKeyPart(k.name, k.heap_type)}`; + keys.set(k.id, ck); + queue.push([k.id, ck]); + } + } + return keys; +} + +// Pair cur rows against base rows by stable name-path. The output mirrors +// the `joined` CTE of the same-trace SQL: every cur node appears once; +// nodes only in base are dropped (matching same-trace semantics — REMOVED +// nodes don't render as boxes on the current heap). +export function pairTrees( + cur: ReadonlyArray<RawTreeRow>, + base: ReadonlyArray<RawTreeRow>, +): PairedRow[] { + const curKeys = computePathKeys(cur); + const baseKeys = computePathKeys(base); + // Aggregate base sizes by path key so cur nodes sharing a path with + // multiple base tree nodes still pair against a single, summed entry + // (mirrors the GROUP BY in the same-trace base_nodes CTE — both paths + // avoid the cross-product hazard). + const baseByKey = new Map<string, {selfSize: number; selfCount: number}>(); + for (const b of base) { + const k = baseKeys.get(b.id); + if (k === undefined) continue; + const prev = baseByKey.get(k); + if (prev === undefined) { + baseByKey.set(k, {selfSize: b.self_size, selfCount: b.self_count}); + } else { + prev.selfSize += b.self_size; + prev.selfCount += b.self_count; + } + } + // Assign each distinct path key a small dense integer id, used as + // path_hash_stable. The raw name-path key embeds \x00 / \x01 separators, + // and path_hash_stable is injected into an inline SQL string literal (see + // injectPairedTable). Standard SQLite — and the native trace_processor — + // reject an embedded NUL there (it terminates the literal at parse time); + // only the Wasm engine happens to tolerate it, so injecting the raw key is + // both non-portable and fragile. A dense id is NUL-free and collision-free, + // and equal name-paths still map to equal ids — matching the same-trace + // path's integer path_h identity (see flamegraph_view.ts buildDiffMetric). + const pathIds = new Map<string, number>(); + const out: PairedRow[] = []; + for (const c of cur) { + const k = curKeys.get(c.id); + if (k === undefined) continue; + let pathId = pathIds.get(k); + if (pathId === undefined) { + pathId = pathIds.size; + pathIds.set(k, pathId); + } + const b = baseByKey.get(k); + const bSelfSize = b?.selfSize ?? 0; + const bSelfCount = b?.selfCount ?? 0; + out.push({ + ...c, + c_self_size: c.self_size, + c_self_count: c.self_count, + b_self_size: bSelfSize, + b_self_count: bSelfCount, + delta_size: c.self_size - bSelfSize, + delta_count: c.self_count - bSelfCount, + is_new: b === undefined ? 1 : 0, + path_hash_stable: String(pathId), + }); + } + return out; +} + +const TREE_QUERY = (tree: string, upid: number, ts: bigint) => ` + SELECT id, parent_id, + coalesce(name, '<' || coalesce(replace(heap_type, 'HEAP_TYPE_', ''), root_type, 'unnamed') || '>') AS name, + root_type, heap_type, self_size, self_count + FROM ${tree} + WHERE upid = ${upid} AND graph_sample_ts = ${ts} + `; + +// Pull a class-tree dump (cur or base) from an engine into JS rows. +export async function fetchClassTreeRows( + engine: Engine, + treeTable: string, + upid: number, + ts: bigint, +): Promise<RawTreeRow[]> { + const res = await engine.query(TREE_QUERY(treeTable, upid, ts)); + const out: RawTreeRow[] = []; + for ( + const it = res.iter({ + id: NUM, + parent_id: NUM_NULL, + name: STR, + root_type: STR_NULL, + heap_type: STR_NULL, + self_size: NUM, + self_count: NUM, + }); + it.valid(); + it.next() + ) { + out.push({ + id: it.id, + parent_id: it.parent_id, + name: it.name, + root_type: it.root_type, + heap_type: it.heap_type, + self_size: it.self_size, + self_count: it.self_count, + }); + } + return out; +} + +// Quote a string for inline-VALUES SQL. The only strings injected here are +// class names (from the engine) and the numeric path_hash_stable, neither of +// which contains an embedded NUL — important because a NUL truncates a SQLite +// string literal at parse time. We only need to escape single quotes (class +// names like `a'b`). +function sqlString(s: string | null): string { + if (s === null) return 'NULL'; + return `'${s.replace(/'/g, "''")}'`; +} + +// Inject paired rows into `engine` as a temp table the flamegraph SQL can +// SELECT from. Drops any pre-existing table with the same name first so +// repeated re-pairs (different baseline picks) are safe. +// +// The column shape matches the `joined` CTE of the same-trace path so the +// downstream `value` / `color_hint` SQL is identical. +export async function injectPairedTable( + engine: Engine, + tableName: string, + rows: ReadonlyArray<PairedRow>, +): Promise<void> { + await engine.query(`DROP TABLE IF EXISTS ${tableName}`); + await engine.query(` + CREATE TEMP TABLE ${tableName} ( + id INTEGER, + parent_id INTEGER, + name TEXT, + root_type TEXT, + heap_type TEXT, + c_self_size INTEGER, + c_self_count INTEGER, + b_self_size INTEGER, + b_self_count INTEGER, + delta_size INTEGER, + delta_count INTEGER, + is_new INTEGER, + path_hash_stable TEXT + ) + `); + if (rows.length === 0) return; + // Batched VALUES — SQLite's default SQLITE_MAX_COMPOUND_SELECT is 500; + // 250 keeps a safe margin for very wide rows. + const BATCH = 250; + for (let i = 0; i < rows.length; i += BATCH) { + const end = Math.min(i + BATCH, rows.length); + const parts: string[] = []; + for (let j = i; j < end; j++) { + const r = rows[j]; + parts.push( + `(${r.id},${r.parent_id === null ? 'NULL' : r.parent_id},` + + `${sqlString(r.name)},${sqlString(r.root_type)},` + + `${sqlString(r.heap_type)},` + + `${r.c_self_size},${r.c_self_count},` + + `${r.b_self_size},${r.b_self_count},` + + `${r.delta_size},${r.delta_count},` + + `${r.is_new},${sqlString(r.path_hash_stable)})`, + ); + } + await engine.query( + `INSERT INTO ${tableName} (id,parent_id,name,root_type,heap_type,` + + `c_self_size,c_self_count,b_self_size,b_self_count,delta_size,` + + `delta_count,is_new,path_hash_stable) VALUES ${parts.join(',')}`, + ); + } +} + +// Full prep step: pull both trees from their engines, JS-pair, inject as +// a temp table in `curEngine`. Returns the temp table name. Throws on +// engine errors. +export async function prepareCrossTraceDiff( + tableName: string, + curEngine: Engine, + cur: {upid: number; ts: bigint}, + baseEngine: Engine, + base: {upid: number; ts: bigint}, + treeTable: string, +): Promise<void> { + const [curRows, baseRows] = await Promise.all([ + fetchClassTreeRows(curEngine, treeTable, cur.upid, cur.ts), + fetchClassTreeRows(baseEngine, treeTable, base.upid, base.ts), + ]); + const paired = pairTrees(curRows, baseRows); + await injectPairedTable(curEngine, tableName, paired); +}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/diff/cross_trace_diff_unittest.ts b/ui/src/plugins/com.android.HeapDumpExplorer/diff/cross_trace_diff_unittest.ts new file mode 100644 index 0000000..220f301 --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/diff/cross_trace_diff_unittest.ts
@@ -0,0 +1,204 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {computePathKeys, pairTrees, type RawTreeRow} from './cross_trace_diff'; + +function node( + id: number, + parentId: number | null, + name: string, + selfSize: number, + selfCount = 1, + heapType: string | null = null, +): RawTreeRow { + return { + id, + parent_id: parentId, + name, + root_type: null, + heap_type: heapType, + self_size: selfSize, + self_count: selfCount, + }; +} + +// node()'s 6th arg is heap_type, 7th is root_type. RawTreeRow needs both +// to test the seed-hash logic faithfully. +function rootNode( + id: number, + name: string, + rootType: string | null, + heapType: string | null = null, +): RawTreeRow { + return { + id, + parent_id: null, + name, + root_type: rootType, + heap_type: heapType, + self_size: 0, + self_count: 1, + }; +} + +describe('cross-trace flamegraph diff: name-path pairing', () => { + test('root key includes (name, heap_type, root_type)', () => { + const k = computePathKeys([node(1, null, 'Root', 0)]); + // Both heap_type and root_type are null on this node, but the seed + // still emits the slots so the format stays consistent. + expect(k.get(1)).toBe('Root\x00\x00'); + }); + + test('a deeper path includes every ancestor', () => { + const k = computePathKeys([ + node(1, null, 'Root', 0), + node(2, 1, 'A', 10), + node(3, 2, 'B', 5), + ]); + // Root\x00\x00 (name + heap_type + root_type slots) then per-level + // separator \x01 then nodeKeyPart for each descendant (name + heap_type). + expect(k.get(3)).toBe('Root\x00\x00\x01A\x00\x01B\x00'); + }); + + test('heap_type is part of the key (same name + different heap_type ⇒ different key)', () => { + const k = computePathKeys([ + node(1, null, 'Root', 0, 1, 'APP'), + node(2, null, 'Root', 0, 1, 'ZYGOTE'), + ]); + expect(k.get(1)).not.toEqual(k.get(2)); + }); + + test('root_type is part of the root key (regression for cross-product bug)', () => { + // Two roots with identical name and heap_type but different + // root_type used to collapse to the same path identity, which made + // the same-trace LEFT JOIN ON path_h cross-product subtrees. + const k = computePathKeys([ + rootNode(1, 'Root', 'ROOT_STICKY_CLASS'), + rootNode(2, 'Root', 'ROOT_JNI_GLOBAL'), + ]); + expect(k.get(1)).not.toEqual(k.get(2)); + }); + + test('pairing: identical trees ⇒ every node UNCHANGED (delta = 0)', () => { + const tree: RawTreeRow[] = [ + node(1, null, 'Root', 0), + node(2, 1, 'A', 100), + node(3, 1, 'B', 50), + ]; + const out = pairTrees( + tree, + tree.map((r) => ({...r})), + ); + expect(out).toHaveLength(3); + for (const r of out) { + expect(r.delta_size).toBe(0); + expect(r.is_new).toBe(0); + expect(r.c_self_size).toBe(r.b_self_size); + } + }); + + test('pairing: cur > base ⇒ positive delta', () => { + const cur: RawTreeRow[] = [node(1, null, 'Root', 0), node(2, 1, 'A', 200)]; + const base: RawTreeRow[] = [ + node(11, null, 'Root', 0), + node(12, 11, 'A', 50), + ]; + const out = pairTrees(cur, base); + const a = out.find((r) => r.name === 'A')!; + expect(a.c_self_size).toBe(200); + expect(a.b_self_size).toBe(50); + expect(a.delta_size).toBe(150); + expect(a.is_new).toBe(0); + }); + + test('pairing: cur-only node is marked is_new=1 with b_self_size=0', () => { + const cur: RawTreeRow[] = [ + node(1, null, 'Root', 0), + node(2, 1, 'Newcomer', 80), + ]; + const base: RawTreeRow[] = [node(11, null, 'Root', 0)]; + const out = pairTrees(cur, base); + const n = out.find((r) => r.name === 'Newcomer')!; + expect(n.is_new).toBe(1); + expect(n.b_self_size).toBe(0); + expect(n.delta_size).toBe(80); + }); + + test('pairing: base-only node is silently dropped (mirrors same-trace SQL semantics)', () => { + const cur: RawTreeRow[] = [node(1, null, 'Root', 0)]; + const base: RawTreeRow[] = [ + node(11, null, 'Root', 0), + node(12, 11, 'Gone', 100), + ]; + const out = pairTrees(cur, base); + expect(out).toHaveLength(1); + expect(out[0].name).toBe('Root'); + }); + + test('pairing: keys match across different engine-assigned ids (the whole point)', () => { + // Same logical tree, completely different `id` numbering — pairing + // must still align by name-path. + const cur: RawTreeRow[] = [ + node(100, null, 'Root', 0), + node(101, 100, 'A', 10), + ]; + const base: RawTreeRow[] = [node(7, null, 'Root', 0), node(8, 7, 'A', 8)]; + const out = pairTrees(cur, base); + const a = out.find((r) => r.name === 'A')!; + expect(a.b_self_size).toBe(8); + expect(a.delta_size).toBe(2); + }); + + test('path_hash_stable is NUL-free and stable per name-path', () => { + // Regression: path_hash_stable is injected into inline SQL VALUES, where + // an embedded NUL (\x00) truncates the string literal at parse time. The + // raw name-path key contains separators, so it must be encoded as a + // NUL-free id — and two cur nodes on the same name-path must share it. + const cur: RawTreeRow[] = [ + node(1, null, 'Root', 0), + node(2, 1, 'A', 10), + node(3, 1, 'B', 20), + // Same name-path as node 2 (Root -> A) but a distinct engine id. + node(4, 1, 'A', 5), + ]; + const base: RawTreeRow[] = [node(11, null, 'Root', 0)]; + const out = pairTrees(cur, base); + for (const r of out) { + expect(r.path_hash_stable).not.toContain('\x00'); + expect(r.path_hash_stable).not.toContain('\x01'); + expect(r.path_hash_stable).toMatch(/^\d+$/); + } + const aRows = out.filter((r) => r.name === 'A'); + expect(aRows).toHaveLength(2); + expect(aRows[0].path_hash_stable).toBe(aRows[1].path_hash_stable); + expect(out.find((r) => r.name === 'B')!.path_hash_stable).not.toBe( + aRows[0].path_hash_stable, + ); + }); + + test('pairing: count is paired alongside size', () => { + const cur: RawTreeRow[] = [ + node(1, null, 'Root', 0, 1), + node(2, 1, 'A', 100, 5), + ]; + const base: RawTreeRow[] = [ + node(11, null, 'Root', 0, 1), + node(12, 11, 'A', 80, 3), + ]; + const out = pairTrees(cur, base); + const a = out.find((r) => r.name === 'A')!; + expect(a.delta_count).toBe(2); + expect(a.delta_size).toBe(20); + }); +});
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_cache.ts b/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_cache.ts new file mode 100644 index 0000000..554460f --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_cache.ts
@@ -0,0 +1,70 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Per-engine cache for diff fetcher results. +// +// Diff views run the same `(upid, graph_sample_ts)`-filtered class / +// dominators aggregation on each side of the diff. With a long-running +// primary trace the user typically swaps the baseline a few times — at +// 7+ s per ClassesDiff load on a 60 MB hprof that adds up quickly. The +// primary's side is the same across baseline swaps; caching it keyed by +// (engine, dump) makes the second visit instantaneous. +// +// Two design choices worth noting: +// +// * The outer map is a WeakMap keyed by Engine, so a disposed baseline +// engine takes its cached rows with it without any explicit cleanup +// call from the baseline-pool teardown path. +// * The value is a Promise, not a resolved array, so concurrent +// fetches (Promise.all of baseline+current on first render of a +// new tab) share the in-flight query rather than dispatching twice. +// +// On rejection we drop the entry so the next call retries instead of +// re-serving a stale failure. + +import type {Engine} from '../../../trace_processor/engine'; +import type {Row} from '../../../trace_processor/query_result'; + +type Cache = Map<string, Promise<ReadonlyArray<Row>>>; + +const PER_ENGINE_CACHE: WeakMap<Engine, Cache> = new WeakMap(); + +export async function cachedFetch( + engine: Engine, + key: string, + fetcher: () => Promise<ReadonlyArray<Row>>, +): Promise<ReadonlyArray<Row>> { + let perEngine = PER_ENGINE_CACHE.get(engine); + if (!perEngine) { + perEngine = new Map(); + PER_ENGINE_CACHE.set(engine, perEngine); + } + const existing = perEngine.get(key); + if (existing !== undefined) return existing; + const promise = fetcher().catch((err) => { + // Drop failed entries so the next caller retries instead of being + // permanently stuck on a stale failure. + if (perEngine!.get(key) === promise) perEngine!.delete(key); + throw err; + }); + perEngine.set(key, promise); + return promise; +} + +// Stable cache-key fragment for a heap dump. Combines (upid, ts) into +// a string that survives across re-mounts of a diff view (Mithril +// instances are short-lived; the cache is module-scope). +export function dumpKey(upid: number, ts: number | bigint): string { + return `${upid}:${ts}`; +}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_debug.ts b/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_debug.ts new file mode 100644 index 0000000..703c6e9 --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_debug.ts
@@ -0,0 +1,63 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type {DiffRow} from './diff_rows'; + +export type DiffViewName = + | 'classes' + | 'strings' + | 'arrays' + | 'bitmaps' + | 'dominators' + | 'overview'; + +interface Snapshot { + readonly gen: number; + readonly rows: ReadonlyArray<DiffRow>; +} + +const snapshots = new Map<DiffViewName, Snapshot>(); +let nextGen = 1; + +export function publishDiffRows( + view: DiffViewName, + rows: ReadonlyArray<DiffRow>, +): void { + snapshots.set(view, {gen: nextGen++, rows}); +} + +export function clearDiffRows(view?: DiffViewName): void { + if (view) snapshots.delete(view); + else snapshots.clear(); +} + +export interface HeapdumpDiffDebugApi { + rows(view: DiffViewName): ReadonlyArray<DiffRow> | null; + gen(view: DiffViewName): number; + views(): DiffViewName[]; +} + +declare global { + interface Window { + __heapdumpDiff?: HeapdumpDiffDebugApi; + } +} + +if (typeof window !== 'undefined') { + window.__heapdumpDiff = { + rows: (view) => snapshots.get(view)?.rows ?? null, + gen: (view) => snapshots.get(view)?.gen ?? 0, + views: () => Array.from(snapshots.keys()), + }; +}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_rows.ts b/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_rows.ts new file mode 100644 index 0000000..70b0948 --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_rows.ts
@@ -0,0 +1,237 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// JS outer-join for diff views: one row per key with baseline / current / +// delta columns plus a status. Cross-engine SQL JOIN isn't possible +// (each baseline trace lives in its own Worker / sqlite), so the merge +// runs in JS over already-aggregated rows. + +import type {Row, SqlValue} from '../../../trace_processor/query_result'; + +export type Num = number | bigint; + +export type DiffStatus = 'NEW' | 'REMOVED' | 'GREW' | 'SHRANK' | 'UNCHANGED'; + +export const KEY_COL = 'key'; +export const STATUS_COL = 'status'; +export const baselineCol = (field: string) => `_b_${field}`; +export const currentCol = (field: string) => `_c_${field}`; +export const deltaCol = (field: string) => `_d_${field}`; + +export interface MergeOptions<T extends Row> { + readonly baseline: readonly T[]; + readonly current: readonly T[]; + readonly keyOf: (row: T) => string; + readonly numericFields: readonly string[]; + // Non-numeric columns to copy through (preferring current side). + readonly passThroughFields?: readonly string[]; + readonly primaryDeltaField: string; + // |Δ(primaryDeltaField)| ≤ threshold → UNCHANGED. Defaults to 0. + readonly statusThreshold?: Num; +} + +export interface DiffRow extends Row { + readonly [KEY_COL]: string; + readonly [STATUS_COL]: DiffStatus; + readonly [field: string]: SqlValue; +} + +// Outer-join `baseline` and `current` by `keyOf`. Throws on duplicate keys +// (inputs must already be aggregated by the join key — see `dedupeByKey`). +export function mergeRows<T extends Row>(opts: MergeOptions<T>): DiffRow[] { + const { + baseline, + current, + keyOf, + numericFields, + passThroughFields = [], + primaryDeltaField, + statusThreshold, + } = opts; + + if (!numericFields.includes(primaryDeltaField)) { + throw new Error( + `mergeRows: primaryDeltaField '${primaryDeltaField}' must be in numericFields`, + ); + } + + const baselineMap = indexByKey(baseline, keyOf, 'baseline'); + const currentMap = indexByKey(current, keyOf, 'current'); + + const allKeys = new Set<string>(); + for (const k of baselineMap.keys()) allKeys.add(k); + for (const k of currentMap.keys()) allKeys.add(k); + + const result: DiffRow[] = []; + for (const key of allKeys) { + const b = baselineMap.get(key); + const c = currentMap.get(key); + + const row: Record<string, SqlValue> = { + [KEY_COL]: key, + [STATUS_COL]: classify(b, c, primaryDeltaField, statusThreshold ?? 0), + }; + + for (const field of numericFields) { + const bv = numericOrNull(b, field); + const cv = numericOrNull(c, field); + row[baselineCol(field)] = bv as SqlValue; + row[currentCol(field)] = cv as SqlValue; + row[deltaCol(field)] = delta(bv, cv) as SqlValue; + } + + for (const field of passThroughFields) { + row[field] = (c?.[field] ?? b?.[field] ?? null) as SqlValue; + } + + result.push(row as DiffRow); + } + + return result; +} + +// b - a. Coerces to bigint if either side is bigint (heap sizes can +// exceed 2^53). Treats null as 0. +export function delta(a: Num | null, b: Num | null): Num { + if (a == null && b == null) return 0; + if (typeof a === 'bigint' || typeof b === 'bigint') { + return toBigInt(b) - toBigInt(a); + } + return ((b as number) ?? 0) - ((a as number) ?? 0); +} + +export function abs(v: Num): Num { + if (typeof v === 'bigint') return v < 0n ? -v : v; + return Math.abs(v); +} + +export function compareNum(a: Num | null, b: Num | null): number { + if (a == null && b == null) return 0; + if (a == null) return -1; + if (b == null) return 1; + if (typeof a === 'bigint' || typeof b === 'bigint') { + const ab = toBigInt(a); + const bb = toBigInt(b); + return ab < bb ? -1 : ab > bb ? 1 : 0; + } + const an = a as number; + const bn = b as number; + return an < bn ? -1 : an > bn ? 1 : 0; +} + +function toBigInt(v: Num | null): bigint { + if (v == null) return 0n; + if (typeof v === 'bigint') return v; + if (Number.isInteger(v)) return BigInt(v); + return BigInt(Math.trunc(v)); +} + +function numericOrNull(row: Row | undefined, field: string): Num | null { + if (!row) return null; + const v = row[field]; + if (v == null) return null; + if (typeof v === 'number' || typeof v === 'bigint') return v; + if (typeof v === 'string') { + const n = Number(v); + if (!Number.isNaN(n)) return n; + } + return null; +} + +// Sum-merges duplicate keys before `mergeRows`. Use when SQL GROUP BY +// can't fully unique-ify the join key (e.g. type_name shared across +// classloaders). +export function dedupeByKey<T extends Row>( + rows: readonly T[], + keyOf: (r: T) => string, + numericFields: readonly string[], +): T[] { + const map = new Map<string, T>(); + for (const r of rows) { + const k = keyOf(r); + const existing = map.get(k); + if (existing === undefined) { + map.set(k, {...r}); + continue; + } + const merged = existing as Record<string, SqlValue>; + for (const f of numericFields) { + const a = numericOrNull(existing, f); + const b = numericOrNull(r, f); + if (a == null && b == null) { + merged[f] = null; + } else if (typeof a === 'bigint' || typeof b === 'bigint') { + merged[f] = (toBigInt(a) + toBigInt(b)) as SqlValue; + } else { + merged[f] = ((a ?? 0) as number) + ((b ?? 0) as number); + } + } + } + return Array.from(map.values()); +} + +function indexByKey<T extends Row>( + rows: readonly T[], + keyOf: (r: T) => string, + side: string, +): Map<string, T> { + const map = new Map<string, T>(); + for (const r of rows) { + const k = keyOf(r); + if (map.has(k)) { + throw new Error( + `mergeRows: duplicate key '${k}' on ${side} side. Both inputs must ` + + `be pre-aggregated by the join key.`, + ); + } + map.set(k, r); + } + return map; +} + +function classify( + b: Row | undefined, + c: Row | undefined, + field: string, + threshold: Num, +): DiffStatus { + // NEW/REMOVED comes from row presence, never value==0 — a class with + // delta=0 that exists on both sides is UNCHANGED, not NEW. + if (b === undefined && c === undefined) return 'UNCHANGED'; + if (b === undefined) return 'NEW'; + if (c === undefined) return 'REMOVED'; + const d = delta(numericOrNull(b, field), numericOrNull(c, field)); + if (compareAbs(d, threshold) <= 0) return 'UNCHANGED'; + return compareNum(d, 0) > 0 ? 'GREW' : 'SHRANK'; +} + +function compareAbs(a: Num, b: Num): number { + return compareNum(abs(a), abs(b)); +} + +// Sort by |Δ| desc, ties broken by key. +export function compareByAbsDeltaDesc( + primaryDeltaField: string, +): (a: DiffRow, b: DiffRow) => number { + const col = deltaCol(primaryDeltaField); + return (a, b) => { + const av = a[col] as Num | null; + const bv = b[col] as Num | null; + const aa = av == null ? 0n : abs(av as Num); + const bb = bv == null ? 0n : abs(bv as Num); + const cmp = compareNum(bb, aa); + if (cmp !== 0) return cmp; + return String(a[KEY_COL]).localeCompare(String(b[KEY_COL])); + }; +}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_rows_unittest.ts b/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_rows_unittest.ts new file mode 100644 index 0000000..54da413 --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_rows_unittest.ts
@@ -0,0 +1,310 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Unit tests for the pure JS-side diff merger. No engine, no Mithril. + +import type {DiffRow} from './diff_rows'; +import { + abs, + baselineCol, + compareByAbsDeltaDesc, + compareNum, + currentCol, + delta, + deltaCol, + KEY_COL, + mergeRows, + STATUS_COL, +} from './diff_rows'; + +describe('delta', () => { + it('handles two numbers', () => { + expect(delta(5, 8)).toBe(3); + expect(delta(8, 5)).toBe(-3); + expect(delta(0, 0)).toBe(0); + }); + + it('treats null as 0', () => { + expect(delta(null, 5)).toBe(5); + expect(delta(5, null)).toBe(-5); + expect(delta(null, null)).toBe(0); + }); + + it('promotes to bigint when either side is bigint', () => { + expect(delta(5n, 8n)).toBe(3n); + expect(delta(5, 8n)).toBe(3n); + expect(delta(8n, 5)).toBe(-3n); + expect(delta(null, 5n)).toBe(5n); + expect(delta(5n, null)).toBe(-5n); + }); + + it('preserves precision above 2^53 for bigint', () => { + const huge = 1n << 60n; + expect(delta(0n, huge)).toBe(huge); + expect(delta(huge, 2n * huge)).toBe(huge); + // Compare against the bigint result, not number-truncated value. + expect(delta(huge, huge + 1n)).toBe(1n); + }); +}); + +describe('compareNum', () => { + it('orders numbers correctly', () => { + expect(compareNum(1, 2)).toBe(-1); + expect(compareNum(2, 1)).toBe(1); + expect(compareNum(1, 1)).toBe(0); + }); + + it('orders bigints correctly', () => { + expect(compareNum(1n, 2n)).toBe(-1); + expect(compareNum(2n, 1n)).toBe(1); + expect(compareNum(1n, 1n)).toBe(0); + }); + + it('mixes number and bigint without throwing', () => { + expect(compareNum(1, 2n)).toBe(-1); + expect(compareNum(2n, 1)).toBe(1); + }); + + it('treats null as smallest', () => { + expect(compareNum(null, 1)).toBe(-1); + expect(compareNum(1, null)).toBe(1); + expect(compareNum(null, null)).toBe(0); + }); +}); + +describe('abs', () => { + it('handles number', () => { + expect(abs(5)).toBe(5); + expect(abs(-5)).toBe(5); + expect(abs(0)).toBe(0); + }); + it('handles bigint', () => { + expect(abs(5n)).toBe(5n); + expect(abs(-5n)).toBe(5n); + expect(abs(0n)).toBe(0n); + }); +}); + +describe('mergeRows', () => { + const opts = { + keyOf: (r: {cls: string}) => r.cls, + numericFields: ['cnt', 'retained'], + primaryDeltaField: 'retained', + }; + + it('produces UNCHANGED for identical rows', () => { + const baseline = [{cls: 'Foo', cnt: 10, retained: 1000}]; + const current = [{cls: 'Foo', cnt: 10, retained: 1000}]; + const out = mergeRows({baseline, current, ...opts}); + expect(out).toHaveLength(1); + expect(out[0][KEY_COL]).toBe('Foo'); + expect(out[0][STATUS_COL]).toBe('UNCHANGED'); + expect(out[0][deltaCol('retained')]).toBe(0); + expect(out[0][baselineCol('retained')]).toBe(1000); + expect(out[0][currentCol('retained')]).toBe(1000); + }); + + it('flags GREW when current > baseline', () => { + const baseline = [{cls: 'Foo', cnt: 10, retained: 1000}]; + const current = [{cls: 'Foo', cnt: 12, retained: 1500}]; + const out = mergeRows({baseline, current, ...opts}); + expect(out[0][STATUS_COL]).toBe('GREW'); + expect(out[0][deltaCol('retained')]).toBe(500); + expect(out[0][deltaCol('cnt')]).toBe(2); + }); + + it('flags SHRANK when current < baseline', () => { + const baseline = [{cls: 'Foo', cnt: 10, retained: 1500}]; + const current = [{cls: 'Foo', cnt: 8, retained: 1000}]; + const out = mergeRows({baseline, current, ...opts}); + expect(out[0][STATUS_COL]).toBe('SHRANK'); + expect(out[0][deltaCol('retained')]).toBe(-500); + }); + + it('flags NEW when row only in current', () => { + const out = mergeRows({ + baseline: [], + current: [{cls: 'Foo', cnt: 5, retained: 500}], + ...opts, + }); + expect(out[0][STATUS_COL]).toBe('NEW'); + expect(out[0][baselineCol('retained')]).toBeNull(); + expect(out[0][currentCol('retained')]).toBe(500); + expect(out[0][deltaCol('retained')]).toBe(500); + }); + + it('flags REMOVED when row only in baseline', () => { + const out = mergeRows({ + baseline: [{cls: 'Foo', cnt: 5, retained: 500}], + current: [], + ...opts, + }); + expect(out[0][STATUS_COL]).toBe('REMOVED'); + expect(out[0][baselineCol('retained')]).toBe(500); + expect(out[0][currentCol('retained')]).toBeNull(); + expect(out[0][deltaCol('retained')]).toBe(-500); + }); + + it('classifies present-on-both rows by delta, not by zero-on-one-side', () => { + // A class present in both snapshots with dominated_size_bytes=0 in + // baseline (common — many classes are reachable but not dominators of + // anything substantial) must be GREW, not NEW. NEW is reserved for + // rows that did not exist in the baseline input at all. + const out = mergeRows({ + baseline: [{cls: 'Foo', cnt: 5, retained: 0}], + current: [{cls: 'Foo', cnt: 100, retained: 1000}], + ...opts, + }); + expect(out[0][STATUS_COL]).toBe('GREW'); + }); + + it('handles empty inputs on both sides', () => { + expect(mergeRows({baseline: [], current: [], ...opts})).toEqual([]); + }); + + it('produces NEW + REMOVED + UNCHANGED in mixed input', () => { + const baseline = [ + {cls: 'A', cnt: 1, retained: 100}, + {cls: 'B', cnt: 2, retained: 200}, + {cls: 'C', cnt: 3, retained: 300}, + ]; + const current = [ + {cls: 'A', cnt: 1, retained: 100}, // unchanged + {cls: 'C', cnt: 5, retained: 500}, // grew + {cls: 'D', cnt: 1, retained: 100}, // new + // B removed + ]; + const out = mergeRows({baseline, current, ...opts}); + const byKey = new Map(out.map((r) => [String(r[KEY_COL]), r])); + expect(byKey.get('A')?.[STATUS_COL]).toBe('UNCHANGED'); + expect(byKey.get('B')?.[STATUS_COL]).toBe('REMOVED'); + expect(byKey.get('C')?.[STATUS_COL]).toBe('GREW'); + expect(byKey.get('D')?.[STATUS_COL]).toBe('NEW'); + }); + + it('mixes bigint and number across sides without throwing', () => { + const baseline = [{cls: 'Foo', cnt: 10n, retained: 1000n}]; + const current = [{cls: 'Foo', cnt: 12, retained: 1500}]; + const out = mergeRows({baseline, current, ...opts}); + expect(out[0][STATUS_COL]).toBe('GREW'); + // Both sides coerced to bigint when one is. + expect(out[0][deltaCol('retained')]).toBe(500n); + }); + + it('preserves precision above 2^53 with bigint', () => { + const huge = 1n << 55n; // > 2^53 + const baseline = [{cls: 'Foo', cnt: 1n, retained: huge}]; + const current = [{cls: 'Foo', cnt: 1n, retained: huge + 1n}]; + const out = mergeRows({baseline, current, ...opts}); + expect(out[0][deltaCol('retained')]).toBe(1n); + }); + + it('throws on duplicate keys (defensive)', () => { + const baseline = [ + {cls: 'Foo', cnt: 1, retained: 100}, + {cls: 'Foo', cnt: 2, retained: 200}, + ]; + expect(() => mergeRows({baseline, current: [], ...opts})).toThrow( + /duplicate key/, + ); + }); + + it('throws if primaryDeltaField not in numericFields', () => { + expect(() => + mergeRows({ + baseline: [], + current: [], + keyOf: (r: {cls: string}) => r.cls, + numericFields: ['cnt'], + primaryDeltaField: 'retained', + }), + ).toThrow(/primaryDeltaField/); + }); + + it('handles non-ASCII Kotlin lambda class names', () => { + const baseline = [ + {cls: '$$Lambda$Foo$abc', cnt: 1, retained: 100}, + {cls: 'kotlin.coroutines.intrinsics. Suspended', cnt: 1, retained: 100}, + ]; + const current = [ + {cls: '$$Lambda$Foo$abc', cnt: 2, retained: 200}, + {cls: 'kotlin.coroutines.intrinsics. Suspended', cnt: 1, retained: 100}, + ]; + const out = mergeRows({baseline, current, ...opts}); + expect(out).toHaveLength(2); + const lambda = out.find((r) => String(r[KEY_COL]).includes('Lambda')); + expect(lambda?.[STATUS_COL]).toBe('GREW'); + expect(lambda?.[deltaCol('retained')]).toBe(100); + }); + + it('passes through extra string fields preferring current', () => { + const baseline = [{cls: 'Foo', cnt: 1, retained: 100, label: 'old'}]; + const current = [{cls: 'Foo', cnt: 2, retained: 200, label: 'new'}]; + const out = mergeRows({ + baseline, + current, + ...opts, + passThroughFields: ['label'], + }); + expect(out[0].label).toBe('new'); + }); + + it('passes through extra fields falling back to baseline', () => { + const baseline = [{cls: 'Foo', cnt: 1, retained: 100, label: 'kept'}]; + const current: typeof baseline = []; + const out = mergeRows({ + baseline, + current, + ...opts, + passThroughFields: ['label'], + }); + expect(out[0].label).toBe('kept'); + }); +}); + +describe('compareByAbsDeltaDesc', () => { + it('sorts by |delta| descending, ties broken by key', () => { + const rows = [ + {[KEY_COL]: 'small', [deltaCol('retained')]: 100, [STATUS_COL]: 'GREW'}, + { + [KEY_COL]: 'big-grew', + [deltaCol('retained')]: 1000, + [STATUS_COL]: 'GREW', + }, + { + [KEY_COL]: 'big-shrank', + [deltaCol('retained')]: -1000, + [STATUS_COL]: 'SHRANK', + }, + {[KEY_COL]: 'zero', [deltaCol('retained')]: 0, [STATUS_COL]: 'UNCHANGED'}, + ] as DiffRow[]; + rows.sort(compareByAbsDeltaDesc('retained')); + expect(rows.map((r) => String(r[KEY_COL]))).toEqual([ + 'big-grew', // ties at |1000|; localeCompare puts 'big-grew' before 'big-shrank' + 'big-shrank', + 'small', + 'zero', + ]); + }); + + it('handles bigint deltas', () => { + const huge = 1n << 60n; + const rows = [ + {[KEY_COL]: 'a', [deltaCol('retained')]: huge, [STATUS_COL]: 'GREW'}, + {[KEY_COL]: 'b', [deltaCol('retained')]: huge - 1n, [STATUS_COL]: 'GREW'}, + ] as DiffRow[]; + rows.sort(compareByAbsDeltaDesc('retained')); + expect(String(rows[0][KEY_COL])).toBe('a'); + }); +});
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_schemas.ts b/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_schemas.ts new file mode 100644 index 0000000..8fa2dfe --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_schemas.ts
@@ -0,0 +1,253 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Cell renderers + schema factories for diff views. Plain coloured text, +// no pills or chips. aria-labels carry the sign in words so colour isn't +// the only cue. + +import m from 'mithril'; +import type { + CellRenderResult, + CellRenderer, + ColumnDef, + SchemaRegistry, +} from '../../../components/widgets/datagrid/datagrid_schema'; +import type {SqlValue} from '../../../trace_processor/query_result'; +import {fmtSize} from '../format'; +import type {DiffStatus, Num} from './diff_rows'; +import { + KEY_COL, + STATUS_COL, + baselineCol, + currentCol, + deltaCol, +} from './diff_rows'; + +const STATUS_LABEL: Record<DiffStatus, string> = { + NEW: 'NEW', + REMOVED: 'REMOVED', + GREW: 'GREW', + SHRANK: 'SHRANK', + UNCHANGED: '', +}; + +const STATUS_CLASS: Record<DiffStatus, string> = { + NEW: 'pf-hde-status-text pf-hde-status-text--new', + REMOVED: 'pf-hde-status-text pf-hde-status-text--removed', + GREW: 'pf-hde-status-text pf-hde-status-text--grew', + SHRANK: 'pf-hde-status-text pf-hde-status-text--shrank', + UNCHANGED: 'pf-hde-status-text pf-hde-status-text--unchanged', +}; + +export const statusRenderer: CellRenderer = ( + value: SqlValue, +): CellRenderResult => { + const s = String(value ?? 'UNCHANGED') as DiffStatus; + const label = STATUS_LABEL[s] ?? s; + if (label === '') { + return {content: m('span'), align: 'left'}; + } + return { + content: m( + 'span', + { + 'class': STATUS_CLASS[s] ?? 'pf-hde-status-text', + 'aria-label': `Status: ${label}`, + }, + label, + ), + align: 'left', + }; +}; + +export const deltaSizeRenderer: CellRenderer = ( + value: SqlValue, +): CellRenderResult => { + return renderDelta(value, (n) => fmtSize(Number(n))); +}; + +export const deltaCountRenderer: CellRenderer = ( + value: SqlValue, +): CellRenderResult => { + return renderDelta(value, (n) => Math.abs(Number(n)).toLocaleString()); +}; + +function renderDelta( + value: SqlValue, + fmtMagnitude: (n: Num) => string, +): CellRenderResult { + const n = toNum(value); + if (n == null) { + return { + content: m('span', {class: 'pf-hde-mono pf-hde-muted'}, '—'), + align: 'right', + }; + } + const sign = compareToZero(n); + if (sign === 0) { + return { + content: m( + 'span', + {'class': 'pf-hde-mono pf-hde-delta--zero', 'aria-label': 'No change'}, + '0', + ), + align: 'right', + }; + } + const symbol = sign > 0 ? '+' : '−'; + const cls = + sign > 0 + ? 'pf-hde-mono pf-hde-delta--grew' + : 'pf-hde-mono pf-hde-delta--shrank'; + const word = sign > 0 ? 'increased by' : 'decreased by'; + const magnitude = fmtMagnitude(absNum(n)); + return { + content: m( + 'span', + {'class': cls, 'aria-label': `${word} ${magnitude}`}, + `${symbol}${magnitude}`, + ), + align: 'right', + }; +} + +// null → "—" so empty cells are visible (the column has data on the +// other side). +export const sideSizeRenderer: CellRenderer = ( + value: SqlValue, +): CellRenderResult => { + const n = toNum(value); + if (n == null) { + return { + content: m('span', {class: 'pf-hde-mono pf-hde-muted'}, '—'), + align: 'right', + }; + } + return { + content: m('span', {class: 'pf-hde-mono'}, fmtSize(Number(n))), + align: 'right', + }; +}; + +export const sideCountRenderer: CellRenderer = ( + value: SqlValue, +): CellRenderResult => { + const n = toNum(value); + if (n == null) { + return { + content: m('span', {class: 'pf-hde-mono pf-hde-muted'}, '—'), + align: 'right', + }; + } + return { + content: m('span', {class: 'pf-hde-mono'}, Number(n).toLocaleString()), + align: 'right', + }; +}; + +function toNum(v: SqlValue): Num | null { + if (v == null) return null; + if (typeof v === 'number' || typeof v === 'bigint') return v; + if (typeof v === 'string') { + const n = Number(v); + if (!Number.isNaN(n)) return n; + } + return null; +} + +function compareToZero(n: Num): number { + if (typeof n === 'bigint') return n < 0n ? -1 : n > 0n ? 1 : 0; + return n < 0 ? -1 : n > 0 ? 1 : 0; +} + +function absNum(n: Num): Num { + if (typeof n === 'bigint') return n < 0n ? -n : n; + return Math.abs(n); +} + +// `field` is the raw column name in the merged DiffRow; `title` is the +// user-facing label. +export interface DiffNumericField { + readonly field: string; + readonly title: string; + readonly kind: 'size' | 'count'; +} + +export function buildSizeCountSchema(opts: { + readonly keyTitle: string; + readonly keyRenderer?: CellRenderer; + readonly size: DiffNumericField; + readonly count: DiffNumericField; + readonly extraFields?: ReadonlyArray<DiffNumericField>; +}): SchemaRegistry { + const cols: Record<string, ColumnDef> = { + [KEY_COL]: { + title: opts.keyTitle, + columnType: 'text', + cellRenderer: opts.keyRenderer, + }, + [STATUS_COL]: { + title: 'Status', + columnType: 'text', + cellRenderer: statusRenderer, + }, + }; + for (const ef of [opts.size, opts.count, ...(opts.extraFields ?? [])]) { + const sideRenderer = + ef.kind === 'size' ? sideSizeRenderer : sideCountRenderer; + const dRenderer = + ef.kind === 'size' ? deltaSizeRenderer : deltaCountRenderer; + cols[deltaCol(ef.field)] = { + title: `Δ ${ef.title}`, + columnType: 'quantitative', + cellRenderer: dRenderer, + }; + cols[baselineCol(ef.field)] = { + title: `Baseline ${ef.title}`, + columnType: 'quantitative', + cellRenderer: sideRenderer, + }; + cols[currentCol(ef.field)] = { + title: `Current ${ef.title}`, + columnType: 'quantitative', + cellRenderer: sideRenderer, + }; + } + return {query: cols}; +} + +// Default sort: |Δ size| desc — biggest mover first. +export function buildSizeCountInitialColumns(opts: { + readonly size: DiffNumericField; + readonly count: DiffNumericField; + readonly extraFields?: ReadonlyArray<DiffNumericField>; +}): Array<{id: string; field: string; sort?: 'ASC' | 'DESC'}> { + const cols: Array<{id: string; field: string; sort?: 'ASC' | 'DESC'}> = [ + {id: KEY_COL, field: KEY_COL}, + {id: STATUS_COL, field: STATUS_COL}, + ]; + let sortApplied = false; + for (const ef of [opts.size, opts.count, ...(opts.extraFields ?? [])]) { + const isPrimarySize = ef === opts.size; + cols.push({ + id: deltaCol(ef.field), + field: deltaCol(ef.field), + sort: !sortApplied && isPrimarySize ? 'DESC' : undefined, + }); + if (!sortApplied && isPrimarySize) sortApplied = true; + cols.push({id: baselineCol(ef.field), field: baselineCol(ef.field)}); + cols.push({id: currentCol(ef.field), field: currentCol(ef.field)}); + } + return cols; +}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/diff/object_pairing.ts b/ui/src/plugins/com.android.HeapDumpExplorer/diff/object_pairing.ts new file mode 100644 index 0000000..4a9ab37 --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/diff/object_pairing.ts
@@ -0,0 +1,206 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Per-instance pairing for the Objects diff. Within a single call this is +// AHAT's Diff.java kernel (heapdump/Diff.java): partition both sides by an +// equivalence-class Key (class + heap_type + value_string + array_length), +// sort each bucket by retained size desc, then zip positionally. Leftovers +// become NEW / REMOVED. heap_graph_object id is parser-internal — never +// stable across traces — so we cannot use it as cross-snapshot identity. +// +// AHAT additionally walks the dominator tree top-down, applying this +// kernel at each level so siblings only match if their dominators +// matched. We approximate that recursively by user navigation: callers +// invoke pairObjects once per scope (per-class for the Objects tab, +// per-parent for "Immediately Dominated Objects") and the drill-down +// chain reproduces AHAT's level-by-level walk on demand. + +import type {DiffStatus} from './diff_rows'; + +export interface ObjectRowRaw { + readonly id: number; + readonly className: string; + readonly heapType: string | null; + readonly valueString: string | null; + readonly arrayLength: number | null; + readonly shallow: number; + readonly shallowNative: number; + readonly retained: number; + readonly retainedNative: number; + readonly retainedCount: number; +} + +export interface ObjectPairRow { + readonly key: string; + readonly status: DiffStatus; + readonly className: string; + readonly heapType: string | null; + readonly valueString: string | null; + readonly c_id: number | null; + readonly b_id: number | null; + readonly c_shallow: number | null; + readonly b_shallow: number | null; + readonly c_shallow_native: number | null; + readonly b_shallow_native: number | null; + readonly c_retained: number | null; + readonly b_retained: number | null; + readonly c_retained_native: number | null; + readonly b_retained_native: number | null; + readonly c_retained_count: number | null; + readonly b_retained_count: number | null; + readonly delta_retained: number; + readonly delta_shallow: number; +} + +function bucketKey(r: ObjectRowRaw): string { + // \x1f as field separator avoids collisions when any field contains the + // empty string, '|', or other characters a user-controlled value_string + // could include. + return [ + r.className, + r.heapType ?? '', + r.valueString ?? '', + r.arrayLength ?? '', + ].join('\x1f'); +} + +function compareByRetainedDesc(a: ObjectRowRaw, b: ObjectRowRaw): number { + // Tie-break by id for deterministic order within an engine. Cross-trace + // ties remain inherently unstable (different parser ids). + if (b.retained !== a.retained) return b.retained - a.retained; + return a.id - b.id; +} + +function classifyDelta(c: number, b: number): DiffStatus { + if (c > b) return 'GREW'; + if (c < b) return 'SHRANK'; + return 'UNCHANGED'; +} + +/** + * Pair instances from `current` and `baseline`. Within each Key bucket, + * sort both sides by retained-size desc and zip positionally. Returns a + * flat list of pair rows — one per pair, plus one per leftover. + */ +export function pairObjects( + current: ReadonlyArray<ObjectRowRaw>, + baseline: ReadonlyArray<ObjectRowRaw>, +): ObjectPairRow[] { + const cBuckets = new Map<string, ObjectRowRaw[]>(); + const bBuckets = new Map<string, ObjectRowRaw[]>(); + for (const r of current) { + const k = bucketKey(r); + let bucket = cBuckets.get(k); + if (!bucket) { + bucket = []; + cBuckets.set(k, bucket); + } + bucket.push(r); + } + for (const r of baseline) { + const k = bucketKey(r); + let bucket = bBuckets.get(k); + if (!bucket) { + bucket = []; + bBuckets.set(k, bucket); + } + bucket.push(r); + } + const allKeys = new Set<string>(); + for (const k of cBuckets.keys()) allKeys.add(k); + for (const k of bBuckets.keys()) allKeys.add(k); + + const out: ObjectPairRow[] = []; + for (const k of allKeys) { + const cs = cBuckets.get(k) ?? []; + const bs = bBuckets.get(k) ?? []; + cs.sort(compareByRetainedDesc); + bs.sort(compareByRetainedDesc); + const common = Math.min(cs.length, bs.length); + for (let i = 0; i < common; i++) { + const c = cs[i]; + const b = bs[i]; + out.push({ + key: `${k}\x1f${i}`, + status: classifyDelta(c.retained, b.retained), + className: c.className, + heapType: c.heapType, + valueString: c.valueString, + c_id: c.id, + b_id: b.id, + c_shallow: c.shallow, + b_shallow: b.shallow, + c_shallow_native: c.shallowNative, + b_shallow_native: b.shallowNative, + c_retained: c.retained, + b_retained: b.retained, + c_retained_native: c.retainedNative, + b_retained_native: b.retainedNative, + c_retained_count: c.retainedCount, + b_retained_count: b.retainedCount, + delta_retained: c.retained - b.retained, + delta_shallow: c.shallow - b.shallow, + }); + } + for (let i = common; i < cs.length; i++) { + const c = cs[i]; + out.push({ + key: `${k}\x1f${i}`, + status: 'NEW', + className: c.className, + heapType: c.heapType, + valueString: c.valueString, + c_id: c.id, + b_id: null, + c_shallow: c.shallow, + b_shallow: null, + c_shallow_native: c.shallowNative, + b_shallow_native: null, + c_retained: c.retained, + b_retained: null, + c_retained_native: c.retainedNative, + b_retained_native: null, + c_retained_count: c.retainedCount, + b_retained_count: null, + delta_retained: c.retained, + delta_shallow: c.shallow, + }); + } + for (let i = common; i < bs.length; i++) { + const b = bs[i]; + out.push({ + key: `${k}\x1f${i}`, + status: 'REMOVED', + className: b.className, + heapType: b.heapType, + valueString: b.valueString, + c_id: null, + b_id: b.id, + c_shallow: null, + b_shallow: b.shallow, + c_shallow_native: null, + b_shallow_native: b.shallowNative, + c_retained: null, + b_retained: b.retained, + c_retained_native: null, + b_retained_native: b.retainedNative, + c_retained_count: null, + b_retained_count: b.retainedCount, + delta_retained: -b.retained, + delta_shallow: -b.shallow, + }); + } + } + return out; +}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/diff/object_pairing_unittest.ts b/ui/src/plugins/com.android.HeapDumpExplorer/diff/object_pairing_unittest.ts new file mode 100644 index 0000000..650dc7a --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/diff/object_pairing_unittest.ts
@@ -0,0 +1,201 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {pairObjects, type ObjectRowRaw} from './object_pairing'; + +function row( + opts: Partial<ObjectRowRaw> & Pick<ObjectRowRaw, 'id'>, +): ObjectRowRaw { + return { + className: 'C', + heapType: 'app', + valueString: null, + arrayLength: null, + shallow: 16, + shallowNative: 0, + retained: 16, + retainedNative: 0, + retainedCount: 1, + ...opts, + }; +} + +describe('pairObjects', () => { + test('empty inputs → no rows', () => { + expect(pairObjects([], [])).toEqual([]); + }); + + test('paired in one bucket: equal retained → UNCHANGED', () => { + const out = pairObjects( + [row({id: 1, retained: 100})], + [row({id: 2, retained: 100})], + ); + expect(out).toHaveLength(1); + expect(out[0].status).toBe('UNCHANGED'); + expect(out[0].c_id).toBe(1); + expect(out[0].b_id).toBe(2); + expect(out[0].delta_retained).toBe(0); + }); + + test('paired in one bucket: current > baseline → GREW', () => { + const out = pairObjects( + [row({id: 1, retained: 200})], + [row({id: 2, retained: 100})], + ); + expect(out[0].status).toBe('GREW'); + expect(out[0].delta_retained).toBe(100); + }); + + test('paired in one bucket: current < baseline → SHRANK', () => { + const out = pairObjects( + [row({id: 1, retained: 80})], + [row({id: 2, retained: 100})], + ); + expect(out[0].status).toBe('SHRANK'); + expect(out[0].delta_retained).toBe(-20); + }); + + test('excess on current side → NEW', () => { + const out = pairObjects( + [row({id: 1, retained: 100}), row({id: 2, retained: 50})], + [row({id: 9, retained: 100})], + ); + expect(out).toHaveLength(2); + const news = out.filter((r) => r.status === 'NEW'); + expect(news).toHaveLength(1); + expect(news[0].c_id).toBe(2); + expect(news[0].b_id).toBeNull(); + expect(news[0].delta_retained).toBe(50); + }); + + test('excess on baseline side → REMOVED', () => { + const out = pairObjects( + [row({id: 1, retained: 100})], + [row({id: 8, retained: 100}), row({id: 9, retained: 50})], + ); + expect(out).toHaveLength(2); + const gone = out.filter((r) => r.status === 'REMOVED'); + expect(gone).toHaveLength(1); + expect(gone[0].c_id).toBeNull(); + expect(gone[0].b_id).toBe(9); + expect(gone[0].delta_retained).toBe(-50); + }); + + test('different className → distinct buckets, no pairing', () => { + const out = pairObjects( + [row({id: 1, className: 'Foo', retained: 100})], + [row({id: 2, className: 'Bar', retained: 100})], + ); + expect(out).toHaveLength(2); + expect(out.map((r) => r.status).sort()).toEqual(['NEW', 'REMOVED']); + }); + + test('different heapType → distinct buckets', () => { + const out = pairObjects( + [row({id: 1, heapType: 'app', retained: 100})], + [row({id: 2, heapType: 'zygote', retained: 100})], + ); + expect(out).toHaveLength(2); + expect(out.map((r) => r.status).sort()).toEqual(['NEW', 'REMOVED']); + }); + + test('different valueString → distinct buckets', () => { + const out = pairObjects( + [row({id: 1, valueString: 'hello', retained: 40})], + [row({id: 2, valueString: 'world', retained: 40})], + ); + expect(out.map((r) => r.status).sort()).toEqual(['NEW', 'REMOVED']); + }); + + test('different arrayLength → distinct buckets', () => { + const out = pairObjects( + [row({id: 1, arrayLength: 4, retained: 40})], + [row({id: 2, arrayLength: 8, retained: 40})], + ); + expect(out.map((r) => r.status).sort()).toEqual(['NEW', 'REMOVED']); + }); + + test('zip is by retained-desc within a bucket', () => { + // Three current, two baseline. Sort desc on each side, zip positions + // 0,1; current[2] is the leftover NEW. + const out = pairObjects( + [ + row({id: 1, retained: 50}), + row({id: 2, retained: 200}), + row({id: 3, retained: 100}), + ], + [row({id: 8, retained: 80}), row({id: 9, retained: 150})], + ); + expect(out).toHaveLength(3); + const byPair = (cId: number) => out.find((r) => r.c_id === cId); + // Position 0: current=200, baseline=150 → paired, GREW + expect(byPair(2)?.status).toBe('GREW'); + expect(byPair(2)?.b_id).toBe(9); + // Position 1: current=100, baseline=80 → paired, GREW + expect(byPair(3)?.status).toBe('GREW'); + expect(byPair(3)?.b_id).toBe(8); + // Leftover current at pos 2: id=1, retained=50 → NEW + expect(byPair(1)?.status).toBe('NEW'); + expect(byPair(1)?.b_id).toBeNull(); + }); + + test('tie on retained → deterministic by id', () => { + // Two current with the same retained, two baseline same retained. + // Both sides sort by id ascending as tiebreaker → consistent pairing + // across runs. + const out1 = pairObjects( + [row({id: 5, retained: 100}), row({id: 1, retained: 100})], + [row({id: 9, retained: 100}), row({id: 2, retained: 100})], + ); + const out2 = pairObjects( + [row({id: 1, retained: 100}), row({id: 5, retained: 100})], + [row({id: 2, retained: 100}), row({id: 9, retained: 100})], + ); + const sig = (rs: ReturnType<typeof pairObjects>) => + rs + .map((r) => `${r.c_id}/${r.b_id}`) + .sort() + .join(','); + expect(sig(out1)).toBe(sig(out2)); + expect(sig(out1)).toBe('1/2,5/9'); + }); + + test('null heapType matches null heapType', () => { + const out = pairObjects( + [row({id: 1, heapType: null, retained: 50})], + [row({id: 2, heapType: null, retained: 80})], + ); + expect(out).toHaveLength(1); + expect(out[0].status).toBe('SHRANK'); + }); + + test('valueString containing pipe does not collide with empty bucket', () => { + // Regression check: the bucket separator must be unambiguous so a + // user-supplied string can never look like a different bucket. + const out = pairObjects( + [row({id: 1, valueString: 'a|b', retained: 10})], + [row({id: 2, valueString: 'a', retained: 10, arrayLength: null})], + ); + expect(out).toHaveLength(2); + expect(out.map((r) => r.status).sort()).toEqual(['NEW', 'REMOVED']); + }); + + test('all-zero deltas after pairing → all UNCHANGED', () => { + const out = pairObjects( + [row({id: 1}), row({id: 2})], + [row({id: 8}), row({id: 9})], + ); + expect(out.every((r) => r.status === 'UNCHANGED')).toBe(true); + }); +});
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/header.ts b/ui/src/plugins/com.android.HeapDumpExplorer/header.ts new file mode 100644 index 0000000..82b2775 --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/header.ts
@@ -0,0 +1,288 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Baseline-pool controls inlined into the top bar. Renders nothing when +// no baseline is loaded and no load is in flight — the "Diff against +// another trace" entry point lives in Overview in that case. + +import m from 'mithril'; +import type {Trace} from '../../public/trace'; +import {Button, ButtonVariant} from '../../widgets/button'; +import {Callout} from '../../widgets/callout'; +import {Intent} from '../../widgets/common'; +import {RadioGroup} from '../../widgets/radio_group'; +import {MenuDivider, MenuItem, PopupMenu} from '../../widgets/menu'; +import type {DiffMode, BaselineDumpRef, BaselineTrace} from './baseline/state'; +import { + clearActiveBaseline, + dispose as disposeAllBaselines, + getActiveBaseline, + getBaselineTraces, + getMode, + removeBaselineTrace, + setActiveBaseline, + setMode, +} from './baseline/state'; +import { + clearLoadError, + getLoadState, + triggerFileLoad, +} from './baseline/load_action'; +import type {HeapDump} from './queries'; + +const MODES: ReadonlyArray<{key: DiffMode; label: string}> = [ + {key: 'diff', label: 'Diff'}, + {key: 'current', label: 'Current'}, + {key: 'baseline', label: 'Baseline'}, +]; + +const FILE_ACCEPT = + '.pftrace,.hprof,.perfetto-trace,.pb,.gz,application/octet-stream'; + +/** + * True when the baseline header should contribute UI to the top bar. The + * parent uses this to decide whether to render the row at all in + * combination with the primary dump selector. + */ +export function shouldShowBaselineHeader(): boolean { + const traces = getBaselineTraces(); + const {loading, error} = getLoadState(); + const hasError = error !== null && getActiveBaseline() === null; + return traces.length > 0 || loading || hasError; +} + +/** + * Triggers the hidden file input owned by the most recently mounted + * baseline header instance, if any. Lets the Overview-tab CTA share the + * same picker as the top-bar selector — no parallel hidden inputs that + * need separate state to coordinate. + */ +let openFilePickerImpl: (() => void) | null = null; +export function openBaselineFilePicker(): void { + if (openFilePickerImpl) { + openFilePickerImpl(); + } else { + requestAnimationFrame(() => openFilePickerImpl?.()); + } +} + +interface HeapDumpDiffHeaderAttrs { + readonly trace: Trace; +} + +export class HeapDumpDiffHeader + implements m.ClassComponent<HeapDumpDiffHeaderAttrs> +{ + private inputEl: HTMLInputElement | null = null; + + oncreate() { + openFilePickerImpl = () => this.inputEl?.click(); + } + onremove() { + openFilePickerImpl = null; + } + view({attrs}: m.Vnode<HeapDumpDiffHeaderAttrs>): m.Children { + const traces = getBaselineTraces(); + const active = getActiveBaseline(); + const {loading, progressPct, error} = getLoadState(); + const hidden = !shouldShowBaselineHeader(); + + // Always render the hidden input so the Overview CTA can click it. + const fileInput = m('input', { + 'type': 'file', + 'accept': FILE_ACCEPT, + 'style': 'display:none', + 'aria-hidden': 'true', + 'oncreate': (v) => { + this.inputEl = v.dom as HTMLInputElement; + }, + 'onchange': async (e: Event) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + await triggerFileLoad(attrs.trace.raf, file); + if (this.inputEl) this.inputEl.value = ''; + }, + }); + + if (hidden) return fileInput; + + return [ + fileInput, + loading ? renderLoadingCallout(progressPct) : null, + error && !active ? renderErrorCallout(error) : null, + m('span', {class: 'pf-hde-top-bar__label'}, 'Baseline:'), + renderBaselineSelector(traces, active, () => this.inputEl?.click()), + m('span', {class: 'pf-hde-top-bar__spacer'}), + active + ? m( + RadioGroup, + { + 'selectedValue': getMode(), + 'onValueChange': (value: string) => setMode(value as DiffMode), + 'aria-label': 'Diff mode', + }, + MODES.map((mode) => + m(RadioGroup.Button, {value: mode.key}, mode.label), + ), + ) + : null, + // One-click affordances: mirror the popup MenuItems but exposed + // directly in the top-bar so users can deactivate or tear down + // baselines without re-opening the picker. The popup keeps the + // same actions as the canonical entry point. + active + ? m(Button, { + 'icon': 'close', + 'compact': true, + 'aria-label': 'Clear active baseline', + 'title': 'Clear active baseline', + 'onclick': () => clearActiveBaseline(), + }) + : null, + traces.length > 0 + ? m(Button, { + 'icon': 'delete_sweep', + 'compact': true, + 'aria-label': 'Remove all baseline traces', + 'title': 'Remove all baseline traces', + 'onclick': () => disposeAllBaselines(), + }) + : null, + ]; + } +} + +function renderLoadingCallout(progressPct: number): m.Children { + return m( + Callout, + {icon: 'hourglass_empty', intent: Intent.None}, + `Loading baseline trace… ${progressPct}%`, + ); +} + +function renderErrorCallout(message: string): m.Children { + return m( + Callout, + { + 'icon': 'error', + 'intent': Intent.Danger, + 'dismissible': true, + 'onDismiss': () => clearLoadError(), + 'role': 'alert', + 'aria-live': 'assertive', + }, + message, + ); +} + +function renderBaselineSelector( + traces: ReadonlyArray<BaselineTrace>, + active: BaselineDumpRef | null, + openFilePicker: () => void, +): m.Children { + const triggerLabel = active ? activeLabel(active) : 'None — pick to diff'; + return m( + PopupMenu, + { + trigger: m(Button, { + label: triggerLabel, + icon: 'difference', + rightIcon: 'arrow_drop_down', + variant: ButtonVariant.Outlined, + compact: true, + }), + }, + [ + ...traces.flatMap((t) => renderTraceSection(t, active)), + traces.length > 0 ? m(MenuDivider) : null, + m(MenuItem, { + label: 'Add baseline trace…', + icon: 'upload_file', + onclick: openFilePicker, + }), + active + ? m(MenuItem, { + label: 'Clear active baseline', + icon: 'close', + onclick: () => clearActiveBaseline(), + }) + : null, + ], + ); +} + +function renderTraceSection( + t: BaselineTrace, + active: BaselineDumpRef | null, +): m.Children[] { + const heading = m(MenuItem, { + label: m('span', {class: 'pf-hde-top-bar__section-title'}, t.title), + icon: 'folder_open', + closePopupOnClick: false, + onclick: () => {}, + }); + const dumpItems = t.dumps.map((d) => + m(MenuItem, { + label: dumpLabel(d, t.dumps), + icon: active && active.trace === t && active.dump === d ? 'check' : '', + onclick: () => setActiveBaseline({trace: t, dump: d}), + }), + ); + const removeItem = m(MenuItem, { + label: `Remove ${t.title}`, + icon: 'delete', + onclick: () => removeBaselineTrace(t.id), + }); + return [heading, ...dumpItems, removeItem, m(MenuDivider)]; +} + +function activeLabel(b: BaselineDumpRef): string { + return `${b.trace.title} · ${dumpProcessLabel(b.dump)}`; +} + +// Multi-dump traces show an offset from `dumps[0].ts`; baseline trace +// times aren't commensurable with the primary's absolute start. +function dumpLabel(d: HeapDump, dumps: ReadonlyArray<HeapDump>): string { + if (dumps.length <= 1) return dumpProcessLabel(d); + return `${dumpProcessLabel(d)} — ${formatBaselineOffset(d, dumps)}`; +} + +function dumpProcessLabel(d: HeapDump): string { + // hprof has no real pid — trace_processor reports 0. "pid 0" reads + // like kernel, so treat 0 as missing. + const hasPid = d.pid !== null && d.pid !== 0; + if (d.processName !== null && hasPid) { + return `${d.processName} (pid ${d.pid})`; + } + if (d.processName !== null) return d.processName; + if (hasPid) return `pid ${d.pid}`; + return 'Java heap dump'; +} + +// "first" / "+250ms" / "+5.2s" / "+3m 15s". +function formatBaselineOffset( + d: HeapDump, + dumps: ReadonlyArray<HeapDump>, +): string { + const start = dumps[0].ts; + const deltaNs = (d.ts as bigint) - (start as bigint); + if (deltaNs === 0n) return 'first'; + const ms = Number(deltaNs / 1_000_000n); + if (ms < 1000) return `+${ms}ms`; + const sec = ms / 1000; + if (sec < 60) return `+${sec.toFixed(1)}s`; + const minutes = Math.floor(sec / 60); + const remSec = Math.round(sec - minutes * 60); + return remSec === 0 ? `+${minutes}m` : `+${minutes}m ${remSec}s`; +}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/heap_dump_page.ts b/ui/src/plugins/com.android.HeapDumpExplorer/heap_dump_page.ts index 452e945..87e87e1 100644 --- a/ui/src/plugins/com.android.HeapDumpExplorer/heap_dump_page.ts +++ b/ui/src/plugins/com.android.HeapDumpExplorer/heap_dump_page.ts
@@ -15,14 +15,13 @@ import m from 'mithril'; import {Time} from '../../base/time'; import {Spinner} from '../../widgets/spinner'; -import {Button, ButtonVariant} from '../../widgets/button'; -import {MenuItem, PopupMenu} from '../../widgets/menu'; +import {EmptyState} from '../../widgets/empty_state'; import {Tabs} from '../../widgets/tabs'; import type {TabsTab} from '../../widgets/tabs'; -import {formatDuration} from '../../components/time_utils'; import type {NavState, NavView} from './nav_state'; +import type {Engine} from '../../trace_processor/engine'; import type {OverviewData} from './types'; -import type * as queries from './queries'; +import * as queries from './queries'; import OverviewView from './views/overview_view'; import DominatorsView from './views/dominators_view'; import ObjectView from './views/object_view'; @@ -35,6 +34,22 @@ import FlamegraphView from './views/flamegraph_view'; import type {HeapDumpExplorerSession} from './session'; +import { + baselineDumpFilterSql, + dispose as disposeBaseline, + getActiveBaseline, + getMode, + isDiffActive, +} from './baseline/state'; +import {TopBar} from './top_bar'; +import ClassesDiffView from './views/diff/classes_diff_view'; +import StringsDiffView from './views/diff/strings_diff_view'; +import ArraysDiffView from './views/diff/arrays_diff_view'; +import BitmapsDiffView from './views/diff/bitmaps_diff_view'; +import DominatorsDiffView from './views/diff/dominators_diff_view'; +import AllObjectsDiffView from './views/diff/all_objects_diff_view'; +import ObjectDiffView from './views/diff/object_diff_view'; + interface HeapDumpPageAttrs { readonly session: HeapDumpExplorerSession; readonly subpage: string | undefined; @@ -43,6 +58,39 @@ const FG_KEY_PREFIX = 'fg-'; const INSTANCE_KEY_PREFIX = 'inst-'; +// Overview cache keyed on (engine identity, filter SQL): in diff/baseline mode +// the page shows overviews from more than one engine (primary + pooled +// baseline), and one engine serves several dumps over the page's lifetime, so +// neither (upid, ts) nor engine identity alone is a sufficient key. +const overviewCache = new Map<string, OverviewData>(); +const overviewLoadingFor = new Set<string>(); + +let nextEngineUid = 1; +const engineUid = new WeakMap<Engine, number>(); +function engineKey(engine: Engine, filterSql: string): string { + let id = engineUid.get(engine); + if (id === undefined) { + id = nextEngineUid++; + engineUid.set(engine, id); + } + return `${id}:${filterSql}`; +} + +// The page's last (primary | baseline | diff) tab context. A context flip +// invalidates any open object tabs (a paired diff tab is meaningless once we +// leave diff mode), so we reset them on change. `null` means "no prior +// context", which the flip check below treats as "don't clear" so a +// permalink-restored object tab survives the first render. +let lastTabContext: string | null = null; + +// Drops all page-level caches. Called on every trace load (see index.ts) so +// state from a previously-loaded trace never bleeds into the next one. +export function resetCachedOverview(): void { + overviewCache.clear(); + overviewLoadingFor.clear(); + lastTabContext = null; +} + function fgTabKey(pathHashes: string, isDominator: boolean): string { return `${FG_KEY_PREFIX}${isDominator ? 'd' : 'n'}:${pathHashes}`; } @@ -83,11 +131,16 @@ activeDump: queries.HeapDump, state: NavState, overview: OverviewData, + baselineOverview: OverviewData | undefined, + baselineLoading: boolean, ): {tabs: TabsTab[]; actions: Map<string, TabActions>} { const {engine, trace, navigateWithTabs, clearNavParam} = session; const hideExplanationSetting = session.hideDefaultChangedHint; const hideHint = hideExplanationSetting.get(); const actions = new Map<string, TabActions>(); + const diffActive = isDiffActive(); + const activeBaseline = getActiveBaseline(); + const baselineEngine = activeBaseline?.trace.engine; const tabs: TabsTab[] = [ { key: 'overview', @@ -95,6 +148,9 @@ content: m(OverviewView, { overview, activeDump, + diffActive, + baselineOverview: diffActive ? baselineOverview : undefined, + baselineLoading: diffActive && baselineLoading, navigate: navigateWithTabs, showDefaultChangedHint: session.autoNavigated && !hideHint, onBackToTimeline: () => trace.navigate('#!/viewer'), @@ -117,77 +173,136 @@ upid: activeDump.upid, ts: activeDump.ts, }), + // For SAME-trace diff, leave baselineEngine unset → SQL JOIN path. + // For CROSS-trace diff, pass the baseline engine → FlamegraphView + // pairs in JS and writes paired rows back into the cur engine. + baseline: + diffActive && activeBaseline?.dump + ? { + upid: activeBaseline.dump.upid, + ts: Time.fromRaw(activeBaseline.dump.ts), + } + : undefined, + baselineEngine: + diffActive && activeBaseline?.trace.disposable === true + ? activeBaseline.trace.engine + : undefined, }), }, { key: 'classes', title: 'Classes', - content: m(ClassesView, { - engine, - activeDump, - navigate: navigateWithTabs, - clearNavParam, - initialRootClass: - state.view === 'classes' ? state.params.rootClass : undefined, - }), + content: + diffActive && baselineEngine + ? m(ClassesDiffView, { + currentEngine: engine, + baselineEngine, + navigate: navigateWithTabs, + }) + : m(ClassesView, { + engine, + activeDump, + navigate: navigateWithTabs, + clearNavParam, + initialRootClass: + state.view === 'classes' ? state.params.rootClass : undefined, + }), }, { key: 'objects', title: 'Objects', - content: m(AllObjectsView, { - engine, - activeDump, - navigate: navigateWithTabs, - clearNavParam, - initialClass: state.view === 'objects' ? state.params.cls : undefined, - }), + content: + diffActive && baselineEngine + ? m(AllObjectsDiffView, { + currentEngine: engine, + baselineEngine, + cls: state.view === 'objects' ? state.params.cls : undefined, + navigate: navigateWithTabs, + }) + : m(AllObjectsView, { + engine, + activeDump, + navigate: navigateWithTabs, + clearNavParam, + initialClass: + state.view === 'objects' ? state.params.cls : undefined, + }), }, { key: 'dominators', title: 'Dominators', - content: m(DominatorsView, { - engine, - activeDump, - navigate: navigateWithTabs, - }), + content: + diffActive && baselineEngine + ? m(DominatorsDiffView, { + currentEngine: engine, + baselineEngine, + navigate: navigateWithTabs, + }) + : m(DominatorsView, { + engine, + activeDump, + navigate: navigateWithTabs, + }), }, { key: 'bitmaps', title: 'Bitmaps', - content: m(BitmapGalleryView, { - engine, - activeDump, - navigate: navigateWithTabs, - clearNavParam, - hasFieldValues: overview.hasFieldValues, - filterKey: - state.view === 'bitmaps' ? state.params.filterKey : undefined, - }), + content: + diffActive && baselineEngine + ? m(BitmapsDiffView, { + currentEngine: engine, + baselineEngine, + navigate: navigateWithTabs, + }) + : m(BitmapGalleryView, { + engine, + activeDump, + navigate: navigateWithTabs, + clearNavParam, + hasFieldValues: overview.hasFieldValues, + filterKey: + state.view === 'bitmaps' ? state.params.filterKey : undefined, + }), }, { key: 'strings', title: 'Strings', - content: m(StringsView, { - engine, - activeDump, - navigate: navigateWithTabs, - clearNavParam, - initialQuery: state.view === 'strings' ? state.params.q : undefined, - hasFieldValues: overview.hasFieldValues, - }), + content: + diffActive && baselineEngine + ? m(StringsDiffView, { + currentEngine: engine, + baselineEngine, + navigate: navigateWithTabs, + }) + : m(StringsView, { + engine, + activeDump, + navigate: navigateWithTabs, + clearNavParam, + initialQuery: + state.view === 'strings' ? state.params.q : undefined, + hasFieldValues: overview.hasFieldValues, + }), }, { key: 'arrays', title: 'Arrays', - content: m(ArraysView, { - engine, - activeDump, - navigate: navigateWithTabs, - clearNavParam, - initialArrayHash: - state.view === 'arrays' ? state.params.arrayHash : undefined, - hasFieldValues: overview.hasFieldValues, - }), + content: + diffActive && baselineEngine + ? m(ArraysDiffView, { + currentEngine: engine, + baselineEngine, + navigate: navigateWithTabs, + }) + : m(ArraysView, { + engine, + activeDump, + navigate: navigateWithTabs, + clearNavParam, + initialArrayHash: + state.view === 'arrays' ? state.params.arrayHash : undefined, + hasFieldValues: overview.hasFieldValues, + }), }, ]; @@ -229,14 +344,24 @@ key, title: obj.label, closeButton: true, - content: m(ObjectView, { - engine, - activeDump, - heaps: overview.heaps, - navigate: navigateWithTabs, - openFlamegraphPivotedAt: session.openFlamegraphPivotedAt, - params: {id: obj.objId}, - }), + content: + diffActive && baselineEngine + ? m(ObjectDiffView, { + currentEngine: engine, + baselineEngine, + activeDump, + currentId: obj.currentId, + baselineId: obj.baselineId, + navigate: navigateWithTabs, + }) + : m(ObjectView, { + engine, + activeDump, + heaps: overview.heaps, + navigate: navigateWithTabs, + openFlamegraphPivotedAt: session.openFlamegraphPivotedAt, + params: {id: obj.objId}, + }), }); actions.set(key, { select: () => session.navigate('object', {id: obj.objId}), @@ -247,59 +372,39 @@ return {tabs, actions}; } -function processLabel(d: queries.HeapDump): string { - return d.processName !== null - ? `${d.processName} (pid ${d.pid})` - : `pid ${d.pid}`; -} - -function renderDumpSelector(session: HeapDumpExplorerSession): m.Children { - const allDumps = session.dumps; - const active = session.activeDump; - if (allDumps.length <= 1 || active === null) return null; - - return m( - 'div', - {class: 'pf-hde-dump-selector'}, - m('span', {class: 'pf-hde-dump-selector__label'}, 'Heap dump:'), - m( - PopupMenu, - { - trigger: m(Button, { - label: processLabel(active), - icon: 'memory', - rightIcon: 'arrow_drop_down', - variant: ButtonVariant.Outlined, - compact: true, - }), - }, - allDumps.map((d) => { - const offset = Time.diff( - Time.fromRaw(d.ts), - session.trace.traceInfo.start, - ); - return m(MenuItem, { - label: `${processLabel(d)} — ${formatDuration(session.trace, offset)}`, - active: d === active, - onclick: () => session.selectDump(d), - }); - }), - ), - ); -} - export class HeapDumpPage implements m.ClassComponent<HeapDumpPageAttrs> { oncreate({attrs}: m.VnodeDOM<HeapDumpPageAttrs>) { attrs.session.setNavigateCallback((sub) => { window.location.hash = `!/heapdump${sub ? '/' + sub : ''}`; }); - void attrs.session.loadOverview(); } onremove({attrs}: m.VnodeDOM<HeapDumpPageAttrs>) { attrs.session.setNavigateCallback(undefined); } + // Loads the overview for one (engine, filter) into the shared cache. No-op if + // already cached or in flight. View-driven so it serves the primary dump, a + // baseline-only view, and either side of a diff with the same code path. + private kickOverviewLoadFor(engine: Engine | null, filterSql: string): void { + if (!engine) return; + const key = engineKey(engine, filterSql); + if (overviewCache.has(key) || overviewLoadingFor.has(key)) return; + overviewLoadingFor.add(key); + queries + .getOverview(engine, filterSql) + .then((data) => { + overviewCache.set(key, data); + }) + .catch((err) => { + console.error('Failed to load overview:', err); + }) + .finally(() => { + overviewLoadingFor.delete(key); + m.redraw(); + }); + } + view({attrs}: m.Vnode<HeapDumpPageAttrs>) { const {session, subpage} = attrs; session.syncFromSubpage(subpage); @@ -307,25 +412,104 @@ session.syncFlamegraphTabFromNav(); const active = session.activeDump; - const overview = session.cachedOverview; - if (active === null || overview === null) { + if (active === null) { return m( 'div', {class: 'pf-hde-page'}, - renderDumpSelector(session), + m(EmptyState, { + icon: 'memory', + title: 'No heap graph data in this trace', + fillHeight: true, + }), + ); + } + + const topBar = m(TopBar, { + trace: session.trace, + session, + onDumpChanged: () => {}, + }); + + const mode = getMode(); + const baseline = getActiveBaseline(); + const activeIsBaseline = mode === 'baseline' && baseline !== null; + // Baseline-only mode points the standard (non-diff) views at the baseline + // dump without changing the user-visible primary selection. + queries.setDumpFilterOverride(activeIsBaseline ? baseline!.dump : null); + queries.setActiveDumpForDiff(session.activeDump); + + const diffActive = isDiffActive(); + const tabContext = diffActive + ? 'diff' + : activeIsBaseline + ? 'baseline' + : 'primary'; + if (lastTabContext !== null && lastTabContext !== tabContext) { + session.clearInstanceTabs(); + } + lastTabContext = tabContext; + + const overviewEngine = activeIsBaseline + ? baseline!.trace.engine + : session.engine; + // Always filter to the dump the user is actually viewing — passing + // `undefined` would rely on dumpFilterOverride, which is null in diff mode + // and yields '1=1' (aggregates across every dump in the engine). + const overviewDump = activeIsBaseline ? baseline!.dump : active; + const overviewFilter = queries.dumpFilterSql(overviewDump, 'o'); + this.kickOverviewLoadFor(overviewEngine, overviewFilter); + const overview = overviewCache.get( + engineKey(overviewEngine, overviewFilter), + ); + + const baselineEngine = baseline?.trace.engine ?? null; + const baselineFilter = baselineDumpFilterSql('o'); + if (baselineEngine && mode === 'diff') { + this.kickOverviewLoadFor(baselineEngine, baselineFilter); + } + const baselineCacheKey = + baselineEngine !== null + ? engineKey(baselineEngine, baselineFilter) + : null; + const baselineOverview = + baselineCacheKey !== null + ? overviewCache.get(baselineCacheKey) + : undefined; + const baselineLoading = + baselineCacheKey !== null && + baselineOverview === undefined && + overviewLoadingFor.has(baselineCacheKey); + + if (!overview) { + return m( + 'div', + {class: 'pf-hde-page'}, + topBar, m('div', {class: 'pf-hde-loading'}, m(Spinner, {easing: true})), ); } - // Keyed so Mithril remounts views (and their SQLDataSources) on - // dump switch. - const tabsKey = `${active.upid}:${active.ts}`; - const {tabs, actions} = buildTabs(session, active, session.nav, overview); + // Key the Tabs widget on (primary dump, baseline dump, mode) — a mode or + // baseline change swaps the engines / filters captured by the standard + // views at oninit, so remounting forces a re-fetch. + const primaryKey = `${active.upid}:${active.ts}`; + const baselineKey = baseline + ? `${baseline.trace.id}:${baseline.dump.upid}:${baseline.dump.ts}` + : 'none'; + const tabsKey = `${primaryKey}|${baselineKey}|${mode}`; + const {tabs, actions} = buildTabs( + session, + active, + session.nav, + overview, + baselineOverview, + baselineLoading, + ); return m( 'div', {class: 'pf-hde-page'}, - renderDumpSelector(session), + topBar, m( 'main', {class: 'pf-hde-main'}, @@ -340,3 +524,6 @@ ); } } + +/** Re-exported convenience for index.ts so it can dispose on trace change. */ +export {disposeBaseline};
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/index.ts b/ui/src/plugins/com.android.HeapDumpExplorer/index.ts index 806b14b..2ad2859 100644 --- a/ui/src/plugins/com.android.HeapDumpExplorer/index.ts +++ b/ui/src/plugins/com.android.HeapDumpExplorer/index.ts
@@ -21,7 +21,11 @@ import HeapProfilePlugin, { traceHasTimelineData, } from '../dev.perfetto.HeapProfile'; -import {HeapDumpPage} from './heap_dump_page'; +import { + HeapDumpPage, + resetCachedOverview, + disposeBaseline, +} from './heap_dump_page'; import {HeapDumpExplorerSession} from './session'; import {migrateHdeState} from './persisted_state'; @@ -58,6 +62,14 @@ ); const restored = await session.loadDumps(); + resetCachedOverview(); + disposeBaseline(); + // The eager dispose above only runs when the next trace also has heap + // data. Defer disposal onto the trace's trash too, so baseline engines + // (each a separate trace_processor Worker) are torn down when this trace + // is closed or replaced by a non-heap trace, not leaked. + ctx.trash.defer(disposeBaseline); + ctx.pages.registerPage({ route: '/heapdump', render: (subpage) => m(HeapDumpPage, {session, subpage}),
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/persisted_state.ts b/ui/src/plugins/com.android.HeapDumpExplorer/persisted_state.ts index 09f6458..9f18424 100644 --- a/ui/src/plugins/com.android.HeapDumpExplorer/persisted_state.ts +++ b/ui/src/plugins/com.android.HeapDumpExplorer/persisted_state.ts
@@ -38,6 +38,12 @@ const INSTANCE_TAB_SCHEMA = z.object({ objId: z.number(), label: z.string(), + // In diff mode an object tab pairs a current-side and a baseline-side object + // id; either may be absent when the object exists on only one side. Plain + // (non-diff) tabs leave currentId = objId and baselineId = null. Optional so + // older permalinks (and non-diff tabs) parse unchanged. + currentId: z.number().nullable().optional(), + baselineId: z.number().nullable().optional(), }); export const HDE_STATE_SCHEMA = z
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/persisted_state_unittest.ts b/ui/src/plugins/com.android.HeapDumpExplorer/persisted_state_unittest.ts new file mode 100644 index 0000000..fbc0c36 --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/persisted_state_unittest.ts
@@ -0,0 +1,65 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Unit tests for the slice of Heap Dump Explorer state that survives in a +// shared permalink. This is the contract the core (de)serializes; a break here +// silently corrupts shared links, so the round-trip is pinned explicitly. + +import {migrateHdeState, type HdeState} from './persisted_state'; + +// A permalink survives a JSON encode/decode in the core, so round-tripping +// through JSON is the realistic fidelity check. +function roundTrip(state: HdeState): HdeState { + return migrateHdeState(JSON.parse(JSON.stringify(state))); +} + +describe('migrateHdeState', () => { + it('round-trips a full state including diff-mode object tabs', () => { + const state: HdeState = { + activeDump: {upid: 3, ts: '123456789'}, + nav: 'object_0x2a', + flamegraphTabs: [ + {pathHashes: 'a,b,c', isDominator: false}, + {pathHashes: 'd', isDominator: true}, + ], + // A diff-mode tab pairs a current-side and baseline-side object id. + instanceTabs: [ + {objId: 42, label: 'Foo', currentId: 42, baselineId: 7}, + {objId: 99, label: 'OnlyCurrent', currentId: 99, baselineId: null}, + ], + flamegraphPanelState: { + selectedMetricName: 'Object Size', + filters: [], + view: {kind: 'TOP_DOWN'}, + }, + }; + expect(roundTrip(state)).toEqual(state); + }); + + it('keeps a legacy permalink without currentId/baselineId parseable', () => { + // Permalinks predating the diff feature stored only {objId, label}. + const legacy = { + activeDump: {upid: 1, ts: '10'}, + instanceTabs: [{objId: 5, label: 'Legacy'}], + }; + const migrated = migrateHdeState(legacy); + expect(migrated.instanceTabs).toEqual([{objId: 5, label: 'Legacy'}]); + }); + + it('falls back to empty state on unparseable input rather than throwing', () => { + expect(migrateHdeState(undefined)).toEqual({}); + expect(migrateHdeState('not an object')).toEqual({}); + expect(migrateHdeState({activeDump: {upid: 'nope'}})).toEqual({}); + }); +});
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/queries.ts b/ui/src/plugins/com.android.HeapDumpExplorer/queries.ts index 32b3fbe..8daff7c 100644 --- a/ui/src/plugins/com.android.HeapDumpExplorer/queries.ts +++ b/ui/src/plugins/com.android.HeapDumpExplorer/queries.ts
@@ -44,7 +44,8 @@ readonly upid: number; readonly ts: bigint; readonly processName: string | null; - readonly pid: number; + /** Null when the trace has no process metadata (typical for raw hprof). */ + readonly pid: number | null; } export async function loadDumpsList(engine: Engine): Promise<HeapDump[]> { @@ -74,17 +75,36 @@ upid: it.upid, ts: it.ts, processName: it.pname, - pid: it.pid ?? 0, + pid: it.pid, }); } return result; } -export function dumpFilterSql(dump: HeapDump, alias: string = 'o'): string { - return ( - `${alias}.upid = ${dump.upid} ` + - `AND ${alias}.graph_sample_ts = ${dump.ts}` - ); +// When set, dumpFilterSql() yields this dump's filter instead of the +// passed dump. Heap_dump_page sets this in Baseline-only mode so +// the standard (non-diff) views read baseline data without changing the +// user-visible primary selection. +let dumpFilterOverride: HeapDump | null = null; + +export function setDumpFilterOverride(d: HeapDump | null): void { + dumpFilterOverride = d; +} + +let activeDumpForDiff: HeapDump | null = null; + +export function setActiveDumpForDiff(d: HeapDump | null): void { + activeDumpForDiff = d; +} + +export function getActiveDump(): HeapDump | null { + return activeDumpForDiff; +} + +export function dumpFilterSql(dump?: HeapDump, alias: string = 'o'): string { + const d = dump ?? dumpFilterOverride; + if (!d) return '1=1'; + return `${alias}.upid = ${d.upid} AND ${alias}.graph_sample_ts = ${d.ts}`; } async function requireDominatorTree(engine: Engine): Promise<void> { @@ -239,11 +259,24 @@ return result; } +// `dumpOrFilter` can be a `HeapDump` object or a pre-computed filter string. +// Passing a filter string is useful in diff mode where the filter must match +// the specific engine (e.g. baseline engine). export async function getOverview( engine: Engine, - activeDump: HeapDump, + dumpOrFilter: HeapDump | string, + activeDump?: HeapDump, ): Promise<OverviewData> { - const dumpFilter = dumpFilterSql(activeDump, 'o'); + // Accept either a HeapDump (computes its own filter) or a pre-computed + // filter string (used by diff mode where the filter must match a + // specific engine's dumps). `activeDump` lets the caller pass a HeapDump + // alongside a filter string for downstream code (e.g. bitmap content + // hashing) that needs the full dump record. + const dumpFilter = + typeof dumpOrFilter === 'string' + ? dumpOrFilter + : dumpFilterSql(dumpOrFilter, 'o'); + const dump = typeof dumpOrFilter === 'string' ? activeDump : dumpOrFilter; const countRes = await engine.query(` SELECT sum(iif(o.reachable, 1, 0)) AS reachable, @@ -261,39 +294,43 @@ const unreachableInstanceCount = countIt.unreachable; const classCount = countIt.classes; - // Process stats at the dump instant. Null when the trace carries no such stats. - const statsRes = await engine.query(` - INCLUDE PERFETTO MODULE android.oom_adjuster; - INCLUDE PERFETTO MODULE android.memory.heap_graph.heap_graph_stats; - SELECT - CAST(oom_score_adj AS INT) AS score, - android_oom_adj_score_to_bucket_name(CAST(oom_score_adj AS INT)) AS bucket, - anon_rss_and_swap_size AS anonRssAndSwapSize, - dmabuf_rss_size AS dmabufRssSize, - process_uptime AS processUptime - FROM android_heap_graph_stats - WHERE upid = ${activeDump.upid} - AND graph_sample_ts = ${activeDump.ts} - LIMIT 1 - `); - const statsIt = statsRes.iter({ - score: NUM_NULL, - bucket: STR_NULL, - anonRssAndSwapSize: LONG_NULL, - dmabufRssSize: LONG_NULL, - processUptime: LONG_NULL, - }); + // Process stats at the dump instant. Null when the trace carries no such + // stats, or when the caller passed only a filter string with no `dump` + // record (the stats query needs the concrete upid + ts of one dump). let oomScore: number | null = null; let oomBucket: string | null = null; let anonRssAndSwapSize: bigint | null = null; let dmabufRssSize: bigint | null = null; let processUptime: bigint | null = null; - if (statsIt.valid()) { - oomScore = statsIt.score; - oomBucket = statsIt.bucket; - anonRssAndSwapSize = statsIt.anonRssAndSwapSize; - dmabufRssSize = statsIt.dmabufRssSize; - processUptime = statsIt.processUptime; + if (dump !== undefined) { + const statsRes = await engine.query(` + INCLUDE PERFETTO MODULE android.oom_adjuster; + INCLUDE PERFETTO MODULE android.memory.heap_graph.heap_graph_stats; + SELECT + CAST(oom_score_adj AS INT) AS score, + android_oom_adj_score_to_bucket_name(CAST(oom_score_adj AS INT)) AS bucket, + anon_rss_and_swap_size AS anonRssAndSwapSize, + dmabuf_rss_size AS dmabufRssSize, + process_uptime AS processUptime + FROM android_heap_graph_stats + WHERE upid = ${dump.upid} + AND graph_sample_ts = ${dump.ts} + LIMIT 1 + `); + const statsIt = statsRes.iter({ + score: NUM_NULL, + bucket: STR_NULL, + anonRssAndSwapSize: LONG_NULL, + dmabufRssSize: LONG_NULL, + processUptime: LONG_NULL, + }); + if (statsIt.valid()) { + oomScore = statsIt.score; + oomBucket = statsIt.bucket; + anonRssAndSwapSize = statsIt.anonRssAndSwapSize; + dmabufRssSize = statsIt.dmabufRssSize; + processUptime = statsIt.processUptime; + } } const heapRes = await engine.query(` @@ -378,8 +415,8 @@ .filter((b) => b.nativePtr !== null) .map((b) => ({objectId: b.id, nativePtr: b.nativePtr!})); const hashes = - hashInputs.length > 0 - ? await batchBitmapBufferHashes(engine, activeDump, hashInputs) + hashInputs.length > 0 && dump + ? await batchBitmapBufferHashes(engine, dump, hashInputs) : new Map<number, string>(); const hashGroups = new Map<
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/session.ts b/ui/src/plugins/com.android.HeapDumpExplorer/session.ts index 3d07cc9..c4d1200 100644 --- a/ui/src/plugins/com.android.HeapDumpExplorer/session.ts +++ b/ui/src/plugins/com.android.HeapDumpExplorer/session.ts
@@ -29,7 +29,6 @@ stateToSubpage, subpageToState, } from './nav_state'; -import type {OverviewData} from './types'; import type {FlamegraphState} from '../../widgets/flamegraph'; import type {HdeState} from './persisted_state'; import { @@ -74,7 +73,6 @@ private _navigateCallback?: (subpage: string) => void; private _dumps: ReadonlyArray<queries.HeapDump> = []; - private _overview: OverviewData | null = null; private readonly _counts = new Map<string, number>(); // Set when the plugin auto-redirected to HDE on load; gates the @@ -140,7 +138,6 @@ } private switchToDump(d: queries.HeapDump): void { - this._overview = null; this._counts.clear(); this.store.edit((s) => { s.activeDump = {upid: d.upid, ts: d.ts.toString()}; @@ -148,7 +145,6 @@ s.instanceTabs = undefined; s.flamegraphPanelState = undefined; }); - void this.loadOverview(); } get nav(): NavState { @@ -182,6 +178,8 @@ this.openInstanceTab( params?.id as number, params?.label as string | undefined, + params?.currentId as number | null | undefined, + params?.baselineId as number | null | undefined, ); } this.navigate(view, params); @@ -312,8 +310,18 @@ .catch(console.error); } - get instanceTabs(): ReadonlyArray<{objId: number; label: string}> { - return this.store.state.instanceTabs ?? []; + get instanceTabs(): ReadonlyArray<{ + objId: number; + label: string; + currentId: number | null; + baselineId: number | null; + }> { + return (this.store.state.instanceTabs ?? []).map((t) => ({ + objId: t.objId, + label: t.label, + currentId: t.currentId ?? t.objId, + baselineId: t.baselineId ?? null, + })); } // The active object's id, derived from the nav (not stored), or null. @@ -322,13 +330,23 @@ return nav.view === 'object' ? nav.params.id : null; } - private openInstanceTab(objId: number, label?: string): void { + private openInstanceTab( + objId: number, + label?: string, + currentId?: number | null, + baselineId?: number | null, + ): void { const tabs = this.store.state.instanceTabs ?? []; if (tabs.some((t) => t.objId === objId)) return; this.store.edit((s) => { s.instanceTabs = [ ...(s.instanceTabs ?? []), - {objId, label: truncateInstanceLabel(label ?? 'Instance')}, + { + objId, + label: truncateInstanceLabel(label ?? 'Instance'), + currentId: currentId === undefined ? objId : currentId, + baselineId: baselineId === undefined ? null : baselineId, + }, ]; }); } @@ -341,6 +359,17 @@ if (wasActive) this.navigate('overview'); } + // Drops all open object tabs. Called when the page's tab context flips + // (primary ⇄ baseline ⇄ diff): a paired diff object tab is meaningless once + // the context changes, so reset rather than show stale ids. + clearInstanceTabs(): void { + if ((this.store.state.instanceTabs ?? []).length === 0) return; + this.store.edit((s) => { + s.instanceTabs = undefined; + }); + if (this.nav.view === 'object') this.navigate('overview'); + } + syncInstanceTabFromNav(): void { const nav = this.nav; if (nav.view !== 'object') return; @@ -380,24 +409,4 @@ }); this.navigate('flamegraph'); }; - - get cachedOverview(): OverviewData | null { - return this._overview; - } - - // Pins the dump at fetch start; if the user switches dumps before the result - // arrives, the result is dropped instead of briefly showing the wrong dump. - async loadOverview(): Promise<void> { - if (this._overview !== null) return; - const dump = this.activeDump; - if (dump === null) return; - try { - const data = await queries.getOverview(this.engine, dump); - if (this.activeDump === dump) this._overview = data; - } catch (err) { - console.error('Failed to load overview:', err); - } finally { - m.redraw(); - } - } }
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/styles.scss b/ui/src/plugins/com.android.HeapDumpExplorer/styles.scss index bb2caf5..f5de4ce 100644 --- a/ui/src/plugins/com.android.HeapDumpExplorer/styles.scss +++ b/ui/src/plugins/com.android.HeapDumpExplorer/styles.scss
@@ -106,21 +106,6 @@ flex: 1; } -.pf-hde-dump-selector { - flex: none; - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - background: var(--pf-color-background); - border-bottom: 1px solid var(--pf-color-border); - font-size: 0.875rem; -} - -.pf-hde-dump-selector__label { - color: var(--pf-color-text-hint); -} - .pf-hde-error-text { color: var(--pf-color-danger); padding: 1rem; @@ -426,6 +411,101 @@ flex-shrink: 0; } +// Combined top bar that hosts both the primary heap-dump selector and the +// baseline pool / diff controls in a single row above the tabs. Multiple +// inline groups are separated by a vertical divider. +.pf-hde-top-bar { + flex: none; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: var(--pf-color-background); + border-bottom: 1px solid var(--pf-color-border); + font-size: 0.875rem; +} + +// Collapsed top bar — keeps a hidden file input mounted but renders no +// visible row. `display: none` would also drop the input from the layout +// tree but `aria-hidden` on the input is enough for screen readers; a +// zero-height container avoids reflow churn when the bar appears. +.pf-hde-top-bar--hidden { + padding: 0; + border-bottom: 0; +} + +.pf-hde-top-bar__label { + color: var(--pf-color-text-hint); +} + +// Vertical divider between the primary and baseline groups. +.pf-hde-top-bar__separator { + color: var(--pf-color-border); + user-select: none; +} + +// Flex spacer that pushes the diff-mode toggle (and trailing controls) +// to the right edge of the top bar. +.pf-hde-top-bar__spacer { + flex: 1; +} + +// Subdued group heading inside the baseline-pool popup (above each +// pooled trace's dump list). +.pf-hde-top-bar__section-title { + font-weight: 600; + color: var(--pf-color-text-muted); +} + +// Vertically scrolling content area for the Overview tab — the cards +// below the heading exceed the viewport on small screens. The Tabs widget +// gives this element a fixed height, so a child with `overflow-y: auto` +// is the right scroll container. +.pf-hde-view-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding-right: 0.25rem; // keep scrollbar from overlapping last column +} + +// Status badge text inside diff DataGrid status cells. Plain coloured +// uppercase, no pill — same visual weight as AHAT. +.pf-hde-status-text { + font-size: 0.75rem; + letter-spacing: 0.04em; + + &--new { + color: var(--pf-color-warning); + } + &--grew { + color: var(--pf-color-danger); + } + &--shrank { + color: var(--pf-color-success); + } + &--removed { + color: var(--pf-color-text-muted); + } + &--unchanged { + color: var(--pf-color-text-hint); + } +} + +// Diff delta values (sized + count cells in diff DataGrids and Overview +// `dWasted`). Sign carries direction: `+` red, `−` green, 0 muted. +.pf-hde-delta { + &--grew { + color: var(--pf-color-danger); + } + &--shrank { + color: var(--pf-color-success); + } + &--zero { + color: var(--pf-color-text-muted); + } +} + // Spacing (minimal set) .pf-hde-mt-1 { margin-top: 0.25rem; @@ -464,3 +544,51 @@ .pf-hde-col-header__info:hover { color: var(--pf-color-fg, #333); } + +// The flamegraph view anchors the floating diff legend. +.pf-hde-flamegraph-view { + position: relative; +} + +// pprof-style diff legend: a compact card floating in the bottom-right of +// the flamegraph (which is top-heavy, so that corner is usually empty). The +// gradient is painted in flamegraph_view.ts from the same getDiffColorCss() +// the node fills use, so the legend always matches the boxes. +// +// pointer-events:none keeps it from ever swallowing a node click/hover, and +// the translucent surface stays readable even if a deep stack reaches the +// corner. +.pf-hde-diff-legend { + position: absolute; + right: 12px; + bottom: 12px; + z-index: 1; + pointer-events: none; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.6rem; + font-size: 0.8125rem; + color: var(--pf-color-text-hint); + background: color-mix(in srgb, var(--pf-color-background) 88%, transparent); + border: 1px solid var(--pf-color-border); + border-radius: 6px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18); + backdrop-filter: blur(2px); + + &__title { + font-weight: 500; + color: var(--pf-color-text); + } + + &__bar { + width: 140px; + height: 0.6rem; + border-radius: 3px; + border: 1px solid var(--pf-color-border); + } + + &__end { + font-size: 0.75rem; + } +}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/top_bar.ts b/ui/src/plugins/com.android.HeapDumpExplorer/top_bar.ts new file mode 100644 index 0000000..ca992df --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/top_bar.ts
@@ -0,0 +1,125 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Top bar hosting the primary heap-dump selector and baseline pool +// controls. Hides when neither has anything to show. + +import m from 'mithril'; +import type {Trace} from '../../public/trace'; +import {Time} from '../../base/time'; +import {Button, ButtonVariant} from '../../widgets/button'; +import {MenuDivider, MenuItem, MenuTitle, PopupMenu} from '../../widgets/menu'; +import {formatDuration} from '../../components/time_utils'; +import type * as queries from './queries'; +import {HeapDumpDiffHeader, shouldShowBaselineHeader} from './header'; +import {setSelfTraceBaseline} from './baseline/state'; +import type {HeapDumpExplorerSession} from './session'; + +interface TopBarAttrs { + readonly trace: Trace; + readonly session: HeapDumpExplorerSession; + readonly onDumpChanged: () => void; +} + +export class TopBar implements m.ClassComponent<TopBarAttrs> { + view({attrs}: m.Vnode<TopBarAttrs>): m.Children { + const hasPrimary = + attrs.session.dumps.length > 1 && attrs.session.activeDump !== null; + const hasBaseline = shouldShowBaselineHeader(); + // The HeapDumpDiffHeader always keeps a hidden file input mounted so + // the Overview-tab CTA can fire it. Render it even when the visible + // row collapses — it returns just the input in that case. + if (!hasPrimary && !hasBaseline) { + return m('div', {class: 'pf-hde-top-bar pf-hde-top-bar--hidden'}, [ + m(HeapDumpDiffHeader, {trace: attrs.trace}), + ]); + } + return m( + 'div', + {class: 'pf-hde-top-bar'}, + hasPrimary ? renderPrimarySelector(attrs) : null, + hasPrimary && hasBaseline + ? m('span', {class: 'pf-hde-top-bar__separator'}, '|') + : null, + m(HeapDumpDiffHeader, {trace: attrs.trace}), + ); + } +} + +function renderPrimarySelector(attrs: TopBarAttrs): m.Children { + const allDumps = attrs.session.dumps; + const active = attrs.session.activeDump!; + const otherDumps = allDumps.filter((d) => d !== active); + return [ + m('span', {class: 'pf-hde-top-bar__label'}, 'Heap dump:'), + m( + PopupMenu, + { + trigger: m(Button, { + label: processLabel(active), + icon: 'memory', + rightIcon: 'arrow_drop_down', + variant: ButtonVariant.Outlined, + compact: true, + }), + }, + [ + ...allDumps.map((d) => + m(MenuItem, { + label: itemLabel(d, attrs.trace), + active: d === active, + onclick: () => { + attrs.session.selectDump(d); + attrs.onDumpChanged(); + }, + }), + ), + otherDumps.length > 0 ? m(MenuDivider) : null, + otherDumps.length > 0 + ? m(MenuTitle, {label: 'Diff against this dump:'}) + : null, + ...otherDumps.map((d) => + m(MenuItem, { + label: itemLabel(d, attrs.trace), + icon: 'difference', + onclick: () => + setSelfTraceBaseline( + attrs.trace.engine, + attrs.trace.traceInfo.traceTitle, + allDumps, + d, + ), + }), + ), + ], + ), + ]; +} + +function processLabel(d: queries.HeapDump): string { + // See header.ts:dumpProcessLabel — pid 0 means "unknown" for hprofs + // without process metadata. + const hasPid = d.pid !== null && d.pid !== 0; + if (d.processName !== null && hasPid) { + return `${d.processName} (pid ${d.pid})`; + } + if (d.processName !== null) return d.processName; + if (hasPid) return `pid ${d.pid}`; + return 'Java heap dump'; +} + +function itemLabel(d: queries.HeapDump, trace: Trace): string { + const offset = Time.diff(Time.fromRaw(d.ts), trace.traceInfo.start); + return `${processLabel(d)} — ${formatDuration(trace, offset)}`; +}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/all_objects_diff_view.ts b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/all_objects_diff_view.ts new file mode 100644 index 0000000..fad8f98 --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/all_objects_diff_view.ts
@@ -0,0 +1,433 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Per-class instance diff. Loads all instances of `cls` from both engines, +// pairs them via the AHAT-style bucket-key + retained-sort-zip in +// object_pairing.ts, then renders a paired DataGrid. Without `cls` shows +// an empty state — global instance pairing is too expensive to compute on +// the main thread, and class scope is what every navigation entry point +// (Classes, Dominators) already provides. + +import m from 'mithril'; +import {Spinner} from '../../../../widgets/spinner'; +import {EmptyState} from '../../../../widgets/empty_state'; +import type {Engine} from '../../../../trace_processor/engine'; +import { + NUM, + NUM_NULL, + STR_NULL, + type SqlValue, +} from '../../../../trace_processor/query_result'; +import {DataGrid} from '../../../../components/widgets/datagrid/datagrid'; +import {InMemoryDataSource} from '../../../../components/widgets/datagrid/in_memory_data_source'; +import type { + CellRenderResult, + CellRenderer, + ColumnDef, + SchemaRegistry, +} from '../../../../components/widgets/datagrid/datagrid_schema'; +import {type NavFn, shortClassName, SQL_PREAMBLE} from '../../components'; +import {fmtHex} from '../../format'; +import { + deltaSizeRenderer, + sideSizeRenderer, + sideCountRenderer, + statusRenderer, +} from '../../diff/diff_schemas'; +import { + pairObjects, + type ObjectPairRow, + type ObjectRowRaw, +} from '../../diff/object_pairing'; +import {baselineDumpFilterSql, getActiveBaseline} from '../../baseline/state'; +import {dumpFilterSql, getActiveDump} from '../../queries'; + +interface AllObjectsDiffViewAttrs { + readonly currentEngine: Engine; + readonly baselineEngine: Engine; + readonly cls: string | undefined; + readonly navigate: NavFn; +} + +const ITER_SPEC = { + id: NUM, + cls: STR_NULL, + heap_type: STR_NULL, + self_size: NUM, + native_size: NUM, + retained: NUM_NULL, + retained_native: NUM_NULL, + retained_count: NUM_NULL, + value_string: STR_NULL, + array_len: NUM_NULL, +}; + +function sqlEsc(s: string): string { + return s.replace(/'/g, "''"); +} + +function buildQuery(filterSql: string, cls: string): string { + const escaped = sqlEsc(cls); + return ` + SELECT + o.id AS id, + coalesce(c.deobfuscated_name, c.name) AS cls, + o.heap_type AS heap_type, + o.self_size AS self_size, + o.native_size AS native_size, + coalesce(d.dominated_size_bytes, o.self_size) AS retained, + coalesce(d.dominated_native_size_bytes, o.native_size) AS retained_native, + coalesce(d.dominated_obj_count, 1) AS retained_count, + od.value_string AS value_string, + od.array_element_count AS array_len + FROM heap_graph_object o + JOIN heap_graph_class c ON o.type_id = c.id + LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id + LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id + WHERE o.reachable != 0 + AND ${filterSql} + AND (c.name = '${escaped}' OR c.deobfuscated_name = '${escaped}') + `; +} + +async function fetchObjectsForClass( + engine: Engine, + filterSql: string, + cls: string, +): Promise<ObjectRowRaw[]> { + await engine.query(SQL_PREAMBLE); + const res = await engine.query(buildQuery(filterSql, cls)); + const out: ObjectRowRaw[] = []; + for (const it = res.iter(ITER_SPEC); it.valid(); it.next()) { + if (it.cls === null) continue; + out.push({ + id: Number(it.id), + className: it.cls, + heapType: it.heap_type, + valueString: it.value_string, + arrayLength: it.array_len, + shallow: it.self_size, + shallowNative: it.native_size, + retained: it.retained ?? it.self_size, + retainedNative: it.retained_native ?? it.native_size, + retainedCount: it.retained_count ?? 1, + }); + } + return out; +} + +function pairToGridRow(p: ObjectPairRow): Record<string, SqlValue> { + // Choose a stable display id: the current id if present, else the + // baseline id. The Object column renderer reads c_id / b_id directly to + // wire its click target. + return { + key: p.key, + status: p.status, + cls: p.className, + heap: p.heapType, + str: p.valueString, + c_id: p.c_id, + b_id: p.b_id, + delta_retained: p.delta_retained, + c_retained: p.c_retained, + b_retained: p.b_retained, + delta_shallow: p.delta_shallow, + c_shallow: p.c_shallow, + b_shallow: p.b_shallow, + c_retained_count: p.c_retained_count, + b_retained_count: p.b_retained_count, + }; +} + +function buildSchema(navigate: NavFn): SchemaRegistry { + const idRenderer: CellRenderer = (value) => { + if (value === null || value === undefined) { + return { + content: m('span', {class: 'pf-hde-mono pf-hde-muted'}, '—'), + align: 'right', + } satisfies CellRenderResult; + } + return { + content: m('span', {class: 'pf-hde-mono'}, fmtHex(Number(value))), + align: 'right', + } satisfies CellRenderResult; + }; + + // The Object column reads c_id / b_id from the same row to wire its + // click target. InMemoryDataSource strips fields not declared in the + // schema, so c_id and b_id MUST appear as columns below — even if we + // also render them via this single composite cell. + const objectRenderer: CellRenderer = (_value, row) => { + const cls = String(row.cls ?? ''); + const cId = row.c_id == null ? null : Number(row.c_id); + const bId = row.b_id == null ? null : Number(row.b_id); + const str = row.str == null ? null : String(row.str); + const displayId = cId ?? bId ?? 0; + const display = `${shortClassName(cls)} ${fmtHex(displayId)}`; + return { + content: m('span', [ + m( + 'button', + { + class: 'pf-hde-link', + onclick: () => + navigate('object', { + id: cId ?? bId ?? 0, + baselineId: bId, + currentId: cId, + label: str ? `"${truncate(str, 30)}"` : display, + }), + }, + display, + ), + str + ? m('span', {class: 'pf-hde-str-badge'}, ` "${truncate(str, 40)}"`) + : null, + ]), + align: 'left', + } satisfies CellRenderResult; + }; + + const cols: Record<string, ColumnDef> = { + cls: {title: 'Object', columnType: 'text', cellRenderer: objectRenderer}, + status: { + title: 'Status', + columnType: 'text', + cellRenderer: statusRenderer, + }, + delta_retained: { + title: 'Δ Retained', + columnType: 'quantitative', + cellRenderer: deltaSizeRenderer, + }, + b_retained: { + title: 'Baseline Retained', + columnType: 'quantitative', + cellRenderer: sideSizeRenderer, + }, + c_retained: { + title: 'Current Retained', + columnType: 'quantitative', + cellRenderer: sideSizeRenderer, + }, + delta_shallow: { + title: 'Δ Shallow', + columnType: 'quantitative', + cellRenderer: deltaSizeRenderer, + }, + b_shallow: { + title: 'Baseline Shallow', + columnType: 'quantitative', + cellRenderer: sideSizeRenderer, + }, + c_shallow: { + title: 'Current Shallow', + columnType: 'quantitative', + cellRenderer: sideSizeRenderer, + }, + b_retained_count: { + title: 'Baseline Retained #', + columnType: 'quantitative', + cellRenderer: sideCountRenderer, + }, + c_retained_count: { + title: 'Current Retained #', + columnType: 'quantitative', + cellRenderer: sideCountRenderer, + }, + heap: {title: 'Heap', columnType: 'text'}, + str: {title: 'String Value', columnType: 'text'}, + c_id: { + title: 'Current id', + columnType: 'identifier', + cellRenderer: idRenderer, + }, + b_id: { + title: 'Baseline id', + columnType: 'identifier', + cellRenderer: idRenderer, + }, + }; + return {query: cols}; +} + +function truncate(s: string, n: number): string { + return s.length > n ? s.slice(0, n) + '…' : s; +} + +function AllObjectsDiffView(): m.Component<AllObjectsDiffViewAttrs> { + let rows: ObjectPairRow[] | null = null; + let loading = false; + let error: string | null = null; + let dataSource: InMemoryDataSource | null = null; + + let lastCurrentEngine: Engine | null = null; + let lastBaselineEngine: Engine | null = null; + let lastCls: string | undefined = undefined; + + async function load( + currentEngine: Engine, + baselineEngine: Engine, + cls: string, + ) { + const primarySnap = getActiveDump(); + const baselineSnap = getActiveBaseline(); + if (!primarySnap || !baselineSnap) return; + const isStale = () => + getActiveDump() !== primarySnap || getActiveBaseline() !== baselineSnap; + loading = true; + error = null; + try { + const baselineFilter = baselineDumpFilterSql('o'); + const currentFilter = dumpFilterSql(primarySnap, 'o'); + const [baselineRows, currentRows] = await Promise.all([ + fetchObjectsForClass(baselineEngine, baselineFilter, cls), + fetchObjectsForClass(currentEngine, currentFilter, cls), + ]); + if (isStale()) return; + const paired = pairObjects(currentRows, baselineRows); + // Default sort: largest |Δ retained| first. + paired.sort( + (a, b) => Math.abs(b.delta_retained) - Math.abs(a.delta_retained), + ); + rows = paired; + dataSource = new InMemoryDataSource(paired.map(pairToGridRow)); + } catch (err) { + if (isStale()) return; + error = err instanceof Error ? err.message : String(err); + console.error('Objects diff load failed:', err); + } finally { + loading = false; + m.redraw(); + } + } + + function ensureLoaded( + currentEngine: Engine, + baselineEngine: Engine, + cls: string | undefined, + ) { + if ( + currentEngine === lastCurrentEngine && + baselineEngine === lastBaselineEngine && + cls === lastCls + ) { + return; + } + lastCurrentEngine = currentEngine; + lastBaselineEngine = baselineEngine; + lastCls = cls; + rows = null; + dataSource = null; + error = null; + if (cls !== undefined) { + load(currentEngine, baselineEngine, cls).catch(console.error); + } + } + + return { + oninit(vnode) { + ensureLoaded( + vnode.attrs.currentEngine, + vnode.attrs.baselineEngine, + vnode.attrs.cls, + ); + }, + onupdate(vnode) { + ensureLoaded( + vnode.attrs.currentEngine, + vnode.attrs.baselineEngine, + vnode.attrs.cls, + ); + }, + view(vnode) { + const {cls, navigate} = vnode.attrs; + + if (cls === undefined) { + return m( + EmptyState, + { + icon: 'filter_list', + title: 'Pick a class to diff its instances', + fillHeight: true, + }, + m( + 'p', + {class: 'pf-hde-muted'}, + 'Open a class from the Classes or Dominators tab to see ' + + 'paired instance-level diffs.', + ), + ); + } + + if (loading && !rows) { + return m('div', {class: 'pf-hde-loading'}, m(Spinner, {easing: true})); + } + if (error) { + return m(EmptyState, { + icon: 'error', + title: `Failed to compute Objects diff: ${error}`, + fillHeight: true, + }); + } + if (!rows || !dataSource) { + return m(EmptyState, { + icon: 'memory', + title: `No instances of ${cls} in either dump`, + fillHeight: true, + }); + } + + const schema = buildSchema(navigate); + return m('div', {class: 'pf-hde-view-content'}, [ + m('h2', {class: 'pf-hde-view-heading'}, [ + 'Objects diff ', + m( + 'span', + {class: 'pf-hde-muted'}, + `(${cls} — ${rows.length.toLocaleString()} pairs)`, + ), + ]), + m(DataGrid, { + schema, + rootSchema: 'query', + data: dataSource, + fillHeight: true, + initialColumns: [ + {id: 'cls', field: 'cls'}, + {id: 'status', field: 'status'}, + {id: 'delta_retained', field: 'delta_retained', sort: 'DESC'}, + {id: 'b_retained', field: 'b_retained'}, + {id: 'c_retained', field: 'c_retained'}, + {id: 'delta_shallow', field: 'delta_shallow'}, + {id: 'b_shallow', field: 'b_shallow'}, + {id: 'c_shallow', field: 'c_shallow'}, + {id: 'b_retained_count', field: 'b_retained_count'}, + {id: 'c_retained_count', field: 'c_retained_count'}, + {id: 'heap', field: 'heap'}, + {id: 'str', field: 'str'}, + // Required for the Object column's click renderer to read both + // ids — InMemoryDataSource projects rows down to declared + // columns, so omitting these silently zeros them in the row. + {id: 'c_id', field: 'c_id'}, + {id: 'b_id', field: 'b_id'}, + ], + showExportButton: true, + }), + ]); + }, + }; +} + +export default AllObjectsDiffView;
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/arrays_diff_view.ts b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/arrays_diff_view.ts new file mode 100644 index 0000000..6d8d0f0 --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/arrays_diff_view.ts
@@ -0,0 +1,211 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Arrays diff: GROUP BY array_data_hash (content hash) per engine, +// JS outer-join. + +import m from 'mithril'; +import {Spinner} from '../../../../widgets/spinner'; +import {EmptyState} from '../../../../widgets/empty_state'; +import type {Engine} from '../../../../trace_processor/engine'; +import {LONG_NULL, NUM} from '../../../../trace_processor/query_result'; +import type {Row} from '../../../../trace_processor/query_result'; +import {DataGrid} from '../../../../components/widgets/datagrid/datagrid'; +import {InMemoryDataSource} from '../../../../components/widgets/datagrid/in_memory_data_source'; +import type {NavFn} from '../../components'; +import type {DiffRow} from '../../diff/diff_rows'; +import { + compareByAbsDeltaDesc, + dedupeByKey, + mergeRows, +} from '../../diff/diff_rows'; +import {publishDiffRows} from '../../diff/diff_debug'; +import { + buildSizeCountInitialColumns, + buildSizeCountSchema, +} from '../../diff/diff_schemas'; +import {baselineDumpFilterSql, getActiveBaseline} from '../../baseline/state'; +import {dumpFilterSql, getActiveDump} from '../../queries'; + +function buildQuery(filterSql: string): string { + return ` + SELECT + od.array_data_hash AS h, + COUNT(*) AS cnt, + SUM(o.self_size) AS retained + FROM heap_graph_object o + JOIN heap_graph_object_data od ON o.object_data_id = od.id + WHERE o.reachable != 0 + AND ${filterSql} + AND od.array_data_hash IS NOT NULL + GROUP BY od.array_data_hash + `; +} + +const ITER_SPEC = {h: LONG_NULL, cnt: NUM, retained: NUM}; + +interface ArraysDiffViewAttrs { + readonly currentEngine: Engine; + readonly baselineEngine: Engine; + readonly navigate: NavFn; +} + +const NUMERIC_FIELDS = ['cnt', 'retained']; + +async function runQuery(engine: Engine, filterSql: string): Promise<Row[]> { + const res = await engine.query(buildQuery(filterSql)); + const out: Row[] = []; + for (const it = res.iter(ITER_SPEC); it.valid(); it.next()) { + if (it.h === null) continue; + out.push({ + h: it.h.toString(), + cnt: it.cnt, + retained: it.retained, + }); + } + return dedupeByKey(out, (r) => String(r.h ?? ''), NUMERIC_FIELDS); +} + +function ArraysDiffView(): m.Component<ArraysDiffViewAttrs> { + let rows: DiffRow[] | null = null; + let loading = false; + let error: string | null = null; + let dataSource: InMemoryDataSource | null = null; + let lastB: Engine | null = null; + let lastC: Engine | null = null; + + async function load(currentEngine: Engine, baselineEngine: Engine) { + // Snapshot active refs; any change during the awaits means the + // result is stale and we drop it. The same check in catch silently + // swallows rejections from a disposed engine. + const primarySnap = getActiveDump(); + const baselineSnap = getActiveBaseline(); + if (!primarySnap || !baselineSnap) return; + const isStale = () => + getActiveDump() !== primarySnap || getActiveBaseline() !== baselineSnap; + loading = true; + error = null; + try { + const baselineFilter = baselineDumpFilterSql('o'); + const currentFilter = dumpFilterSql(primarySnap, 'o'); + const [b, c] = await Promise.all([ + runQuery(baselineEngine, baselineFilter), + runQuery(currentEngine, currentFilter), + ]); + if (isStale()) return; + const merged = mergeRows({ + baseline: b, + current: c, + keyOf: (r) => String(r.h ?? ''), + numericFields: NUMERIC_FIELDS, + primaryDeltaField: 'retained', + }); + merged.sort(compareByAbsDeltaDesc('retained')); + rows = merged; + dataSource = new InMemoryDataSource(merged); + publishDiffRows('arrays', merged); + } catch (err) { + if (isStale()) return; + error = err instanceof Error ? err.message : String(err); + console.error('Arrays diff load failed:', err); + } finally { + loading = false; + m.redraw(); + } + } + + function ensure(currentEngine: Engine, baselineEngine: Engine) { + if (currentEngine !== lastC || baselineEngine !== lastB) { + lastC = currentEngine; + lastB = baselineEngine; + rows = null; + dataSource = null; + load(currentEngine, baselineEngine).catch(console.error); + } + } + + return { + oninit(vnode) { + ensure(vnode.attrs.currentEngine, vnode.attrs.baselineEngine); + }, + onupdate(vnode) { + ensure(vnode.attrs.currentEngine, vnode.attrs.baselineEngine); + }, + view(vnode) { + const {navigate} = vnode.attrs; + if (loading && !rows) { + return m('div', {class: 'pf-hde-loading'}, m(Spinner, {easing: true})); + } + if (error) { + return m(EmptyState, { + icon: 'error', + title: `Failed to compute Arrays diff: ${error}`, + fillHeight: true, + }); + } + if (!rows || !dataSource) { + return m(EmptyState, { + icon: 'data_array', + title: 'No primitive arrays to diff', + fillHeight: true, + }); + } + + const size = { + field: 'retained', + title: 'Retained', + kind: 'size' as const, + }; + const count = {field: 'cnt', title: 'Count', kind: 'count' as const}; + const schema = buildSizeCountSchema({ + keyTitle: 'Array hash', + keyRenderer: (value) => + m( + 'button', + { + class: 'pf-hde-link pf-hde-mono', + onclick: () => + navigate('arrays', {arrayHash: String(value ?? '')}), + }, + String(value ?? ''), + ), + size, + count, + }); + + const initialColumns = buildSizeCountInitialColumns({size, count}); + + return m('div', {class: 'pf-hde-view-content'}, [ + m('h2', {class: 'pf-hde-view-heading'}, [ + 'Arrays diff ', + m( + 'span', + {class: 'pf-hde-muted'}, + `(${rows.length.toLocaleString()} hashes)`, + ), + ]), + m(DataGrid, { + schema, + rootSchema: 'query', + data: dataSource, + fillHeight: true, + initialColumns, + showExportButton: true, + }), + ]); + }, + }; +} + +export default ArraysDiffView;
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/bitmaps_diff_view.ts b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/bitmaps_diff_view.ts new file mode 100644 index 0000000..847e336 --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/bitmaps_diff_view.ts
@@ -0,0 +1,231 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Bitmaps diff: GROUP BY (width, height) per engine, JS outer-join. +// Pixel-content hashes rarely match across snapshots; dimensions answer +// "how many bitmaps of this size?". + +import m from 'mithril'; +import {Spinner} from '../../../../widgets/spinner'; +import {EmptyState} from '../../../../widgets/empty_state'; +import type {Engine} from '../../../../trace_processor/engine'; +import {NUM, NUM_NULL} from '../../../../trace_processor/query_result'; +import type {Row} from '../../../../trace_processor/query_result'; +import {DataGrid} from '../../../../components/widgets/datagrid/datagrid'; +import {InMemoryDataSource} from '../../../../components/widgets/datagrid/in_memory_data_source'; +import type {NavFn} from '../../components'; +import type {DiffRow} from '../../diff/diff_rows'; +import { + compareByAbsDeltaDesc, + dedupeByKey, + mergeRows, +} from '../../diff/diff_rows'; +import {publishDiffRows} from '../../diff/diff_debug'; +import { + buildSizeCountInitialColumns, + buildSizeCountSchema, +} from '../../diff/diff_schemas'; +import {baselineDumpFilterSql, getActiveBaseline} from '../../baseline/state'; +import {dumpFilterSql, getActiveDump} from '../../queries'; + +function buildQuery(filterSql: string): string { + return ` + SELECT + width, + height, + COUNT(*) AS cnt, + SUM(retained) AS retained + FROM ( + SELECT + MAX(CASE WHEN f.field_name GLOB '*mWidth' + THEN f.int_value END) AS width, + MAX(CASE WHEN f.field_name GLOB '*mHeight' + THEN f.int_value END) AS height, + ifnull(d.dominated_size_bytes, o.self_size) + + ifnull(d.dominated_native_size_bytes, o.native_size) AS retained + FROM heap_graph_object o + JOIN heap_graph_class c ON o.type_id = c.id + LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id + LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id + LEFT JOIN heap_graph_primitive f ON f.field_set_id = od.field_set_id + WHERE o.reachable != 0 + AND ${filterSql} + AND (c.name = 'android.graphics.Bitmap' + OR c.deobfuscated_name = 'android.graphics.Bitmap') + GROUP BY o.id + ) + WHERE width > 0 AND height > 0 + GROUP BY width, height + `; +} + +const ITER_SPEC = { + width: NUM_NULL, + height: NUM_NULL, + cnt: NUM, + retained: NUM, +}; + +interface BitmapsDiffViewAttrs { + readonly currentEngine: Engine; + readonly baselineEngine: Engine; + readonly navigate: NavFn; +} + +const NUMERIC_FIELDS = ['cnt', 'retained']; + +async function runQuery(engine: Engine, filterSql: string): Promise<Row[]> { + const res = await engine.query(buildQuery(filterSql)); + const out: Row[] = []; + const it = res.iter(ITER_SPEC); + for (; it.valid(); it.next()) { + out.push({ + width: it.width, + height: it.height, + dimensions: `${it.width ?? 0} × ${it.height ?? 0}`, + cnt: it.cnt, + retained: it.retained, + }); + } + return dedupeByKey(out, (r) => String(r.dimensions ?? ''), NUMERIC_FIELDS); +} + +function BitmapsDiffView(): m.Component<BitmapsDiffViewAttrs> { + let rows: DiffRow[] | null = null; + let loading = false; + let error: string | null = null; + let dataSource: InMemoryDataSource | null = null; + let lastB: Engine | null = null; + let lastC: Engine | null = null; + + async function load(currentEngine: Engine, baselineEngine: Engine) { + const primarySnap = getActiveDump(); + const baselineSnap = getActiveBaseline(); + if (!primarySnap || !baselineSnap) return; + const isStale = () => + getActiveDump() !== primarySnap || getActiveBaseline() !== baselineSnap; + loading = true; + error = null; + try { + const baselineFilter = baselineDumpFilterSql('o'); + const currentFilter = dumpFilterSql(primarySnap, 'o'); + const [b, c] = await Promise.all([ + runQuery(baselineEngine, baselineFilter), + runQuery(currentEngine, currentFilter), + ]); + if (isStale()) return; + const merged = mergeRows({ + baseline: b, + current: c, + keyOf: (r) => String(r.dimensions ?? ''), + numericFields: NUMERIC_FIELDS, + primaryDeltaField: 'retained', + }); + merged.sort(compareByAbsDeltaDesc('retained')); + rows = merged; + dataSource = new InMemoryDataSource(merged); + publishDiffRows('bitmaps', merged); + } catch (err) { + if (isStale()) return; + error = err instanceof Error ? err.message : String(err); + console.error('Bitmaps diff load failed:', err); + } finally { + loading = false; + m.redraw(); + } + } + + function ensure(currentEngine: Engine, baselineEngine: Engine) { + if (currentEngine !== lastC || baselineEngine !== lastB) { + lastC = currentEngine; + lastB = baselineEngine; + rows = null; + dataSource = null; + load(currentEngine, baselineEngine).catch(console.error); + } + } + + return { + oninit(vnode) { + ensure(vnode.attrs.currentEngine, vnode.attrs.baselineEngine); + }, + onupdate(vnode) { + ensure(vnode.attrs.currentEngine, vnode.attrs.baselineEngine); + }, + view(vnode) { + const {navigate} = vnode.attrs; + if (loading && !rows) { + return m('div', {class: 'pf-hde-loading'}, m(Spinner, {easing: true})); + } + if (error) { + return m(EmptyState, { + icon: 'error', + title: `Failed to compute Bitmaps diff: ${error}`, + fillHeight: true, + }); + } + if (!rows || !dataSource) { + return m(EmptyState, { + icon: 'image', + title: 'No bitmaps to diff', + fillHeight: true, + }); + } + + const size = { + field: 'retained', + title: 'Retained', + kind: 'size' as const, + }; + const count = {field: 'cnt', title: 'Count', kind: 'count' as const}; + const schema = buildSizeCountSchema({ + keyTitle: 'Dimensions', + keyRenderer: (value) => + m( + 'button', + { + class: 'pf-hde-link pf-hde-mono', + onclick: () => navigate('bitmaps'), + }, + String(value ?? ''), + ), + size, + count, + }); + + const initialColumns = buildSizeCountInitialColumns({size, count}); + + return m('div', {class: 'pf-hde-view-content'}, [ + m('h2', {class: 'pf-hde-view-heading'}, [ + 'Bitmaps diff ', + m( + 'span', + {class: 'pf-hde-muted'}, + `(${rows.length.toLocaleString()} dimension groups)`, + ), + ]), + m(DataGrid, { + schema, + rootSchema: 'query', + data: dataSource, + fillHeight: true, + initialColumns, + showExportButton: true, + }), + ]); + }, + }; +} + +export default BitmapsDiffView;
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/classes_diff_view.ts b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/classes_diff_view.ts new file mode 100644 index 0000000..34033db --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/classes_diff_view.ts
@@ -0,0 +1,471 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Classes diff: runs the same `android_heap_graph_class_aggregation` +// query the single-engine view uses, with no LIMIT, then merges by +// class name. + +import m from 'mithril'; +import {Spinner} from '../../../../widgets/spinner'; +import {EmptyState} from '../../../../widgets/empty_state'; +import type {Engine} from '../../../../trace_processor/engine'; +import type {Row} from '../../../../trace_processor/query_result'; +import { + NUM, + NUM_NULL, + STR_NULL, +} from '../../../../trace_processor/query_result'; +import {DataGrid} from '../../../../components/widgets/datagrid/datagrid'; +import {InMemoryDataSource} from '../../../../components/widgets/datagrid/in_memory_data_source'; +import type {NavFn} from '../../components'; +import type {DiffRow} from '../../diff/diff_rows'; +import { + compareByAbsDeltaDesc, + dedupeByKey, + mergeRows, +} from '../../diff/diff_rows'; +import {publishDiffRows} from '../../diff/diff_debug'; +import { + buildSizeCountInitialColumns, + buildSizeCountSchema, +} from '../../diff/diff_schemas'; +import { + baselineDumpFilterSql, + getActiveBaseline, + isSelfTraceDiff, +} from '../../baseline/state'; +import {dumpFilterSql, getActiveDump} from '../../queries'; +import {cachedFetch, dumpKey} from '../../diff/diff_cache'; +import { + KEY_COL, + STATUS_COL, + baselineCol, + currentCol, + deltaCol, +} from '../../diff/diff_rows'; +import {LONG, LONG_NULL} from '../../../../trace_processor/query_result'; + +const PREAMBLE = + 'INCLUDE PERFETTO MODULE android.memory.heap_graph.heap_graph_class_aggregation'; + +function buildQuery(filterSql: string): string { + return ` + SELECT + type_name, + reachable_obj_count, + reachable_size_bytes, + reachable_native_size_bytes, + dominated_size_bytes, + dominated_native_size_bytes, + dominated_obj_count + FROM android_heap_graph_class_aggregation a + WHERE a.reachable_obj_count > 0 AND ${filterSql} + `; +} + +const ITER_SPEC = { + // NULL when both name and deobfuscated_name are missing; rows skipped + // since there's no usable join key without a class name. + type_name: STR_NULL, + reachable_obj_count: NUM, + reachable_size_bytes: NUM, + reachable_native_size_bytes: NUM_NULL, + dominated_size_bytes: NUM_NULL, + dominated_native_size_bytes: NUM_NULL, + dominated_obj_count: NUM_NULL, +}; + +const NUMERIC_FIELDS = [ + 'reachable_obj_count', + 'reachable_size_bytes', + 'reachable_native_size_bytes', + 'dominated_size_bytes', + 'dominated_native_size_bytes', + 'dominated_obj_count', +]; + +interface ClassesDiffViewAttrs { + readonly currentEngine: Engine; + readonly baselineEngine: Engine; + readonly navigate: NavFn; +} + +// SQL-side diff: when both dumps live in the same trace processor we +// avoid the two-query + JS merge round-trip entirely. The query LEFT +// JOINs cur on base by class name, then UNION ALL appends the anti-join +// (REMOVED rows that only exist in base). Status + _b_/_c_/_d_ columns +// are computed in SQLite, so the JS side just passes them to the grid. +function buildDiffJoinQuery( + cur: {upid: number; ts: number | bigint}, + base: {upid: number; ts: number | bigint}, +): string { + const reachable = `a.reachable_obj_count > 0 AND a.type_name IS NOT NULL`; + // Per-side aggregates. type_name may repeat across class-loaders, so + // we sum-group by name to dedupe — equivalent to the JS `dedupeByKey` + // step in the cross-trace path. + const side = (name: string, upid: number, ts: number | bigint): string => ` + ${name} AS ( + SELECT + a.type_name AS k, + sum(a.reachable_obj_count) AS reachable_obj_count, + sum(a.reachable_size_bytes) AS reachable_size_bytes, + sum(a.reachable_native_size_bytes) AS reachable_native_size_bytes, + sum(a.dominated_size_bytes) AS dominated_size_bytes, + sum(a.dominated_native_size_bytes) AS dominated_native_size_bytes, + sum(a.dominated_obj_count) AS dominated_obj_count + FROM android_heap_graph_class_aggregation a + WHERE a.upid = ${upid} AND a.graph_sample_ts = ${ts} AND ${reachable} + GROUP BY a.type_name + )`; + // Compute every diff row in SQL. UNION ALL combines the + // cur-LEFT-JOIN-base set (GREW / SHRANK / UNCHANGED / NEW) with the + // base-anti-join-cur set (REMOVED). SQLite requires ORDER BY on a + // compound SELECT to reference column positions or first-branch + // names; wrapping in a subquery sidesteps that and makes the + // ordering obvious. + return ` + SELECT * FROM ( + WITH + ${side('cur', cur.upid, cur.ts)}, + ${side('base', base.upid, base.ts)} + SELECT + c.k AS ${KEY_COL}, + CASE + WHEN b.k IS NULL THEN 'NEW' + WHEN c.dominated_size_bytes = b.dominated_size_bytes THEN 'UNCHANGED' + WHEN c.dominated_size_bytes > b.dominated_size_bytes THEN 'GREW' + ELSE 'SHRANK' + END AS ${STATUS_COL}, + c.k AS type_name, + b.reachable_obj_count AS ${baselineCol('reachable_obj_count')}, + c.reachable_obj_count AS ${currentCol('reachable_obj_count')}, + c.reachable_obj_count - ifnull(b.reachable_obj_count, 0) + AS ${deltaCol('reachable_obj_count')}, + b.reachable_size_bytes AS ${baselineCol('reachable_size_bytes')}, + c.reachable_size_bytes AS ${currentCol('reachable_size_bytes')}, + c.reachable_size_bytes - ifnull(b.reachable_size_bytes, 0) + AS ${deltaCol('reachable_size_bytes')}, + b.reachable_native_size_bytes AS ${baselineCol('reachable_native_size_bytes')}, + c.reachable_native_size_bytes AS ${currentCol('reachable_native_size_bytes')}, + c.reachable_native_size_bytes - ifnull(b.reachable_native_size_bytes, 0) + AS ${deltaCol('reachable_native_size_bytes')}, + b.dominated_size_bytes AS ${baselineCol('dominated_size_bytes')}, + c.dominated_size_bytes AS ${currentCol('dominated_size_bytes')}, + c.dominated_size_bytes - ifnull(b.dominated_size_bytes, 0) + AS ${deltaCol('dominated_size_bytes')}, + b.dominated_native_size_bytes AS ${baselineCol('dominated_native_size_bytes')}, + c.dominated_native_size_bytes AS ${currentCol('dominated_native_size_bytes')}, + c.dominated_native_size_bytes - ifnull(b.dominated_native_size_bytes, 0) + AS ${deltaCol('dominated_native_size_bytes')}, + b.dominated_obj_count AS ${baselineCol('dominated_obj_count')}, + c.dominated_obj_count AS ${currentCol('dominated_obj_count')}, + c.dominated_obj_count - ifnull(b.dominated_obj_count, 0) + AS ${deltaCol('dominated_obj_count')} + FROM cur c LEFT JOIN base b USING (k) + UNION ALL + SELECT + b.k, + 'REMOVED', + b.k, + b.reachable_obj_count, + NULL, + -b.reachable_obj_count, + b.reachable_size_bytes, + NULL, + -b.reachable_size_bytes, + b.reachable_native_size_bytes, + NULL, + -b.reachable_native_size_bytes, + b.dominated_size_bytes, + NULL, + -b.dominated_size_bytes, + b.dominated_native_size_bytes, + NULL, + -b.dominated_native_size_bytes, + b.dominated_obj_count, + NULL, + -b.dominated_obj_count + FROM base b + WHERE NOT EXISTS (SELECT 1 FROM cur c WHERE c.k = b.k) + ) + ORDER BY abs(${deltaCol('dominated_size_bytes')}) DESC + `; +} + +const DIFF_ROW_ITER = { + [KEY_COL]: STR_NULL, + [STATUS_COL]: STR_NULL, + type_name: STR_NULL, + // Per-row "is the value nullable" varies — NEW has null baseline, + // REMOVED has null current. LONG_NULL across the board is safe. + [baselineCol('reachable_obj_count')]: LONG_NULL, + [currentCol('reachable_obj_count')]: LONG_NULL, + [deltaCol('reachable_obj_count')]: LONG, + [baselineCol('reachable_size_bytes')]: LONG_NULL, + [currentCol('reachable_size_bytes')]: LONG_NULL, + [deltaCol('reachable_size_bytes')]: LONG, + [baselineCol('reachable_native_size_bytes')]: LONG_NULL, + [currentCol('reachable_native_size_bytes')]: LONG_NULL, + [deltaCol('reachable_native_size_bytes')]: LONG, + [baselineCol('dominated_size_bytes')]: LONG_NULL, + [currentCol('dominated_size_bytes')]: LONG_NULL, + [deltaCol('dominated_size_bytes')]: LONG, + [baselineCol('dominated_native_size_bytes')]: LONG_NULL, + [currentCol('dominated_native_size_bytes')]: LONG_NULL, + [deltaCol('dominated_native_size_bytes')]: LONG, + [baselineCol('dominated_obj_count')]: LONG_NULL, + [currentCol('dominated_obj_count')]: LONG_NULL, + [deltaCol('dominated_obj_count')]: LONG, +}; + +// Same-trace fast path. Runs ONE query, gets DiffRows back already +// shaped + sorted. Cached by (engine, cur dump, base dump). +async function fetchDiffViaSql( + engine: Engine, + cur: {upid: number; ts: number | bigint}, + base: {upid: number; ts: number | bigint}, +): Promise<DiffRow[]> { + const key = `classes-diff:${dumpKey(cur.upid, cur.ts)}:${dumpKey(base.upid, base.ts)}`; + const rows = await cachedFetch(engine, key, async () => { + await engine.query(PREAMBLE); + const res = await engine.query(buildDiffJoinQuery(cur, base)); + const out: Row[] = []; + for (const it = res.iter(DIFF_ROW_ITER); it.valid(); it.next()) { + const row: Record<string, unknown> = {}; + for (const k of Object.keys(DIFF_ROW_ITER)) { + row[k] = it[k as keyof typeof DIFF_ROW_ITER]; + } + out.push(row as Row); + } + return out; + }); + return rows as unknown as DiffRow[]; +} + +// Cached per (engine, dump). Same primary dump across baseline swaps +// reuses these rows on the second click of Classes — first load on a +// 60 MB hprof is ~7 s; cached re-visit is sub-millisecond. +async function fetchAll( + engine: Engine, + upid: number, + ts: number | bigint, + filterSql: string, +): Promise<ReadonlyArray<Row>> { + return cachedFetch(engine, `classes:${dumpKey(upid, ts)}`, async () => { + await engine.query(PREAMBLE); + const res = await engine.query(buildQuery(filterSql)); + const out: Row[] = []; + for (const it = res.iter(ITER_SPEC); it.valid(); it.next()) { + if (it.type_name === null) continue; + out.push({ + type_name: it.type_name, + reachable_obj_count: it.reachable_obj_count, + reachable_size_bytes: it.reachable_size_bytes, + reachable_native_size_bytes: it.reachable_native_size_bytes, + dominated_size_bytes: it.dominated_size_bytes, + dominated_native_size_bytes: it.dominated_native_size_bytes, + dominated_obj_count: it.dominated_obj_count, + }); + } + return dedupeByKey(out, (r) => String(r.type_name ?? ''), NUMERIC_FIELDS); + }); +} + +function ClassesDiffView(): m.Component<ClassesDiffViewAttrs> { + let rows: DiffRow[] | null = null; + let loading = false; + let error: string | null = null; + let dataSource: InMemoryDataSource | null = null; + + let lastBaselineEngine: Engine | null = null; + let lastCurrentEngine: Engine | null = null; + + async function load(currentEngine: Engine, baselineEngine: Engine) { + // Snapshot active refs; any change during the awaits means the + // result is stale and we drop it. The same check in catch silently + // swallows rejections from a disposed engine. + const primarySnap = getActiveDump(); + const baselineSnap = getActiveBaseline(); + if (!primarySnap || !baselineSnap) return; + const isStale = () => + getActiveDump() !== primarySnap || getActiveBaseline() !== baselineSnap; + loading = true; + error = null; + try { + let merged: DiffRow[]; + if (isSelfTraceDiff()) { + // Single-engine fast path: one SQL JOIN, no JS merge. SQLite + // produces DiffRow-shaped output directly and orders by + // |Δ dominated_size_bytes| desc. + merged = await fetchDiffViaSql( + currentEngine, + {upid: primarySnap.upid, ts: primarySnap.ts}, + {upid: baselineSnap.dump.upid, ts: baselineSnap.dump.ts}, + ); + } else { + // Cross-trace path: two engines, JS outer-join in mergeRows. + // Pass primarySnap explicitly. The bare + // `dumpFilterSql(undefined)` form fell back to + // dumpFilterOverride which is null in diff mode — yielding + // '1=1' and pulling in every dump in the primary engine. + const baselineFilter = baselineDumpFilterSql('a'); + const currentFilter = dumpFilterSql(primarySnap, 'a'); + const [baselineRows, currentRows] = await Promise.all([ + fetchAll( + baselineEngine, + baselineSnap.dump.upid, + baselineSnap.dump.ts, + baselineFilter, + ), + fetchAll( + currentEngine, + primarySnap.upid, + primarySnap.ts, + currentFilter, + ), + ]); + if (isStale()) return; + merged = mergeRows({ + baseline: baselineRows, + current: currentRows, + keyOf: (r) => String(r.type_name ?? ''), + numericFields: NUMERIC_FIELDS, + primaryDeltaField: 'dominated_size_bytes', + }); + merged.sort(compareByAbsDeltaDesc('dominated_size_bytes')); + } + if (isStale()) return; + rows = merged; + dataSource = new InMemoryDataSource(merged); + publishDiffRows('classes', merged); + } catch (err) { + if (isStale()) return; + error = err instanceof Error ? err.message : String(err); + console.error('Classes diff load failed:', err); + } finally { + loading = false; + m.redraw(); + } + } + + function ensureLoaded(currentEngine: Engine, baselineEngine: Engine) { + if ( + currentEngine !== lastCurrentEngine || + baselineEngine !== lastBaselineEngine + ) { + lastCurrentEngine = currentEngine; + lastBaselineEngine = baselineEngine; + rows = null; + dataSource = null; + load(currentEngine, baselineEngine).catch(console.error); + } + } + + return { + oninit(vnode) { + ensureLoaded(vnode.attrs.currentEngine, vnode.attrs.baselineEngine); + }, + onupdate(vnode) { + ensureLoaded(vnode.attrs.currentEngine, vnode.attrs.baselineEngine); + }, + view(vnode) { + const {navigate} = vnode.attrs; + if (loading && !rows) { + return m('div', {class: 'pf-hde-loading'}, m(Spinner, {easing: true})); + } + if (error) { + return m(EmptyState, { + icon: 'error', + title: `Failed to compute Classes diff: ${error}`, + fillHeight: true, + }); + } + if (!rows || !dataSource) { + return m(EmptyState, { + icon: 'memory', + title: 'No Java heap data to diff', + fillHeight: true, + }); + } + + // Same column titles as the non-diff Classes view (`Retained`, + // `Count`, `Reachable`, etc.) so users moving between modes see + // the same vocabulary, just with Δ/Baseline/Current prefixes. + const size = { + field: 'dominated_size_bytes', + title: 'Retained', + kind: 'size' as const, + }; + const count = { + field: 'reachable_obj_count', + title: 'Count', + kind: 'count' as const, + }; + const extraFields = [ + { + field: 'reachable_size_bytes', + title: 'Reachable', + kind: 'size' as const, + }, + { + field: 'dominated_obj_count', + title: 'Retained Count', + kind: 'count' as const, + }, + ]; + const schema = buildSizeCountSchema({ + keyTitle: 'Class', + keyRenderer: (value) => + m( + 'button', + { + class: 'pf-hde-link', + onclick: () => navigate('objects', {cls: String(value)}), + }, + String(value), + ), + size, + count, + extraFields, + }); + + const initialColumns = buildSizeCountInitialColumns({ + size, + count, + extraFields, + }); + + return m('div', {class: 'pf-hde-view-content'}, [ + m('h2', {class: 'pf-hde-view-heading'}, [ + 'Classes diff ', + m( + 'span', + {class: 'pf-hde-muted'}, + `(${rows.length.toLocaleString()} classes)`, + ), + ]), + m(DataGrid, { + schema, + rootSchema: 'query', + data: dataSource, + fillHeight: true, + initialColumns, + showExportButton: true, + }), + ]); + }, + }; +} + +export default ClassesDiffView;
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/dominators_diff_view.ts b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/dominators_diff_view.ts new file mode 100644 index 0000000..0b17469 --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/dominators_diff_view.ts
@@ -0,0 +1,394 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Dominators diff: GROUP BY root class name. Root object ids aren't +// stable across snapshots, so class is the natural join key. + +import m from 'mithril'; +import {Spinner} from '../../../../widgets/spinner'; +import {EmptyState} from '../../../../widgets/empty_state'; +import type {Engine} from '../../../../trace_processor/engine'; +import {NUM, STR_NULL} from '../../../../trace_processor/query_result'; +import type {Row} from '../../../../trace_processor/query_result'; +import {DataGrid} from '../../../../components/widgets/datagrid/datagrid'; +import {InMemoryDataSource} from '../../../../components/widgets/datagrid/in_memory_data_source'; +import type {NavFn} from '../../components'; +import type {DiffRow} from '../../diff/diff_rows'; +import { + compareByAbsDeltaDesc, + dedupeByKey, + mergeRows, +} from '../../diff/diff_rows'; +import {publishDiffRows} from '../../diff/diff_debug'; +import { + buildSizeCountInitialColumns, + buildSizeCountSchema, +} from '../../diff/diff_schemas'; +import {SQL_PREAMBLE} from '../../components'; +import { + baselineDumpFilterSql, + getActiveBaseline, + isSelfTraceDiff, +} from '../../baseline/state'; +import {dumpFilterSql, getActiveDump} from '../../queries'; +import {cachedFetch, dumpKey} from '../../diff/diff_cache'; +import { + KEY_COL, + STATUS_COL, + baselineCol, + currentCol, + deltaCol, +} from '../../diff/diff_rows'; +import {LONG, LONG_NULL} from '../../../../trace_processor/query_result'; + +function buildQuery(filterSql: string): string { + return ` + SELECT + ifnull(cls.deobfuscated_name, cls.name) AS root_class, + COUNT(*) AS cnt, + SUM(d.dominated_size_bytes) AS dominated_size_bytes, + SUM(d.dominated_native_size_bytes) AS dominated_native_size_bytes, + SUM(d.dominated_obj_count) AS dominated_obj_count + FROM heap_graph_dominator_tree d + JOIN heap_graph_object o ON o.id = d.id + JOIN heap_graph_class cls ON cls.id = o.type_id + WHERE d.idom_id IS NULL AND ${filterSql} + GROUP BY root_class + `; +} + +const ITER_SPEC = { + root_class: STR_NULL, + cnt: NUM, + dominated_size_bytes: NUM, + dominated_native_size_bytes: NUM, + dominated_obj_count: NUM, +}; + +interface DominatorsDiffViewAttrs { + readonly currentEngine: Engine; + readonly baselineEngine: Engine; + readonly navigate: NavFn; +} + +const NUMERIC_FIELDS = [ + 'cnt', + 'dominated_size_bytes', + 'dominated_native_size_bytes', + 'dominated_obj_count', +]; + +// Single-engine fast path: one SQL JOIN, no JS merge. +function buildDiffJoinQuery( + cur: {upid: number; ts: number | bigint}, + base: {upid: number; ts: number | bigint}, +): string { + const side = (name: string, upid: number, ts: number | bigint): string => ` + ${name} AS ( + SELECT + ifnull(cls.deobfuscated_name, cls.name) AS k, + COUNT(*) AS cnt, + SUM(d.dominated_size_bytes) AS dominated_size_bytes, + SUM(d.dominated_native_size_bytes) AS dominated_native_size_bytes, + SUM(d.dominated_obj_count) AS dominated_obj_count + FROM heap_graph_dominator_tree d + JOIN heap_graph_object o ON o.id = d.id + JOIN heap_graph_class cls ON cls.id = o.type_id + WHERE d.idom_id IS NULL + AND o.upid = ${upid} AND o.graph_sample_ts = ${ts} + AND ifnull(cls.deobfuscated_name, cls.name) IS NOT NULL + GROUP BY k + )`; + return ` + SELECT * FROM ( + WITH + ${side('cur', cur.upid, cur.ts)}, + ${side('base', base.upid, base.ts)} + SELECT + c.k AS ${KEY_COL}, + CASE + WHEN b.k IS NULL THEN 'NEW' + WHEN c.dominated_size_bytes = b.dominated_size_bytes THEN 'UNCHANGED' + WHEN c.dominated_size_bytes > b.dominated_size_bytes THEN 'GREW' + ELSE 'SHRANK' + END AS ${STATUS_COL}, + c.k AS root_class, + b.cnt AS ${baselineCol('cnt')}, + c.cnt AS ${currentCol('cnt')}, + c.cnt - ifnull(b.cnt, 0) AS ${deltaCol('cnt')}, + b.dominated_size_bytes AS ${baselineCol('dominated_size_bytes')}, + c.dominated_size_bytes AS ${currentCol('dominated_size_bytes')}, + c.dominated_size_bytes - ifnull(b.dominated_size_bytes, 0) + AS ${deltaCol('dominated_size_bytes')}, + b.dominated_native_size_bytes AS ${baselineCol('dominated_native_size_bytes')}, + c.dominated_native_size_bytes AS ${currentCol('dominated_native_size_bytes')}, + c.dominated_native_size_bytes - ifnull(b.dominated_native_size_bytes, 0) + AS ${deltaCol('dominated_native_size_bytes')}, + b.dominated_obj_count AS ${baselineCol('dominated_obj_count')}, + c.dominated_obj_count AS ${currentCol('dominated_obj_count')}, + c.dominated_obj_count - ifnull(b.dominated_obj_count, 0) + AS ${deltaCol('dominated_obj_count')} + FROM cur c LEFT JOIN base b USING (k) + UNION ALL + SELECT + b.k, + 'REMOVED', + b.k, + b.cnt, NULL, -b.cnt, + b.dominated_size_bytes, NULL, -b.dominated_size_bytes, + b.dominated_native_size_bytes, NULL, -b.dominated_native_size_bytes, + b.dominated_obj_count, NULL, -b.dominated_obj_count + FROM base b + WHERE NOT EXISTS (SELECT 1 FROM cur c WHERE c.k = b.k) + ) + ORDER BY abs(${deltaCol('dominated_size_bytes')}) DESC + `; +} + +const DIFF_ROW_ITER = { + [KEY_COL]: STR_NULL, + [STATUS_COL]: STR_NULL, + root_class: STR_NULL, + [baselineCol('cnt')]: LONG_NULL, + [currentCol('cnt')]: LONG_NULL, + [deltaCol('cnt')]: LONG, + [baselineCol('dominated_size_bytes')]: LONG_NULL, + [currentCol('dominated_size_bytes')]: LONG_NULL, + [deltaCol('dominated_size_bytes')]: LONG, + [baselineCol('dominated_native_size_bytes')]: LONG_NULL, + [currentCol('dominated_native_size_bytes')]: LONG_NULL, + [deltaCol('dominated_native_size_bytes')]: LONG, + [baselineCol('dominated_obj_count')]: LONG_NULL, + [currentCol('dominated_obj_count')]: LONG_NULL, + [deltaCol('dominated_obj_count')]: LONG, +}; + +async function fetchDiffViaSql( + engine: Engine, + cur: {upid: number; ts: number | bigint}, + base: {upid: number; ts: number | bigint}, +): Promise<DiffRow[]> { + const key = `dominators-diff:${dumpKey(cur.upid, cur.ts)}:${dumpKey(base.upid, base.ts)}`; + const rows = await cachedFetch(engine, key, async () => { + await engine.query(SQL_PREAMBLE); + const res = await engine.query(buildDiffJoinQuery(cur, base)); + const out: Row[] = []; + for (const it = res.iter(DIFF_ROW_ITER); it.valid(); it.next()) { + const row: Record<string, unknown> = {}; + for (const k of Object.keys(DIFF_ROW_ITER)) { + row[k] = it[k as keyof typeof DIFF_ROW_ITER]; + } + out.push(row as Row); + } + return out; + }); + return rows as unknown as DiffRow[]; +} + +// Cached per (engine, dump). The primary side reuses across baseline +// swaps; see diff_cache.ts. +async function runQuery( + engine: Engine, + upid: number, + ts: number | bigint, + filterSql: string, +): Promise<ReadonlyArray<Row>> { + return cachedFetch(engine, `dominators:${dumpKey(upid, ts)}`, async () => { + await engine.query(SQL_PREAMBLE); + const res = await engine.query(buildQuery(filterSql)); + const out: Row[] = []; + const it = res.iter(ITER_SPEC); + for (; it.valid(); it.next()) { + if (it.root_class === null) continue; + out.push({ + root_class: it.root_class, + cnt: it.cnt, + dominated_size_bytes: it.dominated_size_bytes, + dominated_native_size_bytes: it.dominated_native_size_bytes, + dominated_obj_count: it.dominated_obj_count, + }); + } + return dedupeByKey(out, (r) => String(r.root_class ?? ''), NUMERIC_FIELDS); + }); +} + +function DominatorsDiffView(): m.Component<DominatorsDiffViewAttrs> { + let rows: DiffRow[] | null = null; + let loading = false; + let error: string | null = null; + let dataSource: InMemoryDataSource | null = null; + let lastB: Engine | null = null; + let lastC: Engine | null = null; + + async function load(currentEngine: Engine, baselineEngine: Engine) { + const primarySnap = getActiveDump(); + const baselineSnap = getActiveBaseline(); + if (!primarySnap || !baselineSnap) return; + const isStale = () => + getActiveDump() !== primarySnap || getActiveBaseline() !== baselineSnap; + loading = true; + error = null; + try { + let merged: DiffRow[]; + if (isSelfTraceDiff()) { + merged = await fetchDiffViaSql( + currentEngine, + {upid: primarySnap.upid, ts: primarySnap.ts}, + {upid: baselineSnap.dump.upid, ts: baselineSnap.dump.ts}, + ); + } else { + const baselineFilter = baselineDumpFilterSql('o'); + const currentFilter = dumpFilterSql(primarySnap, 'o'); + const [b, c] = await Promise.all([ + runQuery( + baselineEngine, + baselineSnap.dump.upid, + baselineSnap.dump.ts, + baselineFilter, + ), + runQuery( + currentEngine, + primarySnap.upid, + primarySnap.ts, + currentFilter, + ), + ]); + if (isStale()) return; + merged = mergeRows({ + baseline: b, + current: c, + keyOf: (r) => String(r.root_class ?? ''), + numericFields: NUMERIC_FIELDS, + primaryDeltaField: 'dominated_size_bytes', + }); + merged.sort(compareByAbsDeltaDesc('dominated_size_bytes')); + } + if (isStale()) return; + rows = merged; + dataSource = new InMemoryDataSource(merged); + publishDiffRows('dominators', merged); + } catch (err) { + if (isStale()) return; + error = err instanceof Error ? err.message : String(err); + console.error('Dominators diff load failed:', err); + } finally { + loading = false; + m.redraw(); + } + } + + function ensure(currentEngine: Engine, baselineEngine: Engine) { + if (currentEngine !== lastC || baselineEngine !== lastB) { + lastC = currentEngine; + lastB = baselineEngine; + rows = null; + dataSource = null; + load(currentEngine, baselineEngine).catch(console.error); + } + } + + return { + oninit(vnode) { + ensure(vnode.attrs.currentEngine, vnode.attrs.baselineEngine); + }, + onupdate(vnode) { + ensure(vnode.attrs.currentEngine, vnode.attrs.baselineEngine); + }, + view(vnode) { + const {navigate} = vnode.attrs; + if (loading && !rows) { + return m('div', {class: 'pf-hde-loading'}, m(Spinner, {easing: true})); + } + if (error) { + return m(EmptyState, { + icon: 'error', + title: `Failed to compute Dominators diff: ${error}`, + fillHeight: true, + }); + } + if (!rows || !dataSource) { + return m(EmptyState, { + icon: 'account_tree', + title: 'No dominator-tree roots to diff', + fillHeight: true, + }); + } + + const size = { + field: 'dominated_size_bytes', + title: 'Retained', + kind: 'size' as const, + }; + const count = { + field: 'cnt', + title: 'Roots', + kind: 'count' as const, + }; + const extraFields = [ + { + field: 'dominated_native_size_bytes', + title: 'Retained Native', + kind: 'size' as const, + }, + { + field: 'dominated_obj_count', + title: 'Retained Count', + kind: 'count' as const, + }, + ]; + const schema = buildSizeCountSchema({ + keyTitle: 'Root Class', + keyRenderer: (value) => + m( + 'button', + { + class: 'pf-hde-link', + onclick: () => navigate('objects', {cls: String(value ?? '')}), + }, + String(value ?? ''), + ), + size, + count, + extraFields, + }); + + const initialColumns = buildSizeCountInitialColumns({ + size, + count, + extraFields, + }); + + return m('div', {class: 'pf-hde-view-content'}, [ + m('h2', {class: 'pf-hde-view-heading'}, [ + 'Dominators diff ', + m( + 'span', + {class: 'pf-hde-muted'}, + `(${rows.length.toLocaleString()} root classes)`, + ), + ]), + m(DataGrid, { + schema, + rootSchema: 'query', + data: dataSource, + fillHeight: true, + initialColumns, + showExportButton: true, + }), + ]); + }, + }; +} + +export default DominatorsDiffView;
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/object_diff_view.ts b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/object_diff_view.ts new file mode 100644 index 0000000..ae5b2fb --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/object_diff_view.ts
@@ -0,0 +1,745 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Per-object diff. Loads `getInstance(currentEngine, currentId)` and +// `getInstance(baselineEngine, baselineId)` in parallel and renders both +// sides plus deltas for size, fields, and array elements. NEW / REMOVED +// renders a single side with the status banner. + +import m from 'mithril'; +import {Spinner} from '../../../../widgets/spinner'; +import {EmptyState} from '../../../../widgets/empty_state'; +import type {Engine} from '../../../../trace_processor/engine'; +import {DataGrid} from '../../../../components/widgets/datagrid/datagrid'; +import type { + CellRenderResult, + CellRenderer, + SchemaRegistry, +} from '../../../../components/widgets/datagrid/datagrid_schema'; +import type {Row} from '../../../../trace_processor/query_result'; +import type {InstanceDetail, InstanceRow, PrimOrRef} from '../../types'; +import { + pairObjects, + type ObjectPairRow, + type ObjectRowRaw, +} from '../../diff/object_pairing'; +import {fmtHex} from '../../format'; +import {type NavFn, Section, shortClassName} from '../../components'; +import * as queries from '../../queries'; +import {getActiveBaseline} from '../../baseline/state'; +import type {HeapDump} from '../../queries'; +import { + deltaSizeRenderer, + deltaCountRenderer, + sideSizeRenderer, + sideCountRenderer, + statusRenderer, +} from '../../diff/diff_schemas'; +import type {DiffStatus} from '../../diff/diff_rows'; + +interface ObjectDiffViewAttrs { + readonly currentEngine: Engine; + readonly baselineEngine: Engine; + readonly activeDump: HeapDump; + readonly currentId: number | null; + readonly baselineId: number | null; + readonly navigate: NavFn; +} + +function renderPrimOrRef(v: PrimOrRef): string { + return v.kind === 'prim' ? v.v : v.display; +} + +function primOrRefEqual(a: PrimOrRef, b: PrimOrRef): boolean { + if (a.kind !== b.kind) return false; + if (a.kind === 'prim' && b.kind === 'prim') return a.v === b.v; + if (a.kind === 'ref' && b.kind === 'ref') { + // Pure ID equality is useless cross-trace as parser IDs are unstable. + // As a heuristic for field-level diffs where full object pairing is + // unavailable, we consider references equal if they point to objects + // of the same class and with the same string value (if present). + // + // Note: `a.display` is formatted as "ClassName 0xAddress". We extract + // the class name by taking the part before the space. + const classA = a.display.split(' ')[0]; + const classB = b.display.split(' ')[0]; + return classA === classB && (a.str ?? '') === (b.str ?? ''); + } + return false; +} + +function fieldStatus( + c: PrimOrRef | undefined, + b: PrimOrRef | undefined, +): DiffStatus { + if (c === undefined && b === undefined) return 'UNCHANGED'; + if (c === undefined) return 'REMOVED'; + if (b === undefined) return 'NEW'; + return primOrRefEqual(c, b) ? 'UNCHANGED' : 'GREW'; +} + +const FIELD_DIFF_SCHEMA: SchemaRegistry = { + query: { + name: {title: 'Name', columnType: 'text'}, + type_name: {title: 'Type', columnType: 'text'}, + status: { + title: 'Status', + columnType: 'text', + cellRenderer: statusRenderer, + }, + b_value: {title: 'Baseline', columnType: 'text'}, + c_value: {title: 'Current', columnType: 'text'}, + }, +}; + +const FIELD_DIFF_COLS = [ + {id: 'name', field: 'name'}, + {id: 'type_name', field: 'type_name'}, + {id: 'status', field: 'status'}, + {id: 'b_value', field: 'b_value'}, + {id: 'c_value', field: 'c_value'}, +]; + +const ARRAY_DIFF_SCHEMA: SchemaRegistry = { + query: { + idx: {title: 'Index', columnType: 'quantitative'}, + status: { + title: 'Status', + columnType: 'text', + cellRenderer: statusRenderer, + }, + b_value: {title: 'Baseline', columnType: 'text'}, + c_value: {title: 'Current', columnType: 'text'}, + }, +}; + +const ARRAY_DIFF_COLS = [ + {id: 'idx', field: 'idx'}, + {id: 'status', field: 'status'}, + {id: 'b_value', field: 'b_value'}, + {id: 'c_value', field: 'c_value'}, +]; + +interface SideValueRow extends Row { + metric: string; + baseline: number | null; + current: number | null; + delta: number | null; +} + +// Per-row renderer dispatch: metrics whose name ends in 'Count' format +// values as plain integers, the rest as byte sizes. Folds the "size" and +// "count" rows into one grid so the section body holds a single child. +const SIZE_OR_COUNT_SCHEMA: SchemaRegistry = (() => { + const isCountRow = (row: Row): boolean => + String(row.metric ?? '').endsWith('Count'); + const sideRenderer: CellRenderer = (v, row) => + isCountRow(row) ? sideCountRenderer(v, row) : sideSizeRenderer(v, row); + const deltaRenderer: CellRenderer = (v, row) => { + if (v === null) { + return { + content: m('span', {class: 'pf-hde-mono pf-hde-muted'}, '—'), + align: 'right', + } satisfies CellRenderResult; + } + return isCountRow(row) + ? deltaCountRenderer(v, row) + : deltaSizeRenderer(v, row); + }; + return { + query: { + metric: {title: 'Metric', columnType: 'text'}, + baseline: { + title: 'Baseline', + columnType: 'quantitative', + cellRenderer: sideRenderer, + }, + current: { + title: 'Current', + columnType: 'quantitative', + cellRenderer: sideRenderer, + }, + delta: { + title: 'Δ', + columnType: 'quantitative', + cellRenderer: deltaRenderer, + }, + }, + }; +})(); + +const SIZE_INITIAL_COLS = [ + {id: 'metric', field: 'metric'}, + {id: 'baseline', field: 'baseline'}, + {id: 'current', field: 'current'}, + {id: 'delta', field: 'delta'}, +]; + +function diffStatus( + c: InstanceDetail | null, + b: InstanceDetail | null, +): DiffStatus { + if (c && !b) return 'NEW'; + if (!c && b) return 'REMOVED'; + if (!c || !b) return 'UNCHANGED'; + const cR = c.row.retainedTotal; + const bR = b.row.retainedTotal; + if (cR > bR) return 'GREW'; + if (cR < bR) return 'SHRANK'; + return 'UNCHANGED'; +} + +function nullableDelta(c: number | null, b: number | null): number | null { + if (c === null && b === null) return null; + return (c ?? 0) - (b ?? 0); +} + +function renderSizeTable( + c: InstanceDetail | null, + b: InstanceDetail | null, +): m.Children { + function retainedTotal(d: InstanceDetail): {java: number; native_: number} { + let java = 0; + let nat = 0; + for (const h of d.row.retainedByHeap) { + java += h.java; + nat += h.native_; + } + return {java, native_: nat}; + } + const cRet = c ? retainedTotal(c) : null; + const bRet = b ? retainedTotal(b) : null; + const rows: SideValueRow[] = [ + { + metric: 'Shallow', + baseline: b?.row.shallowJava ?? null, + current: c?.row.shallowJava ?? null, + delta: nullableDelta( + c?.row.shallowJava ?? null, + b?.row.shallowJava ?? null, + ), + }, + { + metric: 'Shallow Native', + baseline: b?.row.shallowNative ?? null, + current: c?.row.shallowNative ?? null, + delta: nullableDelta( + c?.row.shallowNative ?? null, + b?.row.shallowNative ?? null, + ), + }, + { + metric: 'Retained', + baseline: bRet?.java ?? null, + current: cRet?.java ?? null, + delta: nullableDelta(cRet?.java ?? null, bRet?.java ?? null), + }, + { + metric: 'Retained Native', + baseline: bRet?.native_ ?? null, + current: cRet?.native_ ?? null, + delta: nullableDelta(cRet?.native_ ?? null, bRet?.native_ ?? null), + }, + { + metric: 'Retained Count', + baseline: b?.row.retainedCount ?? null, + current: c?.row.retainedCount ?? null, + delta: nullableDelta( + c?.row.retainedCount ?? null, + b?.row.retainedCount ?? null, + ), + }, + ]; + return m(DataGrid, { + schema: SIZE_OR_COUNT_SCHEMA, + rootSchema: 'query', + data: rows, + initialColumns: SIZE_INITIAL_COLS, + }); +} + +function renderHeader( + c: InstanceDetail | null, + b: InstanceDetail | null, + status: DiffStatus, + navigate: NavFn, +): m.Children { + const present = c ?? b; + if (present === null) return null; + const className = present.row.className; + const heap = present.row.heap; + const cId = c?.row.id ?? null; + const bId = b?.row.id ?? null; + // Plain header div (no card), matching non-diff ObjectView. The parent + // pf-hde-view-stack already spaces it from the first Section — wrapping in + // pf-hde-card + pf-hde-mb-4 stacks two margin sources and looks cramped. + return m('div', [ + m('h2', {class: 'pf-hde-view-heading pf-hde-view-heading--tight'}, [ + `${shortClassName(className)} `, + cId !== null ? fmtHex(cId) : bId !== null ? fmtHex(bId) : '', + ' ', + m( + 'span', + { + 'class': statusClass(status), + 'aria-label': `Status: ${status}`, + }, + statusLabel(status), + ), + ]), + m('div', {class: 'pf-hde-info-grid'}, [ + m('span', {class: 'pf-hde-info-grid__label'}, 'Class:'), + m('span', className), + m('span', {class: 'pf-hde-info-grid__label'}, 'Heap:'), + m('span', heap), + cId !== null + ? [ + m('span', {class: 'pf-hde-info-grid__label'}, 'Current id:'), + m('span', [ + m( + 'button', + { + class: 'pf-hde-link', + onclick: () => + navigate('object', {id: cId, label: undefined}), + }, + fmtHex(cId), + ), + ]), + ] + : null, + bId !== null + ? [ + m('span', {class: 'pf-hde-info-grid__label'}, 'Baseline id:'), + m('span', {class: 'pf-hde-mono pf-hde-muted'}, fmtHex(bId)), + ] + : null, + ]), + ]); +} + +function statusLabel(s: DiffStatus): string { + switch (s) { + case 'NEW': + return 'NEW'; + case 'REMOVED': + return 'REMOVED'; + case 'GREW': + return 'GREW'; + case 'SHRANK': + return 'SHRANK'; + case 'UNCHANGED': + return 'unchanged'; + } +} + +function statusClass(s: DiffStatus): string { + return `pf-hde-status-text pf-hde-status-text--${s.toLowerCase()}`; +} + +function renderFieldsDiff( + c: InstanceDetail | null, + b: InstanceDetail | null, +): m.Children { + const cFields = c?.instanceFields ?? []; + const bFields = b?.instanceFields ?? []; + if (cFields.length === 0 && bFields.length === 0) return null; + const byKey = new Map< + string, + { + name: string; + typeName: string; + c?: PrimOrRef; + b?: PrimOrRef; + } + >(); + for (const f of cFields) { + const k = `${f.name}\x1f${f.typeName}`; + byKey.set(k, {name: f.name, typeName: f.typeName, c: f.value}); + } + for (const f of bFields) { + const k = `${f.name}\x1f${f.typeName}`; + const existing = byKey.get(k); + if (existing) { + existing.b = f.value; + } else { + byKey.set(k, {name: f.name, typeName: f.typeName, b: f.value}); + } + } + const rows: Row[] = []; + for (const f of byKey.values()) { + rows.push({ + name: f.name, + type_name: f.typeName, + status: fieldStatus(f.c, f.b), + c_value: f.c ? renderPrimOrRef(f.c) : null, + b_value: f.b ? renderPrimOrRef(f.b) : null, + }); + } + rows.sort((a, b) => String(a.name).localeCompare(String(b.name))); + return m( + Section, + {title: 'Fields', defaultOpen: rows.length < 50}, + m(DataGrid, { + schema: FIELD_DIFF_SCHEMA, + rootSchema: 'query', + data: rows, + initialColumns: FIELD_DIFF_COLS, + }), + ); +} + +function instanceRowToRaw(r: InstanceRow): ObjectRowRaw { + return { + id: r.id, + className: r.className, + heapType: r.heap, + valueString: r.str, + arrayLength: null, + shallow: r.shallowJava, + shallowNative: r.shallowNative, + retained: r.retainedTotal, + retainedNative: 0, + retainedCount: r.retainedCount, + }; +} + +function buildInstanceDiffSchema(navigate: NavFn): SchemaRegistry { + const idRenderer: CellRenderer = (value) => { + if (value === null || value === undefined) { + return { + content: m('span', {class: 'pf-hde-mono pf-hde-muted'}, '—'), + align: 'right', + } satisfies CellRenderResult; + } + return { + content: m('span', {class: 'pf-hde-mono'}, fmtHex(Number(value))), + align: 'right', + } satisfies CellRenderResult; + }; + const objectRenderer: CellRenderer = (_value, row) => { + const cls = String(row.cls ?? ''); + const cId = row.c_id == null ? null : Number(row.c_id); + const bId = row.b_id == null ? null : Number(row.b_id); + const str = row.str == null ? null : String(row.str); + const displayId = cId ?? bId ?? 0; + const display = `${shortClassName(cls)} ${fmtHex(displayId)}`; + return { + content: m( + 'button', + { + class: 'pf-hde-link', + onclick: () => + navigate('object', { + id: cId ?? bId ?? 0, + currentId: cId, + baselineId: bId, + label: str ? `"${str.slice(0, 30)}"` : display, + }), + }, + display, + ), + align: 'left', + } satisfies CellRenderResult; + }; + return { + query: { + cls: { + title: 'Object', + columnType: 'text', + cellRenderer: objectRenderer, + }, + status: { + title: 'Status', + columnType: 'text', + cellRenderer: statusRenderer, + }, + delta_retained: { + title: 'Δ Retained', + columnType: 'quantitative', + cellRenderer: deltaSizeRenderer, + }, + b_retained: { + title: 'Baseline Retained', + columnType: 'quantitative', + cellRenderer: sideSizeRenderer, + }, + c_retained: { + title: 'Current Retained', + columnType: 'quantitative', + cellRenderer: sideSizeRenderer, + }, + str: {title: 'String Value', columnType: 'text'}, + c_id: { + title: 'Current id', + columnType: 'identifier', + cellRenderer: idRenderer, + }, + b_id: { + title: 'Baseline id', + columnType: 'identifier', + cellRenderer: idRenderer, + }, + }, + }; +} + +const INSTANCE_DIFF_COLS = [ + {id: 'cls', field: 'cls'}, + {id: 'status', field: 'status'}, + {id: 'delta_retained', field: 'delta_retained', sort: 'DESC' as const}, + {id: 'b_retained', field: 'b_retained'}, + {id: 'c_retained', field: 'c_retained'}, + {id: 'str', field: 'str'}, + // c_id / b_id are required by the Object column's click renderer — + // InMemoryDataSource drops fields not declared as visible columns. + {id: 'c_id', field: 'c_id'}, + {id: 'b_id', field: 'b_id'}, +]; + +function pairToInstanceDiffRow(p: ObjectPairRow): Row { + return { + cls: p.className, + status: p.status, + str: p.valueString, + c_id: p.c_id, + b_id: p.b_id, + delta_retained: p.delta_retained, + c_retained: p.c_retained, + b_retained: p.b_retained, + }; +} + +function renderInstanceListDiff( + title: string, + current: ReadonlyArray<InstanceRow>, + baseline: ReadonlyArray<InstanceRow>, + navigate: NavFn, +): m.Children { + if (current.length === 0 && baseline.length === 0) return null; + const paired = pairObjects( + current.map(instanceRowToRaw), + baseline.map(instanceRowToRaw), + ); + paired.sort( + (a, b) => Math.abs(b.delta_retained) - Math.abs(a.delta_retained), + ); + const rows = paired.map(pairToInstanceDiffRow); + return m( + Section, + { + title: `${title} (${paired.length})`, + defaultOpen: paired.length > 0 && paired.length < 50, + }, + m(DataGrid, { + schema: buildInstanceDiffSchema(navigate), + rootSchema: 'query', + data: rows, + initialColumns: INSTANCE_DIFF_COLS, + }), + ); +} + +function renderArrayDiff( + c: InstanceDetail | null, + b: InstanceDetail | null, +): m.Children { + const cIsArr = c?.isArrayInstance ?? false; + const bIsArr = b?.isArrayInstance ?? false; + if (!cIsArr && !bIsArr) return null; + const cElems = c?.arrayElems ?? []; + const bElems = b?.arrayElems ?? []; + const cByIdx = new Map<number, PrimOrRef>( + cElems.map((e) => [e.idx, e.value]), + ); + const bByIdx = new Map<number, PrimOrRef>( + bElems.map((e) => [e.idx, e.value]), + ); + const idxs = new Set<number>([...cByIdx.keys(), ...bByIdx.keys()]); + const rows: Row[] = []; + for (const i of idxs) { + const cv = cByIdx.get(i); + const bv = bByIdx.get(i); + rows.push({ + idx: i, + status: fieldStatus(cv, bv), + c_value: cv ? renderPrimOrRef(cv) : null, + b_value: bv ? renderPrimOrRef(bv) : null, + }); + } + rows.sort((a, b) => Number(a.idx) - Number(b.idx)); + const cLen = c?.arrayLength ?? 0; + const bLen = b?.arrayLength ?? 0; + const title = `Array Elements (current: ${cLen}, baseline: ${bLen})`; + return m( + Section, + {title, defaultOpen: rows.length < 50}, + m(DataGrid, { + schema: ARRAY_DIFF_SCHEMA, + rootSchema: 'query', + data: rows, + initialColumns: ARRAY_DIFF_COLS, + }), + ); +} + +function ObjectDiffView(): m.Component<ObjectDiffViewAttrs> { + let currentVnode: m.Vnode<ObjectDiffViewAttrs>; + let current: InstanceDetail | null = null; + let baseline: InstanceDetail | null = null; + let loading = false; + let error: string | null = null; + let lastCid: number | null | undefined = undefined; + let lastBid: number | null | undefined = undefined; + let lastCe: Engine | null = null; + let lastBe: Engine | null = null; + + async function load( + currentEngine: Engine, + baselineEngine: Engine, + currentId: number | null, + baselineId: number | null, + ) { + const primarySnap = currentVnode.attrs.activeDump; + const baselineSnap = getActiveBaseline(); + if (baselineSnap === null) return; + const isStale = () => + currentVnode.attrs.activeDump !== primarySnap || + getActiveBaseline() !== baselineSnap; + loading = true; + error = null; + try { + const [c, b] = await Promise.all([ + currentId !== null + ? queries.getInstance(currentEngine, primarySnap, currentId) + : Promise.resolve(null), + baselineId !== null + ? queries.getInstance(baselineEngine, baselineSnap.dump, baselineId) + : Promise.resolve(null), + ]); + if (isStale()) return; + current = c; + baseline = b; + } catch (err) { + if (isStale()) return; + error = err instanceof Error ? err.message : String(err); + console.error('Object diff load failed:', err); + } finally { + loading = false; + m.redraw(); + } + } + + function ensureLoaded( + currentEngine: Engine, + baselineEngine: Engine, + currentId: number | null, + baselineId: number | null, + ) { + if ( + currentEngine === lastCe && + baselineEngine === lastBe && + currentId === lastCid && + baselineId === lastBid + ) { + return; + } + lastCe = currentEngine; + lastBe = baselineEngine; + lastCid = currentId; + lastBid = baselineId; + current = null; + baseline = null; + error = null; + if (currentId !== null || baselineId !== null) { + load(currentEngine, baselineEngine, currentId, baselineId).catch( + console.error, + ); + } + } + + return { + oninit(vnode) { + currentVnode = vnode; + ensureLoaded( + vnode.attrs.currentEngine, + vnode.attrs.baselineEngine, + vnode.attrs.currentId, + vnode.attrs.baselineId, + ); + }, + onupdate(vnode) { + currentVnode = vnode; + ensureLoaded( + vnode.attrs.currentEngine, + vnode.attrs.baselineEngine, + vnode.attrs.currentId, + vnode.attrs.baselineId, + ); + }, + view(vnode) { + currentVnode = vnode; + const {currentId, baselineId, navigate} = vnode.attrs; + if (currentId === null && baselineId === null) { + return m(EmptyState, { + icon: 'memory', + title: 'No object selected', + fillHeight: true, + }); + } + if (loading && !current && !baseline) { + return m('div', {class: 'pf-hde-loading'}, m(Spinner, {easing: true})); + } + if (error) { + return m(EmptyState, { + icon: 'error', + title: `Failed to load object diff: ${error}`, + fillHeight: true, + }); + } + if (!current && !baseline) { + return m(EmptyState, { + icon: 'memory', + title: 'Object not found', + fillHeight: true, + }); + } + const status = diffStatus(current, baseline); + return m('div', {class: 'pf-hde-view-scroll pf-hde-view-stack'}, [ + renderHeader(current, baseline, status, navigate), + m( + Section, + {title: 'Object Size', defaultOpen: true}, + renderSizeTable(current, baseline), + ), + renderFieldsDiff(current, baseline), + renderArrayDiff(current, baseline), + renderInstanceListDiff( + 'Objects with References to this Object', + current?.reverseRefs ?? [], + baseline?.reverseRefs ?? [], + navigate, + ), + renderInstanceListDiff( + 'Immediately Dominated Objects', + current?.dominated ?? [], + baseline?.dominated ?? [], + navigate, + ), + ]); + }, + }; +} + +export default ObjectDiffView;
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/strings_diff_view.ts b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/strings_diff_view.ts new file mode 100644 index 0000000..fb8c724 --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/strings_diff_view.ts
@@ -0,0 +1,209 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Strings diff: GROUP BY value_string per engine, JS outer-join. + +import m from 'mithril'; +import {Spinner} from '../../../../widgets/spinner'; +import {EmptyState} from '../../../../widgets/empty_state'; +import type {Engine} from '../../../../trace_processor/engine'; +import {NUM, STR} from '../../../../trace_processor/query_result'; +import type {Row} from '../../../../trace_processor/query_result'; +import {DataGrid} from '../../../../components/widgets/datagrid/datagrid'; +import {InMemoryDataSource} from '../../../../components/widgets/datagrid/in_memory_data_source'; +import type {NavFn} from '../../components'; +import type {DiffRow} from '../../diff/diff_rows'; +import { + compareByAbsDeltaDesc, + dedupeByKey, + mergeRows, +} from '../../diff/diff_rows'; +import {publishDiffRows} from '../../diff/diff_debug'; +import { + buildSizeCountInitialColumns, + buildSizeCountSchema, +} from '../../diff/diff_schemas'; +import {baselineDumpFilterSql, getActiveBaseline} from '../../baseline/state'; +import {dumpFilterSql, getActiveDump} from '../../queries'; + +function buildQuery(filterSql: string): string { + return ` + SELECT + od.value_string AS value, + COUNT(*) AS cnt, + SUM(o.self_size) AS retained + FROM heap_graph_object o + JOIN heap_graph_class c ON o.type_id = c.id + JOIN heap_graph_object_data od ON o.object_data_id = od.id + WHERE o.reachable != 0 + AND ${filterSql} + AND od.value_string IS NOT NULL + AND (c.name = 'java.lang.String' + OR c.deobfuscated_name = 'java.lang.String') + GROUP BY od.value_string + `; +} + +const ITER_SPEC = {value: STR, cnt: NUM, retained: NUM}; + +interface StringsDiffViewAttrs { + readonly currentEngine: Engine; + readonly baselineEngine: Engine; + readonly navigate: NavFn; +} + +const NUMERIC_FIELDS = ['cnt', 'retained']; + +async function runQuery(engine: Engine, filterSql: string): Promise<Row[]> { + const res = await engine.query(buildQuery(filterSql)); + const out: Row[] = []; + for (const it = res.iter(ITER_SPEC); it.valid(); it.next()) { + out.push({value: it.value, cnt: it.cnt, retained: it.retained}); + } + return dedupeByKey(out, (r) => String(r.value ?? ''), NUMERIC_FIELDS); +} + +function StringsDiffView(): m.Component<StringsDiffViewAttrs> { + let rows: DiffRow[] | null = null; + let loading = false; + let error: string | null = null; + let dataSource: InMemoryDataSource | null = null; + let lastB: Engine | null = null; + let lastC: Engine | null = null; + + async function load(currentEngine: Engine, baselineEngine: Engine) { + const primarySnap = getActiveDump(); + const baselineSnap = getActiveBaseline(); + if (!primarySnap || !baselineSnap) return; + const isStale = () => + getActiveDump() !== primarySnap || getActiveBaseline() !== baselineSnap; + loading = true; + error = null; + try { + const baselineFilter = baselineDumpFilterSql('o'); + const currentFilter = dumpFilterSql(primarySnap, 'o'); + const [b, c] = await Promise.all([ + runQuery(baselineEngine, baselineFilter), + runQuery(currentEngine, currentFilter), + ]); + if (isStale()) return; + const merged = mergeRows({ + baseline: b, + current: c, + keyOf: (r) => String(r.value ?? ''), + numericFields: NUMERIC_FIELDS, + primaryDeltaField: 'retained', + }); + merged.sort(compareByAbsDeltaDesc('retained')); + rows = merged; + dataSource = new InMemoryDataSource(merged); + publishDiffRows('strings', merged); + } catch (err) { + if (isStale()) return; + error = err instanceof Error ? err.message : String(err); + console.error('Strings diff load failed:', err); + } finally { + loading = false; + m.redraw(); + } + } + + function ensure(currentEngine: Engine, baselineEngine: Engine) { + if (currentEngine !== lastC || baselineEngine !== lastB) { + lastC = currentEngine; + lastB = baselineEngine; + rows = null; + dataSource = null; + load(currentEngine, baselineEngine).catch(console.error); + } + } + + return { + oninit(vnode) { + ensure(vnode.attrs.currentEngine, vnode.attrs.baselineEngine); + }, + onupdate(vnode) { + ensure(vnode.attrs.currentEngine, vnode.attrs.baselineEngine); + }, + view(vnode) { + const {navigate} = vnode.attrs; + if (loading && !rows) { + return m('div', {class: 'pf-hde-loading'}, m(Spinner, {easing: true})); + } + if (error) { + return m(EmptyState, { + icon: 'error', + title: `Failed to compute Strings diff: ${error}`, + fillHeight: true, + }); + } + if (!rows || !dataSource) { + return m(EmptyState, { + icon: 'text_fields', + title: 'No string data to diff', + fillHeight: true, + }); + } + + const size = { + field: 'retained', + title: 'Retained', + kind: 'size' as const, + }; + const count = {field: 'cnt', title: 'Count', kind: 'count' as const}; + const schema = buildSizeCountSchema({ + keyTitle: 'Value', + keyRenderer: (value) => { + const str = String(value ?? ''); + // Display-only truncation; navigation uses the full string. + const truncated = str.length > 200 ? `${str.slice(0, 200)}…` : str; + return m( + 'button', + { + class: + 'pf-hde-link pf-hde-mono pf-hde-break-all pf-hde-str-color', + onclick: () => navigate('strings', {q: str}), + }, + JSON.stringify(truncated), + ); + }, + size, + count, + }); + + const initialColumns = buildSizeCountInitialColumns({size, count}); + + return m('div', {class: 'pf-hde-view-content'}, [ + m('h2', {class: 'pf-hde-view-heading'}, [ + 'Strings diff ', + m( + 'span', + {class: 'pf-hde-muted'}, + `(${rows.length.toLocaleString()} values)`, + ), + ]), + m(DataGrid, { + schema, + rootSchema: 'query', + data: dataSource, + fillHeight: true, + initialColumns, + showExportButton: true, + }), + ]); + }, + }; +} + +export default StringsDiffView;
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/views/flamegraph_view.ts b/ui/src/plugins/com.android.HeapDumpExplorer/views/flamegraph_view.ts index 12858f2..6918c1a 100644 --- a/ui/src/plugins/com.android.HeapDumpExplorer/views/flamegraph_view.ts +++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/flamegraph_view.ts
@@ -15,18 +15,34 @@ import m from 'mithril'; import type {Trace} from '../../../public/trace'; import type {time} from '../../../base/time'; +import type {Engine} from '../../../trace_processor/engine'; import type {QueryFlamegraphMetric} from '../../../components/query_flamegraph'; import {FlamegraphPanel} from '../../../components/flamegraph_panel'; import { Flamegraph, + getDiffColorCss, type FlamegraphState, type FlamegraphOptionalAction, } from '../../../widgets/flamegraph'; +import {Button, ButtonVariant} from '../../../widgets/button'; +import {Callout} from '../../../widgets/callout'; +import {Intent} from '../../../widgets/common'; +import {Spinner} from '../../../widgets/spinner'; +import {prepareCrossTraceDiff} from '../diff/cross_trace_diff'; // Referenced by session.openFlamegraphPivotedAt. export const METRIC_OBJECT_SIZE = 'Object Size'; export const METRIC_DOMINATED_OBJECT_SIZE = 'Dominated Object Size'; +// Same-trace baseline: dump (upid, ts) within the same engine. The diff +// SQL JOINs the current and baseline class trees on `path_hash_stable` +// (computed by hashing parent path + type_id + heap_type — stable across +// dumps in a single trace because class ids are trace-global). +export interface FlamegraphBaselineRef { + readonly upid: number; + readonly ts: time; +} + interface FlamegraphViewAttrs { readonly trace: Trace; readonly upid: number; @@ -35,6 +51,16 @@ readonly onStateChange: (state: FlamegraphState) => void; // Open the flamegraph-objects tab for `pathHashes` (CSV). readonly onShowObjects: (pathHashes: string, isDominator: boolean) => void; + // When set, build diff metrics that color nodes by delta direction and + // size them by |delta|. For same-trace baselines (`baselineEngine` + // unset), pairing happens in SQL via _graph_scan path hashes. For + // cross-trace baselines (`baselineEngine` set to a different engine), + // pairing happens in JS — see diff/cross_trace_diff.ts. + readonly baseline?: FlamegraphBaselineRef; + // The engine to fetch the baseline's class tree from. Leave unset for + // same-trace baselines (the diff SQL queries `attrs.trace.engine`). Set + // to a different engine for cross-trace pairing. + readonly baselineEngine?: Engine; } // path_hash_stable is exposed unaggregatable (and CAST to TEXT in SQL, @@ -84,7 +110,7 @@ select id, parent_id as parentId, - ifnull(name, '[Unknown]') as name, + coalesce(name, '<' || coalesce(replace(heap_type, 'HEAP_TYPE_', ''), root_type, 'unnamed') || '>') as name, root_type, heap_type, ${valueColumn} as value, @@ -162,35 +188,619 @@ ); } +// ---------- Diff metrics (same-trace) ------------------------------------- + +// pprof-style: each spec produces two flamegraph metrics in diff mode — +// `widthMode: 'current'` keeps width = current dump's value (so the +// flamegraph still reflects today's heap layout) with Δ colors overlaid, +// and `widthMode: 'delta'` switches width to |Δ| (movement magnitude). +// Same colour hint, same pairing CTE, just a different value column. +type WidthMode = 'current' | 'delta'; + +// How the diff colour score is normalised: +// 'absolute' — Δ / max(|Δ|) across the tree: big movers stand out, units +// are the metric's own (bytes / count). +// 'relative' — Δ / baseline per node (fractional change), clamped to +// [-1, 1]; a node absent from the baseline reads as +1. +// The user picks one via the selected metric; colour basis is independent +// of how boxes are sized (WidthMode). +type ColorBasis = 'absolute' | 'relative'; + +// Build a JAVA_HEAP_GRAPH diff metric. `widthMode='delta'` makes the +// width reflect movement magnitude (|Δ|). `widthMode='current'` keeps +// width = current dump's value so the absolute heap shape is preserved, +// with Δ direction shown in the colour overlay (pprof-style). The +// colour hint is identical in both modes — the difference is only how +// boxes are sized. +// +// Pairing uses a name-based path hash recomputed via _graph_scan: the +// stdlib's `path_hash_stable` is hashed from class **ids**, which are +// per-process and not even always shared across dumps of one upid. +// Hashing class names + heap_type instead makes the join key stable +// across processes and across dumps within one trace. +function buildDiffMetric( + cur: FlamegraphBaselineRef, + base: FlamegraphBaselineRef, + name: string, + unit: string, + valueColumn: 'self_size' | 'self_count', + isDominator: boolean, + showObjectsAction: FlamegraphOptionalAction, + widthMode: WidthMode = 'delta', + colorBasis: ColorBasis = 'absolute', +): QueryFlamegraphMetric { + const tree = isDominator + ? '_heap_graph_dominator_class_tree' + : '_heap_graph_class_tree'; + const dependencyModule = isDominator + ? 'android.memory.heap_graph.dominator_class_tree' + : 'android.memory.heap_graph.class_tree'; + const dim = valueColumn === 'self_size' ? 'size' : 'count'; + // _graph_scan propagates a hash from each node to its children, where + // each step folds the child's name + heap_type into the parent hash. + // The resulting `h` is a path-of-names hash — stable across dumps and + // processes wherever the same class-name path exists. + const pathHashScan = (upid: number, ts: number | bigint): string => ` + _graph_scan!( + ( + SELECT parent_id AS source_node_id, id AS dest_node_id + FROM ${tree} + WHERE upid = ${upid} AND graph_sample_ts = ${ts} + AND parent_id IS NOT NULL + ), + ( + SELECT id, + HASH(IFNULL(name, ''), IFNULL(heap_type, ''), IFNULL(root_type, '')) AS h + FROM ${tree} + WHERE upid = ${upid} AND graph_sample_ts = ${ts} + AND parent_id IS NULL + ), + (h), + ( + SELECT t.id, + HASH(t.h, IFNULL(c.name, ''), IFNULL(c.heap_type, '')) AS h + FROM $table t + JOIN ${tree} c ON c.id = t.id + ) + ) + `; + // MATERIALIZED forces SQLite to actually realise these CTEs (and build + // automatic indexes on join columns). Without it, the LEFT JOIN below on + // path_h degenerates to a nested-loop scan — O(N*M), tens of seconds on a + // single real system_server heap dump. ~20× speedup measured locally. + const statement = ` + WITH + cur_path_hash AS MATERIALIZED (SELECT * FROM ${pathHashScan(cur.upid, cur.ts)}), + base_path_hash AS MATERIALIZED (SELECT * FROM ${pathHashScan(base.upid, base.ts)}), + cur_nodes AS MATERIALIZED ( + SELECT t.id, t.parent_id, + coalesce(t.name, '<' || coalesce(replace(t.heap_type, 'HEAP_TYPE_', ''), t.root_type, 'unnamed') || '>') AS name, + t.root_type, t.heap_type, + ph.h AS path_h, + t.self_size AS c_self_size, t.self_count AS c_self_count + FROM ${tree} t JOIN cur_path_hash ph USING (id) + WHERE t.upid = ${cur.upid} AND t.graph_sample_ts = ${cur.ts} + ), + base_nodes AS MATERIALIZED ( + -- GROUP BY path_h so the LEFT JOIN below can never cross-product + -- against the base side, even if two base tree nodes happen to share + -- a path identity. Aggregated values are the natural fold (sum). + SELECT ph.h AS path_h, + SUM(t.self_size) AS b_self_size, + SUM(t.self_count) AS b_self_count + FROM ${tree} t JOIN base_path_hash ph USING (id) + WHERE t.upid = ${base.upid} AND t.graph_sample_ts = ${base.ts} + GROUP BY ph.h + ), + joined AS ( + SELECT + c.id, + c.parent_id, + c.name, + c.root_type, + c.heap_type, + c.path_h, + c.c_self_size, c.c_self_count, + ifnull(b.b_self_size, 0) AS b_self_size, + ifnull(b.b_self_count, 0) AS b_self_count, + c.c_self_size - ifnull(b.b_self_size, 0) AS delta_size, + c.c_self_count - ifnull(b.b_self_count, 0) AS delta_count, + b.path_h IS NULL AS is_new + FROM cur_nodes c LEFT JOIN base_nodes b USING (path_h) + ), + stats AS (SELECT max(abs(delta_${dim})) AS m FROM joined) + SELECT + j.id, + j.parent_id AS parentId, + j.name, + j.root_type, + j.heap_type, + CAST(j.path_h AS TEXT) AS path_hash_stable, + ${ + widthMode === 'current' ? `j.c_self_${dim}` : `abs(j.delta_${dim})` + } AS value, + j.c_self_size, j.b_self_size, j.delta_size, + j.c_self_count, j.b_self_count, j.delta_count, + -- color_hint = 'diff:<score>' with score in [-1, 1]; see + -- getColorSchemeFromHint in flamegraph.ts (pprof-style colouring). + ${ + colorBasis === 'relative' + ? `CASE + WHEN j.delta_${dim} = 0 THEN 'diff:0' + -- No baseline mass (new node) ⇒ unbounded growth ⇒ full red. + WHEN j.b_self_${dim} = 0 THEN 'diff:1' + ELSE printf('diff:%.4f', + max(-1.0, min(1.0, j.delta_${dim} * 1.0 / j.b_self_${dim}))) + END` + : `CASE + WHEN s.m IS NULL OR s.m = 0 THEN 'diff:0' + ELSE printf('diff:%.4f', j.delta_${dim} * 1.0 / s.m) + END` + } AS color_hint + FROM joined j CROSS JOIN stats s + `; + return { + name, + unit, + dependencySql: + `include perfetto module ${dependencyModule};\n` + + `include perfetto module graphs.scan;`, + statement, + unaggregatableProperties: UNAGG_PROPS, + aggregatableProperties: [ + { + name: 'c_self_size', + displayName: 'Current Size', + mergeAggregation: 'SUM' as const, + }, + { + name: 'b_self_size', + displayName: 'Baseline Size', + mergeAggregation: 'SUM' as const, + }, + { + name: 'delta_size', + displayName: 'Δ Size', + mergeAggregation: 'SUM' as const, + }, + { + name: 'c_self_count', + displayName: 'Current Count', + mergeAggregation: 'SUM' as const, + }, + { + name: 'b_self_count', + displayName: 'Baseline Count', + mergeAggregation: 'SUM' as const, + }, + { + name: 'delta_count', + displayName: 'Δ Count', + mergeAggregation: 'SUM' as const, + }, + ], + optionalNodeActions: [showObjectsAction], + colorHint: true, + }; +} + +function buildHeapGraphDiffMetrics( + cur: FlamegraphBaselineRef, + base: FlamegraphBaselineRef, + onShowObjects: (pathHashes: string, isDominator: boolean) => void, +): ReadonlyArray<QueryFlamegraphMetric> { + const showObjectsAction = ( + isDominator: boolean, + ): FlamegraphOptionalAction => ({ + name: 'Show objects from this class', + execute: async ({properties}) => { + const pathHashes = properties.get('path_hash_stable'); + if (pathHashes === undefined) return; + onShowObjects(pathHashes, isDominator); + }, + }); + // Two metrics per spec — pprof-style: + // * absolute (width = current dump's value, colour = Δ direction) + // — same flamegraph shape the user saw before engaging diff, with + // change information layered in via hue / saturation. + // * Δ (width = |Δ|, colour = Δ direction) — emphasises movement. + // + // Δ comes first so entering diff mode lands on Δ Object Size, matching + // the prior behaviour. Both variants share the same CTE / pairing, so + // SQLite materializes the JOIN once per (dim, isDominator); only the + // `value` column expression differs. + // + // Only nodes present in current are paired here — nodes that exist + // in baseline but not current (REMOVED) are dropped because they have + // no place in the current tree's id/parent_id structure. They show up + // when the user flips primary and baseline. + // Three metrics per spec, all sharing the same pairing CTE: + // * `Δ {name}` — width = |Δ|, colour = absolute Δ score. + // * `Δ {name} (relative)` — width = |Δ|, colour = relative (%) score. + // * `{name}` — width = current value (heap shape kept), + // colour = absolute Δ score. + // Δ (absolute) comes first so entering diff mode lands on it. + const metrics: QueryFlamegraphMetric[] = []; + for (const s of METRIC_SPECS) { + const action = showObjectsAction(s.isDominator); + metrics.push( + buildDiffMetric( + cur, + base, + `Δ ${s.name}`, + s.unit, + s.valueColumn, + s.isDominator, + action, + 'delta', + 'absolute', + ), + ); + metrics.push( + buildDiffMetric( + cur, + base, + `Δ ${s.name} (relative)`, + s.unit, + s.valueColumn, + s.isDominator, + action, + 'delta', + 'relative', + ), + ); + metrics.push( + buildDiffMetric( + cur, + base, + s.name, + s.unit, + s.valueColumn, + s.isDominator, + action, + 'current', + 'absolute', + ), + ); + } + return metrics; +} + +// ---------- Cross-trace diff metrics -------------------------------------- + +// Build a JAVA_HEAP_GRAPH diff metric that reads from a pre-paired temp +// table (see diff/cross_trace_diff.ts) instead of pairing in SQL. The +// downstream value / colour-hint logic is identical to the same-trace +// path — only the source of `joined` differs. +function buildCrossTraceDiffMetric( + pairedTable: string, + name: string, + unit: string, + valueColumn: 'self_size' | 'self_count', + showObjectsAction: FlamegraphOptionalAction, + widthMode: WidthMode = 'delta', + colorBasis: ColorBasis = 'absolute', +): QueryFlamegraphMetric { + const dim = valueColumn === 'self_size' ? 'size' : 'count'; + const valExpr = + widthMode === 'current' ? `j.c_self_${dim}` : `abs(j.delta_${dim})`; + const colorExpr = + colorBasis === 'relative' + ? `CASE + WHEN j.delta_${dim} = 0 THEN 'diff:0' + WHEN j.b_self_${dim} = 0 THEN 'diff:1' + ELSE printf('diff:%.4f', + max(-1.0, min(1.0, j.delta_${dim} * 1.0 / j.b_self_${dim}))) + END` + : `CASE + WHEN s.m IS NULL OR s.m = 0 THEN 'diff:0' + ELSE printf('diff:%.4f', j.delta_${dim} * 1.0 / s.m) + END`; + const statement = ` + WITH + joined AS (SELECT * FROM ${pairedTable}), + stats AS (SELECT max(abs(delta_${dim})) AS m FROM joined) + SELECT + j.id, + j.parent_id AS parentId, + j.name, + j.root_type, + j.heap_type, + j.path_hash_stable, + ${valExpr} AS value, + j.c_self_size, j.b_self_size, j.delta_size, + j.c_self_count, j.b_self_count, j.delta_count, + ${colorExpr} AS color_hint + FROM joined j CROSS JOIN stats s + `; + return { + name, + unit, + // The temp table is created up-front by prepareCrossTraceDiff. We + // still need a non-empty dependencySql for the QueryFlamegraph + // machinery, so do a harmless no-op. + dependencySql: 'SELECT 1;', + statement, + unaggregatableProperties: UNAGG_PROPS, + aggregatableProperties: [ + { + name: 'c_self_size', + displayName: 'Current Size', + mergeAggregation: 'SUM' as const, + }, + { + name: 'b_self_size', + displayName: 'Baseline Size', + mergeAggregation: 'SUM' as const, + }, + { + name: 'delta_size', + displayName: 'Δ Size', + mergeAggregation: 'SUM' as const, + }, + { + name: 'c_self_count', + displayName: 'Current Count', + mergeAggregation: 'SUM' as const, + }, + { + name: 'b_self_count', + displayName: 'Baseline Count', + mergeAggregation: 'SUM' as const, + }, + { + name: 'delta_count', + displayName: 'Δ Count', + mergeAggregation: 'SUM' as const, + }, + ], + optionalNodeActions: [showObjectsAction], + colorHint: true, + }; +} + +// Cross-trace counterpart of buildHeapGraphDiffMetrics. Same set of 12 +// metrics (4 specs × {Δ, Δ-relative, current-width}), each pointed at the +// appropriate pre-paired temp table. +function buildCrossTraceHeapGraphDiffMetrics( + classTreeTable: string, + dominatorTreeTable: string, + onShowObjects: (pathHashes: string, isDominator: boolean) => void, +): ReadonlyArray<QueryFlamegraphMetric> { + const showObjectsAction = ( + isDominator: boolean, + ): FlamegraphOptionalAction => ({ + name: 'Show objects from this class', + execute: async ({properties}) => { + const pathHashes = properties.get('path_hash_stable'); + if (pathHashes === undefined) return; + onShowObjects(pathHashes, isDominator); + }, + }); + const metrics: QueryFlamegraphMetric[] = []; + for (const s of METRIC_SPECS) { + const table = s.isDominator ? dominatorTreeTable : classTreeTable; + const action = showObjectsAction(s.isDominator); + metrics.push( + buildCrossTraceDiffMetric( + table, + `Δ ${s.name}`, + s.unit, + s.valueColumn, + action, + 'delta', + 'absolute', + ), + ); + metrics.push( + buildCrossTraceDiffMetric( + table, + `Δ ${s.name} (relative)`, + s.unit, + s.valueColumn, + action, + 'delta', + 'relative', + ), + ); + metrics.push( + buildCrossTraceDiffMetric( + table, + s.name, + s.unit, + s.valueColumn, + action, + 'current', + 'absolute', + ), + ); + } + return metrics; +} + +// pprof-style diff legend: a green→grey→red gradient painted from the +// same getDiffColorCss() the flamegraph node fills use, so the legend and +// the boxes always agree. Shown only in diff mode. +function renderDiffLegend(relative: boolean): m.Child { + const stops = [-1, -0.66, -0.33, 0, 0.33, 0.66, 1] + .map((s) => getDiffColorCss(s)) + .join(', '); + return m( + '.pf-hde-diff-legend', + m('span.pf-hde-diff-legend__title', 'Change vs baseline'), + m('span.pf-hde-diff-legend__end', 'smaller'), + m('.pf-hde-diff-legend__bar', { + style: {background: `linear-gradient(to right, ${stops})`}, + }), + m('span.pf-hde-diff-legend__end', 'larger'), + // Standard Perfetto Button widget — matches the dump-selector buttons + // on this page (compact outlined). Non-interactive — purely a label + // indicating the current colour basis. + m(Button, { + label: relative ? 'relative (%)' : 'absolute', + variant: ButtonVariant.Outlined, + compact: true, + disabled: true, + }), + ); +} + +// Per-component counter for cross-trace temp table names. Engine-global +// uniqueness, not cryptographic; just needs to not collide across +// re-prepares within one engine. +let xTraceTableSeq = 0; + const FlamegraphView: m.ClosureComponent<FlamegraphViewAttrs> = () => { let cachedMetrics: ReadonlyArray<QueryFlamegraphMetric> | undefined; let cachedKey: string | undefined; + // Cross-trace prep state. `key` matches the metric key when prep has + // completed for the current (cur, base, engines) tuple, so a stale + // baseline change leaves the old prep visibly out-of-date and we'll + // re-prep. + let prep: + | { + readonly key: string; + status: 'pending' | 'ready' | 'error'; + classTable?: string; + dominatorTable?: string; + error?: Error; + } + | undefined; + return { view({attrs}) { - const key = `${attrs.upid}:${attrs.ts}`; - if (cachedMetrics === undefined || key !== cachedKey) { - cachedMetrics = buildHeapGraphMetrics( - attrs.upid, - attrs.ts, - attrs.onShowObjects, + const isCrossTrace = + attrs.baseline !== undefined && attrs.baselineEngine !== undefined; + const baselineKey = attrs.baseline + ? `${attrs.baseline.upid}:${attrs.baseline.ts}` + : 'none'; + // The cross-trace key includes the baseline engine in `isCrossTrace` + // so swapping baseline traces invalidates the prep. + const key = `${attrs.upid}:${attrs.ts}|${baselineKey}|${ + isCrossTrace ? 'x' : 's' + }`; + + // Kick off cross-trace prep if needed. + if (isCrossTrace && (prep === undefined || prep.key !== key)) { + const seq = xTraceTableSeq++; + const classTable = `_x_diff_class_${seq}`; + const dominatorTable = `_x_diff_dom_${seq}`; + const myPrep: NonNullable<typeof prep> = {key, status: 'pending'}; + prep = myPrep; + const curRef = {upid: attrs.upid, ts: BigInt(attrs.ts)}; + const baseRef = { + upid: attrs.baseline!.upid, + ts: BigInt(attrs.baseline!.ts), + }; + Promise.all([ + prepareCrossTraceDiff( + classTable, + attrs.trace.engine, + curRef, + attrs.baselineEngine!, + baseRef, + '_heap_graph_class_tree', + ), + prepareCrossTraceDiff( + dominatorTable, + attrs.trace.engine, + curRef, + attrs.baselineEngine!, + baseRef, + '_heap_graph_dominator_class_tree', + ), + ]) + .then(() => { + // Ignore the result if a newer prep has taken over. + if (prep !== myPrep) return; + myPrep.status = 'ready'; + myPrep.classTable = classTable; + myPrep.dominatorTable = dominatorTable; + // Force the metric cache to rebuild — `key` already matches. + cachedMetrics = undefined; + cachedKey = undefined; + m.redraw(); + }) + .catch((err: Error) => { + if (prep !== myPrep) return; + myPrep.status = 'error'; + myPrep.error = err; + m.redraw(); + }); + } + if (!isCrossTrace) prep = undefined; + + if (isCrossTrace && prep !== undefined && prep.status === 'pending') { + return m( + 'div', + {class: 'pf-hde-view-content pf-hde-flamegraph-view'}, + m( + Callout, + {icon: 'memory', intent: Intent.None}, + m(Spinner, {easing: true}), + ' Preparing cross-trace diff: pairing class trees…', + ), ); + } + if (isCrossTrace && prep !== undefined && prep.status === 'error') { + return m( + 'div', + {class: 'pf-hde-view-content pf-hde-flamegraph-view'}, + m( + Callout, + {icon: 'error', intent: Intent.Danger}, + `Cross-trace diff failed: ${prep.error?.message ?? 'unknown error'}`, + ), + ); + } + + const metricsChanged = cachedMetrics === undefined || key !== cachedKey; + if (metricsChanged || cachedMetrics === undefined) { + if (isCrossTrace && prep?.status === 'ready') { + cachedMetrics = buildCrossTraceHeapGraphDiffMetrics( + prep.classTable!, + prep.dominatorTable!, + attrs.onShowObjects, + ); + } else if (attrs.baseline) { + cachedMetrics = buildHeapGraphDiffMetrics( + {upid: attrs.upid, ts: attrs.ts}, + attrs.baseline, + attrs.onShowObjects, + ); + } else { + cachedMetrics = buildHeapGraphMetrics( + attrs.upid, + attrs.ts, + attrs.onShowObjects, + ); + } cachedKey = key; } - const metrics = cachedMetrics; + const metrics: ReadonlyArray<QueryFlamegraphMetric> = cachedMetrics; - // First render or after a dump-change reset: create a default - // state so the panel renders meaningfully on the same frame. - + // Either first render OR a dump/baseline change just swapped the + // metric list. Diff mode renames every metric (`Δ Object Size` + // etc.), so a stale state.selectedMetricName from before the flip + // points at a metric that no longer exists. Flamegraph.updateState + // rebuilds the state, falling back to the first metric when the + // selection disappeared. Without this the panel either renders + // nothing or stays on the old metric set. let state = attrs.state; - if (state === undefined) { - state = Flamegraph.createDefaultState(metrics); + if (state === undefined || metricsChanged) { + state = Flamegraph.updateState(state, metrics); attrs.onStateChange(state); } + const legend = attrs.baseline + ? renderDiffLegend(state.selectedMetricName.includes('(relative)')) + : null; + return m( 'div', {class: 'pf-hde-view-content pf-hde-flamegraph-view'}, + legend, m(FlamegraphPanel, { trace: attrs.trace, metrics,
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/views/overview/duplicates_section.ts b/ui/src/plugins/com.android.HeapDumpExplorer/views/overview/duplicates_section.ts new file mode 100644 index 0000000..692741b --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/overview/duplicates_section.ts
@@ -0,0 +1,640 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Duplicate-Bitmaps / Strings / Arrays cards on the Overview tab. Single +// engine: a flat DataGrid. Diff: outer-join on the duplicate key with +// Baseline / Current / Δ columns. + +import m from 'mithril'; +import type {SqlValue, Row} from '../../../../trace_processor/query_result'; +import {DataGrid} from '../../../../components/widgets/datagrid/datagrid'; +import type { + CellRenderer, + CellRenderResult, + ColumnDef, + SchemaRegistry, +} from '../../../../components/widgets/datagrid/datagrid_schema'; +import type {OverviewData} from '../../types'; +import {fmtSize} from '../../format'; +import type {NavState} from '../../nav_state'; +import {type NavFn, sizeRenderer} from '../../components'; +import { + baselineCol, + currentCol, + dedupeByKey, + deltaCol, + mergeRows, + KEY_COL, + STATUS_COL, +} from '../../diff/diff_rows'; +import { + deltaCountRenderer, + deltaSizeRenderer, + sideCountRenderer, + sideSizeRenderer, + statusRenderer, +} from '../../diff/diff_schemas'; + +export function renderDuplicateBitmapsCard( + overview: OverviewData, + baselineOverview: OverviewData | undefined, + navigate: NavFn, +): m.Children { + const cur = overview.duplicateBitmaps ?? []; + const base = baselineOverview?.duplicateBitmaps ?? []; + if (cur.length === 0 && base.length === 0 && !overview.hasFieldValues) { + return null; + } + if (cur.length === 0 && base.length === 0) { + return m( + 'div', + {class: 'pf-hde-card pf-hde-mb-4'}, + m('p', {class: 'pf-hde-muted'}, 'No duplicate bitmaps found.'), + ); + } + const isDiff = baselineOverview !== undefined; + const summary = makeSummary('group', cur, base, (g) => g.wastedBytes, isDiff); + + if (!isDiff) { + return renderDuplicateSectionSingle({ + title: 'Duplicate Bitmaps', + summary, + targetView: 'bitmaps', + linkLabel: 'View Bitmaps', + navigate, + data: cur.map((g) => ({ + dimensions: `${g.width} × ${g.height}`, + groupKey: g.groupKey, + copies: g.count, + total_bytes: g.totalBytes, + wasted_bytes: g.wastedBytes, + })), + schema: { + query: { + dimensions: {title: 'Dimensions', columnType: 'text'}, + groupKey: {title: 'Hash', columnType: 'text'}, + copies: { + title: 'Copies', + columnType: 'quantitative', + cellRenderer: makeNavCountRenderer((row) => + navigate('bitmaps', {filterKey: String(row.groupKey ?? '')}), + ), + }, + total_bytes: { + title: 'Total', + columnType: 'quantitative', + cellRenderer: sizeRenderer, + }, + wasted_bytes: { + title: 'Wasted', + columnType: 'quantitative', + cellRenderer: sizeRenderer, + }, + }, + }, + initialColumns: [ + {id: 'dimensions', field: 'dimensions'}, + {id: 'groupKey', field: 'groupKey'}, + {id: 'copies', field: 'copies'}, + {id: 'total_bytes', field: 'total_bytes'}, + {id: 'wasted_bytes', field: 'wasted_bytes'}, + ], + }); + } + + const numericFields = ['copies', 'total_bytes', 'wasted_bytes']; + const baseRows = dedupeByKey( + base.map((g) => ({ + key: g.groupKey, + dimensions: `${g.width} × ${g.height}`, + copies: g.count, + total_bytes: g.totalBytes, + wasted_bytes: g.wastedBytes, + })), + (r) => String(r.key), + numericFields, + ); + const curRows = dedupeByKey( + cur.map((g) => ({ + key: g.groupKey, + dimensions: `${g.width} × ${g.height}`, + copies: g.count, + total_bytes: g.totalBytes, + wasted_bytes: g.wastedBytes, + })), + (r) => String(r.key), + numericFields, + ); + const merged = mergeRows({ + baseline: baseRows, + current: curRows, + keyOf: (r) => String(r.key), + numericFields, + passThroughFields: ['dimensions'], + primaryDeltaField: 'wasted_bytes', + }); + return renderDuplicateSectionDiff({ + title: 'Duplicate Bitmaps', + summary, + targetView: 'bitmaps', + linkLabel: 'View Bitmaps diff', + navigate, + data: merged.map((r) => ({...r, groupKey: r[KEY_COL]})), + keyTitle: 'Hash', + keyField: 'groupKey', + extraTextFields: [{field: 'dimensions', title: 'Dimensions'}], + sizeFields: [ + {field: 'wasted_bytes', title: 'Wasted'}, + {field: 'total_bytes', title: 'Total'}, + ], + countFields: [{field: 'copies', title: 'Copies'}], + primarySortField: 'wasted_bytes', + }); +} + +export function renderDuplicateStringsCard( + overview: OverviewData, + baselineOverview: OverviewData | undefined, + navigate: NavFn, +): m.Children { + const cur = overview.duplicateStrings ?? []; + const base = baselineOverview?.duplicateStrings ?? []; + if (cur.length === 0 && base.length === 0 && !overview.hasFieldValues) { + return null; + } + if (cur.length === 0 && base.length === 0) { + return m( + 'div', + {class: 'pf-hde-card pf-hde-mb-4'}, + m('p', {class: 'pf-hde-muted'}, 'No duplicate strings found.'), + ); + } + const isDiff = baselineOverview !== undefined; + const summary = makeSummary('group', cur, base, (g) => g.wastedBytes, isDiff); + + if (!isDiff) { + return renderDuplicateSectionSingle({ + title: 'Duplicate Strings', + summary, + targetView: 'strings', + linkLabel: 'View Strings', + navigate, + data: cur.map((g) => ({ + value: g.value, + copies: g.count, + total_bytes: g.totalBytes, + wasted_bytes: g.wastedBytes, + })), + schema: { + query: { + value: { + title: 'Value', + columnType: 'text', + cellRenderer: makeStringRenderer((row) => + navigate('strings', {q: String(row.value ?? '')}), + ), + }, + copies: { + title: 'Copies', + columnType: 'quantitative', + cellRenderer: makeNavCountRenderer((row) => + navigate('strings', {q: String(row.value ?? '')}), + ), + }, + total_bytes: { + title: 'Total', + columnType: 'quantitative', + cellRenderer: sizeRenderer, + }, + wasted_bytes: { + title: 'Wasted', + columnType: 'quantitative', + cellRenderer: sizeRenderer, + }, + }, + }, + initialColumns: [ + {id: 'value', field: 'value'}, + {id: 'copies', field: 'copies'}, + {id: 'total_bytes', field: 'total_bytes'}, + {id: 'wasted_bytes', field: 'wasted_bytes'}, + ], + }); + } + + const numericFields = ['copies', 'total_bytes', 'wasted_bytes']; + const baseRows = dedupeByKey( + base.map((g) => ({ + key: g.value, + copies: g.count, + total_bytes: g.totalBytes, + wasted_bytes: g.wastedBytes, + })), + (r) => String(r.key), + numericFields, + ); + const curRows = dedupeByKey( + cur.map((g) => ({ + key: g.value, + copies: g.count, + total_bytes: g.totalBytes, + wasted_bytes: g.wastedBytes, + })), + (r) => String(r.key), + numericFields, + ); + const merged = mergeRows({ + baseline: baseRows, + current: curRows, + keyOf: (r) => String(r.key), + numericFields, + primaryDeltaField: 'wasted_bytes', + }); + return renderDuplicateSectionDiff({ + title: 'Duplicate Strings', + summary, + targetView: 'strings', + linkLabel: 'View Strings diff', + navigate, + data: merged.map((r) => ({...r, value: r[KEY_COL]})), + keyTitle: 'Value', + keyField: 'value', + keyRenderer: makeStringRenderer((row) => + navigate('strings', {q: String(row.value ?? '')}), + ), + extraTextFields: [], + sizeFields: [ + {field: 'wasted_bytes', title: 'Wasted'}, + {field: 'total_bytes', title: 'Total'}, + ], + countFields: [{field: 'copies', title: 'Copies'}], + primarySortField: 'wasted_bytes', + }); +} + +export function renderDuplicateArraysCard( + overview: OverviewData, + baselineOverview: OverviewData | undefined, + navigate: NavFn, +): m.Children { + const cur = overview.duplicateArrays ?? []; + const base = baselineOverview?.duplicateArrays ?? []; + if (cur.length === 0 && base.length === 0) return null; + const isDiff = baselineOverview !== undefined; + const summary = makeSummary('group', cur, base, (g) => g.wastedBytes, isDiff); + + if (!isDiff) { + return renderDuplicateSectionSingle({ + title: 'Duplicate Primitive Arrays', + summary, + targetView: 'arrays', + linkLabel: 'View Arrays', + navigate, + data: cur.map((g) => ({ + className: g.className, + arrayHash: g.arrayHash, + copies: g.count, + total_bytes: g.totalBytes, + wasted_bytes: g.wastedBytes, + })), + schema: { + query: { + className: { + title: 'Array Type', + columnType: 'text', + cellRenderer: (value: SqlValue) => + ({ + content: m( + 'button', + { + class: 'pf-hde-link', + onclick: () => + navigate('objects', {cls: String(value ?? '')}), + }, + String(value ?? ''), + ), + align: 'left', + }) as CellRenderResult, + }, + arrayHash: {title: 'Hash', columnType: 'text'}, + copies: { + title: 'Copies', + columnType: 'quantitative', + cellRenderer: makeNavCountRenderer((row) => + navigate('arrays', {arrayHash: String(row.arrayHash ?? '')}), + ), + }, + total_bytes: { + title: 'Total', + columnType: 'quantitative', + cellRenderer: sizeRenderer, + }, + wasted_bytes: { + title: 'Wasted', + columnType: 'quantitative', + cellRenderer: sizeRenderer, + }, + }, + }, + initialColumns: [ + {id: 'className', field: 'className'}, + {id: 'arrayHash', field: 'arrayHash'}, + {id: 'copies', field: 'copies'}, + {id: 'total_bytes', field: 'total_bytes'}, + {id: 'wasted_bytes', field: 'wasted_bytes'}, + ], + }); + } + + const numericFields = ['copies', 'total_bytes', 'wasted_bytes']; + const baseRows = dedupeByKey( + base.map((g) => ({ + key: g.arrayHash, + className: g.className, + copies: g.count, + total_bytes: g.totalBytes, + wasted_bytes: g.wastedBytes, + })), + (r) => String(r.key), + numericFields, + ); + const curRows = dedupeByKey( + cur.map((g) => ({ + key: g.arrayHash, + className: g.className, + copies: g.count, + total_bytes: g.totalBytes, + wasted_bytes: g.wastedBytes, + })), + (r) => String(r.key), + numericFields, + ); + const merged = mergeRows({ + baseline: baseRows, + current: curRows, + keyOf: (r) => String(r.key), + numericFields, + passThroughFields: ['className'], + primaryDeltaField: 'wasted_bytes', + }); + return renderDuplicateSectionDiff({ + title: 'Duplicate Primitive Arrays', + summary, + targetView: 'arrays', + linkLabel: 'View Arrays diff', + navigate, + data: merged.map((r) => ({...r, arrayHash: r[KEY_COL]})), + keyTitle: 'Hash', + keyField: 'arrayHash', + extraTextFields: [{field: 'className', title: 'Array Type'}], + sizeFields: [ + {field: 'wasted_bytes', title: 'Wasted'}, + {field: 'total_bytes', title: 'Total'}, + ], + countFields: [{field: 'copies', title: 'Copies'}], + primarySortField: 'wasted_bytes', + }); +} + +// ----- Shared helpers ------------------------------------------------------ + +interface SingleSectionOpts { + readonly title: string; + readonly summary: m.Children; + readonly targetView: string; + readonly linkLabel: string; + readonly navigate: NavFn; + readonly data: Row[]; + readonly schema: SchemaRegistry; + readonly initialColumns: Array<{id: string; field: string}>; +} + +function renderDuplicateSectionSingle(opts: SingleSectionOpts): m.Children { + return m('div', {class: 'pf-hde-card pf-hde-mb-4'}, [ + m('h3', {class: 'pf-hde-sub-heading'}, opts.title), + m('p', {class: 'pf-hde-desc'}, [ + opts.summary, + ' ', + m( + 'button', + { + class: 'pf-hde-link--alt', + onclick: () => opts.navigate(opts.targetView as NavState['view']), + }, + opts.linkLabel, + ), + ]), + m(DataGrid, { + schema: opts.schema, + rootSchema: 'query', + data: opts.data, + initialColumns: opts.initialColumns, + }), + ]); +} + +interface DiffSectionOpts { + readonly title: string; + readonly summary: m.Children; + readonly targetView: string; + readonly linkLabel: string; + readonly navigate: NavFn; + readonly data: Row[]; + readonly keyTitle: string; + readonly keyField: string; + readonly keyRenderer?: CellRenderer; + readonly extraTextFields: ReadonlyArray<{field: string; title: string}>; + readonly sizeFields: ReadonlyArray<{field: string; title: string}>; + readonly countFields: ReadonlyArray<{field: string; title: string}>; + /** Numeric field used for default sort by `|Δ|` desc. */ + readonly primarySortField: string; +} + +function renderDuplicateSectionDiff(opts: DiffSectionOpts): m.Children { + const cols: Record<string, ColumnDef> = { + [opts.keyField]: { + title: opts.keyTitle, + columnType: 'text', + cellRenderer: opts.keyRenderer, + }, + [STATUS_COL]: { + title: 'Status', + columnType: 'text', + cellRenderer: statusRenderer, + }, + }; + for (const tf of opts.extraTextFields) { + cols[tf.field] = {title: tf.title, columnType: 'text'}; + } + for (const f of opts.sizeFields) { + cols[deltaCol(f.field)] = { + title: 'Δ ' + f.title, + columnType: 'quantitative', + cellRenderer: deltaSizeRenderer, + }; + cols[baselineCol(f.field)] = { + title: 'Baseline ' + f.title, + columnType: 'quantitative', + cellRenderer: sideSizeRenderer, + }; + cols[currentCol(f.field)] = { + title: 'Current ' + f.title, + columnType: 'quantitative', + cellRenderer: sideSizeRenderer, + }; + } + for (const f of opts.countFields) { + cols[deltaCol(f.field)] = { + title: 'Δ ' + f.title, + columnType: 'quantitative', + cellRenderer: deltaCountRenderer, + }; + cols[baselineCol(f.field)] = { + title: 'Baseline ' + f.title, + columnType: 'quantitative', + cellRenderer: sideCountRenderer, + }; + cols[currentCol(f.field)] = { + title: 'Current ' + f.title, + columnType: 'quantitative', + cellRenderer: sideCountRenderer, + }; + } + const initialColumns: Array<{ + id: string; + field: string; + sort?: 'ASC' | 'DESC'; + }> = [ + {id: opts.keyField, field: opts.keyField}, + {id: STATUS_COL, field: STATUS_COL}, + ]; + for (const tf of opts.extraTextFields) { + initialColumns.push({id: tf.field, field: tf.field}); + } + for (const f of [...opts.sizeFields, ...opts.countFields]) { + if (f.field === opts.primarySortField) { + initialColumns.push({ + id: deltaCol(f.field), + field: deltaCol(f.field), + sort: 'DESC', + }); + } else { + initialColumns.push({id: deltaCol(f.field), field: deltaCol(f.field)}); + } + initialColumns.push({ + id: baselineCol(f.field), + field: baselineCol(f.field), + }); + initialColumns.push({id: currentCol(f.field), field: currentCol(f.field)}); + } + return m('div', {class: 'pf-hde-card pf-hde-mb-4'}, [ + m('h3', {class: 'pf-hde-sub-heading'}, opts.title), + m('p', {class: 'pf-hde-desc'}, [ + opts.summary, + ' ', + m( + 'button', + { + class: 'pf-hde-link--alt', + onclick: () => opts.navigate(opts.targetView as NavState['view']), + }, + opts.linkLabel, + ), + ]), + m(DataGrid, { + schema: {query: cols}, + rootSchema: 'query', + data: opts.data, + initialColumns, + }), + ]); +} + +function makeNavCountRenderer(onclick: (row: Row) => void): CellRenderer { + return (value: SqlValue, row: Row): CellRenderResult => ({ + content: m( + 'button', + {class: 'pf-hde-link', onclick: () => onclick(row)}, + String(value ?? '0'), + ), + align: 'right', + }); +} + +function makeStringRenderer(onclick: (row: Row) => void): CellRenderer { + return (value: SqlValue, row: Row): CellRenderResult => { + const s = String(value ?? ''); + const display = s.length > 200 ? s.slice(0, 200) + '…' : s; + return { + content: m( + 'button', + { + class: 'pf-hde-link pf-hde-mono pf-hde-break-all pf-hde-str-color', + onclick: () => onclick(row), + }, + '"' + display + '"', + ), + align: 'left', + }; + }; +} + +interface DuplicateGroupLike { + readonly wastedBytes: number; +} + +function makeSummary<T extends DuplicateGroupLike>( + unit: string, + cur: ReadonlyArray<T>, + base: ReadonlyArray<T>, + wastedBytes: (g: T) => number, + isDiff: boolean, +): m.Children { + const cWasted = cur.reduce((a, g) => a + wastedBytes(g), 0); + if (!isDiff) { + return [ + cur.length + + ' ' + + unit + + (cur.length !== 1 ? 's' : '') + + ' detected, wasting ', + m('span', {class: 'pf-hde-mono pf-hde-semibold'}, fmtSize(cWasted)), + '.', + ]; + } + const bWasted = base.reduce((a, g) => a + wastedBytes(g), 0); + const dWasted = cWasted - bWasted; + const dGroups = cur.length - base.length; + return [ + `${cur.length} ${unit}${cur.length !== 1 ? 's' : ''} `, + m('span', {class: 'pf-hde-mono'}, `(${dGroups >= 0 ? '+' : ''}${dGroups})`), + ', wasting ', + m('span', {class: 'pf-hde-mono pf-hde-semibold'}, fmtSize(cWasted)), + ' ', + m( + 'span', + { + class: + 'pf-hde-mono ' + + (dWasted > 0 + ? 'pf-hde-delta--grew' + : dWasted < 0 + ? 'pf-hde-delta--shrank' + : 'pf-hde-delta--zero'), + }, + `(${dWasted >= 0 ? '+' : '−'}${fmtSize(Math.abs(dWasted))})`, + ), + '.', + ]; +}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/views/overview_view.ts b/ui/src/plugins/com.android.HeapDumpExplorer/views/overview_view.ts index 91b6ce2..56ae9b9 100644 --- a/ui/src/plugins/com.android.HeapDumpExplorer/views/overview_view.ts +++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/overview_view.ts
@@ -12,470 +12,556 @@ // See the License for the specific language governing permissions and // limitations under the License. +// Overview tab. Five DataGrid cards. When `baselineOverview` is present +// each card gains Baseline / Current / Δ columns merged via mergeRows. + import m from 'mithril'; import {Duration} from '../../../base/time'; import type {SqlValue, Row} from '../../../trace_processor/query_result'; import {DataGrid} from '../../../components/widgets/datagrid/datagrid'; -import type {SchemaRegistry} from '../../../components/widgets/datagrid/datagrid_schema'; -import type {OverviewData} from '../types'; -import {fmtSize} from '../format'; -import type {NavState} from '../nav_state'; -import {type NavFn, sizeRenderer} from '../components'; -import type {HeapDump} from '../queries'; +import type { + CellRenderer, + CellRenderResult, + ColumnDef, + SchemaRegistry, +} from '../../../components/widgets/datagrid/datagrid_schema'; import {Callout} from '../../../widgets/callout'; -import {Button} from '../../../widgets/button'; +import {Intent} from '../../../widgets/common'; +import type {OverviewData, HeapInfo} from '../types'; +import {fmtSize} from '../format'; +import {type NavFn, sizeRenderer, countRenderer} from '../components'; +import type {HeapDump} from '../queries'; +import {getLoadState} from '../baseline/load_action'; +import {openBaselineFilePicker, shouldShowBaselineHeader} from '../header'; +import {Button, ButtonVariant} from '../../../widgets/button'; +import { + baselineCol, + currentCol, + deltaCol, + mergeRows, + KEY_COL, + STATUS_COL, +} from '../diff/diff_rows'; +import { + deltaCountRenderer, + deltaSizeRenderer, + sideSizeRenderer, + statusRenderer, +} from '../diff/diff_schemas'; +import { + renderDuplicateArraysCard, + renderDuplicateBitmapsCard, + renderDuplicateStringsCard, +} from './overview/duplicates_section'; -const HEAP_SCHEMA: SchemaRegistry = { - query: { - heap: { - title: 'Heap', - columnType: 'text', - }, - java_size: { - title: 'Java Size', - columnType: 'quantitative', - cellRenderer: sizeRenderer, - }, - native_size: { - title: 'Native Size', - columnType: 'quantitative', - cellRenderer: sizeRenderer, - }, - total_size: { - title: 'Total Size', - columnType: 'quantitative', - cellRenderer: sizeRenderer, - }, - }, -}; - -const INFO_SCHEMA: SchemaRegistry = { - query: { - property: { - title: 'Property', - columnType: 'text', - }, - value: { - title: 'Value', - columnType: 'text', - }, - }, -}; - -function makeDuplicateBitmapSchema(navigate: NavFn): SchemaRegistry { - return { - query: { - dimensions: { - title: 'Dimensions', - columnType: 'text', - }, - copies: { - title: 'Copies', - columnType: 'quantitative', - cellRenderer: (value: SqlValue, row) => - m( - 'button', - { - class: 'pf-hde-link', - onclick: () => - navigate('bitmaps', { - filterKey: String(row.groupKey ?? ''), - }), - }, - String(value), - ), - }, - total_bytes: { - title: 'Total', - columnType: 'quantitative', - cellRenderer: sizeRenderer, - }, - wasted_bytes: { - title: 'Wasted', - columnType: 'quantitative', - cellRenderer: sizeRenderer, - }, - }, - }; -} - -function makeDuplicateArraySchema(navigate: NavFn): SchemaRegistry { - return { - query: { - className: { - title: 'Array Type', - columnType: 'text', - cellRenderer: (value: SqlValue) => - m( - 'button', - { - class: 'pf-hde-link', - onclick: () => navigate('objects', {cls: String(value ?? '')}), - }, - String(value ?? ''), - ), - }, - arrayHash: { - title: 'Hash', - columnType: 'text', - }, - copies: { - title: 'Copies', - columnType: 'quantitative', - cellRenderer: (value: SqlValue, row) => - m( - 'button', - { - class: 'pf-hde-link', - onclick: () => - navigate('arrays', { - arrayHash: String(row.arrayHash ?? ''), - }), - }, - String(value), - ), - }, - total_bytes: { - title: 'Total', - columnType: 'quantitative', - cellRenderer: sizeRenderer, - }, - wasted_bytes: { - title: 'Wasted', - columnType: 'quantitative', - cellRenderer: sizeRenderer, - }, - }, - }; -} - -function makeDuplicateStringSchema(navigate: NavFn): SchemaRegistry { - return { - query: { - value: { - title: 'Value', - columnType: 'text', - cellRenderer: (value: SqlValue) => - m( - 'button', - { - class: - 'pf-hde-link pf-hde-mono pf-hde-break-all pf-hde-str-color', - onclick: () => - navigate('strings', { - q: String(value ?? ''), - }), - }, - '"' + - (String(value ?? '').length > 200 - ? String(value).slice(0, 200) + '\u2026' - : String(value ?? '')) + - '"', - ), - }, - copies: { - title: 'Copies', - columnType: 'quantitative', - cellRenderer: (value: SqlValue, row) => - m( - 'button', - { - class: 'pf-hde-link', - onclick: () => navigate('strings', {q: String(row.value ?? '')}), - }, - String(value), - ), - }, - total_bytes: { - title: 'Total', - columnType: 'quantitative', - cellRenderer: sizeRenderer, - }, - wasted_bytes: { - title: 'Wasted', - columnType: 'quantitative', - cellRenderer: sizeRenderer, - }, - }, - }; -} - -function renderDuplicateSection( - title: string, - groupCount: number, - totalWasted: number, - targetView: string, - linkLabel: string, - navigate: NavFn, - schema: SchemaRegistry, - data: Row[], - columns: Array<{id: string; field: string}>, -): m.Children { - return m('div', {class: 'pf-hde-card pf-hde-mt-4'}, [ - m('h3', {class: 'pf-hde-sub-heading'}, title), - m('p', {class: 'pf-hde-desc'}, [ - groupCount + - ' group' + - (groupCount > 1 ? 's' : '') + - ' detected, wasting ', - m('span', {class: 'pf-hde-mono pf-hde-semibold'}, fmtSize(totalWasted)), - '. ', - m( - 'button', - { - class: 'pf-hde-link--alt', - onclick: () => navigate(targetView as NavState['view']), - }, - linkLabel, - ), - ]), - m('div', {class: 'pf-hde-dup-grid-container'}, [ - m(DataGrid, { - schema, - rootSchema: 'query', - data, - initialColumns: columns, - fillHeight: true, - }), - ]), - ]); -} +export const HIDE_DEFAULT_CHANGED_KEY = + 'hideHeapDumpExplorerDefaultChangedHint'; interface OverviewViewAttrs { readonly overview: OverviewData; readonly activeDump: HeapDump; + /** + * True when a baseline is selected (mode is diff). The heading flips even + * before the baseline overview query resolves so the user sees that the + * page is in diff mode immediately. + */ + readonly diffActive: boolean; + /** When present → render diff columns alongside current values. */ + readonly baselineOverview?: OverviewData; + /** + * True when a baseline is loaded but its overview query is still running + * (so diff columns are coming, not absent). + */ + readonly baselineLoading?: boolean; readonly navigate: NavFn; readonly showDefaultChangedHint: boolean; readonly onBackToTimeline: () => void; readonly onDismissDefaultChangedHint: () => void; } + function OverviewView(): m.Component<OverviewViewAttrs> { return { view(vnode) { const { overview, activeDump, + diffActive, + baselineOverview, + baselineLoading, navigate, showDefaultChangedHint, onBackToTimeline, onDismissDefaultChangedHint, } = vnode.attrs; - const showHint = showDefaultChangedHint; - const heapIndices: number[] = []; - for (let i = 0; i < overview.heaps.length; i++) { - const h = overview.heaps[i]; - if (h.java + h.native_ > 0) { - heapIndices.push(i); - } - } - const heaps = heapIndices.map((i) => overview.heaps[i]); - const totalJava = heaps.reduce((a, h) => a + h.java, 0); - const totalNative = heaps.reduce((a, h) => a + h.native_, 0); + const isDiff = baselineOverview !== undefined; + const heading = diffActive ? 'Overview diff' : 'Overview'; + // Mode tag baked into every card's vnode key. DataGrid captures its + // `initialColumns` only on `oninit`, so we must force a remount when + // we flip between the single-engine and diff column sets. + const mode = isDiff ? 'diff' : 'single'; - const heapRows: Row[] = [ - { - heap: 'Total', - java_size: totalJava, - native_size: totalNative, - total_size: totalJava + totalNative, - }, - ...heaps.map((h) => ({ - heap: h.name, - java_size: h.java, - native_size: h.native_, - total_size: h.java + h.native_, - })), - ]; - - const processLabel = - (activeDump.processName ?? '<unknown>') + - (activeDump.pid ? ` (pid ${activeDump.pid})` : ''); - const infoRows: Row[] = [ - {property: 'Process', value: processLabel}, - ...(overview.processUptime !== null - ? [ - { - property: 'Uptime', - value: Duration.format(overview.processUptime), - }, - ] - : []), - ...(overview.oomBucket !== null - ? [ - { - property: 'OOM score', - value: `${overview.oomBucket} (${overview.oomScore})`, - }, - ] - : []), - {property: 'Classes', value: overview.classCount.toLocaleString()}, - { - property: 'Reachable instances', - value: overview.reachableInstanceCount.toLocaleString(), - }, - { - property: 'Unreachable instances', - value: overview.unreachableInstanceCount.toLocaleString(), - }, - ...(overview.anonRssAndSwapSize !== null - ? [ - { - property: 'Anon RSS + Swap', - value: fmtSize(Number(overview.anonRssAndSwapSize)), - }, - ] - : []), - ...(overview.dmabufRssSize !== null - ? [ - { - property: 'DMA Buffer RSS', - value: fmtSize(Number(overview.dmabufRssSize)), - }, - ] - : []), - ]; + // Mithril requires sibling vnodes in a fragment to either all have + // keys or none. Wrap each top-level child in a keyed div so we can + // freely use keys on individual cards (force-remount on mode flip) + // without triggering "all-or-none keys" runtime errors. + const child = (key: string, content: m.Children): m.Vnode => + m('div', {key}, content); return m('div', {class: 'pf-hde-view-scroll'}, [ - m('h2', {class: 'pf-hde-view-heading'}, 'Overview'), - showHint - ? m( - Callout, - { - className: 'pf-hde-default-changed-callout', - icon: 'info', - dismissible: true, - onDismiss: onDismissDefaultChangedHint, - }, - m('p', [ - m( - 'span', - 'Heapdump Explorer is now the default view for traces ' + - 'with heap-graph data.', - ), - m(Button, { - label: 'Back to Timeline', - icon: 'arrow_back', - compact: true, - onclick: onBackToTimeline, - }), - ]), - ) - : null, - - m('div', {class: 'pf-hde-card pf-hde-mb-4'}, [ - m('h3', {class: 'pf-hde-sub-heading'}, 'General Information'), - m(DataGrid, { - schema: INFO_SCHEMA, - rootSchema: 'query', - data: infoRows, - initialColumns: [ - {id: 'property', field: 'property'}, - {id: 'value', field: 'value'}, - ], - }), - ]), - m('div', {class: 'pf-hde-card'}, [ - m('h3', {class: 'pf-hde-sub-heading'}, 'Bytes Retained by Heap'), - m(DataGrid, { - schema: HEAP_SCHEMA, - rootSchema: 'query', - data: heapRows, - initialColumns: [ - {id: 'heap', field: 'heap'}, - {id: 'java_size', field: 'java_size'}, - {id: 'native_size', field: 'native_size'}, - {id: 'total_size', field: 'total_size'}, - ], - }), - ]), - overview.duplicateBitmaps && overview.duplicateBitmaps.length > 0 - ? renderDuplicateSection( - 'Duplicate Bitmaps', - overview.duplicateBitmaps.length, - overview.duplicateBitmaps.reduce((a, g) => a + g.wastedBytes, 0), - 'bitmaps', - 'View Bitmaps', - navigate, - makeDuplicateBitmapSchema(navigate), - overview.duplicateBitmaps.map((g) => ({ - dimensions: `${g.width} \u00d7 ${g.height}`, - groupKey: g.groupKey, - copies: g.count, - total_bytes: g.totalBytes, - wasted_bytes: g.wastedBytes, - })), - [ - {id: 'dimensions', field: 'dimensions'}, - {id: 'groupKey', field: 'groupKey'}, - {id: 'copies', field: 'copies'}, - {id: 'total_bytes', field: 'total_bytes'}, - {id: 'wasted_bytes', field: 'wasted_bytes'}, - ], - ) - : overview.hasFieldValues + child('heading', m('h2', {class: 'pf-hde-view-heading'}, heading)), + child( + 'default-changed-hint', + showDefaultChangedHint ? m( - 'div', - {class: 'pf-hde-card pf-hde-mt-4 pf-hde-mb-4'}, - m('p', {class: 'pf-hde-muted'}, 'No duplicate bitmaps found.'), + Callout, + { + className: 'pf-hde-default-changed-callout pf-hde-mb-4', + icon: 'info', + dismissible: true, + onDismiss: onDismissDefaultChangedHint, + }, + m('p', [ + m( + 'span', + 'Heapdump Explorer is now the default view for traces ' + + 'with heap-graph data. ', + ), + m(Button, { + label: 'Back to Timeline', + icon: 'arrow_back', + compact: true, + onclick: onBackToTimeline, + }), + ]), ) : null, - overview.duplicateStrings && overview.duplicateStrings.length > 0 - ? renderDuplicateSection( - 'Duplicate Strings', - overview.duplicateStrings.length, - overview.duplicateStrings.reduce((a, g) => a + g.wastedBytes, 0), - 'strings', - 'View Strings', - navigate, - makeDuplicateStringSchema(navigate), - overview.duplicateStrings.map((g) => ({ - value: g.value, - copies: g.count, - total_bytes: g.totalBytes, - wasted_bytes: g.wastedBytes, - })), - [ - {id: 'value', field: 'value'}, - {id: 'copies', field: 'copies'}, - {id: 'total_bytes', field: 'total_bytes'}, - {id: 'wasted_bytes', field: 'wasted_bytes'}, - ], - ) - : overview.hasFieldValues + ), + child('load', renderLoadBaselineSection()), + child( + 'loading', + baselineLoading === true && !isDiff ? m( - 'div', - {class: 'pf-hde-card pf-hde-mb-4'}, - m('p', {class: 'pf-hde-muted'}, 'No duplicate strings found.'), + Callout, + { + icon: 'hourglass_empty', + intent: Intent.None, + className: 'pf-hde-mb-4', + role: 'status', + }, + 'Computing baseline overview… diff columns will appear once it finishes.', ) : null, - overview.duplicateArrays && overview.duplicateArrays.length > 0 - ? renderDuplicateSection( - 'Duplicate Primitive Arrays', - overview.duplicateArrays.length, - overview.duplicateArrays.reduce((a, g) => a + g.wastedBytes, 0), - 'arrays', - 'View Arrays', - navigate, - makeDuplicateArraySchema(navigate), - overview.duplicateArrays.map((g) => ({ - className: g.className, - arrayHash: g.arrayHash, - copies: g.count, - total_bytes: g.totalBytes, - wasted_bytes: g.wastedBytes, - })), - [ - {id: 'className', field: 'className'}, - {id: 'arrayHash', field: 'arrayHash'}, - {id: 'copies', field: 'copies'}, - {id: 'total_bytes', field: 'total_bytes'}, - {id: 'wasted_bytes', field: 'wasted_bytes'}, - ], - ) - : null, + ), + child( + `info-${mode}`, + renderInfoCard(activeDump, overview, baselineOverview), + ), + child(`heaps-${mode}`, renderHeapsCard(overview, baselineOverview)), + child( + `bitmaps-${mode}`, + renderDuplicateBitmapsCard(overview, baselineOverview, navigate), + ), + child( + `strings-${mode}`, + renderDuplicateStringsCard(overview, baselineOverview, navigate), + ), + child( + `arrays-${mode}`, + renderDuplicateArraysCard(overview, baselineOverview, navigate), + ), ]); }, }; } +// ----- Top-of-tab "Load baseline" affordance ------------------------------- +// +// Only rendered in single-engine mode. When a baseline IS loaded, the slim +// header above the tabs holds the controls — no need to repeat them here. + +function renderLoadBaselineSection(): m.Children { + // The Overview-tab CTA is the discovery entry point for diff mode in the + // common single-trace, no-diff workflow. Once the top bar is showing + // baseline state (a load is in flight, an error needs reading, or a + // pool / active baseline exists) the row's selector takes over and the + // CTA collapses to keep the page free of duplicated affordances. + if (shouldShowBaselineHeader()) return null; + const {error} = getLoadState(); + // Bare button + helper text; we deliberately don't wrap in a Callout + // here because the Callout's leading icon collides visually with the + // button's `difference` icon (two adjacent glyphs reading the same). + return [ + m( + 'div', + {class: 'pf-hde-heading-row pf-hde-mb-4'}, + m(Button, { + label: 'Diff against another trace…', + icon: 'difference', + intent: Intent.Primary, + variant: ButtonVariant.Filled, + onclick: () => openBaselineFilePicker(), + }), + ), + error && + m( + Callout, + { + icon: 'error', + intent: Intent.Danger, + role: 'alert', + className: 'pf-hde-mb-4', + }, + error, + ), + ]; +} + +// ----- General Information -------------------------------------------------- + +function renderInfoCard( + activeDump: HeapDump, + overview: OverviewData, + baselineOverview: OverviewData | undefined, +): m.Children { + const processLabel = + (activeDump.processName ?? '<unknown>') + + (activeDump.pid !== null ? ` (pid ${activeDump.pid})` : ''); + if (baselineOverview === undefined) { + const rows: Row[] = [ + {property: 'Process', value: processLabel}, + ...(overview.processUptime !== null + ? [{property: 'Uptime', value: Duration.format(overview.processUptime)}] + : []), + ...(overview.oomBucket !== null + ? [ + { + property: 'OOM score', + value: `${overview.oomBucket} (${overview.oomScore})`, + }, + ] + : []), + {property: 'Classes', value: overview.classCount.toLocaleString()}, + { + property: 'Reachable instances', + value: overview.reachableInstanceCount.toLocaleString(), + }, + { + property: 'Unreachable instances', + value: overview.unreachableInstanceCount.toLocaleString(), + }, + ...(overview.anonRssAndSwapSize !== null + ? [ + { + property: 'Anon RSS + Swap', + value: fmtSize(Number(overview.anonRssAndSwapSize)), + }, + ] + : []), + ...(overview.dmabufRssSize !== null + ? [ + { + property: 'DMA Buffer RSS', + value: fmtSize(Number(overview.dmabufRssSize)), + }, + ] + : []), + { + property: 'Heaps', + value: overview.heaps.map((h) => h.name).join(', '), + }, + ]; + const schema: SchemaRegistry = { + query: { + property: {title: 'Property', columnType: 'text'}, + value: {title: 'Value', columnType: 'text'}, + }, + }; + return m('div', {class: 'pf-hde-card pf-hde-mb-4'}, [ + m('h3', {class: 'pf-hde-sub-heading'}, 'General Information'), + m(DataGrid, { + schema, + rootSchema: 'query', + data: rows, + initialColumns: [ + {id: 'property', field: 'property'}, + {id: 'value', field: 'value'}, + ], + }), + ]); + } + + const rows: Row[] = [ + { + property: 'Reachable instances', + baseline: baselineOverview.reachableInstanceCount, + current: overview.reachableInstanceCount, + delta: + overview.reachableInstanceCount - + baselineOverview.reachableInstanceCount, + }, + { + property: 'Heaps', + baseline: baselineOverview.heaps.map((h) => h.name).join(', '), + current: overview.heaps.map((h) => h.name).join(', '), + delta: heapDeltaSummary(overview.heaps, baselineOverview.heaps), + }, + ]; + const schema: SchemaRegistry = { + query: { + property: {title: 'Property', columnType: 'text'}, + baseline: { + title: 'Baseline', + columnType: 'text', + cellRenderer: maybeNumericRenderer, + }, + current: { + title: 'Current', + columnType: 'text', + cellRenderer: maybeNumericRenderer, + }, + delta: { + title: 'Δ', + columnType: 'text', + cellRenderer: maybeDeltaCountRenderer, + }, + }, + }; + return m('div', {class: 'pf-hde-card pf-hde-mb-4'}, [ + m('h3', {class: 'pf-hde-sub-heading'}, 'General Information'), + m(DataGrid, { + schema, + rootSchema: 'query', + data: rows, + initialColumns: [ + {id: 'property', field: 'property'}, + {id: 'baseline', field: 'baseline'}, + {id: 'current', field: 'current'}, + {id: 'delta', field: 'delta'}, + ], + }), + ]); +} + +const maybeNumericRenderer: CellRenderer = (value: SqlValue) => { + if (typeof value === 'number' || typeof value === 'bigint') { + return countRenderer(value); + } + return { + content: m('span', String(value ?? '')), + align: 'left', + } satisfies CellRenderResult; +}; + +const maybeDeltaCountRenderer: CellRenderer = (value: SqlValue, row: Row) => { + if (typeof value === 'number' || typeof value === 'bigint') { + return deltaCountRenderer(value, row); + } + return { + content: m('span', {class: 'pf-hde-muted'}, String(value ?? '')), + align: 'left', + } satisfies CellRenderResult; +}; + +function heapDeltaSummary(current: HeapInfo[], baseline: HeapInfo[]): string { + const cSet = new Set(current.map((h) => h.name)); + const bSet = new Set(baseline.map((h) => h.name)); + const added = [...cSet].filter((h) => !bSet.has(h)); + const removed = [...bSet].filter((h) => !cSet.has(h)); + if (added.length === 0 && removed.length === 0) return 'same'; + const parts: string[] = []; + if (added.length) parts.push(`+${added.join(', ')}`); + if (removed.length) parts.push(`−${removed.join(', ')}`); + return parts.join('; '); +} + +// ----- Bytes retained by heap ---------------------------------------------- + +function renderHeapsCard( + overview: OverviewData, + baselineOverview: OverviewData | undefined, +): m.Children { + // Only show heaps with non-zero retention on at least one side. + const filterNonZero = (heaps: HeapInfo[]) => + heaps.filter((h) => h.java + h.native_ > 0); + const cHeaps = filterNonZero(overview.heaps); + + if (baselineOverview === undefined) { + const rows: Row[] = withTotalRow( + cHeaps.map((h) => ({ + heap: h.name, + java_size: h.java, + native_size: h.native_, + total_size: h.java + h.native_, + })), + 'heap', + ); + return m('div', {class: 'pf-hde-card pf-hde-mb-4'}, [ + m('h3', {class: 'pf-hde-sub-heading'}, 'Bytes Retained by Heap'), + m(DataGrid, { + schema: { + query: { + heap: {title: 'Heap', columnType: 'text'}, + java_size: { + title: 'Java', + columnType: 'quantitative', + cellRenderer: sizeRenderer, + }, + native_size: { + title: 'Native', + columnType: 'quantitative', + cellRenderer: sizeRenderer, + }, + total_size: { + title: 'Total', + columnType: 'quantitative', + cellRenderer: sizeRenderer, + }, + }, + }, + rootSchema: 'query', + data: rows, + initialColumns: [ + {id: 'heap', field: 'heap'}, + {id: 'java_size', field: 'java_size'}, + {id: 'native_size', field: 'native_size'}, + {id: 'total_size', field: 'total_size'}, + ], + }), + ]); + } + + const bHeaps = filterNonZero(baselineOverview.heaps); + const merged = mergeRows({ + baseline: bHeaps.map((h) => ({ + heap: h.name, + java_size: h.java, + native_size: h.native_, + total_size: h.java + h.native_, + })), + current: cHeaps.map((h) => ({ + heap: h.name, + java_size: h.java, + native_size: h.native_, + total_size: h.java + h.native_, + })), + keyOf: (r) => String(r.heap ?? ''), + numericFields: ['java_size', 'native_size', 'total_size'], + primaryDeltaField: 'total_size', + }); + // Rename the merged-row 'key' column to 'heap' for the schema, and pin + // a synthesized Total row at the top — same rollup the non-diff card + // shows, kept here so users don't lose the bottom-line view in diff mode. + const totalRow = sumDiffRows(merged, 'Total', [ + 'java_size', + 'native_size', + 'total_size', + ]); + const dataRows: Row[] = [totalRow, ...merged].map((r) => ({ + ...r, + heap: r[KEY_COL], + })); + const schema = buildHeapsDiffSchema(); + return m('div', {class: 'pf-hde-card pf-hde-mb-4'}, [ + m('h3', {class: 'pf-hde-sub-heading'}, 'Bytes Retained by Heap'), + m(DataGrid, { + schema, + rootSchema: 'query', + data: dataRows, + initialColumns: [ + {id: 'heap', field: 'heap'}, + {id: STATUS_COL, field: STATUS_COL}, + { + id: deltaCol('total_size'), + field: deltaCol('total_size'), + sort: 'DESC', + }, + {id: baselineCol('total_size'), field: baselineCol('total_size')}, + {id: currentCol('total_size'), field: currentCol('total_size')}, + {id: deltaCol('java_size'), field: deltaCol('java_size')}, + {id: baselineCol('java_size'), field: baselineCol('java_size')}, + {id: currentCol('java_size'), field: currentCol('java_size')}, + {id: deltaCol('native_size'), field: deltaCol('native_size')}, + {id: baselineCol('native_size'), field: baselineCol('native_size')}, + {id: currentCol('native_size'), field: currentCol('native_size')}, + ], + }), + ]); +} + +function buildHeapsDiffSchema(): SchemaRegistry { + const cols: Record<string, ColumnDef> = { + heap: {title: 'Heap', columnType: 'text'}, + [STATUS_COL]: { + title: 'Status', + columnType: 'text', + cellRenderer: statusRenderer, + }, + }; + const fields: Array<{field: string; title: string}> = [ + {field: 'total_size', title: 'Total'}, + {field: 'java_size', title: 'Java'}, + {field: 'native_size', title: 'Native'}, + ]; + for (const f of fields) { + cols[deltaCol(f.field)] = { + title: 'Δ ' + f.title, + columnType: 'quantitative', + cellRenderer: deltaSizeRenderer, + }; + cols[baselineCol(f.field)] = { + title: 'Baseline ' + f.title, + columnType: 'quantitative', + cellRenderer: sideSizeRenderer, + }; + cols[currentCol(f.field)] = { + title: 'Current ' + f.title, + columnType: 'quantitative', + cellRenderer: sideSizeRenderer, + }; + } + return {query: cols}; +} + +function withTotalRow<T extends Row>(rows: T[], keyField: keyof T): Row[] { + if (rows.length === 0) return rows; + const total: Row = {[keyField as string]: 'Total'}; + for (const r of rows) { + for (const k of Object.keys(r)) { + if (k === keyField) continue; + const v = r[k]; + if (typeof v === 'number') { + total[k] = ((total[k] as number | undefined) ?? 0) + v; + } + } + } + return [total, ...rows]; +} + +// Roll-up row for diff-mode tables — sums each numeric field's +// baseline / current / delta across `rows`. Sets STATUS_COL based on the +// total `total_size` delta sign (GREW / SHRANK / UNCHANGED) — NEW / +// REMOVED don't apply to a synthetic aggregate. +function sumDiffRows( + rows: ReadonlyArray<Row>, + keyName: string, + numericFields: ReadonlyArray<string>, +): Row { + const out: Row = {[KEY_COL]: keyName}; + for (const field of numericFields) { + let bSum = 0; + let cSum = 0; + for (const r of rows) { + bSum += Number(r[baselineCol(field)] ?? 0); + cSum += Number(r[currentCol(field)] ?? 0); + } + out[baselineCol(field)] = bSum; + out[currentCol(field)] = cSum; + out[deltaCol(field)] = cSum - bSum; + } + const totalDelta = Number(out[deltaCol('total_size')] ?? 0); + out[STATUS_COL] = + totalDelta > 0 ? 'GREW' : totalDelta < 0 ? 'SHRANK' : 'UNCHANGED'; + return out; +} + export default OverviewView;
diff --git a/ui/src/test/heap_dump_diff.test.ts b/ui/src/test/heap_dump_diff.test.ts new file mode 100644 index 0000000..80d9790 --- /dev/null +++ b/ui/src/test/heap_dump_diff.test.ts
@@ -0,0 +1,844 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// End-to-end tests for the Heapdump Explorer baseline / diff mode. +// +// Test fixtures (from upstream test/data/, downloaded by tools/test_data): +// - system-server-heap-graph.pftrace ← baseline +// - system-server-heap-graph-new.pftrace ← current (later snapshot, +// same process) +// - test-dump.hprof ← raw hprof for the hprof test +// - api34_startup_cold.perfetto-trace ← non-heap trace for rejection +// +// The pftrace pair is two real heap dumps of the same process taken at +// different times — perfect for asserting non-zero deltas. The fixtures are +// already part of the upstream repo, so no new files are committed for tests. +// +// UI selectors map to Perfetto widgets: +// - "Diff against another trace" → Primary CTA button inside +// the Overview tab when no baseline is loaded. +// - Clear baseline → Button[aria-label="Clear active baseline"] +// in the top bar's baseline controls. +// - Mode toggle → RadioGroup (.pf-radio-group) with buttons +// (.pf-radio-group__button) labelled +// Diff / Current / Baseline. +// - Diff status text → Plain coloured uppercase span text inside +// a DataGrid cell. We assert by *text* +// (`text=GREW`, etc.) — there is no pill class. +// - Inline error → Callout (.pf-callout.pf-intent-danger). + +import {test, expect, type Page} from '@playwright/test'; +import path from 'path'; +import fs from 'fs'; +import {PerfettoTestHelper} from './perfetto_ui_test_helper'; +// Side-effect imports: bring the global `Window.__heapdumpDebug` and +// `Window.__heapdumpDiff` augmentations into scope so +// `page.evaluate(() => window.__heapdumpDebug)` etc. are typed. +import '../plugins/com.android.HeapDumpExplorer/baseline/state'; +import '../plugins/com.android.HeapDumpExplorer/diff/diff_debug'; + +test.describe.configure({mode: 'serial'}); + +const PRIMARY_TRACE = 'system-server-heap-graph-new.pftrace'; +const BASELINE_TRACE = 'system-server-heap-graph.pftrace'; +const HPROF_TRACE = 'test-dump.hprof'; +const NON_HEAP_TRACE = 'api34_startup_cold.perfetto-trace'; + +let pth: PerfettoTestHelper; +let page: Page; + +function tracePath(name: string): string { + const cwd = process.cwd(); + const parts = ['test', 'data', name]; + if (cwd.endsWith('/ui')) parts.unshift('..'); + const p = path.join(...parts); + if (!fs.existsSync(p)) { + throw new Error(`Missing test fixture ${p} (cwd=${cwd})`); + } + return p; +} + +/** + * Locator for the hidden file input that powers the "Load baseline" button. + * Only present on the page when no baseline is currently loaded (the + * LoadBaselineButton lives in the Overview tab). + */ +function fileInputLocator() { + return page.locator('input[type=file][aria-hidden="true"]'); +} + +async function ensureOnOverview(): Promise<void> { + await page.locator('.pf-tabs__tab:has-text("Overview")').first().click(); + await pth.waitForPerfettoIdle(); +} + +async function loadBaseline(name: string): Promise<void> { + // The "Load baseline" affordance lives in the Overview tab when no + // baseline is currently loaded. + await ensureOnOverview(); + await fileInputLocator().setInputFiles(tracePath(name)); + await page.waitForFunction( + () => window.__heapdumpDebug?.hasBaseline(), + null, + {timeout: 60_000}, + ); + await pth.waitForPerfettoIdle(); +} + +// Page errors and console errors collected during a test, asserted to be +// empty in the dedicated console-clean test. Reset by beforeEach. +let pageErrors: string[] = []; +let consoleErrors: string[] = []; + +test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + pth = new PerfettoTestHelper(page); + page.on('pageerror', (err) => { + pageErrors.push(err.message); + }); + page.on('console', (msg) => { + if (msg.type() === 'error') consoleErrors.push(msg.text()); + }); + await pth.openTraceFile(PRIMARY_TRACE); + // Hash-only navigation so we don't reload the page (which would drop the + // in-memory trace; pth.navigate uses page.goto which is a full reload). + await page.evaluate(() => { + window.location.hash = '#!/heapdump'; + }); + await pth.waitForPerfettoIdle(); +}); + +test.beforeEach(() => { + pageErrors = []; + consoleErrors = []; +}); + +test.afterEach(async () => { + // Reset baseline pool between tests so each starts clean. "Remove all + // baseline traces" disposes every pooled engine — equivalent to the + // pre-pool "close baseline" but tear-down semantics rather than + // deselect-only. + await page.evaluate(() => { + document + .querySelector<HTMLButtonElement>( + 'button[aria-label="Remove all baseline traces"]', + ) + ?.click(); + }); +}); + +// 1. Overview tab exposes the Load baseline button when no baseline is loaded. +test('overview shows Load baseline button initially', async () => { + await ensureOnOverview(); + await expect( + page.locator('button:has-text("Diff against another trace")').first(), + ).toBeVisible(); + // No slim baseline-status callout when nothing is loaded. + await expect( + page.locator('button[aria-label="Clear active baseline"]'), + ).toHaveCount(0); + // Debug surface is wired up. + const hasBaseline = await page.evaluate(() => + window.__heapdumpDebug?.hasBaseline(), + ); + expect(hasBaseline).toBe(false); +}); + +// 2. Load baseline → diff mode is active by default and the slim status +// header is visible with a Close button. +test('loading a baseline activates diff mode', async () => { + await loadBaseline(BASELINE_TRACE); + const filename = await page.evaluate(() => + window.__heapdumpDebug!.baselineFilename(), + ); + expect(filename).toBe(BASELINE_TRACE); + const mode = await page.evaluate(() => window.__heapdumpDebug!.mode()); + expect(mode).toBe('diff'); + // Close button appears once a baseline is loaded. + await expect( + page.locator('button[aria-label="Clear active baseline"]'), + ).toBeVisible(); +}); + +// 3. Same trace as primary AND baseline → every status should be UNCHANGED +// (rendered as empty span); no GREW/SHRANK/NEW/REMOVED text in the grid. +test('same trace as baseline produces all-zero deltas', async () => { + test.setTimeout(120_000); + await loadBaseline(PRIMARY_TRACE); // same file as primary + await page.locator('.pf-tabs__tab:has-text("Classes")').click(); + // Wait for the diff to finish: the heading "Classes diff" appears with a + // resolved row count (no leading spinner anymore). + await page + .locator('.pf-hde-view-heading:has-text("Classes diff")') + .first() + .waitFor({state: 'attached', timeout: 90_000}); + // Wait for at least one DataGrid row to be attached so we know rendering + // completed (otherwise an empty grid trivially passes). + await page + .locator('.pf-data-grid .pf-grid__row') + .first() + .waitFor({state: 'attached', timeout: 90_000}); + // Status text cells in DataGrid: count any GREW/SHRANK/NEW/REMOVED. + const nonUnchanged = await page + .locator('.pf-data-grid') + .locator('text=/^(GREW|SHRANK|NEW|REMOVED)$/') + .count(); + expect(nonUnchanged).toBe(0); +}); + +// 4. Different traces → at least one row appears with a non-UNCHANGED status. +test('different baseline produces visible diffs', async () => { + test.setTimeout(120_000); + await loadBaseline(BASELINE_TRACE); + await page.locator('.pf-tabs__tab:has-text("Classes")').click(); + // The two-phase query against 1M+ heap_graph_object rows can take ~30s. + // Wait for any GREW/SHRANK/NEW/REMOVED text inside a DataGrid cell. + await page + .locator('.pf-data-grid') + .locator('text=/^(GREW|SHRANK|NEW|REMOVED)$/') + .first() + .waitFor({state: 'attached', timeout: 90_000}); + const nonUnchanged = await page + .locator('.pf-data-grid') + .locator('text=/^(GREW|SHRANK|NEW|REMOVED)$/') + .count(); + expect(nonUnchanged).toBeGreaterThan(0); + + // Parity-style invariants on the merged DiffRow array (exposed via the + // window.__heapdumpDiff debug API). The diff classifier must be self- + // consistent: every row has a status drawn from a fixed enum, classes + // present in only one side are NEW/REMOVED (not GREW/SHRANK), and the + // sum of status buckets equals the row count. These are the same rules + // a hand-written diff against ahat would expect to hold. + await page.waitForFunction( + () => (window.__heapdumpDiff?.gen('classes') ?? 0) > 0, + null, + {timeout: 30_000}, + ); + const summary = await page.evaluate(() => { + const rows = window.__heapdumpDiff!.rows('classes')!; + const counts = {NEW: 0, REMOVED: 0, GREW: 0, SHRANK: 0, UNCHANGED: 0}; + let presenceViolations = 0; + for (const r of rows) { + counts[r.status as keyof typeof counts]++; + const bAbs = r._b_reachable_obj_count; + const cAbs = r._c_reachable_obj_count; + // If status is NEW, the baseline side must be missing (encoded as + // null on _b_*). If REMOVED, the current side must be missing. + if (r.status === 'NEW' && bAbs !== null) presenceViolations++; + if (r.status === 'REMOVED' && cAbs !== null) presenceViolations++; + } + return {total: rows.length, counts, presenceViolations}; + }); + expect(summary.total).toBeGreaterThan(0); + expect(summary.presenceViolations).toBe(0); + // Bucket sum must equal total. + const sum = Object.values(summary.counts).reduce((a, b) => a + b, 0); + expect(sum).toBe(summary.total); + // Different traces should produce at least one non-UNCHANGED row in the + // merged data structure too. + expect(summary.counts.UNCHANGED).toBeLessThan(summary.total); +}); + +// 5. HPROF baseline loads as well as a pftrace baseline. +test('hprof baseline loads', async () => { + await loadBaseline(HPROF_TRACE); + const filename = await page.evaluate(() => + window.__heapdumpDebug!.baselineFilename(), + ); + expect(filename).toBe(HPROF_TRACE); +}); + +// 6. A non-heap trace as baseline is rejected with an inline error Callout. +test('non-heap trace rejected with inline error', async () => { + await ensureOnOverview(); + await fileInputLocator().setInputFiles(tracePath(NON_HEAP_TRACE)); + // Wait for the danger-intent Callout (rendered both inside Overview and + // in the slim header above the tabs). + await page + .locator('.pf-callout.pf-intent-danger') + .first() + .waitFor({state: 'visible', timeout: 60_000}); + const err = await page + .locator('.pf-callout.pf-intent-danger') + .first() + .innerText(); + expect(err).toMatch(/no Java heap data|heap dump/i); + // Baseline should NOT be set. + const hasBaseline = await page.evaluate(() => + window.__heapdumpDebug!.hasBaseline(), + ); + expect(hasBaseline).toBe(false); +}); + +// 7. Tearing down the pool returns the page to the load-button state. +test('removing all baselines disposes engines and reverts header', async () => { + await loadBaseline(BASELINE_TRACE); + // "Remove all baseline traces" disposes every pooled engine and clears + // the active selection — the pre-pool "close baseline" UX condensed into + // the explicit teardown gesture. + await page.locator('button[aria-label="Remove all baseline traces"]').click(); + await page.waitForFunction( + () => + window.__heapdumpDebug!.hasBaseline() === false && + window.__heapdumpDebug!.poolSize() === 0, + null, + {timeout: 5_000}, + ); + // Discovery CTA is back inside the Overview tab. + await ensureOnOverview(); + await expect( + page.locator('button:has-text("Diff against another trace")').first(), + ).toBeVisible(); +}); + +// 8. Mode toggle: switching to "Current" shows the existing single-engine +// Classes view (no diff Δ columns). +test('Current-only mode hides diff columns', async () => { + await loadBaseline(BASELINE_TRACE); + // Switch the SegmentedButtons mode toggle to "Current". + await page.locator('.pf-radio-group__button:has-text("Current")').click(); + await pth.waitForPerfettoIdle(); + await page.locator('.pf-tabs__tab:has-text("Classes")').click(); + await pth.waitForPerfettoIdle(); + // The diff view's view-heading reads "Classes diff (N classes)"; + // the single-engine ClassesView heading reads "Classes (M)". + // If Current-only correctly swapped views, no "Classes diff" header + // should be visible anywhere on the page. + const visibleDiffHeader = await page + .locator('.pf-hde-view-heading:visible:has-text("Classes diff")') + .count(); + expect(visibleDiffHeader).toBe(0); + // The single-engine "Classes" heading is visible. + await page + .locator('.pf-hde-view-heading:visible') + .first() + .waitFor({timeout: 30_000}); +}); + +// 9. Smoke for each diff-capable tab: navigate, ensure no error renders. +test('every diff-capable tab renders without error', async () => { + test.setTimeout(300_000); + await loadBaseline(BASELINE_TRACE); + for (const tab of [ + 'Overview', + 'Classes', + 'Strings', + 'Arrays', + 'Bitmaps', + 'Dominators', + ]) { + await page.locator(`.pf-tabs__tab:has-text("${tab}")`).click(); + // Give the diff query time to complete; we don't wait for content + // because Strings on a 1M-object trace can be slow. + await page.waitForTimeout(15_000); + // No error/empty state with "Failed". + const failed = await page + .locator('.pf-empty-state:has-text("Failed")') + .count(); + expect(failed, `tab ${tab} renders an error`).toBe(0); + } +}); + +// 10. Overview tab MUST switch to the diff layout once the baseline overview +// has finished loading. The 'Overview diff' heading proves the unified +// view received `baselineOverview` and rendered the diff branch. +test('Overview tab swaps to diff layout when baseline loads', async () => { + test.setTimeout(180_000); + await loadBaseline(BASELINE_TRACE); + await ensureOnOverview(); + // The view-heading text flips from "Overview" → "Overview diff" once + // baselineOverview is computed and threaded in. Use :visible to skip + // any hidden tab-content headings the Tabs widget may keep around. + await expect( + page.locator('.pf-hde-view-heading:visible:has-text("Overview diff")'), + ).toBeVisible({timeout: 120_000}); + // The General Information card now has Baseline / Current / Δ columns. + // Same :visible discipline; also a longer toContainText timeout in case + // the overview rerender cascades over multiple frames. + const infoCard = page + .locator('.pf-hde-card:visible:has-text("General Information")') + .first(); + await expect(infoCard).toContainText('Baseline', {timeout: 30_000}); + await expect(infoCard).toContainText('Current', {timeout: 5_000}); + // Bytes Retained by Heap card too. + await expect( + page + .locator('.pf-hde-card:visible:has-text("Bytes Retained by Heap")') + .first(), + ).toContainText('Δ Total', {timeout: 30_000}); +}); + +// 11a. Multi-trace pool: pool grows to two distinct baseline traces; the +// first is auto-picked, the second is just queued. The active dump's +// identity comes from the first trace; pool size reflects both. +test('multi-trace pool grows when adding a second baseline', async () => { + test.setTimeout(180_000); + // 1) First baseline (auto-picks since the pool was empty + single-dump). + await loadBaseline(BASELINE_TRACE); + expect(await page.evaluate(() => window.__heapdumpDebug!.poolSize())).toBe(1); + expect( + await page.evaluate(() => window.__heapdumpDebug!.baselineFilename()), + ).toBe(BASELINE_TRACE); + + // 2) Add a second baseline (the hprof). The header keeps the file + // input mounted; we don't need to navigate the popup — the same + // hidden input services the Overview CTA *and* the popup's + // "Add baseline trace…" menu item. Setting files directly is + // representative because that's what either path would do once + // the OS picker resolves. + await fileInputLocator().setInputFiles(tracePath(HPROF_TRACE)); + await page.waitForFunction( + () => window.__heapdumpDebug!.poolSize() === 2, + null, + {timeout: 60_000}, + ); + await pth.waitForPerfettoIdle(); + + // 3) Active baseline did NOT change — we already had one, so the + // auto-pick rule (only when active===null && dumps.length===1) + // didn't fire for the second trace. + expect( + await page.evaluate(() => window.__heapdumpDebug!.baselineFilename()), + ).toBe(BASELINE_TRACE); +}); + +// 11b. The popup must list both pooled traces' titles so the user can +// see what's loaded and pick a dump from either. +test('popup lists every pooled baseline trace', async () => { + test.setTimeout(180_000); + await loadBaseline(BASELINE_TRACE); + await fileInputLocator().setInputFiles(tracePath(HPROF_TRACE)); + await page.waitForFunction( + () => window.__heapdumpDebug!.poolSize() === 2, + null, + {timeout: 60_000}, + ); + await pth.waitForPerfettoIdle(); + // Open the popup by clicking the trigger (its label is the active + // baseline's "title · process". + await page + .locator('.pf-hde-top-bar') + .locator('button:has-text("system-server-heap-graph.pftrace")') + .first() + .click(); + // Both trace section headings appear (they're MenuItems with + // folder_open icons; we just match the title text). + await expect( + page.locator('.pf-menu-item:has-text("system-server-heap-graph.pftrace")'), + ).toHaveCount(2); // section heading + Remove "..." item + await expect( + page.locator('.pf-menu-item:has-text("test-dump.hprof")'), + ).toHaveCount(2); // same for second trace + // Add baseline trace… is always last. + await expect( + page.locator('.pf-menu-item:has-text("Add baseline trace…")'), + ).toBeVisible(); +}); + +// 11b. Pretty column titles: the diff Classes view headers are +// human-readable ("Δ Retained", "Current Retained") not the raw +// snake_case field names ("Δ dominated_size_bytes"). Catches a +// regression where the schema would fall back to field names. +test('Classes diff has human-readable column titles', async () => { + test.setTimeout(120_000); + await loadBaseline(BASELINE_TRACE); + await page.locator('.pf-tabs__tab:has-text("Classes")').click(); + await page + .locator('.pf-hde-view-heading:has-text("Classes diff")') + .first() + .waitFor({state: 'attached', timeout: 90_000}); + const headerText = ( + await page.locator('.pf-data-grid .pf-grid-header-cell').allInnerTexts() + ).join(' | '); + // We just need to assert that *some* nice header is visible AND + // that no raw snake_case header leaked through. + expect(headerText).toMatch(/Δ Retained/); + expect(headerText).toMatch(/Current Retained/); + expect(headerText).toMatch(/Baseline Retained/); + expect(headerText).not.toMatch(/dominated_size_bytes/); + expect(headerText).not.toMatch(/reachable_obj_count/); +}); + +// 11c. Top bar layout: when a baseline is loaded the .pf-hde-top-bar row +// is visible, has the expected children (label, popup trigger, +// mode toggle, clear/dispose buttons), and is a single horizontal +// row (height < 80px on a 1080p viewport). +test('top bar renders as a single styled row when baseline loaded', async () => { + await loadBaseline(BASELINE_TRACE); + const bar = page.locator('.pf-hde-top-bar:not(.pf-hde-top-bar--hidden)'); + await expect(bar).toBeVisible(); + const box = await bar.boundingBox(); + expect(box, 'top bar should have a layout box').not.toBeNull(); + expect(box!.height, 'top bar should be a single row').toBeLessThan(80); + // Required pieces are inside. + await expect(bar.locator('text=Baseline:')).toBeVisible(); + await expect(bar.locator('.pf-radio-group')).toBeVisible(); + await expect( + bar.locator('button[aria-label="Clear active baseline"]'), + ).toBeVisible(); + await expect( + bar.locator('button[aria-label="Remove all baseline traces"]'), + ).toBeVisible(); +}); + +// 11d. Status colour cues: the GREW/SHRANK badges render with the +// expected --pf-color-* tokens. Asserts colour is the dominant +// semantic so colour-blind users have the aria-label as backup. +test('Status badges use Perfetto color tokens', async () => { + test.setTimeout(120_000); + await loadBaseline(BASELINE_TRACE); + await page.locator('.pf-tabs__tab:has-text("Classes")').click(); + // The .pf-hde-status-text span only renders for non-UNCHANGED rows. + // Wait for at least one to attach. + await page + .locator('.pf-data-grid .pf-hde-status-text') + .first() + .waitFor({state: 'attached', timeout: 90_000}); + // Find every status badge in the grid and partition by aria-label + // (the source-of-truth attribute we set in the renderer). We do this + // in one page.evaluate so we don't pay for many roundtrips on a long + // grid, and so we get a single deterministic snapshot. + const samples = await page.evaluate(() => { + const out: Array<{label: string; aria: string; color: string}> = []; + const cells = document.querySelectorAll<HTMLElement>( + '.pf-data-grid .pf-hde-status-text', + ); + for (const c of cells) { + out.push({ + label: (c.textContent ?? '').trim(), + aria: c.getAttribute('aria-label') ?? '', + color: getComputedStyle(c).color, + }); + if (out.length >= 200) break; + } + return out; + }); + expect(samples.length, 'no status badges in grid').toBeGreaterThan(0); + // The DataGrid is virtualized — only currently-visible status cells + // are in the DOM (sorted Δ-DESC, so usually NEW or large GREW). Map + // each visible status to its expected colour invariant and assert. + const expects: Record<string, (rgb: number[]) => boolean> = { + NEW: ([r, g, b]) => r > 100 && g > 100 && b < 100, // amber/warning + GREW: ([r, g, b]) => r > g && r > b, // red/danger + SHRANK: ([r, g, b]) => g > r && g > b, // green/success + REMOVED: ([r, g, b]) => Math.abs(r - g) < 30 && Math.abs(g - b) < 30, // muted gray + }; + let checked = 0; + for (const s of samples) { + const pred = expects[s.label]; + if (pred === undefined) continue; + const rgb = (s.color.match(/\d+/g) ?? []).map(Number); + expect( + pred(rgb), + `${s.label} colour ${s.color} fails its hue invariant`, + ).toBe(true); + checked++; + } + expect( + checked, + `no recognised status badges in sample; got: ${samples.map((s) => s.label).join(',')}`, + ).toBeGreaterThan(0); +}); + +// 11e. Multi-trace pool switch: load two baselines, programmatically pick +// the second, verify the active baseline flips and the diff content +// actually changes. Uses a window-exposed test helper so we don't +// have to fight DataGrid header order or popup ordering. +test('switching active baseline between two pooled traces re-renders diff', async () => { + test.setTimeout(180_000); + await loadBaseline(BASELINE_TRACE); + await fileInputLocator().setInputFiles(tracePath(HPROF_TRACE)); + await page.waitForFunction( + () => window.__heapdumpDebug!.poolSize() === 2, + null, + {timeout: 60_000}, + ); + await pth.waitForPerfettoIdle(); + + // Initial state: BASELINE_TRACE is active. + expect( + await page.evaluate(() => window.__heapdumpDebug!.baselineFilename()), + ).toBe(BASELINE_TRACE); + + // Render the Classes diff once with BASELINE_TRACE so a snapshot is + // published. + await page.locator('.pf-tabs__tab:has-text("Classes")').click(); + await page + .locator('.pf-hde-view-heading:has-text("Classes diff")') + .first() + .waitFor({state: 'attached', timeout: 90_000}); + await page.waitForFunction( + () => (window.__heapdumpDiff?.gen('classes') ?? 0) > 0, + null, + {timeout: 30_000}, + ); + const genBefore = await page.evaluate( + () => window.__heapdumpDiff!.gen('classes')!, + ); + const totalBefore = await page.evaluate( + () => window.__heapdumpDiff!.rows('classes')!.length, + ); + + // Switch to the hprof baseline. + await page.evaluate(() => + window.__heapdumpDebug!.pickBaseline('test-dump.hprof'), + ); + expect( + await page.evaluate(() => window.__heapdumpDebug!.baselineFilename()), + ).toBe(HPROF_TRACE); + + // Wait for a fresh classes snapshot to land — the gen counter must + // strictly advance because the diff view remounted (tabsKey change) + // and re-published. + await page.waitForFunction( + (g) => (window.__heapdumpDiff?.gen('classes') ?? 0) > g, + genBefore, + {timeout: 90_000}, + ); + const totalAfter = await page.evaluate( + () => window.__heapdumpDiff!.rows('classes')!.length, + ); + // The two baselines have different class sets — totals should differ. + expect(totalAfter).not.toBe(totalBefore); +}); + +// 11f. Mid-flight cancellation: start a Classes diff, immediately clear +// the baseline before it can finish. The published snapshot for +// 'classes' must be empty (cleared by clearActiveBaseline → the +// pending fetch's snapshot guard aborts the publish). +test('clearing baseline mid-flight aborts the diff cleanly', async () => { + test.setTimeout(120_000); + await loadBaseline(BASELINE_TRACE); + await page.locator('.pf-tabs__tab:has-text("Classes")').click(); + // Yield one frame so the diff view's load() begins. + await page.evaluate(() => new Promise((r) => requestAnimationFrame(r))); + // Clear before the query completes. + await page.locator('button[aria-label="Clear active baseline"]').click(); + // After clear, the published rows are empty; gen also resets to 0 + // (clearDiffRows wipes the snapshot map). + await page.waitForFunction( + () => window.__heapdumpDebug!.hasBaseline() === false, + null, + {timeout: 5_000}, + ); + expect(await page.evaluate(() => window.__heapdumpDiff!.gen('classes'))).toBe( + 0, + ); + expect( + await page.evaluate(() => window.__heapdumpDiff!.rows('classes')), + ).toBeNull(); + // No error appears — the abort is silent. + expect( + await page.locator('.pf-empty-state:visible:has-text("Failed")').count(), + ).toBe(0); +}); + +// 11g. Re-load a previously-cleared trace's same file. The pool stays +// with one trace BUT it's now a fresh entry (new engine, new id); +// the active dump auto-picks. Verifies clear-vs-remove distinction +// and that the auto-pick rule still works after a clear. +test('clear + reload same baseline file replaces the pooled entry', async () => { + test.setTimeout(120_000); + await loadBaseline(BASELINE_TRACE); + expect(await page.evaluate(() => window.__heapdumpDebug!.poolSize())).toBe(1); + + // Clear (pool keeps the trace) and reload the same file (now we have + // 2 entries, both with title BASELINE_TRACE). + await page.locator('button[aria-label="Clear active baseline"]').click(); + await page.waitForFunction( + () => window.__heapdumpDebug!.hasBaseline() === false, + null, + {timeout: 5_000}, + ); + await fileInputLocator().setInputFiles(tracePath(BASELINE_TRACE)); + await page.waitForFunction( + () => window.__heapdumpDebug!.poolSize() === 2, + null, + {timeout: 60_000}, + ); + // Auto-pick fired this time (active was null + single-dump trace). + await page.waitForFunction( + () => window.__heapdumpDebug!.hasBaseline() === true, + null, + {timeout: 5_000}, + ); + expect( + await page.evaluate(() => window.__heapdumpDebug!.baselineFilename()), + ).toBe(BASELINE_TRACE); +}); + +// 11h. Switching tabs while a diff is loading does not crash, and the +// destination tab eventually shows its data. +test('rapid tab switching during diff load is safe', async () => { + test.setTimeout(180_000); + await loadBaseline(BASELINE_TRACE); + // Visit each diff-capable tab in quick succession. No waits between + // clicks — we want overlapping in-flight fetches. + for (const tab of ['Classes', 'Strings', 'Arrays', 'Bitmaps', 'Dominators']) { + await page.locator(`.pf-tabs__tab:has-text("${tab}")`).click(); + } + // Land on Classes and let it finish. + await page.locator('.pf-tabs__tab:has-text("Classes")').click(); + await page + .locator('.pf-hde-view-heading:has-text("Classes diff")') + .first() + .waitFor({state: 'attached', timeout: 120_000}); + await page.waitForFunction( + () => (window.__heapdumpDiff?.gen('classes') ?? 0) > 0, + null, + {timeout: 90_000}, + ); + // No errors anywhere on the page. + expect( + await page.locator('.pf-empty-state:visible:has-text("Failed")').count(), + ).toBe(0); +}); + +// 11i. Loading the SAME file as both primary (already loaded) and baseline +// yields the all-UNCHANGED contract. Specifically, every status +// bucket except UNCHANGED is empty in the published rows. +test('same trace as primary + baseline → only UNCHANGED rows', async () => { + test.setTimeout(120_000); + await loadBaseline(PRIMARY_TRACE); // same file as primary + await page.locator('.pf-tabs__tab:has-text("Classes")').click(); + await page + .locator('.pf-hde-view-heading:has-text("Classes diff")') + .first() + .waitFor({state: 'attached', timeout: 90_000}); + await page.waitForFunction( + () => (window.__heapdumpDiff?.gen('classes') ?? 0) > 0, + null, + {timeout: 30_000}, + ); + const summary = await page.evaluate(() => { + const rows = window.__heapdumpDiff!.rows('classes')!; + const counts = {NEW: 0, REMOVED: 0, GREW: 0, SHRANK: 0, UNCHANGED: 0}; + for (const r of rows) counts[r.status as keyof typeof counts]++; + return {total: rows.length, counts}; + }); + expect(summary.total).toBeGreaterThan(0); + expect(summary.counts.NEW).toBe(0); + expect(summary.counts.REMOVED).toBe(0); + expect(summary.counts.GREW).toBe(0); + expect(summary.counts.SHRANK).toBe(0); + expect(summary.counts.UNCHANGED).toBe(summary.total); +}); + +// 11j. Mode toggle "Baseline" — show the baseline trace's data using +// the single-engine views (no Δ columns, no diff math). The +// heading must NOT contain "diff". +test('Baseline-only mode shows baseline data via single-engine views', async () => { + test.setTimeout(120_000); + await loadBaseline(BASELINE_TRACE); + await page.locator('.pf-radio-group__button:has-text("Baseline")').click(); + await pth.waitForPerfettoIdle(); + await page.locator('.pf-tabs__tab:has-text("Classes")').click(); + await pth.waitForPerfettoIdle(); + // No "Classes diff" heading. + expect( + await page + .locator('.pf-hde-view-heading:visible:has-text("Classes diff")') + .count(), + ).toBe(0); + // Single-engine "Classes" heading IS visible. + await expect( + page.locator('.pf-hde-view-heading:visible').first(), + ).toContainText(/^Classes/, {timeout: 30_000}); + // The mode debug API agrees. + expect(await page.evaluate(() => window.__heapdumpDebug!.mode())).toBe( + 'baseline', + ); +}); + +// 12. No uncaught page errors or console.error across loading + tab nav. +// Catches regressions where a missing import / runtime crash would only +// surface as a console scribble and an empty card. +// Wait for the pool to reach `expected` traces. The base `loadBaseline` +// helper waits on hasBaseline() which goes true on the first load and +// stays true through subsequent loads — this waits on poolSize change. +async function waitForPoolSize(expected: number): Promise<void> { + await page.waitForFunction( + (n) => window.__heapdumpDebug?.poolSize() === n, + expected, + {timeout: 60_000}, + ); +} + +// Edge: removing a non-active pooled baseline preserves the active +// selection. +test('removing a non-active pooled trace keeps the active selection', async () => { + test.setTimeout(180_000); + await loadBaseline(BASELINE_TRACE); + await waitForPoolSize(1); + await fileInputLocator().setInputFiles(tracePath(HPROF_TRACE)); + await waitForPoolSize(2); + await pth.waitForPerfettoIdle(); + // Switch active to the hprof trace. + await page.evaluate( + (title) => window.__heapdumpDebug?.pickBaseline(title), + HPROF_TRACE, + ); + expect( + await page.evaluate(() => window.__heapdumpDebug?.baselineFilename()), + ).toBe(HPROF_TRACE); + // Remove the non-active trace by clicking its row's "Remove …" item. + await page.locator('.pf-hde-top-bar button:has-text("·")').first().click(); + await page.locator(`text=Remove ${BASELINE_TRACE}`).click(); + expect( + await page.evaluate(() => window.__heapdumpDebug?.baselineFilename()), + ).toBe(HPROF_TRACE); + expect(await page.evaluate(() => window.__heapdumpDebug?.poolSize())).toBe(1); +}); + +// Edge: removing the active pooled trace clears the selection but +// leaves the pool with the remaining trace. +test('removing the active pooled trace clears the selection', async () => { + test.setTimeout(180_000); + await loadBaseline(BASELINE_TRACE); + await waitForPoolSize(1); + await fileInputLocator().setInputFiles(tracePath(HPROF_TRACE)); + await waitForPoolSize(2); + await pth.waitForPerfettoIdle(); + const before = await page.evaluate(() => + window.__heapdumpDebug?.baselineFilename(), + ); + expect(before).not.toBeNull(); + await page.locator('.pf-hde-top-bar button:has-text("·")').first().click(); + await page.locator(`text=Remove ${before}`).click(); + expect(await page.evaluate(() => window.__heapdumpDebug?.hasBaseline())).toBe( + false, + ); + expect(await page.evaluate(() => window.__heapdumpDebug?.poolSize())).toBe(1); +}); + +test('no console errors during baseline load and tab navigation', async () => { + test.setTimeout(120_000); + await loadBaseline(BASELINE_TRACE); + await ensureOnOverview(); + await page.waitForTimeout(8_000); // let baseline overview finish + for (const tab of ['Classes', 'Strings', 'Bitmaps', 'Overview']) { + await page.locator(`.pf-tabs__tab:has-text("${tab}")`).click(); + await page.waitForTimeout(4_000); + } + expect(pageErrors, 'page emitted uncaught errors').toEqual([]); + expect( + consoleErrors.filter( + // Allow trace-processor's noisy benign warnings through; they're not + // from the plugin code. + (e) => !e.includes('TraceProcessor') && !e.includes('WebGL'), + ), + 'plugin emitted console errors', + ).toEqual([]); +});
diff --git a/ui/src/test/heap_dump_diff_hprof_primary.test.ts b/ui/src/test/heap_dump_diff_hprof_primary.test.ts new file mode 100644 index 0000000..4f7dab7 --- /dev/null +++ b/ui/src/test/heap_dump_diff_hprof_primary.test.ts
@@ -0,0 +1,174 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Companion to heap_dump_diff.test.ts: opens a *raw hprof* as the +// primary trace (the other suite uses a perfetto pftrace as primary). +// Confirms the explorer renders, the diff CTA is reachable, and a +// pftrace baseline diffs cleanly against an hprof primary. + +import {test, expect, type Page} from '@playwright/test'; +import path from 'path'; +import fs from 'fs'; +import {PerfettoTestHelper} from './perfetto_ui_test_helper'; +import '../plugins/com.android.HeapDumpExplorer/baseline/state'; +import '../plugins/com.android.HeapDumpExplorer/diff/diff_debug'; + +test.describe.configure({mode: 'serial'}); + +const PRIMARY_HPROF = 'test-dump.hprof'; +const BASELINE_PFTRACE = 'system-server-heap-graph.pftrace'; + +let pth: PerfettoTestHelper; +let page: Page; + +function tracePath(name: string): string { + const cwd = process.cwd(); + const parts = ['test', 'data', name]; + if (cwd.endsWith('/ui')) parts.unshift('..'); + const p = path.join(...parts); + if (!fs.existsSync(p)) throw new Error(`missing ${p} (cwd=${cwd})`); + return p; +} + +function fileInputLocator() { + return page.locator('input[type=file][aria-hidden="true"]'); +} + +async function ensureOnOverview(): Promise<void> { + await page.locator('.pf-tabs__tab:has-text("Overview")').first().click(); + await pth.waitForPerfettoIdle(); +} + +test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + pth = new PerfettoTestHelper(page); + await pth.openTraceFile(PRIMARY_HPROF); + await page.evaluate(() => { + window.location.hash = '#!/heapdump'; + }); + await pth.waitForPerfettoIdle(); +}); + +test.afterEach(async () => { + await page.evaluate(() => { + document + .querySelector<HTMLButtonElement>( + 'button[aria-label="Remove all baseline traces"]', + ) + ?.click(); + }); +}); + +// 1. Hprof opens cleanly as the primary trace. +test('hprof primary loads and shows the Overview heading', async () => { + await ensureOnOverview(); + await expect( + page.locator('.pf-hde-view-heading:has-text("Overview")').first(), + ).toBeVisible(); + // The Diff CTA is rendered (no baseline yet). + await expect( + page.locator('button:has-text("Diff against another trace")'), + ).toBeVisible(); +}); + +// 2. Hprof primary + pftrace baseline → real diff with non-zero deltas. +test('hprof primary + pftrace baseline produces a usable diff', async () => { + test.setTimeout(180_000); + await ensureOnOverview(); + await fileInputLocator().setInputFiles(tracePath(BASELINE_PFTRACE)); + await page.waitForFunction( + () => window.__heapdumpDebug?.hasBaseline(), + null, + {timeout: 60_000}, + ); + await pth.waitForPerfettoIdle(); + // Open Classes diff and wait for non-UNCHANGED rows. + await page.locator('.pf-tabs__tab:has-text("Classes")').click(); + await page + .locator('.pf-hde-view-heading:has-text("Classes diff")') + .first() + .waitFor({state: 'attached', timeout: 90_000}); + await page.waitForFunction( + () => (window.__heapdumpDiff?.gen('classes') ?? 0) > 0, + null, + {timeout: 30_000}, + ); + const summary = await page.evaluate(() => { + const rows = window.__heapdumpDiff!.rows('classes')!; + const counts = {NEW: 0, REMOVED: 0, GREW: 0, SHRANK: 0, UNCHANGED: 0}; + for (const r of rows) counts[r.status as keyof typeof counts]++; + return {total: rows.length, counts}; + }); + expect(summary.total).toBeGreaterThan(0); + // Two completely different traces: a healthy mix of statuses. + expect(summary.counts.NEW + summary.counts.REMOVED).toBeGreaterThan(0); +}); + +// 3. Hprof + hprof reload (same file as primary loaded as baseline). +// Verifies the engine isolation works across two parses of the same +// file: each owns its own Wasm heap. +test('hprof loaded twice (primary & baseline) yields a consistent diff', async () => { + test.setTimeout(120_000); + await ensureOnOverview(); + await fileInputLocator().setInputFiles(tracePath(PRIMARY_HPROF)); + await page.waitForFunction( + () => window.__heapdumpDebug?.hasBaseline(), + null, + {timeout: 60_000}, + ); + await pth.waitForPerfettoIdle(); + await page.locator('.pf-tabs__tab:has-text("Classes")').click(); + await page + .locator('.pf-hde-view-heading:has-text("Classes diff")') + .first() + .waitFor({state: 'attached', timeout: 90_000}); + await page.waitForFunction( + () => (window.__heapdumpDiff?.gen('classes') ?? 0) > 0, + null, + {timeout: 30_000}, + ); + const summary = await page.evaluate(() => { + const rows = window.__heapdumpDiff!.rows('classes')!; + const counts = {NEW: 0, REMOVED: 0, GREW: 0, SHRANK: 0, UNCHANGED: 0}; + for (const r of rows) counts[r.status as keyof typeof counts]++; + return {total: rows.length, counts}; + }); + expect(summary.total).toBeGreaterThan(0); + // Same file diffed against itself: every row is UNCHANGED. + expect(summary.counts.NEW).toBe(0); + expect(summary.counts.REMOVED).toBe(0); + expect(summary.counts.GREW).toBe(0); + expect(summary.counts.SHRANK).toBe(0); +}); + +// 4. Baseline label for hprof: "Java heap dump" rather than "pid 0" +// when the trace has no process metadata. +test('hprof baseline label is "Java heap dump" when pid is unknown', async () => { + await ensureOnOverview(); + await fileInputLocator().setInputFiles(tracePath(PRIMARY_HPROF)); + await page.waitForFunction( + () => window.__heapdumpDebug?.hasBaseline(), + null, + {timeout: 60_000}, + ); + // The trigger label includes the dump's process label. + const trigger = page + .locator('.pf-hde-top-bar') + .locator(`button:has-text("${PRIMARY_HPROF}")`) + .first(); + await expect(trigger).toBeVisible(); + // Either "Java heap dump" (preferred) or "pid X" with X != 0. + const label = (await trigger.textContent()) ?? ''; + expect(label).not.toContain('pid 0'); +});
diff --git a/ui/src/widgets/flamegraph.ts b/ui/src/widgets/flamegraph.ts index b3e9e73..abfbe5a 100644 --- a/ui/src/widgets/flamegraph.ts +++ b/ui/src/widgets/flamegraph.ts
@@ -128,6 +128,11 @@ readonly marker?: string; readonly xStart: number; readonly xEnd: number; + // Opaque colour hint of the form `diff:<score>`, where score ∈ [-1, 1] + // is a signed, normalised change vs a baseline (negative = shrank, + // positive = grew). Mapped to a pprof-style green→grey→red fill by + // getColorSchemeFromHint. Today only the heap-dump diff metric emits it. + readonly colorHint?: string; } export interface FlamegraphQueryData { @@ -729,7 +734,12 @@ colorScheme = getFlamegraphColorScheme(name, state === 'PARTIAL'); } else { name = nodes[source.queryIdx].name; - colorScheme = getFlamegraphColorScheme(name, state === 'PARTIAL'); + const hint = nodes[source.queryIdx].colorHint; + if (hint !== undefined && state !== 'PARTIAL') { + colorScheme = getColorSchemeFromHint(hint); + } else { + colorScheme = getFlamegraphColorScheme(name, state === 'PARTIAL'); + } } const bgColor = hover ? colorScheme.variant : colorScheme.base; const textColor = hover ? colorScheme.textVariant : colorScheme.textBase; @@ -1667,6 +1677,90 @@ // Cache for computed color schemes by name const colorSchemeCache = new Map<string, ColorScheme>(); +// Schemes derived from a `diff:<score>` colour hint, keyed by the hint +// string. The diff colour depends only on the signed score, not the node +// name (pprof-style diff colouring), so the name is not part of the key. +const hintSchemeCache = new Map<string, ColorScheme>(); + +// pprof-style diff colouring. Diff metrics emit a `diff:<score>` hint +// where score ∈ [-1, 1] is the signed, normalised change vs the baseline +// (negative = shrank, positive = grew). We map it to a green→grey→red +// scale with the same algorithm as the pprof web UI's `dotColor` +// (google/pprof internal/report/graph.go), tuned to the flamegraph's +// light node fills (so changes read at a glance and text stays legible). +const DIFF_SATURATION = 0.5; // colour vividness at |score| = 1 +// pprof's web UI defaults to HSV value 0.7 for the diff palette; using 0.93 +// here washed grey-at-zero out to near-white, which made unchanged boxes +// blend into the background and read as "broken styling" against the panel +// chrome. 0.78 keeps coloured nodes vibrant while giving grey real +// distinguishability from the surrounding empty space. +const DIFF_VALUE = 0.78; // node fill lightness (HSV "value") +const DIFF_SHIFT = 0.7; // pushes scores away from grey to use the range + +function clampSigned(x: number): number { + return Number.isFinite(x) ? Math.max(-1, Math.min(1, x)) : 0; +} + +// pprof diff fill colour for a signed score in [-1, 1]. +function pprofDiffColor(score: number): HSLColor { + score = clampSigned(score); + let saturation = DIFF_SATURATION; + const value = DIFF_VALUE; + // Desaturate towards grey near zero so tiny changes read as neutral. + if (Math.abs(score) < 0.2) { + saturation *= Math.abs(score) / 0.2; + } + // Gamma to move scores away from grey, matching pprof. + if (score > 0) { + score = Math.pow(score, 1 - DIFF_SHIFT); + } else if (score < 0) { + score = -Math.pow(-score, 1 - DIFF_SHIFT); + } + let r: number; + let g: number; + if (score < 0) { + g = value; + r = value * (1 + saturation * score); + } else { + r = value; + g = value * (1 - saturation * score); + } + const b = value * (1 - saturation); + return new HSLColor(rgbUnitToHex(r, g, b)); +} + +function rgbUnitToHex(r: number, g: number, b: number): string { + const to255 = (x: number) => + Math.round(Math.max(0, Math.min(1, x)) * 255) + .toString(16) + .padStart(2, '0'); + return `#${to255(r)}${to255(g)}${to255(b)}`; +} + +// CSS colour for a diff score — exported so the Heap Dump Explorer diff +// legend can paint a gradient that exactly matches the node fills. +export function getDiffColorCss(score: number): string { + return pprofDiffColor(score).cssString; +} + +// Resolve a `diff:<score>` hint into a ColorScheme. +function getColorSchemeFromHint(hint: string): ColorScheme { + const cached = hintSchemeCache.get(hint); + if (cached !== undefined) return cached; + const score = clampSigned(Number(hint.split(':')[1] ?? '0')); + const base = pprofDiffColor(score); + const scheme = makeColorScheme(base, base.darken(8)); + hintSchemeCache.set(hint, scheme); + return scheme; +} + +// Base palette colour for a node name. Hashing the name to a hue keeps +// the same class in the same colour across diff and non-diff modes; the +// fixed saturation / lightness match the pprof web UI. +function paletteHsl(name: string): HSLColor { + return new HSLColor({h: hash(name, 360), s: 46, l: 80}); +} + function getFlamegraphColorScheme(name: string, greyed: boolean): ColorScheme { if (greyed) { return GREYED_COLOR_SCHEME; @@ -1674,17 +1768,11 @@ if (name === 'unknown' || name === 'root') { return ROOT_COLOR_SCHEME; } - - // Check cache first let scheme = colorSchemeCache.get(name); if (scheme !== undefined) { return scheme; } - - // Hash the name to get a predictable hue, then create color with fixed - // saturation and lightness values to match what pprof web UI does. - const hue = hash(name, 360); - const base = new HSLColor({h: hue, s: 46, l: 80}); + const base = paletteHsl(name); scheme = makeColorScheme(base, base.darken(15).saturate(15)); colorSchemeCache.set(name, scheme); return scheme;
diff --git a/ui/src/widgets/flamegraph_diff_color_unittest.ts b/ui/src/widgets/flamegraph_diff_color_unittest.ts new file mode 100644 index 0000000..f786726 --- /dev/null +++ b/ui/src/widgets/flamegraph_diff_color_unittest.ts
@@ -0,0 +1,58 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {getDiffColorCss} from './flamegraph'; + +// getDiffColorCss returns the HSLColor cssString, i.e. "rgb(R G B)". +function rgb(score: number): [number, number, number] { + const css = getDiffColorCss(score); + const m = css.match(/rgb\((\d+)\s+(\d+)\s+(\d+)/); + if (m === null) throw new Error(`unexpected colour string: ${css}`); + return [Number(m[1]), Number(m[2]), Number(m[3])]; +} + +describe('flamegraph pprof-style diff colour', () => { + test('zero score is neutral grey', () => { + const [r, g, b] = rgb(0); + expect(r).toBe(g); + expect(g).toBe(b); + }); + + test('positive score (grew) is red-dominant', () => { + const [r, g, b] = rgb(1); + expect(r).toBeGreaterThan(g); + expect(r).toBeGreaterThan(b); + }); + + test('negative score (shrank) is green-dominant', () => { + const [r, g, b] = rgb(-1); + expect(g).toBeGreaterThan(r); + expect(g).toBeGreaterThan(b); + }); + + test('intensity is monotonic in |score|', () => { + // More growth ⇒ the green channel drops further (more vivid red). + expect(rgb(0.9)[1]).toBeLessThan(rgb(0.3)[1]); + // More shrink ⇒ the red channel drops further (more vivid green). + expect(rgb(-0.9)[0]).toBeLessThan(rgb(-0.3)[0]); + }); + + test('clamps out-of-range and non-finite scores', () => { + expect(getDiffColorCss(5)).toBe(getDiffColorCss(1)); + expect(getDiffColorCss(-5)).toBe(getDiffColorCss(-1)); + const [r, g, b] = rgb(NaN); + expect(r).toBe(g); + expect(g).toBe(b); + }); +});