Bug 1521094 - Basic API for handling nsIHangReport in GeckoView r=geckoview-reviewers,snorp
authorElliot Stirling <estirling@mozilla.com>
Thu, 29 Aug 2019 15:51:55 +0000
changeset 554424 8fc1742afe2c2dee8356d865f28c2deefc3c94ab
parent 554423 5014bc0af0ebc034fd4f257a2f03f259b439069a
child 554425 994237f8b72983fa622a6e9ff2d347d0436301d3
push id2165
push userffxbld-merge
push dateMon, 14 Oct 2019 16:30:58 +0000
treeherdermozilla-release@0eae18af659f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersgeckoview-reviewers, snorp
bugs1521094
milestone70.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1521094 - Basic API for handling nsIHangReport in GeckoView r=geckoview-reviewers,snorp Hooks up slow script reporting to the ContentDelegate. Allowing slow script prompts to be implemented. Differential Revision: https://phabricator.services.mozilla.com/D41970
mobile/android/app/geckoview-prefs.js
mobile/android/chrome/geckoview/geckoview.js
mobile/android/geckoview/api.txt
mobile/android/geckoview/src/androidTest/assets/www/hungScript.html
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/BasicGeckoViewPrompt.java
mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/GeckoViewActivity.java
mobile/android/geckoview_example/src/main/res/values/strings.xml
mobile/android/modules/geckoview/GeckoViewProcessHangMonitor.jsm
mobile/android/modules/geckoview/moz.build
testing/profiles/unittest-required/user.js
--- a/mobile/android/app/geckoview-prefs.js
+++ b/mobile/android/app/geckoview-prefs.js
@@ -17,16 +17,19 @@
 
 pref("privacy.trackingprotection.pbmode.enabled", false);
 
 pref("dom.ipc.keepProcessesAlive.web", 1);
 pref("dom.ipc.processCount", 1);
 pref("dom.ipc.processHangMonitor", true);
 pref("dom.ipc.processPrelaunch.enabled", false);
 
+// Enable hang reports
+pref("dom.ipc.reportProcessHangs", true);
+
 // Tell Telemetry that we're in GeckoView mode.
 pref("toolkit.telemetry.isGeckoViewMode", true);
 // Disable the Telemetry Event Ping
 pref("toolkit.telemetry.eventping.enabled", false);
 
 pref("geckoview.console.enabled", false);
 
 #ifdef RELEASE_OR_BETA
--- a/mobile/android/chrome/geckoview/geckoview.js
+++ b/mobile/android/chrome/geckoview/geckoview.js
@@ -477,16 +477,22 @@ function startup() {
     {
       name: "GeckoViewNavigation",
       onInit: {
         resource: "resource://gre/modules/GeckoViewNavigation.jsm",
         frameScript: "chrome://geckoview/content/GeckoViewNavigationChild.js",
       },
     },
     {
+      name: "GeckoViewProcessHangMonitor",
+      onInit: {
+        resource: "resource://gre/modules/GeckoViewProcessHangMonitor.jsm",
+      },
+    },
+    {
       name: "GeckoViewProgress",
       onEnable: {
         resource: "resource://gre/modules/GeckoViewProgress.jsm",
         frameScript: "chrome://geckoview/content/GeckoViewProgressChild.js",
       },
     },
     {
       name: "GeckoViewScroll",
--- a/mobile/android/geckoview/api.txt
+++ b/mobile/android/geckoview/api.txt
@@ -72,16 +72,17 @@ import org.mozilla.geckoview.MediaElemen
 import org.mozilla.geckoview.OverscrollEdgeEffect;
 import org.mozilla.geckoview.PanZoomController;
 import org.mozilla.geckoview.RuntimeSettings;
 import org.mozilla.geckoview.RuntimeTelemetry;
 import org.mozilla.geckoview.ScreenLength;
 import org.mozilla.geckoview.SessionAccessibility;
 import org.mozilla.geckoview.SessionFinder;
 import org.mozilla.geckoview.SessionTextInput;
+import org.mozilla.geckoview.SlowScriptResponse;
 import org.mozilla.geckoview.StorageController;
 import org.mozilla.geckoview.WebExtension;
 import org.mozilla.geckoview.WebExtensionController;
 import org.mozilla.geckoview.WebExtensionEventDispatcher;
 import org.mozilla.geckoview.WebMessage;
 import org.mozilla.geckoview.WebNotification;
 import org.mozilla.geckoview.WebNotificationDelegate;
 import org.mozilla.geckoview.WebRequest;
@@ -503,16 +504,17 @@ package org.mozilla.geckoview {
     method @UiThread default public void onCloseRequest(@NonNull GeckoSession);
     method @UiThread default public void onContextMenu(@NonNull GeckoSession, int, int, @NonNull GeckoSession.ContentDelegate.ContextElement);
     method @UiThread default public void onCrash(@NonNull GeckoSession);
     method @UiThread default public void onExternalResponse(@NonNull GeckoSession, @NonNull GeckoSession.WebResponseInfo);
     method @UiThread default public void onFirstComposite(@NonNull GeckoSession);
     method @UiThread default public void onFocusRequest(@NonNull GeckoSession);
     method @UiThread default public void onFullScreen(@NonNull GeckoSession, boolean);
     method @UiThread default public void onKill(@NonNull GeckoSession);
+    method @UiThread @Nullable default public GeckoResult<SlowScriptResponse> onSlowScript(@NonNull GeckoSession, @NonNull String);
     method @UiThread default public void onTitleChange(@NonNull GeckoSession, @Nullable String);
     method @UiThread default public void onWebAppManifest(@NonNull GeckoSession, @NonNull JSONObject);
   }
 
   public static class GeckoSession.ContentDelegate.ContextElement {
     ctor protected ContextElement(@Nullable String, @Nullable String, @Nullable String, @Nullable String, @NonNull String, @Nullable String);
     field public static final int TYPE_AUDIO = 3;
     field public static final int TYPE_IMAGE = 1;
@@ -1153,16 +1155,23 @@ package org.mozilla.geckoview {
     method @UiThread public boolean onKeyMultiple(int, int, @NonNull KeyEvent);
     method @UiThread public boolean onKeyPreIme(int, @NonNull KeyEvent);
     method @UiThread public boolean onKeyUp(int, @NonNull KeyEvent);
     method @UiThread public void onProvideAutofillVirtualStructure(@NonNull ViewStructure, int);
     method @UiThread public void setDelegate(@Nullable GeckoSession.TextInputDelegate);
     method @UiThread public synchronized void setView(@Nullable View);
   }
 
+  @AnyThread public final enum SlowScriptResponse {
+    method public static SlowScriptResponse valueOf(String);
+    method public static SlowScriptResponse[] values();
+    enum_constant public static final SlowScriptResponse CONTINUE;
+    enum_constant public static final SlowScriptResponse STOP;
+  }
+
   public final class StorageController {
     ctor public StorageController();
     method @AnyThread @NonNull public GeckoResult<Void> clearData(long);
     method @AnyThread public void clearDataForSessionContext(@NonNull String);
     method @AnyThread @NonNull public GeckoResult<Void> clearDataFromHost(@NonNull String, long);
   }
 
   public static class StorageController.ClearFlags {
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/www/hungScript.html
@@ -0,0 +1,11 @@
+<html>
+    <head><title>Hung Script</title>
+	</head>
+    <body><div id="content"></div></body>
+	<script>
+		var start = new Date().getTime();
+		document.getElementById("content").innerHTML = "Started";
+		while((new Date().getTime() - start) < 5000 ) {};
+		document.getElementById("content").innerHTML = "Finished";
+	</script>
+</html>
\ No newline at end of file
--- a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt
@@ -54,16 +54,17 @@ open class BaseSessionTest(noErrorCollec
         const val VIEWPORT_PATH = "/assets/www/viewport.html"
         const val IFRAME_REDIRECT_LOCAL = "/assets/www/iframe_redirect_local.html"
         const val IFRAME_REDIRECT_AUTOMATION = "/assets/www/iframe_redirect_automation.html"
         const val AUTOPLAY_PATH = "/assets/www/autoplay.html"
         const val SCROLL_TEST_PATH = "/assets/www/scroll.html"
         const val COLORS_HTML_PATH = "/assets/www/colors.html"
         const val FIXED_BOTTOM = "/assets/www/fixedbottom.html"
         const val STORAGE_TITLE_HTML_PATH = "/assets/www/reflect_local_storage_into_title.html"
+        const val HUNG_SCRIPT = "/assets/www/hungScript.html"
     }
 
     @get:Rule val sessionRule = GeckoSessionTestRule()
 
     @get:Rule val errors = ErrorCollector()
 
     val mainSession get() = sessionRule.session
 
--- a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt
@@ -31,23 +31,25 @@ import android.util.Pair
 import android.util.SparseArray
 import android.view.Surface
 import android.view.View
 import android.view.ViewStructure
 import android.view.autofill.AutofillId
 import android.view.autofill.AutofillValue
 import android.widget.EditText
 import org.hamcrest.Matchers.*
-import org.json.JSONArray
 import org.json.JSONObject
 import org.junit.Assume.assumeThat
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.mozilla.gecko.GeckoAppShell
+import org.mozilla.geckoview.SlowScriptResponse
 import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.NullDelegate
+
 
 @RunWith(AndroidJUnit4::class)
 @MediumTest
 class ContentDelegateTest : BaseSessionTest() {
     @Test fun titleChange() {
         sessionRule.session.loadTestPath(TITLE_CHANGE_HTML_PATH)
 
         sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate {
@@ -870,9 +872,153 @@ class ContentDelegateTest : BaseSessionT
                 val iconSrc = Uri.parse(icon.getString("src"))
                 assertThat("icon should have a valid src", iconSrc, notNullValue())
                 assertThat("icon src should be absolute", iconSrc.isAbsolute, equalTo(true))
                 assertThat("icon should have sizes", icon.getString("sizes"),  not(isEmptyOrNullString()))
                 assertThat("icon type should match", icon.getString("type"), equalTo("image/gif"))
             }
         })
     }
+
+
+    /**
+     * Preferences to induce wanted behaviour.
+     */
+    private fun setHangReportTestPrefs(timeout: Int = 20000) {
+        sessionRule.setPrefsUntilTestEnd(mapOf(
+                "dom.max_script_run_time" to 1,
+                "dom.max_chrome_script_run_time" to 1,
+                "dom.max_ext_content_script_run_time" to 1,
+                "dom.ipc.cpow.timeout" to 100,
+                "browser.hangNotification.waitPeriod" to timeout
+        ))
+    }
+
+    /**
+     * With no delegate set, the default behaviour is to stop hung scripts.
+     */
+    @NullDelegate(GeckoSession.ContentDelegate::class)
+    @Test fun stopHungProcessDefault() {
+        setHangReportTestPrefs()
+        mainSession.loadTestPath(HUNG_SCRIPT)
+        sessionRule.delegateUntilTestEnd(object : Callbacks.ProgressDelegate {
+            @AssertCalled(count = 1)
+            override fun onPageStop(session: GeckoSession, success: Boolean) {
+                assertThat("The script did not complete.",
+                        sessionRule.session.evaluateJS("document.getElementById(\"content\").innerHTML") as String,
+                        equalTo("Started"))
+            }
+        })
+        sessionRule.waitForPageStop(mainSession)
+    }
+
+    /**
+     * With no overriding implementation for onSlowScript, the default behaviour is to stop hung
+     * scripts.
+     */
+    @Test fun stopHungProcessNull() {
+        setHangReportTestPrefs()
+        sessionRule.delegateUntilTestEnd(object : GeckoSession.ContentDelegate, Callbacks.ProgressDelegate {
+            // default onSlowScript returns null
+            @AssertCalled(count = 1)
+            override fun onPageStop(session: GeckoSession, success: Boolean) {
+                assertThat("The script did not complete.",
+                        sessionRule.session.evaluateJS("document.getElementById(\"content\").innerHTML") as String,
+                        equalTo("Started"))
+            }
+        })
+        mainSession.loadTestPath(HUNG_SCRIPT)
+        sessionRule.waitForPageStop(mainSession)
+    }
+
+    /**
+     * Test that, with a 'do nothing' delegate, the hung process completes after its delay
+     */
+    @Test fun stopHungProcessDoNothing() {
+        setHangReportTestPrefs()
+        var scriptHungReportCount = 0
+        sessionRule.delegateUntilTestEnd(object : GeckoSession.ContentDelegate, Callbacks.ProgressDelegate {
+            @AssertCalled()
+            override fun onSlowScript(geckoSession: GeckoSession, scriptFileName: String): GeckoResult<SlowScriptResponse> {
+                scriptHungReportCount += 1;
+                return GeckoResult.fromValue(null)
+            }
+            @AssertCalled(count = 1)
+            override fun onPageStop(session: GeckoSession, success: Boolean) {
+                assertThat("The delegate was informed of the hang repeatedly", scriptHungReportCount, greaterThan(1))
+                assertThat("The script did complete.",
+                        sessionRule.session.evaluateJS("document.getElementById(\"content\").innerHTML") as String,
+                        equalTo("Finished"))
+            }
+        })
+        mainSession.loadTestPath(HUNG_SCRIPT)
+        sessionRule.waitForPageStop(mainSession)
+    }
+
+    /**
+     * Test that the delegate is called and can stop a hung script
+     */
+    @Test fun stopHungProcess() {
+        setHangReportTestPrefs()
+        sessionRule.delegateUntilTestEnd(object : GeckoSession.ContentDelegate, Callbacks.ProgressDelegate {
+            @AssertCalled(count = 1, order = [1])
+            override fun onSlowScript(geckoSession: GeckoSession, scriptFileName: String): GeckoResult<SlowScriptResponse> {
+                return GeckoResult.fromValue(SlowScriptResponse.STOP)
+            }
+            @AssertCalled(count = 1, order = [2])
+            override fun onPageStop(session: GeckoSession, success: Boolean) {
+                assertThat("The script did not complete.",
+                        sessionRule.session.evaluateJS("document.getElementById(\"content\").innerHTML") as String,
+                        equalTo("Started"))
+            }
+        })
+        mainSession.loadTestPath(HUNG_SCRIPT)
+        sessionRule.waitForPageStop(mainSession)
+    }
+
+    /**
+     * Test that the delegate is called and can continue executing hung scripts
+     */
+    @Test fun stopHungProcessWait() {
+        setHangReportTestPrefs()
+        sessionRule.delegateUntilTestEnd(object : GeckoSession.ContentDelegate, Callbacks.ProgressDelegate {
+            @AssertCalled(count = 1, order = [1])
+            override fun onSlowScript(geckoSession: GeckoSession, scriptFileName: String): GeckoResult<SlowScriptResponse> {
+                return GeckoResult.fromValue(SlowScriptResponse.CONTINUE)
+            }
+            @AssertCalled(count = 1, order = [2])
+            override fun onPageStop(session: GeckoSession, success: Boolean) {
+                assertThat("The script did complete.",
+                        sessionRule.session.evaluateJS("document.getElementById(\"content\").innerHTML") as String,
+                        equalTo("Finished"))
+            }
+        })
+        mainSession.loadTestPath(HUNG_SCRIPT)
+        sessionRule.waitForPageStop(mainSession)
+    }
+
+    /**
+     * Test that the delegate is called and paused scripts re-notify after the wait period
+     */
+    @Test fun stopHungProcessWaitThenStop() {
+        setHangReportTestPrefs(500)
+        var scriptWaited = false
+        sessionRule.delegateUntilTestEnd(object : GeckoSession.ContentDelegate, Callbacks.ProgressDelegate {
+            @AssertCalled(count = 2, order = [1, 2])
+            override fun onSlowScript(geckoSession: GeckoSession, scriptFileName: String): GeckoResult<SlowScriptResponse> {
+                return if (!scriptWaited) {
+                    scriptWaited = true;
+                    GeckoResult.fromValue(SlowScriptResponse.CONTINUE)
+                } else {
+                    GeckoResult.fromValue(SlowScriptResponse.STOP)
+                }
+            }
+            @AssertCalled(count = 1, order = [3])
+            override fun onPageStop(session: GeckoSession, success: Boolean) {
+                assertThat("The script did not complete.",
+                        sessionRule.session.evaluateJS("document.getElementById(\"content\").innerHTML") as String,
+                        equalTo("Started"))
+            }
+        })
+        mainSession.loadTestPath(HUNG_SCRIPT)
+        sessionRule.waitForPageStop(mainSession)
+    }
 }
--- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
@@ -633,16 +633,56 @@ public class GeckoSession implements Par
                             session.open(GeckoSession.this.mWindow.runtime);
                             callback.sendSuccess(session.getId());
                         }
                     }, exception -> callback.sendError(exception.getMessage()));
                 }
             }
         };
 
+    private final GeckoSessionHandler<ContentDelegate> mProcessHangHandler =
+        new GeckoSessionHandler<ContentDelegate>(
+                "GeckoViewProcessHangMonitor", this,
+                new String[]{"GeckoView:HangReport"}) {
+
+
+            @Override
+            protected void handleMessage(final ContentDelegate delegate,
+                                         final String event,
+                                         final GeckoBundle message,
+                                         final EventCallback eventCallback) {
+                Log.d(LOGTAG, "handleMessage " + event + " uri=" + message.getString("uri"));
+
+                GeckoResult<SlowScriptResponse> result = delegate.onSlowScript(GeckoSession.this,
+                        message.getString("scriptFileName"));
+                if (result != null) {
+                    final int mReportId = message.getInt("hangId");
+                    result.accept(stopOrContinue -> {
+                        if (stopOrContinue != null) {
+                            final GeckoBundle bundle = new GeckoBundle();
+                            bundle.putInt("hangId", mReportId);
+                            switch (stopOrContinue) {
+                                case STOP:
+                                    mEventDispatcher.dispatch("GeckoView:HangReportStop", bundle);
+                                    break;
+                                case CONTINUE:
+                                    mEventDispatcher.dispatch("GeckoView:HangReportWait", bundle);
+                                    break;
+                            }
+                        }
+                    });
+                } else {
+                    // default to stopping the script
+                    final GeckoBundle bundle = new GeckoBundle();
+                    bundle.putInt("hangId", message.getInt("hangId"));
+                    mEventDispatcher.dispatch("GeckoView:HangReportStop", bundle);
+                }
+            }
+        };
+
     private final GeckoSessionHandler<ProgressDelegate> mProgressHandler =
         new GeckoSessionHandler<ProgressDelegate>(
             "GeckoViewProgress", this,
             new String[]{
                 "GeckoView:PageStart",
                 "GeckoView:PageStop",
                 "GeckoView:ProgressChanged",
                 "GeckoView:SecurityChanged",
@@ -924,18 +964,18 @@ public class GeckoSession implements Par
         }
     };
 
 
     /* package */ int handlersCount;
 
     private final GeckoSessionHandler<?>[] mSessionHandlers = new GeckoSessionHandler<?>[] {
         mContentHandler, mHistoryHandler, mMediaHandler, mNavigationHandler,
-        mPermissionHandler, mProgressHandler, mScrollHandler, mSelectionActionDelegate,
-        mContentBlockingHandler
+        mPermissionHandler, mProcessHangHandler, mProgressHandler, mScrollHandler,
+        mSelectionActionDelegate, mContentBlockingHandler
     };
 
     private static class PermissionCallback implements
         PermissionDelegate.Callback, PermissionDelegate.MediaCallback {
 
         private final String mType;
         private EventCallback mCallback;
 
@@ -2257,16 +2297,17 @@ public class GeckoSession implements Par
     * Set the content callback handler.
     * This will replace the current handler.
     * @param delegate An implementation of ContentDelegate.
     */
     @UiThread
     public void setContentDelegate(final @Nullable ContentDelegate delegate) {
         ThreadUtils.assertOnUiThread();
         mContentHandler.setDelegate(delegate, this);
+        mProcessHangHandler.setDelegate(delegate, this);
     }
 
     /**
     * Get the content callback handler.
     * @return The current content callback handler.
     */
     @UiThread
     public @Nullable ContentDelegate getContentDelegate() {
@@ -2972,16 +3013,30 @@ public class GeckoSession implements Par
          * This is fired when the loaded document has a valid Web App Manifest present.
          *
          * @param session The GeckoSession that contains the Web App Manifest
          * @param manifest A parsed and validated {@link JSONObject} containing the manifest contents.
          * @see <a href="https://www.w3.org/TR/appmanifest/">Web App Manifest specification</a>
          */
         @UiThread
         default void onWebAppManifest(@NonNull GeckoSession session, @NonNull JSONObject manifest) {}
+
+        /**
+         * A script has exceeded it's execution timeout value
+         * @param geckoSession GeckoSession that initiated the callback.
+         * @param scriptFileName Filename of the slow script
+         * @return A {@link GeckoResult} with a SlowScriptResponse value which indicates whether to
+         *         allow the Slow Script to continue processing. Stop will halt the slow script.
+         *         Continue will pause notifications for a period of time before resuming.
+         */
+        @UiThread
+        default @Nullable GeckoResult<SlowScriptResponse> onSlowScript(@NonNull GeckoSession geckoSession,
+                                                                       @NonNull String scriptFileName) {
+            return null;
+        }
     }
 
     public interface SelectionActionDelegate {
         /**
          * The selection is collapsed at a single position.
          */
         final int FLAG_IS_COLLAPSED = 1;
         /**
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java
@@ -0,0 +1,18 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.support.annotation.AnyThread;
+
+/**
+ * Used by a ContentDelegate to indicate what action to take on a slow script event.
+ * @see GeckoSession.ContentDelegate#onSlowScript(GeckoSession,String)
+ */
+@AnyThread
+public enum SlowScriptResponse {
+    STOP, CONTINUE;
+}
--- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
@@ -49,16 +49,18 @@ exclude: true
 - Added Social Tracking Protection support to [`ContentBlocking`][70.17].
   ([bug 1568295]({{bugzilla}}1568295))
 - Added [`WebExtensionController`][70.18] and [`WebExtensionController.TabDelegate`][70.19] to handle
   [`browser.tabs.create`][70.20] calls by WebExtensions.
   ([bug 1539144]({{bugzilla}}1539144))
 - Added [`onCloseTab`][70.21] to [`WebExtensionController.TabDelegate`][70.19] to handle
   [`browser.tabs.remove`][70.22] calls by WebExtensions.
   ([bug 1565782]({{bugzilla}}1565782))
+- Added onSlowScript to [`ContentDelegate`][70.23] which allows handling of slow and hung scripts.
+  ([bug 1621094]({{bugzilla}}1621094))
 
 [70.1]: {{javadoc_uri}}/GeckoSessionSettings.Builder.html#contextId-java.lang.String-
 [70.2]: {{javadoc_uri}}/StorageController.html#clearDataForSessionContext-java.lang.String-
 [70.3]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport-android.content.Context-java.io.File-java.io.File-java.lang.String-
 [70.4]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport-android.content.Context-java.io.File-java.util.Map-java.lang.String-
 [70.5]: {{javadoc_uri}}/GeckoView.html
 [70.6]: {{javadoc_uri}}/GeckoSession.html
 [70.7]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#CAPTURE_TYPE_NONE
@@ -72,16 +74,17 @@ exclude: true
 [70.15]: {{javadoc_uri}}/WebNotification.html
 [70.16]: {{javadoc_uri}}/WebNotificationDelegate.html
 [70.17]: {{javadoc_uri}}/ContentBlocking.html
 [70.18]: {{javadoc_uri}}/WebExtensionController.html
 [70.19]: {{javadoc_uri}}/WebExtensionController.TabDelegate.html
 [70.20]: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/create
 [70.21]: {{javadoc_uri}}/WebExtensionController.TabDelegate.html#onCloseTab-org.mozilla.geckoview.WebExtension-org.mozilla.geckoview.GeckoSession-
 [70.22]: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/remove
+[70.23]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html
 
 ## v69
 - Modified behavior of ['setAutomaticFontSizeAdjustment'][69.1] so that it no 
   longer has any effect on ['setFontInflationEnabled'][69.2]
 - Add [GeckoSession.LOAD_FLAGS_FORCE_ALLOW_DATA_URI][69.14]
 - Added [`GeckoResult.accept`][69.3] for consuming a result without
   transforming it.
 - [`GeckoSession.setMessageDelegate`][69.13] callers must now specify the
@@ -315,9 +318,9 @@ exclude: true
 [65.19]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.LoadRequest.html#isRedirect
 [65.20]: {{javadoc_uri}}/GeckoSession.html#LOAD_FLAGS_BYPASS_CLASSIFIER    
 [65.21]: {{javadoc_uri}}/GeckoSession.ContentDelegate.ContextElement.html
 [65.22]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onContextMenu-org.mozilla.geckoview.GeckoSession-int-int-org.mozilla.geckoview.GeckoSession.ContentDelegate.ContextElement-
 [65.23]: {{javadoc_uri}}/GeckoSession.FinderResult.html
 [65.24]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport-android.content.Context-android.os.Bundle-java.lang.String-
 [65.25]: {{javadoc_uri}}/GeckoResult.html
 
-[api-version]: 3dc92af421d9e7e2393619f7a17c57422aa51699
+[api-version]: 4199a7a889674d4402926366a7f4fb2f93de5c46
--- a/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/BasicGeckoViewPrompt.java
+++ b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/BasicGeckoViewPrompt.java
@@ -22,22 +22,19 @@ import android.text.InputType;
 import android.text.format.DateFormat;
 import android.util.Log;
 import android.view.InflateException;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
 import android.widget.AdapterView;
 import android.widget.ArrayAdapter;
-import android.widget.CheckBox;
 import android.widget.CheckedTextView;
-import android.widget.CompoundButton;
 import android.widget.DatePicker;
 import android.widget.EditText;
-import android.widget.FrameLayout;
 import android.widget.ImageView;
 import android.widget.LinearLayout;
 import android.widget.ListView;
 import android.widget.ScrollView;
 import android.widget.Spinner;
 import android.widget.TextView;
 import android.widget.TimePicker;
 
@@ -46,18 +43,18 @@ import java.text.SimpleDateFormat;
 import java.util.ArrayList;
 import java.util.Calendar;
 import java.util.Date;
 import java.util.Locale;
 
 import org.mozilla.geckoview.AllowOrDeny;
 import org.mozilla.geckoview.GeckoResult;
 import org.mozilla.geckoview.GeckoSession;
-import org.mozilla.geckoview.GeckoSession.PromptDelegate;
 import org.mozilla.geckoview.GeckoSession.PermissionDelegate.MediaSource;
+import org.mozilla.geckoview.SlowScriptResponse;
 
 final class BasicGeckoViewPrompt implements GeckoSession.PromptDelegate {
     protected static final String LOGTAG = "BasicGeckoViewPrompt";
 
     private final Activity mActivity;
     public int filePickerRequestCode = 1;
     private int mFileType;
     private GeckoResult<PromptResponse> mFileResponse;
@@ -823,16 +820,40 @@ final class BasicGeckoViewPrompt impleme
                        callback.grant();
                    }
                });
 
         final AlertDialog dialog = builder.create();
         dialog.show();
     }
 
+    public void onSlowScriptPrompt(GeckoSession geckoSession, String title, GeckoResult<SlowScriptResponse> reportAction) {
+        final Activity activity = mActivity;
+        if (activity == null) {
+            return;
+        }
+        final AlertDialog.Builder builder = new AlertDialog.Builder(activity);
+        builder.setTitle(title)
+                .setNegativeButton(activity.getString(R.string.wait), new DialogInterface.OnClickListener() {
+                    @Override
+                    public void onClick(final DialogInterface dialog, final int which) {
+                        reportAction.complete(SlowScriptResponse.CONTINUE);
+                    }
+                })
+                .setPositiveButton(activity.getString(R.string.stop), new DialogInterface.OnClickListener() {
+                    @Override
+                    public void onClick(final DialogInterface dialog, final int which) {
+                        reportAction.complete(SlowScriptResponse.STOP);
+                    }
+                });
+
+        final AlertDialog dialog = builder.create();
+        dialog.show();
+    }
+
     private Spinner addMediaSpinner(final Context context, final ViewGroup container,
                                     final MediaSource[] sources, final String[] sourceNames) {
         final ArrayAdapter<MediaSource> adapter = new ArrayAdapter<MediaSource>(
                 context, android.R.layout.simple_spinner_item) {
             private View convertView(final int position, final View view) {
                 if (view != null) {
                     final MediaSource item = getItem(position);
                     ((TextView) view).setText(sourceNames != null ? sourceNames[position] : item.name);
--- a/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/GeckoViewActivity.java
+++ b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/GeckoViewActivity.java
@@ -12,16 +12,17 @@ import org.mozilla.geckoview.BasicSelect
 import org.mozilla.geckoview.ContentBlocking;
 import org.mozilla.geckoview.GeckoResult;
 import org.mozilla.geckoview.GeckoRuntime;
 import org.mozilla.geckoview.GeckoRuntimeSettings;
 import org.mozilla.geckoview.GeckoSession;
 import org.mozilla.geckoview.GeckoSessionSettings;
 import org.mozilla.geckoview.GeckoView;
 import org.mozilla.geckoview.GeckoWebExecutor;
+import org.mozilla.geckoview.SlowScriptResponse;
 import org.mozilla.geckoview.WebExtension;
 import org.mozilla.geckoview.WebExtensionController;
 import org.mozilla.geckoview.WebNotification;
 import org.mozilla.geckoview.WebNotificationDelegate;
 import org.mozilla.geckoview.WebRequest;
 import org.mozilla.geckoview.WebRequestError;
 import org.mozilla.geckoview.RuntimeTelemetry;
 import org.mozilla.geckoview.WebResponse;
@@ -40,16 +41,17 @@ import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
 import android.graphics.drawable.Icon;
 import android.net.Uri;
 import android.os.Build;
 import android.os.Bundle;
 import android.os.Environment;
 import android.os.SystemClock;
 import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
 import android.support.v4.app.ActivityCompat;
 import android.support.v4.app.NotificationCompat;
 import android.support.v4.app.NotificationManagerCompat;
 import android.support.v4.content.ContextCompat;
 import android.support.v7.app.ActionBar;
 import android.support.v7.app.AppCompatActivity;
 import android.util.Log;
 import android.view.Menu;
@@ -745,16 +747,36 @@ public class GeckoViewActivity extends A
         public void onFirstComposite(final GeckoSession session) {
             Log.d(LOGTAG, "onFirstComposite");
         }
 
         @Override
         public void onWebAppManifest(final GeckoSession session, JSONObject manifest) {
             Log.d(LOGTAG, "onWebAppManifest: " + manifest);
         }
+
+        private boolean activeAlert = false;
+
+        @Override
+        public GeckoResult<SlowScriptResponse> onSlowScript(final GeckoSession geckoSession,
+                                                            final String scriptFileName) {
+            BasicGeckoViewPrompt prompt = (BasicGeckoViewPrompt) mTabSessionManager.getCurrentSession().getPromptDelegate();
+            if (prompt != null) {
+                GeckoResult<SlowScriptResponse> result = new GeckoResult<SlowScriptResponse>();
+                if (!activeAlert) {
+                    activeAlert = true;
+                    prompt.onSlowScriptPrompt(geckoSession, getString(R.string.slow_script), result);
+                }
+                return result.then(value -> {
+                    activeAlert = false;
+                    return GeckoResult.fromValue(value);
+                });
+            }
+            return null;
+        }
     }
 
     private class ExampleProgressDelegate implements GeckoSession.ProgressDelegate {
         private ExampleContentBlockingDelegate mCb;
 
         private ExampleProgressDelegate(final ExampleContentBlockingDelegate cb) {
             mCb = cb;
         }
--- a/mobile/android/geckoview_example/src/main/res/values/strings.xml
+++ b/mobile/android/geckoview_example/src/main/res/values/strings.xml
@@ -29,9 +29,12 @@
     <string name="crashed_text">Tap to report to Mozilla.</string>
     <string name="crashed_ignore">Ignore</string>
     <string name="device_sharing_microphone">Microphone is on</string>
     <string name="device_sharing_camera">Camera is on</string>
     <string name="device_sharing_camera_and_mic">Camera and microphone are on</string>
     <string name="new_tab">New tab</string>
     <string name="close_tab">Close tab</string>
     <string name="desktop_mode">Desktop site</string>
+    <string name="slow_script">A script on this page is causing your web browser to run slowly</string>
+    <string name="wait">Wait</string>
+    <string name="stop">Stop</string>
 </resources>
new file mode 100644
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewProcessHangMonitor.jsm
@@ -0,0 +1,243 @@
+/* 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 = ["GeckoViewProcessHangMonitor"];
+
+const { GeckoViewModule } = ChromeUtils.import(
+  "resource://gre/modules/GeckoViewModule.jsm"
+);
+
+const { XPCOMUtils } = ChromeUtils.import(
+  "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  Services: "resource://gre/modules/Services.jsm",
+});
+
+class GeckoViewProcessHangMonitor extends GeckoViewModule {
+  constructor(aModuleInfo) {
+    super(aModuleInfo);
+
+    /**
+     * Collection of hang reports that haven't expired or been dismissed
+     * by the user. These are nsIHangReports.
+     */
+    this._activeReports = new Set();
+
+    /**
+     * Collection of hang reports that have been suppressed for a short
+     * period of time. Keys are nsIHangReports. Values are timeouts for
+     * when the wait time expires.
+     */
+    this._pausedReports = new Map();
+
+    /**
+     * Simple index used for report identification
+     */
+    this._nextIndex = 0;
+
+    /**
+     * Map of report IDs to report objects.
+     * Keys are numbers. Values are nsIHangReports.
+     */
+    this._reportIndex = new Map();
+
+    /**
+     * Map of report objects to report IDs.
+     * Keys are nsIHangReports. Values are numbers.
+     */
+    this._reportLookupIndex = new Map();
+  }
+
+  onInit() {
+    debug`onInit`;
+    Services.obs.addObserver(this, "process-hang-report");
+    Services.obs.addObserver(this, "clear-hang-report");
+  }
+
+  onDestroy() {
+    debug`onDestroy`;
+    Services.obs.removeObserver(this, "process-hang-report");
+    Services.obs.removeObserver(this, "clear-hang-report");
+  }
+
+  onEnable() {
+    debug`onEnable`;
+    this.registerListener([
+      "GeckoView:HangReportStop",
+      "GeckoView:HangReportWait",
+    ]);
+  }
+
+  onDisable() {
+    debug`onDisable`;
+    this.unregisterListener();
+  }
+
+  // Bundle event handler.
+  onEvent(aEvent, aData, aCallback) {
+    debug`onEvent: event=${aEvent}, data=${aData}`;
+
+    if (this._reportIndex.has(aData.hangId)) {
+      const report = this._reportIndex.get(aData.hangId);
+      switch (aEvent) {
+        case "GeckoView:HangReportStop":
+          this.stopHang(report);
+          break;
+        case "GeckoView:HangReportWait":
+          this.pauseHang(report);
+          break;
+      }
+    } else {
+      debug`Report not found: reportIndex=${this._reportIndex}`;
+    }
+  }
+
+  // nsIObserver event handler
+  observe(aSubject, aTopic, aData) {
+    debug`observe(aTopic=${aTopic})`;
+    aSubject.QueryInterface(Ci.nsIHangReport);
+    if (!aSubject.isReportForBrowser(this.browser.frameLoader)) {
+      return;
+    }
+
+    switch (aTopic) {
+      case "process-hang-report": {
+        this.reportHang(aSubject);
+        break;
+      }
+      case "clear-hang-report": {
+        this.clearHang(aSubject);
+        break;
+      }
+    }
+  }
+
+  /**
+   * This timeout is the wait period applied after a user selects "Wait" in
+   * an existing notification.
+   */
+  get WAIT_EXPIRATION_TIME() {
+    try {
+      return Services.prefs.getIntPref("browser.hangNotification.waitPeriod");
+    } catch (ex) {
+      return 10000;
+    }
+  }
+
+  /**
+   * Terminate whatever is causing this report, be it an add-on, page script,
+   * or plug-in. This is done without updating any report notifications.
+   */
+  stopHang(report) {
+    switch (report.hangType) {
+      case report.SLOW_SCRIPT: {
+        if (report.addonId) {
+          report.terminateGlobal();
+        } else {
+          report.terminateScript();
+        }
+        break;
+      }
+      case report.PLUGIN_HANG: {
+        report.terminatePlugin();
+        break;
+      }
+    }
+  }
+
+  /**
+   *
+   */
+  pauseHang(report) {
+    this._activeReports.delete(report);
+
+    // Create a new timeout with notify callback
+    const timer = this.window.setTimeout(() => {
+      for (const [stashedReport, otherTimer] of this._pausedReports) {
+        if (otherTimer === timer) {
+          this._pausedReports.delete(stashedReport);
+
+          // We're still hung, so move the report back to the active
+          // list.
+          this._activeReports.add(report);
+          break;
+        }
+      }
+    }, this.WAIT_EXPIRATION_TIME);
+
+    this._pausedReports.set(report, timer);
+  }
+
+  /**
+   * construct an information bundle
+   */
+  notifyReport(report) {
+    const message = {
+      type: "GeckoView:HangReport",
+      hangId: this._reportLookupIndex.get(report),
+    };
+
+    if (report.hangType == report.SLOW_SCRIPT) {
+      message.hangType = "SLOW_SCRIPT";
+      message.scriptFileName = report.scriptFileName;
+      this.eventDispatcher.sendRequest(message);
+    }
+  }
+
+  /**
+   * Handle a potentially new hang report.
+   */
+  reportHang(report) {
+    // if we aren't enabled then default to stopping the script
+    if (!this.enabled) {
+      this.stopHang(report);
+      return;
+    }
+
+    // if we have already notified, remind
+    if (this._activeReports.has(report)) {
+      this.notifyReport(report);
+      return;
+    }
+
+    // If this hang was already reported and paused by the user then ignore it.
+    if (this._pausedReports.has(report)) {
+      return;
+    }
+
+    const index = this._nextIndex++;
+    this._reportLookupIndex.set(report, index);
+    this._reportIndex.set(index, report);
+    this._activeReports.add(report);
+
+    // Actually notify the new report
+    this.notifyReport(report);
+  }
+
+  clearHang(report) {
+    this._activeReports.delete(report);
+
+    const timer = this._pausedReports.get(report);
+    if (timer) {
+      this.window.clearTimeout(timer);
+    }
+    this._pausedReports.delete(report);
+
+    if (this._reportLookupIndex.has(report)) {
+      const index = this._reportLookupIndex.get(report);
+      this._reportIndex.delete(index);
+    }
+    this._reportLookupIndex.delete(report);
+    report.userCanceled();
+  }
+}
+
+// eslint-disable-next-line no-unused-vars
+const { debug, warn } = GeckoViewProcessHangMonitor.initLogging(
+  "GeckoViewProcessHangMonitor"
+);
--- a/mobile/android/modules/geckoview/moz.build
+++ b/mobile/android/modules/geckoview/moz.build
@@ -11,16 +11,17 @@ EXTRA_JS_MODULES += [
     'GeckoViewAccessibility.jsm',
     'GeckoViewAutoFill.jsm',
     'GeckoViewChildModule.jsm',
     'GeckoViewConsole.jsm',
     'GeckoViewContent.jsm',
     'GeckoViewMedia.jsm',
     'GeckoViewModule.jsm',
     'GeckoViewNavigation.jsm',
+    'GeckoViewProcessHangMonitor.jsm',
     'GeckoViewProgress.jsm',
     'GeckoViewRemoteDebugger.jsm',
     'GeckoViewSettings.jsm',
     'GeckoViewStorageController.jsm',
     'GeckoViewTab.jsm',
     'GeckoViewTelemetry.jsm',
     'GeckoViewUtils.jsm',
     'GeckoViewWebExtension.jsm',
--- a/testing/profiles/unittest-required/user.js
+++ b/testing/profiles/unittest-required/user.js
@@ -81,17 +81,17 @@ user_pref("consoleservice.logcat", true)
 // works. It just can't hit the default production endpoint.
 user_pref("datareporting.healthreport.documentServerURI", "http://{server}/healthreport/");
 user_pref("datareporting.healthreport.uploadEnabled", false);
 user_pref("devtools.browsertoolbox.panel", "jsdebugger");
 user_pref("devtools.debugger.remote-port", 6023);
 user_pref("devtools.testing", true);
 user_pref("dom.allow_scripts_to_close_windows", true);
 user_pref("dom.disable_open_during_load", false);
-user_pref("dom.ipc.reportProcessHangs", false); // process hang monitor
+user_pref("dom.ipc.reportProcessHangs", true); // process hang monitor
 // Don't forceably kill content processes after a timeout
 user_pref("dom.ipc.tabs.shutdownTimeoutSecs", 0);
 user_pref("dom.min_background_timeout_value", 1000);
 user_pref("dom.popup_maximum", -1);
 user_pref("dom.block_multiple_popups", false);
 user_pref("dom.presentation.testing.simulate-receiver", false);
 // Prevent connection to the push server for tests.
 user_pref("dom.push.connection.enabled", false);