Bug 836443 - Automatically stop and restart downloads. r=enn
authorPaolo Amadini <paolo.mozmail@amadzone.org>
Fri, 16 Aug 2013 11:02:18 +0200
changeset 142867 81e6133aa15250f834616d92b54697cf2fe6c165
parent 142866 573d656dadcfc346c7d01d4623939b4ae8fd233a
child 142868 18ea4b3fb24fa40735968f98b5a208a3e9ddeda1
push id2227
push userpaolo.mozmail@amadzone.org
push dateFri, 16 Aug 2013 09:03:35 +0000
treeherderfx-team@18ea4b3fb24f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersenn
bugs836443
milestone26.0a1
Bug 836443 - Automatically stop and restart downloads. r=enn
toolkit/components/jsdownloads/src/DownloadCore.jsm
toolkit/components/jsdownloads/src/DownloadIntegration.jsm
toolkit/components/jsdownloads/src/DownloadList.jsm
toolkit/components/jsdownloads/src/DownloadStore.jsm
--- a/toolkit/components/jsdownloads/src/DownloadCore.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadCore.jsm
@@ -198,17 +198,17 @@ Download.prototype = {
   speed: 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.
+   * successfully, this property is always 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.
    */
   hasPartialData: false,
 
   /**
    * This can be set to a function that is called after other properties change.
@@ -377,16 +377,17 @@ Download.prototype = {
       try {
         // Execute the actual download through the saver object.
         yield this.saver.execute(DS_setProgressBytes.bind(this),
                                  DS_setProperties.bind(this));
 
         // Update the status properties for a successful download.
         this.progress = 100;
         this.succeeded = true;
+        this.hasPartialData = false;
       } catch (ex) {
         // Fail with a generic status code on cancellation, so that the caller
         // is forced to actually check the status properties to see if the
         // download was canceled or failed because of other reasons.
         if (this._promiseCanceled) {
           throw new DownloadError(Cr.NS_ERROR_FAILURE, "Download canceled.");
         }
 
@@ -618,16 +619,57 @@ Download.prototype = {
    * @rejects Never.
    */
   whenSucceeded: function D_whenSucceeded()
   {
     return this._deferSucceeded.promise;
   },
 
   /**
+   * Updates the state of a finished, failed, or canceled download based on the
+   * current state in the file system.  If the download is in progress or it has
+   * been finalized, this method has no effect, and it returns a resolved
+   * promise.
+   *
+   * This allows the properties of the download to be updated in case the user
+   * moved or deleted the target file or its associated ".part" file.
+   *
+   * @return {Promise}
+   * @resolves When the operation has completed.
+   * @rejects Never.
+   */
+  refresh: function ()
+  {
+    return Task.spawn(function () {
+      if (!this.stopped || this._finalized) {
+        return;
+      }
+
+      // Update the current progress from disk if we retained partial data.
+      if (this.hasPartialData && this.target.partFilePath) {
+        let stat = yield OS.File.stat(this.target.partFilePath);
+
+        // Ignore the result if the state has changed meanwhile.
+        if (!this.stopped || this._finalized) {
+          return;
+        }
+
+        // Update the bytes transferred and the related progress properties.
+        this.currentBytes = stat.size;
+        if (this.totalBytes > 0) {
+          this.hasProgress = true;
+          this.progress = Math.floor(this.currentBytes /
+                                         this.totalBytes * 100);
+        }
+        this._notifyChange();
+      }
+    }.bind(this)).then(null, Cu.reportError);
+  },
+
+  /**
    * 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
@@ -756,30 +798,64 @@ Download.prototype = {
     // is an object instead of a simple string, we can't simplify it because we
     // need to persist all its properties, not only "type".  This may happen for
     // savers of type "copy" as well as other types.
     let saver = this.saver.toSerializable();
     if (saver !== "copy") {
       serializable.saver = saver;
     }
 
-    if (this.launcherPath) {
-      serializable.launcherPath = this.launcherPath;
+    if (!this.stopped) {
+      serializable.stopped = false;
+    }
+
+    if (this.error && ("message" in this.error)) {
+      serializable.error = { message: this.error.message };
     }
 
-    if (this.launchWhenSucceeded) {
-      serializable.launchWhenSucceeded = true;
-    }
+    // These are serialized unless they are false, null, or empty strings.
+    let propertiesToSerialize = [
+      "succeeded",
+      "canceled",
+      "startTime",
+      "totalBytes",
+      "hasPartialData",
+      "tryToKeepPartialData",
+      "launcherPath",
+      "launchWhenSucceeded",
+      "contentType",
+    ];
 
-    if (this.contentType) {
-      serializable.contentType = this.contentType;
+    for (let property of propertiesToSerialize) {
+      if (this[property]) {
+        serializable[property] = this[property];
+      }
     }
 
     return serializable;
   },
+
+  /**
+   * Returns a value that changes only when one of the properties of a Download
+   * object that should be saved into a file also change.  This excludes
+   * properties whose value doesn't usually change during the download lifetime.
+   *
+   * This function is used to determine whether the download should be
+   * serialized after a property change notification has been received.
+   *
+   * @return String representing the relevant download state.
+   */
+  getSerializationHash: function ()
+  {
+    // The "succeeded", "canceled", "error", and startTime properties are not
+    // taken into account because they all change before the "stopped" property
+    // changes, and are not altered in other cases.
+    return this.stopped + "," + this.totalBytes + "," + this.hasPartialData +
+           "," + this.contentType;
+  },
 };
 
 /**
  * Creates a new Download object from a serializable representation.  This
  * function is used by the createDownload method of Downloads.jsm when a new
  * Download object is requested, thus some properties may refer to live objects
  * in place of their serializable representations.
  *
@@ -811,26 +887,38 @@ Download.fromSerializable = function (aS
   }
   if ("saver" in aSerializable) {
     download.saver = DownloadSaver.fromSerializable(aSerializable.saver);
   } else {
     download.saver = DownloadSaver.fromSerializable("copy");
   }
   download.saver.download = download;
 
-  if ("launchWhenSucceeded" in aSerializable) {
-    download.launchWhenSucceeded = !!aSerializable.launchWhenSucceeded;
+  let propertiesToDeserialize = [
+    "startTime",
+    "totalBytes",
+    "hasPartialData",
+    "tryToKeepPartialData",
+    "launcherPath",
+    "launchWhenSucceeded",
+    "contentType",
+  ];
+
+  // If the download should not be restarted automatically, update its state to
+  // reflect success or failure during a previous session.
+  if (!("stopped" in aSerializable) || aSerializable.stopped) {
+    propertiesToDeserialize.push("succeeded");
+    propertiesToDeserialize.push("canceled");
+    propertiesToDeserialize.push("error");
   }
 
-  if ("contentType" in aSerializable) {
-    download.contentType = aSerializable.contentType;
-  }
-
-  if ("launcherPath" in aSerializable) {
-    download.launcherPath = aSerializable.launcherPath;
+  for (let property of propertiesToDeserialize) {
+    if (property in aSerializable) {
+      download[property] = aSerializable[property];
+    }
   }
 
   return download;
 };
 
 ////////////////////////////////////////////////////////////////////////////////
 //// DownloadSource
 
@@ -1186,16 +1274,23 @@ DownloadCopySaver.prototype = {
   /**
    * 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,
 
   /**
+   * String corresponding to the entityID property of the nsIResumableChannel
+   * used to execute the download, or null if the channel was not resumable or
+   * the saver was instructed not to keep partially downloaded data.
+   */
+  entityID: null,
+
+  /**
    * Implements "DownloadSaver.execute".
    */
   execute: function DCS_execute(aSetProgressBytesFn, aSetPropertiesFn)
   {
     let copySaver = this;
 
     this._canceled = false;
 
@@ -1423,33 +1518,41 @@ DownloadCopySaver.prototype = {
     }.bind(this));
   },
 
   /**
    * Implements "DownloadSaver.toSerializable".
    */
   toSerializable: function ()
   {
-    // Simplify the representation since we don't have other details for now.
-    return "copy";
+    // Simplify the representation if we don't have other details.
+    if (!this.entityID) {
+      return "copy";
+    }
+
+    return { type: "copy",
+             entityID: this.entityID };
   },
 };
 
 /**
  * Creates a new DownloadCopySaver object, with its initial state derived from
  * its serializable representation.
  *
  * @param aSerializable
  *        Serializable representation of a DownloadCopySaver object.
  *
  * @return The newly created DownloadCopySaver object.
  */
 DownloadCopySaver.fromSerializable = function (aSerializable) {
-  // We don't have other state details for now.
-  return new DownloadCopySaver();
+  let saver = new DownloadCopySaver();
+  if ("entityID" in aSerializable) {
+    saver.entityID = aSerializable.entityID;
+  }
+  return saver;
 };
 
 ////////////////////////////////////////////////////////////////////////////////
 //// DownloadLegacySaver
 
 /**
  * Saver object that integrates with the legacy nsITransfer interface.
  *
@@ -1570,16 +1673,23 @@ DownloadLegacySaver.prototype = {
   /**
    * In case the download is restarted after the first execution finished, this
    * property contains a reference to the DownloadCopySaver that is executing
    * the new download attempt.
    */
   copySaver: null,
 
   /**
+   * String corresponding to the entityID property of the nsIResumableChannel
+   * used to execute the download, or null if the channel was not resumable or
+   * the saver was instructed not to keep partially downloaded data.
+   */
+  entityID: null,
+
+  /**
    * Implements "DownloadSaver.execute".
    */
   execute: function DLS_execute(aSetProgressBytesFn)
   {
     // Check if this is not the first execution of the download.  The Download
     // object guarantees that this function is not re-entered during execution.
     if (this.firstExecutionFinished) {
       if (!this.copySaver) {
@@ -1669,17 +1779,17 @@ DownloadLegacySaver.prototype = {
    * Implements "DownloadSaver.toSerializable".
    */
   toSerializable: function ()
   {
     // This object depends on legacy components that are created externally,
     // thus it cannot be rebuilt during deserialization.  To support resuming
     // across different browser sessions, this object is transformed into a
     // DownloadCopySaver for the purpose of serialization.
-    return "copy";
+    return DownloadCopySaver.prototype.toSerializable.call(this);
   },
 };
 
 /**
  * Returns a new DownloadLegacySaver object.  This saver type has a
  * deserializable form only when creating a new object in memory, because it
  * cannot be serialized to disk.
  */
--- a/toolkit/components/jsdownloads/src/DownloadIntegration.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadIntegration.jsm
@@ -59,16 +59,41 @@ XPCOMUtils.defineLazyGetter(this, "gPare
   return null;
 });
 
 XPCOMUtils.defineLazyGetter(this, "gStringBundle", function() {
   return Services.strings.
     createBundle("chrome://mozapps/locale/downloads/downloads.properties");
 });
 
+const Timer = Components.Constructor("@mozilla.org/timer;1", "nsITimer",
+                                     "initWithCallback");
+
+/**
+ * Indicates the delay between a change to the downloads data and the related
+ * save operation.  This value is the result of a delicate trade-off, assuming
+ * the host application uses the browser history instead of the download store
+ * to save completed downloads.
+ *
+ * If a download takes less than this interval to complete (for example, saving
+ * a page that is already displayed), then no input/output is triggered by the
+ * download store except for an existence check, resulting in the best possible
+ * efficiency.
+ *
+ * Conversely, if the browser is closed before this interval has passed, the
+ * download will not be saved.  This prevents it from being restored in the next
+ * session, and if there is partial data associated with it, then the ".part"
+ * file will not be deleted when the browser starts again.
+ *
+ * In all cases, for best efficiency, this value should be high enough that the
+ * input/output for opening or closing the target file does not overlap with the
+ * one for saving the list of downloads.
+ */
+const kSaveDelayMs = 1500;
+
 ////////////////////////////////////////////////////////////////////////////////
 //// DownloadIntegration
 
 /**
  * Provides functions to integrate with the host application, handling for
  * example the global prompts on shutdown.
  */
 this.DownloadIntegration = {
@@ -100,17 +125,17 @@ this.DownloadIntegration = {
   /**
    * Performs initialization of the list of persistent downloads, before its
    * first use by the host application.  This function may be called only once
    * during the entire lifetime of the application.
    *
    * @param aList
    *        DownloadList object to be populated with the download objects
    *        serialized from the previous session.  This list will be persisted
-   *        to disk during the session lifetime or when the session terminates.
+   *        to disk during the session lifetime.
    *
    * @return {Promise}
    * @resolves When the list has been populated.
    * @rejects JavaScript exception.
    */
   loadPersistent: function DI_loadPersistent(aList)
   {
     if (this.dontLoad) {
@@ -119,17 +144,49 @@ this.DownloadIntegration = {
 
     if (this._store) {
       throw new Error("loadPersistent may be called only once.");
     }
 
     this._store = new DownloadStore(aList, OS.Path.join(
                                               OS.Constants.Path.profileDir,
                                               "downloads.json"));
-    return this._store.load();
+    this._store.onsaveitem = this.shouldPersistDownload.bind(this);
+
+    // Load the list of persistent downloads, then add the DownloadAutoSaveView
+    // even if the load operation failed.
+    return this._store.load().then(null, Cu.reportError).then(() => {
+      new DownloadAutoSaveView(aList, this._store);
+    });
+  },
+
+  /**
+   * Determines if a Download object from the list of persistent downloads
+   * should be saved into a file, so that it can be restored across sessions.
+   *
+   * This function allows filtering out downloads that the host application is
+   * not interested in persisting across sessions, for example downloads that
+   * finished successfully.
+   *
+   * @param aDownload
+   *        The Download object to be inspected.  This is originally taken from
+   *        the global DownloadList object for downloads that were not started
+   *        from a private browsing window.  The item may have been removed
+   *        from the list since the save operation started, though in this case
+   *        the save operation will be repeated later.
+   *
+   * @return True to save the download, false otherwise.
+   */
+  shouldPersistDownload: function (aDownload)
+  {
+    // In the default implementation, we save all the downloads currently in
+    // progress, as well as stopped downloads for which we retained partially
+    // downloaded data.  Stopped downloads for which we don't need to track the
+    // presence of a ".part" file are only retained in the browser history.
+    return aDownload.hasPartialData || !aDownload.stopped;
   },
 
   /**
    * Returns the system downloads directory asynchronously.
    *
    * @return {Promise}
    * @resolves The nsIFile of downloads directory.
    */
@@ -488,28 +545,35 @@ this.DownloadIntegration = {
    *        The public or private downloads list.
    * @param aIsPrivate
    *        True if the list is private, false otherwise.
    *
    * @return {Promise}
    * @resolves When the views and observers are added.
    */
   addListObservers: function DI_addListObservers(aList, aIsPrivate) {
+    if (this.dontLoad) {
+      return Promise.resolve();
+    }
+
     DownloadObserver.registerView(aList, aIsPrivate);
     if (!DownloadObserver.observersAdded) {
       DownloadObserver.observersAdded = true;
       Services.obs.addObserver(DownloadObserver, "quit-application-requested", true);
       Services.obs.addObserver(DownloadObserver, "offline-requested", true);
       Services.obs.addObserver(DownloadObserver, "last-pb-context-exiting", true);
     }
     return Promise.resolve();
   }
 };
 
-let DownloadObserver = {
+////////////////////////////////////////////////////////////////////////////////
+//// DownloadObserver
+
+this.DownloadObserver = {
   /**
    * Flag to determine if the observers have been added previously.
    */
   observersAdded: false,
 
   /**
    * Set that contains the in progress publics downloads.
    * It's keep updated when a public download is added, removed or change its
@@ -652,8 +716,139 @@ let DownloadObserver = {
   },
 
   ////////////////////////////////////////////////////////////////////////////
   //// nsISupports
 
   QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver,
                                          Ci.nsISupportsWeakReference])
 };
+
+////////////////////////////////////////////////////////////////////////////////
+//// DownloadAutoSaveView
+
+/**
+ * This view can be added to a DownloadList object to trigger a save operation
+ * in the given DownloadStore object when a relevant change occurs.
+ *
+ * @param aStore
+ *        The DownloadStore object used for saving.
+ */
+function DownloadAutoSaveView(aList, aStore) {
+  this._store = aStore;
+  this._downloadsMap = new Map();
+
+  // We set _initialized to true after adding the view, so that onDownloadAdded
+  // doesn't cause a save to occur.
+  aList.addView(this);
+  this._initialized = true;
+}
+
+DownloadAutoSaveView.prototype = {
+  /**
+   * True when the initial state of the downloads has been loaded.
+   */
+  _initialized: false,
+
+  /**
+   * The DownloadStore object used for saving.
+   */
+  _store: null,
+
+  /**
+   * This map contains only Download objects that should be saved to disk, and
+   * associates them with the result of their getSerializationHash function, for
+   * the purpose of detecting changes to the relevant properties.
+   */
+  _downloadsMap: null,
+
+  /**
+   * This is set to true when the save operation should be triggered.  This is
+   * required so that a new operation can be scheduled while the current one is
+   * in progress, without re-entering the save method.
+   */
+  _shouldSave: false,
+
+  /**
+   * nsITimer used for triggering the save operation after a delay, or null if
+   * saving has finished and there is no operation scheduled for execution.
+   *
+   * The logic here is different from the DeferredTask module in that multiple
+   * requests will never delay the operation for longer than the expected time
+   * (no grace delay), and the operation is never re-entered during execution.
+   */
+  _timer: null,
+
+  /**
+   * Timer callback used to serialize the list of downloads.
+   */
+  _save: function ()
+  {
+    Task.spawn(function () {
+      // Any save request received during execution will be handled later.
+      this._shouldSave = false;
+
+      // Execute the asynchronous save operation.
+      try {
+        yield this._store.save();
+      } catch (ex) {
+        Cu.reportError(ex);
+      }
+
+      // Handle requests received during the operation.
+      this._timer = null;
+      if (this._shouldSave) {
+        this.saveSoon();
+      }
+    }.bind(this)).then(null, Cu.reportError);
+  },
+
+  /**
+   * Called when the list of downloads changed, this triggers the asynchronous
+   * serialization of the list of downloads.
+   */
+  saveSoon: function ()
+  {
+    this._shouldSave = true;
+    if (!this._timer) {
+      this._timer = new Timer(this._save.bind(this), kSaveDelayMs,
+                              Ci.nsITimer.TYPE_ONE_SHOT);
+    }
+  },
+
+  //////////////////////////////////////////////////////////////////////////////
+  //// DownloadList view
+
+  onDownloadAdded: function (aDownload)
+  {
+    if (DownloadIntegration.shouldPersistDownload(aDownload)) {
+      this._downloadsMap.set(aDownload, aDownload.getSerializationHash());
+      if (this._initialized) {
+        this.saveSoon();
+      }
+    }
+  },
+
+  onDownloadChanged: function (aDownload)
+  {
+    if (!DownloadIntegration.shouldPersistDownload(aDownload)) {
+      if (this._downloadsMap.has(aDownload)) {
+        this._downloadsMap.delete(aDownload);
+        this.saveSoon();
+      }
+      return;
+    }
+
+    let hash = aDownload.getSerializationHash();
+    if (this._downloadsMap.get(aDownload) != hash) {
+      this._downloadsMap.set(aDownload, hash);
+      this.saveSoon();
+    }
+  },
+
+  onDownloadRemoved: function (aDownload)
+  {
+    if (this._downloadsMap.has(aDownload)) {
+      this._downloadsMap.delete(aDownload);
+      this.saveSoon();
+    }
+  },
+};
--- a/toolkit/components/jsdownloads/src/DownloadList.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadList.jsm
@@ -166,16 +166,21 @@ DownloadList.prototype = {
    *          },
    *          onDownloadChanged: function (aDownload) {
    *            // Called after the properties of aDownload change.
    *          },
    *          onDownloadRemoved: function (aDownload) {
    *            // Called after aDownload is removed from the list.
    *          },
    *        }
+   *
+   * @note The onDownloadAdded notifications are sent synchronously.  This
+   *       allows for a complete initialization of the view used for detecting
+   *       changes to downloads to be persisted, before other callers get a
+   *       chance to modify them.
    */
   addView: function DL_addView(aView)
   {
     this._views.add(aView);
 
     if (aView.onDownloadAdded) {
       for (let download of this._downloads) {
         try {
--- a/toolkit/components/jsdownloads/src/DownloadStore.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadStore.jsm
@@ -84,16 +84,22 @@ DownloadStore.prototype = {
   list: null,
 
   /**
    * String containing the file path where data should be saved.
    */
   path: "",
 
   /**
+   * This function is called with a Download object as its first argument, and
+   * should return true if the item should be saved.
+   */
+  onsaveitem: () => true,
+
+  /**
    * Loads persistent downloads from the file to the list.
    *
    * @return {Promise}
    * @resolves When the operation finished successfully.
    * @rejects JavaScript exception.
    */
   load: function DS_load()
   {
@@ -106,17 +112,33 @@ DownloadStore.prototype = {
         return;
       }
 
       let storeData = JSON.parse(gTextDecoder.decode(bytes));
 
       // Create live downloads based on the static snapshot.
       for (let downloadData of storeData.list) {
         try {
-          this.list.add(yield Downloads.createDownload(downloadData));
+          let download = yield Downloads.createDownload(downloadData);
+          try {
+            if (("stopped" in downloadData) && !downloadData.stopped) {
+              // Try to restart the download if it was in progress during the
+              // previous session.
+              download.start();
+            } else {
+              // If the download was not in progress, try to update the current
+              // progress from disk.  This is relevant in case we retained
+              // partially downloaded data.
+              yield download.refresh();
+            }
+          } finally {
+            // Add the download to the list if we succeeded in creating it,
+            // after we have updated its initial state.
+            this.list.add(download);
+          }
         } catch (ex) {
           // If an item is unrecognized, don't prevent others from being loaded.
           Cu.reportError(ex);
         }
       }
     }.bind(this));
   },
 
@@ -134,16 +156,19 @@ DownloadStore.prototype = {
     return Task.spawn(function task_DS_save() {
       let downloads = yield this.list.getAll();
 
       // Take a static snapshot of the current state of all the downloads.
       let storeData = { list: [] };
       let atLeastOneDownload = false;
       for (let download of downloads) {
         try {
+          if (!this.onsaveitem(download)) {
+            continue;
+          }
           storeData.list.push(download.toSerializable());
           atLeastOneDownload = true;
         } catch (ex) {
           // If an item cannot be converted to a serializable form, don't
           // prevent others from being saved.
           Cu.reportError(ex);
         }
       }