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