blob: 0ec3a2fbf2c57abd8395dc6040c07e55a14ce538 [file] [edit]
// 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 type {Store} from '../../base/store';
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,
stateToPath,
stateToSubpage,
subpageToState,
} from './nav_state';
import type {OverviewData} from './types';
import type {FlamegraphState} from '../../widgets/flamegraph';
import type {HdeState} from './persisted_state';
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;
}
// A flamegraph drill-down tab: identity plus the title's object count (null
// until fetched).
export interface FlamegraphTabView {
readonly pathHashes: string;
readonly isDominator: boolean;
readonly count: number | null;
}
const INSTANCE_LABEL_MAX = 30;
function truncateInstanceLabel(label: string): string {
return label.length > INSTANCE_LABEL_MAX
? label.slice(0, INSTANCE_LABEL_MAX) + '…'
: label;
}
// Count-cache key for a flamegraph tab.
function countKey(pathHashes: string, isDominator: boolean): string {
return `${isDominator ? 'd' : 'n'}:${pathHashes}`;
}
// The persistent state lives in the mountStore'd `store`, which the core
// serializes into permalinks and restores before the plugin loads. The session
// is a thin controller over it: mutations are store edits, views render from
// the store, restoration is automatic. Non-serializable trace-derived data (the
// dumps, overview, per-tab counts) is cached here instead.
export class HeapDumpExplorerSession {
private _navigateCallback?: (subpage: string) => void;
private _dumps: ReadonlyArray<queries.HeapDump> = [];
private _overview: OverviewData | null = null;
private readonly _counts = new Map<string, number>();
// Set when the plugin auto-redirected to HDE on load; gates the
// "default view changed" hint on the overview.
autoNavigated = false;
constructor(
readonly trace: Trace,
readonly engine: Engine,
readonly hideDefaultChangedHint: Setting<boolean>,
private readonly store: Store<HdeState>,
) {}
get dumps(): ReadonlyArray<queries.HeapDump> {
return this._dumps;
}
get activeDump(): queries.HeapDump | null {
const ref = this.store.state.activeDump;
if (ref === undefined) return null;
return (
this._dumps.find((d) => d.upid === ref.upid && d.ts === BigInt(ref.ts)) ??
null
);
}
// Loads the dumps and reconciles them with the stored active dump. Returns
// true if a valid permalink was restored, otherwise resets to the first dump.
async loadDumps(): Promise<boolean> {
this._dumps = await queries.loadDumpsList(this.engine);
const ref = this.store.state.activeDump;
const restored =
ref !== undefined &&
this._dumps.some((d) => d.upid === ref.upid && d.ts === BigInt(ref.ts));
if (restored) {
for (const t of this.store.state.flamegraphTabs ?? []) {
this.loadCount(t.pathHashes, t.isDominator);
}
} else {
const first = this._dumps.length > 0 ? this._dumps[0] : undefined;
this.store.edit((s) => {
s.activeDump =
first === undefined
? undefined
: {upid: first.upid, ts: first.ts.toString()};
s.nav = undefined;
s.flamegraphTabs = undefined;
s.instanceTabs = undefined;
s.flamegraphPanelState = undefined;
s.callstackPanelState = undefined;
});
}
return restored;
}
selectDump(d: queries.HeapDump): void {
if (this.activeDump === d) return;
this.switchToDump(d);
const view = this.nav.view;
if (view === 'object' || view === 'flamegraph-objects') {
this.navigate('overview');
}
m.redraw();
}
private switchToDump(d: queries.HeapDump): void {
this._overview = null;
this._counts.clear();
this.store.edit((s) => {
s.activeDump = {upid: d.upid, ts: d.ts.toString()};
s.flamegraphTabs = undefined;
s.instanceTabs = undefined;
s.flamegraphPanelState = undefined;
s.callstackPanelState = undefined;
});
void this.loadOverview();
}
get nav(): NavState {
return subpageToState(this.store.state.nav);
}
// The current nav as a route path (no query params).
get navPath(): string {
return stateToPath(this.nav);
}
setNavigateCallback(cb: ((subpage: string) => void) | undefined): void {
this._navigateCallback = cb;
}
navigate(view: NavView, params: Record<string, unknown> = {}): void {
const sub = stateToSubpage({view, params} as NavState);
this.store.edit((s) => {
s.nav = sub;
});
this._navigateCallback?.(sub);
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);
};
readonly clearNavParam = (key: string): void => {
// A consumed nav param (e.g. ?cls=Foo) becomes a one-shot grid filter, so
// drop it from the nav. Otherwise it would re-apply on restore and clobber
// the user's later manual filter edits to the same grid.
this.store.edit((s) => {
const nav = subpageToState(s.nav);
delete (nav.params as Record<string, unknown>)[key];
s.nav = stateToSubpage(nav);
});
};
// Mirrors URL-driven nav (back/forward, address bar) into the store, on path
// change only. The router drops query params, so only the path round-trips.
syncFromSubpage(subpage: string | undefined): void {
const sub = subpage?.startsWith('/') ? subpage.slice(1) : subpage;
const incomingPath = (sub ?? '').split('?')[0];
if (incomingPath !== this.navPath) {
this.store.edit((s) => {
s.nav = stateToSubpage(subpageToState(sub));
});
}
}
get flamegraphTabs(): ReadonlyArray<FlamegraphTabView> {
return (this.store.state.flamegraphTabs ?? []).map((t) => ({
pathHashes: t.pathHashes,
isDominator: t.isDominator,
count: this._counts.get(countKey(t.pathHashes, t.isDominator)) ?? null,
}));
}
// The active flamegraph tab, derived from the nav (not stored), or null.
get activeFlamegraph(): {pathHashes: string; isDominator: boolean} | null {
const nav = this.nav;
if (
nav.view !== 'flamegraph-objects' ||
nav.params.pathHashes === undefined
) {
return null;
}
return {
pathHashes: nav.params.pathHashes,
isDominator: nav.params.isDominator ?? false,
};
}
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);
}
this.openFlamegraphTab(sel.pathHashes, sel.isDominator);
this.navigate('flamegraph-objects', {
pathHashes: sel.pathHashes,
isDominator: sel.isDominator,
});
}
// Adds the flamegraph tab for a selection in the active dump if not open.
private openFlamegraphTab(pathHashes: string, isDominator: boolean): void {
if (this.activeDump === null) return;
const tabs = this.store.state.flamegraphTabs ?? [];
if (
tabs.some(
(t) => t.pathHashes === pathHashes && t.isDominator === isDominator,
)
) {
return;
}
this.store.edit((s) => {
s.flamegraphTabs = [
...(s.flamegraphTabs ?? []),
{pathHashes, isDominator},
];
});
this.loadCount(pathHashes, isDominator);
}
closeFlamegraph(pathHashes: string, isDominator: boolean): void {
const active = this.activeFlamegraph;
this.store.edit((s) => {
s.flamegraphTabs = (s.flamegraphTabs ?? []).filter(
(t) => !(t.pathHashes === pathHashes && t.isDominator === isDominator),
);
});
if (
active !== null &&
active.pathHashes === pathHashes &&
active.isDominator === isDominator
) {
this.navigate('overview');
}
}
syncFlamegraphTabFromNav(): void {
const active = this.activeFlamegraph;
if (active === null) return;
const tabs = this.store.state.flamegraphTabs ?? [];
if (
!tabs.some(
(t) =>
t.pathHashes === active.pathHashes &&
t.isDominator === active.isDominator,
)
) {
this.openFlamegraphTab(active.pathHashes, active.isDominator);
}
}
// Fetches the title's object count for one flamegraph tab. No-op if cached.
private loadCount(pathHashes: string, isDominator: boolean): void {
const key = countKey(pathHashes, isDominator);
if (this._counts.has(key)) return;
const q = flamegraphQuery(pathHashes, isDominator);
this.engine
.query(`${SQL_PREAMBLE}; SELECT COUNT(*) AS c FROM (${q})`)
.then((r) => {
this._counts.set(key, r.firstRow({c: NUM}).c);
m.redraw();
})
.catch(console.error);
}
get instanceTabs(): ReadonlyArray<{objId: number; label: string}> {
return this.store.state.instanceTabs ?? [];
}
// The active object's id, derived from the nav (not stored), or null.
get activeInstanceObjId(): number | null {
const nav = this.nav;
return nav.view === 'object' ? nav.params.id : null;
}
private openInstanceTab(objId: number, label?: string): void {
const tabs = this.store.state.instanceTabs ?? [];
if (tabs.some((t) => t.objId === objId)) return;
this.store.edit((s) => {
s.instanceTabs = [
...(s.instanceTabs ?? []),
{objId, label: truncateInstanceLabel(label ?? 'Instance')},
];
});
}
closeInstanceTab(objId: number): void {
const wasActive = this.activeInstanceObjId === objId;
this.store.edit((s) => {
s.instanceTabs = (s.instanceTabs ?? []).filter((t) => t.objId !== objId);
});
if (wasActive) this.navigate('overview');
}
syncInstanceTabFromNav(): void {
const nav = this.nav;
if (nav.view !== 'object') return;
const {id, label} = nav.params;
const tabs = this.store.state.instanceTabs ?? [];
if (!tabs.some((t) => t.objId === id)) this.openInstanceTab(id, label);
}
get flamegraphPanelState(): FlamegraphState | undefined {
return this.store.state.flamegraphPanelState;
}
readonly setFlamegraphPanelState = (state: FlamegraphState): void => {
this.store.edit((s) => {
s.flamegraphPanelState = state;
});
};
get callstackPanelState(): FlamegraphState | undefined {
return this.store.state.callstackPanelState;
}
readonly setCallstackPanelState = (state: FlamegraphState): void => {
this.store.edit((s) => {
s.callstackPanelState = state;
});
};
// Open the flamegraph pivoted at `pathHash`. The metric matches the tree the
// hash came from. The chip shows `<label> (this instance)` since the raw hash
// regex is unreadable.
readonly openFlamegraphPivotedAt = (
pathHash: string,
label: string,
isDominator: boolean,
): void => {
this.setFlamegraphPanelState({
selectedMetricName: isDominator
? METRIC_DOMINATED_OBJECT_SIZE
: METRIC_OBJECT_SIZE,
filters: [],
view: {
kind: 'PIVOT',
pivot: `^${pathHash}$`,
displayLabel: `${label} (this instance)`,
},
});
this.navigate('flamegraph');
};
get cachedOverview(): OverviewData | null {
return this._overview;
}
// Pins the dump at fetch start; if the user switches dumps before the result
// arrives, the result is dropped instead of briefly showing the wrong dump.
async loadOverview(): Promise<void> {
if (this._overview !== null) return;
const dump = this.activeDump;
if (dump === null) return;
try {
const data = await queries.getOverview(this.engine, dump);
if (this.activeDump === dump) {
this._overview = data;
}
} catch (err) {
console.error('Failed to load overview:', err);
} finally {
m.redraw();
}
}
}