blob: 90d8fec0f162a39833d4f0b0e0fcac401b80dfb2 [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 Aggregator,
type AggregatorGridConfig,
createAggregationData,
} from '../../components/aggregation_adapter';
import {formatPercentValue} from '../../components/aggregation_panel';
import {titleWithHelp} from '../../components/distribution_panel';
import {DurationWidget} from '../../components/widgets/duration';
import {Timestamp} from '../../components/widgets/timestamp';
import {renderCell} from '../../components/widgets/datagrid/datagrid';
import type {
CellRenderer,
ColumnDef,
} from '../../components/widgets/datagrid/datagrid_schema';
import {Icons} from '../../base/semantic_icons';
import {Duration, Time} from '../../base/time';
import type {AreaSelection} from '../../public/selection';
import type {Trace} from '../../public/trace';
import type {Engine} from '../../trace_processor/engine';
import {createPerfettoTable} from '../../trace_processor/sql_utils';
import {sqliteString} from '../../base/string_utils';
import {Anchor} from '../../widgets/anchor';
/** Track tag `type` of the per-process proc-state timelines. */
export const TAG_PROCESS_TRACK = 'android_process_state';
/** Track tag `type` of the per-state concurrency counters. */
export const TAG_COUNT_TRACK = 'android_process_state_count';
/** Track URI for a single process's proc-state timeline. */
export function processTrackUri(upid: number): string {
return `com.android.ProcessState#process.${upid}`;
}
// Which slice of the interval table the selection refers to. Selecting
// per-process tracks scopes by process; selecting the concurrency counters
// scopes by state, which is what makes "drag over the CACHED_EMPTY counter and
// see who moved" work.
interface Scope {
readonly upids: ReadonlyArray<number>;
readonly states: ReadonlyArray<string>;
}
function probeScope(area: AreaSelection): Scope | undefined {
const upids = new Set<number>();
const states = new Set<string>();
for (const track of area.tracks) {
const tags = track.tags;
if (tags?.type === TAG_PROCESS_TRACK && tags.upid !== undefined) {
upids.add(tags.upid);
} else if (tags?.type === TAG_COUNT_TRACK) {
if (typeof tags.state === 'string') {
states.add(tags.state);
}
}
}
if (upids.size === 0 && states.size === 0) {
return undefined;
}
return {upids: [...upids], states: [...states]};
}
// Restricts the interval table to the selected tracks and the selected time
// range. Intervals are clipped to the selection so partially covered ones are
// attributed proportionally.
function scopedIntervals(scope: Scope, area: AreaSelection): string {
const terms: string[] = [];
if (scope.upids.length > 0) {
terms.push(`upid IN (${scope.upids.join(',')})`);
}
if (scope.states.length > 0) {
terms.push(`state IN (${scope.states.map(sqliteString).join(',')})`);
}
return `
SELECT
*,
min(iif(dur < 0, ${area.end}, ts + dur), ${area.end}) - max(ts, ${area.start}) AS clipped_dur
FROM _android_process_state_intervals
WHERE (${terms.join(' OR ')})
AND ts < ${area.end}
AND (dur < 0 OR ts + dur > ${area.start})
`;
}
// Restricts the interval table to transitions occurring within the selected
// time range on the selected tracks. For state-counter tracks, includes both
// transitions into and out of the selected states.
function scopedTransitions(scope: Scope, area: AreaSelection): string {
const terms: string[] = [];
if (scope.upids.length > 0) {
terms.push(`upid IN (${scope.upids.join(',')})`);
}
if (scope.states.length > 0) {
const stateList = scope.states.map(sqliteString).join(',');
terms.push(`(state IN (${stateList}) OR prev_state IN (${stateList}))`);
}
return `
SELECT *
FROM _android_process_state_intervals
WHERE (${terms.join(' OR ')})
AND ts >= ${area.start}
AND ts < ${area.end}
`;
}
// Durations follow the user's time format and precision settings.
export function durationCellRenderer(trace: Trace): CellRenderer {
return (value) => {
// Averaging aggregates come back as floats.
const dur = typeof value === 'number' ? BigInt(Math.round(value)) : value;
if (typeof dur !== 'bigint') {
return renderCell(value);
}
return m(DurationWidget, {trace, dur: Duration.fromRaw(dur)});
};
}
function durationColumn(
trace: Trace,
title: string,
helpText?: string,
): ColumnDef {
return {
title: helpText !== undefined ? titleWithHelp(title, helpText) : title,
titleString: helpText !== undefined ? title : undefined,
columnType: 'quantitative',
cellRenderer: durationCellRenderer(trace),
};
}
function timestampColumn(trace: Trace, title: string): ColumnDef {
return {
title,
columnType: 'quantitative',
cellRenderer: (value) => {
if (typeof value === 'bigint') {
return m(Timestamp, {trace, ts: Time.fromRaw(value)});
}
return renderCell(value);
},
};
}
function sliceIdColumn(trace: Trace): ColumnDef {
return {
title: 'Slice ID',
columnType: 'identifier',
cellRenderer: (value) => {
if (typeof value !== 'string') {
return renderCell(value);
}
const {id, upid} = JSON.parse(value) as {id: number; upid: number};
return m(
Anchor,
{
title: 'Go to process state slice',
icon: Icons.UpdateSelection,
onclick: () => {
trace.selection.selectTrackEvent(processTrackUri(upid), id, {
scrollToSelection: true,
switchToCurrentSelectionTab: false,
});
},
},
String(id),
);
},
cellFormatter: (value) => {
if (typeof value === 'string') {
return String((JSON.parse(value) as {id: number}).id);
}
return String(value ?? '');
},
};
}
/** How long each process spent in each proc state over the selection. */
export class ProcessStateResidencyAggregator implements Aggregator {
readonly id = 'android_process_state_residency';
constructor(private readonly trace: Trace) {}
probe(area: AreaSelection) {
const scope = probeScope(area);
if (scope === undefined) {
return undefined;
}
return {
getGridConfig: () => this.getGridConfig(),
prepareData: async (engine: Engine) => {
const selectionDur = area.end - area.start;
const table = await createPerfettoTable({
engine,
as: `
WITH scoped AS (${scopedIntervals(scope, area)})
SELECT
process_name,
pid,
state,
sum(clipped_dur) AS total_dur,
sum(clipped_dur) / ${selectionDur}.0 AS occupancy,
count() AS occurrences
FROM scoped
GROUP BY upid, state
`,
});
return createAggregationData(table);
},
};
}
private getGridConfig(): AggregatorGridConfig {
return {
schema: {
process_name: {title: 'Process', columnType: 'text'},
pid: {title: 'PID', columnType: 'identifier'},
state: {title: 'State', columnType: 'text'},
total_dur: durationColumn(this.trace, 'Time in state'),
occupancy: {
title: '% of selection',
columnType: 'quantitative',
cellRenderer: formatPercentValue,
},
occurrences: {title: 'Occurrences', columnType: 'quantitative'},
},
initialColumns: [
{id: 'process_name', field: 'process_name'},
{id: 'pid', field: 'pid'},
{id: 'state', field: 'state'},
{id: 'total_dur', field: 'total_dur', aggregate: 'SUM', sort: 'DESC'},
{id: 'occupancy', field: 'occupancy'},
{id: 'occurrences', field: 'occurrences', aggregate: 'SUM'},
],
};
}
getTabName() {
return 'Process State Residency';
}
}
/** Every live state change in the selection, as a table. */
export class ProcessStateTransitionsAggregator implements Aggregator {
readonly id = 'android_process_state_transitions';
constructor(private readonly trace: Trace) {}
probe(area: AreaSelection) {
const scope = probeScope(area);
if (scope === undefined) {
return undefined;
}
return {
getGridConfig: () => this.getGridConfig(),
prepareData: async (engine: Engine) => {
const table = await createPerfettoTable({
engine,
as: `
WITH scoped AS (${scopedTransitions(scope, area)})
SELECT
ts,
json_object('id', id, 'upid', upid) AS slice_id,
process_name,
pid,
prev_state,
prev_state_duration AS prev_dur,
state AS cur_state,
iif(dur < 0, trace_end() - ts, dur) AS cur_dur,
state_change_reason
FROM scoped
WHERE state != 'NONEXISTENT' AND state != 'EXITED'
`,
});
return createAggregationData(table);
},
};
}
private getGridConfig(): AggregatorGridConfig {
return {
schema: {
ts: timestampColumn(this.trace, 'Transition ts'),
slice_id: sliceIdColumn(this.trace),
process_name: {title: 'Process', columnType: 'text'},
pid: {title: 'PID', columnType: 'identifier'},
prev_state: {title: 'Previous state', columnType: 'text'},
prev_dur: durationColumn(
this.trace,
'Time in previous state',
'Total elapsed duration of the previous state interval, unclipped by the selection window.',
),
cur_state: {title: 'Current state', columnType: 'text'},
cur_dur: durationColumn(
this.trace,
'Time in current state',
'Total elapsed duration of the current state interval, unclipped by the selection window.',
),
state_change_reason: {title: 'State change reason', columnType: 'text'},
},
initialColumns: [
{id: 'ts', field: 'ts', sort: 'ASC'},
{id: 'slice_id', field: 'slice_id'},
{id: 'process_name', field: 'process_name'},
{id: 'pid', field: 'pid'},
{id: 'prev_state', field: 'prev_state'},
{id: 'prev_dur', field: 'prev_dur'},
{id: 'cur_state', field: 'cur_state'},
{id: 'cur_dur', field: 'cur_dur'},
{id: 'state_change_reason', field: 'state_change_reason'},
],
};
}
getTabName() {
return 'Process State Transitions';
}
}
/** Process starts in the selection, with hosting and delay metadata. */
export class ProcessStartsAggregator implements Aggregator {
readonly id = 'android_process_starts';
constructor(private readonly trace: Trace) {}
probe(area: AreaSelection) {
const scope = probeScope(area);
if (scope === undefined) {
return undefined;
}
return {
getGridConfig: () => this.getGridConfig(),
prepareData: async (engine: Engine) => {
const table = await createPerfettoTable({
engine,
as: `
WITH scoped AS (${scopedTransitions(scope, area)})
SELECT
ts,
json_object('id', id, 'upid', upid) AS slice_id,
process_name,
pid,
prev_state,
hosting_type,
hosting_name,
trigger_type,
bind_application_delay_ms * 1000000 AS bind_application_delay,
process_start_delay_ms * 1000000 AS process_start_delay
FROM scoped
WHERE state = 'NONEXISTENT'
`,
});
return createAggregationData(table);
},
};
}
private getGridConfig(): AggregatorGridConfig {
return {
schema: {
ts: timestampColumn(this.trace, 'Start ts'),
slice_id: sliceIdColumn(this.trace),
process_name: {title: 'Process', columnType: 'text'},
pid: {title: 'PID', columnType: 'identifier'},
prev_state: {title: 'Previous state', columnType: 'text'},
hosting_type: {title: 'Hosting type', columnType: 'text'},
hosting_name: {title: 'Hosting name', columnType: 'text'},
trigger_type: {title: 'Trigger type', columnType: 'text'},
bind_application_delay: durationColumn(
this.trace,
'Bind application delay',
),
process_start_delay: durationColumn(this.trace, 'Process start delay'),
},
initialColumns: [
{id: 'ts', field: 'ts', sort: 'ASC'},
{id: 'slice_id', field: 'slice_id'},
{id: 'process_name', field: 'process_name'},
{id: 'pid', field: 'pid'},
{id: 'prev_state', field: 'prev_state'},
{id: 'hosting_type', field: 'hosting_type'},
{id: 'hosting_name', field: 'hosting_name'},
{id: 'trigger_type', field: 'trigger_type'},
{id: 'bind_application_delay', field: 'bind_application_delay'},
{id: 'process_start_delay', field: 'process_start_delay'},
],
};
}
getTabName() {
return 'Process Starts';
}
}
/** Process exits in the selection, with previous state and exit reasons. */
export class ProcessExitsAggregator implements Aggregator {
readonly id = 'android_process_exits';
constructor(private readonly trace: Trace) {}
probe(area: AreaSelection) {
const scope = probeScope(area);
if (scope === undefined) {
return undefined;
}
return {
getGridConfig: () => this.getGridConfig(),
prepareData: async (engine: Engine) => {
const table = await createPerfettoTable({
engine,
as: `
WITH scoped AS (${scopedTransitions(scope, area)})
SELECT
ts,
json_object('id', id, 'upid', upid) AS slice_id,
process_name,
pid,
prev_state,
prev_state_duration AS prev_dur,
exit_reason,
exit_subreason
FROM scoped
WHERE state = 'EXITED'
`,
});
return createAggregationData(table);
},
};
}
private getGridConfig(): AggregatorGridConfig {
return {
schema: {
ts: timestampColumn(this.trace, 'Exit ts'),
slice_id: sliceIdColumn(this.trace),
process_name: {title: 'Process', columnType: 'text'},
pid: {title: 'PID', columnType: 'identifier'},
prev_state: {title: 'Previous state', columnType: 'text'},
prev_dur: durationColumn(
this.trace,
'Time in previous state',
'Total elapsed duration of the previous state interval, unclipped by the selection window.',
),
exit_reason: {title: 'Exit reason', columnType: 'text'},
exit_subreason: {title: 'Exit subreason', columnType: 'text'},
},
initialColumns: [
{id: 'ts', field: 'ts', sort: 'ASC'},
{id: 'slice_id', field: 'slice_id'},
{id: 'process_name', field: 'process_name'},
{id: 'pid', field: 'pid'},
{id: 'prev_state', field: 'prev_state'},
{id: 'prev_dur', field: 'prev_dur'},
{id: 'exit_reason', field: 'exit_reason'},
{id: 'exit_subreason', field: 'exit_subreason'},
],
};
}
getTabName() {
return 'Process Exits';
}
}