00:48
<Justin Ridgewell>
If it's limited to scoped using and not general mutability, I think you can polyfill on top of AsyncContext and only instrument await and yield statements.
00:49
<Justin Ridgewell>
I know we talked about this before and I said otherwise, but I can't remember why I said it wasn't possible before.
04:18
<Steve Hicks>

But I think that's the problem - it can't be limited to scoped using because of composability: you need to be able to write

function enterSpan(id) {
  const span = new Span({id, parent: currentSpan.get()});
  span[Symbol.dispose] = currentSpan.enter(span)[Symbol.dispose];
  log('new span', currentSpan.get());
  return span;
}

That function gives no syntactic indication that it needs any extra transpilation... in fact, thinking about it further, it violates the principle that functions you call shouldn't be able to change your context.

04:23
<Justin Ridgewell>
That example wouldn't be possible, but using using span = enterSpan(…) could be
04:24
<Steve Hicks>
how would you define enterSpan in that case?
04:35
<Justin Ridgewell>
The same?
04:35
<Justin Ridgewell>
The difference is you expected enterSpan to cause the mutation, and I'm expecting the using span … to do it
17:06
<Steve Hicks>
This is the disposable[Symbol.enter]() idea, but AIUI, it's a non-starter if it only works syntactically - i.e. you still need to be able to call it reflectively for composability.
17:07
<Steve Hicks>
Case in point: Span.prototype[Symbol.enter] would need to call AsyncContext.Mutation.prototype[Symbol.enter] transitively.
17:07
<Steve Hicks>
and it couldn't use using syntax for that.
17:27
<nicolo-ribaudo>

For using with Symbol.enter/Symbol.dispose, I don't think we need to enforce it syntactically. Does this provide enough guarantees, or are there still ways to mess up the context and not get an explicit error about it?

function enterWith(variable, value) {
  let oldContext, updatedContext

  return {
    [Symbol.enter]() {
      if (oldContext) throw new Error("Cannot enter twice");
      oldContext = AsyncContextSnapshot();
      updatedContext = { ...oldContext, [variable]: value };

      AsyncContextSwap(updatedContext);
    },
    [Symbol.dispose]() {
      if (!oldContext) throw new Error("Cannot dispose before entering");

      const current = AsyncContextSnapshot();
      if (current !== updatedContext) {
        throw new Error("Cannot dispose, as it's not the current context");
      }

      AsyncContextSwap(oldContext);
    }
  }
}

AsyncContext.Variable.prototype.run = function (value, callback) {
  const oldContext = AsyncContextSnapshot();
  const updatedContext = { ...oldContext, [this]: value };

  AsyncContextSwap(updatedContext);
  try {
    return callback();
  } finally {
    const current = AsyncContextSnapshot();
    AsyncContextSwap(oldContext);

    if (current !== updatedContext) {
      throw new Error(".run ended before that its context was restored");
    }
  }
}

{
  // context: root

  x1[Symbol.enter]();
  // context: x1

  x2[Symbol.enter]();
  // context: x2

  x1[Symbol.dispose](); // error: "Cannot dispose, as it's not the current context"
}


{
  // context: root

  x1[Symbol.enter]();
  // context: x1

  myVar.run("foo", () => {
    // context: foo

    x1[Symbol.dispose](); // error: "Cannot dispose, as it's not the current context"
  })
}

{
  // context: root

  myVar.run("foo", () => {
    // context: foo

    x1[Symbol.enter]();
    // context: x1
  }); // closes the foo context, then error: ".run ended before that its context was restored"
}
17:33
<nicolo-ribaudo>

And you can still leak a little bit, but:

  • .run is a hard boundary you cannot leak accross
  • if you leak, basically as soon as some other context ends you'll get an error
17:33
<nicolo-ribaudo>
And the error could point to the stack trace of where you did enter without then disposing it
17:34
<Steve Hicks>
I think you need to store two locals - one for oldContext and one for updatedContext and then line 15 wants to compare current !== updatedContext
17:34
<nicolo-ribaudo>
True, right (updated)
17:37
<Steve Hicks>

And then the idea is that

async function f() {
  using _ = enterWith(v, 2);
  await 1;
}
{
  using _ = enterWith(v, 1);
  f();
}

would work because the suspension would restore the entry context? But if f were an ordinary function that leaked then the dispose after f() would fail.

17:38
<nicolo-ribaudo>
Yes and yes
17:38
<Steve Hicks>
And any non-syntactic access to the protocol should still at least be sound
17:39
<nicolo-ribaudo>
And the whole program should also have a check at the end that checks you didn't forget to close anything
17:39
<Steve Hicks>
I don't think you strictly need Symbol.enter for these checks.
17:39
<nicolo-ribaudo>
So that even a program that just does x1[Symbol.enter]() throws when it ends
17:39
<Steve Hicks>
though it would certainly be nicer to have
17:39
<nicolo-ribaudo>
I don't think you strictly need Symbol.enter for these checks.
Yeah, you can also just have two functions that you call to activate and deactivate the context
17:43
<Steve Hicks>
So does run also verify that the "outgoing" context hasn't changed?
17:44
<Steve Hicks>
And is there any precedent for this sort of behavior where we allow the unsound thing to happen, but only throw an error later after it becomes more obvious?
17:45
<nicolo-ribaudo>
So does run also verify that the "outgoing" context hasn't changed?
Yes, but still restores the correct one before throwing, so that try/catch+run always guarantees that a bad function can be run properly without it messing anything up
17:49
<Steve Hicks>
This would work fine with generators if yield had the same behavior as await; otherwise, it would effectively make a yield within an enterWith block be an error.
17:50
<Steve Hicks>
unless the generator was fully iterated in a single outer context
17:52
<Steve Hicks>
one could imagine a weird solution where the yield would somehow capture the mutations and replay them on top of the new re-entered context, but that doesn't seem reasonable
17:55
<nicolo-ribaudo>
Agree — I think we need to make yield capture/restore if we don't want to prevent enterWith from happening in the future
17:55
<Steve Hicks>
This makes it impossible to implement dispatch-context iterator helpers (in userland) as generators
17:56
<nicolo-ribaudo>
In a world with enterWith, could we do something like this with a metaproperty? ``` let x = yield foo; using _ = yield.nextCallerContext ```
17:57
<nicolo-ribaudo>
Where yield.nextCallerContext gives you the context of the .next call
17:57
<nicolo-ribaudo>
And you can "enter a snapshot"
17:58
<Steve Hicks>
that seems feasible
18:12
<Steve Hicks>
So I guess that leaves ^ as my main concern - do we think this sort of thing could actually land?
18:13
<nicolo-ribaudo>
I don't know, nothing comes immediately to my mind
18:13
<nicolo-ribaudo>
I'll look around
18:13
<Steve Hicks>
(and aside, I'm a little sad that I won't be able to rip out the over-complicated transpilation for generators)
18:18
<nicolo-ribaudo>
Instead of transpiling the generator, could you wrap it in a function that calls .next setting the original generator context first?
19:49
<Steve Hicks>
That's an interesting idea, I'll need to look into that.
19:51
<Steve Hicks>
it's a little awkward for class methods, but I think we do something similar when downleveling async methods
22:19
<Steve Hicks>
I prototyped a quick proof-of-concept that it's possible to leverage most of an existing implementation and add a disposable enterWith by just replacing AsyncContext.Variable with a new implementation that indirects through a single "real" variable: https://gist.github.com/shicks/0cd7e9b06535793c137934cc52ed12ce
22:20
<Steve Hicks>

I don't think an analogous approach would work for yield.nextCallerContext - you'd be forced to at least go back to transpiling all generators (to wrap with a function that set the nextCallerContext in some reasonable way)

Scratch that - you need to transpile the keyword anyway, and if you do so, you only need to wrap that particular generator.