Bug 1309350: Part 2 - Speed up synchronous resolution of module paths. r=ochameau r?gps draft
authorKris Maglione <maglione.k@gmail.com>
Fri, 14 Oct 2016 05:27:15 +0100
changeset 427679 f6b8e5a4a870dc0c6aa32fb76793d32586a6091f
parent 425750 fbf07a31a44fcbcba9d15114e87e6a2ae86fd9a5
child 534537 29461409bb949bdeb1c9ac856d7409899760b05f
push id33093
push usermaglione.k@gmail.com
push dateThu, 20 Oct 2016 19:30:57 +0000
reviewersochameau, gps
bugs1309350
milestone52.0a1
Bug 1309350: Part 2 - Speed up synchronous resolution of module paths. r=ochameau r?gps r?gps for the build changes and ochameau for the rest. This results in about a 28% speed-up for Jetpack mochitest runs, for me. MozReview-Commit-ID: K30q7BfvTLs
addon-sdk/moz.build
addon-sdk/source/lib/toolkit/loader.js
addon-sdk/source/test/fixtures/create_xpi.py
addon-sdk/source/test/fixtures/moz.build
addon-sdk/source/test/jetpack-package.ini
addon-sdk/source/test/test-loader.js
addon-sdk/source/test/test-native-loader.js
python/mozbuild/mozbuild/util.py
--- a/addon-sdk/moz.build
+++ b/addon-sdk/moz.build
@@ -1,27 +1,24 @@
-# AUTOMATICALLY GENERATED FROM mozbuild.template AND mach.  DO NOT EDIT.
-# 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/.
-
 # -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # 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/.
 
 # Makefile.in uses a misc target through test_addons_TARGET.
 HAS_MISC_RULE = True
 
 BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
 JETPACK_PACKAGE_MANIFESTS += ['source/test/jetpack-package.ini',
                               'source/test/leak/jetpack-package.ini']
 JETPACK_ADDON_MANIFESTS += ['source/test/addons/jetpack-addon.ini']
 
+DIRS += ['source/test/fixtures']
+
 addons = [
     'addon-manager',
     'author-email',
     'child_process',
     'chrome',
     'content-permissions',
     'content-script-messages-latency',
     'contributors',
--- a/addon-sdk/source/lib/toolkit/loader.js
+++ b/addon-sdk/source/lib/toolkit/loader.js
@@ -23,22 +23,29 @@ module.metadata = {
   "stability": "unstable"
 };
 
 const { classes: Cc, Constructor: CC, interfaces: Ci, utils: Cu,
         results: Cr, manager: Cm } = Components;
 const systemPrincipal = CC('@mozilla.org/systemprincipal;1', 'nsIPrincipal')();
 const { loadSubScript } = Cc['@mozilla.org/moz/jssubscript-loader;1'].
                      getService(Ci.mozIJSSubScriptLoader);
-const { notifyObservers } = Cc['@mozilla.org/observer-service;1'].
+const { addObserver, notifyObservers } = Cc['@mozilla.org/observer-service;1'].
                         getService(Ci.nsIObserverService);
 const { XPCOMUtils } = Cu.import("resource://gre/modules/XPCOMUtils.jsm", {});
 const { NetUtil } = Cu.import("resource://gre/modules/NetUtil.jsm", {});
 const { join: pathJoin, normalize, dirname } = Cu.import("resource://gre/modules/osfile/ospath_unix.jsm");
 
+XPCOMUtils.defineLazyServiceGetter(this, "resProto",
+                                   "@mozilla.org/network/protocol;1?name=resource",
+                                   "nsIResProtocolHandler");
+XPCOMUtils.defineLazyServiceGetter(this, "zipCache",
+                                   "@mozilla.org/libjar/zip-reader-cache;1",
+                                   "nsIZipReaderCache");
+
 XPCOMUtils.defineLazyGetter(this, "XulApp", () => {
   let xulappURI = module.uri.replace("toolkit/loader.js",
                                      "sdk/system/xul-app.jsm");
   return Cu.import(xulappURI, {});
 });
 
 // Define some shortcuts.
 const bind = Function.call.bind(Function.bind);
@@ -197,24 +204,150 @@ function serializeStack(frames) {
            frame.fileName + ":" +
            frame.lineNumber + ":" +
            frame.columnNumber + "\n" +
            stack;
   }, "");
 }
 Loader.serializeStack = serializeStack;
 
+class DefaultMap extends Map {
+  constructor(createItem, items = undefined) {
+    super(items);
+
+    this.createItem = createItem;
+  }
+
+  get(key) {
+    if (!this.has(key)) {
+      this.set(key, this.createItem(key));
+    }
+
+    return super.get(key);
+  }
+}
+
+const urlCache = {
+  /**
+   * Returns a list of fully-qualified URLs for entries within the zip
+   * file at the given URI which are either directories or files with a
+   * .js or .json extension.
+   *
+   * @param {nsIJARURI} uri
+   * @param {string} baseURL
+   *        The original base URL, prior to resolution.
+   *
+   * @returns {Set<string>}
+   */
+  getZipFileContents(uri, baseURL) {
+    // Make sure the path has a trailing slash, and strip off the leading
+    // slash, so that we can easily check whether it is a path prefix.
+    let basePath = addTrailingSlash(uri.JAREntry).slice(1);
+    let file = uri.JARFile.QueryInterface(Ci.nsIFileURL).file;
+
+    let enumerator = zipCache.getZip(file).findEntries("(*.js|*.json|*/)");
+
+    let results = new Set();
+    for (let entry of XPCOMUtils.IterStringEnumerator(enumerator)) {
+      if (entry.startsWith(basePath)) {
+        let path = entry.slice(basePath.length);
+
+        results.add(baseURL + path);
+      }
+    }
+
+    return results;
+  },
+
+  zipContentsCache: new DefaultMap(baseURL => {
+    let uri = NetUtil.newURI(baseURL);
+
+    if (baseURL.startsWith("resource:")) {
+      uri = NetUtil.newURI(resProto.resolveURI(uri));
+    }
+
+    if (uri instanceof Ci.nsIJARURI) {
+      return urlCache.getZipFileContents(uri, baseURL);
+    }
+
+    return null;
+  }),
+
+  filesCache: new DefaultMap(url => {
+    try {
+      let uri = NetUtil.newURI(url).QueryInterface(Ci.nsIFileURL);
+
+      return uri.file.exists();
+    } catch (e) {
+      return false;
+    }
+  }),
+
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsISupportsWeakReference]),
+
+  observe() {
+    // Clear any module resolution caches when the startup cache is flushed,
+    // since it probably means we're loading new copies of extensions.
+    this.zipContentsCache.clear();
+    this.filesCache.clear();
+  },
+
+  /**
+   * Returns the base URL for the given URL, if one can be determined. For
+   * a resource: URL, this is the root of the resource package. For a jar:
+   * URL, it is the root of the JAR file. Otherwise, null is returned.
+   *
+   * @param {string} url
+   * @returns {string?}
+   */
+  getBaseURL(url) {
+    // By using simple string matching for the common case of resource: URLs
+    // backed by jar: URLs, we can avoid creating any nsIURI objects for the
+    // common case where the JAR contents are already cached.
+    if (url.startsWith("resource://")) {
+      return /^resource:\/\/[^\/]+\//.exec(url)[0];
+    }
+
+    let uri = NetUtil.newURI(url);
+    if (uri instanceof Ci.nsIJARURI) {
+      return `jar:${uri.JARFile.spec}!/`;
+    }
+
+    return null;
+  },
+
+  /**
+   * Returns true if the target of the given URL exists as a local file,
+   * or as an entry in a local zip file.
+   *
+   * @param {string} url
+   * @returns {boolean}
+   */
+  exists(url) {
+    if (!/\.(?:js|json)$/.test(url)) {
+      url = addTrailingSlash(url);
+    }
+
+    let baseURL = this.getBaseURL(url);
+    let scripts = baseURL && this.zipContentsCache.get(baseURL);
+    if (scripts) {
+      return scripts.has(url);
+    }
+
+    return this.filesCache.get(url);
+  },
+}
+addObserver(urlCache, "startupcache-invalidate", true);
+
 function readURI(uri) {
   let nsURI = NetUtil.newURI(uri);
   if (nsURI.scheme == "resource") {
     // Resolve to a real URI, this will catch any obvious bad paths without
     // logging assertions in debug builds, see bug 1135219
-    let proto = Cc["@mozilla.org/network/protocol;1?name=resource"].
-                getService(Ci.nsIResProtocolHandler);
-    uri = proto.resolveURI(nsURI);
+    uri = resProto.resolveURI(nsURI);
   }
 
   let stream = NetUtil.newChannel({
     uri: NetUtil.newURI(uri, 'UTF-8'),
     loadUsingSystemPrincipal: true}
   ).open2();
   let count = stream.available();
   let data = NetUtil.readInputStreamToString(stream, count, {
@@ -222,27 +355,25 @@ function readURI(uri) {
   });
 
   stream.close();
 
   return data;
 }
 
 // Combines all arguments into a resolved, normalized path
-function join(...paths) {
-  let joined = pathJoin(...paths);
-  let resolved = normalize(joined);
+function join(base, ...paths) {
+  // If this is an absolute URL, we need to normalize only the path portion,
+  // or we wind up stripping too many slashes and producing invalid URLs.
+  let match = /^((?:resource|file|chrome)\:\/\/[^\/]*|jar:[^!]+!)(.*)/.exec(base);
+  if (match) {
+    return match[1] + normalize(pathJoin(match[2], ...paths));
+  }
 
-  // OS.File `normalize` strips out any additional slashes breaking URIs like
-  // `resource://`, `resource:///`, `chrome://` or `file:///`, so we work
-  // around this putting back the slashes originally given, for such schemes.
-  let re = /^(resource|file|chrome)(\:\/{1,3})([^\/])/;
-  let matches = joined.match(re);
-
-  return resolved.replace(re, (...args) => args[1] + matches[2] + args[3]);
+  return normalize(pathJoin(base, ...paths));
 }
 Loader.join = join;
 
 // Function takes set of options and returns a JS sandbox. Function may be
 // passed set of options:
 //  - `name`: A string value which identifies the sandbox in about:memory. Will
 //    throw exception if omitted.
 // - `principal`: String URI or `nsIPrincipal` for the sandbox. Defaults to
@@ -454,141 +585,120 @@ const resolve = iced(function resolve(id
 
   return resolved;
 });
 Loader.resolve = resolve;
 
 // Attempts to load `path` and then `path.js`
 // Returns `path` with valid file, or `undefined` otherwise
 function resolveAsFile(path) {
-  let found;
+  // Append '.js' to path name unless it's another support filetype
+  path = normalizeExt(path);
+  if (urlCache.exists(path)) {
+    return path;
+  }
 
-  // As per node's loader spec,
-  // we first should try and load 'path' (with no extension)
-  // before trying 'path.js'. We will not support this feature
-  // due to performance, but may add it if necessary for adoption.
-  try {
-    // Append '.js' to path name unless it's another support filetype
-    path = normalizeExt(path);
-    readURI(path);
-    found = path;
-  } catch (e) {}
-
-  return found;
+  return null;
 }
 
 // Attempts to load `path/package.json`'s `main` entry,
 // followed by `path/index.js`, or `undefined` otherwise
 function resolveAsDirectory(path) {
   try {
     // If `path/package.json` exists, parse the `main` entry
     // and attempt to load that
-    let main = getManifestMain(JSON.parse(readURI(path + '/package.json')));
-    if (main != null) {
-      let tmpPath = join(path, main);
-      let found = resolveAsFile(tmpPath);
-      if (found)
+    let manifestPath = addTrailingSlash(path) + 'package.json';
+
+    let main = (urlCache.exists(manifestPath) &&
+                getManifestMain(JSON.parse(readURI(manifestPath))));
+    if (main) {
+      let found = resolveAsFile(join(path, main));
+      if (found) {
         return found
+      }
     }
   } catch (e) {}
 
-  try {
-    let tmpPath = path + '/index.js';
-    readURI(tmpPath);
-    return tmpPath;
-  } catch (e) {}
-
-  return null;
+  return resolveAsFile(addTrailingSlash(path) + 'index.js');
 }
 
 function resolveRelative(rootURI, modulesDir, id) {
   let fullId = join(rootURI, modulesDir, id);
-  let resolvedPath;
 
-  if ((resolvedPath = resolveAsFile(fullId)))
+  let resolvedPath = (resolveAsFile(fullId) ||
+                      resolveAsDirectory(fullId));
+  if (resolvedPath) {
     return stripBase(rootURI, resolvedPath);
-
-  if ((resolvedPath = resolveAsDirectory(fullId)))
-    return stripBase(rootURI, resolvedPath);
+  }
 
   return null;
 }
 
 // From `resolve` module
 // https://github.com/substack/node-resolve/blob/master/lib/node-modules-paths.js
-function* getNodeModulePaths(start) {
-  // Configurable in node -- do we need this to be configurable?
+function* getNodeModulePaths(rootURI, start) {
   let moduleDir = 'node_modules';
 
   let parts = start.split('/');
   while (parts.length) {
     let leaf = parts.pop();
-    if (leaf !== moduleDir)
-      yield join(...parts, leaf, moduleDir);
+    let path = join(...parts, leaf, moduleDir);
+    if (leaf !== moduleDir && urlCache.exists(join(rootURI, path))) {
+      yield path;
+    }
   }
 
-  yield moduleDir;
+  if (urlCache.exists(join(rootURI, moduleDir))) {
+    yield moduleDir;
+  }
 }
 
 // Node-style module lookup
 // Takes an id and path and attempts to load a file using node's resolving
 // algorithm.
 // `id` should already be resolved relatively at this point.
 // http://nodejs.org/api/modules.html#modules_all_together
 const nodeResolve = iced(function nodeResolve(id, requirer, { rootURI }) {
   // Resolve again
   id = Loader.resolve(id, requirer);
 
   // If this is already an absolute URI then there is no resolution to do
-  if (isAbsoluteURI(id))
+  if (isAbsoluteURI(id)) {
     return null;
+  }
 
   // we assume that extensions are correct, i.e., a directory doesnt't have '.js'
   // and a js file isn't named 'file.json.js'
   let resolvedPath;
 
-  if ((resolvedPath = resolveRelative(rootURI, "", id)))
+  if ((resolvedPath = resolveRelative(rootURI, "", id))) {
     return resolvedPath;
+  }
 
   // If the requirer is an absolute URI then the node module resolution below
   // won't work correctly as we prefix everything with rootURI
-  if (isAbsoluteURI(requirer))
+  if (isAbsoluteURI(requirer)) {
     return null;
+  }
 
   // If manifest has dependencies, attempt to look up node modules
   // in the `dependencies` list
-  for (let modulesDir of getNodeModulePaths(dirname(requirer))) {
-    if ((resolvedPath = resolveRelative(rootURI, modulesDir, id)))
+  for (let modulesDir of getNodeModulePaths(rootURI, dirname(requirer))) {
+    if ((resolvedPath = resolveRelative(rootURI, modulesDir, id))) {
       return resolvedPath;
+    }
   }
 
   // We would not find lookup for things like `sdk/tabs`, as that's part of
   // the alias mapping. If during `generateMap`, the runtime lookup resolves
   // with `resolveURI` -- if during runtime, then `resolve` will throw.
   return null;
 });
 
-// String (`${rootURI}:${requirer}:${id}`) -> resolvedPath
-Loader.nodeResolverCache = new Map();
-
-const nodeResolveWithCache = iced(function cacheNodeResolutions(id, requirer, { rootURI }) {
-  // Compute the cache key based on current arguments.
-  let cacheKey = `${rootURI || ""}:${requirer}:${id}`;
-
-  // Try to get the result from the cache.
-  if (Loader.nodeResolverCache.has(cacheKey)) {
-    return Loader.nodeResolverCache.get(cacheKey);
-  }
-
-  // Resolve and cache if it is not in the cache yet.
-  let result = nodeResolve(id, requirer, { rootURI });
-  Loader.nodeResolverCache.set(cacheKey, result);
-  return result;
-});
-Loader.nodeResolve = nodeResolveWithCache;
+Loader.nodeResolve = nodeResolve;
 
 function addTrailingSlash(path) {
   return path.replace(/\/*$/, "/");
 }
 
 const resolveURI = iced(function resolveURI(id, mapping) {
   // Do not resolve if already a resource URI
   if (isAbsoluteURI(id))
@@ -810,19 +920,16 @@ const Module = iced(function Module(id, 
     uri: { value: uri }
   });
 });
 Loader.Module = Module;
 
 // Takes `loader`, and unload `reason` string and notifies all observers that
 // they should cleanup after them-self.
 const unload = iced(function unload(loader, reason) {
-  // Clear the nodeResolverCache when the loader is unloaded.
-  Loader.nodeResolverCache.clear();
-
   // subject is a unique object created per loader instance.
   // This allows any code to cleanup on loader unload regardless of how
   // it was loaded. To handle unload for specific loader subject may be
   // asserted against loader.destructor or require('@loader/unload')
   // Note: We don not destroy loader's module cache or sandboxes map as
   // some modules may do cleanup in subsequent turns of event loop. Destroying
   // cache may cause module identity problems in such cases.
   let subject = { wrappedJSObject: loader.destructor };
new file mode 100644
--- /dev/null
+++ b/addon-sdk/source/test/fixtures/create_xpi.py
@@ -0,0 +1,15 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+from os.path import abspath
+
+# TODO replace this script with a direct Python action invocation
+from mozbuild.action.zip import main as create_zip
+
+def main(output, input_dir):
+    output.close()
+
+    return create_zip(['-C', input_dir, abspath(output.name), '**'])
new file mode 100644
--- /dev/null
+++ b/addon-sdk/source/test/fixtures/moz.build
@@ -0,0 +1,22 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+fixtures = [
+    'native-addon-test',
+    'native-overrides-test',
+]
+
+output_dir = OBJDIR_FILES._tests.testing.mochitest['jetpack-package']['addon-sdk'].source.test.fixtures
+
+for fixture in fixtures:
+    xpi = '%s.xpi' % fixture
+
+    GENERATED_FILES += [xpi]
+    f = GENERATED_FILES[xpi]
+    f.script = 'create_xpi.py'
+    f.inputs = [fixture]
+
+    output_dir += ['!%s' % xpi]
--- a/addon-sdk/source/test/jetpack-package.ini
+++ b/addon-sdk/source/test/jetpack-package.ini
@@ -1,32 +1,37 @@
 [DEFAULT]
 support-files =
   buffers/**
   commonjs-test-adapter/**
   context-menu/**
   event/**
   fixtures.js
   fixtures/**
+  fixtures/native-addon-test.xpi
+  fixtures/native-overrides-test.xpi
   framescript-manager/**
   framescript-util/**
   lib/**
   loader/**
   modules/**
   page-mod/**
   path/**
   private-browsing/**
   querystring/**
   sidebar/**
   tabs/**
   test-context-menu.html
   traits/**
   util.js
   windows/**
   zip/**
+generated-files =
+  fixtures/native-addon-test.xpi
+  fixtures/native-overrides-test.xpi
 
 [test-addon-bootstrap.js]
 [test-addon-extras.js]
 [test-addon-installer.js]
 [test-addon-window.js]
 [test-api-utils.js]
 [test-array.js]
 [test-base64.js]
--- a/addon-sdk/source/test/test-loader.js
+++ b/addon-sdk/source/test/test-loader.js
@@ -30,18 +30,18 @@ exports['test resolve'] = function (asse
   assert.equal(resolve('./utils/file.js', './'), './utils/file.js');
   assert.equal(resolve('./utils/file.js', './index.js'), './utils/file.js');
 
   assert.equal(resolve('../utils/./file.js', cuddlefish_id), 'sdk/utils/file.js');
   assert.equal(resolve('../utils/file.js', cuddlefish_id), 'sdk/utils/file.js');
   assert.equal(resolve('./utils/file.js', cuddlefish_id), 'sdk/loader/utils/file.js');
 
   assert.equal(resolve('..//index.js', './dir/c.js'), './index.js');
-  assert.equal(resolve('../../gre/modules/XPCOMUtils.jsm', 'resource://thing/utils/file.js'), 'resource://gre/modules/XPCOMUtils.jsm');
-  assert.equal(resolve('../../gre/modules/XPCOMUtils.jsm', 'chrome://thing/utils/file.js'), 'chrome://gre/modules/XPCOMUtils.jsm');
+  assert.equal(resolve('../modules/XPCOMUtils.jsm', 'resource://gre/utils/file.js'), 'resource://gre/modules/XPCOMUtils.jsm');
+  assert.equal(resolve('../modules/XPCOMUtils.jsm', 'chrome://gre/utils/file.js'), 'chrome://gre/modules/XPCOMUtils.jsm');
   assert.equal(resolve('../../a/b/c.json', 'file:///thing/utils/file.js'), 'file:///a/b/c.json');
 
   // Does not change absolute paths
   assert.equal(resolve('resource://gre/modules/file.js', './dir/b.js'),
     'resource://gre/modules/file.js');
   assert.equal(resolve('file:///gre/modules/file.js', './dir/b.js'),
     'file:///gre/modules/file.js');
   assert.equal(resolve('/root.js', './dir/b.js'),
--- a/addon-sdk/source/test/test-native-loader.js
+++ b/addon-sdk/source/test/test-native-loader.js
@@ -3,77 +3,318 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 'use strict';
 
 var {
   Loader, main, unload, parseStack, resolve, nodeResolve
 } = require('toolkit/loader');
 var { readURI } = require('sdk/net/url');
 var { all } = require('sdk/core/promise');
+var { before, after } = require('sdk/test/utils');
 var testOptions = require('@test/options');
 
 var root = module.uri.substr(0, module.uri.lastIndexOf('/'))
 // The following adds Debugger constructor to the global namespace.
-const { Cu } = require('chrome');
+const { Cc, Ci, Cu } = require('chrome');
 const { addDebuggerToGlobal } = Cu.import('resource://gre/modules/jsdebugger.jsm', {});
 addDebuggerToGlobal(this);
 
+const { NetUtil } = Cu.import('resource://gre/modules/NetUtil.jsm', {});
 
-exports['test nodeResolve'] = function (assert) {
-  let rootURI = root + '/fixtures/native-addon-test/';
-  let manifest = {};
-  manifest.dependencies = {};
+const resProto = Cc["@mozilla.org/network/protocol;1?name=resource"]
+        .getService(Ci.nsIResProtocolHandler);
+
+const fileRoot = resProto.resolveURI(NetUtil.newURI(root));
+
+let variants = [
+  {
+    description: "unpacked resource:",
+    getRootURI(fixture) {
+      return `${root}/fixtures/${fixture}/`;
+    },
+  },
+  {
+    description: "unpacked file:",
+    getRootURI(fixture) {
+      return `${fileRoot}/fixtures/${fixture}/`;
+    },
+  },
+  {
+    description: "packed resource:",
+    getRootURI(fixture) {
+      return `resource://${fixture}/`;
+    },
+  },
+  {
+    description: "packed jar:",
+    getRootURI(fixture) {
+      return `jar:${fileRoot}/fixtures/${fixture}.xpi!/`;
+    },
+  },
+];
 
-  // Handles extensions
-  resolveTest('../package.json', './dir/c.js', './package.json');
-  resolveTest('../dir/b.js', './dir/c.js', './dir/b.js');
+let fixtures = [
+  'native-addon-test',
+  'native-overrides-test',
+];
+
+for (let variant of variants) {
+  exports[`test nodeResolve (${variant.description})`] = function (assert) {
+    let rootURI = variant.getRootURI('native-addon-test');
+    let manifest = {};
+    manifest.dependencies = {};
+
+    // Handles extensions
+    resolveTest('../package.json', './dir/c.js', './package.json');
+    resolveTest('../dir/b.js', './dir/c.js', './dir/b.js');
+
+    resolveTest('./dir/b', './index.js', './dir/b.js');
+    resolveTest('../index', './dir/b.js', './index.js');
+    resolveTest('../', './dir/b.js', './index.js');
+    resolveTest('./dir/a', './index.js', './dir/a.js', 'Precedence dir/a.js over dir/a/');
+    resolveTest('../utils', './dir/a.js', './utils/index.js', 'Requiring a directory defaults to dir/index.js');
+    resolveTest('../newmodule', './dir/c.js', './newmodule/lib/file.js', 'Uses package.json main in dir to load appropriate "main"');
+    resolveTest('test-math', './utils/index.js', './node_modules/test-math/index.js',
+      'Dependencies default to their index.js');
+    resolveTest('test-custom-main', './utils/index.js', './node_modules/test-custom-main/lib/custom-entry.js',
+      'Dependencies use "main" entry');
+    resolveTest('test-math/lib/sqrt', './utils/index.js', './node_modules/test-math/lib/sqrt.js',
+      'Dependencies\' files can be consumed via "/"');
+
+    resolveTest('sdk/tabs/utils', './index.js', undefined,
+      'correctly ignores SDK references in paths');
+    resolveTest('fs', './index.js', undefined,
+      'correctly ignores built in node modules in paths');
 
-  resolveTest('./dir/b', './index.js', './dir/b.js');
-  resolveTest('../index', './dir/b.js', './index.js');
-  resolveTest('../', './dir/b.js', './index.js');
-  resolveTest('./dir/a', './index.js', './dir/a.js', 'Precedence dir/a.js over dir/a/');
-  resolveTest('../utils', './dir/a.js', './utils/index.js', 'Requiring a directory defaults to dir/index.js');
-  resolveTest('../newmodule', './dir/c.js', './newmodule/lib/file.js', 'Uses package.json main in dir to load appropriate "main"');
-  resolveTest('test-math', './utils/index.js', './node_modules/test-math/index.js',
-    'Dependencies default to their index.js');
-  resolveTest('test-custom-main', './utils/index.js', './node_modules/test-custom-main/lib/custom-entry.js',
-    'Dependencies use "main" entry');
-  resolveTest('test-math/lib/sqrt', './utils/index.js', './node_modules/test-math/lib/sqrt.js',
-    'Dependencies\' files can be consumed via "/"');
+    resolveTest('test-add', './node_modules/test-math/index.js',
+      './node_modules/test-math/node_modules/test-add/index.js',
+      'Dependencies\' dependencies can be found');
+
+    resolveTest('resource://gre/modules/commonjs/sdk/tabs.js', './index.js', undefined,
+                'correctly ignores absolute URIs.');
+
+    resolveTest('../tabs', 'resource://gre/modules/commonjs/sdk/addon/bootstrap.js', undefined,
+                'correctly ignores attempts to resolve from a module at an absolute URI.');
+
+    resolveTest('sdk/tabs', 'resource://gre/modules/commonjs/sdk/addon/bootstrap.js', undefined,
+                'correctly ignores attempts to resolve from a module at an absolute URI.');
+
+    function resolveTest (id, requirer, expected, msg) {
+      let result = nodeResolve(id, requirer, { manifest: manifest, rootURI: rootURI });
+      assert.equal(result, expected, 'nodeResolve ' + id + ' from ' + requirer + ' ' +msg);
+    }
+  }
+
+  /*
+  // TODO not working in current env
+  exports[`test bundle (${variant.description`] = function (assert, done) {
+    loadAddon('/native-addons/native-addon-test/')
+  };
+  */
+
+  exports[`test native Loader with mappings (${variant.description})`] = function (assert, done) {
+    all([
+      getJSON('/fixtures/native-addon-test/expectedmap.json'),
+      getJSON('/fixtures/native-addon-test/package.json')
+    ]).then(([expectedMap, manifest]) => {
+
+      // Override dummy module and point it to `test-math` to see if the
+      // require is pulling from the mapping
+      expectedMap['./index.js']['./dir/dummy'] = './dir/a.js';
+
+      let rootURI = variant.getRootURI('native-addon-test');
+      let loader = Loader({
+        paths: makePaths(rootURI),
+        rootURI: rootURI,
+        manifest: manifest,
+        requireMap: expectedMap,
+        isNative: true
+      });
+
+      let program = main(loader);
+      assert.equal(program.dummyModule, 'dir/a',
+        'The lookup uses the information given in the mapping');
+
+      testLoader(program, assert);
+      unload(loader);
+      done();
+    }).then(null, (reason) => console.error(reason));
+  };
+
+  exports[`test native Loader overrides (${variant.description})`] = function*(assert) {
+    const expectedKeys = Object.keys(require("sdk/io/fs")).join(", ");
+    const manifest = yield getJSON('/fixtures/native-overrides-test/package.json');
+    const rootURI = variant.getRootURI('native-overrides-test');
+
+    let loader = Loader({
+      paths: makePaths(rootURI),
+      rootURI: rootURI,
+      manifest: manifest,
+      metadata: manifest,
+      isNative: true
+    });
 
-  resolveTest('sdk/tabs/utils', './index.js', undefined,
-    'correctly ignores SDK references in paths');
-  resolveTest('fs', './index.js', undefined,
-    'correctly ignores built in node modules in paths');
+    let program = main(loader);
+    let fooKeys = Object.keys(program.foo).join(", ");
+    let barKeys = Object.keys(program.foo).join(", ");
+    let fsKeys = Object.keys(program.fs).join(", ");
+    let overloadKeys = Object.keys(program.overload.fs).join(", ");
+    let overloadLibKeys = Object.keys(program.overloadLib.fs).join(", ");
+
+    assert.equal(fooKeys, expectedKeys, "foo exports sdk/io/fs");
+    assert.equal(barKeys, expectedKeys, "bar exports sdk/io/fs");
+    assert.equal(fsKeys, expectedKeys, "sdk/io/fs exports sdk/io/fs");
+    assert.equal(overloadKeys, expectedKeys, "overload exports foo which exports sdk/io/fs");
+    assert.equal(overloadLibKeys, expectedKeys, "overload/lib/foo exports foo/lib/foo");
+    assert.equal(program.internal, "test", "internal exports ./lib/internal");
+    assert.equal(program.extra, true, "fs-extra was exported properly");
+
+    assert.equal(program.Tabs, "no tabs exist", "sdk/tabs exports ./lib/tabs from the add-on");
+    assert.equal(program.CoolTabs, "no tabs exist", "sdk/tabs exports ./lib/tabs from the node_modules");
+    assert.equal(program.CoolTabsLib, "a cool tabs implementation", "./lib/tabs true relative path from the node_modules");
+
+    assert.equal(program.ignore, "do not ignore this export", "../ignore override was ignored.");
+
+    unload(loader);
+  };
+
+  exports[`test invalid native Loader overrides cause no errors (${variant.description})`] = function*(assert) {
+    const manifest = yield getJSON('/fixtures/native-overrides-test/package.json');
+    const rootURI = variant.getRootURI('native-overrides-test');
+    const EXPECTED = JSON.stringify({});
 
-  resolveTest('test-add', './node_modules/test-math/index.js',
-    './node_modules/test-math/node_modules/test-add/index.js',
-    'Dependencies\' dependencies can be found');
+    let makeLoader = (rootURI, manifest) => Loader({
+      paths: makePaths(rootURI),
+      rootURI: rootURI,
+      manifest: manifest,
+      metadata: manifest,
+      isNative: true
+    });
+
+    manifest.jetpack.overrides = "string";
+    let loader = makeLoader(rootURI, manifest);
+    assert.equal(JSON.stringify(loader.manifest.jetpack.overrides), EXPECTED,
+                 "setting jetpack.overrides to a string caused no errors making the loader");
+    unload(loader);
+
+    manifest.jetpack.overrides = true;
+    loader = makeLoader(rootURI, manifest);
+    assert.equal(JSON.stringify(loader.manifest.jetpack.overrides), EXPECTED,
+                 "setting jetpack.overrides to a boolean caused no errors making the loader");
+    unload(loader);
+
+    manifest.jetpack.overrides = 5;
+    loader = makeLoader(rootURI, manifest);
+    assert.equal(JSON.stringify(loader.manifest.jetpack.overrides), EXPECTED,
+                 "setting jetpack.overrides to a number caused no errors making the loader");
+    unload(loader);
+
+    manifest.jetpack.overrides = null;
+    loader = makeLoader(rootURI, manifest);
+    assert.equal(JSON.stringify(loader.manifest.jetpack.overrides), EXPECTED,
+                 "setting jetpack.overrides to null caused no errors making the loader");
+    unload(loader);
+  };
+
+  exports[`test invalid native Loader jetpack key cause no errors (${variant.description})`] = function*(assert) {
+    const manifest = yield getJSON('/fixtures/native-overrides-test/package.json');
+    const rootURI = variant.getRootURI('native-overrides-test');
+    const EXPECTED = JSON.stringify({});
 
-  resolveTest('resource://gre/modules/commonjs/sdk/tabs.js', './index.js', undefined,
-              'correctly ignores absolute URIs.');
+    let makeLoader = (rootURI, manifest) => Loader({
+      paths: makePaths(rootURI),
+      rootURI: rootURI,
+      manifest: manifest,
+      metadata: manifest,
+      isNative: true
+    });
+
+    manifest.jetpack = "string";
+    let loader = makeLoader(rootURI, manifest);
+    assert.equal(JSON.stringify(loader.manifest.jetpack.overrides), EXPECTED,
+                 "setting jetpack.overrides to a string caused no errors making the loader");
+    unload(loader);
 
-  resolveTest('../tabs', 'resource://gre/modules/commonjs/sdk/addon/bootstrap.js', undefined,
-              'correctly ignores attempts to resolve from a module at an absolute URI.');
+    manifest.jetpack = true;
+    loader = makeLoader(rootURI, manifest);
+    assert.equal(JSON.stringify(loader.manifest.jetpack.overrides), EXPECTED,
+                 "setting jetpack.overrides to a boolean caused no errors making the loader");
+    unload(loader);
+
+    manifest.jetpack = 5;
+    loader = makeLoader(rootURI, manifest);
+    assert.equal(JSON.stringify(loader.manifest.jetpack.overrides), EXPECTED,
+                 "setting jetpack.overrides to a number caused no errors making the loader");
+    unload(loader);
+
+    manifest.jetpack = null;
+    loader = makeLoader(rootURI, manifest);
+    assert.equal(JSON.stringify(loader.manifest.jetpack.overrides), EXPECTED,
+                 "setting jetpack.overrides to null caused no errors making the loader");
+    unload(loader);
+  };
 
-  resolveTest('sdk/tabs', 'resource://gre/modules/commonjs/sdk/addon/bootstrap.js', undefined,
-              'correctly ignores attempts to resolve from a module at an absolute URI.');
+  exports[`test native Loader without mappings (${variant.description})`] = function (assert, done) {
+    getJSON('/fixtures/native-addon-test/package.json').then(manifest => {
+      let rootURI = variant.getRootURI('native-addon-test');
+      let loader = Loader({
+        paths: makePaths(rootURI),
+        rootURI: rootURI,
+        manifest: manifest,
+        isNative: true
+      });
+
+      let program = main(loader);
+      testLoader(program, assert);
+      unload(loader);
+      done();
+    }).then(null, (reason) => console.error(reason));
+  };
 
-  function resolveTest (id, requirer, expected, msg) {
-    let result = nodeResolve(id, requirer, { manifest: manifest, rootURI: rootURI });
-    assert.equal(result, expected, 'nodeResolve ' + id + ' from ' + requirer + ' ' +msg);
-  }
+  exports[`test require#resolve with relative, dependencies (${variant.description})`] = function(assert, done) {
+    getJSON('/fixtures/native-addon-test/package.json').then(manifest => {
+      let rootURI = variant.getRootURI('native-addon-test');
+      let loader = Loader({
+        paths: makePaths(rootURI),
+        rootURI: rootURI,
+        manifest: manifest,
+        isNative: true
+      });
+
+      let program = main(loader);
+      let fixtureRoot = program.require.resolve("./").replace(/index\.js$/, "");
+
+      assert.equal(variant.getRootURI("native-addon-test"), fixtureRoot, "correct resolution root");
+      assert.equal(program.require.resolve("test-math"), fixtureRoot + "node_modules/test-math/index.js", "works with node_modules");
+      assert.equal(program.require.resolve("./newmodule"), fixtureRoot + "newmodule/lib/file.js", "works with directory mains");
+      assert.equal(program.require.resolve("./dir/a"), fixtureRoot + "dir/a.js", "works with normal relative module lookups");
+      assert.equal(program.require.resolve("modules/Promise.jsm"), "resource://gre/modules/Promise.jsm", "works with path lookups");
+
+      // TODO bug 1050422, handle loading non JS/JSM file paths
+      // assert.equal(program.require.resolve("test-assets/styles.css"), fixtureRoot + "node_modules/test-assets/styles.css",
+      // "works with different file extension lookups in dependencies");
+
+      unload(loader);
+      done();
+    }).then(null, (reason) => console.error(reason));
+  };
 }
 
-/*
-// TODO not working in current env
-exports['test bundle'] = function (assert, done) {
-  loadAddon('/native-addons/native-addon-test/')
-};
-*/
+before(exports, () => {
+  for (let fixture of fixtures) {
+    let url = `jar:${root}/fixtures/${fixture}.xpi!/`;
+
+    resProto.setSubstitution(fixture, NetUtil.newURI(url));
+  }
+});
+
+after(exports, () => {
+  for (let fixture of fixtures)
+    resProto.setSubstitution(fixture, null);
+});
 
 exports['test JSM loading'] = function (assert, done) {
   getJSON('/fixtures/jsm-package/package.json').then(manifest => {
     let rootURI = root + '/fixtures/jsm-package/';
     let loader = Loader({
       paths: makePaths(rootURI),
       rootURI: rootURI,
       manifest: manifest,
@@ -95,203 +336,16 @@ exports['test JSM loading'] = function (
       assert.equal(path, 10, 'JSM files resolved from path work');
       assert.equal(absolute, 20, 'JSM files resolved from full resource:// work');
       assert.equal(jsabsolute, 30, 'JS files resolved from full resource:// work');
     }).then(done, console.error);
 
   }).then(null, console.error);
 };
 
-exports['test native Loader with mappings'] = function (assert, done) {
-  all([
-    getJSON('/fixtures/native-addon-test/expectedmap.json'),
-    getJSON('/fixtures/native-addon-test/package.json')
-  ]).then(([expectedMap, manifest]) => {
-
-    // Override dummy module and point it to `test-math` to see if the
-    // require is pulling from the mapping
-    expectedMap['./index.js']['./dir/dummy'] = './dir/a.js';
-
-    let rootURI = root + '/fixtures/native-addon-test/';
-    let loader = Loader({
-      paths: makePaths(rootURI),
-      rootURI: rootURI,
-      manifest: manifest,
-      requireMap: expectedMap,
-      isNative: true
-    });
-
-    let program = main(loader);
-    assert.equal(program.dummyModule, 'dir/a',
-      'The lookup uses the information given in the mapping');
-
-    testLoader(program, assert);
-    unload(loader);
-    done();
-  }).then(null, (reason) => console.error(reason));
-};
-
-exports['test native Loader overrides'] = function*(assert) {
-  const expectedKeys = Object.keys(require("sdk/io/fs")).join(", ");
-  const manifest = yield getJSON('/fixtures/native-overrides-test/package.json');
-  const rootURI = root + '/fixtures/native-overrides-test/';
-
-  let loader = Loader({
-    paths: makePaths(rootURI),
-    rootURI: rootURI,
-    manifest: manifest,
-    metadata: manifest,
-    isNative: true
-  });
-
-  let program = main(loader);
-  let fooKeys = Object.keys(program.foo).join(", ");
-  let barKeys = Object.keys(program.foo).join(", ");
-  let fsKeys = Object.keys(program.fs).join(", ");
-  let overloadKeys = Object.keys(program.overload.fs).join(", ");
-  let overloadLibKeys = Object.keys(program.overloadLib.fs).join(", ");
-
-  assert.equal(fooKeys, expectedKeys, "foo exports sdk/io/fs");
-  assert.equal(barKeys, expectedKeys, "bar exports sdk/io/fs");
-  assert.equal(fsKeys, expectedKeys, "sdk/io/fs exports sdk/io/fs");
-  assert.equal(overloadKeys, expectedKeys, "overload exports foo which exports sdk/io/fs");
-  assert.equal(overloadLibKeys, expectedKeys, "overload/lib/foo exports foo/lib/foo");
-  assert.equal(program.internal, "test", "internal exports ./lib/internal");
-  assert.equal(program.extra, true, "fs-extra was exported properly");
-
-  assert.equal(program.Tabs, "no tabs exist", "sdk/tabs exports ./lib/tabs from the add-on");
-  assert.equal(program.CoolTabs, "no tabs exist", "sdk/tabs exports ./lib/tabs from the node_modules");
-  assert.equal(program.CoolTabsLib, "a cool tabs implementation", "./lib/tabs true relative path from the node_modules");
-
-  assert.equal(program.ignore, "do not ignore this export", "../ignore override was ignored.");
-
-  unload(loader);
-};
-
-exports['test invalid native Loader overrides cause no errors'] = function*(assert) {
-  const manifest = yield getJSON('/fixtures/native-overrides-test/package.json');
-  const rootURI = root + '/fixtures/native-overrides-test/';
-  const EXPECTED = JSON.stringify({});
-
-  let makeLoader = (rootURI, manifest) => Loader({
-    paths: makePaths(rootURI),
-    rootURI: rootURI,
-    manifest: manifest,
-    metadata: manifest,
-    isNative: true
-  });
-
-  manifest.jetpack.overrides = "string";
-  let loader = makeLoader(rootURI, manifest);
-  assert.equal(JSON.stringify(loader.manifest.jetpack.overrides), EXPECTED,
-               "setting jetpack.overrides to a string caused no errors making the loader");
-  unload(loader);
-
-  manifest.jetpack.overrides = true;
-  loader = makeLoader(rootURI, manifest);
-  assert.equal(JSON.stringify(loader.manifest.jetpack.overrides), EXPECTED,
-               "setting jetpack.overrides to a boolean caused no errors making the loader");
-  unload(loader);
-
-  manifest.jetpack.overrides = 5;
-  loader = makeLoader(rootURI, manifest);
-  assert.equal(JSON.stringify(loader.manifest.jetpack.overrides), EXPECTED,
-               "setting jetpack.overrides to a number caused no errors making the loader");
-  unload(loader);
-
-  manifest.jetpack.overrides = null;
-  loader = makeLoader(rootURI, manifest);
-  assert.equal(JSON.stringify(loader.manifest.jetpack.overrides), EXPECTED,
-               "setting jetpack.overrides to null caused no errors making the loader");
-  unload(loader);
-};
-
-exports['test invalid native Loader jetpack key cause no errors'] = function*(assert) {
-  const manifest = yield getJSON('/fixtures/native-overrides-test/package.json');
-  const rootURI = root + '/fixtures/native-overrides-test/';
-  const EXPECTED = JSON.stringify({});
-
-  let makeLoader = (rootURI, manifest) => Loader({
-    paths: makePaths(rootURI),
-    rootURI: rootURI,
-    manifest: manifest,
-    metadata: manifest,
-    isNative: true
-  });
-
-  manifest.jetpack = "string";
-  let loader = makeLoader(rootURI, manifest);
-  assert.equal(JSON.stringify(loader.manifest.jetpack.overrides), EXPECTED,
-               "setting jetpack.overrides to a string caused no errors making the loader");
-  unload(loader);
-
-  manifest.jetpack = true;
-  loader = makeLoader(rootURI, manifest);
-  assert.equal(JSON.stringify(loader.manifest.jetpack.overrides), EXPECTED,
-               "setting jetpack.overrides to a boolean caused no errors making the loader");
-  unload(loader);
-
-  manifest.jetpack = 5;
-  loader = makeLoader(rootURI, manifest);
-  assert.equal(JSON.stringify(loader.manifest.jetpack.overrides), EXPECTED,
-               "setting jetpack.overrides to a number caused no errors making the loader");
-  unload(loader);
-
-  manifest.jetpack = null;
-  loader = makeLoader(rootURI, manifest);
-  assert.equal(JSON.stringify(loader.manifest.jetpack.overrides), EXPECTED,
-               "setting jetpack.overrides to null caused no errors making the loader");
-  unload(loader);
-};
-
-exports['test native Loader without mappings'] = function (assert, done) {
-  getJSON('/fixtures/native-addon-test/package.json').then(manifest => {
-    let rootURI = root + '/fixtures/native-addon-test/';
-    let loader = Loader({
-      paths: makePaths(rootURI),
-      rootURI: rootURI,
-      manifest: manifest,
-      isNative: true
-    });
-
-    let program = main(loader);
-    testLoader(program, assert);
-    unload(loader);
-    done();
-  }).then(null, (reason) => console.error(reason));
-};
-
-exports["test require#resolve with relative, dependencies"] = function(assert, done) {
-  getJSON('/fixtures/native-addon-test/package.json').then(manifest => {
-    let rootURI = root + '/fixtures/native-addon-test/';
-    let loader = Loader({
-      paths: makePaths(rootURI),
-      rootURI: rootURI,
-      manifest: manifest,
-      isNative: true
-    });
-
-    let program = main(loader);
-    let fixtureRoot = program.require.resolve("./").replace(/native-addon-test\/(.*)/, "") + "native-addon-test/";
-
-    assert.equal(root + "/fixtures/native-addon-test/", fixtureRoot, "correct resolution root");
-    assert.equal(program.require.resolve("test-math"), fixtureRoot + "node_modules/test-math/index.js", "works with node_modules");
-    assert.equal(program.require.resolve("./newmodule"), fixtureRoot + "newmodule/lib/file.js", "works with directory mains");
-    assert.equal(program.require.resolve("./dir/a"), fixtureRoot + "dir/a.js", "works with normal relative module lookups");
-    assert.equal(program.require.resolve("modules/Promise.jsm"), "resource://gre/modules/Promise.jsm", "works with path lookups");
-
-    // TODO bug 1050422, handle loading non JS/JSM file paths
-    // assert.equal(program.require.resolve("test-assets/styles.css"), fixtureRoot + "node_modules/test-assets/styles.css",
-    // "works with different file extension lookups in dependencies");
-
-    unload(loader);
-    done();
-  }).then(null, (reason) => console.error(reason));
-};
-
 function testLoader (program, assert) {
   // Test 'main' entries
   // no relative custom main `lib/index.js`
   assert.equal(program.customMainModule, 'custom entry file',
     'a node_module dependency correctly uses its `main` entry in manifest');
   // relative custom main `./lib/index.js`
   assert.equal(program.customMainModuleRelative, 'custom entry file relative',
     'a node_module dependency correctly uses its `main` entry in manifest with relative ./');
--- a/python/mozbuild/mozbuild/util.py
+++ b/python/mozbuild/mozbuild/util.py
@@ -285,17 +285,18 @@ class FileAvoidWrite(BytesIO):
                 self.diff = ['Binary or non-ascii file changed: %s' %
                              self.name]
 
         return existed, True
 
     def __enter__(self):
         return self
     def __exit__(self, type, value, traceback):
-        self.close()
+        if not self.closed:
+            self.close()
 
 
 def resolve_target_to_make(topobjdir, target):
     r'''
     Resolve `target` (a target, directory, or file) to a make target.
 
     `topobjdir` is the object directory; all make targets will be
     rooted at or below the top-level Makefile in this directory.