Bug 810490 - Constant stack space promise. r=mossop
authorPaolo Amadini <paolo.mozmail@amadzone.org>
Thu, 30 May 2013 14:23:42 +0200
changeset 133447 87bbe2a5b08af46e1ca3a7d17f3217efdef3b282
parent 133446 d7c6d6061ab59a60446ea3c9d1e092dfdb04811e
child 133448 d71234d65e90c487182729cfce61c98666be0f51
push id28766
push userpaolo.mozmail@amadzone.org
push dateThu, 30 May 2013 12:24:37 +0000
treeherdermozilla-inbound@87bbe2a5b08a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmossop
bugs810490
milestone24.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 810490 - Constant stack space promise. r=mossop
addon-sdk/moz.build
addon-sdk/test/moz.build
addon-sdk/test/unit/head.js
addon-sdk/test/unit/test_promise.js
addon-sdk/test/unit/xpcshell.ini
toolkit/modules/Makefile.in
toolkit/modules/Promise.jsm
toolkit/modules/tests/xpcshell/test_Promise.js
toolkit/modules/tests/xpcshell/xpcshell.ini
--- a/addon-sdk/moz.build
+++ b/addon-sdk/moz.build
@@ -1,8 +1,6 @@
 # -*- Mode: python; c-basic-offset: 4; 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/.
 
-TEST_DIRS += ['test']
-
deleted file mode 100644
--- a/addon-sdk/test/moz.build
+++ /dev/null
@@ -1,9 +0,0 @@
-# -*- Mode: python; c-basic-offset: 4; 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/.
-
-MODULE = 'test_addon_sdk'
-
-XPCSHELL_TESTS_MANIFESTS += ['unit/xpcshell.ini']
deleted file mode 100644
--- a/addon-sdk/test/unit/head.js
+++ /dev/null
@@ -1,74 +0,0 @@
-/* Any copyright is dedicated to the Public Domain.
- * http://creativecommons.org/publicdomain/zero/1.0/ */
-
-
-Components.utils.import("resource://gre/modules/commonjs/sdk/core/promise.js");
-
-let run_promise_tests = function run_promise_tests(tests, cb) {
-  let timer = Components.classes["@mozilla.org/timer;1"]
-     .createInstance(Components.interfaces.nsITimer);
-  let loop = function loop(index) {
-    if (index >= tests.length) {
-      if (cb) {
-        cb.call();
-      }
-      return;
-    }
-    do_print("Launching test " + (index + 1) + "/" + tests.length);
-    let test = tests[index];
-    // Execute from an empty stack
-    let next = function next() {
-      do_print("Test " + (index + 1) + "/" + tests.length + " complete");
-      do_execute_soon(function() {
-        loop(index + 1);
-      });
-    };
-    let result = test();
-    result.then(next, next);
-  };
-  return loop(0);
-};
-
-let make_promise_test = function(test) {
-  return function runtest() {
-    do_print("Test starting: " + test);
-    try {
-      let result = test();
-      if (result && "promise" in result) {
-        result = result.promise;
-      }
-      if (!result || !("then" in result)) {
-        let exn;
-        try {
-          do_throw("Test " + test + " did not return a promise: " + result);
-        } catch (x) {
-          exn = x;
-        }
-        return Promise.reject(exn);
-      }
-      // The test returns a promise
-      result = result.then(
-        // Test complete
-        function onResolve() {
-          do_print("Test complete: " + test);
-        },
-        // The test failed with an unexpected error
-        function onReject(err) {
-          let detail;
-          if (err && typeof err == "object" && "stack" in err) {
-            detail = err.stack;
-          } else {
-            detail = "(no stack)";
-          }
-          do_throw("Test " + test + " rejected with the following reason: "
-              + err + detail);
-      });
-      return result;
-    } catch (x) {
-      // The test failed because of an error outside of a promise
-      do_throw("Error in body of test " + test + ": " + x + " at " + x.stack);
-      return Promise.reject();
-    }
-  };
-};
-
deleted file mode 100644
--- a/addon-sdk/test/unit/xpcshell.ini
+++ /dev/null
@@ -1,5 +0,0 @@
-[DEFAULT]
-head = head.js
-tail = 
-
-[test_promise.js]
--- a/toolkit/modules/Makefile.in
+++ b/toolkit/modules/Makefile.in
@@ -21,16 +21,17 @@ EXTRA_JS_MODULES := \
   FileUtils.jsm \
   Geometry.jsm \
   InlineSpellChecker.jsm \
   NewTabUtils.jsm \
   PageMenu.jsm \
   PopupNotifications.jsm \
   Preferences.jsm \
   PrivateBrowsingUtils.jsm \
+  Promise.jsm \
   PropertyListUtils.jsm \
   RemoteWebProgress.jsm \
   Sqlite.jsm \
   Task.jsm \
   TelemetryTimestamps.jsm \
   Timer.jsm \
   $(NULL)
 
new file mode 100755
--- /dev/null
+++ b/toolkit/modules/Promise.jsm
@@ -0,0 +1,515 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */
+/* 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/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = [
+  "Promise"
+];
+
+/**
+ * This module implements the "promise" construct, according to the
+ * "Promises/A+" proposal as known in April 2013, documented here:
+ *
+ * <http://promises-aplus.github.com/promises-spec/>
+ *
+ * A promise is an object representing a value that may not be available yet.
+ * Internally, a promise can be in one of three states:
+ *
+ * - Pending, when the final value is not available yet.  This is the only state
+ *   that may transition to one of the other two states.
+ *
+ * - Resolved, when and if the final value becomes available.  A resolution
+ *   value becomes permanently associated with the promise.  This may be any
+ *   value, including "undefined".
+ *
+ * - Rejected, if an error prevented the final value from being determined.  A
+ *   rejection reason becomes permanently associated with the promise.  This may
+ *   be any value, including "undefined", though it is generally an Error
+ *   object, like in exception handling.
+ *
+ * A reference to an existing promise may be received by different means, for
+ * example as the return value of a call into an asynchronous API.  In this
+ * case, the state of the promise can be observed but not directly controlled.
+ *
+ * To observe the state of a promise, its "then" method must be used.  This
+ * method registers callback functions that are called as soon as the promise is
+ * either resolved or rejected.  The method returns a new promise, that in turn
+ * is resolved or rejected depending on the state of the original promise and on
+ * the behavior of the callbacks.  For example, unhandled exceptions in the
+ * callbacks cause the new promise to be rejected, even if the original promise
+ * is resolved.  See the documentation of the "then" method for details.
+ *
+ * Promises may also be created using the "Promise.defer" function, the main
+ * entry point of this module.  The function, along with the new promise,
+ * returns separate methods to change its state to be resolved or rejected.
+ * See the documentation of the "Deferred" prototype for details.
+ *
+ * -----------------------------------------------------------------------------
+ *
+ * Cu.import("resource://gre/modules/Promise.jsm");
+ *
+ * // This function creates and returns a new promise.
+ * function promiseValueAfterTimeout(aValue, aTimeout)
+ * {
+ *   let deferred = Promise.defer();
+ *
+ *   try {
+ *     // An asynchronous operation will trigger the resolution of the promise.
+ *     // In this example, we don't have a callback that triggers a rejection.
+ *     do_timeout(aTimeout, function () {
+ *       deferred.resolve(aValue);
+ *     });
+ *   } catch (ex) {
+ *     // Generally, functions returning promises propagate exceptions through
+ *     // the returned promise, though they may also choose to fail early.
+ *     deferred.reject(ex);
+ *   }
+ *
+ *   // We don't return the deferred to the caller, but only the contained
+ *   // promise, so that the caller cannot accidentally change its state.
+ *   return deferred.promise;
+ * }
+ *
+ * // This code uses the promise returned be the function above.
+ * let promise = promiseValueAfterTimeout("Value", 1000);
+ *
+ * let newPromise = promise.then(function onResolve(aValue) {
+ *   do_print("Resolved with this value: " + aValue);
+ * }, function onReject(aReason) {
+ *   do_print("Rejected with this reason: " + aReason);
+ * });
+ *
+ * // Unexpected errors should always be reported at the end of a promise chain.
+ * newPromise.then(null, Components.utils.reportError);
+ *
+ * -----------------------------------------------------------------------------
+ */
+
+////////////////////////////////////////////////////////////////////////////////
+//// Globals
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+const Cr = Components.results;
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+const STATUS_PENDING = 0;
+const STATUS_RESOLVED = 1;
+const STATUS_REJECTED = 2;
+
+// These "private names" allow some properties of the Promise object to be
+// accessed only by this module, while still being visible on the object
+// manually when using a debugger.  They don't strictly guarantee that the
+// properties are inaccessible by other code, but provide enough protection to
+// avoid using them by mistake.
+const salt = Math.floor(Math.random() * 100);
+const Name = (n) => "{private:" + n + ":" + salt + "}";
+const N_STATUS = Name("status");
+const N_VALUE = Name("value");
+const N_HANDLERS = Name("handlers");
+
+////////////////////////////////////////////////////////////////////////////////
+//// Promise
+
+/**
+ * This object provides the public module functions.
+ */
+this.Promise = Object.freeze({
+  /**
+   * Creates a new pending promise and provides methods to resolve or reject it.
+   *
+   * @return A new object, containing the new promise in the "promise" property,
+   *         and the methods to change its state in the "resolve" and "reject"
+   *         properties.  See the Deferred documentation for details.
+   */
+  defer: function ()
+  {
+    return new Deferred();
+  },
+
+  /**
+   * Creates a new promise resolved with the specified value, or propagates the
+   * state of an existing promise.
+   *
+   * @param aValue
+   *        If this value is not a promise, including "undefined", it becomes
+   *        the resolution value of the returned promise.  If this value is a
+   *        promise, then the returned promise will eventually assume the same
+   *        state as the provided promise.
+   *
+   * @return A promise that can be pending, resolved, or rejected.
+   */
+  resolve: function (aValue)
+  {
+    let promise = new PromiseImpl();
+    PromiseWalker.completePromise(promise, STATUS_RESOLVED, aValue);
+    return promise;
+  },
+
+  /**
+   * Creates a new promise rejected with the specified reason.
+   *
+   * @param aReason
+   *        The rejection reason for the returned promise.  Although the reason
+   *        can be "undefined", it is generally an Error object, like in
+   *        exception handling.
+   *
+   * @return A rejected promise.
+   *
+   * @note The aReason argument should not be a promise.  Using a rejected
+   *       promise for the value of aReason would make the rejection reason
+   *       equal to the rejected promise itself, and not its rejection reason.
+   */
+  reject: function (aReason)
+  {
+    let promise = new PromiseImpl();
+    PromiseWalker.completePromise(promise, STATUS_REJECTED, aReason);
+    return promise;
+  },
+});
+
+////////////////////////////////////////////////////////////////////////////////
+//// PromiseWalker
+
+/**
+ * This singleton object invokes the handlers registered on resolved and
+ * rejected promises, ensuring that processing is not recursive and is done in
+ * the same order as registration occurred on each promise.
+ *
+ * There is no guarantee on the order of execution of handlers registered on
+ * different promises.
+ */
+this.PromiseWalker = {
+  /**
+   * Singleton array of all the unprocessed handlers currently registered on
+   * resolved or rejected promises.  Handlers are removed from the array as soon
+   * as they are processed.
+   */
+  handlers: [],
+
+  /**
+   * Called when a promise needs to change state to be resolved or rejected.
+   *
+   * @param aPromise
+   *        Promise that needs to change state.  If this is already resolved or
+   *        rejected, this method has no effect.
+   * @param aStatus
+   *        New desired status, either STATUS_RESOLVED or STATUS_REJECTED.
+   * @param aValue
+   *        Associated resolution value or rejection reason.
+   */
+  completePromise: function (aPromise, aStatus, aValue)
+  {
+    // Do nothing if the promise is already resolved or rejected.
+    if (aPromise[N_STATUS] != STATUS_PENDING) {
+      return;
+    }
+
+    // Resolving with another promise will cause this promise to eventually
+    // assume the state of the provided promise.
+    if (aStatus == STATUS_RESOLVED && aValue &&
+        typeof(aValue.then) == "function") {
+      aValue.then(this.completePromise.bind(this, aPromise, STATUS_RESOLVED),
+                  this.completePromise.bind(this, aPromise, STATUS_REJECTED));
+      return;
+    }
+
+    // Change the promise status and schedule our handlers for processing.
+    aPromise[N_STATUS] = aStatus;
+    aPromise[N_VALUE] = aValue;
+    if (aPromise[N_HANDLERS].length > 0) {
+      this.schedulePromise(aPromise);
+    }
+  },
+
+  /**
+   * Schedules the resolution or rejection handlers registered on the provided
+   * promise for processing.
+   *
+   * @param aPromise
+   *        Resolved or rejected promise whose handlers should be processed.  It
+   *        is expected that this promise has at least one handler to process.
+   */
+  schedulePromise: function (aPromise)
+  {
+    // Migrate the handlers from the provided promise to the global list.
+    for (let handler of aPromise[N_HANDLERS]) {
+      this.handlers.push(handler);
+    }
+    aPromise[N_HANDLERS].length = 0;
+
+    // Schedule the walker loop on the next tick of the event loop.
+    if (!this.walkerLoopScheduled) {
+      this.walkerLoopScheduled = true;
+      Services.tm.currentThread.dispatch(this.walkerLoop,
+                                         Ci.nsIThread.DISPATCH_NORMAL);
+    }
+  },
+
+  /**
+   * Indicates whether the walker loop is currently scheduled for execution on
+   * the next tick of the event loop.
+   */
+  walkerLoopScheduled: false,
+
+  /**
+   * Processes all the known handlers during this tick of the event loop.  This
+   * eager processing is done to avoid unnecessarily exiting and re-entering the
+   * JavaScript context for each handler on a resolved or rejected promise.
+   *
+   * This function is called with "this" bound to the PromiseWalker object.
+   */
+  walkerLoop: function ()
+  {
+    // Allow rescheduling the walker loop immediately.  This makes this walker
+    // resilient to the case where one handler does not return, but starts a
+    // nested event loop.  In that case, the newly scheduled walker will take
+    // over.  In the common case, the newly scheduled walker will be invoked
+    // after this one has returned, with no actual handler to process.  This
+    // small overhead is required to make nested event loops work correctly, but
+    // occurs at most once per resolution chain, thus having only a minor
+    // impact on overall performance.
+    this.walkerLoopScheduled = false;
+
+    // Process all the known handlers eagerly.
+    while (this.handlers.length > 0) {
+      this.handlers.shift().process();
+    }
+  },
+};
+
+// Bind the function to the singleton once.
+PromiseWalker.walkerLoop = PromiseWalker.walkerLoop.bind(PromiseWalker);
+
+////////////////////////////////////////////////////////////////////////////////
+//// Deferred
+
+/**
+ * Returned by "Promise.defer" to provide a new promise along with methods to
+ * change its state.
+ */
+function Deferred()
+{
+  this.promise = new PromiseImpl();
+  this.resolve = this.resolve.bind(this);
+  this.reject = this.reject.bind(this);
+
+  Object.freeze(this);
+}
+
+Deferred.prototype = {
+  /**
+   * A newly created promise, initially in the pending state.
+   */
+  promise: null,
+
+  /**
+   * Resolves the associated promise with the specified value, or propagates the
+   * state of an existing promise.  If the associated promise has already been
+   * resolved or rejected, this method does nothing.
+   *
+   * This function is bound to its associated promise when "Promise.defer" is
+   * called, and can be called with any value of "this".
+   *
+   * @param aValue
+   *        If this value is not a promise, including "undefined", it becomes
+   *        the resolution value of the associated promise.  If this value is a
+   *        promise, then the associated promise will eventually assume the same
+   *        state as the provided promise.
+   *
+   * @note Calling this method with a pending promise as the aValue argument,
+   *       and then calling it again with another value before the promise is
+   *       resolved or rejected, has unspecified behavior and should be avoided.
+   */
+  resolve: function (aValue) {
+    PromiseWalker.completePromise(this.promise, STATUS_RESOLVED, aValue);
+  },
+
+  /**
+   * Rejects the associated promise with the specified reason.  If the promise
+   * has already been resolved or rejected, this method does nothing.
+   *
+   * This function is bound to its associated promise when "Promise.defer" is
+   * called, and can be called with any value of "this".
+   *
+   * @param aReason
+   *        The rejection reason for the associated promise.  Although the
+   *        reason can be "undefined", it is generally an Error object, like in
+   *        exception handling.
+   *
+   * @note The aReason argument should not generally be a promise.  In fact,
+   *       using a rejected promise for the value of aReason would make the
+   *       rejection reason equal to the rejected promise itself, not to the
+   *       rejection reason of the rejected promise.
+   */
+  reject: function (aReason) {
+    PromiseWalker.completePromise(this.promise, STATUS_REJECTED, aReason);
+  },
+};
+
+////////////////////////////////////////////////////////////////////////////////
+//// PromiseImpl
+
+/**
+ * The promise object implementation.  This includes the public "then" method,
+ * as well as private state properties.
+ */
+function PromiseImpl()
+{
+  /*
+   * Internal status of the promise.  This can be equal to STATUS_PENDING,
+   * STATUS_RESOLVED, or STATUS_REJECTED.
+   */
+  Object.defineProperty(this, N_STATUS, { value: STATUS_PENDING,
+                                          writable: true });
+
+  /*
+   * When the N_STATUS property is STATUS_RESOLVED, this contains the final
+   * resolution value, that cannot be a promise, because resolving with a
+   * promise will cause its state to be eventually propagated instead.  When the
+   * N_STATUS property is STATUS_REJECTED, this contains the final rejection
+   * reason, that could be a promise, even if this is uncommon.
+   */
+  Object.defineProperty(this, N_VALUE, { writable: true });
+
+  /*
+   * Array of Handler objects registered by the "then" method, and not processed
+   * yet.  Handlers are removed when the promise is resolved or rejected.
+   */
+  Object.defineProperty(this, N_HANDLERS, { value: [] });
+
+  Object.seal(this);
+}
+
+PromiseImpl.prototype = {
+  /**
+   * Calls one of the provided functions as soon as this promise is either
+   * resolved or rejected.  A new promise is returned, whose state evolves
+   * depending on this promise and the provided callback functions.
+   *
+   * The appropriate callback is always invoked after this method returns, even
+   * if this promise is already resolved or rejected.  You can also call the
+   * "then" method multiple times on the same promise, and the callbacks will be
+   * invoked in the same order as they were registered.
+   *
+   * @param aOnResolve
+   *        If the promise is resolved, this function is invoked with the
+   *        resolution value of the promise as its only argument, and the
+   *        outcome of the function determines the state of the new promise
+   *        returned by the "then" method.  In case this parameter is not a
+   *        function (usually "null"), the new promise returned by the "then"
+   *        method is resolved with the same value as the original promise.
+   *
+   * @param aOnReject
+   *        If the promise is rejected, this function is invoked with the
+   *        rejection reason of the promise as its only argument, and the
+   *        outcome of the function determines the state of the new promise
+   *        returned by the "then" method.  In case this parameter is not a
+   *        function (usually left "undefined"), the new promise returned by the
+   *        "then" method is rejected with the same reason as the original
+   *        promise.
+   *
+   * @return A new promise that is initially pending, then assumes a state that
+   *         depends on the outcome of the invoked callback function:
+   *          - If the callback returns a value that is not a promise, including
+   *            "undefined", the new promise is resolved with this resolution
+   *            value, even if the original promise was rejected.
+   *          - If the callback throws an exception, the new promise is rejected
+   *            with the exception as the rejection reason, even if the original
+   *            promise was resolved.
+   *          - If the callback returns a promise, the new promise will
+   *            eventually assume the same state as the returned promise.
+   *
+   * @note If the aOnResolve callback throws an exception, the aOnReject
+   *       callback is not invoked.  You can register a rejection callback on
+   *       the returned promise instead, to process any exception occurred in
+   *       either of the callbacks registered on this promise.
+   */
+  then: function (aOnResolve, aOnReject)
+  {
+    let handler = new Handler(this, aOnResolve, aOnReject);
+    this[N_HANDLERS].push(handler);
+
+    // Ensure the handler is scheduled for processing if this promise is already
+    // resolved or rejected.
+    if (this[N_STATUS] != STATUS_PENDING) {
+      PromiseWalker.schedulePromise(this);
+    }
+
+    return handler.nextPromise;
+  },
+};
+
+////////////////////////////////////////////////////////////////////////////////
+//// Handler
+
+/**
+ * Handler registered on a promise by the "then" function.
+ */
+function Handler(aThisPromise, aOnResolve, aOnReject)
+{
+  this.thisPromise = aThisPromise;
+  this.onResolve = aOnResolve;
+  this.onReject = aOnReject;
+  this.nextPromise = new PromiseImpl();
+}
+
+Handler.prototype = {
+  /**
+   * Promise on which the "then" method was called.
+   */
+  thisPromise: null,
+
+  /**
+   * Unmodified resolution handler provided to the "then" method.
+   */
+  onResolve: null,
+
+  /**
+   * Unmodified rejection handler provided to the "then" method.
+   */
+  onReject: null,
+
+  /**
+   * New promise that will be returned by the "then" method.
+   */
+  nextPromise: null,
+
+  /**
+   * Called after thisPromise is resolved or rejected, invokes the appropriate
+   * callback and propagates the result to nextPromise.
+   */
+  process: function()
+  {
+    // The state of this promise is propagated unless a handler is defined.
+    let nextStatus = this.thisPromise[N_STATUS];
+    let nextValue = this.thisPromise[N_VALUE];
+
+    try {
+      // If a handler is defined for either resolution or rejection, invoke it
+      // to determine the state of the next promise, that will be resolved with
+      // the returned value, that can also be another promise.
+      if (nextStatus == STATUS_RESOLVED) {
+        if (typeof(this.onResolve) == "function") {
+          nextValue = this.onResolve(nextValue);
+        }
+      } else if (typeof(this.onReject) == "function") {
+        nextValue = this.onReject(nextValue);
+        nextStatus = STATUS_RESOLVED;
+      }
+    } catch (ex) {
+      // If an exception occurred in the handler, reject the next promise.
+      nextStatus = STATUS_REJECTED;
+      nextValue = ex;
+    }
+
+    // Propagate the newly determined state to the next promise.
+    PromiseWalker.completePromise(this.nextPromise, nextStatus, nextValue);
+  },
+};
rename from addon-sdk/test/unit/test_promise.js
rename to toolkit/modules/tests/xpcshell/test_Promise.js
--- a/addon-sdk/test/unit/test_promise.js
+++ b/toolkit/modules/tests/xpcshell/test_Promise.js
@@ -1,12 +1,88 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 "use strict";
 
+Components.utils.import("resource://gre/modules/Promise.jsm");
+
+////////////////////////////////////////////////////////////////////////////////
+//// Test runner
+
+let run_promise_tests = function run_promise_tests(tests, cb) {
+  let timer = Components.classes["@mozilla.org/timer;1"]
+     .createInstance(Components.interfaces.nsITimer);
+  let loop = function loop(index) {
+    if (index >= tests.length) {
+      if (cb) {
+        cb.call();
+      }
+      return;
+    }
+    do_print("Launching test " + (index + 1) + "/" + tests.length);
+    let test = tests[index];
+    // Execute from an empty stack
+    let next = function next() {
+      do_print("Test " + (index + 1) + "/" + tests.length + " complete");
+      do_execute_soon(function() {
+        loop(index + 1);
+      });
+    };
+    let result = test();
+    result.then(next, next);
+  };
+  return loop(0);
+};
+
+let make_promise_test = function(test) {
+  return function runtest() {
+    do_print("Test starting: " + test);
+    try {
+      let result = test();
+      if (result && "promise" in result) {
+        result = result.promise;
+      }
+      if (!result || !("then" in result)) {
+        let exn;
+        try {
+          do_throw("Test " + test + " did not return a promise: " + result);
+        } catch (x) {
+          exn = x;
+        }
+        return Promise.reject(exn);
+      }
+      // The test returns a promise
+      result = result.then(
+        // Test complete
+        function onResolve() {
+          do_print("Test complete: " + test);
+        },
+        // The test failed with an unexpected error
+        function onReject(err) {
+          let detail;
+          if (err && typeof err == "object" && "stack" in err) {
+            detail = err.stack;
+          } else {
+            detail = "(no stack)";
+          }
+          do_throw("Test " + test + " rejected with the following reason: "
+              + err + detail);
+      });
+      return result;
+    } catch (x) {
+      // The test failed because of an error outside of a promise
+      do_throw("Error in body of test " + test + ": " + x + " at " + x.stack);
+      return Promise.reject();
+    }
+  };
+};
+
+////////////////////////////////////////////////////////////////////////////////
+//// Tests
+
 let tests = [];
 
 // Utility function to observe an failures in a promise
 // This function is useful if the promise itself is
 // not returned.
 let observe_failures = function observe_failures(promise) {
   promise.then(null, function onReject(reason) {
     test.do_throw("Observed failure in test " + test + ": " + reason);
@@ -54,16 +130,79 @@ tests.push(make_promise_test(
     // Install remaining observers
     for(;i < SIZE; ++i) {
       install_observer(i);
     }
 
     return result;
   }));
 
+// Test that observers registered on a pending promise are notified in order.
+tests.push(
+  make_promise_test(function then_returns_before_callbacks(test) {
+    let deferred = Promise.defer();
+    let promise = deferred.promise;
+
+    let order = 0;
+
+    promise.then(
+      function onResolve() {
+        do_check_eq(order, 0);
+        order++;
+      }
+    );
+
+    promise.then(
+      function onResolve() {
+        do_check_eq(order, 1);
+        order++;
+      }
+    );
+
+    let newPromise = promise.then(
+      function onResolve() {
+        do_check_eq(order, 2);
+      }
+    );
+
+    deferred.resolve();
+
+    // This test finishes after the last handler succeeds.
+    return newPromise;
+  }));
+
+// Test that observers registered on a resolved promise are notified in order.
+tests.push(
+  make_promise_test(function then_returns_before_callbacks(test) {
+    let promise = Promise.resolve();
+
+    let order = 0;
+
+    promise.then(
+      function onResolve() {
+        do_check_eq(order, 0);
+        order++;
+      }
+    );
+
+    promise.then(
+      function onResolve() {
+        do_check_eq(order, 1);
+        order++;
+      }
+    );
+
+    // This test finishes after the last handler succeeds.
+    return promise.then(
+      function onResolve() {
+        do_check_eq(order, 2);
+      }
+    );
+  }));
+
 // Test that all observers are notified at most once, even if source
 // is resolved/rejected several times
 tests.push(make_promise_test(
   function notification_once(test) {
     // The size of the test
     const SIZE = 10;
     const RESULT = "this is an arbitrary value";
 
@@ -384,13 +523,70 @@ tests.push(
     let promise = Promise.resolve(RESULT).then(
       function onResolve(result) {
         do_check_eq(result, RESULT, "Promise.resolve propagated the correct result");
       }
     );
     return promise;
   }));
 
+// Test that the code after "then" is always executed before the callbacks
+tests.push(
+  make_promise_test(function then_returns_before_callbacks(test) {
+    let promise = Promise.resolve();
+
+    let thenExecuted = false;
+
+    promise = promise.then(
+      function onResolve() {
+        thenExecuted = true;
+      }
+    );
+
+    do_check_false(thenExecuted);
+
+    return promise;
+  }));
+
+// Test that chaining promises does not generate long stack traces
+tests.push(
+  make_promise_test(function chaining_short_stack(test) {
+    let source = Promise.defer();
+    let promise = source.promise;
+
+    const NUM_ITERATIONS = 100;
+
+    for (let i = 0; i < NUM_ITERATIONS; i++) {
+      promise = promise.then(
+        function onResolve(result) {
+          return result + ".";
+        }
+      );
+    }
+
+    promise = promise.then(
+      function onResolve(result) {
+        // Check that the execution went as expected.
+        let expectedString = new Array(1 + NUM_ITERATIONS).join(".");
+        do_check_true(result == expectedString);
+
+        // Check that we didn't generate one or more stack frames per iteration.
+        let stackFrameCount = 0;
+        let stackFrame = Components.stack;
+        while (stackFrame) {
+          stackFrameCount++;
+          stackFrame = stackFrame.caller;
+        }
+
+        do_check_true(stackFrameCount < NUM_ITERATIONS);
+      }
+    );
+
+    source.resolve("");
+
+    return promise;
+  }));
+
 function run_test()
 {
   do_test_pending();
   run_promise_tests(tests, do_test_finished);
 }
--- a/toolkit/modules/tests/xpcshell/xpcshell.ini
+++ b/toolkit/modules/tests/xpcshell/xpcshell.ini
@@ -1,14 +1,15 @@
 [DEFAULT]
 head =
 tail =
 
 [test_dict.js]
 [test_FileUtils.js]
 [test_newtab-migrate-v1.js]
 [test_Preferences.js]
+[test_Promise.js]
 [test_propertyListsUtils.js]
 [test_readCertPrefs.js]
 [test_sqlite.js]
 [test_task.js]
 [test_TelemetryTimestamps.js]
 [test_timer.js]