blob: e36a5aaa0e1a7218ccb6c6f4b349c11312be2054 [file] [log] [blame]
// Copyright (C) 2022 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 {defer, Deferred} from '../../base/deferred';
import {assertExists, assertTrue} from '../../base/logging';
import {binaryDecode, binaryEncode} from '../../base/string_utils';
import {
ChromeExtensionMessage,
isChromeExtensionError,
isChromeExtensionStatus,
isGetCategoriesResponse,
} from '../../controller/chrome_proxy_record_controller';
import {
isDisableTracingResponse,
isEnableTracingResponse,
isFreeBuffersResponse,
isGetTraceStatsResponse,
isReadBuffersResponse,
} from '../../controller/consumer_port_types';
import {
EnableTracingRequest,
IBufferStats,
ISlice,
TraceConfig,
} from '../protos';
import {RecordingError} from './recording_error_handling';
import {
TracingSession,
TracingSessionListener,
} from './recording_interfaces_v2';
import {
BUFFER_USAGE_INCORRECT_FORMAT,
BUFFER_USAGE_NOT_ACCESSIBLE,
EXTENSION_ID,
MALFORMED_EXTENSION_MESSAGE,
} from './recording_utils';
// This class implements the protocol described in
// https://perfetto.dev/docs/design-docs/api-and-abi#tracing-protocol-abi
// However, with the Chrome extension we communicate using JSON messages.
export class ChromeTracedTracingSession implements TracingSession {
// Needed for ReadBufferResponse: all the trace packets are split into
// several slices. |partialPacket| is the buffer for them. Once we receive a
// slice with the flag |lastSliceForPacket|, a new packet is created.
private partialPacket: ISlice[] = [];
// For concurrent calls to 'GetCategories', we return the same value.
private pendingGetCategoriesMessage?: Deferred<string[]>;
private pendingStatsMessages = new Array<Deferred<IBufferStats[]>>();
// Port through which we communicate with the extension.
private chromePort: chrome.runtime.Port;
// True when Perfetto is connected via the port to the tracing session.
private isPortConnected: boolean;
constructor(private tracingSessionListener: TracingSessionListener) {
this.chromePort = chrome.runtime.connect(EXTENSION_ID);
this.isPortConnected = true;
}
start(config: TraceConfig): void {
if (!this.isPortConnected) return;
const duration = config.durationMs;
this.tracingSessionListener.onStatus(`Recording in progress${
duration ? ' for ' + duration.toString() + ' ms' : ''}...`);
const enableTracingRequest = new EnableTracingRequest();
enableTracingRequest.traceConfig = config;
const enableTracingRequestProto = binaryEncode(
EnableTracingRequest.encode(enableTracingRequest).finish());
this.chromePort.postMessage(
{method: 'EnableTracing', requestData: enableTracingRequestProto});
}
// The 'cancel' method will end the tracing session and will NOT return the
// trace. Therefore, we do not need to keep the connection open.
cancel(): void {
if (!this.isPortConnected) return;
this.terminateConnection();
}
// The 'stop' method will end the tracing session and cause the trace to be
// returned via a callback. We maintain the connection to the target so we can
// extract the trace.
// See 'DisableTracing' in:
// https://perfetto.dev/docs/design-docs/life-of-a-tracing-session
stop(): void {
if (!this.isPortConnected) return;
this.chromePort.postMessage({method: 'DisableTracing'});
}
getCategories(): Promise<string[]> {
if (!this.isPortConnected) {
throw new RecordingError(
'Attempting to get categories from a ' +
'disconnected tracing session.');
}
if (this.pendingGetCategoriesMessage) {
return this.pendingGetCategoriesMessage;
}
this.chromePort.postMessage({method: 'GetCategories'});
return this.pendingGetCategoriesMessage = defer<string[]>();
}
async getTraceBufferUsage(): Promise<number> {
if (!this.isPortConnected) return 0;
const bufferStats = await this.getBufferStats();
let percentageUsed = -1;
for (const buffer of bufferStats) {
const used = assertExists(buffer.bytesWritten);
const total = assertExists(buffer.bufferSize);
if (total >= 0) {
percentageUsed = Math.max(percentageUsed, used / total);
}
}
if (percentageUsed === -1) {
throw new RecordingError(BUFFER_USAGE_INCORRECT_FORMAT);
}
return percentageUsed;
}
initConnection(): void {
this.chromePort.onMessage.addListener((message: ChromeExtensionMessage) => {
this.handleExtensionMessage(message);
});
}
private getBufferStats(): Promise<IBufferStats[]> {
this.chromePort.postMessage({method: 'GetTraceStats'});
const statsMessage = defer<IBufferStats[]>();
this.pendingStatsMessages.push(statsMessage);
return statsMessage;
}
private terminateConnection(): void {
this.chromePort.postMessage({method: 'FreeBuffers'});
this.clearState();
}
private clearState() {
this.chromePort.disconnect();
this.isPortConnected = false;
for (const statsMessage of this.pendingStatsMessages) {
statsMessage.reject(new RecordingError(BUFFER_USAGE_NOT_ACCESSIBLE));
}
this.pendingStatsMessages = [];
this.pendingGetCategoriesMessage = undefined;
}
private handleExtensionMessage(message: ChromeExtensionMessage) {
if (isChromeExtensionError(message)) {
this.terminateConnection();
this.tracingSessionListener.onError(message.error);
} else if (isChromeExtensionStatus(message)) {
this.tracingSessionListener.onStatus(message.status);
} else if (isReadBuffersResponse(message)) {
if (!message.slices) {
return;
}
for (const messageSlice of message.slices) {
// The extension sends the binary data as a string.
// see http://shortn/_oPmO2GT6Vb
if (typeof messageSlice.data !== 'string') {
throw new RecordingError(MALFORMED_EXTENSION_MESSAGE);
}
const decodedSlice = {
data: binaryDecode(messageSlice.data),
};
this.partialPacket.push(decodedSlice);
if (messageSlice.lastSliceForPacket) {
let bufferSize = 0;
for (const slice of this.partialPacket) {
bufferSize += slice.data!.length;
}
const completeTrace = new Uint8Array(bufferSize);
let written = 0;
for (const slice of this.partialPacket) {
const data = slice.data!;
completeTrace.set(data, written);
written += data.length;
}
// The trace already comes encoded as a proto.
this.tracingSessionListener.onTraceData(completeTrace);
this.terminateConnection();
}
}
} else if (isGetCategoriesResponse(message)) {
assertExists(this.pendingGetCategoriesMessage)
.resolve(message.categories);
this.pendingGetCategoriesMessage = undefined;
} else if (isEnableTracingResponse(message)) {
// Once the service notifies us that a tracing session is enabled,
// we can start streaming the response using 'ReadBuffers'.
this.chromePort.postMessage({method: 'ReadBuffers'});
} else if (isGetTraceStatsResponse(message)) {
const maybePendingStatsMessage = this.pendingStatsMessages.shift();
if (maybePendingStatsMessage) {
maybePendingStatsMessage.resolve(
message?.traceStats?.bufferStats || []);
}
} else if (isFreeBuffersResponse(message)) {
// No action required. If we successfully read a whole trace,
// we close the connection. Alternatively, if the tracing finishes
// with an exception or if the user cancels it, we also close the
// connection.
} else {
assertTrue(isDisableTracingResponse(message));
// No action required. Same reasoning as for FreeBuffers.
}
}
}