adobe/brackets

View on GitHub
src/utils/Async.js

Summary

Maintainability
B
6 hrs
Test Coverage
/*
 * Copyright (c) 2012 - present Adobe Systems Incorporated. All rights reserved.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the "Software"),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 * DEALINGS IN THE SOFTWARE.
 *
 */

/**
 * Utilities for working with Deferred, Promise, and other asynchronous processes.
 */
define(function (require, exports, module) {
    "use strict";

    // Further ideas for Async utilities...
    //  - Utilities for blocking UI until a Promise completes?
    //  - A "SuperDeferred" could feature some very useful enhancements:
    //     - API for cancellation (non guaranteed, best attempt)
    //     - Easier way to add a timeout clause (withTimeout() wrapper below is more verbose)
    //     - Encapsulate the task kickoff code so you can start it later, e.g. superDeferred.start()
    //  - Deferred/Promise are unable to do anything akin to a 'finally' block. It'd be nice if we
    //    could harvest exceptions across all steps of an async process and pipe them to a handler,
    //    so that we don't leave UI-blocking overlays up forever, etc. But this is hard: we'd have
    //    wrap every async callback (including low-level native ones that don't use [Super]Deferred)
    //    to catch exceptions, and then understand which Deferred(s) the code *would* have resolved/
    //    rejected had it run to completion.


    /**
     * Executes a series of tasks in parallel, returning a "master" Promise that is resolved once
     * all the tasks have resolved. If one or more tasks fail, behavior depends on the failFast
     * flag:
     *   - If true, the master Promise is rejected as soon as the first task fails. The remaining
     *     tasks continue to completion in the background.
     *   - If false, the master Promise is rejected after all tasks have completed.
     *
     * If nothing fails:          (M = master promise; 1-4 = tasks; d = done; F = fail)
     *  M  ------------d
     *  1 >---d        .
     *  2 >------d     .
     *  3 >---------d  .
     *  4 >------------d
     *
     * With failFast = false:
     *  M  ------------F
     *  1 >---d     .  .
     *  2 >------d  .  .
     *  3 >---------F  .
     *  4 >------------d
     *
     * With failFast = true: -- equivalent to $.when()
     *  M  ---------F
     *  1 >---d     .
     *  2 >------d  .
     *  3 >---------F
     *  4 >------------d   (#4 continues even though master Promise has failed)
     * (Note: if tasks finish synchronously, the behavior is more like failFast=false because you
     * won't get a chance to respond to the master Promise until after all items have been processed)
     *
     * To perform task-specific work after an individual task completes, attach handlers to each
     * Promise before beginProcessItem() returns it.
     *
     * Note: don't use this if individual tasks (or their done/fail handlers) could ever show a user-
     * visible dialog: because they run in parallel, you could show multiple dialogs atop each other.
     *
     * @param {!Array.<*>} items
     * @param {!function(*, number):Promise} beginProcessItem
     * @param {!boolean} failFast
     * @return {$.Promise}
     */
    function doInParallel(items, beginProcessItem, failFast) {
        var promises = [];
        var masterDeferred = new $.Deferred();

        if (items.length === 0) {
            masterDeferred.resolve();

        } else {
            var numCompleted = 0;
            var hasFailed = false;

            items.forEach(function (item, i) {
                var itemPromise = beginProcessItem(item, i);
                promises.push(itemPromise);

                itemPromise.fail(function () {
                    if (failFast) {
                        masterDeferred.reject();
                    } else {
                        hasFailed = true;
                    }
                });
                itemPromise.always(function () {
                    numCompleted++;
                    if (numCompleted === items.length) {
                        if (hasFailed) {
                            masterDeferred.reject();
                        } else {
                            masterDeferred.resolve();
                        }
                    }
                });
            });

        }

        return masterDeferred.promise();
    }

    /**
     * Executes a series of tasks in serial (task N does not begin until task N-1 has completed).
     * Returns a "master" Promise that is resolved once all the tasks have resolved. If one or more
     * tasks fail, behavior depends on the failAndStopFast flag:
     *   - If true, the master Promise is rejected as soon as the first task fails. The remaining
     *     tasks are never started (the serial sequence is stopped).
     *   - If false, the master Promise is rejected after all tasks have completed.
     *
     * If nothing fails:
     *  M  ------------d
     *  1 >---d        .
     *  2     >--d     .
     *  3        >--d  .
     *  4           >--d
     *
     * With failAndStopFast = false:
     *  M  ------------F
     *  1 >---d     .  .
     *  2     >--d  .  .
     *  3        >--F  .
     *  4           >--d
     *
     * With failAndStopFast = true:
     *  M  ---------F
     *  1 >---d     .
     *  2     >--d  .
     *  3        >--F
     *  4          (#4 never runs)
     *
     * To perform task-specific work after an individual task completes, attach handlers to each
     * Promise before beginProcessItem() returns it.
     *
     * @param {!Array.<*>} items
     * @param {!function(*, number):Promise} beginProcessItem
     * @param {!boolean} failAndStopFast
     * @return {$.Promise}
     */
    function doSequentially(items, beginProcessItem, failAndStopFast) {

        var masterDeferred = new $.Deferred(),
            hasFailed = false;

        function doItem(i) {
            if (i >= items.length) {
                if (hasFailed) {
                    masterDeferred.reject();
                } else {
                    masterDeferred.resolve();
                }
                return;
            }

            var itemPromise = beginProcessItem(items[i], i);

            itemPromise.done(function () {
                doItem(i + 1);
            });
            itemPromise.fail(function () {
                if (failAndStopFast) {
                    masterDeferred.reject();
                    // note: we do NOT process any further items in this case
                } else {
                    hasFailed = true;
                    doItem(i + 1);
                }
            });
        }

        doItem(0);

        return masterDeferred.promise();
    }

    /**
     * Executes a series of synchronous tasks sequentially spread over time-slices less than maxBlockingTime.
     * Processing yields by idleTime between time-slices.
     *
     * @param {!Array.<*>} items
     * @param {!function(*, number)} fnProcessItem  Function that synchronously processes one item
     * @param {number=} maxBlockingTime
     * @param {number=} idleTime
     * @return {$.Promise}
     */
    function doSequentiallyInBackground(items, fnProcessItem, maxBlockingTime, idleTime) {

        maxBlockingTime = maxBlockingTime || 15;
        idleTime = idleTime || 30;

        var sliceStartTime = (new Date()).getTime();

        return doSequentially(items, function (item, i) {
            var result = new $.Deferred();

            // process the next item
            fnProcessItem(item, i);

            // if we've exhausted our maxBlockingTime
            if ((new Date()).getTime() - sliceStartTime >= maxBlockingTime) {
                //yield
                window.setTimeout(function () {
                    sliceStartTime = (new Date()).getTime();
                    result.resolve();
                }, idleTime);
            } else {
                //continue processing
                result.resolve();
            }

            return result;
        }, false);
    }

    /**
     * Executes a series of tasks in serial (task N does not begin until task N-1 has completed).
     * Returns a "master" Promise that is resolved when the first task has resolved. If all tasks
     * fail, the master Promise is rejected.
     *
     * @param {!Array.<*>} items
     * @param {!function(*, number):Promise} beginProcessItem
     * @return {$.Promise}
     */
    function firstSequentially(items, beginProcessItem) {

        var masterDeferred = new $.Deferred();

        function doItem(i) {
            if (i >= items.length) {
                masterDeferred.reject();
                return;
            }

            beginProcessItem(items[i], i)
                .fail(function () {
                    doItem(i + 1);
                })
                .done(function () {
                    masterDeferred.resolve(items[i]);
                });
        }

        doItem(0);
        return masterDeferred.promise();
    }

    /**
     * Executes a series of tasks in parallel, saving up error info from any that fail along the way.
     * Returns a Promise that is only resolved/rejected once all tasks are complete. This is
     * essentially a wrapper around doInParallel(..., false).
     *
     * If one or more tasks failed, the entire "master" promise is rejected at the end - with one
     * argument: an array objects, one per failed task. Each error object contains:
     *  - item -- the entry in items whose task failed
     *  - error -- the first argument passed to the fail() handler when the task failed
     *
     * @param {!Array.<*>} items
     * @param {!function(*, number):Promise} beginProcessItem
     * @return {$.Promise}
     */
    function doInParallel_aggregateErrors(items, beginProcessItem) {
        var errors = [];

        var masterDeferred = new $.Deferred();

        var parallelResult = doInParallel(
            items,
            function (item, i) {
                var itemResult = beginProcessItem(item, i);
                itemResult.fail(function (error) {
                    errors.push({ item: item, error: error });
                });
                return itemResult;
            },
            false
        );

        parallelResult
            .done(function () {
                masterDeferred.resolve();
            })
            .fail(function () {
                masterDeferred.reject(errors);
            });

        return masterDeferred.promise();
    }

    /** Value passed to fail() handlers that have been triggered due to withTimeout()'s timeout */
    var ERROR_TIMEOUT = {};

    /**
     * Adds timeout-driven termination to a Promise: returns a new Promise that is resolved/rejected when
     * the given original Promise is resolved/rejected, OR is resolved/rejected after the given delay -
     * whichever happens first.
     *
     * If the original Promise is resolved/rejected first, done()/fail() handlers receive arguments
     * piped from the original Promise. If the timeout occurs first instead, then resolve() or
     * fail() (with Async.ERROR_TIMEOUT) is called based on value of resolveTimeout.
     *
     * @param {$.Promise} promise
     * @param {number} timeout
     * @param {boolean=} resolveTimeout If true, then resolve deferred on timeout, otherwise reject. Default is false.
     * @return {$.Promise}
     */
    function withTimeout(promise, timeout, resolveTimeout) {
        var wrapper = new $.Deferred();

        var timer = window.setTimeout(function () {
            if (resolveTimeout) {
                wrapper.resolve();
            } else {
                wrapper.reject(ERROR_TIMEOUT);
            }
        }, timeout);
        promise.always(function () {
            window.clearTimeout(timer);
        });

        // If the wrapper was already rejected due to timeout, the Promise's calls to resolve/reject
        // won't do anything
        promise.then(wrapper.resolve, wrapper.reject);

        return wrapper.promise();
    }

    /**
     * Allows waiting for all the promises to be either resolved or rejected.
     * Unlike $.when(), it does not call .fail() or .always() handlers on first
     * reject. The caller should take all the precaution to make sure all the
     * promises passed to this function are completed to avoid blocking.
     *
     * If failOnReject is set to true, promise returned by the function will be
     * rejected if at least one of the promises was rejected. The default value
     * is false, which will cause the call to this function to be always
     * successfully resolved.
     *
     * If timeout is specified, the promise will be rejected on timeout as per
     * Async.withTimeout.
     *
     * @param {!Array.<$.Promise>} promises Array of promises to wait for
     * @param {boolean=} failOnReject       Whether to reject or not if one of the promises has been rejected.
     * @param {number=} timeout             Number of milliseconds to wait until rejecting the promise
     *
     * @return {$.Promise} A Promise which will be resolved once all dependent promises are resolved.
     *                     It is resolved with an array of results from the successfully resolved dependent promises.
     *                     The resulting array may not be in the same order or contain as many items as there were
     *                     promises to wait on and it will contain 'undefined' entries for those promises that resolve
     *                     without a result.
     *
     */
    function waitForAll(promises, failOnReject, timeout) {
        var masterDeferred = new $.Deferred(),
            results = [],
            count = 0,
            sawRejects = false;

        if (!promises || promises.length === 0) {
            masterDeferred.resolve();
            return masterDeferred.promise();
        }

        // set defaults if needed
        failOnReject = (failOnReject === undefined) ? false : true;

        if (timeout !== undefined) {
            withTimeout(masterDeferred, timeout);
        }

        promises.forEach(function (promise) {
            promise
                .fail(function (err) {
                    sawRejects = true;
                })
                .done(function (result) {
                    results.push(result);
                })
                .always(function () {
                    count++;
                    if (count === promises.length) {
                        if (failOnReject && sawRejects) {
                            masterDeferred.reject();
                        } else {
                            masterDeferred.resolve(results);
                        }
                    }
                });
        });

        return masterDeferred.promise();
    }

    /**
     * Chains a series of synchronous and asynchronous (jQuery promise-returning) functions
     * together, using the result of each successive function as the argument(s) to the next.
     * A promise is returned that resolves with the result of the final call if all calls
     * resolve or return normally. Otherwise, if any of the functions reject or throw, the
     * computation is halted immediately and the promise is rejected with this halting error.
     *
     * @param {Array.<function(*)>} functions Functions to be chained
     * @param {?Array} args Arguments to call the first function with
     * @return {jQuery.Promise} A promise that resolves with the result of the final call, or
     *      rejects with the first error.
     */
    function chain(functions, args) {
        var deferred = $.Deferred();

        function chainHelper(index, args) {
            if (functions.length === index) {
                deferred.resolveWith(null, args);
            } else {
                var nextFunction = functions[index++];
                try {
                    var responseOrPromise = nextFunction.apply(null, args);
                    if (responseOrPromise.hasOwnProperty("done") &&
                            responseOrPromise.hasOwnProperty("fail")) {
                        responseOrPromise.done(function () {
                            chainHelper(index, arguments);
                        });
                        responseOrPromise.fail(function () {
                            deferred.rejectWith(null, arguments);
                        });
                    } else {
                        chainHelper(index, [responseOrPromise]);
                    }
                } catch (e) {
                    deferred.reject(e);
                }
            }
        }

        chainHelper(0, args || []);

        return deferred.promise();
    }

    /**
     * Utility for converting a method that takes (error, callback) to one that returns a promise;
     * useful for using FileSystem methods (or other Node-style API methods) in a promise-oriented
     * workflow. For example, instead of
     *
     *      var deferred = new $.Deferred();
     *      file.read(function (err, contents) {
     *          if (err) {
     *              deferred.reject(err);
     *          } else {
     *              // ...process the contents...
     *              deferred.resolve();
     *          }
     *      }
     *      return deferred.promise();
     *
     * you can just do
     *
     *      return Async.promisify(file, "read").then(function (contents) {
     *          // ...process the contents...
     *      });
     *
     * The object/method are passed as an object/string pair so that we can
     * properly call the method without the caller having to deal with "bind" all the time.
     *
     * @param {Object} obj The object to call the method on.
     * @param {string} method The name of the method. The method should expect the errback
     *      as its last parameter.
     * @param {...Object} varargs The arguments you would have normally passed to the method
     *      (excluding the errback itself).
     * @return {$.Promise} A promise that is resolved with the arguments that were passed to the
     *      errback (not including the err argument) if err is null, or rejected with the err if
     *      non-null.
     */
    function promisify(obj, method) {
        var result = new $.Deferred(),
            args = Array.prototype.slice.call(arguments, 2);
        args.push(function (err) {
            if (err) {
                result.reject(err);
            } else {
                result.resolve.apply(result, Array.prototype.slice.call(arguments, 1));
            }
        });
        obj[method].apply(obj, args);
        return result.promise();
    }

    /**
     * Creates a queue of async operations that will be executed sequentially. Operations can be added to the
     * queue at any time. If the queue is empty and nothing is currently executing when an operation is added,
     * it will execute immediately. Otherwise, it will execute when the last operation currently in the queue
     * has finished.
     * @constructor
     */
    function PromiseQueue() {
        this._queue = [];
    }

    /**
     * @private
     * @type {Array.<function(): $.Promise>}
     * The queue of operations to execute sequentially. Note that even if this array is empty, there might
     * still be an operation we need to wait on; that operation's promise is stored in _curPromise.
     */
    PromiseQueue.prototype._queue = null;

    /**
     * @private
     * @type {$.Promise}
     * The promise we're currently waiting on, or null if there's nothing currently executing.
     */
    PromiseQueue.prototype._curPromise = null;

    /**
     * @type {number} The number of queued promises.
     */
    Object.defineProperties(PromiseQueue.prototype, {
        "length": {
            get: function () { return this._queue.length; },
            set: function () { throw new Error("Cannot set length"); }
        }
    });

    /**
     * Adds an operation to the queue. If nothing is currently executing, it will execute immediately (and
     * the next operation added to the queue will wait for it to complete). Otherwise, it will wait until
     * the last operation in the queue (or the currently executing operation if nothing is in the queue) is
     * finished. The operation must return a promise that will be resolved or rejected when it's finished;
     * the queue will continue with the next operation regardless of whether the current operation's promise
     * is resolved or rejected.
     * @param {function(): $.Promise} op The operation to add to the queue.
     */
    PromiseQueue.prototype.add = function (op) {
        this._queue.push(op);

        // If something is currently executing, then _doNext() will get called when it's done. If nothing
        // is executing (in which case the queue should have been empty), we need to call _doNext() to kickstart
        // the queue.
        if (!this._curPromise) {
            this._doNext();
        }
    };

    /**
     * Removes all pending promises from the queue.
     */
    PromiseQueue.prototype.removeAll = function () {
        this._queue = [];
    };

    /**
     * @private
     * Pulls the next operation off the queue and executes it.
     */
    PromiseQueue.prototype._doNext = function () {
        var self = this;
        if (this._queue.length) {
            var op = this._queue.shift();
            this._curPromise = op();
            this._curPromise.always(function () {
                self._curPromise = null;
                self._doNext();
            });
        }
    };

    // Define public API
    exports.doInParallel        = doInParallel;
    exports.doSequentially      = doSequentially;
    exports.doSequentiallyInBackground   = doSequentiallyInBackground;
    exports.doInParallel_aggregateErrors = doInParallel_aggregateErrors;
    exports.firstSequentially   = firstSequentially;
    exports.withTimeout         = withTimeout;
    exports.waitForAll          = waitForAll;
    exports.ERROR_TIMEOUT       = ERROR_TIMEOUT;
    exports.chain               = chain;
    exports.promisify           = promisify;
    exports.PromiseQueue        = PromiseQueue;
});