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