AsyncGenerator.prototype.return() - JavaScript | MDN states:
The
return()
method of an async generator acts as if areturn
statement is inserted in the generator's body at the current suspended position, which finishes the generator and allows the generator to perform any cleanup tasks when combined with atry...finally
block.
Why then does the following code print 0
–3
rather than only 0
–2
?
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const values = (async function* delayedIntegers() {
let n = 0;
while (true) {
yield n++;
await delay(100);
}
})();
await Promise.all([
(async () => {
for await (const value of values) console.log(value);
})(),
(async () => {
await delay(250);
values.return();
})(),
]);
I tried adding log statements to better understand where the "current suspended position" is and from what I can tell when I call the return()
method the AsyncGenerator
instance isn't suspended (the body execution isn't at a yield
statement) and instead of returning once reaching the yield
statement the next value is yielded
and then suspended at which point the "return" finally happens.
Is there any way to detect that the return()
method has been invoked and not yield
afterwards?
I can implement the AsyncIterator
interface myself but then I lose the yield
syntax supported by async generators:
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const values = (() => {
let n = 0;
let done = false;
return {
[Symbol.asyncIterator]() {
return this;
},
async next() {
if (done) return { done, value: undefined };
if (n !== 0) {
await delay(100);
if (done) return { done, value: undefined };
}
return { done, value: n++ };
},
async return() {
done = true;
return { done, value: undefined };
},
};
})();
await Promise.all([
(async () => {
for await (const value of values) console.log(value);
})(),
(async () => {
await delay(250);
values.return();
})(),
]);
Why does the code print
0–3
rather than only0–2
? From what I can tell, when I call thereturn()
method, theAsyncGenerator
instance isn't suspended (the body execution isn't at ayield
statement) and instead of returning once reaching theyield
statement the next value isyield
ed and then suspended at which point the "return" finally happens.
Yes, precisely this is what happens. The generator is already running because the for await … of
loop did call its .next()
method, and so the generator will complete that before considering the .return()
call.
All the methods that you invoke on an async generator are queued. (In a sync generator, you'd get a "TypeError: Generator is already running" instead). One can demonstrate this by immediately calling next
multiple times:
const values = (async function*() {
let i=0; while (true) {
await new Promise(r => { setTimeout(r, 1000); });
yield i++;
}
})();
values.next().then(console.log, console.error);
values.next().then(console.log, console.error);
values.next().then(console.log, console.error);
values.return('done').then(console.log, console.error);
values.next().then(console.log, console.error);
Is there any way to detect that the
return()
method has been invoked and notyield
afterwards?
No, not from within the generator. And really you probably still should yield the value if you already expended the effort to produce it.
It sounds like what you want to do is to ignore the produced value when you want the generator to stop. You should do that in your for await … of
loop - and you can also use it to stop the generator by using a break
statement:
const delay = (ms) => new Promise((resolve) => {
setTimeout(resolve, ms);
});
async function* delayedIntegers() {
let n = 0;
while (true) {
yield n++;
await delay(1000);
}
}
(async function main() {
const start = Date.now();
const values = delayedIntegers();
for await (const value of values) {
if (Date.now() - start > 2500) {
console.log('done:', value);
break;
}
console.log(value);
}
})();
But if you really want to abort the generator from the outside, you need an out-of-band channel to signal the cancellation. You can use an AbortSignal
for this:
const delay = (ms, signal) => new Promise((resolve, reject) => {
function done() {
resolve();
signal?.removeEventListener("abort", stop);
}
function stop() {
reject(this.reason);
clearTimeout(handle);
}
signal?.throwIfAborted();
const handle = setTimeout(done, ms);
signal?.addEventListener("abort", stop, {once: true});
});
async function* delayedIntegers(signal) {
let n = 0;
while (true) {
yield n++;
await delay(1000, signal);
}
}
(async function main() {
try {
const values = delayedIntegers(AbortSignal.timeout(2500));
for await (const value of values) {
console.log(value);
}
} catch(e) {
if (e.name != "TimeoutError") throw e;
console.log("done");
}
})();
This will actually permit to stop the generator during the timeout, not after the full second has elapsed.