02:14 | <Justin Ridgewell> | Yes, rbuckton was excited about the proposal in June 2020: https://github.com/tc39/notes/blob/3937a973f1903550e33ccebd9bf18f90dd7d5b7c/meetings/2020-06/june-3.md#async-context |
05:04 | <Justin Ridgewell> | littledan: I don't have a full grasp of React's concurrent priorities. How are you imagining this will help? |
05:47 | <Justin Ridgewell> | Slides are updated besides that. |
12:48 | <rbuckton> | How does this compare to AsyncLocalStorage in NodeJS? I've used that to good effect so far: https://nodejs.org/dist/latest-v19.x/docs/api/async_context.html#class-asynclocalstorage |
13:28 | <littledan> | littledan: I don't have a full grasp of React's concurrent priorities. How are you imagining this will help? |
13:29 | <littledan> | How does this compare to |
14:00 | <rbuckton> | Misfeatures? |
14:06 | <rbuckton> | I'm curious what you're categorizing as misfeatures? exit is a convenience method that is shorthand for run(undefined, ...) , enterWith is a convenient way to set the context value without introducing a closure (and thus avoiding TCP issues), and disable is a convenience wrapper for enterWith(undefined) . |
14:23 | <Chengzhong Wu> | The current proposal is seeking for a minimum API that can provide the necessary infrastructure in the language for async context propagation. Compared to the Node.js AsyncLocalStorage, they are motivated by the same requirement so they should be very similar. |
14:26 | <rbuckton> | For comparison, .NET has two ways of doing something similar:
In addition, |
14:27 | <rbuckton> | And AsyncLocal<T> is really just a convenient wrapper over ExecutionContext |
14:28 | <rbuckton> | If anything, I find Node's AsyncLocalStorage to be somewhat limited. |
14:30 | <rbuckton> | I'm also curious what the motivation is for .wrap ? It seems like another convenience method, so I'm interested to know why that is prioritized over others. |
14:31 | <rbuckton> | I can see not having .exit() and .disable() , but .enterWith() isn't a convenience method, it's a core capability. |
14:33 | <Chengzhong Wu> | I'm also curious what the motivation is for |
14:34 | <rbuckton> | Actually, I'll retract that last statement somewhat. My concern is about async context mutability without violating TCP, but that can be achieved via indirection, i.e. context.run({ value: 1 }, () => { context.get().value++ }) |
14:36 | <rbuckton> | I don't disagree with its utility, but It's still a convenience method. You could achieve the same in userland with only
|
14:38 | <Chengzhong Wu> |
.wrap in the proposal behaves the same in this example. |
14:38 | <rbuckton> | Or, as a one-liner: ((store) => ctx.run(store, cb))(ctx.get()) |
14:39 | <rbuckton> | Ah, I see. .wrap captures all async contexts. |
14:39 | <Chengzhong Wu> | The static wrap in the proposal snapshots the current global async context storage, in which all async context values are saved. |
14:39 | <rbuckton> | So its more like .NET's ExecutionContext.Capture() |
14:40 | <Chengzhong Wu> | Yes, with your pointers I think so. |
14:40 | <Chengzhong Wu> | Namings in the proposal are not the final decisions at this point. |
14:40 | <rbuckton> | In that case I'm finding the granularity of .run() at odds with the coarseness of .wrap() . |
14:42 | <rbuckton> | In .NET, you have an ExecutionContext and a LogicalCallContext . When you call ExecutionContext.Run , you pass in what amounts to the entire global async context storage. You then manipulate that storage with LogicalCallContext.GetData(key) and LogicalCallContext.SetData(key, value) . |
14:50 | <rbuckton> | If you have multiple
While in .NET you might do:
|
14:51 | <rbuckton> | I'm not opposed to the design of AsyncContext being a per-context .run() , I just find .wrap to be strange. |
14:53 | <rbuckton> | From what I surmise, the purpose of
|
14:57 | <rbuckton> |
|
15:05 | <rbuckton> | vs. something like:
And if
|
15:14 | <rbuckton> | Also, without
|
15:35 | <Chengzhong Wu> | The user stories of these ideas are important to shape the API in the proposal. I believe it is worthwhile to visit those requirements in stage 1. |
15:40 | <Chengzhong Wu> | Actually, I'll retract that last statement somewhat. My concern is about async context mutability without violating TCP, but that can be achieved via indirection, i.e. |
15:51 | <rbuckton> | Tennent's Correspondence Principle (aka. "Tennent's Principle of Correspondence"): http://techscursion.com/2012/02/tennent-correspondence-principle.html In plenary its often used to describe anything that changes the context of an expression such that the expression can no longer be evaluated in the same way, which is a bit of an more expanded definition than the actual principle. The issue I'm concerned with is as follows: Lets say you start with an async generator:
Now I need to |
15:53 | <rbuckton> | Now I need to introduce an async context for b() and c():
|
15:54 | <rbuckton> | I can make that an async arrow:
But that won't work with |
15:54 | <rbuckton> | I can make it an async generator:
But that won't work with the |
15:55 | <rbuckton> | any solution requires significant refactoring. |
15:56 | <rbuckton> | vs. an
|
15:57 | <rbuckton> | And that could be potentially even more convenient with
|
16:05 | <rbuckton> | TCP violations aren't necessarily bad, but are indicative of inconsistencies in the language. For example, using ctx.run would be fine if there was an async generator equivalent for arrow functions so that we could more easily preserve await , this , and yield |
16:09 | <Chengzhong Wu> | Thanks for sharing! This is a very interesting point on .setValue versus a structured .run method as the basic block. |
17:20 | <Justin Ridgewell> | I can see not having .enterWith() doesn't create a new callstack entry. |
17:21 | <Justin Ridgewell> | The behavior here of leaking data beyond the current callstack, and mutating the containing callstack's data for other execution that follows the current, is only a source of bugs. |
17:30 | <Justin Ridgewell> |
|
17:56 | <littledan> | I still am having trouble understanding the motivation for this reified design |
18:11 | <rbuckton> | I still am having trouble understanding the motivation for this reified design A A Either can be composed with the other, however:
|
18:14 | <rbuckton> | I'd argue its a bit more obvious to a developer that they can do the following to wrap:
vs. the more opaque syntax needed to emulate
|
18:14 | <rbuckton> | But I would argue to have both rather than just one or the other. |
18:16 | <Justin Ridgewell> | I agree, I think that makes is simpler for restoring before multiple callbacks |
18:17 | <rbuckton> | I also wonder if the MVP should include a mechanism for async context control flow so its easier to escape a global context. |
18:17 | <rbuckton> | My examples above use setImmediate , but what if setImmediate also passes along the current execution context? |
18:19 | <rbuckton> | .NET has ExecutionContext.SuppressFlow() and ExecutionContext.RestoreFlow() for this purpose, and the result of SuppressFlow() is disposable (if disposed, it will call RestoreFlow() for you). |
18:19 | <Justin Ridgewell> | (It's intended to keep the execution context, but setImmediate and friends don't live in 262) |
18:20 | <rbuckton> | then we definitely need a way to escape an execution context. |
18:20 | <Justin Ridgewell> | It's already possible with .wrap() in the top level scope |
18:22 | <Justin Ridgewell> |
|
18:23 | <rbuckton> | Yes, but that wouldn't work well if async contexts were mutable like Node's AsyncLocalStorage.enterWith , since that can set up a context at the top level before your module body runs. |
18:23 | <rbuckton> | then again, capture and copy would have the same problem I suppose. |
18:24 | <Justin Ridgewell> | As I said above, I think mutable context is a bug (and very likely to hit challenges with the SES folks) |
18:24 | <rbuckton> | But a suppressFlow() would avoid that as well. |
18:24 | <Justin Ridgewell> | I really don't wanna support it. |
18:24 | <James M Snell> | Definite +1 on not supporting mutable context. Not a fan of enterWith |
18:25 | <rbuckton> | Something like
|
18:27 | <rbuckton> | I'd really like to be able to have a simple
But that wouldn't work without the ability attach an async context to the current execution context without needing to go through a |
18:29 | <rbuckton> | Definite +1 on not supporting mutable context. Not a fan of enterWith context.run({ value: 1 }, () => context.get().value++) is still mutable. Not having enterWith just makes other related primitives harder to implement. |
18:35 | <James M Snell> | Yeah, and we have plenty of use cases where folks add to or change values in the context... mutable context is likely the wrong phrase. I'm not a big fan of the enterWith(...) model at all, and I shouldn't be able to completely replace the context value. |
18:37 | <James M Snell> | unfortunately I'm on my way out the door for an appointment. Will be back and will try to weigh in more |
18:37 | <rbuckton> | Its not the wrong phrase. I was just illustrating that an immutable context can still hold mutable values. And in most other languages I'm familiar with, the context is also mutable. |
18:37 | <James M Snell> | tl;dr is just I really dislike enterWith |
18:37 | <James M Snell> | will be back later |
18:40 | <Justin Ridgewell> | (I'm looking for Marks' comments on mutability during our call) |
18:40 | <Justin Ridgewell> | The model that I'm building the slides is simple to explain only because we don't need deep integration with the runtime |
18:42 | <Justin Ridgewell> | If we were to support mutable contexts, it would either
|
18:43 | <Justin Ridgewell> | It's possible we could work this with Disposable proposal, but I don't want to the two proposals together |
18:45 | <Justin Ridgewell> | The current .run() 's try-finally push-call-pop can be understood extremely easily and it's directly teachable with what's possible in userland today with sync execution |
18:45 | <Justin Ridgewell> | I really like that it's simple |
19:41 | <rbuckton> |
In a mutable context world, you would use |
19:41 | <Justin Ridgewell> | Here's the part where Mark starts talking about mutability: https://youtu.be/Y6hQLM08Ig8?t=4513 |
19:44 | <Justin Ridgewell> | And a bit more at https://youtu.be/Y6hQLM08Ig8?t=5106 |
21:38 | <littledan> | But I would argue to have both rather than just one or the other. |
21:39 | <littledan> | (smaller API surface was a guiding principle of Chengzhong Wu 's recent edits and I like it, even if I have other preferences for the exact form) |
21:54 | <rbuckton> | I don't mind a small API surface, my concerns stem from ensuring the appropriate building blocks are surfaced. Most of what I've mentioned is inconsequential, you can implement some of the missing functionality in terms of other functionality. The @AsyncLocal or new AsyncLocal() approach can't be solved with the current API, which makes it infeasible to do in userland. Suspending and resuming global async context flow is only barely feasible given that it requires you do const emptyContext = AsyncContext.wrap((cb, ...args) => cb(...args)) at the top level before any context is created, which certainly isn't a great developer experience. |
21:58 | <Justin Ridgewell> | Could this be solve by using a mutable object in the context, and allowing a default value when constructing? |
21:58 | <Justin Ridgewell> | I don't understand the decorator usecase yet. |
21:59 | <Justin Ridgewell> | But a default value would allow the your AsyncLocal example to work at the top-level |
21:59 | <Justin Ridgewell> | (React also allows a default value for its contexts) |
22:00 | <rbuckton> | Except there's no way to switch contexts with just @AsyncLocal , it depends on the ability to copy a context or suppress async flow to actually get a different value each time. |
22:01 | <Justin Ridgewell> | Can you explain more? What would code using this look like? |
22:01 | <Justin Ridgewell> | I don't understand what a context would do unless there is execution that happens further down the callstack |
22:04 | <rbuckton> | One moment, I'm refreshing my knowledge of how AsyncLocal works in .NET to make sure I'm not misspeaking |
22:05 | <littledan> | yeah I agree you shouldn't have to run top-level code (this always creates problems with composition/packaging), but I'd like to understand the use case for escaping all the contexts |
22:07 | <littledan> | we should expect the platform itself to make some context variables too, so I'm not even sure what it means to escape all of them |
22:07 | <rbuckton> | It looks like how .NET's AsyncLocal works, is that each "mutation" of the local results in the local values in the execution context being copied, such that when an await occurs, the current snapshot of the locals in the execution context is copied to the context bound to that await . |
22:11 | <rbuckton> | So based on that, here's a rough example:
|
22:13 | <littledan> | yeah, I can see how this snapshotting approach is convenient; I guess I prefer the run -based API which makes it a bit more explicit when the copying occurs |
22:14 | <littledan> | I sort of assumed that, with what's in the main branch of AsyncContext, the get/set functions didn't have any copying semantics, that you're responsible for saving and restoring things when appropriate |
22:15 | <littledan> | maybe I understood wrong? |
22:17 | <littledan> | anyway if you only have run and not a setter, then it is sort of clearer why run doesn't hold in the reaction (because you've already exited it by then) |
22:17 | <rbuckton> | I think earlier I said .NET has two approaches: AsyncLocal which stores a local associated with async control flow, and ExecutionContext , which has a logical call context that can have data stored within it. |
22:18 | <rbuckton> | ExecutionContext.Run() is kind of like AsyncContext.prototype.run() , except it covers the entire execution context, not just a single value. |
22:19 | <rbuckton> | AsyncLocal state is stored in an ExecutionContext , but that context is captured and restored whenever you create a Task or a task continuation is invoked. |
22:19 | <rbuckton> | So AsyncLocal (which snapshots) is built on an ExecutionContext (which is mutable). |
22:21 | <littledan> | I see |
22:22 | <littledan> | Do you have an example of something you can do in this system which wouldn't work with the kinds of approaches we've been discussing? |
22:22 | <littledan> | (apologies if that's above) |
22:23 | <rbuckton> | So in .NET, you can do:
Since the context is mutable. |
22:26 | <rbuckton> | Do you have an example of something you can do in this system which wouldn't work with the kinds of approaches we've been discussing? Since AsyncLocal does snapshotting, its very useful for fork/join operations like this:
Where each |
22:28 | <rbuckton> | In reality, this probably can't be implemented in userland regardless. The kind of snapshotting that is necessary would require the same hooks that AsyncContext needs to restore the context after await . |
22:29 | <rbuckton> | the upside of AsyncLocal is that you don't really need to worry about closures and TCP |
22:29 | <littledan> | I guess all of the alternatives here have the property (which I agree is essential) that you can write to the variable without affecting parallel tasks. The run method certainly does, at least. |
22:30 | <littledan> | the upside of |
22:31 | <littledan> | I guess all of the alternatives here have the property (which I agree is essential) that you can write to the variable without affecting parallel tasks. The |
22:31 | <rbuckton> | No, I've been a bit randomized today so I feel I've been jumping around between topics too frequently and may not be making a coherent argument. |
22:37 | <rbuckton> | I'll simplify my thoughts:
|
22:40 | <rbuckton> | If the value associated with an AsyncContext is immutable, then a .copyContext() isn't necessary because every await (or other async mechanism) would already snapshot the current global execution context. |
22:44 | <littledan> | I guess I could see how freely getting/setting and expecting the snapshotting to be automatic can't be built in terms of run , and what I don't understand yet is why we'd want that. |
22:45 | <littledan> | I also don't understand in what sorts of cases you'd want to restore the global context |
22:45 | <Justin Ridgewell> | I think we can support both, but I would want to see use cases that can't be solved by the immutable context |
22:45 | <littledan> | I guess I assumed that it'd be an anti-goal to have any sort of notion of the global context |
22:45 | <littledan> | (since it's sort of anti-compositional) |
22:48 | <Justin Ridgewell> | I don’t have a full grasp of it either, but Yoav mentioned it in his talk too. Maybe ask Seb? cache , but dropping use discussion (it's not necessary, client will support async/await without it) |
22:48 | <Justin Ridgewell> | Not that I don't need to discuss use , I can dive deeper into the real use for this for client side |
22:48 | <littledan> | Seb pushed me back to talking about |
22:49 | <littledan> | I mean, more importantly: would this feature be useful for priorities? |
22:49 | <Justin Ridgewell> | Yah, he said that priorities really needs brower APIs that this won't solve (the priority scheduler work would be the API) |
22:49 | <Justin Ridgewell> | Possibly, in that they could store the priority on an AsyncContext |
22:49 | <littledan> | ah, but as discussed in Yoav's talk, browsers are unable to make this capability due to the need for the same platform feature |
22:50 | <Justin Ridgewell> | But it needs platform support to not be able to create a high priority task from a low priority one, and that's not solved by us |
22:50 | <rbuckton> | I guess I could see how freely getting/setting and expecting the snapshotting to be automatic can't be built in terms of |
22:51 | <littledan> | I think that's actually how the proposal probably works right now. |
22:51 | <rbuckton> | No |
22:51 | <rbuckton> | Sorry, no. Snapshotting is likely how we would achieve the semantics that Justin Ridgewell seems to prefer. |
22:51 | <littledan> | But it needs platform support to not be able to create a high priority task from a low priority one, and that's not solved by us |
22:54 | <littledan> | Sorry, no. Snapshotting is likely how we would achieve the semantics that Justin Ridgewell seems to prefer. |
22:54 | <rbuckton> | Imagine the current execution context as an ImmutableMap. Adding or replacing entries in the map copies the map.
|
22:54 | <littledan> | Yes, well, if that is snapshotting, then I agree it's how we'd achieve the semantics |
22:54 | <rbuckton> | You don't mutate the execution context, you copy it when you call contxt.run , adding or replacing the value for context in the copy of the execution context seen by the callback. |
22:55 | <Justin Ridgewell> |
|
22:55 | <littledan> | I like to think of it as, a singly-linked list, which you don't need to copy, just push associations onto |
22:55 | <littledan> | this differentiates run from an AsyncLocal that you can literally mutate (which woudln't have that property) |
22:55 | <rbuckton> | Justin Ridgewell: Yes, while you can't mutate the execution context itself (since its immutable), you can mutate the values stored in it. |
22:56 | <littledan> | I like to think of it as, a singly-linked list, which you don't need to copy, just push associations onto |
22:56 | <littledan> | so if that's our data model, the usage of AsyncContext is somehow "structured" (debatable how useful that is, I'm just describing how I'm conceptualizing this) |
22:57 | <rbuckton> | and this singly linked list is immutable; the only thing that changes is the pointer to its root ImmutableDictionary would be implemented. Not precisely a "copy" but essentially a snapshot since it can't be changed. |
22:57 | <littledan> | right so since the snapshot operation is the identity function, we don't have to worry about "when" the snapshot occurs, just when/how the mutation occurs (with run ) |
22:58 | <rbuckton> | The difference between AsyncContext and something like AsyncLocal , is that AsyncContext is explicit (you must call .run() , while AsyncLocal is implicit. |
22:58 | <littledan> | Makes sense |
22:58 | <littledan> | I can see how the two things have this difference; I haven't yet understood the downside of explicitness |
23:00 | <rbuckton> | Explicitness is fine for imperative code, not so much for declarative code (such as using with decorators). Also the TCP issue (managing yield , await , and this when you shift to a callback). |
23:22 | <rbuckton> | Imagine there was an internal
An |
23:23 | <littledan> | Explicitness is fine for imperative code, not so much for declarative code (such as using with decorators). Also the TCP issue (managing |
23:24 | <rbuckton> | Most of the real world examples I can think of are related to things like HttpContext in ASP.NET, which I'm a few years out from using regularly. |
23:24 | <littledan> | my intuition is that it's generally a "big deal" when you use run . There are many wrapped callbacks/promise reactions for every big run setting up a context |
23:24 | <rbuckton> | I've used this feature in .NET a fair bit though. |
23:25 | <rbuckton> | So really, neither AsyncContext nor AsyncLocal are the actual building block, but rather ExecutionContext is. However, I think ExecutionContext provides too much access to internals. |
23:27 | <rbuckton> | my intuition is that it's generally a "big deal" when you use ExecutionContext which is a bit more coarse grained. You will see asyncContext.run more often since its so granular (i.e., a single value). AsyncLocal is designed to be more lightweight. |
23:27 | <littledan> | so, we're talking about https://learn.microsoft.com/en-us/dotnet/api/system.web.httpcontext?view=netframework-4.8 ? |
23:28 | <rbuckton> | When I'm talking about HttpContext, yes. |
23:28 | <littledan> | that looks like an example of something coarse-grained, which library user code doesn't manually set |
23:28 | <rbuckton> | HttpContext.Items uses LogicalCallContext.GetValue under the hood. |
23:28 | <rbuckton> | And multiple HttpRequests are handled by calls to ExecutionContext.Run, which provides isolated execution contexts containing sensitive things like the current security principal, in addition to HttpRequests and responses. |
23:30 | <rbuckton> | this is also how a lightweight version of MEF (Managed Extensibility Framework) interacted with HttpContext to provide dependency injection for web applications. When a new HttpRequest is created, a new DI composition scope would be created and associated with the request context, allowing you to pull in request-specific services. |
23:31 | <rbuckton> | Again, I'm a few years out from using that actively so some things may have changed. |
23:32 | <littledan> | well, it's interesting to hear about this; it's not so relevant if this is current |
23:33 | <rbuckton> | The underlying logic is still actively in use, even if some parts have changed. |
23:33 | <littledan> | Does the security aspect mean that it should be top-level, or just that the framework needs to be careful about what information it nests where? |
23:33 | <littledan> | Are there any use cases here for mutating the value of an AsyncLocal, or is this all through Run? |
23:34 | <rbuckton> | It needs to be careful about information nesting. The LogicalCallContext exists to do this work for you, though awaitable things in .NET let you configure how to capture and restore execution contexts via .ConfigureAwait() . |
23:34 | <rbuckton> | https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.configureawait?view=netframework-4.8#system-threading-tasks-task-configureawait(system-boolean) |
23:35 | <littledan> | Do you think we need .ConfigureAwait? |
23:35 | <rbuckton> | Hopefully not. |
23:35 | <rbuckton> | Mostly .ConfigureAwait is used to determine whether to resume on the context captured at await or to continue with the context that's part of the continuation. |
23:36 | <rbuckton> | One moment and I'll put together an example. |
23:45 | <rbuckton> | Actually, it shouldn't matter. .ContinueAwait(false) is used to resume on the same SynchronizationContext, which affects which thread pool is used. A UI thread in a Windows app uses a Windows message queue based thread pool, while background threads might handle the work that is being awaited. Since you have to be on the UI thread to do UI updates, you need to go back to the original synchronization context to be on the correct thread pool/thread. |
23:51 | <rbuckton> | However, context switching is still useful. Without async context flow suppression, you can still do asyncContext.run(undefined, cb) , but that's on an individual AsyncContext bases. Something like .NET's ExecutionContext.SuppressFlow() would suppress copying the current context for every AsyncContext . Again, that's achieveable with wrap but not very ergonomic. |
23:52 | <littledan> | sorry what is the purpose of context flow suppression? |
23:54 | <rbuckton> | My earlier example was an HTTP Server. you may have context information for the server itself (such as the server's security principal) that you don't want to propagate to each request handler. In that case you would suppress execution context flow so that the current context isn't copied when you spin up a request handler (which will need to set its own context). |
23:54 | <Justin Ridgewell> | I’m also not understanding that usecase |
23:55 | <rbuckton> | Its also useful if you are using an async context that is reachable both from your code and potentially untrusted/sandboxed code. You may want to suppress async flow when invoking the untrusted code so as not to leak information. |
23:55 | <Justin Ridgewell> | Is this because execution context allows you to change all contexts? |
23:56 | <rbuckton> | The untrusted code use case is very important when building an plugin/extensibility ecosystem, such as the one used in VS Code. |
23:56 | <rbuckton> | Its because there's no easy way to reset all the context information for the purpose of an invocation into untrusted code, aside from a top-level AsyncContext.wrap((cb, ...args) => cb(...args)) . |
23:57 | <littledan> | My earlier example was an HTTP Server. you may have context information for the server itself (such as the server's security principal) that you don't want to propagate to each request handler. In that case you would suppress execution context flow so that the current context isn't copied when you spin up a request handler (which will need to set its own context). |
23:58 | <rbuckton> | Plus I may just want a way to kick off an async operation in a base state without whatever extra context baggage the current function is holding on to. |
23:58 | <rbuckton> | Yes. |
23:58 | <littledan> | Its because there's no easy way to reset all the context information for the purpose of an invocation into untrusted code, aside from a top-level |
23:58 | <rbuckton> | Again, you can do that on a case by case basis with asyncContext.run(undefined, cb) , but not for all async contexts |
23:59 | <rbuckton> | Yes, but making this specifically a case-by-case basis is very fiddly. that can be fine for an and user application, but it makes it hard for intermediate libraries to work around. |
23:59 | <littledan> | So, this is important if you have a profusion of sensitive things, where the inner modules can find the AsyncContext objects, but otoh it would be impractical to construct a list of all of them to censor them? |