Search code examples
javascriptnode.jsrecursionpromisebluebird

Strange infinite recursion behavior with Promises


I created a NodeJS program (with Bluebird as Promise library) that handles some validations similar to how the snippet below works, but if I run that script it throws the following error:

Unhandled rejection RangeError: Maximum call stack size exceeded

Apparently, it's doing some recursive function call at the reassigning of the validations functions where I used .bind(ctx)

The way I solved that problem was assigning the Promise factory to obj._validate instead of reassigning obj.validate and use _validate(ctx) where it's needed.

But I still don't realize why that error happened. Can someone explain to me?

// Example validation function
function validate(pass, fail) {
  const ctx = this
  Promise.resolve(ctx.value) // Simulate some async validation
    .then((value) => {
      if (value === 'pass') pass()
      if (value == 'fail') fail('Validation failed!')
    })
}

let validations = [
  {name: 'foo', validate: validate},
  {name: 'bar', validate: validate},
  {name: 'baz', validate: validate},
  {name: 'qux', validate: validate}
]

// Reassigning validate functions to a promise factory
// to handle async validation
validations.forEach(obj => {
  obj.validate = (ctx) => { // ctx used as context to validation
    return new Promise(obj.validate.bind(ctx))
  }
})

function executeValidations(receivedValues, validations) {
  receivedValues.forEach((obj, i) => {
    validations[i].validate(obj) // obj becomes the context to validate
      .then(() => console.log('Validation on', obj.name, 'passed'))
      .catch(e => console.error('Validation error on', obj.name, ':', e))
  })
}

let receivedValues1 = [
  {name: 'foo', value: 'pass'},
  {name: 'bar', value: 'fail'},
  {name: 'baz', value: 'fail'},
  {name: 'qux', value: 'pass'},
]

executeValidations(receivedValues1, validations)

let receivedValues2 = [
  {name: 'foo', value: 'pass'},
  {name: 'bar', value: 'pass'},
  {name: 'baz', value: 'fail'},
  {name: 'qux', value: 'fail'},
]

executeValidations(receivedValues2, validations)
<script src="//cdn.jsdelivr.net/bluebird/3.4.7/bluebird.js"></script>

EDIT: I think this is a short version of the problem

function fn(res, rej) { return this.foo }

fn = function(ctx) { return new Promise(fn.bind(ctx))}

const ctx = {foo: 'bar'}
fn(ctx)
  .then(console.log)
<script src="//cdn.jsdelivr.net/bluebird/3.4.7/bluebird.js"></script>


Solution

  •     obj.validate.bind(ctx)
    

    evaluates to an exotic function object with its this value set to ctx. It is still, very much, a function object.

    It then appears that

        obj.validate = (ctx) => { // ctx used as context to validation
        return new Promise(obj.validate.bind(ctx))
    

    sets obj.validate to a function which returns a promise which during its construction synchronously calls its resolver function obj.validate.bind(ctx) (a.k.a. "executor function" in ES6) which returns a promise object whose construction synchronously calls obj.validate.bind(ctx), and so on ad infinitum or the JavaScript engine throws an error.

    Hence calling obj.validate a first time initiates an infinite loop of promise prodution by the resolver function.

    A further issue with bind usage:

    Arrow functions bind their lexical this value when declared. Syntactically Function.prototype.bind can be applied to an arrow function but does not change the this value seen by the arrow function!

    Hence obj.validate.bind(ctx) never updates the this value seen within obj.validate if the method was defined using an arrow function.


    Edit:

    The biggest problem may be overwriting the value of the function which performs the operation:

    As posted:

        validations.forEach(obj => {
          obj.validate = (ctx) => { // ctx used as context to validation
            return new Promise(obj.validate.bind(ctx))
          }
    

    overwrites the validate property of each validations entry. This property used to be the named function validate declared at the start, but is no longer.

    In the short version,

        function fn(res, rej) { return this.foo }
    
        fn = function(ctx) { return new Promise(fn.bind(ctx))}
    
        const ctx = {foo: 'bar'}
        fn(ctx)
    

    fn = function... overwrites the named function declaration of fn. This means that when fn is called later, the fn of fn.bind(ctx) refers to the updated version of fn, not the original.

    Note also that the resolver function must call its first function parameter (resolve) to synchronously resolve a new promise. Return values of the resolver function are ignored.