blob: 531a98ffbb754b70cee9223550d20474e94bc96a [file]
// 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 m from 'mithril';
import type {Engine} from '../../trace_processor/engine';
import type {Trace} from '../../public/trace';
import type {Setting} from '../../public/settings';
import {NUM} from '../../trace_processor/query_result';
import {SQL_PREAMBLE} from './components';
import {flamegraphQuery} from './views/flamegraph_objects_view';
import * as queries from './queries';
import {
type NavState,
type NavView,
stateToSubpage,
subpageToState,
} from './nav_state';
import type {OverviewData} from './types';
import type {FlamegraphState} from '../../widgets/flamegraph';
import {
METRIC_DOMINATED_OBJECT_SIZE,
METRIC_OBJECT_SIZE,
} from './views/flamegraph_view';
interface FlamegraphSelection {
readonly pathHashes: string;
readonly isDominator: boolean;
readonly upid: number;
readonly ts: bigint;
}
interface FlamegraphTab extends FlamegraphSelection {
readonly id: number;
count: number | null;
}
interface InstanceTab {
readonly id: number;
readonly objId: number;
readonly label: string;
}
const INSTANCE_LABEL_MAX = 30;
function truncateInstanceLabel(label: string): string {
return label.length > INSTANCE_LABEL_MAX
? label.slice(0, INSTANCE_LABEL_MAX) + '…'
: label;
}
// Created per onTraceLoad and replaced on the next one, so per-trace
// state disappears together. Dump switching within a trace keeps the
// session but drops per-dump caches.
export class HeapDumpExplorerSession {
private _nav: NavState = {view: 'overview', params: {}};
private _navigateCallback?: (subpage: string) => void;
private _dumps: ReadonlyArray<queries.HeapDump> = [];
private _activeDump: queries.HeapDump | null = null;
private readonly _flamegraphTabs: FlamegraphTab[] = [];
private _nextFlamegraphId = 0;
private _activeFlamegraphId: number | null = null;
private readonly _instanceTabs: InstanceTab[] = [];
private _nextInstanceId = 0;
private _activeInstanceId: number | null = null;
private _overview: OverviewData | null = null;
// Persists across tab switches; reset on dump change so a new dump
// opens with defaults instead of the prior dump's filters.
private _flamegraphPanelState: FlamegraphState | undefined;
// Set when the plugin auto-redirected to HDE on load; gates the
// "default view changed" hint on the overview.
autoNavigated = false;
constructor(
readonly trace: Trace,
readonly engine: Engine,
readonly hideDefaultChangedHint: Setting<boolean>,
) {}
get dumps(): ReadonlyArray<queries.HeapDump> {
return this._dumps;
}
get activeDump(): queries.HeapDump | null {
return this._activeDump;
}
async loadDumps(): Promise<void> {
this._dumps = await queries.loadDumpsList(this.engine);
this._activeDump = this._dumps.length > 0 ? this._dumps[0] : null;
}
selectDump(d: queries.HeapDump): void {
if (this._activeDump === d) return;
this.switchToDump(d);
if (
this._nav.view === 'object' ||
this._nav.view === 'flamegraph-objects'
) {
this.navigate('overview');
}
m.redraw();
}
private switchToDump(d: queries.HeapDump): void {
this._activeDump = d;
this.resetDumpScopedState();
void this.loadOverview();
}
get nav(): NavState {
return this._nav;
}
setNavigateCallback(cb: ((subpage: string) => void) | undefined): void {
this._navigateCallback = cb;
}
navigate(view: NavView, params: Record<string, unknown> = {}): void {
this._nav = {view, params} as NavState;
this._navigateCallback?.(stateToSubpage(this._nav));
m.redraw();
}
// Arrow property: passed by reference into Mithril attrs.
readonly navigateWithTabs = (
view: NavView,
params?: Record<string, unknown>,
): void => {
if (view === 'object') {
this.openInstanceTab(
params?.id as number,
params?.label as string | undefined,
);
this.navigate(view, params);
return;
}
this._activeInstanceId = null;
this.navigate(view, params);
};
readonly clearNavParam = (key: string): void => {
delete (this._nav.params as Record<string, unknown>)[key];
};
syncFromSubpage(subpage: string | undefined): void {
const sub = subpage?.startsWith('/') ? subpage.slice(1) : subpage;
// The router strips query params from `subpage`; compare path-only.
const currentPath = stateToSubpage(this._nav).split('?')[0];
const incomingPath = (sub ?? '').split('?')[0];
if (incomingPath !== currentPath) {
this._nav = subpageToState(sub);
}
}
get flamegraphTabs(): ReadonlyArray<FlamegraphTab> {
return this._flamegraphTabs;
}
get activeFlamegraphId(): number | null {
return this._activeFlamegraphId;
}
setActiveFlamegraphTab(id: number): void {
this._activeFlamegraphId = id;
}
clearActiveFlamegraphTab(): void {
this._activeFlamegraphId = null;
}
openFlamegraph(sel: FlamegraphSelection): void {
const target = this._dumps.find(
(d) => d.upid === sel.upid && d.ts === sel.ts,
);
if (target && target !== this._activeDump) {
this.switchToDump(target);
}
const existing = this._flamegraphTabs.find(
(t) =>
t.pathHashes === sel.pathHashes && t.isDominator === sel.isDominator,
);
if (existing) {
this._activeFlamegraphId = existing.id;
this.navigate('flamegraph-objects');
return;
}
const tab: FlamegraphTab = {
id: this._nextFlamegraphId++,
count: null,
pathHashes: sel.pathHashes,
isDominator: sel.isDominator,
upid: sel.upid,
ts: sel.ts,
};
this._flamegraphTabs.push(tab);
this._activeFlamegraphId = tab.id;
this.navigate('flamegraph-objects');
const q = flamegraphQuery(sel.pathHashes, sel.isDominator);
this.engine
.query(`${SQL_PREAMBLE}; SELECT COUNT(*) AS c FROM (${q})`)
.then((r) => {
tab.count = Number(r.firstRow({c: NUM}).c);
m.redraw();
})
.catch(console.error);
}
closeFlamegraph(id: number): void {
const idx = this._flamegraphTabs.findIndex((t) => t.id === id);
if (idx === -1) return;
this._flamegraphTabs.splice(idx, 1);
if (this._activeFlamegraphId === id) {
this._activeFlamegraphId = null;
this.navigate('overview');
}
}
get instanceTabs(): ReadonlyArray<InstanceTab> {
return this._instanceTabs;
}
get activeInstanceId(): number | null {
return this._activeInstanceId;
}
setActiveInstanceTab(id: number): void {
this._activeInstanceId = id;
}
clearActiveInstanceTab(): void {
this._activeInstanceId = null;
}
private openInstanceTab(objId: number, label?: string): void {
const existing = this._instanceTabs.find((t) => t.objId === objId);
if (existing) {
this._activeInstanceId = existing.id;
return;
}
const tab: InstanceTab = {
id: this._nextInstanceId++,
objId,
label: truncateInstanceLabel(label ?? 'Instance'),
};
this._instanceTabs.push(tab);
this._activeInstanceId = tab.id;
}
closeInstanceTab(id: number): void {
const idx = this._instanceTabs.findIndex((t) => t.id === id);
if (idx === -1) return;
this._instanceTabs.splice(idx, 1);
if (this._activeInstanceId === id) {
this._activeInstanceId = null;
this.navigate('overview');
}
}
syncInstanceTabFromNav(): void {
if (this._nav.view !== 'object') {
this._activeInstanceId = null;
return;
}
const {id, label} = this._nav.params;
const existing = this._instanceTabs.find((t) => t.objId === id);
if (existing) {
this._activeInstanceId = existing.id;
} else {
this.openInstanceTab(id, label);
}
}
private resetDumpScopedState(): void {
this._overview = null;
this._flamegraphTabs.length = 0;
this._nextFlamegraphId = 0;
this._activeFlamegraphId = null;
this._instanceTabs.length = 0;
this._nextInstanceId = 0;
this._activeInstanceId = null;
this._flamegraphPanelState = undefined;
}
get cachedOverview(): OverviewData | null {
return this._overview;
}
get flamegraphPanelState(): FlamegraphState | undefined {
return this._flamegraphPanelState;
}
readonly setFlamegraphPanelState = (s: FlamegraphState): void => {
this._flamegraphPanelState = s;
};
// Open the flamegraph pivoted at `pathHash`. The metric is chosen to
// match the tree the hash came from. The chip displays
// `<label> (this instance)` since the raw hash regex is unreadable.
readonly openFlamegraphPivotedAt = (
pathHash: string,
label: string,
isDominator: boolean,
): void => {
this._flamegraphPanelState = {
selectedMetricName: isDominator
? METRIC_DOMINATED_OBJECT_SIZE
: METRIC_OBJECT_SIZE,
filters: [],
view: {
kind: 'PIVOT',
pivot: `^${pathHash}$`,
displayLabel: `${label} (this instance)`,
},
};
this.navigate('flamegraph');
};
// Pins the dump at fetch start; if the user switches dumps before
// the result arrives, the result is dropped instead of briefly
// displaying the wrong dump's overview.
async loadOverview(): Promise<void> {
if (this._overview !== null) return;
const dump = this._activeDump;
if (dump === null) return;
try {
const data = await queries.getOverview(this.engine, dump);
if (this._activeDump === dump) this._overview = data;
} catch (err) {
console.error('Failed to load overview:', err);
} finally {
m.redraw();
}
}
}