Skip to content

async generators do not capture async context #42237

Closed

Description

@devsnek

tested on v17 but i don't suspect that matters much.

'use strict';

const { AsyncLocalStorage } = require('async_hooks');

const s = new AsyncLocalStorage();

async function normal() {
  console.log('async fn', s.getStore());
}

async function* gen() {
  console.log('async gen', s.getStore());
}

s.run('hello', normal);

s.run('hello', gen).next();

output:

async fn hello
async gen undefined

/cc @nodejs/async_hooks

i'm assuming this probably will need some sort of effort on the v8 side?

Activity

  1. added
    async_hooksIssues and PRs related to the async hooks subsystem.
    on Mar 6, 2022
  2. benjamingr commented on Mar 7, 2022

    @benjamingr
    Member

    @devsnek is this v8 not firing the right promise hooks or on our side (or you haven't had a chance to investigate yet)?

  3. devsnek commented on Mar 7, 2022

    @devsnek
    MemberAuthor

    I'm not sure.

  4. Flarna commented on Mar 7, 2022

    @Flarna
    Member

    Should async generators capture the async context?

  5. devsnek commented on Mar 7, 2022

    @devsnek
    MemberAuthor

    i dunno... its technically an abstraction that's outside of promises, but in terms of the usage of apis like AsyncLocalStorage, i think i would expect hooks to apply to them.

  6. Flarna commented on Mar 7, 2022

    @Flarna
    Member

    next() is a sync call which runs using the asyncId of the caller. Doesn't seem that wrong to me.
    e.g. if you use s.run('hello', () => g.next()) the context is visible in this next() call. in your sample code next is called directly after returning from s.run() therefore the store not active anymore.

    If the generator would capture the id where it was created everything after await next() would end up in this context. Hard to tell if this is good or bad.

  7. iamgabrielsoft commented on Mar 25, 2022

    @iamgabrielsoft

    Are you missing the yield keyword, I can't find it

    Your using a generator

  8. benjamingr commented on Mar 25, 2022

    @benjamingr
    Member

    Should async generators capture the async context?

    User expectation would probably be "yes"

  9. RMHonor commented on Jun 15, 2023

    @RMHonor

    Using inspiration from open-telemetry/opentelemetry-js#2951 (comment), I was able to build a generic wrapper to work around this issue in the mean time.

    export function bindAsyncGenerator<T = unknown, TReturn = any, TNext = unknown>(
      store: AsyncLocalStorage<unknown>,
      generator: AsyncGenerator<T, TReturn, TNext>,
    ): AsyncGenerator<T, TReturn, TNext> {
      const ctx = store.getStore();
      return {
        next: () => store.run(ctx, generator.next.bind(generator)),
        return: (args) => store.run(ctx, generator.return.bind(generator), args),
        throw: (args) => store.run(ctx, generator.throw.bind(generator), args),
    
        [Symbol.asyncIterator]() {
          return this;
        },
      };
    }
  10. anonrig commented on Feb 21, 2024

    @anonrig
    Member

    cc @nodejs/tsc who would have the most information to resolve this bug?

  11. mcollina commented on Mar 20, 2024

    @mcollina
    SponsorMember

    I think @bengl can coordinate this.

  12. Flarna commented on Mar 21, 2024

    @Flarna
    Member

    FWIW there is an ongoing discussion in scope of TC39 AsyncContext proposal regarding the same topic.

  13. mrmar99 commented on Apr 2, 2024

    @mrmar99

    Got this problem when I was writing logging middleware for nice-grpc server.

    Middleware:

    export default function loggingMiddleware(logger: ILogger) {
      return async function* <Request, Response>(
        call: ServerMiddlewareCall<Request, Response>,
        context: CallContext,
      ) {
        const someId = context.metadata.get("some-id");
    
        return yield* withContext({ someId }, async function* () {
          try {
            return yield* call.next(call.request, context);
          } catch (err) {
            // ...
            throw err;
          }
        });
      };
    }

    In withContext function I use enterWith to attach storeData to my store:

    import store from "path/to/AsyncLocalStorage/instance";
    
    type TStoreData = {
      someId: string;
    }
    
    export function withContext<T>(storeData: TStoreData, callback: () => T): T {
      store.enterWith(storeData);
      return store.run(storeData, callback);
    }

    Now, I have access to data in storage in my callback function by importing store and calling store.getStore().

    Let me know if there is a better way.

  14. SlugFiller commented on Jul 24, 2025

    @SlugFiller

    After being "burned" by this, I delved more into it, and realized that this is not a bug.

    If you want to capture the context for in generator, you can use a proxy like this:

    function bindYield(generator) {
    	const shot = AsyncLocalStorage.snapshot();
    	class Generator {
    		async next(...args) {
    			return await shot(() => generator.next(...args));
    		}
    
    		async return(...args) {
    			return await shot(() => generator.return(...args));
    		}
    
    		async throw(...args) {
    			return await shot(() => generator.throw(...args));
    		}
    
    		[Symbol.asyncIterator]() {
    			return this;
    		}
    	}
    	return new Generator();
    }

    You can turn your any regular generator into a capturing generator like this:

    function toBinding(generator) {
    	return (...args) => {
    		return bindYield(generator(...args));
    	};
    }

    But you also have the option to not capture at creation time, but rather, at a later point.

    However, if generators captured context, you would NOT have any way to pass ANY different context via .next. No matter which context you used, it would replace it with the captured one.

    Use case

    What made me understand this wasn't a bug, was working on a library for structured concurrency. As part of the library, I pass a task-local AbortSignal which allows essentially any async function to be abortable, with no extra code, simply by calling/awaiting functions that are abortable via the task-local. Any functions in the middle needn't even be aware that they are abortable. The signal is passed forward via task-local without needing an explicit parameter, and an abort causes an exception which can move back up the stack through any number of functions so long as they don't try to catch it.

    When it came to handling generators, I suddenly had to face the question: If one task creates a generator, and then a second task iterates over the generator, and then the second task aborts (via its task-local AbortSignal), what should happen in the generator?

    Well, if the generator does not abort, then the second task would become stuck awaiting the generator's .next. which could very well be in the middle of a long, or even a frozen action. For example, if the generator is waiting for network traffic, and the second task handles the network timeout by aborting on a timer. If the abort does not propagate into the generator, the network operation performed by the generator would not abort, causing both the generator, and the second task, to remain stuck.

    If async generators did capture the context at creation time, passing the AbortSignal from the iterating task into the generator would require a complex and ugly workaround, if it was at all possible. It might not even be doable as a wrapper, and may require the generator itself to include code to receive and use a passed value for the task-local context.

    This is why I think the functionality should remain as-is. Those who need a captured context have an easy enough workaround (which could easily be made into a standard utility function similar to util.promisify). At most, this is a documentation issue.

  15. mcollina commented on Jul 29, 2025

    @mcollina
    SponsorMember

    Thanks for the good write up, I'm going to close this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    async_hooksIssues and PRs related to the async hooks subsystem.async_local_storageIssues and PRs related to the AsyncLocalStorage API.confirmed-bugIssues and PRs for confirmed bugs.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions