• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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