blob: f15d3eb592cbbb2c56a195001e92b4e7f81b454d [file] [log] [blame]
// 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 {
Plugin,
PluginContext,
PluginContextTrace,
PluginDescriptor,
} from '../../public';
import {duration, Span, Time, 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.
*/
class TimelineSync implements Plugin {
private _chan?: BroadcastChannel;
private _ctx?: PluginContextTrace;
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<ClientId, ClientInfo>();
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
>();
onActivate(ctx: PluginContext): void {
ctx.registerCommand({
id: `dev.perfetto.SplitScreen#enableTimelineSync`,
name: 'Enable timeline sync with other Perfetto UI tabs',
callback: () => this.showTimelineSyncDialog(),
});
ctx.registerCommand({
id: `dev.perfetto.SplitScreen#disableTimelineSync`,
name: 'Disable timeline sync',
callback: () => this.disableTimelineSync(this._sessionId),
});
// 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;
}
}
onDeactivate(_: PluginContext) {
this.disableTimelineSync(this._sessionId);
}
async onTraceLoad(ctx: PluginContextTrace) {
this._ctx = ctx;
this._traceLoadTime = Date.now();
this.advertise();
if (this._sessionidFromUrl !== 0) {
this.enableTimelineSync(this._sessionidFromUrl);
}
}
async onTraceUnload(_: PluginContextTrace) {
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 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<ClientId>();
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;
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.viewport;
}
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 = Span<time, duration>;
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().
}
export const plugin: PluginDescriptor = {
pluginId: PLUGIN_ID,
plugin: TimelineSync,
};