tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

ProcessHangMonitor.sys.mjs (20633B)


      1 /* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */
      2 /* This Source Code Form is subject to the terms of the Mozilla Public
      3 * License, v. 2.0. If a copy of the MPL was not distributed with this
      4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      5 
      6 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
      7 
      8 /**
      9 * Elides the middle of a string by replacing it with an elipsis if it is
     10 * longer than `threshold` characters. Does its best to not break up grapheme
     11 * clusters.
     12 */
     13 function elideMiddleOfString(str, threshold) {
     14  const searchDistance = 5;
     15  const stubLength = threshold / 2 - searchDistance;
     16  if (str.length <= threshold || stubLength < searchDistance) {
     17    return str;
     18  }
     19 
     20  function searchElisionPoint(position) {
     21    let unsplittableCharacter = c => /[\p{M}\uDC00-\uDFFF]/u.test(c);
     22    for (let i = 0; i < searchDistance; i++) {
     23      if (!unsplittableCharacter(str[position + i])) {
     24        return position + i;
     25      }
     26 
     27      if (!unsplittableCharacter(str[position - i])) {
     28        return position - i;
     29      }
     30    }
     31    return position;
     32  }
     33 
     34  let elisionStart = searchElisionPoint(stubLength);
     35  let elisionEnd = searchElisionPoint(str.length - stubLength);
     36  if (elisionStart < elisionEnd) {
     37    str = str.slice(0, elisionStart) + "\u2026" + str.slice(elisionEnd);
     38  }
     39  return str;
     40 }
     41 
     42 /**
     43 * This JSM is responsible for observing content process hang reports
     44 * and asking the user what to do about them. See nsIHangReport for
     45 * the platform interface.
     46 */
     47 
     48 export var ProcessHangMonitor = {
     49  /**
     50   * This timeout is the wait period applied after a user selects "Wait" in
     51   * an existing notification.
     52   */
     53  get WAIT_EXPIRATION_TIME() {
     54    try {
     55      return Services.prefs.getIntPref("browser.hangNotification.waitPeriod");
     56    } catch (ex) {
     57      return 10000;
     58    }
     59  },
     60 
     61  /**
     62   * Should only be set to true once the quit-application-granted notification
     63   * has been fired.
     64   */
     65  _shuttingDown: false,
     66 
     67  /**
     68   * Collection of hang reports that haven't expired or been dismissed
     69   * by the user. These are nsIHangReports. They are mapped to objects
     70   * containing:
     71   * - notificationTime: when (ChromeUtils.now()) we first showed a notification
     72   * - waitCount: how often the user asked to wait for the script to finish
     73   * - lastReportFromChild: when (ChromeUtils.now()) we last got hang info from the
     74   *   child.
     75   */
     76  _activeReports: new Map(),
     77 
     78  /**
     79   * Collection of hang reports that have been suppressed for a short
     80   * period of time. Value is an object like in _activeReports, but also
     81   * including a `timer` prop, which is an nsITimer for when the wait time
     82   * expires.
     83   */
     84  _pausedReports: new Map(),
     85 
     86  /**
     87   * Initialize hang reporting. Called once in the parent process.
     88   */
     89  init() {
     90    Services.obs.addObserver(this, "process-hang-report");
     91    Services.obs.addObserver(this, "clear-hang-report");
     92    Services.obs.addObserver(this, "quit-application-granted");
     93    Services.obs.addObserver(this, "xpcom-shutdown");
     94    Services.ww.registerNotification(this);
     95  },
     96 
     97  /**
     98   * Terminate JavaScript associated with the hang being reported for
     99   * the selected browser in |win|.
    100   */
    101  terminateScript(win) {
    102    this.handleUserInput(win, report => report.terminateScript());
    103  },
    104 
    105  /**
    106   * Start devtools debugger for JavaScript associated with the hang
    107   * being reported for the selected browser in |win|.
    108   */
    109  debugScript(win) {
    110    this.handleUserInput(win, report => {
    111      function callback() {
    112        report.endStartingDebugger();
    113      }
    114 
    115      this._recordTelemetryForReport(report, "debugging");
    116      report.beginStartingDebugger();
    117 
    118      let svc = Cc["@mozilla.org/dom/slow-script-debug;1"].getService(
    119        Ci.nsISlowScriptDebug
    120      );
    121      let handler = svc.remoteActivationHandler;
    122      handler.handleSlowScriptDebug(report.scriptBrowser, callback);
    123    });
    124  },
    125 
    126  /**
    127   * Dismiss the browser notification and invoke an appropriate action based on
    128   * the hang type.
    129   */
    130  stopIt(win) {
    131    let report = this.findActiveReport(win.gBrowser.selectedBrowser);
    132    if (!report) {
    133      return;
    134    }
    135 
    136    this._recordTelemetryForReport(report, "user-aborted");
    137    this.terminateScript(win);
    138  },
    139 
    140  /**
    141   * Terminate the script causing this report. This is done without
    142   * updating any report notifications.
    143   */
    144  stopHang(report, endReason, backupInfo) {
    145    this._recordTelemetryForReport(report, endReason, backupInfo);
    146    report.terminateScript();
    147  },
    148 
    149  /**
    150   * Dismiss the notification, clear the report from the active list and set up
    151   * a new timer to track a wait period during which we won't notify.
    152   */
    153  waitLonger(win) {
    154    let report = this.findActiveReport(win.gBrowser.selectedBrowser);
    155    if (!report) {
    156      return;
    157    }
    158    // Update the other info we keep.
    159    let reportInfo = this._activeReports.get(report);
    160    reportInfo.waitCount++;
    161 
    162    // Remove the report from the active list.
    163    this.removeActiveReport(report);
    164 
    165    // NOTE, we didn't call userCanceled on nsIHangReport here. This insures
    166    // we don't repeatedly generate and cache crash report data for this hang
    167    // in the process hang reporter. It already has one report for the browser
    168    // process we want it hold onto.
    169 
    170    // Create a new wait timer with notify callback
    171    let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
    172    timer.initWithCallback(
    173      () => {
    174        for (let [stashedReport, pausedInfo] of this._pausedReports) {
    175          if (pausedInfo.timer === timer) {
    176            this.removePausedReport(stashedReport);
    177 
    178            // We're still hung, so move the report back to the active
    179            // list and update the UI.
    180            this._activeReports.set(report, pausedInfo);
    181            this.updateWindows();
    182            break;
    183          }
    184        }
    185      },
    186      this.WAIT_EXPIRATION_TIME,
    187      timer.TYPE_ONE_SHOT
    188    );
    189 
    190    reportInfo.timer = timer;
    191    this._pausedReports.set(report, reportInfo);
    192 
    193    // remove the browser notification associated with this hang
    194    this.updateWindows();
    195  },
    196 
    197  /**
    198   * If there is a hang report associated with the selected browser in
    199   * |win|, invoke |func| on that report and stop notifying the user
    200   * about it.
    201   */
    202  handleUserInput(win, func) {
    203    let report = this.findActiveReport(win.gBrowser.selectedBrowser);
    204    if (!report) {
    205      return null;
    206    }
    207    this.removeActiveReport(report);
    208 
    209    return func(report);
    210  },
    211 
    212  observe(subject, topic) {
    213    switch (topic) {
    214      case "xpcom-shutdown": {
    215        Services.obs.removeObserver(this, "xpcom-shutdown");
    216        Services.obs.removeObserver(this, "process-hang-report");
    217        Services.obs.removeObserver(this, "clear-hang-report");
    218        Services.obs.removeObserver(this, "quit-application-granted");
    219        Services.ww.unregisterNotification(this);
    220        break;
    221      }
    222 
    223      case "quit-application-granted": {
    224        this.onQuitApplicationGranted();
    225        break;
    226      }
    227 
    228      case "process-hang-report": {
    229        this.reportHang(subject.QueryInterface(Ci.nsIHangReport));
    230        break;
    231      }
    232 
    233      case "clear-hang-report": {
    234        this.clearHang(subject.QueryInterface(Ci.nsIHangReport));
    235        break;
    236      }
    237 
    238      case "domwindowopened": {
    239        // Install event listeners on the new window in case one of
    240        // its tabs is already hung.
    241        let win = subject;
    242        let listener = () => {
    243          win.removeEventListener("load", listener, true);
    244          this.updateWindows();
    245        };
    246        win.addEventListener("load", listener, true);
    247        break;
    248      }
    249 
    250      case "domwindowclosed": {
    251        let win = subject;
    252        this.onWindowClosed(win);
    253        break;
    254      }
    255    }
    256  },
    257 
    258  /**
    259   * Called early on in the shutdown sequence. We take this opportunity to
    260   * take any pre-existing hang reports, and terminate them. We also put
    261   * ourselves in a state so that if any more hang reports show up while
    262   * we're shutting down, we terminate them immediately.
    263   */
    264  onQuitApplicationGranted() {
    265    this._shuttingDown = true;
    266    this.stopAllHangs("quit-application-granted");
    267    this.updateWindows();
    268  },
    269 
    270  onWindowClosed(win) {
    271    let maybeStopHang = report => {
    272      let hungBrowserWindow = null;
    273      try {
    274        hungBrowserWindow = report.scriptBrowser.ownerGlobal;
    275      } catch (e) {
    276        // Ignore failures to get the script browser - we'll be
    277        // conservative, and assume that if we cannot access the
    278        // window that belongs to this report that we should stop
    279        // the hang.
    280      }
    281      if (!hungBrowserWindow || hungBrowserWindow == win) {
    282        this.stopHang(report, "window-closed");
    283        return true;
    284      }
    285      return false;
    286    };
    287 
    288    // If there are any script hangs for browsers that are in this window
    289    // that is closing, we can stop them now.
    290    for (let [report] of this._activeReports) {
    291      if (maybeStopHang(report)) {
    292        this._activeReports.delete(report);
    293      }
    294    }
    295 
    296    for (let [pausedReport] of this._pausedReports) {
    297      if (maybeStopHang(pausedReport)) {
    298        this.removePausedReport(pausedReport);
    299      }
    300    }
    301 
    302    this.updateWindows();
    303  },
    304 
    305  stopAllHangs(endReason) {
    306    for (let [report] of this._activeReports) {
    307      this.stopHang(report, endReason);
    308    }
    309 
    310    this._activeReports = new Map();
    311 
    312    for (let [pausedReport] of this._pausedReports) {
    313      this.stopHang(pausedReport, endReason);
    314      this.removePausedReport(pausedReport);
    315    }
    316  },
    317 
    318  /**
    319   * Find a active hang report for the given <browser> element.
    320   */
    321  findActiveReport(browser) {
    322    let frameLoader = browser.frameLoader;
    323    for (let report of this._activeReports.keys()) {
    324      if (report.isReportForBrowserOrChildren(frameLoader)) {
    325        return report;
    326      }
    327    }
    328    return null;
    329  },
    330 
    331  /**
    332   * Find a paused hang report for the given <browser> element.
    333   */
    334  findPausedReport(browser) {
    335    let frameLoader = browser.frameLoader;
    336    for (let [report] of this._pausedReports) {
    337      if (report.isReportForBrowserOrChildren(frameLoader)) {
    338        return report;
    339      }
    340    }
    341    return null;
    342  },
    343 
    344  /**
    345   * Tell telemetry about the report.
    346   */
    347  _recordTelemetryForReport(report, endReason, backupInfo) {
    348    let info =
    349      this._activeReports.get(report) ||
    350      this._pausedReports.get(report) ||
    351      backupInfo;
    352    if (!info) {
    353      return;
    354    }
    355    try {
    356      let uri_type;
    357      if (report.addonId) {
    358        uri_type = "extension";
    359      } else if (report.scriptFileName?.startsWith("debugger")) {
    360        uri_type = "devtools";
    361      } else {
    362        try {
    363          let url = new URL(report.scriptFileName);
    364          if (url.protocol == "chrome:" || url.protocol == "resource:") {
    365            uri_type = "browser";
    366          } else {
    367            uri_type = "content";
    368          }
    369        } catch (ex) {
    370          console.error(ex);
    371          uri_type = "unknown";
    372        }
    373      }
    374      let uptime = 0;
    375      if (info.notificationTime) {
    376        uptime = ChromeUtils.now() - info.notificationTime;
    377      }
    378      uptime = "" + uptime;
    379      // We combine the duration of the hang in the content process with the
    380      // time since we were last told about the hang in the parent. This is
    381      // not the same as the time we showed a notification, as we only do that
    382      // for the currently selected browser. It's as messy as it is because
    383      // there is no cross-process monotonically increasing timestamp we can
    384      // use. :-(
    385      let hangDuration =
    386        report.hangDuration + ChromeUtils.now() - info.lastReportFromChild;
    387      Glean.slowScriptWarning.shownContent.record({
    388        end_reason: endReason,
    389        hang_duration: hangDuration,
    390        n_tab_deselect: info.deselectCount,
    391        uri_type,
    392        uptime,
    393        wait_count: info.waitCount,
    394      });
    395    } catch (ex) {
    396      console.error(ex);
    397    }
    398  },
    399 
    400  /**
    401   * Remove an active hang report from the active list and cancel the timer
    402   * associated with it.
    403   */
    404  removeActiveReport(report) {
    405    this._activeReports.delete(report);
    406    this.updateWindows();
    407  },
    408 
    409  /**
    410   * Remove a paused hang report from the paused list and cancel the timer
    411   * associated with it.
    412   */
    413  removePausedReport(report) {
    414    let info = this._pausedReports.get(report);
    415    info?.timer?.cancel();
    416    this._pausedReports.delete(report);
    417  },
    418 
    419  /**
    420   * Iterate over all XUL windows and ensure that the proper hang
    421   * reports are shown for each one. Also install event handlers in
    422   * each window to watch for events that would cause a different hang
    423   * report to be displayed.
    424   */
    425  updateWindows() {
    426    let e = Services.wm.getEnumerator("navigator:browser");
    427 
    428    // If it turns out we have no windows (this can happen on macOS),
    429    // we have no opportunity to ask the user whether or not they want
    430    // to stop the hang or wait, so we'll opt for stopping the hang.
    431    if (!e.hasMoreElements()) {
    432      this.stopAllHangs("no-windows-left");
    433      return;
    434    }
    435 
    436    for (let win of e) {
    437      this.updateWindow(win);
    438 
    439      // Only listen for these events if there are active hang reports.
    440      if (this._activeReports.size) {
    441        this.trackWindow(win);
    442      } else {
    443        this.untrackWindow(win);
    444      }
    445    }
    446  },
    447 
    448  /**
    449   * If there is a hang report for the current tab in |win|, display it.
    450   */
    451  updateWindow(win) {
    452    let report = this.findActiveReport(win.gBrowser.selectedBrowser);
    453 
    454    if (report) {
    455      let info = this._activeReports.get(report);
    456      if (info && !info.notificationTime) {
    457        info.notificationTime = ChromeUtils.now();
    458      }
    459      this.showNotification(win, report);
    460    } else {
    461      this.hideNotification(win);
    462    }
    463  },
    464 
    465  /**
    466   * Show the notification for a hang.
    467   */
    468  async showNotification(win, report) {
    469    let bundle = win.gNavigatorBundle;
    470 
    471    let buttons = [
    472      {
    473        label: bundle.getString("processHang.button_stop2.label"),
    474        accessKey: bundle.getString("processHang.button_stop2.accessKey"),
    475        callback() {
    476          ProcessHangMonitor.stopIt(win);
    477        },
    478      },
    479    ];
    480 
    481    let message;
    482    let doc = win.document;
    483    let brandShortName = doc
    484      .getElementById("bundle_brand")
    485      .getString("brandShortName");
    486    let notificationTag;
    487    if (report.addonId) {
    488      notificationTag = report.addonId;
    489      let aps = Cc["@mozilla.org/addons/policy-service;1"].getService(
    490        Ci.nsIAddonPolicyService
    491      );
    492 
    493      let addonName = aps.getExtensionName(report.addonId);
    494 
    495      message = bundle.getFormattedString("processHang.add-on.label2", [
    496        addonName,
    497        brandShortName,
    498      ]);
    499 
    500      buttons.unshift({
    501        label: bundle.getString("processHang.add-on.learn-more.text"),
    502        link: "https://support.mozilla.org/kb/warning-unresponsive-script#w_other-causes",
    503      });
    504    } else {
    505      let scriptBrowser = report.scriptBrowser;
    506      if (scriptBrowser == win.gBrowser?.selectedBrowser) {
    507        notificationTag = "selected-tab";
    508        message = bundle.getFormattedString("processHang.selected_tab.label", [
    509          brandShortName,
    510        ]);
    511      } else {
    512        let tab =
    513          scriptBrowser?.ownerGlobal.gBrowser?.getTabForBrowser(scriptBrowser);
    514        if (!tab) {
    515          notificationTag = "nonspecific_tab";
    516          message = bundle.getFormattedString(
    517            "processHang.nonspecific_tab.label",
    518            [brandShortName]
    519          );
    520        } else {
    521          notificationTag = scriptBrowser.browserId.toString();
    522          let title = tab.getAttribute("label");
    523          title = elideMiddleOfString(title, 60);
    524          message = bundle.getFormattedString(
    525            "processHang.specific_tab.label",
    526            [title, brandShortName]
    527          );
    528        }
    529      }
    530    }
    531 
    532    let notification =
    533      win.gNotificationBox.getNotificationWithValue("process-hang");
    534    if (notificationTag == notification?.getAttribute("notification-tag")) {
    535      return;
    536    }
    537 
    538    if (notification) {
    539      notification.label = message;
    540      notification.setAttribute("notification-tag", notificationTag);
    541      return;
    542    }
    543 
    544    // Show the "debug script" button unconditionally if we are in Developer or Nightly
    545    // editions, or if DevTools are opened on the slow tab.
    546    if (
    547      AppConstants.MOZ_DEV_EDITION ||
    548      AppConstants.NIGHTLY_BUILD ||
    549      report.scriptBrowser.browsingContext.watchedByDevTools
    550    ) {
    551      buttons.push({
    552        label: bundle.getString("processHang.button_debug.label"),
    553        accessKey: bundle.getString("processHang.button_debug.accessKey"),
    554        callback() {
    555          ProcessHangMonitor.debugScript(win);
    556        },
    557      });
    558    }
    559 
    560    // Sometimes the window may have closed already, in which case we won't
    561    // be able to create a message bar so we need to handle any related errors.
    562    try {
    563      let hangNotification = await win.gNotificationBox.appendNotification(
    564        "process-hang",
    565        {
    566          label: message,
    567          image: "chrome://browser/content/aboutRobots-icon.png",
    568          priority: win.gNotificationBox.PRIORITY_INFO_HIGH,
    569          eventCallback: event => {
    570            if (event == "dismissed") {
    571              ProcessHangMonitor.waitLonger(win);
    572            }
    573          },
    574        },
    575        buttons
    576      );
    577      hangNotification.setAttribute("notification-tag", notificationTag);
    578    } catch (err) {
    579      console.warn(err);
    580    }
    581  },
    582 
    583  /**
    584   * Ensure that no hang notifications are visible in |win|.
    585   */
    586  hideNotification(win) {
    587    let notification =
    588      win.gNotificationBox.getNotificationWithValue("process-hang");
    589    if (notification) {
    590      win.gNotificationBox.removeNotification(notification);
    591    }
    592  },
    593 
    594  /**
    595   * Install event handlers on |win| to watch for events that would
    596   * cause a different hang report to be displayed.
    597   */
    598  trackWindow(win) {
    599    win.gBrowser.tabContainer.addEventListener("TabSelect", this, true);
    600    win.gBrowser.tabContainer.addEventListener(
    601      "TabRemotenessChange",
    602      this,
    603      true
    604    );
    605  },
    606 
    607  untrackWindow(win) {
    608    win.gBrowser.tabContainer.removeEventListener("TabSelect", this, true);
    609    win.gBrowser.tabContainer.removeEventListener(
    610      "TabRemotenessChange",
    611      this,
    612      true
    613    );
    614  },
    615 
    616  handleEvent(event) {
    617    let win = event.target.ownerGlobal;
    618 
    619    // If a new tab is selected or if a tab changes remoteness, then
    620    // we may need to show or hide a hang notification.
    621    if (event.type == "TabSelect" || event.type == "TabRemotenessChange") {
    622      if (event.type == "TabSelect" && event.detail.previousTab) {
    623        // If we've got a notification, check the previous tab's report and
    624        // indicate the user switched tabs while the notification was up.
    625        let r =
    626          this.findActiveReport(event.detail.previousTab.linkedBrowser) ||
    627          this.findPausedReport(event.detail.previousTab.linkedBrowser);
    628        if (r) {
    629          let info = this._activeReports.get(r) || this._pausedReports.get(r);
    630          info.deselectCount++;
    631        }
    632      }
    633      this.updateWindow(win);
    634    }
    635  },
    636 
    637  /**
    638   * Handle a potentially new hang report. If it hasn't been seen
    639   * before, show a notification for it in all open XUL windows.
    640   */
    641  reportHang(report) {
    642    let now = ChromeUtils.now();
    643    if (this._shuttingDown) {
    644      this.stopHang(report, "shutdown-in-progress", {
    645        lastReportFromChild: now,
    646        waitCount: 0,
    647        deselectCount: 0,
    648      });
    649      return;
    650    }
    651 
    652    // If this hang was already reported reset the timer for it.
    653    if (this._activeReports.has(report)) {
    654      this._activeReports.get(report).lastReportFromChild = now;
    655      // if this report is in active but doesn't have a notification associated
    656      // with it, display a notification.
    657      this.updateWindows();
    658      return;
    659    }
    660 
    661    // If this hang was already reported and paused by the user ignore it.
    662    if (this._pausedReports.has(report)) {
    663      this._pausedReports.get(report).lastReportFromChild = now;
    664      return;
    665    }
    666 
    667    // On e10s this counts slow-script notice only once.
    668    // This code is not reached on non-e10s.
    669    Glean.dom.slowScriptNoticeCount.add(1);
    670 
    671    this._activeReports.set(report, {
    672      deselectCount: 0,
    673      lastReportFromChild: now,
    674      waitCount: 0,
    675    });
    676    this.updateWindows();
    677  },
    678 
    679  clearHang(report) {
    680    this._recordTelemetryForReport(report, "cleared");
    681 
    682    this.removeActiveReport(report);
    683    this.removePausedReport(report);
    684    report.userCanceled();
    685  },
    686 };