ContentCrashHandlers.sys.mjs (40043B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; 6 7 const lazy = {}; 8 9 ChromeUtils.defineESModuleGetters(lazy, { 10 BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", 11 CrashSubmit: "resource://gre/modules/CrashSubmit.sys.mjs", 12 E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", 13 RemoteSettingsCrashPull: 14 "resource://gre/modules/RemoteSettingsCrashPull.sys.mjs", 15 SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", 16 clearTimeout: "resource://gre/modules/Timer.sys.mjs", 17 setTimeout: "resource://gre/modules/Timer.sys.mjs", 18 }); 19 20 // We don't process crash reports older than 28 days, so don't bother 21 // submitting them 22 const PENDING_CRASH_REPORT_DAYS = 28; 23 const DAY = 24 * 60 * 60 * 1000; // milliseconds 24 const DAYS_TO_SUPPRESS = 30; 25 const MAX_UNSEEN_CRASHED_CHILD_IDS = 20; 26 const MAX_UNSEEN_CRASHED_SUBFRAME_IDS = 10; 27 28 // Make sure that we will not prompt the user more than once a week 29 const SILENCE_FOR_DAYS_IN_S = 7 * 86400; 30 31 // Time after which we will begin scanning for unsubmitted crash reports 32 const CHECK_FOR_UNSUBMITTED_CRASH_REPORTS_DELAY_MS = 60 * 10000; // 10 minutes 33 34 // This is SIGUSR1 and indicates a user-invoked crash 35 const EXIT_CODE_CONTENT_CRASHED = 245; 36 37 const TABCRASHED_ICON_URI = "chrome://browser/skin/tab-crashed.svg"; 38 39 const SUBFRAMECRASH_LEARNMORE_URI = 40 "https://support.mozilla.org/kb/firefox-crashes-troubleshoot-prevent-and-get-help"; 41 42 /** 43 * BrowserWeakMap is exactly like a WeakMap, but expects <xul:browser> 44 * objects only. 45 * 46 * Under the hood, BrowserWeakMap keys the map off of the <xul:browser> 47 * permanentKey. If, however, the browser has never gotten a permanentKey, 48 * it falls back to keying on the <xul:browser> element itself. 49 */ 50 class BrowserWeakMap extends WeakMap { 51 get(browser) { 52 if (browser.permanentKey) { 53 return super.get(browser.permanentKey); 54 } 55 return super.get(browser); 56 } 57 58 set(browser, value) { 59 if (browser.permanentKey) { 60 return super.set(browser.permanentKey, value); 61 } 62 return super.set(browser, value); 63 } 64 65 delete(browser) { 66 if (browser.permanentKey) { 67 return super.delete(browser.permanentKey); 68 } 69 return super.delete(browser); 70 } 71 } 72 73 export var TabCrashHandler = { 74 _crashedTabCount: 0, 75 childMap: new Map(), 76 browserMap: new BrowserWeakMap(), 77 notificationsMap: new Map(), 78 unseenCrashedChildIDs: [], 79 pendingSubFrameCrashes: new Map(), 80 pendingSubFrameCrashesIDs: [], 81 crashedBrowserQueues: new Map(), 82 restartRequiredBrowsers: new WeakSet(), 83 testBuildIDMismatch: false, 84 85 get prefs() { 86 delete this.prefs; 87 return (this.prefs = Services.prefs.getBranch( 88 "browser.tabs.crashReporting." 89 )); 90 }, 91 92 init() { 93 if (this.initialized) { 94 return; 95 } 96 this.initialized = true; 97 98 Services.obs.addObserver(this, "ipc:content-shutdown"); 99 Services.obs.addObserver(this, "oop-frameloader-crashed"); 100 }, 101 102 observe(aSubject, aTopic) { 103 switch (aTopic) { 104 case "ipc:content-shutdown": { 105 aSubject.QueryInterface(Ci.nsIPropertyBag2); 106 107 if (!aSubject.get("abnormal")) { 108 return; 109 } 110 111 let childID = aSubject.get("childID"); 112 let dumpID = aSubject.get("dumpID"); 113 114 // Get and remove the subframe crash info first. 115 let subframeCrashItem = this.getAndRemoveSubframeCrash(childID); 116 117 if (!dumpID) { 118 Glean.browserContentCrash.dumpUnavailable.add(1); 119 } else if (AppConstants.MOZ_CRASHREPORTER) { 120 this.childMap.set(childID, dumpID); 121 122 // If this is a subframe crash, show the crash notification. Only 123 // show subframe notifications when there is a minidump available. 124 if (subframeCrashItem) { 125 let browsers = 126 ChromeUtils.nondeterministicGetWeakMapKeys(subframeCrashItem) || 127 []; 128 for (let browserItem of browsers) { 129 let browser = subframeCrashItem.get(browserItem); 130 if (browser.isConnected && !browser.ownerGlobal.closed) { 131 this.showSubFrameNotification(browser, childID, dumpID); 132 } 133 } 134 } 135 } 136 137 if (!this.flushCrashedBrowserQueue(childID)) { 138 this.unseenCrashedChildIDs.push(childID); 139 // The elements in unseenCrashedChildIDs will only be removed if 140 // the tab crash page is shown. However, ipc:content-shutdown might 141 // be fired for processes for which we'll never show the tab crash 142 // page - for example, the thumbnailing process. Another case to 143 // consider is if the user is configured to submit backlogged crash 144 // reports automatically, and a background tab crashes. In that case, 145 // we will never show the tab crash page, and never remove the element 146 // from the list. 147 // 148 // Instead of trying to account for all of those cases, we prevent 149 // this list from getting too large by putting a reasonable upper 150 // limit on how many childIDs we track. It's unlikely that this 151 // array would ever get so large as to be unwieldy (that'd be a lot 152 // or crashes!), but a leak is a leak. 153 if ( 154 this.unseenCrashedChildIDs.length > MAX_UNSEEN_CRASHED_CHILD_IDS 155 ) { 156 this.unseenCrashedChildIDs.shift(); 157 } 158 } 159 160 // check for environment affecting crash reporting 161 let shutdown = Services.env.exists("MOZ_CRASHREPORTER_SHUTDOWN"); 162 163 if (shutdown) { 164 dump( 165 "A content process crashed and MOZ_CRASHREPORTER_SHUTDOWN is " + 166 "set, shutting down\n" 167 ); 168 Services.startup.quit( 169 Ci.nsIAppStartup.eForceQuit, 170 EXIT_CODE_CONTENT_CRASHED 171 ); 172 } 173 174 break; 175 } 176 case "oop-frameloader-crashed": { 177 let browser = aSubject.ownerElement; 178 if (!browser) { 179 return; 180 } 181 182 this.browserMap.set(browser, aSubject.childID); 183 break; 184 } 185 } 186 }, 187 188 /** 189 * This should be called once a content process has finished 190 * shutting down abnormally. Any tabbrowser browsers that were 191 * selected at the time of the crash will then be sent to 192 * the crashed tab page. 193 * 194 * @param childID (int) 195 * The childID of the content process that just crashed. 196 * @returns boolean 197 * True if one or more browsers were sent to the tab crashed 198 * page. 199 */ 200 flushCrashedBrowserQueue(childID) { 201 let browserQueue = this.crashedBrowserQueues.get(childID); 202 if (!browserQueue) { 203 return false; 204 } 205 206 this.crashedBrowserQueues.delete(childID); 207 208 let sentBrowser = false; 209 for (let weakBrowser of browserQueue) { 210 let browser = weakBrowser.get(); 211 if (browser) { 212 if ( 213 this.restartRequiredBrowsers.has(browser) || 214 this.testBuildIDMismatch 215 ) { 216 this.sendToRestartRequiredPage(browser); 217 } else { 218 this.sendToTabCrashedPage(browser); 219 } 220 sentBrowser = true; 221 } 222 } 223 224 return sentBrowser; 225 }, 226 227 /** 228 * Called by a tabbrowser when it notices that its selected browser 229 * has crashed. This will queue the browser to show the tab crash 230 * page once the content process has finished tearing down. 231 * 232 * @param browser (<xul:browser>) 233 * The selected browser that just crashed. 234 * @param restartRequired (bool) 235 * Whether or not a browser restart is required to recover. 236 */ 237 onSelectedBrowserCrash(browser, restartRequired) { 238 if (!browser.isRemoteBrowser) { 239 console.error("Selected crashed browser is not remote."); 240 return; 241 } 242 if (!browser.frameLoader) { 243 console.error("Selected crashed browser has no frameloader."); 244 return; 245 } 246 247 let childID = browser.frameLoader.childID; 248 249 let browserQueue = this.crashedBrowserQueues.get(childID); 250 if (!browserQueue) { 251 browserQueue = []; 252 this.crashedBrowserQueues.set(childID, browserQueue); 253 } 254 // It's probably unnecessary to store this browser as a 255 // weak reference, since the content process should complete 256 // its teardown in the same tick of the event loop, and then 257 // this queue will be flushed. The weak reference is to avoid 258 // leaking browsers in case anything goes wrong during this 259 // teardown process. 260 browserQueue.push(Cu.getWeakReference(browser)); 261 262 if (restartRequired) { 263 this.restartRequiredBrowsers.add(browser); 264 } 265 266 // In the event that the content process failed to launch, then 267 // the childID will be 0. In that case, we will never receive 268 // a dumpID nor an ipc:content-shutdown observer notification, 269 // so we should flush the queue for childID 0 immediately. 270 if (childID == 0) { 271 this.flushCrashedBrowserQueue(0); 272 } 273 }, 274 275 /** 276 * Called by a tabbrowser when it notices that a background browser 277 * has crashed. This will flip its remoteness to non-remote, and attempt 278 * to revive the crashed tab so that upon selection the tab either shows 279 * an error page, or automatically restores. 280 * 281 * @param browser (<xul:browser>) 282 * The background browser that just crashed. 283 * @param restartRequired (bool) 284 * Whether or not a browser restart is required to recover. 285 */ 286 onBackgroundBrowserCrash(browser, restartRequired) { 287 if (restartRequired) { 288 this.restartRequiredBrowsers.add(browser); 289 } 290 291 let gBrowser = browser.getTabBrowser(); 292 let tab = gBrowser.getTabForBrowser(browser); 293 294 gBrowser.updateBrowserRemoteness(browser, { 295 remoteType: lazy.E10SUtils.NOT_REMOTE, 296 }); 297 298 lazy.SessionStore.reviveCrashedTab(tab); 299 }, 300 301 /** 302 * Called when a subframe crashes. If the dump is available, shows a subframe 303 * crashed notification, otherwise waits for one to be available. 304 * 305 * @param browser (<xul:browser>) 306 * The browser containing the frame that just crashed. 307 * @param childId 308 * The id of the process that just crashed. 309 */ 310 async onSubFrameCrash(browser, childID) { 311 if (!AppConstants.MOZ_CRASHREPORTER) { 312 return; 313 } 314 315 // If a crash dump is available, use it. Otherwise, add the child id to the pending 316 // subframe crashes list, and wait for the crash "ipc:content-shutdown" notification 317 // to get the minidump. If it never arrives, don't show the notification. 318 let dumpID = this.childMap.get(childID); 319 if (dumpID) { 320 this.showSubFrameNotification(browser, childID, dumpID); 321 } else { 322 let item = this.pendingSubFrameCrashes.get(childID); 323 if (!item) { 324 item = new BrowserWeakMap(); 325 this.pendingSubFrameCrashes.set(childID, item); 326 327 // Add the childID to an array that only has room for MAX_UNSEEN_CRASHED_SUBFRAME_IDS 328 // items. If there is no more room, pop the oldest off and remove it. This technique 329 // is used instead of a timeout. 330 if ( 331 this.pendingSubFrameCrashesIDs.length >= 332 MAX_UNSEEN_CRASHED_SUBFRAME_IDS 333 ) { 334 let idToDelete = this.pendingSubFrameCrashesIDs.shift(); 335 this.pendingSubFrameCrashes.delete(idToDelete); 336 } 337 this.pendingSubFrameCrashesIDs.push(childID); 338 } 339 item.set(browser, browser); 340 } 341 }, 342 343 /** 344 * Given a childID, retrieve the subframe crash info for it 345 * from the pendingSubFrameCrashes map. The data is removed 346 * from the map and returned. 347 * 348 * @param childID number 349 * childID of the content that crashed. 350 * @returns subframe crash info added by previous call to onSubFrameCrash. 351 */ 352 getAndRemoveSubframeCrash(childID) { 353 let item = this.pendingSubFrameCrashes.get(childID); 354 if (item) { 355 this.pendingSubFrameCrashes.delete(childID); 356 let idx = this.pendingSubFrameCrashesIDs.indexOf(childID); 357 if (idx >= 0) { 358 this.pendingSubFrameCrashesIDs.splice(idx, 1); 359 } 360 } 361 362 return item; 363 }, 364 365 /** 366 * Called to indicate that a subframe within a browser has crashed. A notification 367 * bar will be shown. 368 * 369 * @param browser (<xul:browser>) 370 * The browser containing the frame that just crashed. 371 * @param childId 372 * The id of the process that just crashed. 373 * @param dumpID 374 * Minidump id of the crash. 375 */ 376 async showSubFrameNotification(browser, childID, dumpID) { 377 let gBrowser = browser.getTabBrowser(); 378 let notificationBox = gBrowser.getNotificationBox(browser); 379 380 const value = "subframe-crashed"; 381 let notification = notificationBox.getNotificationWithValue(value); 382 if (notification) { 383 // Don't show multiple notifications for a browser. 384 return; 385 } 386 387 let closeAllNotifications = () => { 388 // Close all other notifications on other tabs that might 389 // be open for the same crashed process. 390 let existingItem = this.notificationsMap.get(childID); 391 if (existingItem) { 392 for (let notif of existingItem.slice()) { 393 notif.close(); 394 } 395 } 396 }; 397 398 gBrowser.ownerGlobal.MozXULElement.insertFTLIfNeeded( 399 "browser/contentCrash.ftl" 400 ); 401 402 let buttons = [ 403 { 404 "l10n-id": "crashed-subframe-learnmore-link", 405 popup: null, 406 link: SUBFRAMECRASH_LEARNMORE_URI, 407 }, 408 { 409 "l10n-id": "crashed-subframe-submit", 410 popup: null, 411 callback: async () => { 412 if (dumpID) { 413 UnsubmittedCrashHandler.submitReports( 414 [dumpID], 415 lazy.CrashSubmit.SUBMITTED_FROM_CRASH_TAB 416 ); 417 } 418 closeAllNotifications(); 419 }, 420 }, 421 ]; 422 423 notification = await notificationBox.appendNotification( 424 value, 425 { 426 label: { "l10n-id": "crashed-subframe-message" }, 427 image: TABCRASHED_ICON_URI, 428 priority: notificationBox.PRIORITY_INFO_MEDIUM, 429 eventCallback: eventName => { 430 if (eventName == "disconnected") { 431 let existingItem = this.notificationsMap.get(childID); 432 if (existingItem) { 433 let idx = existingItem.indexOf(notification); 434 if (idx >= 0) { 435 existingItem.splice(idx, 1); 436 } 437 438 if (!existingItem.length) { 439 this.notificationsMap.delete(childID); 440 } 441 } 442 } else if (eventName == "dismissed") { 443 if (dumpID) { 444 lazy.CrashSubmit.ignore(dumpID); 445 this.childMap.delete(childID); 446 } 447 448 closeAllNotifications(); 449 } 450 }, 451 }, 452 buttons 453 ); 454 455 let existingItem = this.notificationsMap.get(childID); 456 if (existingItem) { 457 existingItem.push(notification); 458 } else { 459 this.notificationsMap.set(childID, [notification]); 460 } 461 }, 462 463 /** 464 * This method is exposed for SessionStore to call if the user selects 465 * a tab which will restore on demand. It's possible that the tab 466 * is in this state because it recently crashed. If that's the case, then 467 * it's also possible that the user has not seen the tab crash page for 468 * that particular crash, in which case, we might show it to them instead 469 * of restoring the tab. 470 * 471 * @param browser (<xul:browser>) 472 * A browser from a browser tab that the user has just selected 473 * to restore on demand. 474 * @returns (boolean) 475 * True if TabCrashHandler will send the user to the tab crash 476 * page instead. 477 */ 478 willShowCrashedTab(browser) { 479 let childID = this.browserMap.get(browser); 480 // We will only show the tab crash page if: 481 // 1) We are aware that this browser crashed 482 // 2) We know we've never shown the tab crash page for the 483 // crash yet 484 // 3) The user is not configured to automatically submit backlogged 485 // crash reports. If they are, we'll send the crash report 486 // immediately. 487 if (childID && this.unseenCrashedChildIDs.includes(childID)) { 488 if (UnsubmittedCrashHandler.autoSubmit) { 489 let dumpID = this.childMap.get(childID); 490 if (dumpID) { 491 UnsubmittedCrashHandler.submitReports( 492 [dumpID], 493 lazy.CrashSubmit.SUBMITTED_FROM_AUTO 494 ); 495 } 496 } else { 497 this.sendToTabCrashedPage(browser); 498 return true; 499 } 500 } else if (childID === 0) { 501 if (this.restartRequiredBrowsers.has(browser)) { 502 this.sendToRestartRequiredPage(browser); 503 } else { 504 this.sendToTabCrashedPage(browser); 505 } 506 return true; 507 } 508 509 return false; 510 }, 511 512 sendToRestartRequiredPage(browser) { 513 let uri = browser.currentURI; 514 let gBrowser = browser.getTabBrowser(); 515 let tab = gBrowser.getTabForBrowser(browser); 516 // The restart required page is non-remote by default. 517 gBrowser.updateBrowserRemoteness(browser, { 518 remoteType: lazy.E10SUtils.NOT_REMOTE, 519 }); 520 521 browser.docShell.displayLoadError(Cr.NS_ERROR_BUILDID_MISMATCH, uri, null); 522 tab.setAttribute("crashed", true); 523 }, 524 525 /** 526 * We show a special page to users when a normal browser tab has crashed. 527 * This method should be called to send a browser to that page once the 528 * process has completely closed. 529 * 530 * @param browser (<xul:browser>) 531 * The browser that has recently crashed. 532 */ 533 sendToTabCrashedPage(browser) { 534 let title = browser.contentTitle; 535 let uri = browser.currentURI; 536 let gBrowser = browser.getTabBrowser(); 537 let tab = gBrowser.getTabForBrowser(browser); 538 // The tab crashed page is non-remote by default. 539 gBrowser.updateBrowserRemoteness(browser, { 540 remoteType: lazy.E10SUtils.NOT_REMOTE, 541 }); 542 543 browser.setAttribute("crashedPageTitle", title); 544 browser.docShell.displayLoadError(Cr.NS_ERROR_CONTENT_CRASHED, uri, null); 545 browser.removeAttribute("crashedPageTitle"); 546 tab.setAttribute("crashed", true); 547 }, 548 549 /** 550 * Submits a crash report from about:tabcrashed, if the crash 551 * reporter is enabled and a crash report can be found. 552 * 553 * @param browser 554 * The <xul:browser> that the report was sent from. 555 * @param message 556 * Message data with the following properties: 557 * 558 * includeURL (bool): 559 * Whether to include the URL that the user was on 560 * in the crashed tab before the crash occurred. 561 * URL (String) 562 * The URL that the user was on in the crashed tab 563 * before the crash occurred. 564 * comments (String): 565 * Any additional comments from the user. 566 * 567 * Note that it is expected that all properties are set, 568 * even if they are empty. 569 */ 570 maybeSendCrashReport(browser, message) { 571 if (!AppConstants.MOZ_CRASHREPORTER) { 572 return; 573 } 574 575 if (!message.data.hasReport) { 576 // There was no report, so nothing to do. 577 return; 578 } 579 580 if (message.data.autoSubmit) { 581 // The user has opted in to autosubmitted backlogged 582 // crash reports in the future. 583 UnsubmittedCrashHandler.autoSubmit = true; 584 } 585 586 let childID = this.browserMap.get(browser); 587 let dumpID = this.childMap.get(childID); 588 if (!dumpID) { 589 return; 590 } 591 592 if (!message.data.sendReport) { 593 Glean.browserContentCrash.notSubmitted.add(1); 594 this.prefs.setBoolPref("sendReport", false); 595 return; 596 } 597 598 // eslint-disable-next-line no-shadow 599 let { includeURL, comments, URL } = message.data; 600 601 let extraExtraKeyVals = { 602 Comments: comments, 603 URL, 604 }; 605 606 // For the entries in extraExtraKeyVals, we only want to submit the 607 // extra data values where they are not the empty string. 608 for (let key in extraExtraKeyVals) { 609 let val = extraExtraKeyVals[key].trim(); 610 if (!val) { 611 delete extraExtraKeyVals[key]; 612 } 613 } 614 615 // URL is special, since it's already been written to extra data by 616 // default. In order to make sure we don't send it, we overwrite it 617 // with the empty string. 618 if (!includeURL) { 619 extraExtraKeyVals.URL = ""; 620 } 621 622 lazy.CrashSubmit.submit(dumpID, lazy.CrashSubmit.SUBMITTED_FROM_CRASH_TAB, { 623 recordSubmission: true, 624 extraExtraKeyVals, 625 }).catch(console.error); 626 627 this.prefs.setBoolPref("sendReport", true); 628 this.prefs.setBoolPref("includeURL", includeURL); 629 630 this.childMap.set(childID, null); // Avoid resubmission. 631 this.removeSubmitCheckboxesForSameCrash(childID); 632 }, 633 634 removeSubmitCheckboxesForSameCrash(childID) { 635 for (let window of Services.wm.getEnumerator("navigator:browser")) { 636 if (!window.gMultiProcessBrowser) { 637 continue; 638 } 639 640 for (let browser of window.gBrowser.browsers) { 641 if (browser.isRemoteBrowser) { 642 continue; 643 } 644 645 let doc = browser.contentDocument; 646 if (!doc.documentURI.startsWith("about:tabcrashed")) { 647 continue; 648 } 649 650 if (this.browserMap.get(browser) == childID) { 651 this.browserMap.delete(browser); 652 browser.sendMessageToActor("CrashReportSent", {}, "AboutTabCrashed"); 653 } 654 } 655 } 656 }, 657 658 /** 659 * Process a crashed tab loaded into a browser. 660 * 661 * @param browser 662 * The <xul:browser> containing the page that crashed. 663 * @returns crash data 664 * Message data containing information about the crash. 665 */ 666 onAboutTabCrashedLoad(browser) { 667 this._crashedTabCount++; 668 669 let window = browser.ownerGlobal; 670 671 // Reset the zoom for the tabcrashed page. 672 window.ZoomManager.setZoomForBrowser(browser, 1); 673 674 let childID = this.browserMap.get(browser); 675 let index = this.unseenCrashedChildIDs.indexOf(childID); 676 if (index != -1) { 677 this.unseenCrashedChildIDs.splice(index, 1); 678 } 679 680 let dumpID = this.getDumpID(browser); 681 if (!dumpID) { 682 return { 683 hasReport: false, 684 }; 685 } 686 687 let requestAutoSubmit = !UnsubmittedCrashHandler.autoSubmit; 688 let sendReport = this.prefs.getBoolPref("sendReport"); 689 let includeURL = this.prefs.getBoolPref("includeURL"); 690 691 let data = { 692 hasReport: true, 693 sendReport, 694 includeURL, 695 requestAutoSubmit, 696 }; 697 698 return data; 699 }, 700 701 onAboutTabCrashedUnload(browser) { 702 if (!this._crashedTabCount) { 703 console.error("Can not decrement crashed tab count to below 0"); 704 return; 705 } 706 this._crashedTabCount--; 707 708 let childID = this.browserMap.get(browser); 709 710 // Make sure to only count once even if there are multiple windows 711 // that will all show about:tabcrashed. 712 if (this._crashedTabCount == 0 && childID) { 713 Glean.browserContentCrash.notSubmitted.add(1); 714 } 715 }, 716 717 /** 718 * For some <xul:browser>, return a crash report dump ID for that browser 719 * if we have been informed of one. Otherwise, return null. 720 * 721 * @param browser (<xul:browser) 722 * The browser to try to get the dump ID for 723 * @returns dumpID (String) 724 */ 725 getDumpID(browser) { 726 if (!AppConstants.MOZ_CRASHREPORTER) { 727 return null; 728 } 729 730 return this.childMap.get(this.browserMap.get(browser)); 731 }, 732 733 /** 734 * This is intended for TESTING ONLY. It returns the amount of 735 * content processes that have crashed such that we're still waiting 736 * for dump IDs for their crash reports. 737 * 738 * For our automated tests, accessing the crashed content process 739 * count helps us test the behaviour when content processes crash due 740 * to launch failure, since in those cases we should not increase the 741 * crashed browser queue (since we never receive dump IDs for launch 742 * failures). 743 */ 744 get queuedCrashedBrowsers() { 745 return this.crashedBrowserQueues.size; 746 }, 747 }; 748 749 /** 750 * This component is responsible for scanning the pending 751 * crash report directory for reports, and (if enabled), to 752 * prompt the user to submit those reports. It might also 753 * submit those reports automatically without prompting if 754 * the user has opted in. 755 */ 756 export var UnsubmittedCrashHandler = { 757 get prefs() { 758 delete this.prefs; 759 return (this.prefs = Services.prefs.getBranch( 760 "browser.crashReports.unsubmittedCheck." 761 )); 762 }, 763 764 get enabled() { 765 return this.prefs.getBoolPref("enabled"); 766 }, 767 768 // showingNotification is set to true once a notification 769 // is successfully shown, and then set back to false if 770 // the notification is dismissed by an action by the user. 771 showingNotification: false, 772 // suppressed is true if we've determined that we've shown 773 // the notification too many times across too many days without 774 // user interaction, so we're suppressing the notification for 775 // some number of days. See the documentation for 776 // shouldShowPendingSubmissionsNotification(). 777 suppressed: false, 778 779 _checkTimeout: null, 780 781 log: null, 782 783 init() { 784 if (this.initialized) { 785 return; 786 } 787 788 this.initialized = true; 789 790 this.log = console.createInstance({ 791 prefix: "UnsubmittedCrashHandler", 792 maxLogLevel: this.prefs.getStringPref("loglevel", "Error"), 793 }); 794 795 // UnsubmittedCrashHandler can be initialized but still be disabled. 796 // This is intentional, as this makes simulating UnsubmittedCrashHandler's 797 // reactions to browser startup and shutdown easier in test automation. 798 // 799 // UnsubmittedCrashHandler, when initialized but not enabled, is inert. 800 if (this.enabled) { 801 lazy.RemoteSettingsCrashPull.start( 802 this.showRequestedSubmissionsNotification.bind(this) 803 ); 804 if (this.prefs.prefHasUserValue("suppressUntilDate")) { 805 if (this.prefs.getCharPref("suppressUntilDate") > this.dateString()) { 806 // We'll be suppressing any notifications until after suppressedDate, 807 // so there's no need to do anything more. 808 this.suppressed = true; 809 this.log.debug("suppressing crash handler due to suppressUntilDate"); 810 return; 811 } 812 813 // We're done suppressing, so we don't need this pref anymore. 814 this.prefs.clearUserPref("suppressUntilDate"); 815 } 816 817 Services.obs.addObserver(this, "profile-before-change"); 818 } else { 819 this.log.debug("not enabled"); 820 } 821 }, 822 823 uninit() { 824 if (!this.initialized) { 825 return; 826 } 827 828 this.initialized = false; 829 830 this.log = null; 831 832 if (this._checkTimeout) { 833 lazy.clearTimeout(this._checkTimeout); 834 this._checkTimeout = null; 835 } 836 837 if (!this.enabled) { 838 return; 839 } 840 841 lazy.RemoteSettingsCrashPull.stop(); 842 843 if (this.suppressed) { 844 this.suppressed = false; 845 // No need to do any more clean-up, since we were suppressed. 846 return; 847 } 848 849 if (this.showingNotification) { 850 this.prefs.setBoolPref("shutdownWhileShowing", true); 851 this.showingNotification = false; 852 } 853 854 Services.obs.removeObserver(this, "profile-before-change"); 855 }, 856 857 observe(subject, topic) { 858 switch (topic) { 859 case "profile-before-change": { 860 this.uninit(); 861 break; 862 } 863 } 864 }, 865 866 scheduleCheckForUnsubmittedCrashReports() { 867 this._checkTimeout = lazy.setTimeout(() => { 868 Services.tm.idleDispatchToMainThread(() => { 869 this.checkForUnsubmittedCrashReports(); 870 }); 871 }, CHECK_FOR_UNSUBMITTED_CRASH_REPORTS_DELAY_MS); 872 }, 873 874 /** 875 * Scans the profile directory for unsubmitted crash reports 876 * within the past PENDING_CRASH_REPORT_DAYS days. If it 877 * finds any, it will, if necessary, attempt to open a notification 878 * bar to prompt the user to submit them. 879 * 880 * @returns Promise 881 * Resolves with the <xul:notification> after it tries to 882 * show a notification on the most recent browser window. 883 * If a notification cannot be shown, will resolve with null. 884 */ 885 async checkForUnsubmittedCrashReports() { 886 if (!this.enabled || this.suppressed) { 887 return null; 888 } 889 890 this.log.debug("checking for unsubmitted crash reports"); 891 892 let dateLimit = new Date(); 893 dateLimit.setDate(dateLimit.getDate() - PENDING_CRASH_REPORT_DAYS); 894 895 let reportIDs = []; 896 try { 897 reportIDs = await lazy.CrashSubmit.pendingIDs(dateLimit); 898 } catch (e) { 899 this.log.error(e); 900 return null; 901 } 902 903 if (reportIDs.length) { 904 this.log.debug("found ", reportIDs.length, " unsubmitted crash reports"); 905 Glean.crashSubmission.pending.add(reportIDs.length); 906 if (this.autoSubmit) { 907 this.log.debug("auto submitted crash reports"); 908 this.submitReports(reportIDs, lazy.CrashSubmit.SUBMITTED_FROM_AUTO); 909 } else if (this.shouldShowPendingSubmissionsNotification()) { 910 return this.showPendingSubmissionsNotification(reportIDs); 911 } 912 } 913 return null; 914 }, 915 916 /** 917 * Returns true if the notification should be shown. 918 * shouldShowPendingSubmissionsNotification makes this decision 919 * by looking at whether or not the user has seen the notification 920 * over several days without ever interacting with it. If this occurs 921 * too many times, we suppress the notification for DAYS_TO_SUPPRESS 922 * days. 923 * 924 * @returns bool 925 */ 926 shouldShowPendingSubmissionsNotification() { 927 // If user is already presented with a remote settings request, do not show 928 if (this._requestedSubmission.notification) { 929 return false; 930 } 931 932 if (!this.prefs.prefHasUserValue("shutdownWhileShowing")) { 933 return true; 934 } 935 936 let shutdownWhileShowing = this.prefs.getBoolPref("shutdownWhileShowing"); 937 this.prefs.clearUserPref("shutdownWhileShowing"); 938 939 if (!this.prefs.prefHasUserValue("lastShownDate")) { 940 // This isn't expected, but we're being defensive here. We'll 941 // opt for showing the notification in this case. 942 return true; 943 } 944 945 let lastShownDate = this.prefs.getCharPref("lastShownDate"); 946 if (this.dateString() > lastShownDate && shutdownWhileShowing) { 947 // We're on a newer day then when we last showed the 948 // notification without closing it. We don't want to do 949 // this too many times, so we'll decrement a counter for 950 // this situation. Too many of these, and we'll assume the 951 // user doesn't know or care about unsubmitted notifications, 952 // and we'll suppress the notification for a while. 953 let chances = this.prefs.getIntPref("chancesUntilSuppress"); 954 if (--chances < 0) { 955 // We're out of chances! 956 this.prefs.clearUserPref("chancesUntilSuppress"); 957 // We'll suppress for DAYS_TO_SUPPRESS days. 958 let suppressUntil = this.dateString( 959 new Date(Date.now() + DAY * DAYS_TO_SUPPRESS) 960 ); 961 this.prefs.setCharPref("suppressUntilDate", suppressUntil); 962 return false; 963 } 964 this.prefs.setIntPref("chancesUntilSuppress", chances); 965 } 966 967 return true; 968 }, 969 970 /** 971 * Given an array of unsubmitted crash report IDs, try to open 972 * up a notification asking the user to submit them. 973 * 974 * @param reportIDs (Array<string>) 975 * The Array of report IDs to offer the user to send. 976 * @returns The <xul:notification> if one is shown. null otherwise. 977 */ 978 async showPendingSubmissionsNotification(reportIDs) { 979 if (!reportIDs.length) { 980 return null; 981 } 982 983 this.log.debug("showing pending submissions notification"); 984 985 let notification = await this.show({ 986 notificationID: "pending-crash-reports", 987 reportIDs, 988 onAction: () => { 989 this.showingNotification = false; 990 }, 991 }); 992 993 if (notification) { 994 this.showingNotification = true; 995 this.prefs.setCharPref("lastShownDate", this.dateString()); 996 } 997 998 return notification; 999 }, 1000 1001 removeExistingNotification(aNotification) { 1002 if (aNotification) { 1003 let chromeWin = lazy.BrowserWindowTracker.getTopWindow({ 1004 allowFromInactiveWorkspace: true, 1005 }); 1006 if (!chromeWin) { 1007 return false; 1008 } 1009 1010 chromeWin.gNotificationBox.removeNotification(aNotification, true); 1011 aNotification = null; 1012 } 1013 return true; 1014 }, 1015 1016 _requestedSubmission: { 1017 notification: null, 1018 reportIDs: [], 1019 }, 1020 1021 /** 1022 * Given an array of interesting unsubmitted crash report IDs, try to open 1023 * up a notification asking the user to submit them. 1024 * 1025 * @param newReportIDs (Array<string>) 1026 * The Array of report IDs to offer the user to send. 1027 * @returns The <xul:notification> if one is shown. null otherwise. 1028 */ 1029 async showRequestedSubmissionsNotification(newReportIDs) { 1030 if (!newReportIDs.length) { 1031 return null; 1032 } 1033 1034 this.log.debug("showing requested pending notification"); 1035 1036 if ( 1037 !this.removeExistingNotification(this._requestedSubmission.notification) 1038 ) { 1039 return null; 1040 } 1041 1042 const dontShowBefore = Services.prefs.getIntPref( 1043 "browser.crashReports.dontShowBefore" 1044 ); 1045 const now = Math.trunc(Date.now() / 1000); 1046 if (now < dontShowBefore) { 1047 return null; 1048 } 1049 1050 this._requestedSubmission.reportIDs.push(...newReportIDs); 1051 this._requestedSubmission.notification = await this.show({ 1052 notificationID: "pending-crash-reports-req", 1053 reportIDs: this._requestedSubmission.reportIDs, 1054 onAction: () => { 1055 this._requestedSubmission.notification = null; 1056 this._requestedSubmission.reportIDs = []; 1057 Services.prefs.setIntPref( 1058 "browser.crashReports.dontShowBefore", 1059 now + SILENCE_FOR_DAYS_IN_S 1060 ); 1061 }, 1062 requestedByDevs: true, 1063 }); 1064 1065 return this._requestedSubmission.notification; 1066 }, 1067 1068 /** 1069 * Returns a string representation of a Date in the format 1070 * YYYYMMDD. 1071 * 1072 * @param someDate (Date, optional) 1073 * The Date to convert to the string. If not provided, 1074 * defaults to today's date. 1075 * @returns String 1076 */ 1077 dateString(someDate = new Date()) { 1078 let year = String(someDate.getFullYear()).padStart(4, "0"); 1079 let month = String(someDate.getMonth() + 1).padStart(2, "0"); 1080 let day = String(someDate.getDate()).padStart(2, "0"); 1081 return year + month + day; 1082 }, 1083 1084 /** 1085 * Attempts to show a notification bar to the user in the most 1086 * recent browser window asking them to submit some crash report 1087 * IDs. If a notification cannot be shown (for example, there 1088 * is no browser window), this method exits silently. 1089 * 1090 * The notification will allow the user to submit their crash 1091 * reports. If the user dismissed the notification, the crash 1092 * reports will be marked to be ignored (though they can 1093 * still be manually submitted via about:crashes). 1094 * 1095 * @param JS Object 1096 * An Object with the following properties: 1097 * 1098 * notificationID (string) 1099 * The ID for the notification to be opened. 1100 * 1101 * reportIDs (Array<string>) 1102 * The array of report IDs to offer to the user. 1103 * 1104 * onAction (function, optional) 1105 * A callback to fire once the user performs an 1106 * action on the notification bar (this includes 1107 * dismissing the notification). 1108 * 1109 * requestedByDevs (boolean) 1110 * Does it match a Remote Settings request. 1111 * If true, a few impactful differences on the notification: 1112 * - message will have a different wording 1113 * - will not include the "always send" and "view all" buttons. 1114 * - when clicking "send", the crash reports will disable throttling 1115 * 1116 * @returns The <xul:notification> if one is shown. null otherwise. 1117 */ 1118 show({ notificationID, reportIDs, onAction, requestedByDevs }) { 1119 let chromeWin = lazy.BrowserWindowTracker.getTopWindow({ 1120 allowFromInactiveWorkspace: true, 1121 }); 1122 if (!chromeWin) { 1123 // Can't show a notification in this case. We'll hopefully 1124 // get another opportunity to have the user submit their 1125 // crash reports later. 1126 return null; 1127 } 1128 1129 let notification = 1130 chromeWin.gNotificationBox.getNotificationWithValue(notificationID); 1131 if (notification) { 1132 return null; 1133 } 1134 1135 chromeWin.MozXULElement.insertFTLIfNeeded("browser/contentCrash.ftl"); 1136 1137 const pendingCrashReportsSend = { 1138 "l10n-id": "pending-crash-reports-send", 1139 callback: () => { 1140 this.submitReports( 1141 reportIDs, 1142 lazy.CrashSubmit.SUBMITTED_FROM_INFOBAR, 1143 requestedByDevs ? { noThrottle: true } : {} 1144 ); 1145 if (onAction) { 1146 onAction(); 1147 } 1148 }, 1149 }; 1150 const pendingCrashReportsAlwaysSend = { 1151 "l10n-id": "pending-crash-reports-always-send", 1152 callback: () => { 1153 this.autoSubmit = true; 1154 this.submitReports(reportIDs, lazy.CrashSubmit.SUBMITTED_FROM_INFOBAR); 1155 if (onAction) { 1156 onAction(); 1157 } 1158 }, 1159 }; 1160 const pendingCrashReportsViewAll = { 1161 "l10n-id": "pending-crash-reports-view-all", 1162 callback() { 1163 chromeWin.openTrustedLinkIn("about:crashes", "tab"); 1164 return true; 1165 }, 1166 }; 1167 1168 const requestedCrashSupport = { 1169 supportPage: "unsent-crash-reports-in-firefox", 1170 }; 1171 const requestedCrashReportsDontShowAgain = { 1172 "l10n-id": "requested-crash-reports-dont-show-again", 1173 callback: () => { 1174 this.requestedNeverShowAgain = true; 1175 if (onAction) { 1176 onAction(); 1177 } 1178 }, 1179 }; 1180 1181 let buttons = []; 1182 if (requestedByDevs) { 1183 // Somehow needs to be the first one 1184 buttons.push(requestedCrashSupport); 1185 } 1186 1187 buttons.push(pendingCrashReportsSend); 1188 1189 if (!requestedByDevs) { 1190 buttons.push(pendingCrashReportsAlwaysSend, pendingCrashReportsViewAll); 1191 } else { 1192 buttons.push(requestedCrashReportsDontShowAgain); 1193 } 1194 1195 let eventCallback = eventType => { 1196 if (eventType == "dismissed") { 1197 // The user intentionally dismissed the notification, 1198 // which we interpret as meaning that they don't care 1199 // to submit the reports. We'll ignore these particular 1200 // reports going forward. 1201 reportIDs.forEach(function (reportID) { 1202 lazy.CrashSubmit.ignore(reportID); 1203 }); 1204 if (onAction) { 1205 onAction(); 1206 } 1207 } 1208 }; 1209 1210 return chromeWin.gNotificationBox.appendNotification( 1211 notificationID, 1212 { 1213 label: { 1214 "l10n-id": requestedByDevs 1215 ? "requested-crash-reports-message-new" 1216 : "pending-crash-reports-message", 1217 "l10n-args": { reportCount: reportIDs.length }, 1218 }, 1219 image: TABCRASHED_ICON_URI, 1220 priority: chromeWin.gNotificationBox.PRIORITY_INFO_HIGH, 1221 eventCallback, 1222 }, 1223 buttons 1224 ); 1225 }, 1226 1227 get autoSubmit() { 1228 return Services.prefs.getBoolPref( 1229 "browser.crashReports.unsubmittedCheck.autoSubmit2" 1230 ); 1231 }, 1232 1233 set autoSubmit(val) { 1234 Services.prefs.setBoolPref( 1235 "browser.crashReports.unsubmittedCheck.autoSubmit2", 1236 val 1237 ); 1238 }, 1239 1240 set requestedNeverShowAgain(val) { 1241 Services.prefs.setBoolPref( 1242 "browser.crashReports.requestedNeverShowAgain", 1243 val 1244 ); 1245 }, 1246 1247 /** 1248 * Attempt to submit reports to the crash report server. 1249 * 1250 * @param reportIDs (Array<string>) 1251 * The array of reportIDs to submit. 1252 * @param submittedFrom (string) 1253 * One of the CrashSubmit.SUBMITTED_FROM_* constants representing 1254 * how this crash was submitted. 1255 * @param params (object) 1256 * Parameters to be passed to CrashSubmit.submit(), e.g. 'noThrottle'. 1257 */ 1258 submitReports(reportIDs, submittedFrom, params) { 1259 this.log.debug( 1260 "submitting ", 1261 reportIDs.length, 1262 " reports from ", 1263 submittedFrom 1264 ); 1265 for (let reportID of reportIDs) { 1266 lazy.CrashSubmit.submit(reportID, submittedFrom, params).catch( 1267 this.log.error.bind(this.log) 1268 ); 1269 } 1270 }, 1271 };