xref: /aosp_15_r20/external/perfetto/ui/src/plugins/dev.perfetto.TimelineSync/index.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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