The problem with async generators

Let’s imagine that we want to manage cancellation of an async operation using generator functions in javascript. When a secondary event occurs, we can listen for it and force the generator function to return by calling its return method and handle cleanup in the finally block, which will always run before a generator returns. Say we have the following imaginary async generator, which will wait for a second and yield;

var generator = async function*() {
    try {
        yield await new Promise((r) => {
            setTimeout(r, 1000)
        }).then(() => 'Waited for 1 sec.')
        // More yields...
    } finally {
        // Do cleanup here. e.g. abort an XHR or invoke a
        // cancellation on some ongoing operation
        console.log('Did cleanup.')
    }
}

Then we want to run such a generator to completion in a loop. One way to do this is using an asynchronous for await…of loop. Let’s say we call return before the async operation in the generator is complete.

var it = generator();

setTimeout(() => {
    console.log('Force return.');
    it.return();
}, 500)

for await (val of it) {
    console.log(val)
}

What we expect is finally block being executed before the one second Promise gets resolved, so that we can cancel that operation. But instead, if you go ahead and open your developer console now (if your browser is supporting the syntax) and try the above scenario you get;

Force return.
Waited for 1 sec.
Did cleanup.

So what happened? Indeed we were able to call return way before the second long Promise was resolved but our async generator preferred to wait until the promise is resolved and after then executed the finally block. Indeed if you think about it, it should be what we actually expect as we are instructing it to do nothing until it resolves with await. The intended operation then, is not possible with this method.

The solution

We need to ditch the async generator and for await…of syntax to make things work as we intended. First, let’s convert the async generator into a regular generator function and take the redux-saga approach and yield the Promise instead;

var generator = function*() {
  try {
    yield new Promise(r => {
      setTimeout(r, 1000);
    }).then(() => "Waited for 1 sec.");
    // More yields...
  } finally {
    // This will be clearer later on.
    const res = yield "checkCancelled";
    console.log("Did cleanup.", res);
  }
};

Then our execution loop needs to handle a Promise as a yield value and wait until it is resolved before continuing with the next iteration cycle. A naive implementation can be seen below.

function generatorRunner(generator) {
  var cancel = null;
  var cancelled = false;

  var initiateCancel = it => {
    console.log("Force return.");
    var yieldValue = it.return();
    // Provide the generator with cancel information
    if (yieldValue.value === "checkCancelled") {
      it.next(true);
    }
  };

  (async () => {
    var it = generator();
    for (val of it) {
      // We are not yet waiting for anything,
      // just force cancel and break the loop
      if (cancelled) {
        initiateCancel(it);
        break;
      }
      if (val instanceof Promise) {
        var cancelPromise = new Promise((\_, rej) => {
          cancel = rej;
        });
        try {
          // Wait for the first one to finish
          var result = await Promise.race(\[val, cancelPromise\]);
          console.log(result);
        } catch (e) {
          // if we have rejected for cancel
          if (e === "initiateCancel") {
            initiateCancel(it);
          } else {
            throw e;
          }
        }
      }
      // Handle other yield types.
    }
  })();

  return () => {
    cancelled = true;
    cancel("initiateCancel");
  };
}

For the sake of completeness, we have implemented a generatorRunner here, which will return a cancel function, when executed before the 1 second Promise resolves, will invoke the finally block as we wanted to.

var cancel = generatorRunner(generator);
setTimeout(cancel, 500);

Prints;

Force return.
Did cleanup. true

And when we run the same runner without invoking cancel until completion, console reads;

Waited for 1 sec.
Did cleanup. undefined

Here, we also see that the yield with a value of checkCancelled hinted the runner to pass along the cancel status so that the finally block is able to discriminate a cancellation event and act accordingly. We achieve this endeavor with the initiateCancel function, which calls it.next once more after forcefully returning the function with true if the generator requested cancel information. These selective yield responses are handled by returning special ‘effects’ in redux-saga but we are just using strings here, just for demonstration purposes.

Write a comment below if you have a better way to achieve the same effect!

Do not expect the js engine to handle it all for you. Be creative!

Kyle Simpson2nd December 2020 at 21:45
archived comment

This post got me thinking, we could create a utility that’s a bit more comprehensive than the one in the article, one that will wrap regular generators and let them “act” still like async generators with the full iteration protocol (so they work with for await (..) loops, etc), but where those async generators can still be stopped immediately with a call to return(..).

I took a stab at such a utility as a proof of concept: https://gist.github.com/getify/0e1b1ef7e270c6d16f4d1d616296eda4

© Ali Naci Erdem 2024