Bug 1561061: Move SpecialPowers pref env code to parent and make sane-ish. r=aswan
authorKris Maglione <maglione.k@gmail.com>
Mon, 24 Jun 2019 13:47:53 -0700
changeset 544121 72cd895b16137490bbc29d8e6f19f12551fc15f0
parent 544120 f0bac27bad8a6541611fb0cf59f666b45c574cc2
child 544122 6644e38a669212b4e933cdb0a5f3badf6cba0b5f
push id2131
push userffxbld-merge
push dateMon, 26 Aug 2019 18:30:20 +0000
treeherdermozilla-release@b19ffb3ca153 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersaswan
bugs1561061
milestone69.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 1561061: Move SpecialPowers pref env code to parent and make sane-ish. r=aswan Differential Revision: https://phabricator.services.mozilla.com/D35706
browser/base/content/test/general/head.js
devtools/client/inspector/extensions/test/browser_inspector_extension_sidebar.js
devtools/client/shared/test/shared-head.js
dom/base/test/chrome/test_bug914381.html
dom/bindings/test/test_exception_options_from_jsimplemented.html
dom/bindings/test/test_promise_rejections_from_jsimplemented.html
dom/canvas/test/test_hitregion_event.html
dom/html/test/file_fullscreen-denied.html
dom/media/test/test_eme_request_notifications.html
dom/tests/mochitest/dom-level0/test_background_loading_iframes.html
layout/base/tests/test_bug394057.html
testing/specialpowers/content/SpecialPowersAPI.jsm
testing/specialpowers/content/SpecialPowersAPIParent.jsm
toolkit/components/extensions/test/mochitest/test_ext_storage_cleanup.html
--- a/browser/base/content/test/general/head.js
+++ b/browser/base/content/test/general/head.js
@@ -156,25 +156,21 @@ function setTestPluginEnabledState(newEn
   var oldEnabledState = plugin.enabledState;
   plugin.enabledState = newEnabledState;
   SimpleTest.registerCleanupFunction(function() {
     getTestPlugin(pluginName).enabledState = oldEnabledState;
   });
 }
 
 function pushPrefs(...aPrefs) {
-  return new Promise(resolve => {
-    SpecialPowers.pushPrefEnv({"set": aPrefs}, resolve);
-  });
+  return SpecialPowers.pushPrefEnv({"set": aPrefs});
 }
 
 function popPrefs() {
-  return new Promise(resolve => {
-    SpecialPowers.popPrefEnv(resolve);
-  });
+  return SpecialPowers.popPrefEnv();
 }
 
 function updateBlocklist(aCallback) {
   var blocklistNotifier = Cc["@mozilla.org/extensions/blocklist;1"]
                           .getService(Ci.nsITimerCallback);
   var observer = function() {
     Services.obs.removeObserver(observer, "blocklist-updated");
     SimpleTest.executeSoon(aCallback);
--- a/devtools/client/inspector/extensions/test/browser_inspector_extension_sidebar.js
+++ b/devtools/client/inspector/extensions/test/browser_inspector_extension_sidebar.js
@@ -268,17 +268,17 @@ add_task(async function teardownExtensio
   extension = null;
 });
 
 add_task(async function testActiveTabOnNonExistingSidebar() {
   // Set a fake non existing sidebar id in the activeSidebar pref,
   // to simulate the scenario where an extension has installed a sidebar
   // which has been saved in the preference but it doesn't exist anymore.
   await SpecialPowers.pushPrefEnv({
-    set: [["devtools.inspector.activeSidebar"], "unexisting-sidebar-id"],
+    set: [["devtools.inspector.activeSidebar", "unexisting-sidebar-id"]],
   });
 
   const res = await openInspectorForURL("about:blank");
   inspector = res.inspector;
   toolbox = res.toolbox;
 
   const onceSidebarCreated = toolbox.once(`extension-sidebar-created-${SIDEBAR_ID}`);
   toolbox.registerInspectorExtensionSidebar(SIDEBAR_ID, {title: SIDEBAR_TITLE});
--- a/devtools/client/shared/test/shared-head.js
+++ b/devtools/client/shared/test/shared-head.js
@@ -451,16 +451,18 @@ var closeTabAndToolbox = async function(
   if (TargetFactory.isKnownTab(tab)) {
     const target = await TargetFactory.forTab(tab);
     if (target) {
       await gDevTools.closeToolbox(target);
     }
   }
 
   await removeTab(tab);
+
+  await new Promise(resolve => setTimeout(resolve, 0));
 };
 
 /**
  * Close a toolbox and the current tab.
  * @param {Toolbox} toolbox The toolbox to close.
  * @return {Promise} Resolves when the toolbox and tab have been destroyed and
  * closed.
  */
@@ -591,20 +593,18 @@ function waitForClipboardPromise(setup, 
  *
  * @param {String} preferenceName
  *        The name of the preference to updated
  * @param {} value
  *        The preference value, type can vary
  * @return {Promise} resolves when the preferences have been updated
  */
 function pushPref(preferenceName, value) {
-  return new Promise(resolve => {
-    const options = {"set": [[preferenceName, value]]};
-    SpecialPowers.pushPrefEnv(options, resolve);
-  });
+  const options = {"set": [[preferenceName, value]]};
+  return SpecialPowers.pushPrefEnv(options);
 }
 
 /**
  * Lookup the provided dotted path ("prop1.subprop2.myProp") in the provided object.
  *
  * @param {Object} obj
  *        Object to expand.
  * @param {String} path
--- a/dom/base/test/chrome/test_bug914381.html
+++ b/dom/base/test/chrome/test_bug914381.html
@@ -29,17 +29,17 @@ function createFileWithData(fileData) {
   outStream.close();
 
   return testFile;
 }
 
 /** Test for Bug 914381. File's created in JS using an nsIFile should allow mozGetFullPathInternal calls to succeed **/
 var file = createFileWithData("Test bug 914381");
 
-SpecialPowers.pushPrefEnv({ set: [ "dom.file.createInChild" ]})
+SpecialPowers.pushPrefEnv({ set: [["dom.file.createInChild", true]]})
 .then(() => {
   return File.createFromNsIFile(file);
 })
 .then(f => {
   is(f.mozFullPathInternal, undefined, "mozFullPathInternal is undefined from js");
   is(f.mozFullPath, file.path, "mozFullPath returns path if created with nsIFile");
 })
 .then(() => {
--- a/dom/bindings/test/test_exception_options_from_jsimplemented.html
+++ b/dom/bindings/test/test_exception_options_from_jsimplemented.html
@@ -11,20 +11,20 @@ https://bugzilla.mozilla.org/show_bug.cg
   <script type="application/javascript">
   /* global TestInterfaceJS */
   /** Test for Bug 1107592 **/
 
   SimpleTest.waitForExplicitFinish();
 
   function doTest() {
     var file = location.href;
+
     var asyncFrame;
     /* Async parent frames from pushPrefEnv don't show up in e10s.  */
-    var isE10S = !SpecialPowers.isMainProcess();
-    if (!isE10S && SpecialPowers.getBoolPref("javascript.options.asyncstack")) {
+    if (SpecialPowers.getBoolPref("javascript.options.asyncstack")) {
       asyncFrame = `Async*@${file}:153:17
 `;
     } else {
       asyncFrame = "";
     }
 
     var t = new TestInterfaceJS();
     try {
--- a/dom/bindings/test/test_promise_rejections_from_jsimplemented.html
+++ b/dom/bindings/test/test_promise_rejections_from_jsimplemented.html
@@ -32,22 +32,22 @@ https://bugzilla.mozilla.org/show_bug.cg
   }
 
   function ensurePromiseFail(testNumber, value) {
     ok(false, "Test " + testNumber + " should not have a fulfilled promise");
   }
 
   function doTest() {
     var t = new TestInterfaceJS();
-    /* Async parent frames from pushPrefEnv don't show up in e10s.  */
-    var isE10S = !SpecialPowers.isMainProcess();
+
+
     var asyncStack = SpecialPowers.getBoolPref("javascript.options.asyncstack");
     var ourFile = location.href;
     var unwrapError = "Promise rejection value is a non-unwrappable cross-compartment wrapper.";
-    var parentFrame = (asyncStack && !isE10S) ? `Async*@${ourFile}:130:17
+    var parentFrame = asyncStack ? `Async*@${ourFile}:130:17
 ` : "";
 
     Promise.all([
       t.testPromiseWithThrowingChromePromiseInit().then(
           ensurePromiseFail.bind(null, 1),
           checkExn.bind(null, 49, "InternalError", unwrapError,
                         undefined, ourFile, 1,
                         `doTest@${ourFile}:49:9
--- a/dom/canvas/test/test_hitregion_event.html
+++ b/dom/canvas/test/test_hitregion_event.html
@@ -11,17 +11,17 @@
 <canvas id="input">
 </canvas>
 </p>
 <div id="content" style="display: none">
 
 </div>
 <pre id="test">
 <script type="application/javascript">
-SpecialPowers.pushPrefEnv({"set": [["canvas.hitregions.enabled", true]]}, function() {
+SpecialPowers.pushPrefEnv({"set": [["canvas.hitregions.enabled", true]]}).then(function() {
 
   var input = document.getElementById("input");
   var regionId = "";
   input.addEventListener('mousedown', function(evt){
     regionId = evt.region;
   })
 
   function runTests()
--- a/dom/html/test/file_fullscreen-denied.html
+++ b/dom/html/test/file_fullscreen-denied.html
@@ -147,16 +147,17 @@ async function testFullscreenMouseBtn(ev
       requestFullscreenMouseBtn(evt, mouseButton);
       await fsDenied;
       ok(!document.fullscreenElement, `Should not grant request on '${evt}' triggered by mouse button ${mouseButton}`);
     }
   }
   // Restore the pref environment we changed before
   // entering testNonTrustContext.
   await SpecialPowers.popPrefEnv();
+  await SpecialPowers.popPrefEnv();
   finish();
 }
 
 function finish() {
   opener.nextTest();
 }
 
 </script>
--- a/dom/media/test/test_eme_request_notifications.html
+++ b/dom/media/test/test_eme_request_notifications.html
@@ -55,17 +55,17 @@ var tests = [
   {
     keySystem: "com.widevine.alpha",
     expectedStatus: 'cdm-disabled',
     prefs: [["media.eme.enabled", true], ["media.gmp-widevinecdm.enabled", false]]
   },
   {
     keySystem: "com.widevine.alpha",
     expectedStatus: 'cdm-not-installed',
-    prefs: [["media.eme.enabled", true], , ["media.gmp-widevinecdm.enabled", true]]
+    prefs: [["media.eme.enabled", true], ["media.gmp-widevinecdm.enabled", true]]
   },
   {
     keySystem: CLEARKEY_KEYSYSTEM,
     expectedStatus: 'cdm-created',
     prefs: [["media.eme.enabled", true]]
   }
 ];
 
--- a/dom/tests/mochitest/dom-level0/test_background_loading_iframes.html
+++ b/dom/tests/mochitest/dom-level0/test_background_loading_iframes.html
@@ -29,17 +29,17 @@ window.addEventListener('message', funct
     SimpleTest.finish();
   }
 })
 
 window.onload = function() {
   myLoadTime = performance.now();
 }
 
-SpecialPowers.pushPrefEnv({"set":[["dom.background_loading_iframe", true]]}, function () {
+SpecialPowers.pushPrefEnv({"set":[["dom.background_loading_iframe", true]]}).then(function () {
   var iframe1 = document.createElement("iframe");
   var iframe2 = document.createElement("iframe");
   var iframe3 = document.createElement("iframe");
 
   iframe1.src = "http://example.org:80/tests/dom/tests/mochitest/dom-level0/file_test_background_loading_iframes.html";
   iframe2.src = "http://example.org:80/tests/dom/tests/mochitest/dom-level0/file_test_background_loading_iframes.html";
   iframe3.src = "http://example.org:80/tests/dom/tests/mochitest/dom-level0/file_test_background_loading_iframes.html";
 
--- a/layout/base/tests/test_bug394057.html
+++ b/layout/base/tests/test_bug394057.html
@@ -58,22 +58,22 @@ if (serifWidth == monospaceWidth) {
     if (serifWidth != monospaceWidth)
       break;
   }
 }
 
 isnot(serifWidth, monospaceWidth,
       "can't find serif and monospace fonts of different width");
 
-SpecialPowers.pushPrefEnv({'set': [['font.name.serif.x-western', serifFonts[serifIdx]]]}, step2);
+SpecialPowers.pushPrefEnv({'set': [['font.name.serif.x-western', serifFonts[serifIdx]]]}).then(step2);
 
 var serifWidthFromPref;
 function step2() {
     serifWidthFromPref = tableElement.offsetWidth;
-    SpecialPowers.pushPrefEnv({'set': [['font.name.serif.x-western', monospaceFonts[monospaceIdx]]]}, step3);
+    SpecialPowers.pushPrefEnv({'set': [['font.name.serif.x-western', monospaceFonts[monospaceIdx]]]}).then(step3);
 }
 var monospaceWidthFromPref;
 function step3() {
     monospaceWidthFromPref = tableElement.offsetWidth;
 
     is(serifWidthFromPref, serifWidth,
        "changing font pref should change width of table (serif)");
     is(monospaceWidthFromPref, monospaceWidth,
--- a/testing/specialpowers/content/SpecialPowersAPI.jsm
+++ b/testing/specialpowers/content/SpecialPowersAPI.jsm
@@ -113,17 +113,16 @@ class SpecialPowersAPI extends JSWindowA
   constructor() {
     super();
 
     this._consoleListeners = [];
     this._encounteredCrashDumpFiles = [];
     this._unexpectedCrashDumpFiles = { };
     this._crashDumpDir = null;
     this._mfl = null;
-    this._applyingPrefs = false;
     this._applyingPermissions = false;
     this._observingPermissions = false;
     this._asyncObservers = new WeakMap();
     this._xpcomabi = null;
     this._os = null;
     this._pu = null;
 
 
@@ -512,16 +511,22 @@ class SpecialPowersAPI extends JSWindowA
     // for mochitest-browser
     if (typeof this.chromeWindow != "undefined")
       this.chromeWindow.setTimeout(callback, 0);
     // for mochitest-plain
     else
       this.contentWindow.setTimeout(callback, 0);
   }
 
+  promiseTimeout(delay) {
+    return new Promise(resolve => {
+      this._setTimeout(resolve, delay);
+    });
+  }
+
   _delayCallbackTwice(callback) {
      let delayedCallback = () => {
        let delayAgain = (aCallback) => {
          // Using this._setTimeout doesn't work here
          // It causes failures in mochtests that use
          // multiple pushPrefEnv calls
          // For chrome/browser-chrome mochitests
          this._setTimeout(aCallback);
@@ -732,246 +737,29 @@ class SpecialPowersAPI extends JSWindowA
     };
 
     for (var idx in pendingActions) {
       var perm = pendingActions[idx];
       this.sendAsyncMessage("SPPermissionManager", perm);
     }
   }
 
-  /**
-   * Helper to resolve a promise by calling the resolve function and call an
-   * optional callback.
-   */
-  _resolveAndCallOptionalCallback(resolveFn, callback = null) {
-    resolveFn();
-
-    if (callback) {
-      callback();
-    }
+  async pushPrefEnv(inPrefs, callback = null) {
+    await this.sendQuery("PushPrefEnv", inPrefs).then(callback);
+    await this.promiseTimeout(0);
   }
 
-  /**
-   * Take in a list of pref changes to make, then invokes |callback| and resolves
-   * the returned Promise once those changes have taken effect.  When the test
-   * finishes, these changes are reverted.
-   *
-   * |inPrefs| must be an object with up to two properties: "set" and "clear".
-   * pushPrefEnv will set prefs as indicated in |inPrefs.set| and will unset
-   * the prefs indicated in |inPrefs.clear|.
-   *
-   * For example, you might pass |inPrefs| as:
-   *
-   *  inPrefs = {'set': [['foo.bar', 2], ['magic.pref', 'baz']],
-   *             'clear': [['clear.this'], ['also.this']] };
-   *
-   * Notice that |set| and |clear| are both an array of arrays.  In |set|, each
-   * of the inner arrays must have the form [pref_name, value] or [pref_name,
-   * value, iid].  (The latter form is used for prefs with "complex" values.)
-   *
-   * In |clear|, each inner array should have the form [pref_name].
-   *
-   * If you set the same pref more than once (or both set and clear a pref),
-   * the behavior of this method is undefined.
-   *
-   * (Implementation note: _prefEnvUndoStack is a stack of values to revert to,
-   * not values which have been set!)
-   *
-   * TODO: complex values for original cleanup?
-   *
-   */
-  async _pushPrefEnv(inPrefs) {
-    var prefs = Services.prefs;
-
-    var pref_string = [];
-    pref_string[prefs.PREF_INT] = "INT";
-    pref_string[prefs.PREF_BOOL] = "BOOL";
-    pref_string[prefs.PREF_STRING] = "CHAR";
-
-    var pendingActions = [];
-    var cleanupActions = [];
-
-    for (var action in inPrefs) { /* set|clear */
-      for (var idx in inPrefs[action]) {
-        var aPref = inPrefs[action][idx];
-        var prefName = aPref[0];
-        var prefValue = null;
-        var prefIid = null;
-        var prefType = prefs.PREF_INVALID;
-        var originalValue = null;
-
-        if (aPref.length == 3) {
-          prefValue = aPref[1];
-          prefIid = aPref[2];
-        } else if (aPref.length == 2) {
-          prefValue = aPref[1];
-        }
-
-        /* If pref is not found or invalid it doesn't exist. */
-        if (prefs.getPrefType(prefName) != prefs.PREF_INVALID) {
-          prefType = pref_string[prefs.getPrefType(prefName)];
-          if ((prefs.prefHasUserValue(prefName) && action == "clear") ||
-              (action == "set"))
-            originalValue = this._getPref(prefName, prefType, {});
-        } else if (action == "set") {
-          /* prefName doesn't exist, so 'clear' is pointless */
-          if (aPref.length == 3) {
-            prefType = "COMPLEX";
-          } else if (aPref.length == 2) {
-            if (typeof(prefValue) == "boolean")
-              prefType = "BOOL";
-            else if (typeof(prefValue) == "number")
-              prefType = "INT";
-            else if (typeof(prefValue) == "string")
-              prefType = "CHAR";
-          }
-        }
-
-        /* PREF_INVALID: A non existing pref which we are clearing or invalid values for a set */
-        if (prefType == prefs.PREF_INVALID)
-          continue;
-
-        /* We are not going to set a pref if the value is the same */
-        if (originalValue == prefValue)
-          continue;
-
-        pendingActions.push({"action": action, "type": prefType, "name": prefName, "value": prefValue, "Iid": prefIid});
-
-        /* Push original preference value or clear into cleanup array */
-        var cleanupTodo = {"action": action, "type": prefType, "name": prefName, "value": originalValue, "Iid": prefIid};
-        if (originalValue == null) {
-          cleanupTodo.action = "clear";
-        } else {
-          cleanupTodo.action = "set";
-        }
-        cleanupActions.push(cleanupTodo);
-      }
-    }
-
-    return new Promise(resolve => {
-      if (pendingActions.length > 0) {
-        // The callback needs to be delayed twice. One delay is because the pref
-        // service doesn't guarantee the order it calls its observers in, so it
-        // may notify the observer holding the callback before the other
-        // observers have been notified and given a chance to make the changes
-        // that the callback checks for. The second delay is because pref
-        // observers often defer making their changes by posting an event to the
-        // event loop.
-        this._prefEnvUndoStack.push(cleanupActions);
-        this._pendingPrefs.push([pendingActions,
-                                 this._delayCallbackTwice(resolve)]);
-        this._applyPrefs();
-      } else {
-        this._setTimeout(resolve);
-      }
-    });
+  async popPrefEnv(callback = null) {
+    await this.sendQuery("PopPrefEnv").then(callback);
+    await this.promiseTimeout(0);
   }
 
-  pushPrefEnv(inPrefs, callback = null) {
-    let promise = this._pushPrefEnv(inPrefs);
-    if (callback) {
-      promise.then(callback);
-    }
-    return promise;
-  }
-
-  popPrefEnv(callback = null) {
-    return new Promise(resolve => {
-      let done = this._resolveAndCallOptionalCallback.bind(this, resolve, callback);
-      if (this._prefEnvUndoStack.length > 0) {
-        // See pushPrefEnv comment regarding delay.
-        let cb = this._delayCallbackTwice(done);
-        /* Each pop will have a valid block of preferences */
-        this._pendingPrefs.push([this._prefEnvUndoStack.pop(), cb]);
-        this._applyPrefs();
-      } else {
-        this._setTimeout(done);
-      }
-    });
-  }
-
-  flushPrefEnv(callback = null) {
-    while (this._prefEnvUndoStack.length > 1)
-      this.popPrefEnv(null);
-
-    return new Promise(resolve => {
-      let done = this._resolveAndCallOptionalCallback.bind(this, resolve, callback);
-      this.popPrefEnv(done);
-    });
-  }
-
-  _isPrefActionNeeded(prefAction) {
-    if (prefAction.action === "clear") {
-      return Services.prefs.prefHasUserValue(prefAction.name);
-    } else if (prefAction.action === "set") {
-      try {
-        let currentValue = this._getPref(prefAction.name, prefAction.type, {});
-        return currentValue != prefAction.value;
-      } catch (e) {
-        // If the preference is not defined yet, setting the value will have an effect.
-        return true;
-      }
-    }
-    // Only "clear" and "set" actions are supported.
-    return false;
-  }
-
-  /*
-    Iterate through one atomic set of pref actions and perform sets/clears as appropriate.
-    All actions performed must modify the relevant pref.
-  */
-  _applyPrefs() {
-    if (this._applyingPrefs || this._pendingPrefs.length <= 0) {
-      return;
-    }
-
-    /* Set lock and get prefs from the _pendingPrefs queue */
-    this._applyingPrefs = true;
-    var transaction = this._pendingPrefs.shift();
-    var pendingActions = transaction[0];
-    var callback = transaction[1];
-
-    // Filter out all the pending actions that will not have any effect.
-    pendingActions = pendingActions.filter(action => {
-      return this._isPrefActionNeeded(action);
-    });
-
-
-    var self = this;
-    let onPrefActionsApplied = function() {
-      self._setTimeout(callback);
-      self._setTimeout(function() {
-        self._applyingPrefs = false;
-        // Now apply any prefs that may have been queued while we were applying
-        self._applyPrefs();
-      });
-    };
-
-    // If no valid action remains, call onPrefActionsApplied directly and bail out.
-    if (pendingActions.length === 0) {
-      onPrefActionsApplied();
-      return;
-    }
-
-    var lastPref = pendingActions[pendingActions.length - 1];
-
-    var pb = Services.prefs;
-    pb.addObserver(lastPref.name, function prefObs(subject, topic, data) {
-      pb.removeObserver(lastPref.name, prefObs);
-      onPrefActionsApplied();
-    });
-
-    for (var idx in pendingActions) {
-      var pref = pendingActions[idx];
-      if (pref.action == "set") {
-        this._setPref(pref.name, pref.type, pref.value, pref.Iid);
-      } else if (pref.action == "clear") {
-        this.clearUserPref(pref.name);
-      }
-    }
+  async flushPrefEnv(callback = null) {
+    await this.sendQuery("FlushPrefEnv").then(callback);
+    await this.promiseTimeout(0);
   }
 
   _addObserverProxy(notification) {
     if (notification in this._proxiedObservers) {
       this._addMessageListener(notification, this._proxiedObservers[notification]);
     }
   }
   _removeObserverProxy(notification) {
@@ -2070,16 +1858,14 @@ SpecialPowersAPI.prototype._permissionOb
 
 SpecialPowersAPI.prototype.EARLY_BETA_OR_EARLIER = AppConstants.EARLY_BETA_OR_EARLIER;
 
 // Due to an unfortunate accident of history, when this API was
 // subclassed using `Thing.prototype = new SpecialPowersAPI()`, existing
 // code depends on all SpecialPowers instances using the same arrays for
 // these.
 Object.assign(SpecialPowersAPI.prototype, {
-  _prefEnvUndoStack: [],
-  _pendingPrefs: [],
   _permissionsUndoStack: [],
   _pendingPermissions: [],
 });
 
 this.SpecialPowersAPI = SpecialPowersAPI;
 this.bindDOMWindowUtils = bindDOMWindowUtils;
--- a/testing/specialpowers/content/SpecialPowersAPIParent.jsm
+++ b/testing/specialpowers/content/SpecialPowersAPIParent.jsm
@@ -67,16 +67,43 @@ function getTestPlugin(pluginName) {
     if (tag.name == name) {
       return tag;
     }
   }
 
   return null;
 }
 
+const PREF_TYPES = {
+  [Ci.nsIPrefBranch.PREF_INVALID]: "INVALID",
+  [Ci.nsIPrefBranch.PREF_INT]: "INT",
+  [Ci.nsIPrefBranch.PREF_BOOL]: "BOOL",
+  [Ci.nsIPrefBranch.PREF_STRING]: "CHAR",
+  "number": "INT",
+  "boolean": "BOOL",
+  "string": "CHAR",
+};
+
+// We share a single preference environment stack between all
+// SpecialPowers instances, across all processes.
+let prefUndoStack = [];
+let inPrefEnvOp = false;
+
+function doPrefEnvOp(fn) {
+  if (inPrefEnvOp) {
+    throw new Error("Reentrant preference environment operations not supported");
+  }
+  inPrefEnvOp = true;
+  try {
+    return fn();
+  } finally {
+    inPrefEnvOp = false;
+  }
+}
+
 class SpecialPowersAPIParent extends JSWindowActorParent {
   constructor() {
     super();
     this._crashDumpDir = null;
     this._processCrashObserversRegistered = false;
     this._chromeScriptListeners = [];
     this._extensions = new Map();
   }
@@ -240,83 +267,211 @@ class SpecialPowersAPIParent extends JSW
 
     observers.forEach(function(observer) {
       try {
         observer.observe(subject, topic, data);
       } catch (e) { }
     });
   }
 
+  /*
+    Iterate through one atomic set of pref actions and perform sets/clears as appropriate.
+    All actions performed must modify the relevant pref.
+  */
+  _applyPrefs(actions) {
+    for (let pref of actions) {
+      if (pref.action == "set") {
+        this._setPref(pref.name, pref.type, pref.value, pref.iid);
+      } else if (pref.action == "clear") {
+        Services.prefs.clearUserPref(pref.name);
+      }
+    }
+  }
+
+  /**
+   * Take in a list of pref changes to make, pushes their current values
+   * onto the restore stack, and makes the changes.  When the test
+   * finishes, these changes are reverted.
+   *
+   * |inPrefs| must be an object with up to two properties: "set" and "clear".
+   * pushPrefEnv will set prefs as indicated in |inPrefs.set| and will unset
+   * the prefs indicated in |inPrefs.clear|.
+   *
+   * For example, you might pass |inPrefs| as:
+   *
+   *  inPrefs = {'set': [['foo.bar', 2], ['magic.pref', 'baz']],
+   *             'clear': [['clear.this'], ['also.this']] };
+   *
+   * Notice that |set| and |clear| are both an array of arrays.  In |set|, each
+   * of the inner arrays must have the form [pref_name, value] or [pref_name,
+   * value, iid].  (The latter form is used for prefs with "complex" values.)
+   *
+   * In |clear|, each inner array should have the form [pref_name].
+   *
+   * If you set the same pref more than once (or both set and clear a pref),
+   * the behavior of this method is undefined.
+   */
+  pushPrefEnv(inPrefs) {
+    return doPrefEnvOp(() => {
+      let pendingActions = [];
+      let cleanupActions = [];
+
+      for (let [action, prefs] of Object.entries(inPrefs)) {
+        for (let pref of prefs) {
+          let name = pref[0];
+          let value = null;
+          let iid = null;
+          let type = PREF_TYPES[Services.prefs.getPrefType(name)];
+          let originalValue = null;
+
+          if (pref.length == 3) {
+            value = pref[1];
+            iid = pref[2];
+          } else if (pref.length == 2) {
+            value = pref[1];
+          }
+
+
+          /* If pref is not found or invalid it doesn't exist. */
+          if (type !== "INVALID") {
+            if ((Services.prefs.prefHasUserValue(name) && action == "clear") ||
+                action == "set") {
+              originalValue = this._getPref(name, type);
+            }
+          } else if (action == "set") {
+            /* name doesn't exist, so 'clear' is pointless */
+            if (iid) {
+              type = "COMPLEX";
+            }
+          }
+
+          if (type === "INVALID") {
+            type = PREF_TYPES[typeof value];
+          }
+          if (type === "INVALID") {
+            throw new Error("Unexpected preference type");
+          }
+
+          pendingActions.push({action, type, name, value, iid});
+
+          /* Push original preference value or clear into cleanup array */
+          var cleanupTodo = {type, name, value: originalValue, iid};
+          if (originalValue == null) {
+            cleanupTodo.action = "clear";
+          } else {
+            cleanupTodo.action = "set";
+          }
+          cleanupActions.push(cleanupTodo);
+        }
+      }
+
+      prefUndoStack.push(cleanupActions);
+      this._applyPrefs(pendingActions);
+    });
+  }
+
+  async popPrefEnv() {
+    return doPrefEnvOp(() => {
+      let env = prefUndoStack.pop();
+      if (env) {
+        this._applyPrefs(env);
+        return true;
+      }
+      return false;
+    });
+  }
+
+  flushPrefEnv() {
+    while (prefUndoStack.length) {
+      this.popPrefEnv();
+    }
+  }
+
+  _setPref(name, type, value, iid) {
+    switch (type) {
+      case "BOOL":
+        return Services.prefs.setBoolPref(name, value);
+      case "INT":
+        return Services.prefs.setIntPref(name, value);
+      case "CHAR":
+        return Services.prefs.setCharPref(name, value);
+      case "COMPLEX":
+        return Services.prefs.setComplexValue(name, iid, value);
+    }
+    throw new Error(`Unexpected preference type: ${type}`);
+  }
+
+  _getPref(name, type, defaultValue, iid) {
+    switch (type) {
+      case "BOOL":
+        if (defaultValue !== undefined) {
+          return Services.prefs.getBoolPref(name, defaultValue);
+        }
+        return Services.prefs.getBoolPref(name);
+      case "INT":
+        if (defaultValue !== undefined) {
+          return Services.prefs.getIntPref(name, defaultValue);
+        }
+        return Services.prefs.getIntPref(name);
+      case "CHAR":
+        if (defaultValue !== undefined) {
+          return Services.prefs.getCharPref(name, defaultValue);
+        }
+        return Services.prefs.getCharPref(name);
+      case "COMPLEX":
+        return Services.prefs.getComplexValue(name, iid);
+    }
+    throw new Error(`Unexpected preference type: ${type}`);
+  }
+
   /**
    * messageManager callback function
    * This will get requests from our API in the window and process them in chrome for it
    **/
   receiveMessage(aMessage) { // eslint-disable-line complexity
     // We explicitly return values in the below code so that this function
     // doesn't trigger a flurry of warnings about "does not always return
     // a value".
     switch (aMessage.name) {
+      case "PushPrefEnv":
+        return this.pushPrefEnv(aMessage.data);
+
+      case "PopPrefEnv":
+        return this.popPrefEnv();
+
+      case "FlushPrefEnv":
+        return this.flushPrefEnv();
+
       case "SPPrefService": {
         let prefs = Services.prefs;
         let prefType = aMessage.json.prefType.toUpperCase();
         let { prefName, prefValue, iid, defaultValue } = aMessage.json;
 
         if (aMessage.json.op == "get") {
           if (!prefName || !prefType)
             throw new SpecialPowersError("Invalid parameters for get in SPPrefService");
 
           // return null if the pref doesn't exist
           if (defaultValue === undefined && prefs.getPrefType(prefName) == prefs.PREF_INVALID)
             return null;
+          return this._getPref(prefName, prefType, defaultValue, iid);
         } else if (aMessage.json.op == "set") {
           if (!prefName || !prefType || prefValue === undefined)
             throw new SpecialPowersError("Invalid parameters for set in SPPrefService");
+
+          return this._setPref(prefName, prefType, prefValue, iid);
         } else if (aMessage.json.op == "clear") {
           if (!prefName)
             throw new SpecialPowersError("Invalid parameters for clear in SPPrefService");
+
+          prefs.clearUserPref(prefName);
         } else {
           throw new SpecialPowersError("Invalid operation for SPPrefService");
         }
 
-        // Now we make the call
-        switch (prefType) {
-          case "BOOL":
-            if (aMessage.json.op == "get") {
-              if (defaultValue !== undefined) {
-                return prefs.getBoolPref(prefName, defaultValue);
-              }
-              return prefs.getBoolPref(prefName);
-            }
-            return prefs.setBoolPref(prefName, prefValue);
-          case "INT":
-            if (aMessage.json.op == "get") {
-              if (defaultValue !== undefined) {
-                return prefs.getIntPref(prefName, defaultValue);
-              }
-              return prefs.getIntPref(prefName);
-            }
-            return prefs.setIntPref(prefName, prefValue);
-          case "CHAR":
-            if (aMessage.json.op == "get") {
-              if (defaultValue !== undefined) {
-                return prefs.getCharPref(prefName, defaultValue);
-              }
-              return prefs.getCharPref(prefName);
-            }
-            return prefs.setCharPref(prefName, prefValue);
-          case "COMPLEX":
-            if (aMessage.json.op == "get")
-              return prefs.getComplexValue(prefName, iid);
-            return prefs.setComplexValue(prefName, iid, prefValue);
-          case "":
-            if (aMessage.json.op == "clear") {
-              prefs.clearUserPref(prefName);
-              return undefined;
-            }
-        }
         return undefined; // See comment at the beginning of this function.
       }
 
       case "SPProcessCrashService": {
         switch (aMessage.json.op) {
           case "register-observer":
             this._addProcessCrashObservers();
             break;
--- a/toolkit/components/extensions/test/mochitest/test_ext_storage_cleanup.html
+++ b/toolkit/components/extensions/test/mochitest/test_ext_storage_cleanup.html
@@ -207,29 +207,29 @@ add_task(async function test_uninistall_
     set: [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]],
   });
 
   await test_uninstall({
     extensionId: "storage.cleanup-JSONFileBackend@tests.mozilla.org",
     ...(storageTestHelpers.storageLocal),
   });
 
-  await SpecialPowers.pushPrefEnv();
+  await SpecialPowers.popPrefEnv();
 });
 
 // Repeat the cleanup test when the storage.local IndexedDB backend is enabled.
 add_task(async function test_uninistall_with_storage_local_idb_backend() {
   await SpecialPowers.pushPrefEnv({
     set: [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]],
   });
 
   await test_uninstall({
     extensionId: "storage.cleanup-IDBBackend@tests.mozilla.org",
     ...(storageTestHelpers.storageLocal),
   });
 
-  await SpecialPowers.pushPrefEnv();
+  await SpecialPowers.popPrefEnv();
 });
 
 </script>
 
 </body>
 </html>