diff --git a/ui/src/plugins/com.android.AndroidLockContention/OWNERS b/ui/src/plugins/com.android.AndroidLockContention/OWNERS new file mode 100644 index 0000000000..9ffa38e0fb --- /dev/null +++ b/ui/src/plugins/com.android.AndroidLockContention/OWNERS @@ -0,0 +1 @@ +ivankc@google.com \ No newline at end of file diff --git a/ui/src/plugins/com.android.AndroidLockContention/android_lock_contention_event_source.ts b/ui/src/plugins/com.android.AndroidLockContention/android_lock_contention_event_source.ts new file mode 100644 index 0000000000..59cd9ae5d8 --- /dev/null +++ b/ui/src/plugins/com.android.AndroidLockContention/android_lock_contention_event_source.ts @@ -0,0 +1,115 @@ +// 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 {Trace} from '../../public/trace'; +import { + EventSource, + RelatedEvent, + RelatedEventData, + getTrackUriForTrackId, +} from '../dev.perfetto.RelatedEvents'; +import {time, duration} from '../../base/time'; +import {STR, NUM_NULL, LONG_NULL} from '../../trace_processor/query_result'; + +export class AndroidLockContentionEventSource implements EventSource { + constructor(private trace: Trace) {} + + async getRelatedEventData(eventId: number): Promise { + const query = ` + SELECT + amc.id AS contention_id, + amc.ts AS contention_ts, + amc.dur AS contention_dur, + amc.track_id AS blocked_track_id, + amc.blocked_utid, + amc.blocking_utid, + amc.short_blocked_method, + amc.blocked_thread_name, + amc.blocked_src, + amc.short_blocking_method, + amc.blocking_thread_name, + amc.blocking_src, + s.id AS blocking_slice_id, + s.track_id AS blocking_track_id, + s.ts AS blocking_ts, + s.dur AS blocking_dur + FROM android_monitor_contention amc + LEFT JOIN thread_or_process_slice s ON s.utid = amc.blocking_utid + AND amc.ts >= s.ts + AND amc.ts < s.ts + s.dur + WHERE amc.id = ${eventId} + ORDER BY s.depth DESC, s.id DESC + LIMIT 1 + `; + + const result = await this.trace.engine.query(query); + const it = result.iter({ + contention_id: NUM_NULL, + contention_ts: LONG_NULL, + contention_dur: LONG_NULL, + blocked_track_id: NUM_NULL, + blocked_utid: NUM_NULL, + blocking_utid: NUM_NULL, + short_blocked_method: STR, + blocked_thread_name: STR, + blocked_src: STR, + short_blocking_method: STR, + blocking_thread_name: STR, + blocking_src: STR, + blocking_slice_id: NUM_NULL, + blocking_track_id: NUM_NULL, + blocking_ts: LONG_NULL, + blocking_dur: LONG_NULL, + }); + + const events: RelatedEvent[] = []; + + if (!it.valid()) { + return {events: [], relations: []}; + } + + const blockedEventId = it.contention_id!; + const blockedTs = BigInt(it.contention_ts!) as time; + const blockedDur = BigInt(it.contention_dur!) as duration; + const blockedTrackId = it.blocked_track_id!; + const blockingThreadName = it.blocking_thread_name; + + const blockedTrackUri = getTrackUriForTrackId(this.trace, blockedTrackId); + const blockingTrackId = it.blocking_track_id; + const blockingTrackUri = + typeof blockingTrackId === 'number' + ? getTrackUriForTrackId(this.trace, blockingTrackId) + : undefined; + + const tabEvent: RelatedEvent = { + id: blockedEventId, + ts: blockedTs, + dur: blockedDur, + trackUri: blockedTrackUri, + type: 'Lock Contention', + customArgs: { + short_blocked_method: it.short_blocked_method, + blocked_thread_name: it.blocked_thread_name, + blocked_src: it.blocked_src, + short_blocking_method: it.short_blocking_method, + blocking_thread_name: blockingThreadName, + blocking_src: it.blocking_src, + blockingTrackUri: blockingTrackUri, + blockingSliceId: it.blocking_slice_id, + }, + }; + events.push(tabEvent); + return {events, relations: []}; + } +} diff --git a/ui/src/plugins/com.android.AndroidLockContention/index.ts b/ui/src/plugins/com.android.AndroidLockContention/index.ts new file mode 100644 index 0000000000..2285e24d87 --- /dev/null +++ b/ui/src/plugins/com.android.AndroidLockContention/index.ts @@ -0,0 +1,99 @@ +// 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 {PerfettoPlugin} from '../../public/plugin'; +import {Trace} from '../../public/trace'; +import RelatedEventsPlugin from '../dev.perfetto.RelatedEvents'; +import {AndroidLockContentionEventSource} 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 = ` + This plugin shows the blocking thread which is causing monitor contention. + + To use this, when selecting a track event beginning with 'monitor contention', you can call the command 'Android Lock + Contention: Toggle Blocked/Blocking Slice' which shows a tab with details of the blocking + blocked methods with + links to navigate. The default hotkey for this command is ']'. + `; + static readonly dependencies = [RelatedEventsPlugin]; + + async onTraceLoad(trace: Trace): Promise { + trace.engine.query('INCLUDE PERFETTO MODULE android.monitor_contention'); + + const source = new AndroidLockContentionEventSource(trace); + const tab = new AndroidLockContentionTab({trace, source}); + + trace.tabs.registerTab({ + uri: 'com.android.AndroidLockContentionTab', + isEphemeral: false, + content: tab, + }); + + trace.commands.registerCommand({ + id: 'toggleContentionNavigation', + name: 'Android Lock Contention: Toggle Blocked/Blocking Slice', + defaultHotkey: ']', + callback: async () => { + const selection = trace.selection.selection; + const tabInstance = tab; + + trace.tabs.showTab('com.android.AndroidLockContentionTab'); + + if (!tabInstance.hasEvent()) { + if (selection.kind === 'track_event') { + await tabInstance.loadData(selection.eventId); + } + return; + } + + const currentEventArgs = tabInstance.getEventArgs(); + if (!currentEventArgs) return; + + const contentionId = tabInstance.getContentionId(); + const {blockingTrackUri, blockingSliceId} = currentEventArgs; + + if (selection.kind === 'track_event') { + if (selection.eventId === contentionId) { + // Currently on blocked, jump to blocking + if (blockingTrackUri && blockingSliceId !== undefined) { + trace.selection.selectTrackEvent( + blockingTrackUri, + blockingSliceId, + { + scrollToSelection: true, + switchToCurrentSelectionTab: false, + }, + ); + } + } else if (selection.eventId === blockingSliceId) { + // Currently on blocking, jump back to blocked + const blockedTrackUri = tabInstance.getEventTrackUri(); + if (blockedTrackUri && contentionId !== undefined) { + trace.selection.selectTrackEvent(blockedTrackUri, contentionId, { + scrollToSelection: true, + switchToCurrentSelectionTab: false, + }); + } + } else { + // New selection, load it + await tabInstance.loadData(selection.eventId); + } + } else { + // No selection, do nothing to the navigation + } + }, + }); + } +} diff --git a/ui/src/plugins/com.android.AndroidLockContention/tab.ts b/ui/src/plugins/com.android.AndroidLockContention/tab.ts new file mode 100644 index 0000000000..cdf7ddd2ac --- /dev/null +++ b/ui/src/plugins/com.android.AndroidLockContention/tab.ts @@ -0,0 +1,207 @@ +// 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 {Tab} from '../../public/tab'; +import {Trace} from '../../public/trace'; +import {DetailsShell} from '../../widgets/details_shell'; +import {Section} from '../../widgets/section'; +import {AndroidLockContentionEventSource} from './android_lock_contention_event_source'; +import {Anchor} from '../../widgets/anchor'; +import {Icons} from '../../base/semantic_icons'; +import {Spinner} from '../../widgets/spinner'; +import {time} from '../../base/time'; +import {RelatedEvent} from '../dev.perfetto.RelatedEvents/interface'; + +interface LockContentionArgs { + short_blocked_method: string; + blocked_thread_name: string; + blocked_src: string; + short_blocking_method: string; + blocking_thread_name: string; + blocking_src: string; + blockingTrackUri?: string; + blockingSliceId?: number; + allTrackUris?: string[]; +} + +function isLockContentionArgs(args: unknown): args is LockContentionArgs { + if (typeof args !== 'object' || args === null) return false; + const obj = args as Record; + return ( + typeof obj.short_blocked_method === 'string' && + typeof obj.short_blocking_method === 'string' + ); +} + +interface LockContentionTabConfig { + trace: Trace; + source: AndroidLockContentionEventSource; +} + +export class AndroidLockContentionTab implements Tab { + private event: RelatedEvent | null = null; + private isLoading = false; + + constructor(private config: LockContentionTabConfig) {} + + syncSelection() { + const selection = this.config.trace.selection.selection; + if (selection.kind === 'track_event') { + this.loadData(selection.eventId); + } else { + this.event = null; + m.redraw(); + } + } + + async loadData(eventId: number) { + if (this.isLoading) return; + this.isLoading = true; + this.event = null; + m.redraw(); + try { + const data = await this.config.source.getRelatedEventData(eventId); + this.event = data.events.length > 0 ? data.events[0] : null; + } finally { + this.isLoading = false; + m.redraw(); + } + } + + hasEvent(): boolean { + return this.event !== null; + } + + getEventArgs(): LockContentionArgs | null { + if (!this.event || !this.event.customArgs) return null; + if (!isLockContentionArgs(this.event.customArgs)) return null; + return this.event.customArgs; + } + + getEventTrackUri(): string | undefined { + return this.event?.trackUri; + } + + getContentionId(): number | undefined { + return this.event?.id; + } + + getTitle() { + return 'Lock Contention'; + } + + private goTo(trackUri: string, eventId: number) { + this.config.trace.selection.selectTrackEvent(trackUri, eventId, { + scrollToSelection: true, + switchToCurrentSelectionTab: false, + }); + } + + private scrollToTime(trackUri: string, ts: time) { + this.config.trace.scrollTo({ + time: { + start: ts, + behavior: 'pan', + }, + track: { + uri: trackUri, + expandGroup: true, + }, + }); + } + + render(): m.Children { + if (this.isLoading) { + return m(DetailsShell, {title: this.getTitle()}, m(Spinner, {})); + } + + if (!this.event || !this.event.customArgs) { + return m( + DetailsShell, + {title: this.getTitle()}, + m('.note', 'Select a lock contention event.'), + ); + } + + const args = this.event.customArgs; + if (!isLockContentionArgs(args)) { + console.error('Invalid customArgs for LockContention event', args); + return m( + DetailsShell, + {title: this.getTitle()}, + m('.note', 'Error: Invalid event data.'), + ); + } + + return m( + DetailsShell, + {title: this.getTitle()}, + m( + '.contention-details', + m( + Section, + { + title: 'Blocked Thread/Method', + }, + m('div', `Thread: ${args.blocked_thread_name}`), + m('div', `Method: ${args.short_blocked_method}`), + m('div', `Source: ${args.blocked_src}`), + m( + Anchor, + { + icon: Icons.GoTo, + onclick: () => this.goTo(this.event!.trackUri, this.event!.id), + title: 'Go to Blocked Event', + }, + 'Go to Blocked', + ), + ), + m( + Section, + { + title: 'Blocking Thread/Method', + }, + m('div', `Thread: ${args.blocking_thread_name}`), + m('div', `Method: ${args.short_blocking_method}`), + m('div', `Source: ${args.blocking_src}`), + args.blockingTrackUri && + args.blockingSliceId !== undefined && + m( + Anchor, + { + icon: Icons.GoTo, + onclick: () => + this.goTo(args.blockingTrackUri!, args.blockingSliceId!), + title: 'Go to Blocking Slice', + }, + 'Go to Blocking Slice', + ), + args.blockingTrackUri && + args.blockingSliceId === undefined && + m( + Anchor, + { + icon: Icons.GoTo, + onclick: () => + this.scrollToTime(args.blockingTrackUri!, this.event!.ts), + title: 'Scroll to Blocking Thread at Contention Time', + }, + 'Scroll to Blocking Thread', + ), + ), + ), + ); + } +}