blob: 221a10daf4e2ef5fe99e0c8fab647795dbf6511b [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 {QueryResult} from '../../base/query_slot';
import {addDebugSliceTrack} from '../../components/tracks/debug_tracks';
import {PerfettoPlugin} from '../../public/plugin';
import {Selection} from '../../public/selection';
import {Trace} from '../../public/trace';
import {NUM_NULL} from '../../trace_processor/query_result';
import {
AndroidLockContentionEventSource,
LockContentionDetails,
} from './android_lock_contention_event_source';
import {AndroidLockContentionTab} from './tab';
export default class AndroidLockContentionPlugin implements PerfettoPlugin {
static readonly id = 'com.android.AndroidLockContention';
static readonly description =
'Visualise lock contention events in the trace. Activate by running the command ' +
"'Android Lock Contention: Go Forward' or using Ctrl+]. You can then navigate " +
'between contention events using Ctrl+] and Ctrl+[';
private nav = new LockContentionNavigation();
private activeContentionId?: number;
/**
* Navigates to the source of a monitor contention.
* Jumps to the parent slice in the chain if available, otherwise
* shifts focus to the track of the blocking thread.
*/
private async contextualJump(trace: Trace) {
const selection = trace.selection.selection;
if (selection.kind !== 'track_event') return;
const queryRes = await trace.engine.query(`
SELECT
parent_id,
(SELECT id FROM thread_track WHERE utid = blocking_utid) as track_id
FROM android_monitor_contention_chain
WHERE id = ${selection.eventId}
`);
if (queryRes.numRows() === 0) return;
const row = queryRes.firstRow({parent_id: NUM_NULL, track_id: NUM_NULL});
if (row.parent_id !== null) {
this.nav.push(selection);
trace.selection.selectSqlEvent('slice', row.parent_id, {
scrollToSelection: true,
switchToCurrentSelectionTab: false,
});
this.nav.push({
kind: 'track_event',
trackUri: 'unknown',
eventId: row.parent_id,
} as Selection);
} else if (row.track_id !== null) {
const track = trace.tracks.findTrack((t) =>
t.tags?.trackIds?.includes(row.track_id as number),
);
const trackUri = track?.uri || '/slice_' + row.track_id;
this.nav.push(selection);
trace.selection.selectTrack(trackUri, {
scrollToSelection: true,
switchToCurrentSelectionTab: false,
});
this.nav.push({kind: 'track', trackUri} as Selection);
}
}
private useDetailsState(
trace: Trace,
source: AndroidLockContentionEventSource,
): QueryResult<LockContentionDetails | null> {
const selection = trace.selection.selection;
if (this.nav.has(selection) && this.activeContentionId !== undefined) {
return source.use(this.activeContentionId);
}
if (selection.kind !== 'track_event') {
return {data: null, isPending: false, isFresh: true};
}
this.activeContentionId = selection.eventId;
return source.use(selection.eventId);
}
async onTraceLoad(trace: Trace): Promise<void> {
await trace.engine.query(
'INCLUDE PERFETTO MODULE android.monitor_contention;',
);
const source = new AndroidLockContentionEventSource(trace);
trace.tabs.registerTab({
uri: 'com.android.AndroidLockContentionTab',
isEphemeral: false,
content: {
getTitle: () => 'Lock Contention Analysis',
render: () => {
const {data: row, isPending} = this.useDetailsState(trace, source);
const goToSlice = (id: number) => {
this.nav.push(trace.selection.selection);
trace.selection.selectSqlEvent('slice', id, {
scrollToSelection: true,
switchToCurrentSelectionTab: false,
});
this.nav.push({
kind: 'track_event',
trackUri: 'unknown',
eventId: id,
} as Selection);
};
const goToTrack = (uri: string) => {
this.nav.push(trace.selection.selection);
trace.selection.selectTrack(uri, {
scrollToSelection: true,
switchToCurrentSelectionTab: false,
});
this.nav.push({kind: 'track', trackUri: uri} as Selection);
};
return m(AndroidLockContentionTab, {
trace,
row: row ?? null,
isPending,
goToSlice,
goToTrack,
});
},
},
});
trace.commands.registerCommand({
id: 'com.android.AndroidLockContention:GoBack',
name: 'Android Lock Contention: Go Back',
defaultHotkey: 'Ctrl+[',
callback: () => {
trace.tabs.showTab('com.android.AndroidLockContentionTab');
this.nav.goBack(trace);
},
});
trace.commands.registerCommand({
id: 'com.android.AndroidLockContention:GoForward',
name: 'Android Lock Contention: Go Forward',
defaultHotkey: 'Ctrl+]',
callback: async () => {
trace.tabs.showTab('com.android.AndroidLockContentionTab');
if (this.nav.canGoForward()) {
this.nav.goForward(trace);
} else {
await this.contextualJump(trace);
}
},
});
// Visualise "big" locks on a debug track
trace.commands.registerCommand({
id: 'com.android.visualiseHeldLocks',
name: 'Lock Contention: Visualise held locks',
callback: async () => {
await addDebugSliceTrack({
trace: trace,
data: {
sqlSource: `
WITH lock_held_slices AS (
SELECT ts, dur, lock_name, utid
FROM interval_merge_overlapping_partitioned!((
SELECT ts, dur, name AS lock_name, utid
FROM thread_slice
WHERE dur > 0 AND thread_slice.name GLOB '*_lock_held'
), (lock_name, utid))
)
SELECT
row_number() OVER () AS id,
name AS thread_name,
lock_name,
utid,
ts,
MIN(LEAD(ts) OVER(PARTITION BY lock_name ORDER BY ts), ts + dur) - ts AS dur
FROM lock_held_slices
JOIN thread USING (utid)
`,
},
title: 'Held Lock',
columns: {
name: 'thread_name',
},
pivotOn: 'lock_name',
});
},
});
}
}
class LockContentionNavigation {
private stack: Selection[] = [];
private index = -1;
push(selection: Selection) {
if (this.index >= 0 && selectionsEqual(this.stack[this.index], selection)) {
return;
}
this.stack.splice(this.index + 1);
this.stack.push(selection);
this.index++;
}
goBack(trace: Trace) {
if (this.index > 0) {
this.index--;
this.restore(trace, this.stack[this.index]);
}
}
goForward(trace: Trace) {
if (this.index < this.stack.length - 1) {
this.index++;
this.restore(trace, this.stack[this.index]);
}
}
canGoForward(): boolean {
return this.index < this.stack.length - 1;
}
private restore(trace: Trace, selection: Selection) {
if (selection.kind === 'track_event') {
if (selection.trackUri === 'unknown' || selection.trackUri === '') {
trace.selection.selectSqlEvent('slice', selection.eventId, {
scrollToSelection: true,
switchToCurrentSelectionTab: false,
});
} else {
trace.selection.selectTrackEvent(
selection.trackUri,
selection.eventId,
{
scrollToSelection: true,
switchToCurrentSelectionTab: false,
},
);
}
} else if (selection.kind === 'track') {
trace.selection.selectTrack(selection.trackUri, {
scrollToSelection: true,
switchToCurrentSelectionTab: false,
});
}
}
has(selection: Selection) {
return this.stack.some((s) => selectionsEqual(s, selection));
}
}
function selectionsEqual(a: Selection, b: Selection): boolean {
if (a.kind !== b.kind) return false;
if (a.kind === 'track_event' && b.kind === 'track_event') {
return a.eventId === b.eventId;
}
if (a.kind === 'track' && b.kind === 'track') {
return a.trackUri === b.trackUri;
}
return false;
}