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