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 };