xref: /aosp_15_r20/external/perfetto/ui/src/frontend/error_dialog.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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