Bug 1372670 - part 3 - add spinEventLoopUntil to nsIThreadManager; r=erahm,florian
authorNathan Froyd <froydnj@mozilla.com>
Wed, 21 Jun 2017 12:59:28 -0400
changeset 414060 a00c6d0328e675080dfee1345dc75cc51f2b0cdf
parent 414059 e2d2b68377bfa06c348d79068c474b7c6451a4d5
child 414061 0f360c703e46ea15d9cfffbb7e50cb5f27ebbf91
push id7566
push usermtabara@mozilla.com
push dateWed, 02 Aug 2017 08:25:16 +0000
treeherdermozilla-beta@86913f512c3c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerserahm, florian
bugs1372670
milestone56.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 1372670 - part 3 - add spinEventLoopUntil to nsIThreadManager; r=erahm,florian
addon-sdk/source/lib/sdk/system/child_process/subprocess.js
addon-sdk/source/test/addons/content-permissions/httpd.js
addon-sdk/source/test/addons/content-script-messages-latency/httpd.js
addon-sdk/source/test/addons/e10s-content/lib/httpd.js
addon-sdk/source/test/addons/places/lib/httpd.js
addon-sdk/source/test/lib/httpd.js
browser/extensions/mortar/host/common/ppapi-runtime.jsm
devtools/client/devtools-startup.js
devtools/client/framework/devtools-browser.js
devtools/client/responsive.html/browser/tunnel.js
dom/base/test/plugin.js
dom/browser-element/BrowserElementChildPreload.js
dom/indexedDB/test/unit/test_transaction_lifetimes_nested.js
dom/plugins/test/mochitest/plugin-utils.js
dom/workers/test/serviceworkers/test_request_context.js
mobile/android/components/FilePicker.js
mobile/android/components/NSSDialogService.js
mobile/android/components/PromptService.js
mobile/android/components/TabSource.js
mobile/android/components/geckoview/GeckoViewPrompt.js
mobile/android/tests/browser/robocop/robocop_head.js
security/manager/tools/getHSTSPreloadList.js
services/common/async.js
services/sync/tps/extensions/mozmill/resource/modules/assertions.js
services/sync/tps/extensions/mozmill/resource/stdlib/httpd.js
services/sync/tps/extensions/mozmill/resource/stdlib/utils.js
storage/test/unit/head_storage.js
storage/test/unit/test_statement_executeAsync.js
testing/xpcshell/head.js
toolkit/components/places/PlacesUtils.jsm
toolkit/components/places/tests/unit/test_async_in_batchmode.js
toolkit/components/prompts/src/nsPrompter.js
toolkit/components/satchel/FormHistory.jsm
toolkit/content/tests/browser/head.js
toolkit/crashreporter/test/unit/crasher_subprocess_tail.js
toolkit/crashreporter/test/unit/test_crash_terminator.js
toolkit/modules/tests/modules/PromiseTestUtils.jsm
toolkit/modules/tests/xpcshell/test_Promise.js
toolkit/mozapps/extensions/internal/AddonTestUtils.jsm
toolkit/mozapps/extensions/internal/XPIProvider.jsm
toolkit/mozapps/extensions/test/xpcshell/test_bug542391.js
xpcom/tests/gtest/TestThreadManager.cpp
xpcom/tests/gtest/moz.build
xpcom/threads/nsIThreadManager.idl
xpcom/threads/nsThreadManager.cpp
--- a/addon-sdk/source/lib/sdk/system/child_process/subprocess.js
+++ b/addon-sdk/source/lib/sdk/system/child_process/subprocess.js
@@ -23,18 +23,17 @@ function awaitPromise(promise) {
   promise.then(val => {
     resolved = true;
     value = val;
   }, val => {
     resolved = false;
     value = val;
   });
 
-  while (resolved === null)
-    Services.tm.mainThread.processNextEvent(true);
+  Services.tm.spinEventLoopUntil(() => resolved !== null);
 
   if (resolved === true)
     return value;
   throw value;
 }
 
 let readAllData = Task.async(function* (pipe, read, callback) {
   let string;
--- a/addon-sdk/source/test/addons/content-permissions/httpd.js
+++ b/addon-sdk/source/test/addons/content-permissions/httpd.js
@@ -5170,19 +5170,19 @@ function server(port, basePath)
   DEBUG = true;
 
   var srv = new nsHttpServer();
   if (lp)
     srv.registerDirectory("/", lp);
   srv.registerContentType("sjs", SJS_TYPE);
   srv.start(port);
 
+  gThreadManager.spinEventLoopUntil(() => srv.isStopped());
+
   var thread = gThreadManager.currentThread;
-  while (!srv.isStopped())
-    thread.processNextEvent(true);
 
   // get rid of any pending requests
   while (thread.hasPendingEvents())
     thread.processNextEvent(true);
 
   DEBUG = false;
 }
 
--- a/addon-sdk/source/test/addons/content-script-messages-latency/httpd.js
+++ b/addon-sdk/source/test/addons/content-script-messages-latency/httpd.js
@@ -5170,19 +5170,19 @@ function server(port, basePath)
   DEBUG = true;
 
   var srv = new nsHttpServer();
   if (lp)
     srv.registerDirectory("/", lp);
   srv.registerContentType("sjs", SJS_TYPE);
   srv.start(port);
 
+  gThreadManager.spinEventLoopUntil(() => srv.isStopped());
+
   var thread = gThreadManager.currentThread;
-  while (!srv.isStopped())
-    thread.processNextEvent(true);
 
   // get rid of any pending requests
   while (thread.hasPendingEvents())
     thread.processNextEvent(true);
 
   DEBUG = false;
 }
 
--- a/addon-sdk/source/test/addons/e10s-content/lib/httpd.js
+++ b/addon-sdk/source/test/addons/e10s-content/lib/httpd.js
@@ -5171,19 +5171,19 @@ function server(port, basePath)
   DEBUG = true;
 
   var srv = new nsHttpServer();
   if (lp)
     srv.registerDirectory("/", lp);
   srv.registerContentType("sjs", SJS_TYPE);
   srv.start(port);
 
+  gThreadManager.spinEventLoopUntil(() => srv.isStopped());
+
   var thread = gThreadManager.currentThread;
-  while (!srv.isStopped())
-    thread.processNextEvent(true);
 
   // get rid of any pending requests
   while (thread.hasPendingEvents())
     thread.processNextEvent(true);
 
   DEBUG = false;
 }
 
--- a/addon-sdk/source/test/addons/places/lib/httpd.js
+++ b/addon-sdk/source/test/addons/places/lib/httpd.js
@@ -5170,19 +5170,19 @@ function server(port, basePath)
   DEBUG = true;
 
   var srv = new nsHttpServer();
   if (lp)
     srv.registerDirectory("/", lp);
   srv.registerContentType("sjs", SJS_TYPE);
   srv.start(port);
 
+  gThreadManager.spinEventLoopUntil(() => srv.isStopped());
+
   var thread = gThreadManager.currentThread;
-  while (!srv.isStopped())
-    thread.processNextEvent(true);
 
   // get rid of any pending requests
   while (thread.hasPendingEvents())
     thread.processNextEvent(true);
 
   DEBUG = false;
 }
 
--- a/addon-sdk/source/test/lib/httpd.js
+++ b/addon-sdk/source/test/lib/httpd.js
@@ -5171,19 +5171,19 @@ function server(port, basePath)
   DEBUG = true;
 
   var srv = new nsHttpServer();
   if (lp)
     srv.registerDirectory("/", lp);
   srv.registerContentType("sjs", SJS_TYPE);
   srv.start(port);
 
+  gThreadManager.spinEventLoopUntil(() => srv.isStopped());
+
   var thread = gThreadManager.currentThread;
-  while (!srv.isStopped())
-    thread.processNextEvent(true);
 
   // get rid of any pending requests
   while (thread.hasPendingEvents())
     thread.processNextEvent(true);
 
   DEBUG = false;
 }
 
--- a/browser/extensions/mortar/host/common/ppapi-runtime.jsm
+++ b/browser/extensions/mortar/host/common/ppapi-runtime.jsm
@@ -910,20 +910,20 @@ class Buffer extends PP_Resource {
       this.instance.rt.freeCachedBuffer(this.mem);
       delete this.mem;
     }
   }
 }
 class Flash_MessageLoop extends PP_Resource {
   run() {
     this._running = true;
-    let thread = Cc["@mozilla.org/thread-manager;1"].getService().currentThread;
-    while (this._running) {
-      thread.processNextEvent(true);
-    }
+    let tm = Cc["@mozilla.org/thread-manager;1"].getService();
+    tm.spinEventLoopUntil(() => {
+      return !this._running;
+    });
   }
   quit() {
     this._running = false;
   }
 }
 class Graphics extends PP_Resource {
   constructor(instance) {
     super(instance);
--- a/devtools/client/devtools-startup.js
+++ b/devtools/client/devtools-startup.js
@@ -134,20 +134,20 @@ DevToolsStartup.prototype = {
       Services.obs.addObserver(observe, "devtools-thread-resumed");
     }
 
     const { BrowserToolboxProcess } = Cu.import("resource://devtools/client/framework/ToolboxProcess.jsm", {});
     BrowserToolboxProcess.init();
 
     if (pauseOnStartup) {
       // Spin the event loop until the debugger connects.
-      let thread = Cc["@mozilla.org/thread-manager;1"].getService().currentThread;
-      while (!devtoolsThreadResumed) {
-        thread.processNextEvent(true);
-      }
+      let tm = Cc["@mozilla.org/thread-manager;1"].getService();
+      tm.spinEventLoopUntil(() => {
+        return devtoolsThreadResumed;
+      });
     }
 
     if (cmdLine.state == Ci.nsICommandLine.STATE_REMOTE_AUTO) {
       cmdLine.preventDefault = true;
     }
   },
 
   /**
--- a/devtools/client/framework/devtools-browser.js
+++ b/devtools/client/framework/devtools-browser.js
@@ -622,19 +622,19 @@ var gDevToolsBrowser = exports.gDevTools
           setupFinished = true;
         });
 
       // Don't return from the interrupt handler until the debugger is brought
       // up; no reason to continue executing the slow script.
       let utils = window.QueryInterface(Ci.nsIInterfaceRequestor)
                          .getInterface(Ci.nsIDOMWindowUtils);
       utils.enterModalState();
-      while (!setupFinished) {
-        tm.currentThread.processNextEvent(true);
-      }
+      tm.spinEventLoopUntil(() => {
+        return setupFinished;
+      });
       utils.leaveModalState();
     };
 
     debugService.remoteActivationHandler = function (browser, callback) {
       let chromeWindow = browser.ownerDocument.defaultView;
       let tab = chromeWindow.gBrowser.getTabForBrowser(browser);
       chromeWindow.gBrowser.selected = tab;
 
--- a/devtools/client/responsive.html/browser/tunnel.js
+++ b/devtools/client/responsive.html/browser/tunnel.js
@@ -158,19 +158,19 @@ function tunnelToInnerBrowser(outer, inn
       // different browser properties.  It is safe to alter a XBL binding dynamically.
       // The content within is not reloaded.
       outer.style.MozBinding = "url(chrome://browser/content/tabbrowser.xml" +
                                "#tabbrowser-remote-browser)";
 
       // The constructor of the new XBL binding is run asynchronously and there is no
       // event to signal its completion.  Spin an event loop to watch for properties that
       // are set by the contructor.
-      while (!outer._remoteWebNavigation) {
-        Services.tm.currentThread.processNextEvent(true);
-      }
+      Services.tm.spinEventLoopUntil(() => {
+        return outer._remoteWebNavigation;
+      });
 
       // Replace the `webNavigation` object with our own version which tries to use
       // mozbrowser APIs where possible.  This replaces the webNavigation object that the
       // remote-browser.xml binding creates.  We do not care about it's original value
       // because stop() will remove the remote-browser.xml binding and these will no
       // longer be used.
       let webNavigation = new BrowserElementWebNavigation(inner);
       webNavigation.copyStateFrom(inner._remoteWebNavigationImpl);
--- a/dom/base/test/plugin.js
+++ b/dom/base/test/plugin.js
@@ -15,18 +15,18 @@ function getTestPlugin(pluginName) {
 }
 // Copied from /dom/plugins/test/mochitest/utils.js
 function setTestPluginEnabledState(newEnabledState, pluginName) {
   var oldEnabledState = SpecialPowers.setTestPluginEnabledState(newEnabledState, pluginName);
   if (!oldEnabledState) {
     return;
   }
   var plugin = getTestPlugin(pluginName);
-  while (plugin.enabledState != newEnabledState) {
-    // Run a nested event loop to wait for the preference change to
-    // propagate to the child. Yuck!
-    SpecialPowers.Services.tm.currentThread.processNextEvent(true);
-  }
+  // Run a nested event loop to wait for the preference change to
+  // propagate to the child. Yuck!
+  SpecialPowers.Services.tm.spinEventLoopUntil(() => {
+    return plugin.enabledState == newEnabledState;
+  });
   SimpleTest.registerCleanupFunction(function() {
     SpecialPowers.setTestPluginEnabledState(oldEnabledState, pluginName);
   });
 }
 setTestPluginEnabledState(SpecialPowers.Ci.nsIPluginTag.STATE_ENABLED);
--- a/dom/browser-element/BrowserElementChildPreload.js
+++ b/dom/browser-element/BrowserElementChildPreload.js
@@ -420,30 +420,29 @@ BrowserElementChild.prototype = {
     // We'll decrement win.modalDepth when we receive a unblock-modal-prompt message
     // for the window.
     if (!win.modalDepth) {
       win.modalDepth = 0;
     }
     win.modalDepth++;
     let origModalDepth = win.modalDepth;
 
-    let thread = Services.tm.currentThread;
     debug("Nested event loop - begin");
-    while (win.modalDepth == origModalDepth && !this._shuttingDown) {
+    Services.tm.spinEventLoopUntil(() => {
       // Bail out of the loop if the inner window changed; that means the
       // window navigated.  Bail out when we're shutting down because otherwise
       // we'll leak our window.
       if (this._tryGetInnerWindowID(win) !== innerWindowID) {
         debug("_waitForResult: Inner window ID changed " +
               "while in nested event loop.");
-        break;
+        return true;
       }
 
-      thread.processNextEvent(/* mayWait = */ true);
-    }
+      return win.modalDepth !== origModalDepth || this._shuttingDown;
+    });
     debug("Nested event loop - finish");
 
     if (win.modalDepth == 0) {
       delete this._windowIDDict[outerWindowID];
     }
 
     // If we exited the loop because the inner window changed, then bail on the
     // modal prompt.
--- a/dom/indexedDB/test/unit/test_transaction_lifetimes_nested.js
+++ b/dom/indexedDB/test/unit/test_transaction_lifetimes_nested.js
@@ -21,31 +21,29 @@ function* testSteps()
   db.createObjectStore("foo");
   yield undefined;
 
   db.transaction("foo");
 
   let transaction2;
 
   let comp = this.window ? SpecialPowers.wrap(Components) : Components;
-  let thread = comp.classes["@mozilla.org/thread-manager;1"]
-                   .getService(comp.interfaces.nsIThreadManager)
-                   .currentThread;
+  let tm = comp.classes["@mozilla.org/thread-manager;1"]
+               .getService(comp.interfaces.nsIThreadManager);
+  let thread = tm.currentThread;
 
   let eventHasRun;
 
   thread.dispatch(function() {
     eventHasRun = true;
 
     transaction2 = db.transaction("foo");
   }, Components.interfaces.nsIThread.DISPATCH_NORMAL);
 
-  while (!eventHasRun) {
-    thread.processNextEvent(false);
-  }
+  tm.spinEventLoopUntil(() => eventHasRun);
 
   ok(transaction2, "Non-null transaction2");
 
   continueToNextStep();
   yield undefined;
 
   finishTest();
 }
--- a/dom/plugins/test/mochitest/plugin-utils.js
+++ b/dom/plugins/test/mochitest/plugin-utils.js
@@ -23,21 +23,21 @@ function getTestPlugin(pluginName) {
 }
 
 // call this to set the test plugin(s) initially expected enabled state.
 // it will automatically be reset to it's previous value after the test
 // ends
 function setTestPluginEnabledState(newEnabledState, pluginName) {
   var oldEnabledState = SpecialPowers.setTestPluginEnabledState(newEnabledState, pluginName);
   var plugin = getTestPlugin(pluginName);
-  while (plugin.enabledState != newEnabledState) {
-    // Run a nested event loop to wait for the preference change to
-    // propagate to the child. Yuck!
-    SpecialPowers.Services.tm.currentThread.processNextEvent(true);
-  }
+  // Run a nested event loop to wait for the preference change to
+  // propagate to the child. Yuck!
+  SpecialPowers.Services.tm.spinEventLoopUntil(() => {
+    return plugin.enabledState == newEnabledState;
+  });
   SimpleTest.registerCleanupFunction(function() {
     SpecialPowers.setTestPluginEnabledState(oldEnabledState, pluginName);
   });
 }
 
 function crashAndGetCrashServiceRecord(crashMethodName, callback) {
   var crashMan =
     SpecialPowers.Cu.import("resource://gre/modules/Services.jsm").
--- a/dom/workers/test/serviceworkers/test_request_context.js
+++ b/dom/workers/test/serviceworkers/test_request_context.js
@@ -14,21 +14,19 @@ function getTestPlugin(pluginName) {
   return null;
 }
 function setTestPluginEnabledState(newEnabledState, pluginName) {
   var oldEnabledState = SpecialPowers.setTestPluginEnabledState(newEnabledState, pluginName);
   if (!oldEnabledState) {
     return;
   }
   var plugin = getTestPlugin(pluginName);
-  while (plugin.enabledState != newEnabledState) {
-    // Run a nested event loop to wait for the preference change to
-    // propagate to the child. Yuck!
-    SpecialPowers.Services.tm.currentThread.processNextEvent(true);
-  }
+  // Run a nested event loop to wait for the preference change to
+  // propagate to the child. Yuck!
+  SpecialPowers.Services.tm.spinEventLoopUntil(() => plugin.enabledState == newEnabledState);
   SimpleTest.registerCleanupFunction(function() {
     SpecialPowers.setTestPluginEnabledState(oldEnabledState, pluginName);
   });
 }
 setTestPluginEnabledState(SpecialPowers.Ci.nsIPluginTag.STATE_ENABLED);
 
 function isMulet() {
   try {
--- a/mobile/android/components/FilePicker.js
+++ b/mobile/android/components/FilePicker.js
@@ -174,19 +174,17 @@ FilePicker.prototype = {
       this.fireDialogEvent(this._domWin, "DOMWillOpenModalDialog");
       let winUtils = this._domWin.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils);
       winUtils.enterModalState();
     }
 
     this._promptActive = true;
     this._sendMessage();
 
-    let thread = Services.tm.currentThread;
-    while (this._promptActive)
-      thread.processNextEvent(true);
+    Services.tm.spinEventLoopUntil(() => !this._promptActive);
     delete this._promptActive;
 
     if (this._domWin) {
       let winUtils = this._domWin.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils);
       winUtils.leaveModalState();
       this.fireDialogEvent(this._domWin, "DOMModalDialogClosed");
     }
 
--- a/mobile/android/components/NSSDialogService.js
+++ b/mobile/android/components/NSSDialogService.js
@@ -66,19 +66,17 @@ NSSDialogs.prototype = {
 
   showPrompt: function(aPrompt) {
     let response = null;
     aPrompt.show(function(data) {
       response = data;
     });
 
     // Spin this thread while we wait for a result
-    let thread = Services.tm.currentThread;
-    while (response === null)
-      thread.processNextEvent(true);
+    Services.tm.spinEventLoopUntil(() => response != null);
 
     return response;
   },
 
   confirmDownloadCACert: function(aCtx, aCert, aTrust) {
     while (true) {
       let prompt = this.getPrompt(this.getString("downloadCert.title"),
                                   this.getString("downloadCert.message1"),
--- a/mobile/android/components/PromptService.js
+++ b/mobile/android/components/PromptService.js
@@ -195,19 +195,17 @@ InternalPrompt.prototype = {
     }
 
     let retval = null;
     aPrompt.show(function(data) {
       retval = data;
     });
 
     // Spin this thread while we wait for a result
-    let thread = Services.tm.currentThread;
-    while (retval == null)
-      thread.processNextEvent(true);
+    Services.tm.spinEventLoopUntil(() => retval != null);
 
     if (this._domWin) {
       let winUtils = this._domWin.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils);
       winUtils.leaveModalState();
       PromptUtils.fireDialogEvent(this._domWin, "DOMModalDialogClosed");
     }
 
     return retval;
--- a/mobile/android/components/TabSource.js
+++ b/mobile/android/components/TabSource.js
@@ -51,20 +51,17 @@ TabSource.prototype = {
     }));
 
     let result = null;
     prompt.show(function(data) {
       result = data.button;
     });
 
     // Spin this thread while we wait for a result.
-    let thread = Services.tm.currentThread;
-    while (result == null) {
-      thread.processNextEvent(true);
-    }
+    Services.tm.spinEventLoopUntil(() => result != null);
 
     if (result == -1) {
       return null;
     }
     return tabs[result].browser.contentWindow;
   },
 
   notifyStreamStart: function(window) {
--- a/mobile/android/components/geckoview/GeckoViewPrompt.js
+++ b/mobile/android/components/geckoview/GeckoViewPrompt.js
@@ -455,20 +455,17 @@ PromptDelegate.prototype = {
     let result = undefined;
     if (!this._changeModalState(/* aEntering */ true)) {
       return;
     }
     try {
       this.asyncShowPrompt(aMsg, res => result = res);
 
       // Spin this thread while we wait for a result
-      let thread = Services.tm.currentThread;
-      while (result === undefined) {
-        thread.processNextEvent(true);
-      }
+      Services.tm.spinEventLoopUntil(() => result !== undefined);
     } finally {
       this._changeModalState(/* aEntering */ false);
     }
     return result;
   },
 
   asyncShowPrompt: function(aMsg, aCallback) {
     if (Services.appinfo.processType != Services.appinfo.PROCESS_TYPE_DEFAULT) {
--- a/mobile/android/tests/browser/robocop/robocop_head.js
+++ b/mobile/android/tests/browser/robocop/robocop_head.js
@@ -833,19 +833,17 @@ JavaBridge.prototype = {
     let thread = this._Services.tm.currentThread;
     let initialReplies = this._repliesNeeded;
     // Need one more reply to answer the current sync call.
     this._repliesNeeded++;
     // Wait for the reply to arrive. Normally we would not want to
     // spin the event loop, but here we're in a test and our API
     // specifies a synchronous call, so we spin the loop to wait for
     // the call to finish.
-    while (this._repliesNeeded > initialReplies) {
-      thread.processNextEvent(true);
-    }
+    this._Services.tm.spinEventLoopUntil(() => this._repliesNeeded <= initialReplies);
   },
 
   /**
    * Asynchronously call a method in Java,
    * given the method name followed by a list of arguments.
    */
   asyncCall: function (methodName /*, ... */) {
     this._sendMessage("async-call", arguments);
--- a/security/manager/tools/getHSTSPreloadList.js
+++ b/security/manager/tools/getHSTSPreloadList.js
@@ -382,20 +382,17 @@ function getHSTSStatuses(inHosts, outSta
 }
 
 // Since all events are processed on the main thread, and since event
 // handlers are not preemptible, there shouldn't be any concurrency issues.
 function waitForAResponse(outputList) {
   // From <https://developer.mozilla.org/en/XPConnect/xpcshell/HOWTO>
   var threadManager = Cc["@mozilla.org/thread-manager;1"]
                       .getService(Ci.nsIThreadManager);
-  var mainThread = threadManager.currentThread;
-  while (outputList.length == 0) {
-    mainThread.processNextEvent(true);
-  }
+  threadManager.spinEventLoopUntil(() => outputList.length != 0);
 }
 
 function readCurrentList(filename) {
   var currentHosts = {};
   var file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile);
   file.initWithPath(filename);
   var fis = Cc["@mozilla.org/network/file-input-stream;1"]
               .createInstance(Ci.nsILineInputStream);
--- a/services/common/async.js
+++ b/services/common/async.js
@@ -86,22 +86,20 @@ this.Async = {
     return onComplete;
   },
 
   /**
    * Wait for a sync callback to finish.
    */
   waitForSyncCallback: function waitForSyncCallback(callback) {
     // Grab the current thread so we can make it give up priority.
-    let thread = Cc["@mozilla.org/thread-manager;1"].getService().currentThread;
+    let tm = Cc["@mozilla.org/thread-manager;1"].getService();
 
     // Keep waiting until our callback is triggered (unless the app is quitting).
-    while (Async.checkAppReady() && callback.state == CB_READY) {
-      thread.processNextEvent(true);
-    }
+    tm.spinEventLoopUntil(() => !Async.checkAppReady || callback.state != CB_READY);
 
     // Reset the state of the callback to prepare for another call.
     let state = callback.state;
     callback.state = CB_READY;
 
     // Throw the value the callback decided to fail with.
     if (state == CB_FAIL) {
       throw callback.value;
--- a/services/sync/tps/extensions/mozmill/resource/modules/assertions.js
+++ b/services/sync/tps/extensions/mozmill/resource/modules/assertions.js
@@ -586,24 +586,25 @@ Assert.prototype = {
         self.timeIsUp = Date.now() > deadline;
       }
     }
 
     var hwindow = Services.appShell.hiddenDOMWindow;
     var timeoutInterval = hwindow.setInterval(wait, interval);
     var thread = Services.tm.currentThread;
 
-    while (self.result !== true && !self.timeIsUp) {
-      thread.processNextEvent(true);
-
+    Services.tm.spinEventLoopUntil(() => {
       let type = typeof(self.result);
-      if (type !== 'boolean')
+      if (type !== 'boolean') {
         throw TypeError("waitFor() callback has to return a boolean" +
                         " instead of '" + type + "'");
-    }
+      }
+
+      return self.result === true || self.timeIsUp;
+    });
 
     hwindow.clearInterval(timeoutInterval);
 
     if (self.result !== true && self.timeIsUp) {
       aMessage = aMessage || arguments.callee.name + ": Timeout exceeded for '" + aCallback + "'";
       throw new errors.TimeoutError(aMessage);
     }
 
--- a/services/sync/tps/extensions/mozmill/resource/stdlib/httpd.js
+++ b/services/sync/tps/extensions/mozmill/resource/stdlib/httpd.js
@@ -5336,18 +5336,18 @@ function server(port, basePath)
 
   var srv = new nsHttpServer();
   if (lp)
     srv.registerDirectory("/", lp);
   srv.registerContentType("sjs", SJS_TYPE);
   srv.identity.setPrimary("http", "localhost", port);
   srv.start(port);
 
+  gThreadManager.spinEventLoopUntil(() => srv.isStopped());
+
   var thread = gThreadManager.currentThread;
-  while (!srv.isStopped())
-    thread.processNextEvent(true);
 
   // get rid of any pending requests
   while (thread.hasPendingEvents())
     thread.processNextEvent(true);
 
   DEBUG = false;
 }
--- a/services/sync/tps/extensions/mozmill/resource/stdlib/utils.js
+++ b/services/sync/tps/extensions/mozmill/resource/stdlib/utils.js
@@ -205,21 +205,17 @@ function setPreference(aName, aValue) {
  *
  * @param {number} milliseconds
  *        Sleeps the given number of milliseconds
  */
 function sleep(milliseconds) {
   var timeup = false;
 
   hwindow.setTimeout(function () { timeup = true; }, milliseconds);
-  var thread = Services.tm.currentThread;
-
-  while (!timeup) {
-    thread.processNextEvent(true);
-  }
+  Services.tm.spinEventLoopUntil(() => timeup);
 
   broker.pass({'function':'utils.sleep()'});
 }
 
 /**
  * Check if the callback function evaluates to true
  */
 function assert(callback, message, thisObject) {
--- a/storage/test/unit/head_storage.js
+++ b/storage/test/unit/head_storage.js
@@ -68,20 +68,18 @@ function cleanup() {
  */
 function asyncCleanup() {
   let closed = false;
 
   // close the connection
   print("*** Storage Tests: Trying to asyncClose!");
   getOpenedDatabase().asyncClose(function() { closed = true; });
 
-  let curThread = Components.classes["@mozilla.org/thread-manager;1"]
-                            .getService().currentThread;
-  while (!closed)
-    curThread.processNextEvent(true);
+  let tm = Cc["@mozilla.org/thread-manager;1"].getService();
+  tm.spinEventLoopUntil(() => closed);
 
   // we need to null out the database variable to get a new connection the next
   // time getOpenedDatabase is called
   gDBConn = null;
 
   // removing test db
   deleteTestDB();
 }
--- a/storage/test/unit/test_statement_executeAsync.js
+++ b/storage/test/unit/test_statement_executeAsync.js
@@ -130,20 +130,18 @@ function execAsync(aStmt, aOptions, aRes
     pending = aStmt.executeAsync(listener);
   } else {
     aStmt.executeAsync(listener);
   }
 
   if ("cancel" in aOptions && aOptions.cancel)
     pending.cancel();
 
-  let curThread = Components.classes["@mozilla.org/thread-manager;1"]
-                            .getService().currentThread;
-  while (!completed && !_quit)
-    curThread.processNextEvent(true);
+  let tm = Cc["@mozilla.org/thread-manager;1"].getService(Ci.nsIThreadManager);
+  tm.spinEventLoopUntil(() => completed || _quit);
 
   return pending;
 }
 
 /**
  * Make sure that illegal SQL generates the expected runtime error and does not
  * result in any crashes.  Async-only since the synchronous case generates the
  * error synchronously (and is tested elsewhere).
--- a/testing/xpcshell/head.js
+++ b/testing/xpcshell/head.js
@@ -210,21 +210,20 @@ function _Timer(func, delay) {
 };
 
 function _do_main() {
   if (_quit)
     return;
 
   _testLogger.info("running event loop");
 
-  var thr = Components.classes["@mozilla.org/thread-manager;1"]
-                      .getService().currentThread;
+  var tm = Components.classes["@mozilla.org/thread-manager;1"].getService();
+  var thr = tm.currentThread;
 
-  while (!_quit)
-    thr.processNextEvent(true);
+  tm.spinEventLoopUntil(() => _quit);
 
   while (thr.hasPendingEvents())
     thr.processNextEvent(true);
 }
 
 function _do_quit() {
   _testLogger.info("exiting test");
   _quit = true;
@@ -474,22 +473,24 @@ function _initDebugging(port) {
   };
 
   let listener = DebuggerServer.createListener();
   listener.portOrPath = port;
   listener.authenticator = authenticator;
   listener.open();
 
   // spin an event loop until the debugger connects.
-  let thr = Components.classes["@mozilla.org/thread-manager;1"]
-              .getService().currentThread;
-  while (!initialized) {
+  let tm = Components.classes["@mozilla.org/thread-manager;1"].getService();
+  tm.spinEventLoopUntil(() => {
+    if (initialized) {
+      return true;
+    }
     do_print("Still waiting for debugger to connect...");
-    thr.processNextEvent(true);
-  }
+    return false;
+  });
   // NOTE: if you want to debug the harness itself, you can now add a 'debugger'
   // statement anywhere and it will stop - but we've already added a breakpoint
   // for the first line of the test scripts, so we just continue...
   do_print("Debugger connected, starting test execution");
 }
 
 function _execute_test() {
   // _JSDEBUGGER_PORT is dynamically defined by <runxpcshelltests.py>.
@@ -609,21 +610,18 @@ function _execute_test() {
       try {
         yield func();
       } catch (ex) {
         reportCleanupError(ex);
       }
     }
     _cleanupFunctions = [];
   }).catch(reportCleanupError).then(() => complete = true);
-  let thr = Components.classes["@mozilla.org/thread-manager;1"]
-                      .getService().currentThread;
-  while (!complete) {
-    thr.processNextEvent(true);
-  }
+  let tm = Components.classes["@mozilla.org/thread-manager;1"].getService();
+  tm.spinEventLoopUntil(() => complete);
 
   // Restore idle service to avoid leaks.
   _fakeIdleService.deactivate();
 
   if (_profileInitialized) {
     // Since we have a profile, we will notify profile shutdown topics at
     // the end of the current test, to ensure correct cleanup on shutdown.
     let obs = Components.classes["@mozilla.org/observer-service;1"]
--- a/toolkit/components/places/PlacesUtils.jsm
+++ b/toolkit/components/places/PlacesUtils.jsm
@@ -3560,20 +3560,17 @@ PlacesEditBookmarkKeywordTransaction.pro
           keyword: this.new.keyword,
           postData: this.new.postData || this.item.postData
         });
       }
     })().catch(Cu.reportError)
                  .then(() => done = true);
     // TODO: Until we can move to PlacesTransactions.jsm, we must spin the
     // events loop :(
-    let thread = Services.tm.currentThread;
-    while (!done) {
-      thread.processNextEvent(true);
-    }
+    Services.tm.spinEventLoopUntil(() => done);
   },
 
   undoTransaction: function EBKTXN_undoTransaction() {
 
     let done = false;
     (async () => {
       if (this.new.keyword) {
         await PlacesUtils.keywords.remove(this.new.keyword);
@@ -3585,20 +3582,19 @@ PlacesEditBookmarkKeywordTransaction.pro
           keyword: this.item.keyword,
           postData: this.item.postData
         });
       }
     })().catch(Cu.reportError)
                  .then(() => done = true);
     // TODO: Until we can move to PlacesTransactions.jsm, we must spin the
     // events loop :(
-    let thread = Services.tm.currentThread;
-    while (!done) {
-      thread.processNextEvent(true);
-    }
+    Services.tm.spinEventLoopUntil(() => {
+      return done;
+    });
   }
 };
 
 
 /**
  * Transaction for editing the post data associated with a bookmark.
  *
  * @param aItemId
--- a/toolkit/components/places/tests/unit/test_async_in_batchmode.js
+++ b/toolkit/components/places/tests/unit/test_async_in_batchmode.js
@@ -3,30 +3,29 @@
 // APIs are used (either by Sync itself, or by any other code in the system)
 // As seen in bug 1197856 and bug 1190131.
 
 Cu.import("resource://gre/modules/PlacesUtils.jsm");
 
 // This function "waits" for a promise to resolve by spinning a nested event
 // loop.
 function waitForPromise(promise) {
-  let thread = Cc["@mozilla.org/thread-manager;1"].getService().currentThread;
+  let tm = Cc["@mozilla.org/thread-manager;1"].getService();
 
   let finalResult, finalException;
 
   promise.then(result => {
     finalResult = result;
   }, err => {
     finalException = err;
   });
 
   // Keep waiting until our callback is triggered (unless the app is quitting).
-  while (!finalResult && !finalException) {
-    thread.processNextEvent(true);
-  }
+  tm.spinEventLoopUntil(() => finalResult || finalException);
+
   if (finalException) {
     throw finalException;
   }
   return finalResult;
 }
 
 add_test(function() {
   let testCompleted = false;
--- a/toolkit/components/prompts/src/nsPrompter.js
+++ b/toolkit/components/prompts/src/nsPrompter.js
@@ -410,19 +410,17 @@ function openTabPrompt(domWin, tabPrompt
         args.promptActive = true;
 
         newPrompt = tabPrompt.appendPrompt(args, onPromptClose);
 
         // TODO since we don't actually open a window, need to check if
         // there's other stuff in nsWindowWatcher::OpenWindowInternal
         // that we might need to do here as well.
 
-        let thread = Services.tm.currentThread;
-        while (args.promptActive)
-            thread.processNextEvent(true);
+	Services.tm.spinEventLoopUntil(() => !args.promptActive);
         delete args.promptActive;
 
         if (args.promptAborted)
             throw Components.Exception("prompt aborted by user", Cr.NS_ERROR_NOT_AVAILABLE);
     } finally {
         // If the prompt unexpectedly failed to invoke the callback, do so here.
         if (!callbackInvoked)
             onPromptClose(true);
@@ -484,20 +482,17 @@ function openRemotePrompt(domWin, args, 
     args.promptPrincipal = promptPrincipal;
     args.showAlertOrigin = topPrincipal.equals(promptPrincipal);
     args.inPermitUnload = inPermitUnload;
 
     args._remoteId = id;
 
     messageManager.sendAsyncMessage("Prompt:Open", args, {});
 
-    let thread = Services.tm.currentThread;
-    while (!closed) {
-        thread.processNextEvent(true);
-    }
+    Services.tm.spinEventLoopUntil(() => closed);
 }
 
 function ModalPrompter(domWin) {
     this.domWin = domWin;
 }
 ModalPrompter.prototype = {
     domWin: null,
     /*
--- a/toolkit/components/satchel/FormHistory.jsm
+++ b/toolkit/components/satchel/FormHistory.jsm
@@ -600,20 +600,17 @@ function dbClose(aShutdown) {
   }
 
   dbStmts = new Map();
 
   let closed = false;
   _dbConnection.asyncClose(() => closed = true);
 
   if (!aShutdown) {
-    let thread = Services.tm.currentThread;
-    while (!closed) {
-      thread.processNextEvent(true);
-    }
+    Services.tm.spinEventLoopUntil(() => closed);
   }
 }
 
 /**
  * updateFormHistoryWrite
  *
  * Constructs and executes database statements from a pre-processed list of
  * inputted changes.
--- a/toolkit/content/tests/browser/head.js
+++ b/toolkit/content/tests/browser/head.js
@@ -84,21 +84,21 @@ function getTestPlugin(pluginName) {
 }
 
 function setTestPluginEnabledState(newEnabledState, pluginName) {
   var oldEnabledState = SpecialPowers.setTestPluginEnabledState(newEnabledState, pluginName);
   if (!oldEnabledState) {
     return;
   }
   var plugin = getTestPlugin(pluginName);
-  while (plugin.enabledState != newEnabledState) {
-    // Run a nested event loop to wait for the preference change to
-    // propagate to the child. Yuck!
-    SpecialPowers.Services.tm.currentThread.processNextEvent(true);
-  }
+  // Run a nested event loop to wait for the preference change to
+  // propagate to the child. Yuck!
+  SpecialPowers.Services.tm.spinEventLoopUntil(() => {
+    return plugin.enabledState == newEnabledState;
+  });
   SimpleTest.registerCleanupFunction(function() {
     SpecialPowers.setTestPluginEnabledState(oldEnabledState, pluginName);
   });
 }
 
 function disable_non_test_mouse(disable) {
   let utils = window.QueryInterface(Ci.nsIInterfaceRequestor)
                     .getInterface(Ci.nsIDOMWindowUtils);
--- a/toolkit/crashreporter/test/unit/crasher_subprocess_tail.js
+++ b/toolkit/crashreporter/test/unit/crasher_subprocess_tail.js
@@ -1,17 +1,16 @@
 /* import-globals-from crasher_subprocess_head.js */
 
 // Let the event loop process a bit before crashing.
 if (shouldDelay) {
   let shouldCrashNow = false;
-  let thr = Components.classes["@mozilla.org/thread-manager;1"]
-                          .getService().currentThread;
+  let tm = Components.classes["@mozilla.org/thread-manager;1"]
+                     .getService();
+  let thr = tm.currentThread;
   thr.dispatch({ run: () => { shouldCrashNow = true; } },
                Components.interfaces.nsIThread.DISPATCH_NORMAL);
 
-  while (!shouldCrashNow) {
-    thr.processNextEvent(true);
-  }
+  tm.spinEventLoopUntil(() => shouldCrashNow);
 }
 
 // now actually crash
 CrashTestUtils.crash(crashType);
--- a/toolkit/crashreporter/test/unit/test_crash_terminator.js
+++ b/toolkit/crashreporter/test/unit/test_crash_terminator.js
@@ -19,19 +19,17 @@ function setup_crash() {
   terminator.observe(null, "profile-after-change", null);
 
   // Inform the terminator that shutdown has started
   // Pick an arbitrary notification
   terminator.observe(null, "xpcom-will-shutdown", null);
   terminator.observe(null, "profile-before-change", null);
 
   dump("Waiting (actively) for the crash\n");
-  while (true) {
-    Services.tm.currentThread.processNextEvent(true);
-  }
+  Services.tm.spinEventLoopUntil(() => false);
 }
 
 
 function after_crash(mdump, extra) {
   do_print("Crash signature: " + JSON.stringify(extra, null, "\t"));
   Assert.equal(extra.ShutdownProgress, "profile-before-change");
 }
 
--- a/toolkit/modules/tests/modules/PromiseTestUtils.jsm
+++ b/toolkit/modules/tests/modules/PromiseTestUtils.jsm
@@ -109,19 +109,17 @@ this.PromiseTestUtils = {
           observed = true;
         }
       },
       onConsumed() {},
     };
 
     PromiseDebugging.addUncaughtRejectionObserver(observer);
     Promise.reject(this._ensureDOMPromiseRejectionsProcessedReason);
-    while (!observed) {
-      Services.tm.mainThread.processNextEvent(true);
-    }
+    Services.tm.spinEventLoopUntil(() => observed);
     PromiseDebugging.removeUncaughtRejectionObserver(observer);
   },
   _ensureDOMPromiseRejectionsProcessedReason: {},
 
   /**
    * Called only by the tests for PromiseDebugging.addUncaughtRejectionObserver
    * and for JSMPromise.Debugging, disables the observers in this module.
    */
--- a/toolkit/modules/tests/xpcshell/test_Promise.js
+++ b/toolkit/modules/tests/xpcshell/test_Promise.js
@@ -917,20 +917,17 @@ tests.push(
 // have similar deadlocks.
 tests.push(
   make_promise_test(function promise_nested_eventloop_deadlock(test) {
     // Set up a (long enough to be noticeable) timeout to
     // exit the nested event loop and throw if the test run is hung
     let shouldExitNestedEventLoop = false;
 
     function event_loop() {
-      let thr = Services.tm.mainThread;
-      while (!shouldExitNestedEventLoop) {
-        thr.processNextEvent(true);
-      }
+      Services.tm.spinEventLoopUntil(() => shouldExitNestedEventLoop);
     }
 
     // I wish there was a way to cancel xpcshell do_timeout()s
     do_timeout(2000, () => {
       if (!shouldExitNestedEventLoop) {
         shouldExitNestedEventLoop = true;
         do_throw("Test timed out");
       }
--- a/toolkit/mozapps/extensions/internal/AddonTestUtils.jsm
+++ b/toolkit/mozapps/extensions/internal/AddonTestUtils.jsm
@@ -396,18 +396,17 @@ var AddonTestUtils = {
     let error;
     promise.then(
       val => { result = val; },
       err => { error = err; }
     ).then(() => {
       done = true;
     });
 
-    while (!done)
-      Services.tm.mainThread.processNextEvent(true);
+    Services.tm.spinEventLoopUntil(() => done);
 
     if (error !== undefined)
       throw error;
     return result;
   },
 
   createAppInfo(ID, name, version, platformVersion = "1.0") {
     AppInfo.updateAppInfo({
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -1007,20 +1007,17 @@ function syncLoadManifestFromFile(aFile,
   loadManifestFromFile(aFile, aInstallLocation).then(val => {
     success = true;
     result = val;
   }, val => {
     success = false;
     result = val
   });
 
-  let thread = Services.tm.currentThread;
-
-  while (success === undefined)
-    thread.processNextEvent(true);
+  Services.tm.spinEventLoopUntil(() => success !== undefined);
 
   if (!success)
     throw result;
   return result;
 }
 
 /**
  * Gets an nsIURI for a file within another file, either a directory or an XPI
--- a/toolkit/mozapps/extensions/test/xpcshell/test_bug542391.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_bug542391.js
@@ -55,22 +55,20 @@ var WindowWatcher = {
         AddonManagerPrivate.addStartupChange("updated", "upgradeable1x2-3@tests.mozilla.org");
         installed = true;
       });
     }
 
     // The dialog is meant to be opened modally and the install operation can be
     // asynchronous, so we must spin an event loop (like the modal window does)
     // until the install is complete
-    let thr = AM_Cc["@mozilla.org/thread-manager;1"].
-              getService(AM_Ci.nsIThreadManager).
-              mainThread;
+    let tm = AM_Cc["@mozilla.org/thread-manager;1"].
+             getService(AM_Ci.nsIThreadManager);
 
-    while (!installed || !updated)
-      thr.processNextEvent(false);
+    tm.spinEventLoopUntil(() => installed && updated);
   },
 
   QueryInterface(iid) {
     if (iid.equals(Ci.nsIWindowWatcher)
      || iid.equals(Ci.nsISupports))
       return this;
 
     throw Cr.NS_ERROR_NO_INTERFACE;
new file mode 100644
--- /dev/null
+++ b/xpcom/tests/gtest/TestThreadManager.cpp
@@ -0,0 +1,164 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* 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/. */
+
+#include "nsIThreadManager.h"
+#include "nsCOMPtr.h"
+#include "nsXPCOM.h"
+#include "nsThreadUtils.h"
+#include "mozilla/Atomics.h"
+#include "gtest/gtest.h"
+
+using mozilla::Atomic;
+using mozilla::Runnable;
+
+class WaitCondition final : public nsINestedEventLoopCondition
+{
+public:
+  NS_DECL_THREADSAFE_ISUPPORTS
+
+  WaitCondition(Atomic<uint32_t>& aCounter, uint32_t aMaxCount)
+    : mCounter(aCounter)
+    , mMaxCount(aMaxCount)
+  {
+  }
+
+  NS_IMETHODIMP IsDone(bool* aDone) override
+  {
+    *aDone = (mCounter == mMaxCount);
+    return NS_OK;
+  }
+
+private:
+  ~WaitCondition() = default;
+
+  Atomic<uint32_t>& mCounter;
+  const uint32_t mMaxCount;
+};
+
+NS_IMPL_ISUPPORTS(WaitCondition, nsINestedEventLoopCondition)
+
+class SpinRunnable final : public Runnable
+{
+public:
+  explicit SpinRunnable(nsINestedEventLoopCondition* aCondition)
+    : mCondition(aCondition)
+    , mResult(NS_OK)
+  {
+  }
+
+  NS_IMETHODIMP Run()
+  {
+    nsCOMPtr<nsIThreadManager> threadMan =
+      do_GetService("@mozilla.org/thread-manager;1");
+    mResult = threadMan->SpinEventLoopUntil(mCondition);
+    return NS_OK;
+  }
+
+  nsresult SpinLoopResult()
+  {
+    return mResult;
+  }
+
+private:
+  ~SpinRunnable() = default;
+
+  nsCOMPtr<nsINestedEventLoopCondition> mCondition;
+  Atomic<nsresult> mResult;
+};
+
+class CountRunnable final : public Runnable
+{
+public:
+  explicit CountRunnable(Atomic<uint32_t>& aCounter)
+    : mCounter(aCounter)
+  {
+  }
+
+  NS_IMETHODIMP Run()
+  {
+    mCounter++;
+    return NS_OK;
+  }
+
+private:
+  Atomic<uint32_t>& mCounter;
+};
+
+TEST(ThreadManager, SpinEventLoopUntilSuccess)
+{
+  const uint32_t kRunnablesToDispatch = 100;
+  nsresult rv;
+  mozilla::Atomic<uint32_t> count(0);
+
+  nsCOMPtr<nsINestedEventLoopCondition> condition =
+    new WaitCondition(count, kRunnablesToDispatch);
+  RefPtr<SpinRunnable> spinner = new SpinRunnable(condition);
+  nsCOMPtr<nsIThread> thread;
+  rv = NS_NewNamedThread("SpinEventLoop", getter_AddRefs(thread), spinner);
+  ASSERT_TRUE(NS_SUCCEEDED(rv));
+
+  nsCOMPtr<nsIRunnable> counter = new CountRunnable(count);
+  for (uint32_t i = 0; i < kRunnablesToDispatch; ++i) {
+    rv = thread->Dispatch(counter, NS_DISPATCH_NORMAL);
+    ASSERT_TRUE(NS_SUCCEEDED(rv));
+  }
+
+  rv = thread->Shutdown();
+  ASSERT_TRUE(NS_SUCCEEDED(rv));
+  ASSERT_TRUE(NS_SUCCEEDED(spinner->SpinLoopResult()));
+}
+
+class ErrorCondition final : public nsINestedEventLoopCondition
+{
+public:
+  NS_DECL_THREADSAFE_ISUPPORTS
+
+  ErrorCondition(Atomic<uint32_t>& aCounter, uint32_t aMaxCount)
+    : mCounter(aCounter)
+    , mMaxCount(aMaxCount)
+  {
+  }
+
+  NS_IMETHODIMP IsDone(bool* aDone) override
+  {
+    if (mCounter == mMaxCount) {
+      return NS_ERROR_ILLEGAL_VALUE;
+    }
+    return NS_OK;
+  }
+
+private:
+  ~ErrorCondition() = default;
+
+  Atomic<uint32_t>& mCounter;
+  const uint32_t mMaxCount;
+};
+
+NS_IMPL_ISUPPORTS(ErrorCondition, nsINestedEventLoopCondition)
+
+TEST(ThreadManager, SpinEventLoopUntilError)
+{
+  const uint32_t kRunnablesToDispatch = 100;
+  nsresult rv;
+  mozilla::Atomic<uint32_t> count(0);
+
+  nsCOMPtr<nsINestedEventLoopCondition> condition =
+    new ErrorCondition(count, kRunnablesToDispatch);
+  RefPtr<SpinRunnable> spinner = new SpinRunnable(condition);
+  nsCOMPtr<nsIThread> thread;
+  rv = NS_NewNamedThread("SpinEventLoop", getter_AddRefs(thread), spinner);
+  ASSERT_TRUE(NS_SUCCEEDED(rv));
+
+  nsCOMPtr<nsIRunnable> counter = new CountRunnable(count);
+  for (uint32_t i = 0; i < kRunnablesToDispatch; ++i) {
+    rv = thread->Dispatch(counter, NS_DISPATCH_NORMAL);
+    ASSERT_TRUE(NS_SUCCEEDED(rv));
+  }
+
+  rv = thread->Shutdown();
+  ASSERT_TRUE(NS_SUCCEEDED(rv));
+  ASSERT_TRUE(NS_FAILED(spinner->SpinLoopResult()));
+}
--- a/xpcom/tests/gtest/moz.build
+++ b/xpcom/tests/gtest/moz.build
@@ -39,16 +39,17 @@ UNIFIED_SOURCES += [
     'TestStorageStream.cpp',
     'TestStrings.cpp',
     'TestStringStream.cpp',
     'TestSynchronization.cpp',
     'TestTArray.cpp',
     'TestTArray2.cpp',
     'TestTaskQueue.cpp',
     'TestTextFormatter.cpp',
+    'TestThreadManager.cpp',
     'TestThreadPool.cpp',
     'TestThreadPoolListener.cpp',
     'TestThreads.cpp',
     'TestThreadUtils.cpp',
     'TestTimers.cpp',
     'TestTimeStamp.cpp',
     'TestTokenizer.cpp',
     'TestUTF.cpp',
--- a/xpcom/threads/nsIThreadManager.idl
+++ b/xpcom/threads/nsIThreadManager.idl
@@ -6,16 +6,25 @@
 
 #include "nsISupports.idl"
 
 [ptr] native PRThread(PRThread);
 
 interface nsIRunnable;
 interface nsIThread;
 
+[scriptable, function, uuid(039a227d-0cb7-44a5-a8f9-dbb7071979f2)]
+interface nsINestedEventLoopCondition : nsISupports
+{
+  /**
+   * Returns true if the current nested event loop should stop spinning.
+   */
+  bool isDone();
+};
+
 /**
  * An interface for creating and locating nsIThread instances.
  */
 [scriptable, uuid(1be89eca-e2f7-453b-8d38-c11ba247f6f3)]
 interface nsIThreadManager : nsISupports
 {
   /**
    * Default number of bytes reserved for a thread's stack, if no stack size
@@ -81,16 +90,27 @@ interface nsIThreadManager : nsISupports
    *   .mainThread.dispatch(runnable, Ci.nsIEventTarget.DISPATCH_NORMAL);
    * or
    *   .currentThread.dispatch(runnable, Ci.nsIEventTarget.DISPATCH_NORMAL);
    * C++ callers should instead use NS_DispatchToMainThread.
    */
   void dispatchToMainThread(in nsIRunnable event);
 
   /**
+   * Enter a nested event loop on the current thread, waiting on, and
+   * processing events until condition.isDone() returns true.
+   *
+   * If condition.isDone() throws, this function will throw as well.
+   *
+   * C++ code should not use this function, instead preferring
+   * mozilla::SpinEventLoopUntil.
+   */
+  void spinEventLoopUntil(in nsINestedEventLoopCondition condition);
+
+  /**
    * This queues a runnable to the main thread's idle queue.
    *
    * @param event
    *   The event to dispatch.
    * @param timeout
    *   The time in milliseconds until this event should be moved from the idle
    *   queue to the regular queue if it hasn't been executed by then.  If not
    *   passed or a zero value is specified, the event will never be moved to
--- a/xpcom/threads/nsThreadManager.cpp
+++ b/xpcom/threads/nsThreadManager.cpp
@@ -333,16 +333,42 @@ nsThreadManager::GetCurrentThread(nsIThr
   *aResult = GetCurrentThread();
   if (!*aResult) {
     return NS_ERROR_OUT_OF_MEMORY;
   }
   NS_ADDREF(*aResult);
   return NS_OK;
 }
 
+NS_IMETHODIMP
+nsThreadManager::SpinEventLoopUntil(nsINestedEventLoopCondition* aCondition)
+{
+  nsCOMPtr<nsINestedEventLoopCondition> condition(aCondition);
+  nsresult rv = NS_OK;
+
+  if (!mozilla::SpinEventLoopUntil([&]() -> bool {
+        bool isDone = false;
+        rv = condition->IsDone(&isDone);
+        // JS failure should be unusual, but we need to stop and propagate
+        // the error back to the caller.
+        if (NS_FAILED(rv)) {
+          return true;
+        }
+
+        return isDone;
+      })) {
+    // We stopped early for some reason, which is unexpected.
+    return NS_ERROR_UNEXPECTED;
+  }
+
+  // If we exited when the condition told us to, we need to return whether
+  // the condition encountered failure when executing.
+  return rv;
+}
+
 uint32_t
 nsThreadManager::GetHighestNumberOfThreads()
 {
   OffTheBooksMutexAutoLock lock(mLock);
   return mHighestNumberOfThreads;
 }
 
 NS_IMETHODIMP