diff --git a/Android.bp b/Android.bp index 8a329f5e2b5..c375d1016ce 100644 --- a/Android.bp +++ b/Android.bp @@ -808,7 +808,7 @@ cc_library_shared { ], shared_libs: [ "android.hardware.atrace@1.0", - "android.hardware.health-V2-ndk", + "android.hardware.health-V5-ndk", "android.hardware.health@2.0", "android.hardware.power.stats-V1-cpp", "android.hardware.power.stats@1.0", @@ -23159,8 +23159,8 @@ phony { // TODO(primiano): uncomment after ag/38422283 lands. // "system_tracing_protos_descriptor", ], - } - } + }, + }, } filegroup { diff --git a/docs/heap_diff/README.md b/docs/heap_diff/README.md new file mode 100644 index 00000000000..86b3b37a49f --- /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: **GREW +/ NEW** (red), **SHRANK / GONE** +(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. + +![Overview](screenshots/01_overview_diff.png) + +### 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). + +![Flamegraph](screenshots/02_flamegraph_diff.png) + +### Classes tab + +Per-class diff breakdown. Status pills (GREW / SHRANK / NEW / GONE) and +signed deltas next to baseline / current values. + +![Classes](screenshots/03_classes_diff.png) + +### Objects tab + +![Objects](screenshots/04_objects_diff.png) + +### Dominators tab + +Dominator-tree retained-size diff per root class (size, count, native +size, retained obj count — all four columns are diffable). + +![Dominators](screenshots/05_dominators_diff.png) + +### Bitmaps tab + +![Bitmaps](screenshots/06_bitmaps_diff.png) + +### Strings tab + +![Strings](screenshots/07_strings_diff.png) + +### Arrays tab + +![Arrays](screenshots/08_arrays_diff.png) + +### 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. + +![Popup](screenshots/09_popup.png) + +## 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) + +![Overview cross-trace](screenshots/cross_trace/01_overview_diff.png) + +### Classes (cross-trace) + +![Classes cross-trace](screenshots/cross_trace/03_classes_diff.png) + +### Dominators (cross-trace) + +![Dominators cross-trace](screenshots/cross_trace/05_dominators_diff.png) + +### Strings (cross-trace) + +![Strings cross-trace](screenshots/cross_trace/07_strings_diff.png) + +### Arrays (cross-trace) + +![Arrays cross-trace](screenshots/cross_trace/08_arrays_diff.png) + +## 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 00000000000..4d235ebfb27 Binary files /dev/null and b/docs/heap_diff/screenshots/01_overview_diff.png 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 00000000000..e6cd4d2c1cb Binary files /dev/null and b/docs/heap_diff/screenshots/02_flamegraph_diff.png 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 00000000000..3be457bfa11 Binary files /dev/null and b/docs/heap_diff/screenshots/03_classes_diff.png 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 00000000000..2d0fa5c2336 Binary files /dev/null and b/docs/heap_diff/screenshots/04_objects_diff.png 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 00000000000..1f5a37c5fd0 Binary files /dev/null and b/docs/heap_diff/screenshots/05_dominators_diff.png 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 00000000000..0fd759cfbc0 Binary files /dev/null and b/docs/heap_diff/screenshots/06_bitmaps_diff.png 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 00000000000..d6d6dc06fe8 Binary files /dev/null and b/docs/heap_diff/screenshots/07_strings_diff.png 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 00000000000..4957e71e022 Binary files /dev/null and b/docs/heap_diff/screenshots/08_arrays_diff.png differ diff --git a/docs/heap_diff/screenshots/09_popup.png b/docs/heap_diff/screenshots/09_popup.png new file mode 100644 index 00000000000..64b23bd4b04 Binary files /dev/null and b/docs/heap_diff/screenshots/09_popup.png 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 00000000000..6aadc0ee5dc Binary files /dev/null and b/docs/heap_diff/screenshots/cross_trace/01_overview_diff.png 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 00000000000..0bf7bb8cfeb Binary files /dev/null and b/docs/heap_diff/screenshots/cross_trace/02_flamegraph_diff.png 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 00000000000..c71b408861f Binary files /dev/null and b/docs/heap_diff/screenshots/cross_trace/03_classes_diff.png 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 00000000000..8d4b487f0cb Binary files /dev/null and b/docs/heap_diff/screenshots/cross_trace/04_objects_diff.png 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 00000000000..a95c0870fbc Binary files /dev/null and b/docs/heap_diff/screenshots/cross_trace/05_dominators_diff.png 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 00000000000..bcfa2c01650 Binary files /dev/null and b/docs/heap_diff/screenshots/cross_trace/06_bitmaps_diff.png 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 00000000000..794901a7c5d Binary files /dev/null and b/docs/heap_diff/screenshots/cross_trace/07_strings_diff.png 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 00000000000..62e8bae46fa Binary files /dev/null and b/docs/heap_diff/screenshots/cross_trace/08_arrays_diff.png 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 00000000000..47f7c6bf5b8 --- /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 "); + 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 removed = new ArrayList<>(); + List 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 activities = new ArrayList<>(); + } + + static class Activity { + String name; + FragmentManager fragmentManager = new FragmentManager(); + Activity(String n) { this.name = n; } + } + + static class FragmentManager { + List fragments = new ArrayList<>(); + BackStack backStack = new BackStack(); + } + + static class Fragment { + String tag; + List holders = new ArrayList<>(); + ViewModel viewModel = new ViewModel(); + Fragment(String t) { this.tag = t; } + } + + static class BackStack { + List entries = new ArrayList<>(); + } + + static class BackStackEntry { + String name; + BackStackEntry(String n) { this.name = n; } + } + + static class ViewHolder { + List textViews = new ArrayList<>(); + List imageViews = new ArrayList<>(); + } + + static class TextView { + List strings = new ArrayList<>(); + } + + static class ImageView { + List bitmaps = new ArrayList<>(); + } + + static class Bitmap { + byte[] pixels; + Bitmap(int n) { this.pixels = new byte[n]; } + } + + static class ViewModel { + List caches = new ArrayList<>(); + List apis = new ArrayList<>(); + } + + static class DataCache { + List entries = new ArrayList<>(); + } + + static class CacheEntry { + byte[] payload; + } + + static class ApiClient { + List responses = new ArrayList<>(); + } + + static class HttpResponse { + byte[] body; + } + + static class ServiceManager { + List services = new ArrayList<>(); + ConnectivityService connectivity = new ConnectivityService(); + } + + static class BackgroundService { + String name; + List workers = new ArrayList<>(); + EventBus eventBus = new EventBus(); + BackgroundService(String n) { this.name = n; } + } + + static class Worker { + List tasks = new ArrayList<>(); + Scheduler scheduler = new Scheduler(); + } + + static class Task { + TaskData data; + } + + static class TaskData { + byte[] payload; + } + + static class Scheduler { + List scheduled = new ArrayList<>(); + } + + static class ScheduledTask { + int delayMs; + ScheduledTask(int s) { this.delayMs = s; } + } + + static class EventBus { + List 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 connections = new ArrayList<>(); + } + + static class Connection { + List 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 00000000000..f7652838344 --- /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 00000000000..bafbb1c184f --- /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 32deacda1a8..72368773416 100644 --- a/ui/src/components/query_flamegraph.ts +++ b/ui/src/components/query_flamegraph.ts @@ -118,6 +118,12 @@ export interface QueryFlamegraphMetric { // // 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 @@ async function computeFlamegraphTree( optionalNodeActions, optionalRootActions, optionalMarker, + colorHint: colorHintEnabled, }: QueryFlamegraphMetric, {filters, view}: FlamegraphState, ): Promise { @@ -287,7 +294,22 @@ async function computeFlamegraphTree( 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 @@ async function computeFlamegraphTree( 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 @@ async function computeFlamegraphTree( 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 00000000000..5247aca463d --- /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 { + 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 00000000000..7deece374ed --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/baseline/loader.ts @@ -0,0 +1,182 @@ +// 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; +} + +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 { + 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.', + ); + } + + 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, + ); + } +} + +// Mirrors `dumps/loader.ts:loadDumps` for the primary engine, but +// returns the list instead of mutating module state. +async function enumerateDumps( + engine: WasmEngineProxy, +): Promise> { + 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 { + 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 00000000000..1eac0cf10de --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/baseline/state.ts @@ -0,0 +1,205 @@ +// 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; + // 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 { + 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'; +} + +// "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, +): 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, + 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/diff_debug.ts b/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_debug.ts new file mode 100644 index 00000000000..703c6e9ffe3 --- /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; +} + +const snapshots = new Map(); +let nextGen = 1; + +export function publishDiffRows( + view: DiffViewName, + rows: ReadonlyArray, +): 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 | 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 00000000000..70b0948e7fe --- /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 { + 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(opts: MergeOptions): 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(); + 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 = { + [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( + rows: readonly T[], + keyOf: (r: T) => string, + numericFields: readonly string[], +): T[] { + const map = new Map(); + 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; + 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( + rows: readonly T[], + keyOf: (r: T) => string, + side: string, +): Map { + const map = new Map(); + 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 00000000000..54da413b576 --- /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 00000000000..e92f4fef2dd --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_schemas.ts @@ -0,0 +1,250 @@ +// 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 = { + NEW: 'NEW', + REMOVED: 'REMOVED', + GREW: 'GREW', + SHRANK: 'SHRANK', + UNCHANGED: '', +}; + +const STATUS_CLASS: Record = { + NEW: 'ah-status-text ah-status-text--new', + REMOVED: 'ah-status-text ah-status-text--removed', + GREW: 'ah-status-text ah-status-text--grew', + SHRANK: 'ah-status-text ah-status-text--shrank', + UNCHANGED: 'ah-status-text ah-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] ?? 'ah-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: 'ah-mono ah-muted'}, '—'), + align: 'right', + }; + } + const sign = compareToZero(n); + if (sign === 0) { + return { + content: m( + 'span', + {'class': 'ah-mono ah-delta--zero', 'aria-label': 'No change'}, + '0', + ), + align: 'right', + }; + } + const symbol = sign > 0 ? '+' : '−'; + const cls = sign > 0 ? 'ah-mono ah-delta--grew' : 'ah-mono ah-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: 'ah-mono ah-muted'}, '—'), + align: 'right', + }; + } + return { + content: m('span', {class: 'ah-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: 'ah-mono ah-muted'}, '—'), + align: 'right', + }; + } + return { + content: m('span', {class: 'ah-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; +}): SchemaRegistry { + const cols: Record = { + [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; +}): 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 00000000000..4a9ab37bf97 --- /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, + baseline: ReadonlyArray, +): ObjectPairRow[] { + const cBuckets = new Map(); + const bBuckets = new Map(); + 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(); + 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 00000000000..650dc7a1aa8 --- /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 & Pick, +): 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) => + 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 00000000000..8963ddb3223 --- /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 {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 +{ + private inputEl: HTMLInputElement | null = null; + + oncreate() { + openFilePickerImpl = () => this.inputEl?.click(); + } + onremove() { + openFilePickerImpl = null; + } + view({attrs}: m.Vnode): 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: 'ah-top-bar__label'}, 'Baseline:'), + renderBaselineSelector(traces, active, () => this.inputEl?.click()), + m('span', {class: 'ah-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, + 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: 'ah-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): 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, +): 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 7391c4e42fc..f89001a2017 100644 --- a/ui/src/plugins/com.android.HeapDumpExplorer/heap_dump_page.ts +++ b/ui/src/plugins/com.android.HeapDumpExplorer/heap_dump_page.ts @@ -15,12 +15,14 @@ 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 {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 {EmptyState} from '../../widgets/empty_state'; import type {OverviewData} from './types'; import * as queries from './queries'; import OverviewView from './views/overview_view'; @@ -35,6 +37,22 @@ import FlamegraphObjectsView from './views/flamegraph_objects_view'; 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 +61,29 @@ interface HeapDumpPageAttrs { const FG_KEY_PREFIX = 'fg-'; const INSTANCE_KEY_PREFIX = 'inst-'; +// Overview cache keyed on (engine identity, filter SQL): two engines +// may share (upid, ts) values, and one engine serves several dumps +// over the page's lifetime. +const overviewCache = new Map(); +const overviewLoadingFor = new Set(); + +let nextEngineUid = 1; +const engineUid = new WeakMap(); +function engineKey(engine: Engine, filterSql: string): string { + let id = engineUid.get(engine); + if (id === undefined) { + id = nextEngineUid++; + engineUid.set(engine, id); + } + return `${id}:${filterSql}`; +} + +export function resetCachedOverview(): void { + overviewCache.clear(); + overviewLoadingFor.clear(); +} +let lastTabContext: string | null = null; + function fgTabKey(id: number): string { return `${FG_KEY_PREFIX}${id}`; } @@ -109,10 +150,22 @@ function buildTabs( activeDump: queries.HeapDump, state: NavState, overview: OverviewData, + baselineOverview: OverviewData | undefined, + baselineLoading: boolean, ): TabsTab[] { const {engine, trace, navigateWithTabs, clearNavParam} = session; const hideExplanationSetting = session.hideDefaultChangedHint; const hideHint = hideExplanationSetting.get(); + const diffActive = isDiffActive(); + const activeBaseline = getActiveBaseline(); + const baselineEngine = activeBaseline?.trace.engine; + // Same-engine baseline (only kind that supports the SQL-JOINed + // flamegraph diff). `trace.engine` is a fresh proxy per access, so we + // identify the singleton by its `disposable === false` flag instead. + const sameEngineBaselineDump = + diffActive && activeBaseline?.trace.disposable === false + ? activeBaseline.dump + : undefined; const tabs: TabsTab[] = [ { key: 'overview', @@ -120,6 +173,9 @@ function buildTabs( content: m(OverviewView, { overview, activeDump, + diffActive, + baselineOverview: diffActive ? baselineOverview : undefined, + baselineLoading: diffActive && baselineLoading, navigate: navigateWithTabs, showDefaultChangedHint: session.autoNavigated && !hideHint, onBackToTimeline: () => trace.navigate('#!/viewer'), @@ -142,77 +198,128 @@ function buildTabs( upid: activeDump.upid, ts: activeDump.ts, }), + baseline: sameEngineBaselineDump + ? { + upid: sameEngineBaselineDump.upid, + ts: Time.fromRaw(sameEngineBaselineDump.ts), + } + : 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, + }), }, ]; @@ -239,61 +346,30 @@ function buildTabs( key: instanceTabKey(obj.id), 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}, + }), }); } return tabs; } -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: 'ah-dump-selector'}, - m('span', {class: 'ah-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 { oncreate({attrs}: m.VnodeDOM) { attrs.session.setNavigateCallback((sub) => { @@ -306,36 +382,128 @@ export class HeapDumpPage implements m.ClassComponent { attrs.session.setNavigateCallback(undefined); } + 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) { const {session, subpage} = attrs; session.syncFromSubpage(subpage); session.syncInstanceTabFromNav(); const active = session.activeDump; - const overview = session.cachedOverview; - if (active === null || overview === null) { + if (active === null) { + return m( + 'div', + {class: 'ah-page'}, + 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; + 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; + const overviewFilter = queries.dumpFilterSql(undefined, '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: 'ah-page'}, - renderDumpSelector(session), + topBar, m('div', {class: 'ah-loading'}, m(Spinner, {easing: true})), ); } - // Keyed so Mithril remounts views (and their SQLDataSources) on - // dump switch. - const tabsKey = `${active.upid}:${active.ts}`; + // Key the Tabs widget on (primary dump, baseline dump, mode) — mode + // changes swap engines / filters used by standard views, which only + // capture them at oninit. Remount forces 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}`; return m( 'div', {class: 'ah-page'}, - renderDumpSelector(session), + topBar, m( 'main', {class: 'ah-main'}, m(Tabs, { key: tabsKey, - tabs: buildTabs(session, active, session.nav, overview), + tabs: buildTabs( + session, + active, + session.nav, + overview, + baselineOverview, + baselineLoading, + ), activeTabKey: activeTabKey(session), onTabChange: (key: string) => handleTabChange(session, key), onTabClose: (key: string) => handleTabClose(session, key), @@ -344,3 +512,6 @@ export class HeapDumpPage implements m.ClassComponent { ); } } + +/** 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 3a1944953b0..29ff36a723d 100644 --- a/ui/src/plugins/com.android.HeapDumpExplorer/index.ts +++ b/ui/src/plugins/com.android.HeapDumpExplorer/index.ts @@ -18,7 +18,11 @@ import {PerfettoPlugin} from '../../public/plugin'; import {Trace} from '../../public/trace'; import {NUM} from '../../trace_processor/query_result'; import HeapProfilePlugin from '../dev.perfetto.HeapProfile'; -import {HeapDumpPage} from './heap_dump_page'; +import { + HeapDumpPage, + resetCachedOverview, + disposeBaseline, +} from './heap_dump_page'; import {HeapDumpExplorerSession} from './session'; export default class implements PerfettoPlugin { @@ -47,6 +51,9 @@ export default class implements PerfettoPlugin { ); await session.loadDumps(); + resetCachedOverview(); + disposeBaseline(); + ctx.pages.registerPage({ route: '/heapdump', render: (subpage) => m(HeapDumpPage, {session, subpage}), diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/queries.ts b/ui/src/plugins/com.android.HeapDumpExplorer/queries.ts index d2c747f446a..594df9deb52 100644 --- a/ui/src/plugins/com.android.HeapDumpExplorer/queries.ts +++ b/ui/src/plugins/com.android.HeapDumpExplorer/queries.ts @@ -44,7 +44,8 @@ export interface HeapDump { 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 { @@ -74,17 +75,36 @@ export async function loadDumpsList(engine: Engine): Promise { 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 { @@ -239,11 +259,24 @@ async function batchBitmapBufferHashes( 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 { - 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, @@ -343,8 +376,8 @@ export async function getOverview( .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(); 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 531a98ffbb7..ae3abc25ba8 100644 --- a/ui/src/plugins/com.android.HeapDumpExplorer/session.ts +++ b/ui/src/plugins/com.android.HeapDumpExplorer/session.ts @@ -50,6 +50,8 @@ interface InstanceTab { readonly id: number; readonly objId: number; readonly label: string; + readonly currentId: number | null; + readonly baselineId: number | null; } const INSTANCE_LABEL_MAX = 30; @@ -148,6 +150,8 @@ export class HeapDumpExplorerSession { 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); return; @@ -250,7 +254,12 @@ export class HeapDumpExplorerSession { this._activeInstanceId = null; } - private openInstanceTab(objId: number, label?: string): void { + private openInstanceTab( + objId: number, + label?: string, + currentId?: number | null, + baselineId?: number | null, + ): void { const existing = this._instanceTabs.find((t) => t.objId === objId); if (existing) { this._activeInstanceId = existing.id; @@ -260,6 +269,8 @@ export class HeapDumpExplorerSession { id: this._nextInstanceId++, objId, label: truncateInstanceLabel(label ?? 'Instance'), + currentId: currentId === undefined ? objId : currentId, + baselineId: baselineId === undefined ? null : baselineId, }; this._instanceTabs.push(tab); this._activeInstanceId = tab.id; @@ -275,6 +286,13 @@ export class HeapDumpExplorerSession { } } + clearInstanceTabs(): void { + this._instanceTabs.length = 0; + this._activeInstanceId = null; + if (this._nav.view === 'object') { + this.navigate('overview'); + } + } syncInstanceTabFromNav(): void { if (this._nav.view !== 'object') { this._activeInstanceId = null; diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/styles.scss b/ui/src/plugins/com.android.HeapDumpExplorer/styles.scss index 8893cc848d6..4a5ccb4c8be 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; } -.ah-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; -} - -.ah-dump-selector__label { - color: var(--pf-color-text-hint); -} - .ah-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. +.ah-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. +.ah-top-bar--hidden { + padding: 0; + border-bottom: 0; +} + +.ah-top-bar__label { + color: var(--pf-color-text-hint); +} + +// Vertical divider between the primary and baseline groups. +.ah-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. +.ah-top-bar__spacer { + flex: 1; +} + +// Subdued group heading inside the baseline-pool popup (above each +// pooled trace's dump list). +.ah-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. +.ah-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. +.ah-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. +.ah-delta { + &--grew { + color: var(--pf-color-danger); + } + &--shrank { + color: var(--pf-color-success); + } + &--zero { + color: var(--pf-color-text-muted); + } +} + // Spacing (minimal set) .ah-mt-1 { margin-top: 0.25rem; 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 00000000000..fadf262aac4 --- /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 {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 * as queries from './queries'; +import {HeapDumpDiffHeader, shouldShowBaselineHeader} from './header'; +import {setSelfTraceBaseline} from './baseline/state'; +import {HeapDumpExplorerSession} from './session'; + +interface TopBarAttrs { + readonly trace: Trace; + readonly session: HeapDumpExplorerSession; + readonly onDumpChanged: () => void; +} + +export class TopBar implements m.ClassComponent { + view({attrs}: m.Vnode): 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: 'ah-top-bar ah-top-bar--hidden'}, [ + m(HeapDumpDiffHeader, {trace: attrs.trace}), + ]); + } + return m( + 'div', + {class: 'ah-top-bar'}, + hasPrimary ? renderPrimarySelector(attrs) : null, + hasPrimary && hasBaseline + ? m('span', {class: 'ah-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: 'ah-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 00000000000..423c17064a1 --- /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 { + 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 { + // 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: 'ah-mono ah-muted'}, '—'), + align: 'right', + } satisfies CellRenderResult; + } + return { + content: m('span', {class: 'ah-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: 'ah-link', + onclick: () => + navigate('object', { + id: cId ?? bId ?? 0, + baselineId: bId, + currentId: cId, + label: str ? `"${truncate(str, 30)}"` : display, + }), + }, + display, + ), + str + ? m('span', {class: 'ah-str-badge'}, ` "${truncate(str, 40)}"`) + : null, + ]), + align: 'left', + } satisfies CellRenderResult; + }; + + const cols: Record = { + 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 { + 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(undefined, '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: 'ah-muted'}, + 'Open a class from the Classes or Dominators tab to see ' + + 'paired instance-level diffs.', + ), + ); + } + + if (loading && !rows) { + return m('div', {class: 'ah-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: 'ah-view-content'}, [ + m('h2', {class: 'ah-view-heading'}, [ + 'Objects diff ', + m( + 'span', + {class: 'ah-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 00000000000..0bff5c08a04 --- /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 { + 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 { + 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(undefined, '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: 'ah-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: 'ah-link ah-mono', + onclick: () => + navigate('arrays', {arrayHash: String(value ?? '')}), + }, + String(value ?? ''), + ), + size, + count, + }); + + const initialColumns = buildSizeCountInitialColumns({size, count}); + + return m('div', {class: 'ah-view-content'}, [ + m('h2', {class: 'ah-view-heading'}, [ + 'Arrays diff ', + m( + 'span', + {class: 'ah-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 00000000000..700d43165a5 --- /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 { + 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 { + 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(undefined, '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: 'ah-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: 'ah-link ah-mono', + onclick: () => navigate('bitmaps'), + }, + String(value ?? ''), + ), + size, + count, + }); + + const initialColumns = buildSizeCountInitialColumns({size, count}); + + return m('div', {class: 'ah-view-content'}, [ + m('h2', {class: 'ah-view-heading'}, [ + 'Bitmaps diff ', + m( + 'span', + {class: 'ah-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 00000000000..dbc0f021adb --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/classes_diff_view.ts @@ -0,0 +1,268 @@ +// 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} from '../../baseline/state'; +import {dumpFilterSql, getActiveDump} from '../../queries'; + +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; +} + +async function fetchAll(engine: Engine, filterSql: string): Promise { + 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 { + 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 { + const baselineFilter = baselineDumpFilterSql('a'); + const currentFilter = dumpFilterSql(undefined, 'a'); + const [baselineRows, currentRows] = await Promise.all([ + fetchAll(baselineEngine, baselineFilter), + fetchAll(currentEngine, currentFilter), + ]); + if (isStale()) return; + const 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')); + 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: 'ah-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: 'ah-link', + onclick: () => navigate('objects', {cls: String(value)}), + }, + String(value), + ), + size, + count, + extraFields, + }); + + const initialColumns = buildSizeCountInitialColumns({ + size, + count, + extraFields, + }); + + return m('div', {class: 'ah-view-content'}, [ + m('h2', {class: 'ah-view-heading'}, [ + 'Classes diff ', + m( + 'span', + {class: 'ah-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 00000000000..113a3d32296 --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/dominators_diff_view.ts @@ -0,0 +1,245 @@ +// 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} from '../../baseline/state'; +import {dumpFilterSql, getActiveDump} from '../../queries'; + +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', +]; + +async function runQuery(engine: Engine, filterSql: string): Promise { + 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 { + 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(undefined, '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.root_class ?? ''), + numericFields: NUMERIC_FIELDS, + primaryDeltaField: 'dominated_size_bytes', + }); + merged.sort(compareByAbsDeltaDesc('dominated_size_bytes')); + 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: 'ah-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: 'ah-link', + onclick: () => navigate('objects', {cls: String(value ?? '')}), + }, + String(value ?? ''), + ), + size, + count, + extraFields, + }); + + const initialColumns = buildSizeCountInitialColumns({ + size, + count, + extraFields, + }); + + return m('div', {class: 'ah-view-content'}, [ + m('h2', {class: 'ah-view-heading'}, [ + 'Dominators diff ', + m( + 'span', + {class: 'ah-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 00000000000..6686d003c1d --- /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: 'ah-mono ah-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 + // ah-view-stack already spaces it from the first Section — wrapping in + // ah-card + ah-mb-4 stacks two margin sources and looks cramped. + return m('div', [ + m('h2', {class: 'ah-view-heading ah-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: 'ah-info-grid'}, [ + m('span', {class: 'ah-info-grid__label'}, 'Class:'), + m('span', className), + m('span', {class: 'ah-info-grid__label'}, 'Heap:'), + m('span', heap), + cId !== null + ? [ + m('span', {class: 'ah-info-grid__label'}, 'Current id:'), + m('span', [ + m( + 'button', + { + class: 'ah-link', + onclick: () => + navigate('object', {id: cId, label: undefined}), + }, + fmtHex(cId), + ), + ]), + ] + : null, + bId !== null + ? [ + m('span', {class: 'ah-info-grid__label'}, 'Baseline id:'), + m('span', {class: 'ah-mono ah-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 `ah-status-text ah-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: 'ah-mono ah-muted'}, '—'), + align: 'right', + } satisfies CellRenderResult; + } + return { + content: m('span', {class: 'ah-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: 'ah-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, + baseline: ReadonlyArray, + 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( + cElems.map((e) => [e.idx, e.value]), + ); + const bByIdx = new Map( + bElems.map((e) => [e.idx, e.value]), + ); + const idxs = new Set([...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 { + let currentVnode: m.Vnode; + 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: 'ah-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: 'ah-view-scroll ah-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 00000000000..94f0a932ba9 --- /dev/null +++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/strings_diff_view.ts @@ -0,0 +1,208 @@ +// 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 { + 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 { + 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(undefined, '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: 'ah-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: 'ah-link ah-mono ah-break-all ah-str-color', + onclick: () => navigate('strings', {q: str}), + }, + JSON.stringify(truncated), + ); + }, + size, + count, + }); + + const initialColumns = buildSizeCountInitialColumns({size, count}); + + return m('div', {class: 'ah-view-content'}, [ + m('h2', {class: 'ah-view-heading'}, [ + 'Strings diff ', + m( + 'span', + {class: 'ah-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 5603d4171f8..c06a7559dbc 100644 --- a/ui/src/plugins/com.android.HeapDumpExplorer/views/flamegraph_view.ts +++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/flamegraph_view.ts @@ -27,6 +27,15 @@ import { 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 +44,10 @@ interface FlamegraphViewAttrs { 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|. Only same-trace baselines are supported here — + // cross-engine SQL JOINs aren't possible across separate workers. + readonly baseline?: FlamegraphBaselineRef; } // path_hash_stable is exposed unaggregatable (and CAST to TEXT in SQL, @@ -162,29 +175,228 @@ function buildHeapGraphMetrics( ); } +// ---------- Diff metrics (same-trace) ------------------------------------- + +// Build a JAVA_HEAP_GRAPH diff metric. `value` is `abs(delta)` so width +// reflects movement magnitude; `color_hint` encodes direction. +// +// 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, +): 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, '')) 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 + ) + ) + `; + const statement = ` + WITH + cur_path_hash AS (SELECT * FROM ${pathHashScan(cur.upid, cur.ts)}), + base_path_hash AS (SELECT * FROM ${pathHashScan(base.upid, base.ts)}), + cur_nodes AS ( + SELECT t.id, t.parent_id, ifnull(t.name, '[Unknown]') 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 ( + SELECT ph.h AS path_h, + t.self_size AS b_self_size, + 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} + ), + 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, + 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 format: see getColorSchemeFromHint in flamegraph.ts. + CASE + WHEN s.m IS NULL OR s.m = 0 THEN 'palette:u' + WHEN j.is_new = 1 THEN 'palette:n' + WHEN j.delta_${dim} = 0 THEN 'palette:u' + WHEN j.delta_${dim} > 0 + THEN printf('palette:g:%.3f', + abs(j.delta_${dim}) * 1.0 / s.m) + ELSE printf('palette:s:%.3f', + abs(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 { + 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); + }, + }); + // 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. + return METRIC_SPECS.map((s) => + buildDiffMetric( + cur, + base, + `Δ ${s.name}`, + s.unit, + s.valueColumn, + s.isDominator, + showObjectsAction(s.isDominator), + ), + ); +} + const FlamegraphView: m.ClosureComponent = () => { let cachedMetrics: ReadonlyArray | undefined; let cachedKey: string | undefined; return { view({attrs}) { - const key = `${attrs.upid}:${attrs.ts}`; - if (cachedMetrics === undefined || key !== cachedKey) { - cachedMetrics = buildHeapGraphMetrics( - attrs.upid, - attrs.ts, - attrs.onShowObjects, - ); + const baselineKey = attrs.baseline + ? `${attrs.baseline.upid}:${attrs.baseline.ts}` + : 'none'; + const key = `${attrs.upid}:${attrs.ts}|${baselineKey}`; + const metricsChanged = cachedMetrics === undefined || key !== cachedKey; + if (metricsChanged || cachedMetrics === undefined) { + cachedMetrics = attrs.baseline + ? buildHeapGraphDiffMetrics( + {upid: attrs.upid, ts: attrs.ts}, + attrs.baseline, + attrs.onShowObjects, + ) + : buildHeapGraphMetrics(attrs.upid, attrs.ts, attrs.onShowObjects); cachedKey = key; } - const metrics = cachedMetrics; - - // First render or after a dump-change reset: create a default - // state so the panel renders meaningfully on the same frame. + const metrics: ReadonlyArray = cachedMetrics; + // 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); } 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 00000000000..4b583a792a5 --- /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: 'ah-card ah-mb-4'}, + m('p', {class: 'ah-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: 'ah-card ah-mb-4'}, + m('p', {class: 'ah-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: 'ah-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: 'ah-card ah-mb-4'}, [ + m('h3', {class: 'ah-sub-heading'}, opts.title), + m('p', {class: 'ah-desc'}, [ + opts.summary, + ' ', + m( + 'button', + { + class: 'ah-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 = { + [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: 'ah-card ah-mb-4'}, [ + m('h3', {class: 'ah-sub-heading'}, opts.title), + m('p', {class: 'ah-desc'}, [ + opts.summary, + ' ', + m( + 'button', + { + class: 'ah-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: 'ah-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: 'ah-link ah-mono ah-break-all ah-str-color', + onclick: () => onclick(row), + }, + '"' + display + '"', + ), + align: 'left', + }; + }; +} + +interface DuplicateGroupLike { + readonly wastedBytes: number; +} + +function makeSummary( + unit: string, + cur: ReadonlyArray, + base: ReadonlyArray, + 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: 'ah-mono ah-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: 'ah-mono'}, `(${dGroups >= 0 ? '+' : ''}${dGroups})`), + ', wasting ', + m('span', {class: 'ah-mono ah-semibold'}, fmtSize(cWasted)), + ' ', + m( + 'span', + { + class: + 'ah-mono ' + + (dWasted > 0 + ? 'ah-delta--grew' + : dWasted < 0 + ? 'ah-delta--shrank' + : 'ah-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 e3a7e09b429..2621b6fc587 100644 --- a/ui/src/plugins/com.android.HeapDumpExplorer/views/overview_view.ts +++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/overview_view.ts @@ -12,436 +12,527 @@ // 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 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'; - -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: 'ah-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: 'ah-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: 'ah-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: 'ah-link ah-mono ah-break-all ah-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: 'ah-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, - }, - }, - }; -} +import {Intent} from '../../../widgets/common'; +import type {OverviewData, HeapInfo} from '../types'; +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'; -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: 'ah-card ah-mt-4'}, [ - m('h3', {class: 'ah-sub-heading'}, title), - m('p', {class: 'ah-desc'}, [ - groupCount + - ' group' + - (groupCount > 1 ? 's' : '') + - ' detected, wasting ', - m('span', {class: 'ah-mono ah-semibold'}, fmtSize(totalWasted)), - '. ', - m( - 'button', - { - class: 'ah-link--alt', - onclick: () => navigate(targetView as NavState['view']), - }, - linkLabel, - ), - ]), - m('div', {class: 'ah-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 { 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 ?? '') + - (activeDump.pid ? ` (pid ${activeDump.pid})` : ''); - const infoRows: Row[] = [ - {property: 'Process', value: processLabel}, - {property: 'Classes', value: overview.classCount.toLocaleString()}, - { - property: 'Reachable instances', - value: overview.reachableInstanceCount.toLocaleString(), - }, - { - property: 'Unreachable instances', - value: overview.unreachableInstanceCount.toLocaleString(), - }, - ]; + // 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: 'ah-view-scroll'}, [ - m('h2', {class: 'ah-view-heading'}, 'Overview'), - showHint - ? m( - Callout, - { - className: 'ah-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: 'ah-card ah-mb-4'}, [ - m('h3', {class: 'ah-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: 'ah-card'}, [ - m('h3', {class: 'ah-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: 'ah-view-heading'}, heading)), + child( + 'default-changed-hint', + showDefaultChangedHint ? m( - 'div', - {class: 'ah-card ah-mt-4 ah-mb-4'}, - m('p', {class: 'ah-muted'}, 'No duplicate bitmaps found.'), + Callout, + { + className: 'ah-default-changed-callout ah-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: 'ah-card ah-mb-4'}, - m('p', {class: 'ah-muted'}, 'No duplicate strings found.'), + Callout, + { + icon: 'hourglass_empty', + intent: Intent.None, + className: 'ah-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: 'ah-heading-row ah-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: 'ah-mb-4', + }, + error, + ), + ]; +} + +// ----- General Information -------------------------------------------------- + +function renderInfoCard( + activeDump: HeapDump, + overview: OverviewData, + baselineOverview: OverviewData | undefined, +): m.Children { + const processLabel = + (activeDump.processName ?? '') + + (activeDump.pid !== null ? ` (pid ${activeDump.pid})` : ''); + if (baselineOverview === undefined) { + const rows: Row[] = [ + {property: 'Process', value: processLabel}, + {property: 'Classes', value: overview.classCount.toLocaleString()}, + { + property: 'Reachable instances', + value: overview.reachableInstanceCount.toLocaleString(), + }, + { + property: 'Unreachable instances', + value: overview.unreachableInstanceCount.toLocaleString(), + }, + { + 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: 'ah-card ah-mb-4'}, [ + m('h3', {class: 'ah-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: 'ah-card ah-mb-4'}, [ + m('h3', {class: 'ah-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: 'ah-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: 'ah-card ah-mb-4'}, [ + m('h3', {class: 'ah-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: 'ah-card ah-mb-4'}, [ + m('h3', {class: 'ah-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 = { + 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(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, + keyName: string, + numericFields: ReadonlyArray, +): 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 00000000000..2684c698da5 --- /dev/null +++ b/ui/src/test/heap_dump_diff.test.ts @@ -0,0 +1,842 @@ +// 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 → SegmentedButtons (.pf-segmented-buttons) with +// buttons 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, 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 { + await page.locator('.pf-tabs__tab:has-text("Overview")').first().click(); + await pth.waitForPerfettoIdle(); +} + +async function loadBaseline(name: string): Promise { + // 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( + '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('.ah-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-segmented-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('.ah-view-heading:visible:has-text("Classes diff")') + .count(); + expect(visibleDiffHeader).toBe(0); + // The single-engine "Classes" heading is visible. + await page + .locator('.ah-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('.ah-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('.ah-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('.ah-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('.ah-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('.ah-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 .ah-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('.ah-top-bar:not(.ah-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-segmented-buttons')).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 .ah-status-text span only renders for non-UNCHANGED rows. + // Wait for at least one to attach. + await page + .locator('.pf-data-grid .ah-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( + '.pf-data-grid .ah-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 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('.ah-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('.ah-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('.ah-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-segmented-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('.ah-view-heading:visible:has-text("Classes diff")') + .count(), + ).toBe(0); + // Single-engine "Classes" heading IS visible. + await expect(page.locator('.ah-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 { + 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('.ah-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('.ah-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 00000000000..14ff1b0d710 --- /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, 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 { + 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( + '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('.ah-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('.ah-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('.ah-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('.ah-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 f3091e7aa7e..8a843f789b8 100644 --- a/ui/src/widgets/flamegraph.ts +++ b/ui/src/widgets/flamegraph.ts @@ -124,6 +124,11 @@ export interface FlamegraphNode { readonly marker?: string; readonly xStart: number; readonly xEnd: number; + // Opaque modulation hint of the form `palette:DIR[:INTENSITY]`. Keeps + // the per-name palette hue and shifts saturation/lightness only — see + // getColorSchemeFromHint. Today only the heap-dump diff metric emits + // these. + readonly colorHint?: string; } export interface FlamegraphQueryData { @@ -691,7 +696,12 @@ export class Flamegraph implements m.ClassComponent { 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, name); + } else { + colorScheme = getFlamegraphColorScheme(name, state === 'PARTIAL'); + } } const bgColor = hover ? colorScheme.variant : colorScheme.base; const textColor = hover ? colorScheme.textVariant : colorScheme.textBase; @@ -1629,6 +1639,61 @@ const ROOT_COLOR_SCHEME = makeColorScheme( // Cache for computed color schemes by name const colorSchemeCache = new Map(); +// Schemes derived from `palette:DIR[:INTENSITY]` hints. Cache key +// includes the node name because the hue depends on it. +const hintSchemeCache = new Map(); + +// Resolve a palette-modulator hint into a ColorScheme. Format: +// `palette:DIR[:INTENSITY]` where DIR ∈ {g, s, n, u} (grew, shrank, new, +// unchanged) and INTENSITY ∈ [0, 1] scales how far the colour shifts +// from the base palette. Hue is always preserved so each node keeps the +// identity it has in the non-diff palette. +function getColorSchemeFromHint(hint: string, name: string): ColorScheme { + const cacheKey = `${name}|${hint}`; + const cached = hintSchemeCache.get(cacheKey); + if (cached !== undefined) return cached; + const [, dir, intensityStr] = hint.split(':'); + const i = clampUnit(Number(intensityStr ?? '1')); + const base = modulatePalette(paletteHsl(name), dir, i); + const scheme = makeColorScheme(base, base.darken(12).saturate(15)); + hintSchemeCache.set(cacheKey, scheme); + return scheme; +} + +function modulatePalette( + base: HSLColor, + dir: string, + intensity: number, +): HSLColor { + switch (dir) { + case 'g': // grew: more vivid, slightly darker + return base + .saturate(Math.round(25 * intensity)) + .darken(Math.round(8 * intensity)); + case 's': // shrank: faded toward background, hue retained + return base + .lighten(Math.round(15 * intensity)) + .desaturate(Math.round(15 * intensity)); + case 'n': // new + return base.saturate(25).darken(5); + case 'u': // unchanged + return base.lighten(8).desaturate(10); + default: + return base; + } +} + +function clampUnit(x: number): number { + return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 1; +} + +// 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; @@ -1636,17 +1701,11 @@ function getFlamegraphColorScheme(name: string, greyed: boolean): ColorScheme { 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;