Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

fix(asynciterable): use more yield #379

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

jeengbe
Copy link

@jeengbe jeengbe commented Jan 19, 2025

I went through the async-iterable functions and swapped out custom while loops with yield where I could. Also took the liberty to clean up some of the code while at it. The benefit of yield over custom code is that it takes care of returning the wrapped iterator, etc.


As a consequence of microsoft/TypeScript#61022, the following pattern fails for operators ( concat) implemented using yield* on targets that use <= ES2017 (passes for higher targets):

test('yield*', async () => {
  let i = 0;

  async function* asyncGenerator() {
    i++;
    yield 1;
  }

  const res = concat(asyncGenerator(), asyncGenerator()).pipe(take(1));
  const items = await toArray(res);

  expect(i).toBe(1); // Actually is 2 because loop continues
});

So even though yield* should be able to be used in most places, because of this, I went with loops and yield.

@jeengbe

This comment was marked as outdated.

@jeengbe

This comment was marked as outdated.

@jeengbe jeengbe marked this pull request as ready for review January 22, 2025 19:34
@trxcllnt
Copy link
Member

Wow that TS issue is concerning. Is the addition of the returnAsyncIterators function related to that change? It looks to be materially different from the existing behavior, e.g. it doesn't await the it.return() Promises.

@jeengbe
Copy link
Author

jeengbe commented Jan 24, 2025

The bug just means that we can't use yield* and must instead manually iterate and yield individual items. For all we care here, both are functionally equivalent.

I'm not fully certain about what the best play with returning iterators is, but (thinking out loud here), if you look at for example timeout():

try {
  while (1) {
    const { type, value } = await safeRace<TimeoutOperation<TSource>>([
      it.next().then((val) => {
        return { type: VALUE_TYPE, value: val };
      }),
      sleep(this._dueTime, signal).then(() => {
        return { type: ERROR_TYPE };
      }),
    ]);

    if (type === ERROR_TYPE) {
      throw new TimeoutError();
    }

    if (!value || value.done) {
      break;
    }
    yield value.value;
  }
} finally {
  await it?.return?.();
}

What we want in finally is not necessarily to return the iterator, but to abort it instead. The following test should pass, but currently doesn't. It hangs, and Jest eventually kills the run.

test('AsyncIterable#timeout with never', async () => {
  const xs = never().pipe(timeout(1000));

  const it = xs[Symbol.asyncIterator]();
  await noNext(it);
});

The following snippet hopefully demonstrates why we can't await it.return() calls.

(async () => {
    setTimeout(() => { }, 10e3); // Keep event loop running

    async function* a() {
        await new Promise(() => { });
    }

    const it = a();
    void it.next();
    await it.return();
    console.log("Done")
})()

If you return a generator that's currently running (the unresolved it.next() is important. If you remove it, it works as expected), the it.return() promise hangs indefinitely.

To be fully correct when we race iterators, we should actually abort whichever don't finish the race (if you instead return them, you get the above scenario), and only the one that yielded first should eventually return. What such an implementation, we would not need #378 at all, since bufferCountOrTime would instead abort the interval.


The following test currently passes on master, even though it technically should not (with the current return implementation).

test('canceled', async () => {
  let canceled = false;

  async function* generate() {
    try {
      for (let i = 0; ; i++) {
        await delay(100);
        yield i;
      }
    } finally {
      canceled = true;
    }
  }

  const it = batch()(generate())[Symbol.asyncIterator]();

  await delay(150);
  expect(await it.next()).toEqual({ done: false, value: [0] });

  expect(await it.return!()).toEqual({ done: true });
  expect(canceled).toBe(true);
});

The test only succeeds because when you return, it awaits the remaining delay and eventually returns from the suspended position. If you crank up the delay and run something like the following instead, the test no longer passes.

test('canceled', async () => {
  let canceled = false;

  async function* generate() {
    try {
      for (let i = 0; ; i++) {
        await delay(10000);
        yield i;
      }
    } finally {
      canceled = true;
    }
  }

  const it = batch()(generate())[Symbol.asyncIterator]();

  expect(await it.return!()).toEqual({ done: true });
  expect(canceled).toBe(true);
});

This test would pass if and only if we instead aborted the delay when the buffered iterator is returned.

For that reason, I have removed tests like these that rely on await it.return() to continue execution until the next yield is reached.


In conclusion, I think, there is no scenario in which we can blindly await it!.return?.(); and expect it to work. We would always need to keep track of whether any generators are currently suspended or executing, and either return or abort accordingly. For now, however, void it!.return?.(); has to do, I would say.

# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants