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?
I don’t have a full grasp of it either, but Yoav mentioned it in his talk too. Maybe ask Seb?
13:29
<littledan>
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
It is hoped to be roughly equivalent in expressiveness for normal usage, just omitting some weird misfeatures and simplifying the API surface.
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:

AsyncLocal<T> (https://learn.microsoft.com/en-us/dotnet/api/system.threading.asynclocal-1?view=net-7.0) has a .Value property (analogous to Node's AsyncLocalStorage.getStore() and .enterWith(store)

ExecutionContext (https://learn.microsoft.com/en-us/dotnet/api/system.threading.executioncontext.run?view=netframework-4.8) has .Run() (analogous to Node's AsyncLocalStorage.run()) and can be used with LogicalCallContext (https://learn.microsoft.com/en-us/dotnet/api/system.runtime.remoting.messaging.logicalcallcontext.getdata?view=netframework-4.8) to get and set values associated with the logical call flow. It also has a Dispose, which is analogous to Node's .disable().

In addition, ExecutionContext allows you to capture the current context, copy it into a new context, and suppress and restore the context across async invocations (using AsyncFlowControl).

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 .wrap? It seems like another convenience method, so I'm interested to know why that is prioritized over others.
It snapshots the current context into the wrapped function, and restores it when the wrapped function is been invoked. It is part of the basic block to be able to defining userland queues with async context.
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 .run() and .get():

function wrap(ctx, cb) {
  const store = ctx.get();
  return () => ctx.run(store, cb);
}
14:38
<Chengzhong Wu>

I don't disagree with its utility, but It's still a convenience method. You could achieve the same in userland with only .run() and .get():

function wrap(ctx, cb) {
  const store = ctx.get();
  return () => ctx.run(store, cb);
}
I don't think the .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 AsyncContext objects, you might end up with:

const ctx1 = new AsyncContext();
const ctx2 = new AsyncContext();

function foo() {
  ctx1.run({ id: 1 }, bar);
}

function bar() {
  ctx1.run({ id: 2 }, baz);
}

function baz() {
  ctx1.get(); // { id: 1 }
  ctx2.get(); // { id: 2 } 
}

While in .NET you might do:

var local1 = new AsyncLocal<number>();
var local2 = new AsyncLocal<number>();

void Foo() {
  var context = ExecutionContext.CreateCopy();
  ExecutionContext.Run(context, () => {
    local1.Value = 1;
    Bar();
  });
}

void Bar() {
  local2.Value = 2;
  Baz();
}

void Baz() { ... }
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 .wrap is to address two specific needs:

  1. To capture the current logical call context (i.e., all global async context values)
  2. To, at some point in the future, use that captured context to execute a callback.
14:57
<rbuckton>

.wrap() does that by combining those two operations into a single operation. This is fine if you want to wrap a single callback, but is less convenient if you want to capture all contexts and pass it to different callbacks:

const callWithContext = AsyncContext.wrap((cb, ...args) => cb(...args));
callWithContext(f);
callWithContext(g);
15:05
<rbuckton>

vs. something like:

const context = AsyncContext.capture();

// with a `.run` method on the captured context...
context.run(f);
context.run(g);

// or with a static method
AsyncContext.runWithContext(context, f);
AsyncContext.runWithContext(context, g);

And if AsyncContext ever does become mutable (i.e., via .enterWith), you might want to be able to clone the global context so that async context mutations are local to the logical call:

const ctx = new AsyncContext();
ctx.run(1, main);

function main() {
  const captured = AsyncContext.capture();
  const copied = AsyncContext.copyContext();

  AsyncContext.runWithContext(captured, foo);
  AsyncContext.runWithContext(copied, bar);

  console.log(ctx.get()); // 2 (bar's mutation acted on a copy)
}

function foo() {
  console.log(ctx.get()); // 1
  ctx.set(2);
}

function bar() {
  console.log(ctx.get()); // 1 (due to copy)
  ctx.set(3);
}
15:14
<rbuckton>

Also, without .enterWith, you could not easily emulate something like AsyncLocal in user code:

// with AsyncLocalStorage
class AsyncLocal {
  #context = new AsyncLocalStorage();
  get value() { return this.#context.getStore(); }
  set value(v) { this.#context.enterWith(v); }
}

const loc = new AsyncLocal();
loc.value = 1;
loc.value; // 1

// with AsyncContext
class AsyncLocal {
  #context = new AsyncContext();
  get value() { return this.#context.value; }
  set value(v) { this.#context.value = v; }
  enable(cb) {
    return this.#context.run({ value: undefined }, cb);
  }
}

const loc = new AsyncLocal();
loc.value = 1; // ReferenceError

// need to establish context first
loc.enable(() => {
  loc.value = 1;
  loc.value; // 1
});
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. context.run({ value: 1 }, () => { context.get().value++ })
I might get it wrong. Would you mind expanding on the TCP issue?
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:

class C {
async function* foo() {
  await a();
  yield b();
  await c();
}

Now I need to

15:53
<rbuckton>

Now I need to introduce an async context for b() and c():

const ctx = new AsyncContext();
class C {
  async * foo() {
    await a();
    ctx.run(value, () => {
      yield b(); // syntax error, arrow function is not a generator
      await this.c(); // syntax error, arrow function is not async
    });
  }
}
15:54
<rbuckton>

I can make that an async arrow:

const ctx = new AsyncContext();
class C {
  async * foo() {
    await a();
    await ctx.run(value, async () => {
      yield b(); // syntax error, arrow function is not a generator
      await this.c(); // ok
    });
  }
}

But that won't work with yield.

15:54
<rbuckton>

I can make it an async generator:

const ctx = new AsyncContext();
class C {
  async * foo() {
    await a();
    ctx.run(value, async function *() {
      yield b(); // ok
      await this.c(); // reference error, this is undefined
    });
  }
}

But that won't work with the this binding.

15:55
<rbuckton>
any solution requires significant refactoring.
15:56
<rbuckton>

vs. an enterWith:

const ctx = new AsyncContext();
class C {
  async * foo() {
    await a();
    ctx.enterWith(value);
    try {
      yield b();
      await this.c();
    }
    finally {
      ctx.disable();
    }
  }
}
15:57
<rbuckton>

And that could be potentially even more convenient with using declarations:

const ctx = new AsyncContext();
class C {
  async * foo() {
    await a();
    using _ = ctx.enterWith(value); // assumes disposable return value...
    yield b();
    await this.c();
  }
}
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 .exit() and .disable(), but .enterWith() isn't a convenience method, it's a core capability.
Still reading, all the messages, but this is the first I disagree with. The way I'm explaining the proposal during the meeting will be to equate it with putting data onto the call stack, and .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>

vs. something like:

const context = AsyncContext.capture();

// with a `.run` method on the captured context...
context.run(f);
context.run(g);

// or with a static method
AsyncContext.runWithContext(context, f);
AsyncContext.runWithContext(context, g);

And if AsyncContext ever does become mutable (i.e., via .enterWith), you might want to be able to clone the global context so that async context mutations are local to the logical call:

const ctx = new AsyncContext();
ctx.run(1, main);

function main() {
  const captured = AsyncContext.capture();
  const copied = AsyncContext.copyContext();

  AsyncContext.runWithContext(captured, foo);
  AsyncContext.runWithContext(copied, bar);

  console.log(ctx.get()); // 2 (bar's mutation acted on a copy)
}

function foo() {
  console.log(ctx.get()); // 1
  ctx.set(2);
}

function bar() {
  console.log(ctx.get()); // 1 (due to copy)
  ctx.set(3);
}
Dan suggested we not reify the snapshot into a class structure. Your suggestion matches pretty closely with what I have in my gist
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 .wrap() method makes it easy to wrap a single callback in a captured execution context, but harder to reuse that context for multiple callbacks without incurring the overhead of an additional function wrapper for each callback.

A .capture() method, and an associated .run(globalContext, cb) method make it easy to reuse a context with multiple functions without incurring the overhead of a function wrapper.

Either can be composed with the other, however:

// emulate `capture` if you only have `wrap()`:

function capture() {
    return AsyncContext.wrap((cb, ...args) => cb(...args));
}

// wrap a single callback
const wrapped = AsyncContext.wrap(cb);
setImmediate(() => {
    wrapped();
});

// capture context and use with multiple functions
const context = capture();
setImmediate(() => {
    context(foo);
    context(bar);
});

// emulate `wrap` if you only have `capture()`:

// assumes `AsyncContext.capture()` produces `(cb, ...args) => any`
function wrap(cb) {
    const context = AsyncContext.capture();
    return (...args) => context(cb, ...args);
}

// wrap a single callback
const wrapped = wrap(cb);
setImmediate(() => {
    wrapped();
});

// capture context and use with multiple functions
const context = AsyncContext.capture();
setImmediate(() => {
    context(foo);
    context(bar);
});
18:14
<rbuckton>

I'd argue its a bit more obvious to a developer that they can do the following to wrap:

const context = AsyncContext.capture();
return () => context(f);

vs. the more opaque syntax needed to emulate context():

const context = AsyncContext.wrap((cb, ...args) => cb(...args));
context(f);
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>
const suppressed = AsyncContext.wrap((cb, …args) => cb(…args));

context.run(1, () => {
  suppressed(() => {
    context.get() === undefined;
  });
});
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 SuppressFlow() would also work well with using declarations, i.e.:

const ctx = new AsyncContext();
ctx.run(1, foo);

async function foo() {
  console.log(ctx.get()); // 1
  {
    using flow = AsyncContext.suppressFlow();
    console.log(ctx.get()); // undefined
  }
  console.log(ctx.get()); // 1
}
18:27
<rbuckton>

I'd really like to be able to have a simple AsyncLocal primitive, or even an @AsyncLocal decorator (not unlike a potential @ThreadLocal decorator that could someday exist for shared structs):

class HttpServer {
  @AsyncLocal
  accessor currentRequest;
  ...
}

But that wouldn't work without the ability attach an async context to the current execution context without needing to go through a .run call.

18:29
<rbuckton>
Definite +1 on not supporting mutable context. Not a fan of enterWith
Yet 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

  • allow one function to replace the context for sibling calls (which I think is what Mark is objecting to)
  • push a context onto the stack (but then it's not obvious where we would pop with deep integration with the host's actual call stack)
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>
  • allow one function to replace the context for sibling calls (which I think is what Mark is objecting to)

In a mutable context world, you would use .copyContext() to clone the global context to avoid mutations in siblings.

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.
Yeah, my interpretation has been that each can express the other; that's why my intuition was that we should go with the smaller API surface
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:

const local = new AsyncLocal();
local.value = 1;
await foo(); 

// mutation of local doesn't change the snapshot seen by the `await` in foo
local.value = 2; 

await bar();

function foo() {
  console.log(local.value); // 1

  // takes snapshot of current async execution context
  // restores snapshot when `await` resumes
  await Promise.resolve(); 

  console.log(local.value); // 1
}

function bar() {
  console.log(local.value); // 2

  // takes snapshot of current async execution context
  // restores snapshot when `await` resumes
  await Promise.resolve(); 

  console.log(local.value); // 2
}
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:

LogicalCallContext.SetData("foo", 1);
var context = ExecutionContext.Capture();

ExecutionContext.Run(context, () => {
  Console.WriteLine(LogicalCallContext.GetData("foo")); // 1
  LogicalCallContext.SetData("foo", 2);
});

Console.WriteLine(LogicalCallContext.GetData("foo")); // 2

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:

const local = new AsyncLocal();
local.value = 1;
await Promise.all(operations.map(async (op) => {
  await op.execute(); // where op.execute reads or writes local.value
}));

Where each op.execute() gets a copy of the state, such that any state mutations don't affect other parallel tasks.

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 AsyncLocal is that you don't really need to worry about closures and TCP
I see, I guess this is the part I need to understand better (probably you already explained this and I need to reread the logs)
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 run method certainly does, at least.
I guess the get/set functions in the main branch would not handle this properly
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:

  • new AsyncContext() is great.
  • AsyncContext.prototype.run() is great.
  • AsyncContext.prototype.get() is great.
  • The capability introduced by AsyncContext.wrap() is necessary, but has some ergonomics issues I have concerns about.
  • The minimal API for AsyncContext means some other useful primitives like an AsyncLocal can't be modeled in userland. However, its likely it would need to be introduced as a built-in anyways if we wanted anything like snapshotting capabilities such that the value is associated with control flow.
  • A way to suppress and restore the global context is necessary, and I'm not convinced the approach of a "call to AsyncContext.wrap() at the top level" is sufficiently ergonomic.
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?
Seb pushed me back to talking about 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 cache, but dropping use discussion (it's not necessary, client will support async/await without it)
Did he not like the idea of discussing priorities?
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 run, and what I don't understand yet is why we'd want that.
I think that's actually how the proposal probably works right now.
22:51
<littledan>
I think that's actually how the proposal probably works right now.
by "right now" you mean the flat get/set variable API in the main branch?
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
hmm, I would like to dig into this more (but maybe another day/with Seb and/or Yoav)
22:54
<littledan>
Sorry, no. Snapshotting is likely how we would achieve the semantics that Justin Ridgewell seems to prefer.
What do you mean by this?
22:54
<rbuckton>

Imagine the current execution context as an ImmutableMap. Adding or replacing entries in the map copies the map.
So:

const ctx1 = new AsyncContext();
const ctx2 = new AsyncContext();
const ctx3 = new AsyncContext();

// execution context: {}
ctx1.run("a", () => {
  // execution context: { [ctx1]: "a" }
  ctx2.run("b", () => {
    // execution context: { [ctx1]: "a", [ctx2]: "b" }
  });
  ctx3.run("c", () => {
    // execution context: { [ctx1]: "a", [ctx3]: "c" }
  });
});
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>
let __storage__ = new Map();
class AsyncContext {
  // Pushes a new state, and pops it when done
  run(val, cb) {
    let prev = __storage__;
    try {
      this.set(val);
      return cb();
    } finally {
      __storage__ = prev;
    }
  }

  // Mutates the current state
  set(val) {
    const next = new Map(__storage__);
    next.set(this, val);
    __storage__ = next;
  }
}
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
and this singly linked list is immutable; the only thing that changes is the pointer to its root
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
Yes, and that's how an 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 ExecutionContext object that had an immutable dictionary of AsyncContext->value entries. What an AsyncLocal would need is a second immutable dictionary on that object:

// internal implementation details...
class ExecutionContext {
  #asyncContextValues;
  #asyncLocalValues;
  constructor(asyncContextValues, asyncLocalValues) {
    this.#asyncContextValues = asyncContextValues;
    this.#asyncLocalValues = asyncLocalValues;
  }

  static get current() {
    return %GetCurrentExecutionContext();
  }

  static set current(value) {
    %SetCurrentExecutionContext(value);
  }

  static create() {
    return new ExecutionContext(new ImmutableMap(), new ImmutableMap());
  }

  copy() {
    return new ExecutionContext(this.#asyncContextValues, this.#asyncLocalValues);
  }

  getContext(key) {
    return this.#asyncContextValues.get(key);
  }

  getLocal(key) {
    return this.#asyncLocalValues.get(key);
  }

  setLocal(key, value) {
    this.#asyncLocalValues = this.#asyncLocalValues.set(key, value);
  }

  runWithContext(asyncContext, value, callback, args) {
    const context = new ExecutionContext(this.#asyncContextValues.set(asyncContext, value), this.#asyncLocalValues);
    return %RunWithContext(context, callback, args);
  }
}

// global scope would have a root context
ExecutionContext.current = ExecutionContext.create();

// public
class AsyncContext {
  run(value, callback, ...args) {
    return ExecutionContext.current.runWithContext(this, value, callback, args);
  }

  get() {
    return ExecutionContext.current.getContext(this);
  }

  static wrap(cb) {
    const captured = ExecutionContext.current;
    return (...args) => {
      const current = ExecutionContext.current;
      try {
        ExecutionContext.captured = captured;
        return cb(...args);
      }
      finally {
        ExecutionContext.current = current;
      }
    }
  }
}

// public
class AsyncLocal {
  get value() { return ExecutionContext.current.getLocal(this); }
  set value(v) { ExecutionContext.current.setLocal(this, v); }
}

// and `await f()` is translated to something like:

const result = Promise.resolve(f());
result.[[ExecutionContext]] = ExecutionContext.current.copy(); // used for continuations
await result;

An await would just preserve the pointer to the AsyncContext entries, but get an independent reference to the AsyncLocal entries. Mutating the asyncLocalValues reference wouldn't affect snapshots taken during await.

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 yield, await, and this when you shift to a callback).
Ah, I see (abstractly, still trying to conceptualize how this relates to likely concrete code)
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 run. There are many wrapped callbacks/promise reactions for every big run setting up a context
Yes, and that's generally true for an 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).
OK, so in this case, the sensitive information is in an outer context, and then inner nested contexts need to not be able to see it?
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 AsyncContext.wrap((cb, ...args) => cb(...args)).
I guess, for both of these examples, a "true" top-level usage is not needed--we just need to capture this before the sensitive information is added by the server/vscode, right?
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?