Search code examples
javascriptasynchronousasync-awaites6-promiseevent-loop

timeout loop in promise never executes after promise is resolved?


I'm running into an issue where a callback sent to setTimeout from a resolved promise never get executed.

supposed I have the following:

class Foo {
  constructor(foo) {
    this.foo = foo;
  }

  async execUntilStop(callback) {
    const timeoutLoopCallback = () => {
      if (this.stopExec) return;
      callback({ data: 'data' });
      setTimeout(timeoutLoopCallback, 10);
    };
    setTimeout(timeoutLoopCallback, 10);

    return { data: 'data'};
  }

  stop() {
    this.stopExec = true;
  }
}

const myFunc = async function () {
  let callbackCalled = false;
  const callback = () => callbackCalled = true;
  foo = new Foo('foo');
  foo.execUntilStop(callback);
  const hasCallbackCalled = async () => callbackCalled;

  while(!(await hasCallbackCalled())) null;
  foo.stop();
  return 'success!';
};

myFunc().then((result) => console.log(result))

myFunc() never resolves as it is continually waiting for callbackCalled to be true.

What am I missing here? I believe the event loop shouldn't be blocked since I'm calling await on an async function to check if the callback has been called. I hypothesise it has something to do with timeoutLoopCallback being bound to a resolved promise but I'm not a javascript expert and could use some feedback.

Note: This looks a little odd but essentially this is derivative of a class I'm trying to write test cases for that will be continually executing a callback until stopped.


SOLVED

Using what I learned from @traktor53 answer, I wrote a handy dandy wait function:

// resolves when callback returns true
const wait = callback => new Promise((resolve, reject) => {
  const end = () => {
    try {
      if (callback()) {
        resolve(true);
      } else {
        setTimeout(end, 0);
      }
    } catch(error) {
      reject(error);
    }
  };
  setTimeout(end, 0);
});


class Foo {
  constructor(foo) {
    this.foo = foo;
  }

  async execUntilStop(callback) {
    const timeoutLoopCallback = () => {
      if (this.stopExec) return;
      callback({ data: 'data' });
      setTimeout(timeoutLoopCallback, 10);
    };
    setTimeout(timeoutLoopCallback, 10);

    return { data: 'data'};
  }

  stop() {
    this.stopExec = true;
  }
}

const myFunc = async function (num) {
  let callbackCalled = false;
  const callback = () => callbackCalled = true;
  foo = new Foo('foo');
  foo.execUntilStop(callback);

  const hasCallbackCalled = () => callbackCalled;
  await wait(hasCallbackCalled);
  foo.stop();
  return 'success!';
};

myFunc().then((result) => console.log(result)); // => success!

Solution

  • Jobs to handle promise settlement go into a "Promise Job Queue" (PJQ) described in ECMAScript standards. This nomenclature is not often used in HTML documentation.

    Browsers (and at least one script engine) put jobs for the PJQ into what is commonly called the "Micro Task Queue" (MTQ). The event loop task manager checks the MTQ on return from script callout from the event loop, to see if it has any jobs in it, and will pop and executes the oldest job in the queue if there is one. The line in the original post

     while(!(await callbackCalled)) null;
    

    (which in the first call is equivalent to

    while( !( await Promise.resolve( false));  // callbackCalled is false
    

    )

    puts a job to get the settled value of the promise returned by Promise.resolve, into the MTQ and continue executing by having the await operator return the fulfilled value, which is false.

    Because browsers process the MTQ at a higher priority than tasks generated by timer expiry, execution continues after the await operation and immediately executes another iteration of the loop and puts another job into the MTQ to await the false value, without processing any timer call backs in between.

    This sets up an asynchronous infinite loop ( congratulations BTW, I havn't seen one before!), and under these conditions I would not expect the timer call back to execute and call timeoutLoopCallback a second time.

    The infinite loop is also blocking continuation to the next line:

      foo.stop()
    

    never executes.

    Note the blocking effect observed here is a consequence of the HTML implementation of the "Promise Job Queue" - The ECMAScript committed chose to not specify implimentation and priority details for real JavaScript systems. So blame HTML standards, not ECMAScript :D

    Also note: replacing await calledBackCalled with await hasCallbackCalled() will not fix the problem - different promise jobs will be generated, but the await operator will still return false.


    (Update) Since you ask, the actual steps for

     while(!(await hasCallbackCalled())) null;
    

    are:

    1. Evaluate hasCallbackCalled()
    2. 'hasCallbackCalled` is an async function and returns a promise fulfilled with the return value of the function body.
    3. The function body is synchronous code, and fulfills the returned promise on first call by synchronously returning the value of callbackCalled (which is false)
    4. The promise returned by the async function has so far been synchronously fulfilled with the value false.
    5. await now adds handlers by calling .then on the promise obtained in step 4, to let await know the settled value and state (in this case "fulfilled").
    6. But calling then on a fulfilled promise synchronously inserts a job to call the fulfilled handler with the fulfilled value into the MTQ
    7. the MTQ now has a job to call code for this particular await back;
    8. await returns to the event loop manager.
    9. the MTQ job now executes the then handler added in step 5,
    10. the then handler resumes await operator processing which returns the value false to user script.
    11. the while loop test continues execution from step 1.