Bug 836437 - Part 2 of 3 - Add the ability to resume a download from where it stopped to DownloadCopySaver. r=enn

This commit is contained in:
Paolo Amadini 2013-08-01 14:37:02 +02:00
parent f275aa42e0
commit dd4f927547
5 changed files with 909 additions and 380 deletions

View File

@ -183,6 +183,19 @@ Download.prototype = {
*/
currentBytes: 0,
/**
* Indicates whether, at this time, there is any partially downloaded data
* that can be used when restarting a failed or canceled download.
*
* This property is relevant while the download is in progress, and also if it
* failed or has been canceled. If the download has been completed
* successfully, this property is not relevant anymore.
*
* Whether partial data can actually be retained depends on the saver and the
* download source, and may not be known before the download is started.
*/
hasPartialData: false,
/**
* This can be set to a function that is called after other properties change.
*/
@ -266,6 +279,13 @@ Download.prototype = {
return this._currentAttempt;
}
// While shutting down or disposing of this object, we prevent the download
// from returning to be in progress.
if (this._finalized) {
return Promise.reject(new DownloadError(Cr.NS_ERROR_FAILURE,
"Cannot start after finalization."));
}
// Initialize all the status properties for a new or restarted download.
this.stopped = false;
this.canceled = false;
@ -284,10 +304,10 @@ Download.prototype = {
// This function propagates progress from the DownloadSaver object, unless
// it comes in late from a download attempt that was replaced by a new one.
function DS_setProgressBytes(aCurrentBytes, aTotalBytes)
function DS_setProgressBytes(aCurrentBytes, aTotalBytes, aHasPartialData)
{
if (this._currentAttempt == currentAttempt || !this._currentAttempt) {
this._setBytes(aCurrentBytes, aTotalBytes);
this._setBytes(aCurrentBytes, aTotalBytes, aHasPartialData);
}
}
@ -315,10 +335,18 @@ Download.prototype = {
// Now that we stored the promise in the download object, we can start the
// task that will actually execute the download.
deferAttempt.resolve(Task.spawn(function task_D_start() {
// Wait upon any pending cancellation request.
// Wait upon any pending operation before restarting.
if (this._promiseCanceled) {
yield this._promiseCanceled;
}
if (this._promiseRemovePartialData) {
try {
yield this._promiseRemovePartialData;
} catch (ex) {
// Ignore any errors, which are already reported by the original
// caller of the removePartialData method.
}
}
// Disallow download if parental controls service restricts it.
if (yield DownloadIntegration.shouldBlockForParentalControls(this)) {
@ -473,6 +501,83 @@ Download.prototype = {
return this._promiseCanceled;
},
/**
* Indicates whether any partially downloaded data should be retained, to use
* when restarting a failed or canceled download. The default is false.
*
* Whether partial data can actually be retained depends on the saver and the
* download source, and may not be known before the download is started.
*
* To have any effect, this property must be set before starting the download.
* Resetting this property to false after the download has already started
* will not remove any partial data.
*
* If this property is set to true, care should be taken that partial data is
* removed before the reference to the download is discarded. This can be
* done using the removePartialData or the "finalize" methods.
*/
tryToKeepPartialData: false,
/**
* When a request to remove partially downloaded data is received, contains a
* promise that will be resolved when the removal request is processed. When
* the request is processed, this property becomes null again.
*/
_promiseRemovePartialData: null,
/**
* Removes any partial data kept as part of a canceled or failed download.
*
* If the download is not canceled or failed, this method has no effect, and
* it returns a resolved promise. If the "cancel" method was called but the
* cancellation process has not finished yet, this method waits for the
* cancellation to finish, then removes the partial data.
*
* After this method has been called, if the tryToKeepPartialData property is
* still true when the download is restarted, partial data will be retained
* during the new download attempt.
*
* @return {Promise}
* @resolves When the partial data has been successfully removed.
* @rejects JavaScript exception if the operation could not be completed.
*/
removePartialData: function ()
{
if (!this.canceled && !this.error) {
return Promise.resolve();
}
let promiseRemovePartialData = this._promiseRemovePartialData;
if (!promiseRemovePartialData) {
let deferRemovePartialData = Promise.defer();
promiseRemovePartialData = deferRemovePartialData.promise;
this._promiseRemovePartialData = promiseRemovePartialData;
deferRemovePartialData.resolve(
Task.spawn(function task_D_removePartialData() {
try {
// Wait upon any pending cancellation request.
if (this._promiseCanceled) {
yield this._promiseCanceled;
}
// Ask the saver object to remove any partial data.
yield this.saver.removePartialData();
// For completeness, clear the number of bytes transferred.
if (this.currentBytes != 0 || this.hasPartialData) {
this.currentBytes = 0;
this.hasPartialData = false;
this._notifyChange();
}
} finally {
this._promiseRemovePartialData = null;
}
}.bind(this)));
}
return promiseRemovePartialData;
},
/**
* This deferred object contains a promise that is resolved as soon as this
* download finishes successfully, and is never rejected. This property is
@ -498,6 +603,48 @@ Download.prototype = {
return this._deferSucceeded.promise;
},
/**
* True if the "finalize" method has been called. This prevents the download
* from starting again after having been stopped.
*/
_finalized: false,
/**
* Ensures that the download is stopped, and optionally removes any partial
* data kept as part of a canceled or failed download. After this method has
* been called, the download cannot be started again.
*
* This method should be used in place of "cancel" and removePartialData while
* shutting down or disposing of the download object, to prevent other callers
* from interfering with the operation. This is required because cancellation
* and other operations are asynchronous.
*
* @param aRemovePartialData
* Whether any partially downloaded data should be removed after the
* download has been stopped.
*
* @return {Promise}
* @resolves When the operation has finished successfully.
* @rejects JavaScript exception if an error occurred while removing the
* partially downloaded data.
*/
finalize: function (aRemovePartialData)
{
// Prevents the download from starting again after having been stopped.
this._finalized = true;
if (aRemovePartialData) {
// Cancel the download, in case it is currently in progress, then remove
// any partially downloaded data. The removal operation waits for
// cancellation to be completed before resolving the promise it returns.
this.cancel();
return this.removePartialData();
} else {
// Just cancel the download, in case it is currently in progress.
return this.cancel();
}
},
/**
* Updates progress notifications based on the number of bytes transferred.
*
@ -505,9 +652,13 @@ Download.prototype = {
* Number of bytes transferred until now.
* @param aTotalBytes
* Total number of bytes to be transferred, or -1 if unknown.
* @param aHasPartialData
* Indicates whether the partially downloaded data can be used when
* restarting the download if it fails or is canceled.
*/
_setBytes: function D_setBytes(aCurrentBytes, aTotalBytes) {
_setBytes: function D_setBytes(aCurrentBytes, aTotalBytes, aHasPartialData) {
this.currentBytes = aCurrentBytes;
this.hasPartialData = aHasPartialData;
if (aTotalBytes != -1) {
this.hasProgress = true;
this.totalBytes = aTotalBytes;
@ -680,11 +831,13 @@ DownloadSource.prototype = {
DownloadSource.fromSerializable = function (aSerializable) {
let source = new DownloadSource();
if (isString(aSerializable)) {
source.url = aSerializable;
// Convert String objects to primitive strings at this point.
source.url = aSerializable.toString();
} else if (aSerializable instanceof Ci.nsIURI) {
source.url = aSerializable.spec;
} else {
source.url = aSerializable.url;
// Convert String objects to primitive strings at this point.
source.url = aSerializable.url.toString();
if ("isPrivate" in aSerializable) {
source.isPrivate = aSerializable.isPrivate;
}
@ -710,6 +863,13 @@ DownloadTarget.prototype = {
*/
path: null,
/**
* String containing the path of the ".part" file containing the data
* downloaded so far, or null to disable the use of a ".part" file to keep
* partially downloaded data.
*/
partFilePath: null,
/**
* Returns a static representation of the current object state.
*
@ -717,8 +877,13 @@ DownloadTarget.prototype = {
*/
toSerializable: function ()
{
// Simplify the representation since we don't have other details for now.
// Simplify the representation if we don't have other details.
if (!this.partFilePath) {
return this.path;
}
return { path: this.path,
partFilePath: this.partFilePath };
},
};
@ -738,14 +903,18 @@ DownloadTarget.prototype = {
DownloadTarget.fromSerializable = function (aSerializable) {
let target = new DownloadTarget();
if (isString(aSerializable)) {
target.path = aSerializable;
// Convert String objects to primitive strings at this point.
target.path = aSerializable.toString();
} else if (aSerializable instanceof Ci.nsIFile) {
// Read the "path" property of nsIFile after checking the object type.
target.path = aSerializable.path;
} else {
// Read the "path" property of the serializable DownloadTarget
// representation.
target.path = aSerializable.path;
// representation, converting String objects to primitive strings.
target.path = aSerializable.path.toString();
if ("partFilePath" in aSerializable) {
target.partFilePath = aSerializable.partFilePath;
}
}
return target;
};
@ -787,6 +956,7 @@ function DownloadError(aResult, aMessage, aInferCause)
this.becauseSourceFailed = (module == NS_ERROR_MODULE_NETWORK);
this.becauseTargetFailed = (module == NS_ERROR_MODULE_FILES);
}
this.stack = new Error().stack;
}
DownloadError.prototype = {
@ -831,6 +1001,10 @@ function DownloadSaver() { }
DownloadSaver.prototype = {
/**
* Download object for raising notifications and reading properties.
*
* If the tryToKeepPartialData property of the download object is false, the
* saver should never try to keep partially downloaded data if the download
* fails.
*/
download: null,
@ -839,9 +1013,11 @@ DownloadSaver.prototype = {
*
* @param aSetProgressBytesFn
* This function may be called by the saver to report progress. It
* takes two arguments: the first is the number of bytes transferred
* takes three arguments: the first is the number of bytes transferred
* until now, the second is the total number of bytes to be
* transferred, or -1 if unknown.
* transferred (or -1 if unknown), the third indicates whether the
* partially downloaded data can be used when restarting the download
* if it fails or is canceled.
* @parem aSetPropertiesFn
* This function may be called by the saver to report information
* about new download properties discovered by the saver during the
@ -866,6 +1042,22 @@ DownloadSaver.prototype = {
throw new Error("Not implemented.");
},
/**
* Removes any partial data kept as part of a canceled or failed download.
*
* This method is never called until the promise returned by "execute" is
* either resolved or rejected, and the "execute" method is not called again
* until the promise returned by this method is resolved or rejected.
*
* @return {Promise}
* @resolves When the operation has finished successfully.
* @rejects JavaScript exception.
*/
removePartialData: function DS_removePartialData()
{
return Promise.resolve();
},
/**
* Returns a static representation of the current object state.
*
@ -920,13 +1112,54 @@ DownloadCopySaver.prototype = {
*/
_backgroundFileSaver: null,
/**
* Indicates whether the "cancel" method has been called. This is used to
* prevent the request from starting in case the operation is canceled before
* the BackgroundFileSaver instance has been created.
*/
_canceled: false,
/**
* Implements "DownloadSaver.execute".
*/
execute: function DCS_execute(aSetProgressBytesFn, aSetPropertiesFn)
{
let deferred = Promise.defer();
let copySaver = this;
this._canceled = false;
let download = this.download;
let targetPath = download.target.path;
let partFilePath = download.target.partFilePath;
let keepPartialData = download.tryToKeepPartialData;
return Task.spawn(function task_DCS_execute() {
// To reduce the chance that other downloads reuse the same final target
// file name, we should create a placeholder as soon as possible, before
// starting the network request. The placeholder is also required in case
// we are using a ".part" file instead of the final target while the
// download is in progress.
try {
// If the file already exists, don't delete its contents yet.
let file = yield OS.File.open(targetPath, { write: true });
yield file.close();
} catch (ex if ex instanceof OS.File.Error) {
// Throw a DownloadError indicating that the operation failed because of
// the target file. We cannot translate this into a specific result
// code, but we preserve the original message using the toString method.
let error = new DownloadError(Cr.NS_ERROR_FAILURE, ex.toString());
error.becauseTargetFailed = true;
throw error;
}
try {
let deferSaveComplete = Promise.defer();
if (this._canceled) {
// Don't create the BackgroundFileSaver object if we have been
// canceled meanwhile.
throw new DownloadError(Cr.NS_ERROR_FAILURE, "Saver canceled.");
}
// Create the object that will save the file in a background thread.
let backgroundFileSaver = new BackgroundFileSaverStreamListener();
@ -937,41 +1170,54 @@ DownloadCopySaver.prototype = {
onTargetChange: function () { },
onSaveComplete: function DCSE_onSaveComplete(aSaver, aStatus)
{
// Free the reference cycle, in order to release resources earlier.
// Free the reference cycle, to release resources earlier.
backgroundFileSaver.observer = null;
this._backgroundFileSaver = null;
// Send notifications now that we can restart the download if needed.
// Send notifications now that we can restart if needed.
if (Components.isSuccessCode(aStatus)) {
deferred.resolve();
deferSaveComplete.resolve();
} else {
// Infer the origin of the error from the failure code, because
// BackgroundFileSaver does not provide more specific data.
deferred.reject(new DownloadError(aStatus, null, true));
deferSaveComplete.reject(new DownloadError(aStatus, null,
true));
}
},
};
// Set the target file, that will be deleted if the download fails.
backgroundFileSaver.setTarget(new FileUtils.File(download.target.path),
false);
// Create a channel from the source, and listen to progress notifications.
// Create a channel from the source, and listen to progress
// notifications.
let channel = NetUtil.newChannel(NetUtil.newURI(download.source.url));
if (channel instanceof Ci.nsIPrivateBrowsingChannel) {
channel.setPrivate(download.source.isPrivate);
}
if (channel instanceof Ci.nsIHttpChannel && download.source.referrer) {
if (channel instanceof Ci.nsIHttpChannel &&
download.source.referrer) {
channel.referrer = NetUtil.newURI(download.source.referrer);
}
// If we have data that we can use to resume the download from where
// it stopped, try to use it.
let resumeAttempted = false;
if (channel instanceof Ci.nsIResumableChannel && this.entityID &&
partFilePath && keepPartialData) {
try {
let stat = yield OS.File.stat(partFilePath);
channel.resumeAt(stat.size, this.entityID);
resumeAttempted = true;
} catch (ex if ex instanceof OS.File.Error &&
ex.becauseNoSuchFile) { }
}
channel.notificationCallbacks = {
QueryInterface: XPCOMUtils.generateQI([Ci.nsIInterfaceRequestor]),
getInterface: XPCOMUtils.generateQI([Ci.nsIProgressEventSink]),
onProgress: function DCSE_onProgress(aRequest, aContext, aProgress,
aProgressMax)
{
aSetProgressBytesFn(aProgress, aProgressMax);
aSetProgressBytesFn(aProgress, aProgressMax, aProgress > 0 &&
partFilePath && keepPartialData);
},
onStatus: function () { },
};
@ -979,50 +1225,100 @@ DownloadCopySaver.prototype = {
// Open the channel, directing output to the background file saver.
backgroundFileSaver.QueryInterface(Ci.nsIStreamListener);
channel.asyncOpen({
onStartRequest: function DCSE_onStartRequest(aRequest, aContext)
{
onStartRequest: function (aRequest, aContext) {
backgroundFileSaver.onStartRequest(aRequest, aContext);
// Ensure we report the value of "Content-Length", if available, even
// if the download doesn't generate any progress events later.
// Ensure we report the value of "Content-Length", if available,
// even if the download doesn't generate any progress events
// later.
if (aRequest instanceof Ci.nsIChannel &&
aRequest.contentLength >= 0) {
aSetProgressBytesFn(0, aRequest.contentLength);
aSetPropertiesFn({ contentType: aRequest.contentType });
}
},
onStopRequest: function DCSE_onStopRequest(aRequest, aContext,
aStatusCode)
{
if (keepPartialData) {
// If the source is not resumable, don't keep partial data even
// if we were asked to try and do it.
if (aRequest instanceof Ci.nsIResumableChannel) {
try {
backgroundFileSaver.onStopRequest(aRequest, aContext, aStatusCode);
// If reading the ID succeeds, the source is resumable.
this.entityID = aRequest.entityID;
} catch (ex if ex instanceof Components.Exception &&
ex.result == Cr.NS_ERROR_NOT_RESUMABLE) {
keepPartialData = false;
}
} else {
keepPartialData = false;
}
}
if (partFilePath) {
// If we actually resumed a request, append to the partial data.
if (resumeAttempted) {
// TODO: Handle Cr.NS_ERROR_ENTITY_CHANGED
backgroundFileSaver.enableAppend();
}
// Use a part file, determining if we should keep it on failure.
backgroundFileSaver.setTarget(new FileUtils.File(partFilePath),
keepPartialData);
} else {
// Set the final target file, and delete it on failure.
backgroundFileSaver.setTarget(new FileUtils.File(targetPath),
false);
}
}.bind(copySaver),
onStopRequest: function (aRequest, aContext, aStatusCode) {
try {
backgroundFileSaver.onStopRequest(aRequest, aContext,
aStatusCode);
} finally {
// If the data transfer completed successfully, indicate to the
// background file saver that the operation can finish. If the
// data transfer failed, the saver has been already stopped.
if (Components.isSuccessCode(aStatusCode)) {
if (partFilePath) {
// Move to the final target if we were using a part file.
backgroundFileSaver.setTarget(
new FileUtils.File(targetPath), false);
}
backgroundFileSaver.finish(Cr.NS_OK);
}
}
},
onDataAvailable: function DCSE_onDataAvailable(aRequest, aContext,
}.bind(copySaver),
onDataAvailable: function (aRequest, aContext, aInputStream,
aOffset, aCount) {
backgroundFileSaver.onDataAvailable(aRequest, aContext,
aInputStream, aOffset,
aCount)
{
backgroundFileSaver.onDataAvailable(aRequest, aContext, aInputStream,
aOffset, aCount);
},
aCount);
}.bind(copySaver),
}, null);
// If the operation succeeded, store the object to allow cancellation.
this._backgroundFileSaver = backgroundFileSaver;
} catch (ex) {
// In case an error occurs while setting up the chain of objects for the
// download, ensure that we release the resources of the background saver.
deferred.reject(ex);
// In case an error occurs while setting up the chain of objects for
// the download, ensure that we release the resources of the saver.
backgroundFileSaver.finish(Cr.NS_ERROR_FAILURE);
throw ex;
}
return deferred.promise;
// We will wait on this promise in case no error occurred while setting
// up the chain of objects for the download.
yield deferSaveComplete.promise;
} catch (ex) {
// Ensure we always remove the placeholder for the final target file on
// failure, independently of which code path failed. In some cases, the
// background file saver may have already removed the file.
try {
yield OS.File.remove(targetPath);
} catch (e2 if e2 instanceof OS.File.Error && e2.becauseNoSuchFile) { }
throw ex;
}
}.bind(this));
},
/**
@ -1030,12 +1326,27 @@ DownloadCopySaver.prototype = {
*/
cancel: function DCS_cancel()
{
this._canceled = true;
if (this._backgroundFileSaver) {
this._backgroundFileSaver.finish(Cr.NS_ERROR_FAILURE);
this._backgroundFileSaver = null;
}
},
/**
* Implements "DownloadSaver.removePartialData".
*/
removePartialData: function ()
{
return Task.spawn(function task_DCS_removePartialData() {
if (this.download.target.partFilePath) {
try {
yield OS.File.remove(this.download.target.partFilePath);
} catch (ex if ex instanceof OS.File.Error && ex.becauseNoSuchFile) { }
}
}.bind(this));
},
/**
* Implements "DownloadSaver.toSerializable".
*/

View File

@ -104,6 +104,11 @@ DownloadList.prototype = {
* Removes a download from the list. If the download was already removed,
* this method has no effect.
*
* This method does not change the state of the download, to allow adding it
* to another list, or control it directly. If you want to dispose of the
* download object, you should cancel it afterwards, and remove any partially
* downloaded data if needed.
*
* @param aDownload
* The Download object to remove.
*/
@ -208,7 +213,14 @@ DownloadList.prototype = {
// operation hasn't completed yet so we don't check "stopped" here.
if ((download.succeeded || download.canceled || download.error) &&
aTestFn(download)) {
// Remove the download first, so that the views don't get the change
// notifications that may occur during finalization.
this.remove(download);
// Ensure that the download is stopped and no partial data is kept.
// This works even if the download state has changed meanwhile. We
// don't need to wait for the procedure to be complete before
// processing the other downloads in the list.
download.finalize(true);
}
}
}.bind(this)).then(null, Cu.reportError);

View File

@ -90,9 +90,12 @@ this.Downloads = {
/**
* Downloads data from a remote network location to a local file.
*
* This download method does not provide user interface or the ability to
* cancel the download programmatically. For that, you should obtain a
* reference to a Download object using the createDownload function.
* This download method does not provide user interface, or the ability to
* cancel or restart the download programmatically. For that, you should
* obtain a reference to a Download object using the createDownload function.
*
* Since the download cannot be restarted, any partially downloaded data will
* not be kept in case the download fails.
*
* @param aSource
* String containing the URI for the download source. Alternatively,

View File

@ -35,6 +35,36 @@ function promiseStartDownload(aSourceUrl) {
});
}
/**
* Waits for a download to reach half of its progress, in case it has not
* reached the expected progress already.
*
* @param aDownload
* The Download object to wait upon.
*
* @return {Promise}
* @resolves When the download has reached half of its progress.
* @rejects Never.
*/
function promiseDownloadMidway(aDownload) {
let deferred = Promise.defer();
// Wait for the download to reach half of its progress.
let onchange = function () {
if (!aDownload.stopped && !aDownload.canceled && aDownload.progress == 50) {
aDownload.onchange = null;
deferred.resolve();
}
};
// Register for the notification, but also call the function directly in
// case the download already reached the expected progress.
aDownload.onchange = onchange;
onchange();
return deferred.promise;
}
/**
* Waits for a download to finish, in case it has not finished already.
*
@ -60,6 +90,53 @@ function promiseDownloadStopped(aDownload) {
return Promise.reject(aDownload.error || new Error("Download canceled."));
}
/**
* Creates and starts a new download, configured to keep partial data, and
* returns only when the first part of "interruptible_resumable.txt" has been
* saved to disk. You must call "continueResponses" to allow the interruptible
* request to continue.
*
* TODO: This function uses either DownloadCopySaver or DownloadLegacySaver
* based on the current test run.
*
* @return {Promise}
* @resolves The newly created Download object, still in progress.
* @rejects JavaScript exception.
*/
function promiseStartDownload_tryToKeepPartialData() {
return Task.spawn(function () {
mustInterruptResponses();
// Start a new download and configure it to keep partially downloaded data.
let targetFilePath = getTempFile(TEST_TARGET_FILE_NAME).path;
let download = yield Downloads.createDownload({
source: httpUrl("interruptible_resumable.txt"),
target: { path: targetFilePath,
partFilePath: targetFilePath + ".part" },
});
download.tryToKeepPartialData = true;
download.start();
yield promiseDownloadMidway(download);
// After we receive the progress notification, we should wait for the worker
// thread of BackgroundFileSaver to receive the data to be written to disk.
// We don't have control over when this happens. We may only check that the
// ".part" file has been created, while its size cannot be determined because
// the file is currently open.
try {
while (!(yield OS.File.exists(download.target.partFilePath))) {
yield promiseTimeout(50);
}
} catch (ex if ex instanceof OS.File.Error) {
// This indicates that the file has been created and cannot be accessed.
// The specific error might vary with the platform.
}
throw new Task.Result(download);
});
}
////////////////////////////////////////////////////////////////////////////////
//// Tests
@ -116,7 +193,6 @@ add_task(function test_referrer()
function cleanup() {
gHttpServer.registerPathHandler(sourcePath, null);
}
do_register_cleanup(cleanup);
gHttpServer.registerPathHandler(sourcePath, function (aRequest, aResponse) {
@ -191,7 +267,7 @@ add_task(function test_initial_final_state()
*/
add_task(function test_final_state_notified()
{
let deferResponse = deferNextResponse();
mustInterruptResponses();
let download = yield promiseStartDownload(httpUrl("interruptible.txt"));
@ -206,7 +282,7 @@ add_task(function test_final_state_notified()
// Allow the download to complete.
let promiseAttempt = download.start();
deferResponse.resolve();
continueResponses();
yield promiseAttempt;
// The view should have been notified before the download completes.
@ -220,26 +296,18 @@ add_task(function test_final_state_notified()
*/
add_task(function test_intermediate_progress()
{
let deferResponse = deferNextResponse();
mustInterruptResponses();
let download = yield promiseStartDownload(httpUrl("interruptible.txt"));
let onchange = function () {
if (download.progress == 50) {
yield promiseDownloadMidway(download);
do_check_true(download.hasProgress);
do_check_eq(download.currentBytes, TEST_DATA_SHORT.length);
do_check_eq(download.totalBytes, TEST_DATA_SHORT.length * 2);
// Continue after the first chunk of data is fully received.
deferResponse.resolve();
}
};
// Register for the notification, but also call the function directly in case
// the download already reached the expected progress.
download.onchange = onchange;
onchange();
continueResponses();
yield promiseDownloadStopped(download);
do_check_true(download.stopped);
@ -271,15 +339,30 @@ add_task(function test_empty_progress()
*/
add_task(function test_empty_noprogress()
{
let deferResponse = deferNextResponse();
let promiseEmptyRequestReceived = promiseNextRequestReceived();
let sourcePath = "/test_empty_noprogress.txt";
let sourceUrl = httpUrl("test_empty_noprogress.txt");
let deferRequestReceived = Promise.defer();
// Register an interruptible handler that notifies us when the request occurs.
function cleanup() {
gHttpServer.registerPathHandler(sourcePath, null);
}
do_register_cleanup(cleanup);
registerInterruptibleHandler(sourcePath,
function firstPart(aRequest, aResponse) {
aResponse.setHeader("Content-Type", "text/plain", false);
deferRequestReceived.resolve();
}, function secondPart(aRequest, aResponse) { });
// Start the download, without allowing the request to finish.
mustInterruptResponses();
let download;
if (!gUseLegacySaver) {
// When testing DownloadCopySaver, we have control over the download, thus
// we can hook its onchange callback that will be notified when the
// download starts.
download = yield promiseNewDownload(httpUrl("empty-noprogress.txt"));
download = yield promiseNewDownload(sourceUrl);
download.onchange = function () {
if (!download.stopped) {
@ -294,14 +377,13 @@ add_task(function test_empty_noprogress()
// When testing DownloadLegacySaver, the download is already started when it
// is created, and it may have already made all needed property change
// notifications, thus there is no point in checking the onchange callback.
download = yield promiseStartLegacyDownload(
httpUrl("empty-noprogress.txt"));
download = yield promiseStartLegacyDownload(sourceUrl);
}
// Wait for the request to be received by the HTTP server, but don't allow the
// request to finish yet. Before checking the download state, wait for the
// events to be processed by the client.
yield promiseEmptyRequestReceived;
yield deferRequestReceived.promise;
yield promiseExecuteSoon();
// Check that this download has no progress report.
@ -311,7 +393,7 @@ add_task(function test_empty_noprogress()
do_check_eq(download.totalBytes, 0);
// Now allow the response to finish.
deferResponse.resolve();
continueResponses();
yield promiseDownloadStopped(download);
// Verify the state of the completed download.
@ -329,8 +411,7 @@ add_task(function test_empty_noprogress()
*/
add_task(function test_start_twice()
{
// Ensure that the download cannot complete before start is called twice.
let deferResponse = deferNextResponse();
mustInterruptResponses();
let download;
if (!gUseLegacySaver) {
@ -348,7 +429,7 @@ add_task(function test_start_twice()
let promiseAttempt2 = download.start();
// Allow the download to finish.
deferResponse.resolve();
continueResponses();
// Both promises should now be resolved.
yield promiseAttempt1;
@ -368,7 +449,7 @@ add_task(function test_start_twice()
*/
add_task(function test_cancel_midway()
{
let deferResponse = deferNextResponse();
mustInterruptResponses();
// In this test case, we execute different checks that are only possible with
// DownloadCopySaver or DownloadLegacySaver respectively.
@ -381,11 +462,11 @@ add_task(function test_cancel_midway()
options);
}
try {
// Cancel the download after receiving the first part of the response.
let deferCancel = Promise.defer();
let onchange = function () {
if (!download.stopped && !download.canceled && download.progress == 50) {
// Cancel the download immediately during the notification.
deferCancel.resolve(download.cancel());
// The state change happens immediately after calling "cancel", but
@ -435,9 +516,6 @@ add_task(function test_cancel_midway()
do_check_false(ex.becauseTargetFailed);
}
}
} finally {
deferResponse.resolve();
}
});
/**
@ -445,9 +523,8 @@ add_task(function test_cancel_midway()
*/
add_task(function test_cancel_immediately()
{
// Ensure that the download cannot complete before cancel is called.
let deferResponse = deferNextResponse();
try {
mustInterruptResponses();
let download = yield promiseStartDownload(httpUrl("interruptible.txt"));
let promiseAttempt = download.start();
@ -475,17 +552,6 @@ add_task(function test_cancel_immediately()
// Check that the promise returned by the "cancel" method has been resolved.
yield promiseCancel;
} finally {
deferResponse.resolve();
}
// Even if we canceled the download immediately, the HTTP request might have
// been made, and the internal HTTP handler might be waiting to process it.
// Thus, we process any pending events now, to avoid that the request is
// processed during the tests that follow, interfering with them.
for (let i = 0; i < 5; i++) {
yield promiseExecuteSoon();
}
});
/**
@ -498,26 +564,18 @@ add_task(function test_cancel_midway_restart()
return;
}
let download = yield promiseNewDownload(httpUrl("interruptible.txt"));
mustInterruptResponses();
let download = yield promiseStartDownload(httpUrl("interruptible.txt"));
// The first time, cancel the download midway.
let deferResponse = deferNextResponse();
try {
let deferCancel = Promise.defer();
download.onchange = function () {
if (!download.stopped && !download.canceled && download.progress == 50) {
deferCancel.resolve(download.cancel());
}
};
download.start();
yield deferCancel.promise;
} finally {
deferResponse.resolve();
}
yield promiseDownloadMidway(download);
yield download.cancel();
do_check_true(download.stopped);
// The second time, we'll provide the entire interruptible response.
continueResponses();
download.onchange = null;
let promiseAttempt = download.start();
@ -544,6 +602,115 @@ add_task(function test_cancel_midway_restart()
TEST_DATA_SHORT + TEST_DATA_SHORT);
});
/**
* Cancels a download and restarts it from where it stopped.
*/
add_task(function test_cancel_midway_restart_tryToKeepPartialData()
{
let download = yield promiseStartDownload_tryToKeepPartialData();
yield download.cancel();
do_check_true(download.stopped);
do_check_true(download.hasPartialData);
// The target file should not exist, but we should have kept the partial data.
do_check_false(yield OS.File.exists(download.target.path));
yield promiseVerifyContents(download.target.partFilePath, TEST_DATA_SHORT);
// Verify that the server sent the response from the start.
do_check_eq(gMostRecentFirstBytePos, 0);
// The second time, we'll request and obtain the second part of the response.
continueResponses();
yield download.start();
// Check that the server now sent the second part only.
do_check_eq(gMostRecentFirstBytePos, TEST_DATA_SHORT.length);
// The target file should now have been created, and the ".part" file deleted.
yield promiseVerifyContents(download.target.path,
TEST_DATA_SHORT + TEST_DATA_SHORT);
do_check_false(yield OS.File.exists(download.target.partFilePath));
});
/**
* Cancels a download while keeping partially downloaded data, then removes the
* data and restarts the download from the beginning.
*/
add_task(function test_cancel_midway_restart_removePartialData()
{
let download = yield promiseStartDownload_tryToKeepPartialData();
yield download.cancel();
do_check_true(download.hasPartialData);
yield promiseVerifyContents(download.target.partFilePath, TEST_DATA_SHORT);
yield download.removePartialData();
do_check_false(download.hasPartialData);
do_check_false(yield OS.File.exists(download.target.partFilePath));
// The second time, we'll request and obtain the entire response again.
continueResponses();
yield download.start();
// Verify that the server sent the response from the start.
do_check_eq(gMostRecentFirstBytePos, 0);
// The target file should now have been created, and the ".part" file deleted.
yield promiseVerifyContents(download.target.path,
TEST_DATA_SHORT + TEST_DATA_SHORT);
do_check_false(yield OS.File.exists(download.target.partFilePath));
});
/**
* Cancels a download while keeping partially downloaded data, then removes the
* data and restarts the download from the beginning without keeping the partial
* data anymore.
*/
add_task(function test_cancel_midway_restart_tryToKeepPartialData_false()
{
let download = yield promiseStartDownload_tryToKeepPartialData();
yield download.cancel();
download.tryToKeepPartialData = false;
// The above property change does not affect existing partial data.
do_check_true(download.hasPartialData);
yield promiseVerifyContents(download.target.partFilePath, TEST_DATA_SHORT);
yield download.removePartialData();
do_check_false(yield OS.File.exists(download.target.partFilePath));
// Restart the download from the beginning.
mustInterruptResponses();
download.start();
yield promiseDownloadMidway(download);
// While the download is in progress, we should still have a ".part" file.
do_check_false(download.hasPartialData);
do_check_true(yield OS.File.exists(download.target.partFilePath));
yield download.cancel();
// The ".part" file should be deleted now that the download is canceled.
do_check_false(download.hasPartialData);
do_check_false(yield OS.File.exists(download.target.partFilePath));
// The third time, we'll request and obtain the entire response again.
continueResponses();
yield download.start();
// Verify that the server sent the response from the start.
do_check_eq(gMostRecentFirstBytePos, 0);
// The target file should now have been created, and the ".part" file deleted.
yield promiseVerifyContents(download.target.path,
TEST_DATA_SHORT + TEST_DATA_SHORT);
do_check_false(yield OS.File.exists(download.target.partFilePath));
});
/**
* Cancels a download right after starting it, then restarts it immediately.
*/
@ -554,12 +721,11 @@ add_task(function test_cancel_immediately_restart_immediately()
return;
}
let download = yield promiseNewDownload(httpUrl("interruptible.txt"));
// Ensure that the download cannot complete before cancel is called.
let deferResponse = deferNextResponse();
mustInterruptResponses();
let download = yield promiseStartDownload(httpUrl("interruptible.txt"));
let promiseAttempt = download.start();
do_check_false(download.stopped);
download.cancel();
@ -578,18 +744,9 @@ add_task(function test_cancel_immediately_restart_immediately()
do_check_eq(download.totalBytes, 0);
do_check_eq(download.currentBytes, 0);
// Even if we canceled the download immediately, the HTTP request might have
// been made, and the internal HTTP handler might be waiting to process it.
// Thus, we process any pending events now, to avoid that the request is
// processed during the tests that follow, interfering with them.
for (let i = 0; i < 5; i++) {
yield promiseExecuteSoon();
}
// Ensure the next request is now allowed to complete, regardless of whether
// the canceled request was received by the server or not.
deferResponse.resolve();
continueResponses();
try {
yield promiseAttempt;
do_throw("The download should have been canceled.");
@ -619,21 +776,13 @@ add_task(function test_cancel_midway_restart_immediately()
return;
}
let download = yield promiseNewDownload(httpUrl("interruptible.txt"));
mustInterruptResponses();
let download = yield promiseStartDownload(httpUrl("interruptible.txt"));
let promiseAttempt = download.start();
// The first time, cancel the download midway.
let deferResponse = deferNextResponse();
let deferMidway = Promise.defer();
download.onchange = function () {
if (!download.stopped && !download.canceled && download.progress == 50) {
do_check_eq(download.progress, 50);
deferMidway.resolve();
}
};
let promiseAttempt = download.start();
yield deferMidway.promise;
yield promiseDownloadMidway(download);
download.cancel();
do_check_true(download.canceled);
@ -650,9 +799,8 @@ add_task(function test_cancel_midway_restart_immediately()
do_check_eq(download.totalBytes, 0);
do_check_eq(download.currentBytes, 0);
deferResponse.resolve();
// The second request is allowed to complete.
continueResponses();
try {
yield promiseAttempt;
do_throw("The download should have been canceled.");
@ -696,9 +844,8 @@ add_task(function test_cancel_successful()
*/
add_task(function test_cancel_twice()
{
// Ensure that the download cannot complete before cancel is called.
let deferResponse = deferNextResponse();
try {
mustInterruptResponses();
let download = yield promiseStartDownload(httpUrl("interruptible.txt"));
let promiseAttempt = download.start();
@ -726,9 +873,55 @@ add_task(function test_cancel_twice()
do_check_true(download.error === null);
do_check_false(yield OS.File.exists(download.target.path));
} finally {
deferResponse.resolve();
}
});
/**
* Checks that a download cannot be restarted after the "finalize" method.
*/
add_task(function test_finalize()
{
mustInterruptResponses();
let download = yield promiseStartDownload(httpUrl("interruptible.txt"));
let promiseFinalized = download.finalize();
try {
yield download.start();
do_throw("It should not be possible to restart after finalization.");
} catch (ex) { }
yield promiseFinalized;
do_check_true(download.stopped);
do_check_false(download.succeeded);
do_check_true(download.canceled);
do_check_true(download.error === null);
do_check_false(yield OS.File.exists(download.target.path));
});
/**
* Checks that the "finalize" method can remove partially downloaded data.
*/
add_task(function test_finalize_tryToKeepPartialData()
{
// Check finalization without removing partial data.
let download = yield promiseStartDownload_tryToKeepPartialData();
yield download.finalize();
do_check_true(download.hasPartialData);
do_check_true(yield OS.File.exists(download.target.partFilePath));
// Clean up.
yield download.removePartialData();
// Check finalization while removing partial data.
download = yield promiseStartDownload_tryToKeepPartialData();
yield download.finalize(true);
do_check_false(download.hasPartialData);
do_check_false(yield OS.File.exists(download.target.partFilePath));
});
/**
@ -741,10 +934,9 @@ add_task(function test_whenSucceeded_after_restart()
return;
}
let download = yield promiseNewDownload(httpUrl("interruptible.txt"));
mustInterruptResponses();
// Ensure that the download cannot complete before cancel is called.
let deferResponse = deferNextResponse();
let download = yield promiseNewDownload(httpUrl("interruptible.txt"));
// Get a reference before the first download attempt.
let promiseSucceeded = download.whenSucceeded();
@ -753,9 +945,8 @@ add_task(function test_whenSucceeded_after_restart()
download.start();
yield download.cancel();
deferResponse.resolve();
// The second request is allowed to complete.
continueResponses();
download.start();
// Wait for the download to finish by waiting on the whenSucceeded promise.
@ -1039,8 +1230,7 @@ add_task(function test_cancel_midway_restart_with_content_encoding()
let download = yield promiseNewDownload(httpUrl("interruptible_gzip.txt"));
// The first time, cancel the download midway.
let deferResponse = deferNextResponse();
try {
mustInterruptResponses();
let deferCancel = Promise.defer();
download.onchange = function () {
if (!download.stopped && !download.canceled &&
@ -1050,15 +1240,13 @@ add_task(function test_cancel_midway_restart_with_content_encoding()
};
download.start();
yield deferCancel.promise;
} finally {
deferResponse.resolve();
}
do_check_true(download.stopped);
// The second time, we'll provide the entire interruptible response.
continueResponses();
download.onchange = null;
yield download.start()
yield download.start();
do_check_eq(download.progress, 100);
do_check_eq(download.totalBytes, TEST_DATA_SHORT_GZIP_ENCODED.length);

View File

@ -388,55 +388,47 @@ function startFakeServer()
}
/**
* This function allows testing events or actions that need to happen in the
* middle of a download.
* This is an internal reference that should not be used directly by tests.
*/
let _gDeferResponses = Promise.defer();
/**
* Ensures that all the interruptible requests started after this function is
* called won't complete until the continueResponses function is called.
*
* Normally, the internal HTTP server returns all the available data as soon as
* a request is received. In order for some requests to be served one part at a
* time, special interruptible handlers are registered on the HTTP server.
*
* Before making a request to one of the addresses served by the interruptible
* handlers, you may call "deferNextResponse" to get a reference to an object
* that allows you to control the next request.
* time, special interruptible handlers are registered on the HTTP server. This
* allows testing events or actions that need to happen in the middle of a
* download.
*
* For example, the handler accessible at the httpUri("interruptible.txt")
* address returns the TEST_DATA_SHORT text, then waits until the "resolve"
* method is called on the object returned by the function. At this point, the
* handler sends the TEST_DATA_SHORT text again to complete the response.
* address returns the TEST_DATA_SHORT text, then it may block until the
* continueResponses method is called. At this point, the handler sends the
* TEST_DATA_SHORT text again to complete the response.
*
* You can also call the "reject" method on the returned object to interrupt the
* response midway. Because of how the network layer is implemented, this does
* not cause the socket to return an error.
*
* @returns Deferred object used to control the response.
* If an interruptible request is started before the function is called, it may
* or may not be blocked depending on the actual sequence of events.
*/
function deferNextResponse()
function mustInterruptResponses()
{
do_print("Interruptible request will be controlled.");
// If there are pending blocked requests, allow them to complete. This is
// done to prevent requests from being blocked forever, but should not affect
// the test logic, since previously started requests should not be monitored
// on the client side anymore.
_gDeferResponses.resolve();
// Store an internal reference that should not be used directly by tests.
if (!deferNextResponse._deferred) {
deferNextResponse._deferred = Promise.defer();
}
return deferNextResponse._deferred;
do_print("Interruptible responses will be blocked midway.");
_gDeferResponses = Promise.defer();
}
/**
* Returns a promise that is resolved when the next interruptible response
* handler has received the request, and has started sending the first part of
* the response. The response might not have been received by the client yet.
*
* @return {Promise}
* @resolves When the next request has been received.
* @rejects Never.
* Allows all the current and future interruptible requests to complete.
*/
function promiseNextRequestReceived()
function continueResponses()
{
do_print("Requested notification when interruptible request is received.");
// Store an internal reference that should not be used directly by tests.
promiseNextRequestReceived._deferred = Promise.defer();
return promiseNextRequestReceived._deferred.promise;
do_print("Interruptible responses are now allowed to continue.");
_gDeferResponses.resolve();
}
/**
@ -448,44 +440,24 @@ function promiseNextRequestReceived()
* This function is called when the response is received, with the
* aRequest and aResponse arguments of the server.
* @param aSecondPartFn
* This function is called after the "resolve" method of the object
* returned by deferNextResponse is called. This function is called with
* the aRequest and aResponse arguments of the server.
* This function is called with the aRequest and aResponse arguments of
* the server, when the continueResponses function is called.
*/
function registerInterruptibleHandler(aPath, aFirstPartFn, aSecondPartFn)
{
gHttpServer.registerPathHandler(aPath, function (aRequest, aResponse) {
// Get a reference to the controlling object for this request. If the
// deferNextResponse function was not called, interrupt the test.
let deferResponse = deferNextResponse._deferred;
deferNextResponse._deferred = null;
if (deferResponse) {
do_print("Interruptible request started under control.");
} else {
do_print("Interruptible request started without being controlled.");
deferResponse = Promise.defer();
deferResponse.resolve();
}
do_print("Interruptible request started.");
// Process the first part of the response.
aResponse.processAsync();
aFirstPartFn(aRequest, aResponse);
if (promiseNextRequestReceived._deferred) {
do_print("Notifying that interruptible request has been received.");
promiseNextRequestReceived._deferred.resolve();
promiseNextRequestReceived._deferred = null;
}
// Wait on the deferred object, then finish or abort the request.
deferResponse.promise.then(function RIH_onSuccess() {
// Wait on the current deferred object, then finish the request.
_gDeferResponses.promise.then(function RIH_onSuccess() {
aSecondPartFn(aRequest, aResponse);
aResponse.finish();
do_print("Interruptible request finished.");
}, function RIH_onFailure() {
aResponse.abort();
do_print("Interruptible request aborted.");
});
}).then(null, Cu.reportError);
});
}
@ -499,6 +471,12 @@ function isValidDate(aDate) {
return aDate && aDate.getTime && !isNaN(aDate.getTime());
}
/**
* Position of the first byte served by the "interruptible_resumable.txt"
* handler during the most recent response.
*/
let gMostRecentFirstBytePos;
////////////////////////////////////////////////////////////////////////////////
//// Initialization functions common to all tests
@ -519,11 +497,48 @@ add_task(function test_common_initialize()
aResponse.write(TEST_DATA_SHORT);
});
registerInterruptibleHandler("/empty-noprogress.txt",
registerInterruptibleHandler("/interruptible_resumable.txt",
function firstPart(aRequest, aResponse) {
aResponse.setHeader("Content-Type", "text/plain", false);
}, function secondPart(aRequest, aResponse) { });
// Determine if only part of the data should be sent.
let data = TEST_DATA_SHORT + TEST_DATA_SHORT;
if (aRequest.hasHeader("Range")) {
var matches = aRequest.getHeader("Range")
.match(/^\s*bytes=(\d+)?-(\d+)?\s*$/);
var firstBytePos = (matches[1] === undefined) ? 0 : matches[1];
var lastBytePos = (matches[2] === undefined) ? data.length - 1
: matches[2];
if (firstBytePos >= data.length) {
aResponse.setStatusLine(aRequest.httpVersion, 416,
"Requested Range Not Satisfiable");
aResponse.setHeader("Content-Range", "*/" + data.length, false);
aResponse.finish();
return;
}
aResponse.setStatusLine(aRequest.httpVersion, 206, "Partial Content");
aResponse.setHeader("Content-Range", firstBytePos + "-" +
lastBytePos + "/" +
data.length, false);
data = data.substring(firstBytePos, lastBytePos + 1);
gMostRecentFirstBytePos = firstBytePos;
} else {
gMostRecentFirstBytePos = 0;
}
aResponse.setHeader("Content-Length", "" + data.length, false);
aResponse.write(data.substring(0, data.length / 2));
// Store the second part of the data on the response object, so that it
// can be used by the secondPart function.
aResponse.secondPartData = data.substring(data.length / 2);
}, function secondPart(aRequest, aResponse) {
aResponse.write(aResponse.secondPartData);
});
registerInterruptibleHandler("/interruptible_gzip.txt",
function firstPart(aRequest, aResponse) {