1// Copyright (C) 2019 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 m from 'mithril'; 16 17import {ErrorDetails} from '../base/logging'; 18import {EXTENSION_URL} from '../common/recordingV2/recording_utils'; 19import {TraceGcsUploader} from '../common/upload_utils'; 20import {RECORDING_V2_FLAG} from '../core/feature_flags'; 21import {raf} from '../core/raf_scheduler'; 22import {VERSION} from '../gen/perfetto_version'; 23import {getCurrentModalKey, showModal} from '../widgets/modal'; 24 25import {globals} from './globals'; 26 27const MODAL_KEY = 'crash_modal'; 28 29// Never show more than one dialog per 10s. 30const MIN_REPORT_PERIOD_MS = 10000; 31let timeLastReport = 0; 32 33export function maybeShowErrorDialog(err: ErrorDetails) { 34 const now = performance.now(); 35 36 // Here we rely on the exception message from onCannotGrowMemory function 37 if ( 38 err.message.includes('Cannot enlarge memory') || 39 err.stack.some((entry) => entry.name.includes('OutOfMemoryHandler')) || 40 err.stack.some((entry) => entry.name.includes('_emscripten_resize_heap')) || 41 err.stack.some((entry) => entry.name.includes('sbrk')) || 42 /^out of memory$/m.exec(err.message) 43 ) { 44 showOutOfMemoryDialog(); 45 // Refresh timeLastReport to prevent a different error showing a dialog 46 timeLastReport = now; 47 return; 48 } 49 50 if (!RECORDING_V2_FLAG.get()) { 51 if (err.message.includes('Unable to claim interface')) { 52 showWebUSBError(); 53 timeLastReport = now; 54 return; 55 } 56 57 if ( 58 err.message.includes('A transfer error has occurred') || 59 err.message.includes('The device was disconnected') || 60 err.message.includes('The transfer was cancelled') 61 ) { 62 showConnectionLostError(); 63 timeLastReport = now; 64 return; 65 } 66 } 67 68 if (err.message.includes('(ERR:fmt)')) { 69 showUnknownFileError(); 70 return; 71 } 72 73 if (err.message.includes('(ERR:rpc_seq)')) { 74 showRpcSequencingError(); 75 return; 76 } 77 78 if (timeLastReport > 0 && now - timeLastReport <= MIN_REPORT_PERIOD_MS) { 79 console.log('Suppressing crash dialog, last error notified too soon.'); 80 return; 81 } 82 timeLastReport = now; 83 84 // If we are already showing a crash dialog, don't overwrite it with a newer 85 // crash. Usually the first crash matters, the rest avalanching effects. 86 if (getCurrentModalKey() === MODAL_KEY) { 87 return; 88 } 89 90 showModal({ 91 key: MODAL_KEY, 92 title: 'Oops, something went wrong. Please file a bug.', 93 content: () => m(ErrorDialogComponent, err), 94 }); 95} 96 97class ErrorDialogComponent implements m.ClassComponent<ErrorDetails> { 98 private traceState: 99 | 'NOT_AVAILABLE' 100 | 'NOT_UPLOADED' 101 | 'UPLOADING' 102 | 'UPLOADED'; 103 private traceType: string = 'No trace loaded'; 104 private traceData?: ArrayBuffer | File; 105 private traceUrl?: string; 106 private attachTrace = false; 107 private uploadStatus = ''; 108 private userDescription = ''; 109 private errorMessage = ''; 110 private uploader?: TraceGcsUploader; 111 112 constructor() { 113 this.traceState = 'NOT_AVAILABLE'; 114 const engine = globals.getCurrentEngine(); 115 if (engine === undefined) return; 116 this.traceType = engine.source.type; 117 // If the trace is either already uploaded, or comes from a postmessage+url 118 // we don't need any re-upload. 119 if ('url' in engine.source && engine.source.url !== undefined) { 120 this.traceUrl = engine.source.url; 121 this.traceState = 'UPLOADED'; 122 // The trace is already uploaded, so assume the user is fine attaching to 123 // the bugreport (this make the checkbox ticked by default). 124 this.attachTrace = true; 125 return; 126 } 127 128 // If the user is not a googler, don't even offer the option to upload it. 129 if (!globals.isInternalUser) return; 130 131 if (engine.source.type === 'FILE') { 132 this.traceState = 'NOT_UPLOADED'; 133 this.traceData = engine.source.file; 134 // this.traceSize = this.traceData.size; 135 } else if (engine.source.type === 'ARRAY_BUFFER') { 136 this.traceData = engine.source.buffer; 137 // this.traceSize = this.traceData.byteLength; 138 } else { 139 return; // Can't upload HTTP+RPC. 140 } 141 this.traceState = 'NOT_UPLOADED'; 142 } 143 144 view(vnode: m.Vnode<ErrorDetails>) { 145 const err = vnode.attrs; 146 let msg = `UI: ${location.protocol}//${location.host}/${VERSION}\n\n`; 147 148 // Append the trace stack. 149 msg += `${err.message}\n`; 150 for (const entry of err.stack) { 151 msg += ` - ${entry.name} (${entry.location})\n`; 152 } 153 msg += '\n'; 154 155 // Append the trace URL. 156 if (this.attachTrace && this.traceUrl) { 157 msg += `Trace: ${this.traceUrl}\n`; 158 } else if (this.attachTrace && this.traceState === 'UPLOADING') { 159 msg += `Trace: uploading...\n`; 160 } else { 161 msg += `Trace: not available (${this.traceType}). Provide repro steps.\n`; 162 } 163 msg += `UA: ${navigator.userAgent}\n`; 164 msg += `Referrer: ${document.referrer}\n`; 165 this.errorMessage = msg; 166 167 let shareTraceSection: m.Vnode | null = null; 168 if (this.traceState !== 'NOT_AVAILABLE') { 169 shareTraceSection = m( 170 'div', 171 m( 172 'label', 173 m(`input[type=checkbox]`, { 174 checked: this.attachTrace, 175 oninput: (ev: InputEvent) => { 176 const checked = (ev.target as HTMLInputElement).checked; 177 this.onUploadCheckboxChange(checked); 178 }, 179 }), 180 this.traceState === 'UPLOADING' 181 ? `Uploading trace... ${this.uploadStatus}` 182 : 'Tick to share the current trace and help debugging', 183 ), // m('label') 184 m( 185 'div.modal-small', 186 `This will upload the trace and attach a link to the bug. 187 You may leave it unchecked and attach the trace manually to the bug 188 if preferred.`, 189 ), 190 ); 191 } // if (this.traceState !== 'NOT_AVAILABLE') 192 193 return [ 194 m( 195 'div', 196 m('.modal-logs', msg), 197 m( 198 'span', 199 `Please provide any additional details describing 200 how the crash occurred:`, 201 ), 202 m('textarea.modal-textarea', { 203 rows: 3, 204 maxlength: 1000, 205 oninput: (ev: InputEvent) => { 206 this.userDescription = (ev.target as HTMLTextAreaElement).value; 207 }, 208 onkeydown: (e: Event) => e.stopPropagation(), 209 onkeyup: (e: Event) => e.stopPropagation(), 210 }), 211 shareTraceSection, 212 ), 213 m( 214 'footer', 215 m( 216 'button.modal-btn.modal-btn-primary', 217 {onclick: () => this.fileBug(err)}, 218 'File a bug (Googlers only)', 219 ), 220 ), 221 ]; 222 } 223 224 private onUploadCheckboxChange(checked: boolean) { 225 raf.scheduleFullRedraw(); 226 this.attachTrace = checked; 227 228 if ( 229 checked && 230 this.traceData !== undefined && 231 this.traceState === 'NOT_UPLOADED' 232 ) { 233 this.traceState = 'UPLOADING'; 234 this.uploadStatus = ''; 235 const uploader = new TraceGcsUploader(this.traceData, () => { 236 raf.scheduleFullRedraw(); 237 this.uploadStatus = uploader.getEtaString(); 238 if (uploader.state === 'UPLOADED') { 239 this.traceState = 'UPLOADED'; 240 this.traceUrl = uploader.uploadedUrl; 241 } else if (uploader.state === 'ERROR') { 242 this.traceState = 'NOT_UPLOADED'; 243 this.uploadStatus = uploader.error; 244 } 245 }); 246 this.uploader = uploader; 247 } else if (!checked && this.uploader) { 248 this.uploader.abort(); 249 } 250 } 251 252 private fileBug(err: ErrorDetails) { 253 const errTitle = err.message.split('\n', 1)[0].substring(0, 80); 254 let url = 'https://goto.google.com/perfetto-ui-bug'; 255 url += '?title=' + encodeURIComponent(`UI Error: ${errTitle}`); 256 url += '&description='; 257 if (this.userDescription !== '') { 258 url += encodeURIComponent( 259 'User description:\n' + this.userDescription + '\n\n', 260 ); 261 } 262 url += encodeURIComponent(this.errorMessage); 263 // 8kb is common limit on request size so restrict links to that long: 264 url = url.substring(0, 8000); 265 window.open(url, '_blank'); 266 } 267} 268 269function showOutOfMemoryDialog() { 270 const url = 271 'https://perfetto.dev/docs/quickstart/trace-analysis#get-trace-processor'; 272 273 const tpCmd = 274 'curl -LO https://get.perfetto.dev/trace_processor\n' + 275 'chmod +x ./trace_processor\n' + 276 'trace_processor --httpd /path/to/trace.pftrace\n' + 277 '# Reload the UI, it will prompt to use the HTTP+RPC interface'; 278 showModal({ 279 title: 'Oops! Your WASM trace processor ran out of memory', 280 content: m( 281 'div', 282 m( 283 'span', 284 'The in-memory representation of the trace is too big ' + 285 'for the browser memory limits (typically 2GB per tab).', 286 ), 287 m('br'), 288 m( 289 'span', 290 'You can work around this problem by using the trace_processor ' + 291 'native binary as an accelerator for the UI as follows:', 292 ), 293 m('br'), 294 m('br'), 295 m('.modal-bash', tpCmd), 296 m('br'), 297 m('span', 'For details see '), 298 m('a', {href: url, target: '_blank'}, url), 299 ), 300 }); 301} 302 303function showUnknownFileError() { 304 showModal({ 305 title: 'Cannot open this file', 306 content: m( 307 'div', 308 m( 309 'p', 310 "The file opened doesn't look like a Perfetto trace or any " + 311 'other format recognized by the Perfetto TraceProcessor.', 312 ), 313 m('p', 'Formats supported:'), 314 m( 315 'ul', 316 m('li', 'Perfetto protobuf trace'), 317 m('li', 'chrome://tracing JSON'), 318 m('li', 'Android systrace'), 319 m('li', 'Fuchsia trace'), 320 m('li', 'Ninja build log'), 321 ), 322 ), 323 }); 324} 325 326function showWebUSBError() { 327 showModal({ 328 title: 'A WebUSB error occurred', 329 content: m( 330 'div', 331 m( 332 'span', 333 `Is adb already running on the host? Run this command and 334 try again.`, 335 ), 336 m('br'), 337 m('.modal-bash', '> adb kill-server'), 338 m('br'), 339 m('span', 'For details see '), 340 m('a', {href: 'http://b/159048331', target: '_blank'}, 'b/159048331'), 341 ), 342 }); 343} 344 345export function showWebUSBErrorV2() { 346 showModal({ 347 title: 'A WebUSB error occurred', 348 content: m( 349 'div', 350 m( 351 'span', 352 `Is adb already running on the host? Run this command and 353 try again.`, 354 ), 355 m('br'), 356 m('.modal-bash', '> adb kill-server'), 357 m('br'), 358 // The statement below covers the following edge case: 359 // 1. 'adb server' is running on the device. 360 // 2. The user selects the new Android target, so we try to fetch the 361 // OS version and do QSS. 362 // 3. The error modal is shown. 363 // 4. The user runs 'adb kill-server'. 364 // At this point we don't have a trigger to try fetching the OS version 365 // + QSS again. Therefore, the user will need to refresh the page. 366 m( 367 'span', 368 "If after running 'adb kill-server', you don't see " + 369 "a 'Start Recording' button on the page and you don't see " + 370 "'Allow USB debugging' on the device, " + 371 'you will need to reload this page.', 372 ), 373 m('br'), 374 m('br'), 375 m('span', 'For details see '), 376 m('a', {href: 'http://b/159048331', target: '_blank'}, 'b/159048331'), 377 ), 378 }); 379} 380 381export function showConnectionLostError(): void { 382 showModal({ 383 title: 'Connection with the ADB device lost', 384 content: m( 385 'div', 386 m('span', `Please connect the device again to restart the recording.`), 387 m('br'), 388 ), 389 }); 390} 391 392export function showAllowUSBDebugging(): void { 393 showModal({ 394 title: 'Could not connect to the device', 395 content: m( 396 'div', 397 m('span', 'Please allow USB debugging on the device.'), 398 m('br'), 399 ), 400 }); 401} 402 403export function showNoDeviceSelected(): void { 404 showModal({ 405 title: 'No device was selected for recording', 406 content: m( 407 'div', 408 m( 409 'span', 410 `If you want to connect to an ADB device, 411 please select it from the list.`, 412 ), 413 m('br'), 414 ), 415 }); 416} 417 418export function showExtensionNotInstalled(): void { 419 showModal({ 420 title: 'Perfetto Chrome extension not installed', 421 content: m( 422 'div', 423 m( 424 '.note', 425 `To trace Chrome from the Perfetto UI, you need to install our `, 426 m('a', {href: EXTENSION_URL, target: '_blank'}, 'Chrome extension'), 427 ' and then reload this page.', 428 ), 429 m('br'), 430 ), 431 }); 432} 433 434export function showWebsocketConnectionIssue(message: string): void { 435 showModal({ 436 title: 'Unable to connect to the device via websocket', 437 content: m('div', m('span', message), m('br')), 438 }); 439} 440 441export function showIssueParsingTheTracedResponse(message: string): void { 442 showModal({ 443 title: 444 'A problem was encountered while connecting to' + 445 ' the Perfetto tracing service', 446 content: m('div', m('span', message), m('br')), 447 }); 448} 449 450export function showFailedToPushBinary(message: string): void { 451 showModal({ 452 title: 'Failed to push a binary to the device', 453 content: m( 454 'div', 455 m( 456 'span', 457 'This can happen if your Android device has an OS version lower ' + 458 'than Q. Perfetto tried to push the latest version of its ' + 459 'embedded binary but failed.', 460 ), 461 m('br'), 462 m('br'), 463 m('span', 'Error message:'), 464 m('br'), 465 m('span', message), 466 ), 467 }); 468} 469 470function showRpcSequencingError() { 471 showModal({ 472 title: 'A TraceProcessor RPC error occurred', 473 content: m( 474 'div', 475 m('p', 'The trace processor RPC sequence ID was broken'), 476 m( 477 'p', 478 `This can happen when using a HTTP trace processor instance and 479either accidentally sharing this between multiple tabs or 480restarting the trace processor while still in use by UI.`, 481 ), 482 m( 483 'p', 484 `Please refresh this tab and ensure that trace processor is used 485at most one tab at a time.`, 486 ), 487 ), 488 }); 489} 490