1// Copyright (C) 2024 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import m from 'mithril'; 16import {Trace} from '../../public/trace'; 17import {PerfettoPlugin} from '../../public/plugin'; 18import {Time, TimeSpan} from '../../base/time'; 19import {redrawModal, showModal} from '../../widgets/modal'; 20import {assertExists} from '../../base/logging'; 21 22const PLUGIN_ID = 'dev.perfetto.TimelineSync'; 23const DEFAULT_BROADCAST_CHANNEL = `${PLUGIN_ID}#broadcastChannel`; 24const VIEWPORT_UPDATE_THROTTLE_TIME_FOR_SENDING_AFTER_RECEIVING_MS = 1_000; 25const BIGINT_PRECISION_MULTIPLIER = 1_000_000_000n; 26const ADVERTISE_PERIOD_MS = 10_000; 27const DEFAULT_SESSION_ID = 1; 28type ClientId = number; 29type SessionId = number; 30 31/** 32 * Synchronizes the timeline of 2 or more perfetto traces. 33 * 34 * To trigger the sync, the command needs to be executed on one tab. It will 35 * prompt a list of other tabs to keep in sync. Each tab advertise itself 36 * on a BroadcastChannel upon trace load. 37 * 38 * This is able to sync between traces recorded at different times, even if 39 * their durations don't match. The initial viewport bound for each trace is 40 * selected when the enable command is called. 41 */ 42export default class implements PerfettoPlugin { 43 static readonly id = PLUGIN_ID; 44 private _chan?: BroadcastChannel; 45 private _ctx?: Trace; 46 private _traceLoadTime = 0; 47 // Attached to broadcast messages to allow other windows to remap viewports. 48 private readonly _clientId: ClientId = Math.floor(Math.random() * 1_000_000); 49 // Used to throttle sending updates after one has been received. 50 private _lastReceivedUpdateMillis: number = 0; 51 private _lastViewportBounds?: ViewportBounds; 52 private _advertisedClients = new Map<ClientId, ClientInfo>(); 53 private _sessionId: SessionId = 0; 54 // Used when the url passes ?dev.perfetto.TimelineSync:enable to auto-enable 55 // timeline sync on trace load. 56 private _sessionidFromUrl: SessionId = 0; 57 58 // Contains the Viewport bounds of this window when it received the first sync 59 // message from another one. This is used to re-scale timestamps, so that we 60 // can sync 2 (or more!) traces with different length. 61 // The initial viewport will be the one visible when the command is enabled. 62 private _initialBoundsForSibling = new Map< 63 ClientId, 64 ViewportBoundsSnapshot 65 >(); 66 67 async onTraceLoad(ctx: Trace) { 68 ctx.commands.registerCommand({ 69 id: `dev.perfetto.SplitScreen#enableTimelineSync`, 70 name: 'Enable timeline sync with other Perfetto UI tabs', 71 callback: () => this.showTimelineSyncDialog(), 72 }); 73 ctx.commands.registerCommand({ 74 id: `dev.perfetto.SplitScreen#disableTimelineSync`, 75 name: 'Disable timeline sync', 76 callback: () => this.disableTimelineSync(this._sessionId), 77 }); 78 ctx.commands.registerCommand({ 79 id: `dev.perfetto.SplitScreen#toggleTimelineSync`, 80 name: 'Toggle timeline sync with other PerfettoUI tabs', 81 callback: () => this.toggleTimelineSync(), 82 defaultHotkey: 'Mod+Alt+S', 83 }); 84 85 // Start advertising this tab. This allows the command run in other 86 // instances to discover us. 87 this._chan = new BroadcastChannel(DEFAULT_BROADCAST_CHANNEL); 88 this._chan.onmessage = this.onmessage.bind(this); 89 document.addEventListener('visibilitychange', () => this.advertise()); 90 window.addEventListener('focus', () => this.advertise()); 91 setInterval(() => this.advertise(), ADVERTISE_PERIOD_MS); 92 93 // Allow auto-enabling of timeline sync from the URI. The user can 94 // optionally specify a session id, otherwise we just use a default one. 95 const m = /dev.perfetto.TimelineSync:enable(=\d+)?/.exec(location.hash); 96 if (m !== null) { 97 this._sessionidFromUrl = m[1] 98 ? parseInt(m[1].substring(1)) 99 : DEFAULT_SESSION_ID; 100 } 101 102 this._ctx = ctx; 103 this._traceLoadTime = Date.now(); 104 this.advertise(); 105 if (this._sessionidFromUrl !== 0) { 106 this.enableTimelineSync(this._sessionidFromUrl); 107 } 108 ctx.trash.defer(() => { 109 this.disableTimelineSync(this._sessionId); 110 this._ctx = undefined; 111 }); 112 } 113 114 private advertise() { 115 if (this._ctx === undefined) return; // Don't advertise if no trace loaded. 116 this._chan?.postMessage({ 117 perfettoSync: { 118 cmd: 'MSG_ADVERTISE', 119 title: document.title, 120 traceLoadTime: this._traceLoadTime, 121 }, 122 clientId: this._clientId, 123 } as SyncMessage); 124 } 125 126 private toggleTimelineSync() { 127 if (this._sessionId === 0) { 128 this.showTimelineSyncDialog(); 129 } else { 130 this.disableTimelineSync(this._sessionId); 131 } 132 } 133 134 private showTimelineSyncDialog() { 135 let clientsSelect: HTMLSelectElement; 136 137 // This nested function is invoked when the modal dialog buton is pressed. 138 const doStartSession = () => { 139 // Disable any prior session. 140 this.disableTimelineSync(this._sessionId); 141 const selectedClients = new Array<ClientId>(); 142 const sel = assertExists(clientsSelect).selectedOptions; 143 for (let i = 0; i < sel.length; i++) { 144 const clientId = parseInt(sel[i].value); 145 if (!isNaN(clientId)) selectedClients.push(clientId); 146 } 147 selectedClients.push(this._clientId); // Always add ourselves. 148 this._sessionId = Math.floor(Math.random() * 1_000_000); 149 this._chan?.postMessage({ 150 perfettoSync: { 151 cmd: 'MSG_SESSION_START', 152 sessionId: this._sessionId, 153 clients: selectedClients, 154 }, 155 clientId: this._clientId, 156 } as SyncMessage); 157 this._initialBoundsForSibling.clear(); 158 this.scheduleViewportUpdateMessage(); 159 }; 160 161 // The function below is called on every mithril render pass. It's important 162 // that this function re-computes the list of other clients on every pass. 163 // The user will go to other tabs (which causes an advertise due to the 164 // visibilitychange listener) and come back on here while the modal dialog 165 // is still being displayed. 166 const renderModalContents = (): m.Children => { 167 const children: m.Children = []; 168 this.purgeInactiveClients(); 169 const clients = Array.from(this._advertisedClients.entries()); 170 clients.sort((a, b) => b[1].traceLoadTime - a[1].traceLoadTime); 171 for (const [clientId, info] of clients) { 172 const opened = new Date(info.traceLoadTime).toLocaleTimeString(); 173 const attrs: {value: number; selected?: boolean} = {value: clientId}; 174 if (this._advertisedClients.size === 1) { 175 attrs.selected = true; 176 } 177 children.push(m('option', attrs, `${info.title} (${opened})`)); 178 } 179 return m( 180 'div', 181 {style: 'display: flex; flex-direction: column;'}, 182 m( 183 'div', 184 'Select the perfetto UI tab(s) you want to keep in sync ' + 185 '(Ctrl+Click to select many).', 186 ), 187 m( 188 'div', 189 "If you don't see the trace listed here, temporarily focus the " + 190 'corresponding browser tab and then come back here.', 191 ), 192 m( 193 'select[multiple=multiple][size=8]', 194 { 195 oncreate: (vnode: m.VnodeDOM) => { 196 clientsSelect = vnode.dom as HTMLSelectElement; 197 }, 198 }, 199 children, 200 ), 201 ); 202 }; 203 204 showModal({ 205 title: 'Synchronize timeline across several tabs', 206 content: renderModalContents, 207 buttons: [ 208 { 209 primary: true, 210 text: `Synchronize timelines`, 211 action: doStartSession, 212 }, 213 ], 214 }); 215 } 216 217 private enableTimelineSync(sessionId: SessionId) { 218 if (sessionId === this._sessionId) return; // Already in this session id. 219 this._sessionId = sessionId; 220 this._initialBoundsForSibling.clear(); 221 this.scheduleViewportUpdateMessage(); 222 } 223 224 private disableTimelineSync(sessionId: SessionId, skipMsg = false) { 225 if (sessionId !== this._sessionId || this._sessionId === 0) return; 226 227 if (!skipMsg) { 228 this._chan?.postMessage({ 229 perfettoSync: { 230 cmd: 'MSG_SESSION_STOP', 231 sessionId: this._sessionId, 232 }, 233 clientId: this._clientId, 234 } as SyncMessage); 235 } 236 this._sessionId = 0; 237 this._initialBoundsForSibling.clear(); 238 } 239 240 private shouldThrottleViewportUpdates() { 241 return ( 242 Date.now() - this._lastReceivedUpdateMillis <= 243 VIEWPORT_UPDATE_THROTTLE_TIME_FOR_SENDING_AFTER_RECEIVING_MS 244 ); 245 } 246 247 private scheduleViewportUpdateMessage() { 248 if (!this.active) return; 249 const currentViewport = this.getCurrentViewportBounds(); 250 if ( 251 (!this._lastViewportBounds || 252 !this._lastViewportBounds.equals(currentViewport)) && 253 !this.shouldThrottleViewportUpdates() 254 ) { 255 this.sendViewportBounds(currentViewport); 256 this._lastViewportBounds = currentViewport; 257 } 258 requestAnimationFrame(this.scheduleViewportUpdateMessage.bind(this)); 259 } 260 261 private sendViewportBounds(viewportBounds: ViewportBounds) { 262 this._chan?.postMessage({ 263 perfettoSync: { 264 cmd: 'MSG_SET_VIEWPORT', 265 sessionId: this._sessionId, 266 viewportBounds, 267 }, 268 clientId: this._clientId, 269 } as SyncMessage); 270 } 271 272 private onmessage(msg: MessageEvent) { 273 if (this._ctx === undefined) return; // Trace unloaded 274 if (!('perfettoSync' in msg.data)) return; 275 this._ctx.scheduleFullRedraw('force'); 276 const msgData = msg.data as SyncMessage; 277 const sync = msgData.perfettoSync; 278 switch (sync.cmd) { 279 case 'MSG_ADVERTISE': 280 if (msgData.clientId !== this._clientId) { 281 this._advertisedClients.set(msgData.clientId, { 282 title: sync.title, 283 traceLoadTime: sync.traceLoadTime, 284 lastHeartbeat: Date.now(), 285 }); 286 this.purgeInactiveClients(); 287 redrawModal(); 288 } 289 break; 290 case 'MSG_SESSION_START': 291 if (sync.clients.includes(this._clientId)) { 292 this.enableTimelineSync(sync.sessionId); 293 } 294 break; 295 case 'MSG_SESSION_STOP': 296 this.disableTimelineSync(sync.sessionId, /* skipMsg= */ true); 297 break; 298 case 'MSG_SET_VIEWPORT': 299 if (sync.sessionId === this._sessionId) { 300 this.onViewportSyncReceived(sync.viewportBounds, msgData.clientId); 301 } 302 break; 303 } 304 } 305 306 private onViewportSyncReceived( 307 requestViewBounds: ViewportBounds, 308 source: ClientId, 309 ) { 310 if (!this.active) return; 311 this.cacheSiblingInitialBoundIfNeeded(requestViewBounds, source); 312 const remappedViewport = this.remapViewportBounds( 313 requestViewBounds, 314 source, 315 ); 316 if (!this.getCurrentViewportBounds().equals(remappedViewport)) { 317 this._lastReceivedUpdateMillis = Date.now(); 318 this._lastViewportBounds = remappedViewport; 319 this._ctx?.timeline.setViewportTime( 320 remappedViewport.start, 321 remappedViewport.end, 322 ); 323 } 324 } 325 326 private cacheSiblingInitialBoundIfNeeded( 327 requestViewBounds: ViewportBounds, 328 source: ClientId, 329 ) { 330 if (!this._initialBoundsForSibling.has(source)) { 331 this._initialBoundsForSibling.set(source, { 332 thisWindow: this.getCurrentViewportBounds(), 333 otherWindow: requestViewBounds, 334 }); 335 } 336 } 337 338 private remapViewportBounds( 339 otherWindowBounds: ViewportBounds, 340 source: ClientId, 341 ): ViewportBounds { 342 const initialSnapshot = this._initialBoundsForSibling.get(source)!; 343 const otherInitial = initialSnapshot.otherWindow; 344 const thisInitial = initialSnapshot.thisWindow; 345 346 const [l, r] = this.percentageChange( 347 otherInitial.start, 348 otherInitial.end, 349 otherWindowBounds.start, 350 otherWindowBounds.end, 351 ); 352 const thisWindowInitialLength = thisInitial.end - thisInitial.start; 353 354 return new TimeSpan( 355 Time.fromRaw( 356 thisInitial.start + 357 (thisWindowInitialLength * l) / BIGINT_PRECISION_MULTIPLIER, 358 ), 359 Time.fromRaw( 360 thisInitial.start + 361 (thisWindowInitialLength * r) / BIGINT_PRECISION_MULTIPLIER, 362 ), 363 ); 364 } 365 366 /* 367 * Returns the percentage (*1e9) of the starting point inside 368 * [initialL, initialR] of [currentL, currentR]. 369 * 370 * A few examples: 371 * - If current == initial, the output is expected to be [0,1e9] 372 * - If current is inside the initial -> [>0, < 1e9] 373 * - If current is completely outside initial to the right -> [>1e9, >>1e9]. 374 * - If current is completely outside initial to the left -> [<<0, <0] 375 */ 376 private percentageChange( 377 initialL: bigint, 378 initialR: bigint, 379 currentL: bigint, 380 currentR: bigint, 381 ): [bigint, bigint] { 382 const initialW = initialR - initialL; 383 const dtL = currentL - initialL; 384 const dtR = currentR - initialL; 385 return [this.divide(dtL, initialW), this.divide(dtR, initialW)]; 386 } 387 388 private divide(a: bigint, b: bigint): bigint { 389 // Let's not lose precision 390 return (a * BIGINT_PRECISION_MULTIPLIER) / b; 391 } 392 393 private getCurrentViewportBounds(): ViewportBounds { 394 return this._ctx!.timeline.visibleWindow.toTimeSpan(); 395 } 396 397 private purgeInactiveClients() { 398 const now = Date.now(); 399 const TIMEOUT_MS = 30_000; 400 for (const [clientId, info] of this._advertisedClients.entries()) { 401 if (now - info.lastHeartbeat < TIMEOUT_MS) continue; 402 this._advertisedClients.delete(clientId); 403 } 404 } 405 406 private get active() { 407 return this._sessionId !== 0; 408 } 409} 410 411type ViewportBounds = TimeSpan; 412 413interface ViewportBoundsSnapshot { 414 thisWindow: ViewportBounds; 415 otherWindow: ViewportBounds; 416} 417 418interface MsgSetViewport { 419 cmd: 'MSG_SET_VIEWPORT'; 420 sessionId: SessionId; 421 viewportBounds: ViewportBounds; 422} 423 424interface MsgAdvertise { 425 cmd: 'MSG_ADVERTISE'; 426 title: string; 427 traceLoadTime: number; 428} 429 430interface MsgSessionStart { 431 cmd: 'MSG_SESSION_START'; 432 sessionId: SessionId; 433 clients: ClientId[]; 434} 435 436interface MsgSessionStop { 437 cmd: 'MSG_SESSION_STOP'; 438 sessionId: SessionId; 439} 440 441// In case of new messages, they should be "or-ed" here. 442type SyncMessages = 443 | MsgSetViewport 444 | MsgAdvertise 445 | MsgSessionStart 446 | MsgSessionStop; 447 448interface SyncMessage { 449 perfettoSync: SyncMessages; 450 clientId: ClientId; 451} 452 453interface ClientInfo { 454 title: string; 455 lastHeartbeat: number; // Datetime.now() of the last MSG_ADVERTISE. 456 traceLoadTime: number; // Datetime.now() of the onTraceLoad(). 457} 458