Try   HackMD

Web integration for AsyncContext: in-spec context tracking

One of the objectives of the AsyncContext web integration is to figure out how and when to expose to event handlers the AsyncContext of what caused the event to be dispatched.

This can be done in two possible ways: either by running the event handler in that context, or by exposing its snapshot as a property on the event itself.

For example:

const myVar = new AsyncContext.Variable();

const req = new XMLHttpRequest();
req.open("GET", "https://example.com/");

myVar.run("Hello!", () => {
    // Send the request. This will eventually
    // cause the `load` event to fire
    req.send();
});

req.addEventListener("load", (e) => {
    // somehow, get the value "Hello!" out of myVar here
})

Regardless of how this is exposed to the users, specs have to wire this snapshot through to make sure that it's available when running the event handler. While this is trivial for events that are dispatched synchronously (such as when calling Element.prototype.click()) because it's available as the ambient context, asynchronous events require the context to be snapshotted and propagated.

Editorially, there are two possible ways of propagating it:

  • explicitly, by storing the snapshot in a variable and passing it around
  • implicitly, by defining a spec-internal concept similar to AsyncContext, and have it automatically flow through spec scheduling mechanism like AsyncContext flows through JS schedulers

While the first approach has the advantage of (obviously! :P) being explicit and thus making it clear what is going on, it has the effect of adding a significant amount of noise to every algorihtm that might result in an event being fired. Most casual the readers of the specification will probably not be interested in how AsyncContext is flowing through its events, and instead they just want to learn the semantics of a specific algorithm.

The second approach can solve that problem, by introducing some complexity of the spec that readers will only be affected by when they are specifically interested in AsyncContext propagation through a given API. It has the drawback that implementers might miss it when implementing a new API: we would have to make sure to add AsyncContext WPT tests for every possible way of triggering each event that propagates it.

How to implicitly track context

HTML-adjacent specifications use multiple scheduling mechanisms:

  • you can schedule tasks on a task queue, which is run by the event loop
  • you can run algorithms in parallel
  • you can use a few other queues run by the window event loop, such as the fullscreen steps or the scroll steps

All algorithms need to accept an implicit async context snapshot argument, that is implicitly passed whenever running another algorithm/steps. This implicit async context snapshot is read/written in multiple places:

The async context snapshot cannot be introspected when running in parallel, but only propagated.

TODO

  • Check what to do with parallel queues
  • The JavaScript-visible async context must not leak across agents (which is observable through round-trips), so WebIDL needs to have some guard against it.

Effectiveness of the implicit tracking

When it comes to event handlers, which are the largest surface area for JavaScript code triggered asynchronously as a consequence of other JavaScript code, this implicit event tracking works in many cases but not all of them.

We started an analysis at https://docs.google.com/spreadsheets/d/1r-IjEyTEuCzQtJgSyY-a5htrQFME9RPylkv3hT_puqg, see the "Context easily available at async dispatch" column. For events where the dispatch context is not available (or for the rare cases in which there is a context available but not the expected one), it would still need to be passed manually.

Correctness of the implicit tracking

There are a limited amount of places in web specs where JavaScript code is run asynchronously, all listed at https://github.com/tc39/proposal-async-context/pull/100/files.

Setting aside events for a moment, there are only ~25 APIs that take a callback and run it asynchronously. The correctness of implicit tracking in all of those cases must be verified and tested as part of the first iteration, with adjustments done to those specs as needed. For all cases in which the callback is registered in the same operation as the one starting the work (for example, setTimeout() both receives the callback and schedules it), we can have a WebIDL flag that marks APIs as "these automatically call AsyncContext.wrap on the callback", so that they don't need to rely on propagation through spec algorithms.

Events are more complex, because there are hundreds of them and getting all of them right upfront without bugs is a monumental task (both validating that the spec propagates the context as expected, and implementing it). We should instead explore an incremental approach, trying to get the easy part done soon and iterating on the more complex cases over time.

To do so, we can add a boolean flag "use dispatch context" to the fire an event which defaults to false. When this flag is set to false, the event listener is run in the fallback root context instead of in whatever context is being automatically propagated to it. We can enable this flag gradually, as we validate the callers of this algorithm in the various specs.

A first sets of events that can already have thus "use dispatch context" set are all the events that are triggered only either by non-JS causes (for which the dispatch context and the root context correspond) or by sync JS causes (for which the dispatch context is trivially propagated): these includes all the user-interaction-like events that can be triggered by HTML methods such as .click().

TODO: Explore how safe this incremental approach would be. Is there any realistic code that would break if it receives a non-empty context rather than the empty context?

Examples in which the implicit propagation would work

HTMLCanvasElement.prototype.toBlob()

Although toBlob() would probably use the WebIDL approach, it's a good first example of implicit propagation:

  • When toBlob() is called, there is a current async context snapshot s.
  • It schedules some steps to run in parallel, that implicitly inherit the async context snapshot s. from toBlob()
  • They queue an element task, explicitly passing some steps and implicitly passing the s to it.
  • Queue an element task then runs queue a global task, implicitly passing s along. The lattern then runs queue a task, passing s along again.
  • Queue a task creates a task whose async context snapshot is set to s.
  • When the event loop runs the task, it passes the task's async context snapshot s to the associated steps.
  • The steps (which were defined in toBlob()) receive s as the implicit async context snapshot, and invoke the callback implicitly passing it along.
  • Invoke would perform AsyncContextSwap(s) before calling the JavaScript function.

ReadableStream's unserlying source's pull() when called by ReadableStreamDefaultReader.prototype.read()

NOTE: This is all synchronous, so the result would be the same if web specs were completely unaware of AsyncContext

XMLHttpRequest's readystatechange event handlers

This is a more complete example where the implicit propagation is actually useful.