Bug 562674: Make XPI extraction asynchronous. r=Unfocused
authorDave Townsend <dtownsend@oxymoronical.com>
Thu, 24 Oct 2013 09:23:32 -0700
changeset 165906 ccf70000829b2f925a35b9018393c335ffa59146
parent 165905 cfd30965638a5b72202d702f129d857fcabd400e
child 165907 f2326eb4fb72f28a5b2b9af0dd41f1255a7246a2
push id3066
push userakeybl@mozilla.com
push dateMon, 09 Dec 2013 19:58:46 +0000
treeherdermozilla-beta@a31a0dce83aa [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersUnfocused
bugs562674
milestone27.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 562674: Make XPI extraction asynchronous. r=Unfocused
toolkit/mozapps/extensions/XPIProvider.jsm
toolkit/mozapps/extensions/test/xpcshell/test_bootstrap.js
--- a/toolkit/mozapps/extensions/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/XPIProvider.jsm
@@ -22,16 +22,22 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyModuleGetter(this, "LightweightThemeManager",
                                   "resource://gre/modules/LightweightThemeManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
                                   "resource://gre/modules/FileUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PermissionsUtils",
                                   "resource://gre/modules/PermissionsUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+                                  "resource://gre/modules/Promise.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+                                  "resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "OS",
+                                  "resource://gre/modules/osfile.jsm");
 
 XPCOMUtils.defineLazyServiceGetter(this,
                                    "ChromeRegistry",
                                    "@mozilla.org/chrome/chrome-registry;1",
                                    "nsIChromeRegistry");
 XPCOMUtils.defineLazyServiceGetter(this,
                                    "ResProtocolHandler",
                                    "@mozilla.org/network/protocol;1?name=resource",
@@ -98,16 +104,19 @@ const KEY_APP_SYSTEM_USER             = 
 
 const XPI_PERMISSION                  = "install";
 
 const RDFURI_INSTALL_MANIFEST_ROOT    = "urn:mozilla:install-manifest";
 const PREFIX_NS_EM                    = "http://www.mozilla.org/2004/em-rdf#";
 
 const TOOLKIT_ID                      = "toolkit@mozilla.org";
 
+// The maximum amount of file data to buffer at a time during file extraction
+const EXTRACTION_BUFFER               = 1024 * 512;
+
 // The value for this is in Makefile.in
 #expand const DB_SCHEMA                       = __MOZ_EXTENSIONS_DB_SCHEMA__;
 
 // Properties that exist in the install manifest
 const PROP_METADATA      = ["id", "version", "type", "internalName", "updateURL",
                             "updateKey", "optionsURL", "optionsType", "aboutURL",
                             "iconURL", "icon64URL"];
 const PROP_LOCALE_SINGLE = ["name", "description", "creator", "homepageURL"];
@@ -1106,16 +1115,150 @@ function getTemporaryFile() {
   let random = Math.random().toString(36).replace(/0./, '').substr(-3);
   file.append("tmp-" + random + ".xpi");
   file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
 
   return file;
 }
 
 /**
+ * Asynchronously writes data from an nsIInputStream to an OS.File instance.
+ * The source stream and OS.File are closed regardless of whether the operation
+ * succeeds or fails.
+ * Returns a promise that will be resolved when complete.
+ *
+ * @param  aPath
+ *         The name of the file being extracted for logging purposes.
+ * @param  aStream
+ *         The source nsIInputStream.
+ * @param  aFile
+ *         The open OS.File instance to write to.
+ */
+function saveStreamAsync(aPath, aStream, aFile) {
+  let deferred = Promise.defer();
+
+  // Read the input stream on a background thread
+  let sts = Cc["@mozilla.org/network/stream-transport-service;1"].
+            getService(Ci.nsIStreamTransportService);
+  let transport = sts.createInputTransport(aStream, -1, -1, true);
+  let input = transport.openInputStream(0, 0, 0)
+                       .QueryInterface(Ci.nsIAsyncInputStream);
+  let source = Cc["@mozilla.org/binaryinputstream;1"].
+               createInstance(Ci.nsIBinaryInputStream);
+  source.setInputStream(input);
+
+  let data = Uint8Array(EXTRACTION_BUFFER);
+
+  function readFailed(error) {
+    try {
+      aStream.close();
+    }
+    catch (e) {
+      ERROR("Failed to close JAR stream for " + aPath);
+    }
+
+    aFile.close().then(function() {
+      deferred.reject(error);
+    }, function(e) {
+      ERROR("Failed to close file for " + aPath);
+      deferred.reject(error);
+    });
+  }
+
+  function readData() {
+    try {
+      let count = Math.min(source.available(), data.byteLength);
+      source.readArrayBuffer(count, data.buffer);
+
+      aFile.write(data, { bytes: count }).then(function() {
+        input.asyncWait(readData, 0, 0, Services.tm.currentThread);
+      }, readFailed);
+    }
+    catch (e if e.result == Cr.NS_BASE_STREAM_CLOSED) {
+      deferred.resolve(aFile.close());
+    }
+    catch (e) {
+      readFailed(e);
+    }
+  }
+
+  input.asyncWait(readData, 0, 0, Services.tm.currentThread);
+
+  return deferred.promise;
+}
+
+/**
+ * Asynchronously extracts files from a ZIP file into a directory.
+ * Returns a promise that will be resolved when the extraction is complete.
+ *
+ * @param  aZipFile
+ *         The source ZIP file that contains the add-on.
+ * @param  aDir
+ *         The nsIFile to extract to.
+ */
+function extractFilesAsync(aZipFile, aDir) {
+  let zipReader = Cc["@mozilla.org/libjar/zip-reader;1"].
+                  createInstance(Ci.nsIZipReader);
+  zipReader.open(aZipFile);
+
+  let promises = [];
+
+  // Get all of the entries in the zip and sort them so we create directories
+  // before files
+  let entries = zipReader.findEntries(null);
+  let names = [];
+  while (entries.hasMore())
+    names.push(entries.getNext());
+  names.sort();
+
+  for (let name of names) {
+    let entryName = name;
+    let zipentry = zipReader.getEntry(name);
+    let path = OS.Path.join(aDir.path, ...name.split("/"));
+
+    if (zipentry.isDirectory) {
+      promises.push(OS.File.makeDir(path).then(null, function(e) {
+        ERROR("extractFilesAsync: failed to create directory " + path, e);
+        throw e;
+      }));
+    }
+    else {
+      let options = { unixMode: zipentry.permissions | FileUtils.PERMS_FILE };
+      let promise = OS.File.open(path, { truncate: true }, options).then(function(file) {
+        if (zipentry.realSize == 0)
+          return file.close();
+
+        return saveStreamAsync(path, zipReader.getInputStream(entryName), file);
+      });
+
+      promises.push(promise.then(null, function(e) {
+        ERROR("extractFilesAsync: failed to extract file " + path, e);
+        throw e;
+      }));
+    }
+  }
+
+  // Will be rejected if any of the promises are rejected and resolved otherwise
+  let result = Promise.defer();
+
+  // If any promise is rejected then result is rejected, the resulting array of
+  // promises are all resolved though
+  promises = promises.map(p => p.then(null, result.reject));
+
+  // Wait for all of the promises to be resolved
+  return Promise.all(promises).then(function() {
+    // Resolve the result if it hasn't already been rejected
+    result.resolve();
+
+    zipReader.close();
+    return result.promise;
+  });
+}
+
+/**
  * Extracts files from a ZIP file into a directory.
  *
  * @param  aZipFile
  *         The source ZIP file that contains the add-on.
  * @param  aDir
  *         The nsIFile to extract to.
  */
 function extractFiles(aZipFile, aDir) {
@@ -1239,52 +1382,53 @@ function escapeAddonURI(aAddon, aUri, aU
     compatMode = "ignore";
   else if (AddonManager.strictCompatibility)
     compatMode = "strict";
   uri = uri.replace(/%COMPATIBILITY_MODE%/g, compatMode);
 
   return uri;
 }
 
-
-/**
- * Removes the specified files or directories in a staging directory and then if
- * the staging directory is empty attempts to remove it.
- *
- * @param  aDir
- *         nsIFile for the staging directory to clean up
- * @param  aLeafNames
- *         An array of file or directory to remove from the directory, the
- *         array may be empty
- */
-function cleanStagingDir(aDir, aLeafNames) {
-  aLeafNames.forEach(function(aName) {
-    let file = aDir.clone();
-    file.append(aName);
-    if (file.exists())
-      recursiveRemove(file);
+function recursiveRemoveAsync(aFile) {
+  return Task.spawn(function () {
+    let info = null;
+    try {
+      info = yield OS.File.stat(aFile.path);
+    }
+    catch (e if e instanceof OS.File.Error && e.becauseNoSuchFile) {
+      // The file has already gone away
+      return;
+    }
+
+    setFilePermissions(aFile, info.isDir ? FileUtils.PERMS_DIRECTORY
+                                         : FileUtils.PERMS_FILE);
+
+    // OS.File means we have to recurse into directories
+    if (info.isDir) {
+      let iterator = new OS.File.DirectoryIterator(aFile.path);
+      yield iterator.forEach(function(entry) {
+        let nextFile = aFile.clone();
+        nextFile.append(entry.name);
+        return recursiveRemoveAsync(nextFile);
+      });
+      yield iterator.close();
+    }
+
+    try {
+      yield info.isDir ? OS.File.removeEmptyDir(aFile.path)
+                       : OS.File.remove(aFile.path);
+    }
+    catch (e if e instanceof OS.File.Error && e.becauseNoSuchFile) {
+      // The file has already gone away
+    }
+    catch (e) {
+      ERROR("Failed to remove file " + aFile.path, e);
+      throw e;
+    }
   });
-
-  let dirEntries = aDir.directoryEntries.QueryInterface(Ci.nsIDirectoryEnumerator);
-  try {
-    if (dirEntries.nextFile)
-      return;
-  }
-  finally {
-    dirEntries.close();
-  }
-
-  try {
-    setFilePermissions(aDir, FileUtils.PERMS_DIRECTORY);
-    aDir.remove(false);
-  }
-  catch (e) {
-    WARN("Failed to remove staging dir", e);
-    // Failing to remove the staging directory is ignorable
-  }
 }
 
 /**
  * Recursively removes a directory or file fixing permissions when necessary.
  *
  * @param  aFile
  *         The nsIFile to remove
  */
@@ -1295,16 +1439,18 @@ function recursiveRemove(aFile) {
     isDir = aFile.isDirectory();
   }
   catch (e) {
     // If the file has already gone away then don't worry about it, this can
     // happen on OSX where the resource fork is automatically moved with the
     // data fork for the file. See bug 733436.
     if (e.result == Cr.NS_ERROR_FILE_TARGET_DOES_NOT_EXIST)
       return;
+    if (e.result == Cr.NS_ERROR_FILE_NOT_FOUND)
+      return;
 
     throw e;
   }
 
   setFilePermissions(aFile, isDir ? FileUtils.PERMS_DIRECTORY
                                   : FileUtils.PERMS_FILE);
 
   try {
@@ -2371,17 +2517,17 @@ var XPIProvider = {
                                      oldBootstrap.type, existingAddon, "install",
                                      BOOTSTRAP_REASONS.ADDON_INSTALL);
           }
           continue;
         }
       }
 
       try {
-        cleanStagingDir(stagingDir, seenFiles);
+        aLocation.cleanStagingDir(seenFiles);
       }
       catch (e) {
         // Non-critical, just saves some perf on startup if we clean this up.
         LOG("Error cleaning staging dir " + stagingDir.path, e);
       }
     }, this);
     return changed;
   },
@@ -4281,17 +4427,17 @@ var XPIProvider = {
    *
    * @param  aAddon
    *         The DBAddonInternal to cancel uninstall for
    */
   cancelUninstallAddon: function XPI_cancelUninstallAddon(aAddon) {
     if (!(aAddon.inDatabase))
       throw new Error("Can only cancel uninstall for installed addons.");
 
-    cleanStagingDir(aAddon._installLocation.getStagingDir(), [aAddon.id]);
+    aAddon._installLocation.cleanStagingDir([aAddon.id]);
 
     XPIDatabase.setAddonProperties(aAddon, {
       pendingUninstall: false
     });
 
     if (!aAddon.visible)
       return;
 
@@ -4602,19 +4748,18 @@ AddonInstall.prototype = {
                                                this.listeners, this.wrapper);
       this.removeTemporaryFile();
       break;
     case AddonManager.STATE_INSTALLED:
       LOG("Cancelling install of " + this.addon.id);
       let xpi = this.installLocation.getStagingDir();
       xpi.append(this.addon.id + ".xpi");
       flushJarCache(xpi);
-      cleanStagingDir(this.installLocation.getStagingDir(),
-                      [this.addon.id, this.addon.id + ".xpi",
-                       this.addon.id + ".json"]);
+      this.installLocation.cleanStagingDir([this.addon.id, this.addon.id + ".xpi",
+                                            this.addon.id + ".json"]);
       this.state = AddonManager.STATE_CANCELLED;
       XPIProvider.removeActiveInstall(this);
 
       if (this.existingAddon) {
         delete this.existingAddon.pendingUpgrade;
         this.existingAddon.pendingUpgrade = null;
       }
 
@@ -5239,37 +5384,38 @@ AddonInstall.prototype = {
     let isUpgrade = this.existingAddon &&
                     this.existingAddon._installLocation == this.installLocation;
     let requiresRestart = XPIProvider.installRequiresRestart(this.addon);
 
     LOG("Starting install of " + this.sourceURI.spec);
     AddonManagerPrivate.callAddonListeners("onInstalling",
                                            createWrapper(this.addon),
                                            requiresRestart);
-    let stagedAddon = this.installLocation.getStagingDir();
-
-    try {
+
+    let stagingDir = this.installLocation.getStagingDir();
+    let stagedAddon = stagingDir.clone();
+
+    Task.spawn((function() {
+      yield this.installLocation.requestStagingDir();
+
       // First stage the file regardless of whether restarting is necessary
       if (this.addon.unpack || Prefs.getBoolPref(PREF_XPI_UNPACK, false)) {
         LOG("Addon " + this.addon.id + " will be installed as " +
             "an unpacked directory");
         stagedAddon.append(this.addon.id);
-        if (stagedAddon.exists())
-          recursiveRemove(stagedAddon);
-        stagedAddon.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
-        extractFiles(this.file, stagedAddon);
+        yield recursiveRemoveAsync(stagedAddon);
+        yield OS.File.makeDir(stagedAddon.path);
+        yield extractFilesAsync(this.file, stagedAddon);
       }
       else {
         LOG("Addon " + this.addon.id + " will be installed as " +
             "a packed xpi");
         stagedAddon.append(this.addon.id + ".xpi");
-        if (stagedAddon.exists())
-          stagedAddon.remove(true);
-        this.file.copyTo(this.installLocation.getStagingDir(),
-                         this.addon.id + ".xpi");
+        yield recursiveRemoveAsync(stagedAddon);
+        yield OS.File.copy(this.file.path, stagedAddon.path);
       }
 
       if (requiresRestart) {
         // Point the add-on to its extracted files as the xpi may get deleted
         this.addon._sourceBundle = stagedAddon;
 
         // Cache the AddonInternal as it may have updated compatibility info
         let stagedJSON = stagedAddon.clone();
@@ -5343,17 +5489,16 @@ AddonInstall.prototype = {
             XPIDatabase.updateAddonActive(this.existingAddon, false);
           }
         }
 
         // Install the new add-on into its final location
         let existingAddonID = this.existingAddon ? this.existingAddon.id : null;
         let file = this.installLocation.installAddon(this.addon.id, stagedAddon,
                                                      existingAddonID);
-        cleanStagingDir(stagedAddon.parent, []);
 
         // Update the metadata in the database
         this.addon._sourceBundle = file;
         this.addon._installLocation = this.installLocation;
         let [mFile, mTime] = recursiveLastModifiedTime(file);
         this.addon.updateDate = mTime;
         this.addon.visible = true;
         if (isUpgrade) {
@@ -5394,31 +5539,30 @@ AddonInstall.prototype = {
           }
           else {
             // XXX this makes it dangerous to do many things in onInstallEnded
             // listeners because important cleanup hasn't been done yet
             XPIProvider.unloadBootstrapScope(this.addon.id);
           }
         }
       }
-    }
-    catch (e) {
-      WARN("Failed to install", e);
+    }).bind(this)).then(null, (e) => {
+      WARN("Failed to install " + this.file.path + " from " + this.sourceURI.spec, e);
       if (stagedAddon.exists())
         recursiveRemove(stagedAddon);
       this.state = AddonManager.STATE_INSTALL_FAILED;
       this.error = AddonManager.ERROR_FILE_ACCESS;
       XPIProvider.removeActiveInstall(this);
       AddonManagerPrivate.callInstallListeners("onInstallFailed",
                                                this.listeners,
                                                this.wrapper);
-    }
-    finally {
+    }).then(() => {
       this.removeTemporaryFile();
-    }
+      return this.installLocation.releaseStagingDir();
+    });
   },
 
   getInterface: function AI_getInterface(iid) {
     if (iid.equals(Ci.nsIAuthPrompt2)) {
       var factory = Cc["@mozilla.org/prompter;1"].
                     getService(Ci.nsIPromptFactory);
       return factory.getPrompt(this.window, Ci.nsIAuthPrompt);
     }
@@ -6511,16 +6655,17 @@ function AddonWrapper(aAddon) {
 function DirectoryInstallLocation(aName, aDirectory, aScope, aLocked) {
   this._name = aName;
   this.locked = aLocked;
   this._directory = aDirectory;
   this._scope = aScope
   this._IDToFileMap = {};
   this._FileToIDMap = {};
   this._linkedAddons = [];
+  this._stagingDirLock = 0;
 
   if (!aDirectory.exists())
     return;
   if (!aDirectory.isDirectory())
     throw new Error("Location must be a directory.");
 
   this._readAddons();
 }
@@ -6658,16 +6803,82 @@ DirectoryInstallLocation.prototype = {
    * @return an nsIFile
    */
   getStagingDir: function DirInstallLocation_getStagingDir() {
     let dir = this._directory.clone();
     dir.append(DIR_STAGE);
     return dir;
   },
 
+  requestStagingDir: function() {
+    this._stagingDirLock++;
+
+    if (this._stagingDirPromise)
+      return this._stagingDirPromise;
+
+    OS.File.makeDir(this._directory.path);
+    let stagepath = OS.Path.join(this._directory.path, DIR_STAGE);
+    return this._stagingDirPromise = OS.File.makeDir(stagepath).then(null, (e) => {
+      if (e instanceof OS.File.Error && e.becauseExists)
+        return;
+      ERROR("Failed to create staging directory", e);
+      throw e;
+    });
+  },
+
+  releaseStagingDir: function() {
+    this._stagingDirLock--;
+
+    if (this._stagingDirLock == 0) {
+      this._stagingDirPromise = null;
+      this.cleanStagingDir();
+    }
+
+    return Promise.resolve();
+  },
+
+  /**
+   * Removes the specified files or directories in the staging directory and
+   * then if the staging directory is empty attempts to remove it.
+   *
+   * @param  aLeafNames
+   *         An array of file or directory to remove from the directory, the
+   *         array may be empty
+   */
+  cleanStagingDir: function(aLeafNames = []) {
+    let dir = this.getStagingDir();
+
+    for (let name of aLeafNames) {
+      let file = dir.clone();
+      file.append(name);
+      recursiveRemove(file);
+    }
+
+    if (this.stagingDirLock > 0)
+      return;
+
+    let dirEntries = dir.directoryEntries.QueryInterface(Ci.nsIDirectoryEnumerator);
+    try {
+      if (dirEntries.nextFile)
+        return;
+    }
+    finally {
+      dirEntries.close();
+    }
+
+    try {
+      setFilePermissions(dir, FileUtils.PERMS_DIRECTORY);
+      dir.remove(false);
+    }
+    catch (e) {
+      WARN("Failed to remove staging dir", e);
+      // Failing to remove the staging directory is ignorable
+    }
+  },
+
   /**
    * Gets the directory used by old versions for staging XPI and JAR files ready
    * to be installed.
    *
    * @return an nsIFile
    */
   getXPIStagingDir: function DirInstallLocation_getXPIStagingDir() {
     let dir = this._directory.clone();
--- a/toolkit/mozapps/extensions/test/xpcshell/test_bootstrap.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_bootstrap.js
@@ -8,16 +8,17 @@ const ADDON_ENABLE                    = 
 const ADDON_DISABLE                   = 4;
 const ADDON_INSTALL                   = 5;
 const ADDON_UNINSTALL                 = 6;
 const ADDON_UPGRADE                   = 7;
 const ADDON_DOWNGRADE                 = 8;
 
 // This verifies that bootstrappable add-ons can be used without restarts.
 Components.utils.import("resource://gre/modules/Services.jsm");
+Components.utils.import("resource://gre/modules/Promise.jsm");
 
 // Enable loading extensions from the user scopes
 Services.prefs.setIntPref("extensions.enabledScopes",
                           AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_USER);
 
 createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
 
 const profileDir = gProfD.clone();
@@ -53,16 +54,34 @@ function waitForPref(aPref, aCallback) {
   function prefChanged() {
     Services.prefs.removeObserver(aPref, prefChanged);
     // Always let whoever set the preference keep running
     do_execute_soon(aCallback);
   }
   Services.prefs.addObserver(aPref, prefChanged, false);
 }
 
+function promisePref(aPref) {
+  let deferred = Promise.defer();
+
+  waitForPref(aPref, deferred.resolve.bind(deferred));
+
+  return deferred.promise;
+}
+
+function promiseInstall(aFiles) {
+  let deferred = Promise.defer();
+
+  installAllFiles(aFiles, function() {
+    deferred.resolve();
+  });
+
+  return deferred.promise;
+}
+
 function getActiveVersion() {
   return Services.prefs.getIntPref("bootstraptest.active_version");
 }
 
 function getInstalledVersion() {
   return Services.prefs.getIntPref("bootstraptest.installed_version");
 }
 
@@ -1219,17 +1238,20 @@ function check_test_23() {
     });
   });
 }
 
 // Tests that we recover from a broken preference
 function run_test_24() {
   resetPrefs();
   do_print("starting 24");
-  waitForPref("bootstraptest2.active_version", function test_24_pref() {
+
+  Promise.all([promisePref("bootstraptest2.active_version"),
+              promiseInstall([do_get_addon("test_bootstrap1_1"), do_get_addon("test_bootstrap2_1")])])
+         .then(function test_24_pref() {
     do_print("test 24 got prefs");
     do_check_eq(getInstalledVersion(), 1);
     do_check_eq(getActiveVersion(), 1);
     do_check_eq(getInstalledVersion2(), 1);
     do_check_eq(getActiveVersion2(), 1);
 
     resetPrefs();
 
@@ -1256,21 +1278,16 @@ function run_test_24() {
 
     do_check_eq(getInstalledVersion(), -1);
     do_check_eq(getActiveVersion(), 1);
     do_check_eq(getInstalledVersion2(), -1);
     do_check_eq(getActiveVersion2(), 1);
 
     run_test_25();
   });
-
-  installAllFiles([do_get_addon("test_bootstrap1_1"), do_get_addon("test_bootstrap2_1")],
-                  function test_24_installed() {
-    do_print("test 24 installed");
-  });
 }
 
 // Tests that updating from a bootstrappable add-on to a normal add-on calls
 // the uninstall method
 function run_test_25() {
   waitForPref("bootstraptest.startup_reason", function test_25_after_pref() {
       do_print("test 25 pref change detected");
       do_check_eq(getInstalledVersion(), 1);