Bug 562674: Make XPI extraction asynchronous. r=Unfocused

This commit is contained in:
Dave Townsend 2013-10-24 09:23:32 -07:00
parent 4a799e1347
commit a76e2ce55f
2 changed files with 290 additions and 62 deletions

View File

@ -27,6 +27,12 @@ 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",
@ -103,6 +109,9 @@ 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__;
@ -1110,6 +1119,140 @@ function getTemporaryFile() {
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.
*
@ -1244,42 +1387,43 @@ function escapeAddonURI(aAddon, aUri, aUpdateType, aAppVersion)
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);
});
let dirEntries = aDir.directoryEntries.QueryInterface(Ci.nsIDirectoryEnumerator);
try {
if (dirEntries.nextFile)
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;
}
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
}
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;
}
});
}
/**
@ -1300,6 +1444,8 @@ function recursiveRemove(aFile) {
// 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;
}
@ -2376,7 +2522,7 @@ var XPIProvider = {
}
try {
cleanStagingDir(stagingDir, seenFiles);
aLocation.cleanStagingDir(seenFiles);
}
catch (e) {
// Non-critical, just saves some perf on startup if we clean this up.
@ -4286,7 +4432,7 @@ var XPIProvider = {
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
@ -4607,9 +4753,8 @@ AddonInstall.prototype = {
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);
@ -5244,27 +5389,28 @@ AddonInstall.prototype = {
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) {
@ -5348,7 +5494,6 @@ AddonInstall.prototype = {
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;
@ -5399,9 +5544,8 @@ AddonInstall.prototype = {
}
}
}
}
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;
@ -5410,10 +5554,10 @@ AddonInstall.prototype = {
AddonManagerPrivate.callInstallListeners("onInstallFailed",
this.listeners,
this.wrapper);
}
finally {
}).then(() => {
this.removeTemporaryFile();
}
return this.installLocation.releaseStagingDir();
});
},
getInterface: function AI_getInterface(iid) {
@ -6516,6 +6660,7 @@ function DirectoryInstallLocation(aName, aDirectory, aScope, aLocked) {
this._IDToFileMap = {};
this._FileToIDMap = {};
this._linkedAddons = [];
this._stagingDirLock = 0;
if (!aDirectory.exists())
return;
@ -6663,6 +6808,72 @@ DirectoryInstallLocation.prototype = {
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.

View File

@ -13,6 +13,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",
@ -58,6 +59,24 @@ function waitForPref(aPref, 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");
}
@ -1224,7 +1243,10 @@ function check_test_23() {
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);
@ -1261,11 +1283,6 @@ function run_test_24() {
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