blob: 0135ad24dcf2302bc74c66f5e0c93e8a61c613be [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 {Trace} from '../../public/trace';
import type {Row} from '../../trace_processor/query_result';
import {LONG, NUM, STR_NULL} from '../../trace_processor/query_result';
import {InMemoryDataSource} from '../../components/widgets/datagrid/in_memory_data_source';
import {type DiffStatus, type EdgeSel, sameEdge} from './process_graph';
// URI of the single snapshot timeline track. The slice id IS the snapshot id,
// so a snapshot id is a valid event id on this track — letting the explorer
// keep the timeline slice highlight in sync with the scrubber.
export const SNAPSHOT_TRACK_URI = '/process_state_snapshots';
// Process fields compared in diff mode; a change is shown in the field's own
// grid column as "old → new". (All already arrive as display strings.)
const DIFF_COLS = ['oom_score', 'proc_state', 'capabilities'];
export interface SnapshotInfo {
readonly id: number;
readonly ts: bigint;
// OomChangeReasonEnum name (already resolved by the importer); undefined for a
// one-shot dumpsys snapshot.
readonly reason?: string;
}
// Single source of truth for the ProcessState explorer: created once per trace
// and shared by the (one) details-panel surface. Holds all selection / view
// state and the loaded snapshot data; views are thin renderers over it and
// mutate only through its methods, which load lazily and redraw. No global
// state.
export class ProcessStateController {
readonly trace: Trace;
// ---- view state (the shared selection) ----
snapshots: ReadonlyArray<SnapshotInfo> = [];
snapshotId?: number;
selectedPid?: number;
selectedEdge?: EdgeSel;
diffOn = false;
baselineId?: number;
// When true (the default), the diff baseline follows the current snapshot's
// immediately-previous one as you scrub ("what did this event change"). When
// false, the baseline is pinned to a chosen snapshot (cumulative "since X").
baselineFollowsPrev = true;
tab: 'current' | 'procs' = 'procs';
// ---- loaded data for the current snapshot ----
processes: Row[] = [];
procCols: string[] = [];
// Union of current + baseline-only "removed" processes (== processes when not
// diffing); what the graph and the process list are drawn from.
graphProcesses: Row[] = [];
procDs?: InMemoryDataSource;
diffNodes = new Map<number, DiffStatus>();
// ---- selected-process detail ----
stateRows: Row[] = [];
stateDs?: InMemoryDataSource;
hostedSvc: Row[] = [];
hostedSvcCols: string[] = [];
hostedSvcDs?: InMemoryDataSource;
hostedProv: Row[] = [];
hostedProvCols: string[] = [];
hostedProvDs?: InMemoryDataSource;
outAll: Row[] = [];
outDs?: InMemoryDataSource;
inAll: Row[] = [];
inDs?: InMemoryDataSource;
selfAll: Row[] = [];
selfDs?: InMemoryDataSource;
// ---- selected-edge detail ----
edgeRows: Row[] = [];
edgeDs?: InMemoryDataSource;
edgeNames: Row[] = [];
edgeNamesDs?: InMemoryDataSource;
private loadToken = 0;
private snapshotsLoaded = false;
constructor(trace: Trace) {
this.trace = trace;
}
reasonOf(id: number | undefined): string | undefined {
return this.snapshots.find((s) => s.id === id)?.reason ?? undefined;
}
// Loads the snapshot list (once) and selects `id`, or the latest if absent.
async ensureLoaded(id?: number) {
if (!this.snapshotsLoaded) {
const q = await this.trace.engine.query(
`SELECT id, ts, reason FROM _ps_snapshot ORDER BY ts`,
);
const snaps: SnapshotInfo[] = [];
const it = q.iter({id: NUM, ts: LONG, reason: STR_NULL});
for (; it.valid(); it.next()) {
snaps.push({id: it.id, ts: it.ts, reason: it.reason ?? undefined});
}
this.snapshots = snaps;
this.snapshotsLoaded = true;
}
const want =
id ??
this.snapshotId ??
(this.snapshots.length > 0
? this.snapshots[this.snapshots.length - 1].id
: undefined);
if (want !== undefined && want !== this.snapshotId) {
await this.setSnapshot(want);
} else if (this.processes.length === 0 && want !== undefined) {
await this.setSnapshot(want);
}
}
// Move to a snapshot AND highlight its slice on the timeline, so the scrubber
// and the timeline selection stay in sync. setSnapshot updates snapshotId
// synchronously, so the selectTrackEvent below re-enters load()/ensureLoaded
// as a no-op (no reload loop).
goToSnapshot(id: number) {
this.setSnapshot(id).catch((e) => console.error('ProcessState', e));
this.trace.selection.selectTrackEvent(SNAPSHOT_TRACK_URI, id);
}
async setSnapshot(id: number) {
this.snapshotId = id;
this.selectedPid = undefined;
this.selectedEdge = undefined;
this.clearSelectedData();
const token = ++this.loadToken;
const q = await this.trace.engine.query(`
SELECT * FROM _ps_process WHERE snapshot_id = ${id} ORDER BY oom_score`);
if (token !== this.loadToken) return;
this.procCols = q.columns();
this.processes = this.rowsOf(q);
// In follow mode the baseline tracks the new snapshot's previous one; a
// pinned baseline stays put (but never equal to the current snapshot).
if (this.baselineFollowsPrev || this.baselineId === id) {
this.baselineId = this.prevSnapshotId(id);
}
await this.refreshDiff(token);
m.redraw();
}
prevSnapshotId(id: number): number | undefined {
const i = this.snapshots.findIndex((s) => s.id === id);
return i > 0 ? this.snapshots[i - 1].id : undefined;
}
// ---- selection ----
select(pid: number) {
if (this.selectedPid === pid && this.selectedEdge === undefined) {
this.selectedPid = undefined;
this.clearSelectedData();
this.tab = 'procs';
m.redraw();
return;
}
this.selectedPid = pid;
this.selectedEdge = undefined;
this.tab = 'current';
this.loadSelected(pid)
.then(() => m.redraw())
.catch((e) => console.error('ProcessState', e));
}
selectEdge(e: EdgeSel) {
if (this.selectedEdge !== undefined && sameEdge(this.selectedEdge, e)) {
this.selectedEdge = undefined;
this.tab = this.selectedPid !== undefined ? 'current' : 'procs';
m.redraw();
return;
}
this.selectedEdge = e;
this.buildEdge(e);
this.tab = 'current';
m.redraw();
}
deselect() {
this.selectedPid = undefined;
this.selectedEdge = undefined;
this.clearSelectedData();
this.tab = 'procs';
m.redraw();
}
setTab(tab: 'current' | 'procs') {
this.tab = tab;
m.redraw();
}
toggleDiff() {
this.diffOn = !this.diffOn;
// Default to the follow-previous view each time diff is (re)enabled.
if (
this.diffOn &&
this.baselineFollowsPrev &&
this.snapshotId !== undefined
) {
this.baselineId = this.prevSnapshotId(this.snapshotId);
}
this.refreshDiff(this.loadToken)
.then(() => m.redraw())
.catch((e) => console.error('ProcessState', e));
}
// Switch back to following the previous snapshot (the auto baseline).
followPrevBaseline() {
this.baselineFollowsPrev = true;
if (this.snapshotId !== undefined) {
this.baselineId = this.prevSnapshotId(this.snapshotId);
}
this.refreshDiff(this.loadToken)
.then(() => m.redraw())
.catch((e) => console.error('ProcessState', e));
}
// Pin the baseline to a specific snapshot (cumulative diff since that point).
setBaseline(id: number) {
this.baselineFollowsPrev = false;
this.baselineId = id;
this.refreshDiff(this.loadToken)
.then(() => m.redraw())
.catch((e) => console.error('ProcessState', e));
}
// ---- data loading ----
private clearSelectedData() {
this.stateRows = [];
this.hostedSvc = this.hostedProv = [];
this.outAll = this.inAll = this.selfAll = [];
this.stateDs = this.hostedSvcDs = this.hostedProvDs = undefined;
this.outDs = this.inDs = this.selfDs = undefined;
}
// Recomputes graphProcesses / diffNodes / the process-list rows for the
// current diff state.
private async refreshDiff(token: number) {
const id = this.snapshotId;
if (id === undefined) return;
if (!this.diffOn || this.baselineId === undefined) {
this.diffNodes = new Map();
this.graphProcesses = this.processes;
this.procDs = new InMemoryDataSource(this.processes);
return;
}
const bq = await this.trace.engine.query(
`SELECT * FROM _ps_process WHERE snapshot_id = ${this.baselineId}`,
);
if (token !== this.loadToken) return;
const base = this.rowsOf(bq);
const baseByPid = new Map(base.map((r) => [Number(r['pid']), r]));
const curByPid = new Map(this.processes.map((r) => [Number(r['pid']), r]));
const nodes = new Map<number, DiffStatus>();
// The process list shows the CURRENT snapshot; a changed field is rewritten
// in place to "old → new" (encoded in the cell's own value so it survives
// the DataGrid's column projection). Removed processes aren't in the current
// snapshot, so they appear only as ghosts in the graph, not the list.
const listRows: Row[] = [];
for (const r of this.processes) {
const pid = Number(r['pid']);
const b = baseByPid.get(pid);
if (b === undefined) {
nodes.set(pid, 'added');
listRows.push({...r});
continue;
}
const out: Row = {...r};
let changed = false;
for (const col of DIFF_COLS) {
if (String(b[col] ?? '') !== String(r[col] ?? '')) {
out[col] = `${fmt(b[col])} → ${fmt(r[col])}`;
changed = true;
}
}
if (changed) nodes.set(pid, 'changed');
listRows.push(out);
}
const removed: Row[] = [];
for (const r of base) {
if (!curByPid.has(Number(r['pid']))) {
nodes.set(Number(r['pid']), 'removed');
removed.push(r);
}
}
this.diffNodes = nodes;
this.graphProcesses = [...this.processes, ...removed];
this.procDs = new InMemoryDataSource(listRows);
}
private async loadSelected(pid: number) {
const id = this.snapshotId;
if (id === undefined) return;
const token = this.loadToken;
const outQ = await this.trace.engine.query(`
SELECT s.owning_pid AS server_pid, s.name AS service,
MAX(b.foreground) AS fg, COUNT(*) AS n
FROM _ps_service_binding b
LEFT JOIN _ps_service s
ON s.snapshot_id = b.snapshot_id AND s.service_id = b.service_id
WHERE b.snapshot_id = ${id} AND b.client_pid = ${pid}
AND s.owning_pid != ${pid}
GROUP BY s.owning_pid, b.service_id`);
const provOutQ = await this.trace.engine.query(`
SELECT p.owning_pid AS server_pid, p.authority, COUNT(*) AS n
FROM _ps_provider_binding pb
JOIN _ps_provider p
ON p.snapshot_id = pb.snapshot_id AND p.provider_id = pb.provider_id
WHERE pb.snapshot_id = ${id} AND pb.client_pid = ${pid}
AND p.owning_pid != ${pid}
GROUP BY p.owning_pid, pb.provider_id`);
const inQ = await this.trace.engine.query(`
SELECT b.client_pid, s.name AS service,
MAX(b.foreground) AS fg, COUNT(*) AS n
FROM _ps_service_binding b
JOIN _ps_service s
ON s.snapshot_id = b.snapshot_id AND s.service_id = b.service_id
WHERE b.snapshot_id = ${id} AND s.owning_pid = ${pid}
AND b.client_pid != ${pid}
GROUP BY b.client_pid, b.service_id`);
const provInQ = await this.trace.engine.query(`
SELECT pb.client_pid, p.authority, COUNT(*) AS n
FROM _ps_provider_binding pb
JOIN _ps_provider p
ON p.snapshot_id = pb.snapshot_id AND p.provider_id = pb.provider_id
WHERE pb.snapshot_id = ${id} AND p.owning_pid = ${pid}
AND pb.client_pid != ${pid}
GROUP BY pb.client_pid, pb.provider_id`);
const selfSvcQ = await this.trace.engine.query(`
SELECT s.name AS service, MAX(b.foreground) AS fg, COUNT(*) AS n
FROM _ps_service_binding b
JOIN _ps_service s
ON s.snapshot_id = b.snapshot_id AND s.service_id = b.service_id
WHERE b.snapshot_id = ${id} AND b.client_pid = ${pid}
AND s.owning_pid = ${pid}
GROUP BY b.service_id`);
const selfProvQ = await this.trace.engine.query(`
SELECT p.authority, COUNT(*) AS n
FROM _ps_provider_binding pb
JOIN _ps_provider p
ON p.snapshot_id = pb.snapshot_id AND p.provider_id = pb.provider_id
WHERE pb.snapshot_id = ${id} AND pb.client_pid = ${pid}
AND p.owning_pid = ${pid}
GROUP BY pb.provider_id`);
const hsvcQ = await this.trace.engine.query(
`SELECT * FROM _ps_service WHERE snapshot_id = ${id} AND owning_pid = ${pid}`,
);
const hprovQ = await this.trace.engine.query(
`SELECT * FROM _ps_provider WHERE snapshot_id = ${id} AND owning_pid = ${pid}`,
);
if (token !== this.loadToken) return;
this.outAll = [
...this.bindRows(outQ, 'server_pid', 'service', true),
...this.bindRows(provOutQ, 'server_pid', 'authority', false),
];
this.inAll = [
...this.bindRows(inQ, 'client_pid', 'service', true),
...this.bindRows(provInQ, 'client_pid', 'authority', false),
];
this.selfAll = [
...this.selfRows(selfSvcQ, 'service', true),
...this.selfRows(selfProvQ, 'authority', false),
];
this.hostedSvc = this.rowsOf(hsvcQ);
this.hostedSvcCols = hsvcQ.columns();
this.hostedProv = this.rowsOf(hprovQ);
this.hostedProvCols = hprovQ.columns();
this.buildStateCard(pid);
this.outDs = new InMemoryDataSource(this.outAll);
this.inDs = new InMemoryDataSource(this.inAll);
this.selfDs = new InMemoryDataSource(this.selfAll);
this.hostedSvcDs = new InMemoryDataSource(this.hostedSvc);
this.hostedProvDs = new InMemoryDataSource(this.hostedProv);
}
// Map a binding/provider summary query to {pid,kind,name,fg,n} grid rows.
private bindRows(
q: ReturnType<Trace['engine']['query']> extends Promise<infer R>
? R
: never,
pidCol: string,
nameCol: string,
isSvc: boolean,
): Row[] {
return this.rowsOf(q).map((b) => ({
pid: Number(b[pidCol] ?? 0),
kind: isSvc ? 'service' : 'provider',
name: String(b[nameCol] ?? ''),
fg: isSvc && Number(b['fg']) ? 'fg' : '',
n: Number(b['n'] ?? 1),
}));
}
private selfRows(
q: ReturnType<Trace['engine']['query']> extends Promise<infer R>
? R
: never,
nameCol: string,
isSvc: boolean,
): Row[] {
return this.rowsOf(q).map((b) => ({
kind: isSvc ? 'service' : 'provider',
name: String(b[nameCol] ?? ''),
fg: isSvc && Number(b['fg']) ? 'fg' : '',
n: Number(b['n'] ?? 1),
}));
}
private buildStateCard(pid: number) {
const p = this.graphProcesses.find((r) => Number(r['pid']) === pid);
// Empty cells show the SQL value NULL (not "—"/"none"/"no") so a missing
// value is visibly distinct from a real 0/false.
const v = (k: string) =>
!p || p[k] === null || p[k] === undefined ? 'NULL' : String(p[k]);
const persistent =
!p || p['persistent'] === null || p['persistent'] === undefined
? 'NULL'
: Number(p['persistent'])
? 'yes'
: 'no';
this.stateRows = [
{property: 'oom adj', value: v('oom_score')},
{property: 'proc state', value: v('proc_state')},
{property: 'capabilities', value: v('capabilities')},
{property: 'persistent', value: persistent},
];
this.stateDs = new InMemoryDataSource(this.stateRows);
}
private buildEdge(e: EdgeSel) {
this.edgeRows = [
{
client_pid: e.from,
host_pid: e.to,
connections: e.count,
foreground: e.fg ? 'yes' : 'no',
},
];
const col = e.kind === 'provider' ? 'authority' : 'service';
this.edgeNames = (e.names ? e.names.split(',') : []).map((n) => ({
[col]: n,
}));
this.edgeDs = new InMemoryDataSource(this.edgeRows);
this.edgeNamesDs = new InMemoryDataSource(this.edgeNames);
}
nameOf(pid: number): string {
const r = this.graphProcesses.find((p) => Number(p['pid']) === pid);
return r ? String(r['name'] ?? pid).replace(/^.*\//, '') : String(pid);
}
private rowsOf(
q: ReturnType<Trace['engine']['query']> extends Promise<infer R>
? R
: never,
): Row[] {
const cols = q.columns();
const it = q.iter({});
const rows: Row[] = [];
for (; it.valid(); it.next()) {
const r: Row = {};
for (const c of cols) r[c] = it.get(c);
rows.push(r);
}
return rows;
}
}
// "old → new" cell formatting helper for diffed columns (all display strings).
function fmt(v: unknown): string {
return v === null || v === undefined ? '' : String(v);
}