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