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