/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; var EXPORTED_SYMBOLS = ["TabCrashHandler", "UnsubmittedCrashHandler"]; const { XPCOMUtils } = ChromeUtils.import( "resource://gre/modules/XPCOMUtils.jsm" ); const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); XPCOMUtils.defineLazyModuleGetters(this, { AppConstants: "resource://gre/modules/AppConstants.jsm", BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", clearTimeout: "resource://gre/modules/Timer.jsm", CrashSubmit: "resource://gre/modules/CrashSubmit.jsm", E10SUtils: "resource://gre/modules/E10SUtils.jsm", PluralForm: "resource://gre/modules/PluralForm.jsm", RemotePages: "resource://gre/modules/remotepagemanager/RemotePageManagerParent.jsm", SessionStore: "resource:///modules/sessionstore/SessionStore.jsm", setTimeout: "resource://gre/modules/Timer.jsm", }); XPCOMUtils.defineLazyGetter(this, "gNavigatorBundle", function() { const url = "chrome://browser/locale/browser.properties"; return Services.strings.createBundle(url); }); // We don't process crash reports older than 28 days, so don't bother // submitting them const PENDING_CRASH_REPORT_DAYS = 28; const DAY = 24 * 60 * 60 * 1000; // milliseconds const DAYS_TO_SUPPRESS = 30; const MAX_UNSEEN_CRASHED_CHILD_IDS = 20; // Time after which we will begin scanning for unsubmitted crash reports const CHECK_FOR_UNSUBMITTED_CRASH_REPORTS_DELAY_MS = 60 * 10000; // 10 minutes /** * BrowserWeakMap is exactly like a WeakMap, but expects * objects only. * * Under the hood, BrowserWeakMap keys the map off of the * permanentKey. If, however, the browser has never gotten a permanentKey, * it falls back to keying on the element itself. */ class BrowserWeakMap extends WeakMap { get(browser) { if (browser.permanentKey) { return super.get(browser.permanentKey); } return super.get(browser); } set(browser, value) { if (browser.permanentKey) { return super.set(browser.permanentKey, value); } return super.set(browser, value); } delete(browser) { if (browser.permanentKey) { return super.delete(browser.permanentKey); } return super.delete(browser); } } var TabCrashHandler = { _crashedTabCount: 0, childMap: new Map(), browserMap: new BrowserWeakMap(), unseenCrashedChildIDs: [], crashedBrowserQueues: new Map(), testBuildIDMismatch: false, get prefs() { delete this.prefs; return (this.prefs = Services.prefs.getBranch( "browser.tabs.crashReporting." )); }, init() { if (this.initialized) { return; } this.initialized = true; Services.obs.addObserver(this, "ipc:content-shutdown"); Services.obs.addObserver(this, "oop-frameloader-crashed"); this.pageListener = new RemotePages("about:tabcrashed"); // LOAD_BACKGROUND pages don't fire load events, so the about:tabcrashed // content will fire up its own message when its initial scripts have // finished running. this.pageListener.addMessageListener( "Load", this.receiveMessage.bind(this) ); this.pageListener.addMessageListener( "RemotePage:Unload", this.receiveMessage.bind(this) ); this.pageListener.addMessageListener( "closeTab", this.receiveMessage.bind(this) ); this.pageListener.addMessageListener( "restoreTab", this.receiveMessage.bind(this) ); this.pageListener.addMessageListener( "restoreAll", this.receiveMessage.bind(this) ); }, observe(aSubject, aTopic, aData) { switch (aTopic) { case "ipc:content-shutdown": { aSubject.QueryInterface(Ci.nsIPropertyBag2); if (!aSubject.get("abnormal")) { return; } let childID = aSubject.get("childID"); let dumpID = aSubject.get("dumpID"); if (!dumpID) { Services.telemetry .getHistogramById("FX_CONTENT_CRASH_DUMP_UNAVAILABLE") .add(1); } else if (AppConstants.MOZ_CRASHREPORTER) { this.childMap.set(childID, dumpID); } if (!this.flushCrashedBrowserQueue(childID)) { this.unseenCrashedChildIDs.push(childID); // The elements in unseenCrashedChildIDs will only be removed if // the tab crash page is shown. However, ipc:content-shutdown might // be fired for processes for which we'll never show the tab crash // page - for example, the thumbnailing process. Another case to // consider is if the user is configured to submit backlogged crash // reports automatically, and a background tab crashes. In that case, // we will never show the tab crash page, and never remove the element // from the list. // // Instead of trying to account for all of those cases, we prevent // this list from getting too large by putting a reasonable upper // limit on how many childIDs we track. It's unlikely that this // array would ever get so large as to be unwieldy (that'd be a lot // or crashes!), but a leak is a leak. if ( this.unseenCrashedChildIDs.length > MAX_UNSEEN_CRASHED_CHILD_IDS ) { this.unseenCrashedChildIDs.shift(); } } // check for environment affecting crash reporting let env = Cc["@mozilla.org/process/environment;1"].getService( Ci.nsIEnvironment ); let shutdown = env.exists("MOZ_CRASHREPORTER_SHUTDOWN"); if (shutdown) { dump( "A content process crashed and MOZ_CRASHREPORTER_SHUTDOWN is " + "set, shutting down\n" ); Services.startup.quit(Ci.nsIAppStartup.eForceQuit); } break; } case "oop-frameloader-crashed": { let browser = aSubject.ownerElement; if (!browser) { return; } this.browserMap.set(browser, aSubject.childID); break; } } }, receiveMessage(message) { let browser = message.target.browser; let gBrowser = browser.ownerGlobal.gBrowser; let tab = gBrowser.getTabForBrowser(browser); switch (message.name) { case "Load": { this.onAboutTabCrashedLoad(message); break; } case "RemotePage:Unload": { this.onAboutTabCrashedUnload(message); break; } case "closeTab": { this.maybeSendCrashReport(message); gBrowser.removeTab(tab, { animate: true }); break; } case "restoreTab": { this.maybeSendCrashReport(message); SessionStore.reviveCrashedTab(tab); break; } case "restoreAll": { this.maybeSendCrashReport(message); SessionStore.reviveAllCrashedTabs(); break; } } }, /** * This should be called once a content process has finished * shutting down abnormally. Any tabbrowser browsers that were * selected at the time of the crash will then be sent to * the crashed tab page. * * @param childID (int) * The childID of the content process that just crashed. * @returns boolean * True if one or more browsers were sent to the tab crashed * page. */ flushCrashedBrowserQueue(childID) { let browserQueue = this.crashedBrowserQueues.get(childID); if (!browserQueue) { return false; } this.crashedBrowserQueues.delete(childID); let sentBrowser = false; for (let weakBrowser of browserQueue) { let browser = weakBrowser.browser.get(); if (browser) { if (weakBrowser.restartRequired || this.testBuildIDMismatch) { this.sendToRestartRequiredPage(browser); } else { this.sendToTabCrashedPage(browser); } sentBrowser = true; } } return sentBrowser; }, /** * Called by a tabbrowser when it notices that its selected browser * has crashed. This will queue the browser to show the tab crash * page once the content process has finished tearing down. * * @param browser () * The selected browser that just crashed. * @param restartRequired (bool) * Whether or not a browser restart is required to recover. */ onSelectedBrowserCrash(browser, restartRequired) { if (!browser.isRemoteBrowser) { Cu.reportError("Selected crashed browser is not remote."); return; } if (!browser.frameLoader) { Cu.reportError("Selected crashed browser has no frameloader."); return; } let childID = browser.frameLoader.childID; let browserQueue = this.crashedBrowserQueues.get(childID); if (!browserQueue) { browserQueue = []; this.crashedBrowserQueues.set(childID, browserQueue); } // It's probably unnecessary to store this browser as a // weak reference, since the content process should complete // its teardown in the same tick of the event loop, and then // this queue will be flushed. The weak reference is to avoid // leaking browsers in case anything goes wrong during this // teardown process. browserQueue.push({ browser: Cu.getWeakReference(browser), restartRequired, }); }, /** * This method is exposed for SessionStore to call if the user selects * a tab which will restore on demand. It's possible that the tab * is in this state because it recently crashed. If that's the case, then * it's also possible that the user has not seen the tab crash page for * that particular crash, in which case, we might show it to them instead * of restoring the tab. * * @param browser () * A browser from a browser tab that the user has just selected * to restore on demand. * @returns (boolean) * True if TabCrashHandler will send the user to the tab crash * page instead. */ willShowCrashedTab(browser) { let childID = this.browserMap.get(browser); // We will only show the tab crash page if: // 1) We are aware that this browser crashed // 2) We know we've never shown the tab crash page for the // crash yet // 3) The user is not configured to automatically submit backlogged // crash reports. If they are, we'll send the crash report // immediately. if (childID && this.unseenCrashedChildIDs.includes(childID)) { if (UnsubmittedCrashHandler.autoSubmit) { let dumpID = this.childMap.get(childID); if (dumpID) { UnsubmittedCrashHandler.submitReports([dumpID]); } } else { this.sendToTabCrashedPage(browser); return true; } } return false; }, sendToRestartRequiredPage(browser) { let uri = browser.currentURI; let gBrowser = browser.ownerGlobal.gBrowser; let tab = gBrowser.getTabForBrowser(browser); // The restart required page is non-remote by default. gBrowser.updateBrowserRemoteness(browser, { remoteType: E10SUtils.NOT_REMOTE, }); browser.docShell.displayLoadError(Cr.NS_ERROR_BUILDID_MISMATCH, uri, null); tab.setAttribute("crashed", true); // Make sure to only count once even if there are multiple windows // that will all show about:restartrequired. if (this._crashedTabCount == 1) { Services.telemetry.scalarAdd("dom.contentprocess.buildID_mismatch", 1); } }, /** * We show a special page to users when a normal browser tab has crashed. * This method should be called to send a browser to that page once the * process has completely closed. * * @param browser () * The browser that has recently crashed. */ sendToTabCrashedPage(browser) { let title = browser.contentTitle; let uri = browser.currentURI; let gBrowser = browser.ownerGlobal.gBrowser; let tab = gBrowser.getTabForBrowser(browser); // The tab crashed page is non-remote by default. gBrowser.updateBrowserRemoteness(browser, { remoteType: E10SUtils.NOT_REMOTE, }); browser.setAttribute("crashedPageTitle", title); browser.docShell.displayLoadError(Cr.NS_ERROR_CONTENT_CRASHED, uri, null); browser.removeAttribute("crashedPageTitle"); tab.setAttribute("crashed", true); }, /** * Submits a crash report from about:tabcrashed, if the crash * reporter is enabled and a crash report can be found. * * @param aBrowser * The that the report was sent from. * @param aFormData * An Object with the following properties: * * includeURL (bool): * Whether to include the URL that the user was on * in the crashed tab before the crash occurred. * URL (String) * The URL that the user was on in the crashed tab * before the crash occurred. * emailMe (bool): * Whether or not to include the user's email address * in the crash report. * email (String): * The email address of the user. * comments (String): * Any additional comments from the user. * * Note that it is expected that all properties are set, * even if they are empty. */ maybeSendCrashReport(message) { if (!AppConstants.MOZ_CRASHREPORTER) { return; } if (!message.data.hasReport) { // There was no report, so nothing to do. return; } let browser = message.target.browser; if (message.data.autoSubmit) { // The user has opted in to autosubmitted backlogged // crash reports in the future. UnsubmittedCrashHandler.autoSubmit = true; } let childID = this.browserMap.get(browser); let dumpID = this.childMap.get(childID); if (!dumpID) { return; } if (!message.data.sendReport) { Services.telemetry .getHistogramById("FX_CONTENT_CRASH_NOT_SUBMITTED") .add(1); this.prefs.setBoolPref("sendReport", false); return; } let { includeURL, comments, email, emailMe, URL } = message.data; let extraExtraKeyVals = { Comments: comments, Email: email, URL, }; // For the entries in extraExtraKeyVals, we only want to submit the // extra data values where they are not the empty string. for (let key in extraExtraKeyVals) { let val = extraExtraKeyVals[key].trim(); if (!val) { delete extraExtraKeyVals[key]; } } // URL is special, since it's already been written to extra data by // default. In order to make sure we don't send it, we overwrite it // with the empty string. if (!includeURL) { extraExtraKeyVals.URL = ""; } CrashSubmit.submit(dumpID, { recordSubmission: true, extraExtraKeyVals, }).catch(Cu.reportError); this.prefs.setBoolPref("sendReport", true); this.prefs.setBoolPref("includeURL", includeURL); this.prefs.setBoolPref("emailMe", emailMe); if (emailMe) { this.prefs.setCharPref("email", email); } else { this.prefs.setCharPref("email", ""); } this.childMap.set(childID, null); // Avoid resubmission. this.removeSubmitCheckboxesForSameCrash(childID); }, removeSubmitCheckboxesForSameCrash(childID) { for (let window of Services.wm.getEnumerator("navigator:browser")) { if (!window.gMultiProcessBrowser) { continue; } for (let browser of window.gBrowser.browsers) { if (browser.isRemoteBrowser) { continue; } let doc = browser.contentDocument; if (!doc.documentURI.startsWith("about:tabcrashed")) { continue; } if (this.browserMap.get(browser) == childID) { this.browserMap.delete(browser); let ports = this.pageListener.portsForBrowser(browser); if (ports.length) { // For about:tabcrashed, we don't expect subframes. We can // assume sending to the first port is sufficient. ports[0].sendAsyncMessage("CrashReportSent"); } } } } }, onAboutTabCrashedLoad(message) { this._crashedTabCount++; // Broadcast to all about:tabcrashed pages a count of // how many about:tabcrashed pages exist, so that they // can decide whether or not to display the "Restore All // Crashed Tabs" button. this.pageListener.sendAsyncMessage("UpdateCount", { count: this._crashedTabCount, }); let browser = message.target.browser; let window = browser.ownerGlobal; // Reset the zoom for the tabcrashed page. window.ZoomManager.setZoomForBrowser(browser, 1); let childID = this.browserMap.get(browser); let index = this.unseenCrashedChildIDs.indexOf(childID); if (index != -1) { this.unseenCrashedChildIDs.splice(index, 1); } let dumpID = this.getDumpID(browser); if (!dumpID) { message.target.sendAsyncMessage("SetCrashReportAvailable", { hasReport: false, }); return; } let requestAutoSubmit = !UnsubmittedCrashHandler.autoSubmit; let requestEmail = this.prefs.getBoolPref("requestEmail"); let sendReport = this.prefs.getBoolPref("sendReport"); let includeURL = this.prefs.getBoolPref("includeURL"); let emailMe = this.prefs.getBoolPref("emailMe"); let data = { hasReport: true, sendReport, includeURL, emailMe, requestAutoSubmit, requestEmail, }; if (emailMe) { data.email = this.prefs.getCharPref("email"); } // Make sure to only count once even if there are multiple windows // that will all show about:tabcrashed. if (this._crashedTabCount == 1) { Services.telemetry.getHistogramById("FX_CONTENT_CRASH_PRESENTED").add(1); } message.target.sendAsyncMessage("SetCrashReportAvailable", data); }, onAboutTabCrashedUnload(message) { if (!this._crashedTabCount) { Cu.reportError("Can not decrement crashed tab count to below 0"); return; } this._crashedTabCount--; // Broadcast to all about:tabcrashed pages a count of // how many about:tabcrashed pages exist, so that they // can decide whether or not to display the "Restore All // Crashed Tabs" button. this.pageListener.sendAsyncMessage("UpdateCount", { count: this._crashedTabCount, }); let browser = message.target.browser; let childID = this.browserMap.get(browser); // Make sure to only count once even if there are multiple windows // that will all show about:tabcrashed. if (this._crashedTabCount == 0 && childID) { Services.telemetry .getHistogramById("FX_CONTENT_CRASH_NOT_SUBMITTED") .add(1); } }, /** * For some , return a crash report dump ID for that browser * if we have been informed of one. Otherwise, return null. * * @param browser ( this.dateString()) { // We'll be suppressing any notifications until after suppressedDate, // so there's no need to do anything more. this.suppressed = true; return; } // We're done suppressing, so we don't need this pref anymore. this.prefs.clearUserPref("suppressUntilDate"); } Services.obs.addObserver(this, "profile-before-change"); } }, uninit() { if (!this.initialized) { return; } this.initialized = false; if (this._checkTimeout) { clearTimeout(this._checkTimeout); this._checkTimeout = null; } if (!this.enabled) { return; } if (this.suppressed) { this.suppressed = false; // No need to do any more clean-up, since we were suppressed. return; } if (this.showingNotification) { this.prefs.setBoolPref("shutdownWhileShowing", true); this.showingNotification = false; } Services.obs.removeObserver(this, "profile-before-change"); }, observe(subject, topic, data) { switch (topic) { case "profile-before-change": { this.uninit(); break; } } }, scheduleCheckForUnsubmittedCrashReports() { this._checkTimeout = setTimeout(() => { Services.tm.idleDispatchToMainThread(() => { this.checkForUnsubmittedCrashReports(); }); }, CHECK_FOR_UNSUBMITTED_CRASH_REPORTS_DELAY_MS); }, /** * Scans the profile directory for unsubmitted crash reports * within the past PENDING_CRASH_REPORT_DAYS days. If it * finds any, it will, if necessary, attempt to open a notification * bar to prompt the user to submit them. * * @returns Promise * Resolves with the after it tries to * show a notification on the most recent browser window. * If a notification cannot be shown, will resolve with null. */ async checkForUnsubmittedCrashReports() { if (!this.enabled || this.suppressed) { return null; } let dateLimit = new Date(); dateLimit.setDate(dateLimit.getDate() - PENDING_CRASH_REPORT_DAYS); let reportIDs = []; try { reportIDs = await CrashSubmit.pendingIDs(dateLimit); } catch (e) { Cu.reportError(e); return null; } if (reportIDs.length) { if (this.autoSubmit) { this.submitReports(reportIDs); } else if (this.shouldShowPendingSubmissionsNotification()) { return this.showPendingSubmissionsNotification(reportIDs); } } return null; }, /** * Returns true if the notification should be shown. * shouldShowPendingSubmissionsNotification makes this decision * by looking at whether or not the user has seen the notification * over several days without ever interacting with it. If this occurs * too many times, we suppress the notification for DAYS_TO_SUPPRESS * days. * * @returns bool */ shouldShowPendingSubmissionsNotification() { if (!this.prefs.prefHasUserValue("shutdownWhileShowing")) { return true; } let shutdownWhileShowing = this.prefs.getBoolPref("shutdownWhileShowing"); this.prefs.clearUserPref("shutdownWhileShowing"); if (!this.prefs.prefHasUserValue("lastShownDate")) { // This isn't expected, but we're being defensive here. We'll // opt for showing the notification in this case. return true; } let lastShownDate = this.prefs.getCharPref("lastShownDate"); if (this.dateString() > lastShownDate && shutdownWhileShowing) { // We're on a newer day then when we last showed the // notification without closing it. We don't want to do // this too many times, so we'll decrement a counter for // this situation. Too many of these, and we'll assume the // user doesn't know or care about unsubmitted notifications, // and we'll suppress the notification for a while. let chances = this.prefs.getIntPref("chancesUntilSuppress"); if (--chances < 0) { // We're out of chances! this.prefs.clearUserPref("chancesUntilSuppress"); // We'll suppress for DAYS_TO_SUPPRESS days. let suppressUntil = this.dateString( new Date(Date.now() + DAY * DAYS_TO_SUPPRESS) ); this.prefs.setCharPref("suppressUntilDate", suppressUntil); return false; } this.prefs.setIntPref("chancesUntilSuppress", chances); } return true; }, /** * Given an array of unsubmitted crash report IDs, try to open * up a notification asking the user to submit them. * * @param reportIDs (Array) * The Array of report IDs to offer the user to send. * @returns The if one is shown. null otherwise. */ showPendingSubmissionsNotification(reportIDs) { let count = reportIDs.length; if (!count) { return null; } let messageTemplate = gNavigatorBundle.GetStringFromName( "pendingCrashReports2.label" ); let message = PluralForm.get(count, messageTemplate).replace("#1", count); let notification = this.show({ notificationID: "pending-crash-reports", message, reportIDs, onAction: () => { this.showingNotification = false; }, }); if (notification) { this.showingNotification = true; this.prefs.setCharPref("lastShownDate", this.dateString()); } return notification; }, /** * Returns a string representation of a Date in the format * YYYYMMDD. * * @param someDate (Date, optional) * The Date to convert to the string. If not provided, * defaults to today's date. * @returns String */ dateString(someDate = new Date()) { let year = String(someDate.getFullYear()).padStart(4, "0"); let month = String(someDate.getMonth() + 1).padStart(2, "0"); let day = String(someDate.getDate()).padStart(2, "0"); return year + month + day; }, /** * Attempts to show a notification bar to the user in the most * recent browser window asking them to submit some crash report * IDs. If a notification cannot be shown (for example, there * is no browser window), this method exits silently. * * The notification will allow the user to submit their crash * reports. If the user dismissed the notification, the crash * reports will be marked to be ignored (though they can * still be manually submitted via about:crashes). * * @param JS Object * An Object with the following properties: * * notificationID (string) * The ID for the notification to be opened. * * message (string) * The message to be displayed in the notification. * * reportIDs (Array) * The array of report IDs to offer to the user. * * onAction (function, optional) * A callback to fire once the user performs an * action on the notification bar (this includes * dismissing the notification). * * @returns The if one is shown. null otherwise. */ show({ notificationID, message, reportIDs, onAction }) { let chromeWin = BrowserWindowTracker.getTopWindow(); if (!chromeWin) { // Can't show a notification in this case. We'll hopefully // get another opportunity to have the user submit their // crash reports later. return null; } let notification = chromeWin.gNotificationBox.getNotificationWithValue( notificationID ); if (notification) { return null; } let buttons = [ { label: gNavigatorBundle.GetStringFromName("pendingCrashReports.send"), callback: () => { this.submitReports(reportIDs); if (onAction) { onAction(); } }, }, { label: gNavigatorBundle.GetStringFromName( "pendingCrashReports.alwaysSend" ), callback: () => { this.autoSubmit = true; this.submitReports(reportIDs); if (onAction) { onAction(); } }, }, { label: gNavigatorBundle.GetStringFromName( "pendingCrashReports.viewAll" ), callback() { chromeWin.openTrustedLinkIn("about:crashes", "tab"); return true; }, }, ]; let eventCallback = eventType => { if (eventType == "dismissed") { // The user intentionally dismissed the notification, // which we interpret as meaning that they don't care // to submit the reports. We'll ignore these particular // reports going forward. reportIDs.forEach(function(reportID) { CrashSubmit.ignore(reportID); }); if (onAction) { onAction(); } } }; return chromeWin.gNotificationBox.appendNotification( message, notificationID, "chrome://browser/skin/tab-crashed.svg", chromeWin.gNotificationBox.PRIORITY_INFO_HIGH, buttons, eventCallback ); }, get autoSubmit() { return Services.prefs.getBoolPref( "browser.crashReports.unsubmittedCheck.autoSubmit2" ); }, set autoSubmit(val) { Services.prefs.setBoolPref( "browser.crashReports.unsubmittedCheck.autoSubmit2", val ); }, /** * Attempt to submit reports to the crash report server. Each * report will have the "SubmittedFromInfobar" extra key set * to true. * * @param reportIDs (Array) * The array of reportIDs to submit. */ submitReports(reportIDs) { for (let reportID of reportIDs) { CrashSubmit.submit(reportID, { extraExtraKeyVals: { SubmittedFromInfobar: true, }, }).catch(Cu.reportError); } }, };