diff --git a/.travis.yml b/.travis.yml index 64cb8da..7b94aa2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ language: node_js node_js: - - "0.12" - "4" - "6" + - "node" after_success: 'make report-coverage' diff --git a/README.md b/README.md index 7bf3eae..84d620c 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,11 @@ reissue takes the following options to its `create()` method: * `opts.func` {Function} the function to execute. This function is invoked with a callback function as it's last parameter. -* `opts.interval` {Number | Function} the inteval in ms to execute the function, -or a function that returns an interval, allowing usage of a dynamic interval. +* `opts.interval` {Number | Function} the interval in ms to execute the +function, or a function that returns an interval, allowing usage of a dynamic +interval. +* `[opts.timeout]` {Number} an optional timeout in ms. if any invocation of the +the supplied func exceeds this timeout, the `timeout` event is fired. * `[opts.context]` {Context} an optional `this` context for the function. use this in lieu of native `bind()` if you are concerned about performance. reissue uses `apply()` under the hood to do context/arg binding. @@ -98,8 +101,16 @@ If your function returns an error to the callback, this event will be emitted. The subscribed function will receive an error as it's only parameter. ### handler.on('stop', function() {...}) -The stop event is emitted when reissue successfully completes any ongoing -function invocations and stops all future invocations. +When the `stop()` method is called, this event is emitted when either the +current invocation is successfully completed, or when the next scheduled +invocation is successfully cancelled. If the current invocation is "stuck" in +the sense that the callback never returns, the stop event will never fire. + +### handler.on('timeout', function() {...}) +If a `timeout` value is specified, this event will be fired when any given +invocation of the function exceeds the specified value. However, if your user +supplied function is synchronous, and never gives up the event loop, it is +possible that this event may never get fired. ## Contributing @@ -127,6 +138,6 @@ make codestyle-fix ## License -Copyright (c) 2015 Alex Liu +Copyright (c) 2017 Alex Liu Licensed under the MIT license. diff --git a/lib/index.js b/lib/index.js index 71d1e4c..c57a7f0 100644 --- a/lib/index.js +++ b/lib/index.js @@ -24,6 +24,9 @@ var bind = require('./bind'); * @param {Object} opts.func an the function to execute * @param {Object | Function} opts.interval the interval to execute at, or a * function that returns an interval. function allows for dynamic intervals. + * @param {Number} [opts.timeout] an optional timeout value that causes the + * `timeout` event to be fired when a given invocation exceeds this value. + * function that returns an interval. function allows for dynamic intervals. * @param {Object} [opts.context] the context to bind the function to * @param {Object} [opts.args] any arguments to pass to the function */ @@ -34,6 +37,7 @@ function Reissue(opts) { assert.func(opts.func, 'func'); assert.optionalObject(opts.context, 'context'); assert.optionalArray(opts.args, 'args'); + assert.optionalNumber(opts.timeout, 'timeout'); // assert options of different types var typeofInterval = typeof opts.interval; @@ -76,6 +80,12 @@ function Reissue(opts) { self._funcArgs = (opts.args) ? opts.args.concat(boundDone) : [boundDone]; + /** + * schedule an optional timeout which will trigger timeout event if a + * given invocation exceeds this number. + * @type {Number} + */ + self._timeoutMs = opts.timeout || null; //-------------------------------------------------------------------------- // internal properties @@ -94,12 +104,6 @@ function Reissue(opts) { */ self._startTime = 0; - /** - * setTimeout handler of next invocation - * @type {Function} - */ - self._nextHandlerId = null; - /** * boolean flag set when we are waiting for user supplied function to * complete. technically we should know this if self._nextHandlerId had @@ -108,6 +112,19 @@ function Reissue(opts) { * @type {Boolean} */ self._inUserFunc = false; + + /** + * setTimeout handler of next invocation + * @type {Function} + */ + self._nextHandlerId = null; + + /* + * setTimeout handler of an internal timeout implementation. used to fire + * the timeout event if a user supplied function takes too long. + * @type {Function} + */ + self._timeoutHandlerId = null; } util.inherits(Reissue, events.EventEmitter); @@ -124,16 +141,31 @@ util.inherits(Reissue, events.EventEmitter); * @return {undefined} */ Reissue.prototype._execute = function _execute() { + var self = this; - self._startTime = Date.now(); - // this last invocation might have been scheduled before user called - // stop(). ensure we're still active before invocation. - if (self._active === true) { - // set flag so we know we're currently in user supplied func - self._inUserFunc = true; - // execute their func + // start invocation timer + self._startTime = Date.now(); + // set flag so we know we're currently in user supplied func + self._inUserFunc = true; + // execute their func on a setImmediate, such that we can schedule the + // timeout first. to be clear though, user func could be sync and our + // timeout may never fire. + setImmediate(function _executeImmediately() { self._func.apply(self._funcContext, self._funcArgs); + }); + + // if timeout option is specified, schedule one here. basically, we + // execute the next invocation immediately above, then schedule a + // timeout handler, so we basically have two set timeout functions + // being scheduled. + if (self._timeoutMs !== null) { + // assign timeout to self so that we can cancel it if we complete + // on time. + self._timeoutHandlerId = setTimeout( + bind(self._onTimeout, self), + self._timeoutMs + ); } }; @@ -163,25 +195,40 @@ Reissue.prototype._done = function _done(err) { self.emit('error', err); } - // if user called stop(), we're done! don't queue up another invocation. - if (self._active === false) { - return; - } else { - // start invocation immediately if: the elapsedTime is greater than the - // interval, which means the last execution took longer than the - // interval itself. otherwise, subtract the time the previous - // invocation took. - var timeToInvocation = (elapsedTime >= interval) ? - 0 : (interval - elapsedTime); - - self._nextHandlerId = setTimeout(function _nextInvocation() { - self._execute(); - }, timeToInvocation); + // in every other case, we're fine, since we've finished before the + // timeout event has occurred. call _internalDone where we will clear + // the timeout event. + return _internalDone(); + + // common completion function called by forked code above + function _internalDone() { + + // clear any timeout handlers + if (self._timeoutHandlerId) { + clearTimeout(self._timeoutHandlerId); + self._timeoutHandlerId = null; + } + + // if user called stop() sometime during last invocation, we're done! + // don't queue up another invocation. + if (self._active === false) { + self._stop(); + } else { + // start invocation immediately if: the elapsedTime is greater than + // the interval, which means the last execution took longer than + // the interval itself. otherwise, subtract the time the previous + // invocation took. + var timeToInvocation = (elapsedTime >= interval) ? + 0 : (interval - elapsedTime); + + self._nextHandlerId = setTimeout(function _nextInvocation() { + self._execute(); + }, timeToInvocation); + } } }; - /** * internal implementation of stop. clears all timeout handlers and emits the * stop event. @@ -199,10 +246,34 @@ Reissue.prototype._stop = function _stop() { self._nextHandlerId = null; } + // no need to clear timeout handlers, as they're already cleared + // in _done before we get here. + + // emit stop, and we're done! self.emit('stop'); }; +/** + * called when the interval function "times out", or in other words takes + * longer than then specified timeout interval. this blocks the next invocation + * of the interval function until user calls the callback on the timeout + * event. + * @private + * @method _onTimeout + * @return {undefined} + */ +Reissue.prototype._onTimeout = function _onTimeout() { + + var self = this; + + // we might have called stop during current invocation. emit timeout event + // only if we're still active. + if (self._active === true) { + self.emit('timeout'); + } +}; + //------------------------------------------------------------------------------ // public methods //------------------------------------------------------------------------------ @@ -216,9 +287,12 @@ Reissue.prototype._stop = function _stop() { * @return {undefined} */ Reissue.prototype.start = function start(delay) { - var self = this; + assert.optionalNumber(delay); + var self = this; + var realDelay = (typeof delay === 'number' && delay >= 0) ? delay : 0; + // before starting, see if reissue is already active. if so, throw an // error. if (self._active === true) { @@ -228,13 +302,9 @@ Reissue.prototype.start = function start(delay) { // set the flag and off we go! self._active = true; - if (typeof delay === 'number') { - self._nextHandlerId = setTimeout(function _nextInvocation() { - self._execute(); - }, delay); - } else { + self._nextHandlerId = setTimeout(function _nextInvocation() { self._execute(); - } + }, realDelay); }; @@ -245,6 +315,7 @@ Reissue.prototype.start = function start(delay) { * @return {undefined} */ Reissue.prototype.stop = function stop() { + var self = this; // NOTE: while the below if statements could be collapsed to be more more @@ -258,7 +329,6 @@ Reissue.prototype.stop = function stop() { // here, we are either: // 1) queued up waiting for the next invocation // 2) waiting for user supplied function to complete - if (self._inUserFunc === false) { // case #1 // if we're just waiting for the next invocation, call stop now diff --git a/test/index.js b/test/index.js index aa0d75f..556f909 100644 --- a/test/index.js +++ b/test/index.js @@ -341,27 +341,6 @@ describe('Reissue module', function() { }); - it('should start asynchronously after explicit 0 delay', function(done) { - - var async = false; - - var timer = reissue.create({ - func: function(callback) { - - // if async === false, this was called synchronously - assert.equal(async, true); - return done(); - // no need to call reissue's callback here, as that will just - // invoke this function again and call done() which will fail - // the test. - }, - interval: 100 - }); - timer.start(0); - async = true; - }); - - it('should not execute first invocation if stop was called', function(done) { @@ -398,14 +377,12 @@ describe('Reissue module', function() { it('should emit stop, cancelling next invocation', function(done) { - var out = []; var i = 0; - var timer = reissue.create({ func: function(callback) { out.push(i++); - return callback(); + return setTimeout(callback, 400); }, interval: 500 }); @@ -420,7 +397,7 @@ describe('Reissue module', function() { // this should allow two invocations, then cancel the third. setTimeout(function() { timer.stop(); - }, 1000); + }, 900); }); @@ -442,11 +419,6 @@ describe('Reissue module', function() { interval: 500 }); - timer.on('stop', function() { - assert.deepEqual(out, [0,1]); - return done(); - }); - timer.start(); // this should allow two invocations, calling stop while user supplied @@ -454,7 +426,104 @@ describe('Reissue module', function() { // completes, stop should get emitted and the third invocation is never // scheduled. setTimeout(function() { + timer.on('stop', function() { + assert.deepEqual(out, [0,1]); + return done(); + }); timer.stop(); }, 1000); }); + + + it('should emit timeout event', function(done) { + + var callCount = 0; + var timeoutFired = false; + var timer = reissue.create({ + func: function(callback) { + callCount++; + return setTimeout(callback, 300); + }, + interval: 1000, + timeout: 150 + }); + + timer.on('timeout', function() { + timeoutFired = true; + }); + + timer.start(); + + // call stop after first invocation completes but before next + // one is fired + setTimeout(function() { + timer.on('stop', function() { + assert.isTrue(timeoutFired); + assert.equal(callCount, 1); + return done(); + }); + timer.stop(); + }, 400); + }); + + + it('should stop during invocation, and timeout event should not fire', + function(done) { + + var callCount = 0; + var timeoutFired = false; + var timer = reissue.create({ + func: function(callback) { + callCount++; + return setTimeout(callback, 250); + }, + interval: 200, + timeout: 100 + }); + + timer.on('timeout', function() { + timeoutFired = true; + }); + + timer.start(); + + // first invocation should fire, and while we're waiting for it, we + // call stop. we should not invoke it a second time, and should not + // fire the timeout event. + setTimeout(function() { + timer.on('stop', function() { + assert.isFalse(timeoutFired); + assert.equal(callCount, 1); + return done(); + }); + timer.stop(); + }, 0); + }); + + + it('call stop during invocation, and timeout fires before invocation ' + + 'completes', function(done) { + + var timer = reissue.create({ + func: function(callback) { + return setTimeout(callback, 250); + }, + interval: 200, + timeout: 400 + }); + + timer.on('timeout', function(callback) { + assert.fail('should not get here!'); + return callback(); + }); + + timer.start(); + + // first invocation should fire, and while we're waiting for it to + // complete (250ms) stop is called. timeout event should not fire. + setTimeout(function() { + timer.on('stop', done); + timer.stop(); + }, 100); + }); });