• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (C) 2018 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 {Message, Method, rpc, RPCImplCallback} from 'protobufjs';
16
17import {base64Encode} from '../base/string_utils';
18import {Actions} from '../common/actions';
19import {TRACE_SUFFIX} from '../common/constants';
20import {
21  ConsumerPort,
22  TraceConfig,
23} from '../common/protos';
24import {genTraceConfig} from '../common/recordingV2/recording_config_utils';
25import {TargetInfo} from '../common/recordingV2/recording_interfaces_v2';
26import {
27  AdbRecordingTarget,
28  isAdbTarget,
29  isChromeTarget,
30  RecordingTarget,
31} from '../common/state';
32import {globals} from '../frontend/globals';
33import {publishBufferUsage, publishTrackData} from '../frontend/publish';
34
35import {AdbOverWebUsb} from './adb';
36import {AdbConsumerPort} from './adb_shell_controller';
37import {AdbSocketConsumerPort} from './adb_socket_controller';
38import {ChromeExtensionConsumerPort} from './chrome_proxy_record_controller';
39import {
40  ConsumerPortResponse,
41  GetTraceStatsResponse,
42  isDisableTracingResponse,
43  isEnableTracingResponse,
44  isFreeBuffersResponse,
45  isGetTraceStatsResponse,
46  isReadBuffersResponse,
47} from './consumer_port_types';
48import {Controller} from './controller';
49import {RecordConfig} from './record_config_types';
50import {Consumer, RpcConsumerPort} from './record_controller_interfaces';
51
52type RPCImplMethod = (Method|rpc.ServiceMethod<Message<{}>, Message<{}>>);
53
54export function genConfigProto(
55    uiCfg: RecordConfig, target: RecordingTarget): Uint8Array {
56  return TraceConfig.encode(convertToRecordingV2Input(uiCfg, target)).finish();
57}
58
59// This method converts the 'RecordingTarget' to the 'TargetInfo' used by V2 of
60// the recording code. It is used so the logic is not duplicated and does not
61// diverge.
62// TODO(octaviant) delete this once we switch to RecordingV2.
63function convertToRecordingV2Input(
64    uiCfg: RecordConfig, target: RecordingTarget): TraceConfig {
65  let targetType: 'ANDROID'|'CHROME'|'CHROME_OS'|'LINUX';
66  let androidApiLevel!: number;
67  switch (target.os) {
68    case 'L':
69      targetType = 'LINUX';
70      break;
71    case 'C':
72      targetType = 'CHROME';
73      break;
74    case 'CrOS':
75      targetType = 'CHROME_OS';
76      break;
77    case 'S':
78      androidApiLevel = 31;
79      targetType = 'ANDROID';
80      break;
81    case 'R':
82      androidApiLevel = 30;
83      targetType = 'ANDROID';
84      break;
85    case 'Q':
86      androidApiLevel = 29;
87      targetType = 'ANDROID';
88      break;
89    case 'P':
90      androidApiLevel = 28;
91      targetType = 'ANDROID';
92      break;
93    default:
94      androidApiLevel = 26;
95      targetType = 'ANDROID';
96  }
97
98  let targetInfo: TargetInfo;
99  if (targetType === 'ANDROID') {
100    targetInfo = {
101      targetType,
102      androidApiLevel,
103      dataSources: [],
104      name: '',
105    };
106  } else if (targetType === 'CHROME' || targetType === 'CHROME_OS') {
107    targetInfo = {
108      targetType,
109      dataSources: [],
110      name: '',
111    };
112  } else {
113    targetInfo = {targetType, dataSources: [], name: ''};
114  }
115
116  return genTraceConfig(uiCfg, targetInfo);
117}
118
119export function toPbtxt(configBuffer: Uint8Array): string {
120  const msg = TraceConfig.decode(configBuffer);
121  const json = msg.toJSON();
122  function snakeCase(s: string): string {
123    return s.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase());
124  }
125  // With the ahead of time compiled protos we can't seem to tell which
126  // fields are enums.
127  function isEnum(value: string): boolean {
128    return value.startsWith('MEMINFO_') || value.startsWith('VMSTAT_') ||
129        value.startsWith('STAT_') || value.startsWith('LID_') ||
130        value.startsWith('BATTERY_COUNTER_') || value === 'DISCARD' ||
131        value === 'RING_BUFFER' || value === 'BACKGROUND' ||
132        value === 'USER_INITIATED';
133  }
134  // Since javascript doesn't have 64 bit numbers when converting protos to
135  // json the proto library encodes them as strings. This is lossy since
136  // we can't tell which strings that look like numbers are actually strings
137  // and which are actually numbers. Ideally we would reflect on the proto
138  // definition somehow but for now we just hard code keys which have this
139  // problem in the config.
140  function is64BitNumber(key: string): boolean {
141    return [
142      'maxFileSizeBytes',
143      'pid',
144      'samplingIntervalBytes',
145      'shmemSizeBytes',
146      'timestampUnitMultiplier',
147    ].includes(key);
148  }
149  function* message(msg: {}, indent: number): IterableIterator<string> {
150    for (const [key, value] of Object.entries(msg)) {
151      const isRepeated = Array.isArray(value);
152      const isNested = typeof value === 'object' && !isRepeated;
153      for (const entry of (isRepeated ? value as Array<{}> : [value])) {
154        yield ' '.repeat(indent) + `${snakeCase(key)}${isNested ? '' : ':'} `;
155        if (typeof entry === 'string') {
156          if (isEnum(entry) || is64BitNumber(key)) {
157            yield entry;
158          } else {
159            yield `"${entry.replace(new RegExp('"', 'g'), '\\"')}"`;
160          }
161        } else if (typeof entry === 'number') {
162          yield entry.toString();
163        } else if (typeof entry === 'boolean') {
164          yield entry.toString();
165        } else if (typeof entry === 'object' && entry !== null) {
166          yield '{\n';
167          yield* message(entry, indent + 4);
168          yield ' '.repeat(indent) + '}';
169        } else {
170          throw new Error(`Record proto entry "${entry}" with unexpected type ${
171              typeof entry}`);
172        }
173        yield '\n';
174      }
175    }
176  }
177  return [...message(json, 0)].join('');
178}
179
180export class RecordController extends Controller<'main'> implements Consumer {
181  private config: RecordConfig|null = null;
182  private readonly extensionPort: MessagePort;
183  private recordingInProgress = false;
184  private consumerPort: ConsumerPort;
185  private traceBuffer: Uint8Array[] = [];
186  private bufferUpdateInterval: ReturnType<typeof setTimeout>|undefined;
187  private adb = new AdbOverWebUsb();
188  private recordedTraceSuffix = TRACE_SUFFIX;
189  private fetchedCategories = false;
190
191  // We have a different controller for each targetOS. The correct one will be
192  // created when needed, and stored here. When the key is a string, it is the
193  // serial of the target (used for android devices). When the key is a single
194  // char, it is the 'targetOS'
195  private controllerPromises = new Map<string, Promise<RpcConsumerPort>>();
196
197  constructor(args: {extensionPort: MessagePort}) {
198    super('main');
199    this.consumerPort = ConsumerPort.create(this.rpcImpl.bind(this));
200    this.extensionPort = args.extensionPort;
201  }
202
203  run() {
204    // TODO(eseckler): Use ConsumerPort's QueryServiceState instead
205    // of posting a custom extension message to retrieve the category list.
206    if (globals.state.fetchChromeCategories && !this.fetchedCategories) {
207      this.fetchedCategories = true;
208      if (globals.state.extensionInstalled) {
209        this.extensionPort.postMessage({method: 'GetCategories'});
210      }
211      globals.dispatch(Actions.setFetchChromeCategories({fetch: false}));
212    }
213    if (globals.state.recordConfig === this.config &&
214        globals.state.recordingInProgress === this.recordingInProgress) {
215      return;
216    }
217    this.config = globals.state.recordConfig;
218
219    const configProto =
220        genConfigProto(this.config, globals.state.recordingTarget);
221    const configProtoText = toPbtxt(configProto);
222    const configProtoBase64 = base64Encode(configProto);
223    const commandline = `
224      echo '${configProtoBase64}' |
225      base64 --decode |
226      adb shell "perfetto -c - -o /data/misc/perfetto-traces/trace" &&
227      adb pull /data/misc/perfetto-traces/trace /tmp/trace
228    `;
229    const traceConfig =
230        convertToRecordingV2Input(this.config, globals.state.recordingTarget);
231    // TODO(hjd): This should not be TrackData after we unify the stores.
232    publishTrackData({
233      id: 'config',
234      data: {
235        commandline,
236        pbBase64: configProtoBase64,
237        pbtxt: configProtoText,
238        traceConfig,
239      },
240    });
241
242    // If the recordingInProgress boolean state is different, it means that we
243    // have to start or stop recording a trace.
244    if (globals.state.recordingInProgress === this.recordingInProgress) return;
245    this.recordingInProgress = globals.state.recordingInProgress;
246
247    if (this.recordingInProgress) {
248      this.startRecordTrace(traceConfig);
249    } else {
250      this.stopRecordTrace();
251    }
252  }
253
254  startRecordTrace(traceConfig: TraceConfig) {
255    this.scheduleBufferUpdateRequests();
256    this.traceBuffer = [];
257    this.consumerPort.enableTracing({traceConfig});
258  }
259
260  stopRecordTrace() {
261    if (this.bufferUpdateInterval) clearInterval(this.bufferUpdateInterval);
262    this.consumerPort.disableTracing({});
263  }
264
265  scheduleBufferUpdateRequests() {
266    if (this.bufferUpdateInterval) clearInterval(this.bufferUpdateInterval);
267    this.bufferUpdateInterval = setInterval(() => {
268      this.consumerPort.getTraceStats({});
269    }, 200);
270  }
271
272  readBuffers() {
273    this.consumerPort.readBuffers({});
274  }
275
276  onConsumerPortResponse(data: ConsumerPortResponse) {
277    if (data === undefined) return;
278    if (isReadBuffersResponse(data)) {
279      if (!data.slices || data.slices.length === 0) return;
280      // TODO(nicomazz): handle this as intended by consumer_port.proto.
281      console.assert(data.slices.length === 1);
282      if (data.slices[0].data) this.traceBuffer.push(data.slices[0].data);
283      // The line underneath is 'misusing' the format ReadBuffersResponse.
284      // The boolean field 'lastSliceForPacket' is used as 'lastPacketInTrace'.
285      // See http://shortn/_53WB8A1aIr.
286      if (data.slices[0].lastSliceForPacket) this.onTraceComplete();
287    } else if (isEnableTracingResponse(data)) {
288      this.readBuffers();
289    } else if (isGetTraceStatsResponse(data)) {
290      const percentage = this.getBufferUsagePercentage(data);
291      if (percentage) {
292        publishBufferUsage({percentage});
293      }
294    } else if (isFreeBuffersResponse(data)) {
295      // No action required.
296    } else if (isDisableTracingResponse(data)) {
297      // No action required.
298    } else {
299      console.error('Unrecognized consumer port response:', data);
300    }
301  }
302
303  onTraceComplete() {
304    this.consumerPort.freeBuffers({});
305    globals.dispatch(Actions.setRecordingStatus({status: undefined}));
306    if (globals.state.recordingCancelled) {
307      globals.dispatch(
308          Actions.setLastRecordingError({error: 'Recording cancelled.'}));
309      this.traceBuffer = [];
310      return;
311    }
312    const trace = this.generateTrace();
313    globals.dispatch(Actions.openTraceFromBuffer({
314      title: 'Recorded trace',
315      buffer: trace.buffer,
316      fileName: `recorded_trace${this.recordedTraceSuffix}`,
317    }));
318    this.traceBuffer = [];
319  }
320
321  // TODO(nicomazz): stream each chunk into the trace processor, instead of
322  // creating a big long trace.
323  generateTrace() {
324    let traceLen = 0;
325    for (const chunk of this.traceBuffer) traceLen += chunk.length;
326    const completeTrace = new Uint8Array(traceLen);
327    let written = 0;
328    for (const chunk of this.traceBuffer) {
329      completeTrace.set(chunk, written);
330      written += chunk.length;
331    }
332    return completeTrace;
333  }
334
335  getBufferUsagePercentage(data: GetTraceStatsResponse): number {
336    if (!data.traceStats || !data.traceStats.bufferStats) return 0.0;
337    let maximumUsage = 0;
338    for (const buffer of data.traceStats.bufferStats) {
339      const used = buffer.bytesWritten as number;
340      const total = buffer.bufferSize as number;
341      maximumUsage = Math.max(maximumUsage, used / total);
342    }
343    return maximumUsage;
344  }
345
346  onError(message: string) {
347    // TODO(octaviant): b/204998302
348    console.error('Error in record controller: ', message);
349    globals.dispatch(
350        Actions.setLastRecordingError({error: message.substr(0, 150)}));
351    globals.dispatch(Actions.stopRecording({}));
352  }
353
354  onStatus(message: string) {
355    globals.dispatch(Actions.setRecordingStatus({status: message}));
356  }
357
358  // Depending on the recording target, different implementation of the
359  // consumer_port will be used.
360  // - Chrome target: This forwards the messages that have to be sent
361  // to the extension to the frontend. This is necessary because this
362  // controller is running in a separate worker, that can't directly send
363  // messages to the extension.
364  // - Android device target: WebUSB is used to communicate using the adb
365  // protocol. Actually, there is no full consumer_port implementation, but
366  // only the support to start tracing and fetch the file.
367  async getTargetController(target: RecordingTarget): Promise<RpcConsumerPort> {
368    const identifier = RecordController.getTargetIdentifier(target);
369
370    // The reason why caching the target 'record controller' Promise is that
371    // multiple rcp calls can happen while we are trying to understand if an
372    // android device has a socket connection available or not.
373    const precedentPromise = this.controllerPromises.get(identifier);
374    if (precedentPromise) return precedentPromise;
375
376    const controllerPromise =
377        new Promise<RpcConsumerPort>(async (resolve, _) => {
378          let controller: RpcConsumerPort|undefined = undefined;
379          if (isChromeTarget(target)) {
380            controller =
381                new ChromeExtensionConsumerPort(this.extensionPort, this);
382          } else if (isAdbTarget(target)) {
383            this.onStatus(`Please allow USB debugging on device.
384                 If you press cancel, reload the page.`);
385            const socketAccess = await this.hasSocketAccess(target);
386
387            controller = socketAccess ?
388                new AdbSocketConsumerPort(this.adb, this) :
389                new AdbConsumerPort(this.adb, this);
390          } else {
391            throw Error(`No device connected`);
392          }
393
394          if (!controller) throw Error(`Unknown target: ${target}`);
395          resolve(controller);
396        });
397
398    this.controllerPromises.set(identifier, controllerPromise);
399    return controllerPromise;
400  }
401
402  private static getTargetIdentifier(target: RecordingTarget): string {
403    return isAdbTarget(target) ? target.serial : target.os;
404  }
405
406  private async hasSocketAccess(target: AdbRecordingTarget) {
407    const devices = await navigator.usb.getDevices();
408    const device = devices.find((d) => d.serialNumber === target.serial);
409    console.assert(device);
410    if (!device) return Promise.resolve(false);
411    return AdbSocketConsumerPort.hasSocketAccess(device, this.adb);
412  }
413
414  private async rpcImpl(
415      method: RPCImplMethod, requestData: Uint8Array,
416      _callback: RPCImplCallback) {
417    try {
418      const state = globals.state;
419      // TODO(hjd): This is a bit weird. We implicitly send each RPC message to
420      // whichever target is currently selected (creating that target if needed)
421      // it would be nicer if the setup/teardown was more explicit.
422      const target = await this.getTargetController(state.recordingTarget);
423      this.recordedTraceSuffix = target.getRecordedTraceSuffix();
424      target.handleCommand(method.name, requestData);
425    } catch (e) {
426      console.error(`error invoking ${method}: ${e.message}`);
427    }
428  }
429}
430