// Copyright (C) 2024 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 {Trace} from '../../public/trace'; import {PerfettoPlugin} from '../../public/plugin'; import {Time, TimeSpan} from '../../base/time'; import {redrawModal, showModal} from '../../widgets/modal'; import {assertExists} from '../../base/logging'; const PLUGIN_ID = 'dev.perfetto.TimelineSync'; const DEFAULT_BROADCAST_CHANNEL = `${PLUGIN_ID}#broadcastChannel`; const VIEWPORT_UPDATE_THROTTLE_TIME_FOR_SENDING_AFTER_RECEIVING_MS = 1_000; const BIGINT_PRECISION_MULTIPLIER = 1_000_000_000n; const ADVERTISE_PERIOD_MS = 10_000; const DEFAULT_SESSION_ID = 1; type ClientId = number; type SessionId = number; /** * Synchronizes the timeline of 2 or more perfetto traces. * * To trigger the sync, the command needs to be executed on one tab. It will * prompt a list of other tabs to keep in sync. Each tab advertise itself * on a BroadcastChannel upon trace load. * * This is able to sync between traces recorded at different times, even if * their durations don't match. The initial viewport bound for each trace is * selected when the enable command is called. */ export default class implements PerfettoPlugin { static readonly id = PLUGIN_ID; private _chan?: BroadcastChannel; private _ctx?: Trace; private _traceLoadTime = 0; // Attached to broadcast messages to allow other windows to remap viewports. private readonly _clientId: ClientId = Math.floor(Math.random() * 1_000_000); // Used to throttle sending updates after one has been received. private _lastReceivedUpdateMillis: number = 0; private _lastViewportBounds?: ViewportBounds; private _advertisedClients = new Map(); private _sessionId: SessionId = 0; // Used when the url passes ?dev.perfetto.TimelineSync:enable to auto-enable // timeline sync on trace load. private _sessionidFromUrl: SessionId = 0; // Contains the Viewport bounds of this window when it received the first sync // message from another one. This is used to re-scale timestamps, so that we // can sync 2 (or more!) traces with different length. // The initial viewport will be the one visible when the command is enabled. private _initialBoundsForSibling = new Map< ClientId, ViewportBoundsSnapshot >(); async onTraceLoad(ctx: Trace) { ctx.commands.registerCommand({ id: `dev.perfetto.SplitScreen#enableTimelineSync`, name: 'Enable timeline sync with other Perfetto UI tabs', callback: () => this.showTimelineSyncDialog(), }); ctx.commands.registerCommand({ id: `dev.perfetto.SplitScreen#disableTimelineSync`, name: 'Disable timeline sync', callback: () => this.disableTimelineSync(this._sessionId), }); ctx.commands.registerCommand({ id: `dev.perfetto.SplitScreen#toggleTimelineSync`, name: 'Toggle timeline sync with other PerfettoUI tabs', callback: () => this.toggleTimelineSync(), defaultHotkey: 'Mod+Alt+S', }); // Start advertising this tab. This allows the command run in other // instances to discover us. this._chan = new BroadcastChannel(DEFAULT_BROADCAST_CHANNEL); this._chan.onmessage = this.onmessage.bind(this); document.addEventListener('visibilitychange', () => this.advertise()); window.addEventListener('focus', () => this.advertise()); setInterval(() => this.advertise(), ADVERTISE_PERIOD_MS); // Allow auto-enabling of timeline sync from the URI. The user can // optionally specify a session id, otherwise we just use a default one. const m = /dev.perfetto.TimelineSync:enable(=\d+)?/.exec(location.hash); if (m !== null) { this._sessionidFromUrl = m[1] ? parseInt(m[1].substring(1)) : DEFAULT_SESSION_ID; } this._ctx = ctx; this._traceLoadTime = Date.now(); this.advertise(); if (this._sessionidFromUrl !== 0) { this.enableTimelineSync(this._sessionidFromUrl); } ctx.trash.defer(() => { this.disableTimelineSync(this._sessionId); this._ctx = undefined; }); } private advertise() { if (this._ctx === undefined) return; // Don't advertise if no trace loaded. this._chan?.postMessage({ perfettoSync: { cmd: 'MSG_ADVERTISE', title: document.title, traceLoadTime: this._traceLoadTime, }, clientId: this._clientId, } as SyncMessage); } private toggleTimelineSync() { if (this._sessionId === 0) { this.showTimelineSyncDialog(); } else { this.disableTimelineSync(this._sessionId); } } private showTimelineSyncDialog() { let clientsSelect: HTMLSelectElement; // This nested function is invoked when the modal dialog buton is pressed. const doStartSession = () => { // Disable any prior session. this.disableTimelineSync(this._sessionId); const selectedClients = new Array(); const sel = assertExists(clientsSelect).selectedOptions; for (let i = 0; i < sel.length; i++) { const clientId = parseInt(sel[i].value); if (!isNaN(clientId)) selectedClients.push(clientId); } selectedClients.push(this._clientId); // Always add ourselves. this._sessionId = Math.floor(Math.random() * 1_000_000); this._chan?.postMessage({ perfettoSync: { cmd: 'MSG_SESSION_START', sessionId: this._sessionId, clients: selectedClients, }, clientId: this._clientId, } as SyncMessage); this._initialBoundsForSibling.clear(); this.scheduleViewportUpdateMessage(); }; // The function below is called on every mithril render pass. It's important // that this function re-computes the list of other clients on every pass. // The user will go to other tabs (which causes an advertise due to the // visibilitychange listener) and come back on here while the modal dialog // is still being displayed. const renderModalContents = (): m.Children => { const children: m.Children = []; this.purgeInactiveClients(); const clients = Array.from(this._advertisedClients.entries()); clients.sort((a, b) => b[1].traceLoadTime - a[1].traceLoadTime); for (const [clientId, info] of clients) { const opened = new Date(info.traceLoadTime).toLocaleTimeString(); const attrs: {value: number; selected?: boolean} = {value: clientId}; if (this._advertisedClients.size === 1) { attrs.selected = true; } children.push(m('option', attrs, `${info.title} (${opened})`)); } return m( 'div', {style: 'display: flex; flex-direction: column;'}, m( 'div', 'Select the perfetto UI tab(s) you want to keep in sync ' + '(Ctrl+Click to select many).', ), m( 'div', "If you don't see the trace listed here, temporarily focus the " + 'corresponding browser tab and then come back here.', ), m( 'select[multiple=multiple][size=8]', { oncreate: (vnode: m.VnodeDOM) => { clientsSelect = vnode.dom as HTMLSelectElement; }, }, children, ), ); }; showModal({ title: 'Synchronize timeline across several tabs', content: renderModalContents, buttons: [ { primary: true, text: `Synchronize timelines`, action: doStartSession, }, ], }); } private enableTimelineSync(sessionId: SessionId) { if (sessionId === this._sessionId) return; // Already in this session id. this._sessionId = sessionId; this._initialBoundsForSibling.clear(); this.scheduleViewportUpdateMessage(); } private disableTimelineSync(sessionId: SessionId, skipMsg = false) { if (sessionId !== this._sessionId || this._sessionId === 0) return; if (!skipMsg) { this._chan?.postMessage({ perfettoSync: { cmd: 'MSG_SESSION_STOP', sessionId: this._sessionId, }, clientId: this._clientId, } as SyncMessage); } this._sessionId = 0; this._initialBoundsForSibling.clear(); } private shouldThrottleViewportUpdates() { return ( Date.now() - this._lastReceivedUpdateMillis <= VIEWPORT_UPDATE_THROTTLE_TIME_FOR_SENDING_AFTER_RECEIVING_MS ); } private scheduleViewportUpdateMessage() { if (!this.active) return; const currentViewport = this.getCurrentViewportBounds(); if ( (!this._lastViewportBounds || !this._lastViewportBounds.equals(currentViewport)) && !this.shouldThrottleViewportUpdates() ) { this.sendViewportBounds(currentViewport); this._lastViewportBounds = currentViewport; } requestAnimationFrame(this.scheduleViewportUpdateMessage.bind(this)); } private sendViewportBounds(viewportBounds: ViewportBounds) { this._chan?.postMessage({ perfettoSync: { cmd: 'MSG_SET_VIEWPORT', sessionId: this._sessionId, viewportBounds, }, clientId: this._clientId, } as SyncMessage); } private onmessage(msg: MessageEvent) { if (this._ctx === undefined) return; // Trace unloaded if (!('perfettoSync' in msg.data)) return; this._ctx.scheduleFullRedraw('force'); const msgData = msg.data as SyncMessage; const sync = msgData.perfettoSync; switch (sync.cmd) { case 'MSG_ADVERTISE': if (msgData.clientId !== this._clientId) { this._advertisedClients.set(msgData.clientId, { title: sync.title, traceLoadTime: sync.traceLoadTime, lastHeartbeat: Date.now(), }); this.purgeInactiveClients(); redrawModal(); } break; case 'MSG_SESSION_START': if (sync.clients.includes(this._clientId)) { this.enableTimelineSync(sync.sessionId); } break; case 'MSG_SESSION_STOP': this.disableTimelineSync(sync.sessionId, /* skipMsg= */ true); break; case 'MSG_SET_VIEWPORT': if (sync.sessionId === this._sessionId) { this.onViewportSyncReceived(sync.viewportBounds, msgData.clientId); } break; } } private onViewportSyncReceived( requestViewBounds: ViewportBounds, source: ClientId, ) { if (!this.active) return; this.cacheSiblingInitialBoundIfNeeded(requestViewBounds, source); const remappedViewport = this.remapViewportBounds( requestViewBounds, source, ); if (!this.getCurrentViewportBounds().equals(remappedViewport)) { this._lastReceivedUpdateMillis = Date.now(); this._lastViewportBounds = remappedViewport; this._ctx?.timeline.setViewportTime( remappedViewport.start, remappedViewport.end, ); } } private cacheSiblingInitialBoundIfNeeded( requestViewBounds: ViewportBounds, source: ClientId, ) { if (!this._initialBoundsForSibling.has(source)) { this._initialBoundsForSibling.set(source, { thisWindow: this.getCurrentViewportBounds(), otherWindow: requestViewBounds, }); } } private remapViewportBounds( otherWindowBounds: ViewportBounds, source: ClientId, ): ViewportBounds { const initialSnapshot = this._initialBoundsForSibling.get(source)!; const otherInitial = initialSnapshot.otherWindow; const thisInitial = initialSnapshot.thisWindow; const [l, r] = this.percentageChange( otherInitial.start, otherInitial.end, otherWindowBounds.start, otherWindowBounds.end, ); const thisWindowInitialLength = thisInitial.end - thisInitial.start; return new TimeSpan( Time.fromRaw( thisInitial.start + (thisWindowInitialLength * l) / BIGINT_PRECISION_MULTIPLIER, ), Time.fromRaw( thisInitial.start + (thisWindowInitialLength * r) / BIGINT_PRECISION_MULTIPLIER, ), ); } /* * Returns the percentage (*1e9) of the starting point inside * [initialL, initialR] of [currentL, currentR]. * * A few examples: * - If current == initial, the output is expected to be [0,1e9] * - If current is inside the initial -> [>0, < 1e9] * - If current is completely outside initial to the right -> [>1e9, >>1e9]. * - If current is completely outside initial to the left -> [<<0, <0] */ private percentageChange( initialL: bigint, initialR: bigint, currentL: bigint, currentR: bigint, ): [bigint, bigint] { const initialW = initialR - initialL; const dtL = currentL - initialL; const dtR = currentR - initialL; return [this.divide(dtL, initialW), this.divide(dtR, initialW)]; } private divide(a: bigint, b: bigint): bigint { // Let's not lose precision return (a * BIGINT_PRECISION_MULTIPLIER) / b; } private getCurrentViewportBounds(): ViewportBounds { return this._ctx!.timeline.visibleWindow.toTimeSpan(); } private purgeInactiveClients() { const now = Date.now(); const TIMEOUT_MS = 30_000; for (const [clientId, info] of this._advertisedClients.entries()) { if (now - info.lastHeartbeat < TIMEOUT_MS) continue; this._advertisedClients.delete(clientId); } } private get active() { return this._sessionId !== 0; } } type ViewportBounds = TimeSpan; interface ViewportBoundsSnapshot { thisWindow: ViewportBounds; otherWindow: ViewportBounds; } interface MsgSetViewport { cmd: 'MSG_SET_VIEWPORT'; sessionId: SessionId; viewportBounds: ViewportBounds; } interface MsgAdvertise { cmd: 'MSG_ADVERTISE'; title: string; traceLoadTime: number; } interface MsgSessionStart { cmd: 'MSG_SESSION_START'; sessionId: SessionId; clients: ClientId[]; } interface MsgSessionStop { cmd: 'MSG_SESSION_STOP'; sessionId: SessionId; } // In case of new messages, they should be "or-ed" here. type SyncMessages = | MsgSetViewport | MsgAdvertise | MsgSessionStart | MsgSessionStop; interface SyncMessage { perfettoSync: SyncMessages; clientId: ClientId; } interface ClientInfo { title: string; lastHeartbeat: number; // Datetime.now() of the last MSG_ADVERTISE. traceLoadTime: number; // Datetime.now() of the onTraceLoad(). }