Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ECMAScript Explicit Resource Management proposal integration with web APIs #8557

Open
rbuckton opened this issue Nov 28, 2022 · 4 comments
Open

Comments

@rbuckton
Copy link

The ECMAScript Explicit Resource Management proposal is currently at Stage 2 and is nearing advancement Stage 3. I'd like to get a feel from WHATWG as to whether, and how, WHATWG might integrate the capabilities of this proposal into existing APIs. I've created a rough list of potential integration points in the proposal explainer.

What follows is a brief summary of the proposal. You can read more about the proposal in the proposal repository and the proposal specification text. If you are unfamiliar with the TC39 staging process, please see the TC39 process document.


The Explicit Resource Management proposal provides the ability for JavaScript users to leverage RAII ("Resource Acquisition is Initialization")-style declarations via the new using declaration:

{
  using x = acquireSomeResource();
  ...
} // 'x' is disposed when exiting Block

A using declaration is constant, block-scoped declaration (like const), and tracks its binding for disposal at the end of the containing block, regardless as to whether from a normal or abrupt completion. This allows for explicit control over the lifetime of a resource. A resource is an object with a Symbol.dispose method that, when called, should perform synchronous cleanup activities.

This kind of declaration is extremely useful when managing the scoping and lifetime of multiple resources, as it guarantees ordered cleanup of resources (excluding process/thread termination):

{
  using x = createResource1();
  using y = createResource2();
  ...
} // disposes 'y', then disposes 'x'

In addition, this proposal introduces a new DisposableStack constructor which allows a user to collect multiple disposable resources in a single disposable container, which is extremely helpful when composing disposable classes consisting of other resources:

class MyResource {
  #x;
  #y;
  #disposables;

  constructor() {
    using stack = new DisposableStack(); // 'stack' will be disposed at end of constructor
    this.#x = stack.use(createResource1()); // Add resource for '#x' to 'stack'
    this.#y = stack.use(createResource2()); // Add resource for '#y' to 'stack'

    // if either 'createResource1()' or 'createResource2()' throws, 'stack' and its contents
    // will be disposed.

    this.#disposables = stack.move(); // move resources out of 'stack' and into '#disposables'
    
    // disposes 'stack' (now empty), but not '#x', '#y', or '#disposables'
  }
  
  ...
  
  [Symbol.dispose]() {
    this.#disposables.dispose(); // disposes '#y' and '#x'
  }
}

Not all resources can be disposed of in a synchronous manner, however. This proposal also introduces Symbol.asyncDispose and an AsyncDisposableStack API for asynchronously disposed resources. These two APIs are part of the main proposal and are intended to advance to Stage 3. However, syntax for an asynchronous using declaration has been postponed to a follow-on proposal due to its possible dependency on async do expressions, though it will likely look something like the following:

async function f() {
  // body-scoped 'async using'
  async using x = someAsyncDisposableResource1();
  
  // block-scoped 'async using' requires an explicit 'await' marker:
  await async do {
    async using y = someAsyncDisposableResource2();
    ...
  }; // performs (roughly) 'await y?.[Symbol.asyncDispose]()
  
} // performs (roughly) 'await x?.[Symbol.asyncDispose]()

In summary, the following features are at Stage 2 and will be proposed for advancement to Stage 3 at the upcoming TC39 plenary:

  • using declarations — block-scoped, constant, synchronous disposal.
  • Symbol.dispose — built-in symbol, indicates disposal method.
  • Symbol.asyncDispose — built-in symbol, indicates async disposal method.
  • DisposableStack — disposable container.
  • AsyncDisposableStack — async disposable container.

The following features are at Stage 2 and have been postponed temporarily due to dependency on other proposals:

  • async using declarations — block-scoped, constant, asynchronous disposal.
@bakkot
Copy link
Contributor

bakkot commented Nov 29, 2022

A use case we discussed on Matrix, which I want to capture here so I don't forget about it, is to have a cancel-on-dispose AbortController, along the lines of

using controller = new AbortController.AutoAbort();
let pages = await Promise.all(urls.map(url => fetch(url, { signal: controller.signal }));
// automatically cancels outstanding requests if any request fails

I think that would be very useful. It's possible to do today with try-finally, as in

let controller = new AbortController;
let pages;
try {
  pages = await Promise.all(urls.map(url => fetch(url, { signal: controller.signal }));
} finally {
  controller.abort();
}

but that's pretty awkward, and I suspect the awkwardness has prevented people from adopting that pattern.

(Personally I'd be fine with making AbortController itself disposable, but @rbuckton objected to that, and I'm not firmly attached to building it in to the main AbortController constructor as long as there's an easy way to get this functionality.)

@petamoriken
Copy link

SuppressedError should be a serializable. related: #5749

@rbuckton
Copy link
Author

rbuckton commented Sep 18, 2023

(Personally I'd be fine with making AbortController itself disposable, but @rbuckton objected to that, and I'm not firmly attached to building it in to the main AbortController constructor as long as there's an easy way to get this functionality.)

My main objection to [Symbol.dispose]() triggering cancellation is that I would still like to see AbortController have some mechanism of signaling "I can no longer be aborted" when an async operation progresses beyond the point where signaling abort is no longer necessary or observable. Currently, AbortController and its associated AbortSignal will retain the memory associated with callbacks/closures even after such a point has been reached unless each closure is manually removed from the signal, which is far harder to do when it is the owner of the AbortController that determines when that point is reached.

In .NET, the default behavior of Dispose() on CancellationTokenSource (.NET's equivalent to AbortController) is to indicate "I can no longer be canceled" and perform cleanup of associated subscriptions and links to other CancellationTokenSource objects in its cancellation graph. Should AbortController ever also adopt this capability, I feel that a [Symbol.dispose]() on an AbortController should behave the same way, e.g., free up the resources held by AbortController itself. If we were to make [Symbol.dispose]() perform abort(), and AbortController later added this functionality, we would no longer be able to change the behavior of [Symbol.dispose]() to be more consistent with this resource/memory management operation.

In lieu of that, I would suggest the addition of an RAII-style wrapper class that could be used instead:

{
  const controller = new AbortController();
  using scope = new AbortController.AbortScope(controller);
  let pages = await Promise.all(urls.map(url => fetch(url, { signal: controller.signal }));
} // scopeis disposed, which in turn calls `controller.abort()`

This provides more flexibility, as other "do-X-on-dispose" behaviors can be modeled independently as well as be composed with other objects such as DisposeStack:

// create a controller that is usable across several phases of an async operation
const controller = new AbortController();

{
  using stack = new DisposeStack();
  stack.use(new AbortController.AbortScope(controller));
  // between here until the last statement of the block, any exception will abort the controller
  
  ...
  
  stack.move(); // we've stepped past the point where we need to ensure we abort
}

// next phase of operation

{
  using stack = new DisposableStack();
  stack.use(new AbortController.CleanupScope(controller));
  // between here until the last statement of the block, any exception will just result
  // in cleanup
  
  ...
  
  stack.move(); // we've stepped past the point where we want to cleanup
}

// final phase

{
  using stack = new DisposableStack();
  stack.use(new AbortController.AbortScope(controller));
  // once again, any exception will abort the controller
  
  // no need to move the scope out of the dispose stack now, since we're done
  return;
}

While this is arguably less convenient than just using controller = new AbortController(), it is far more flexible and expressive. It can also be accomplished without the need for DisposableStack, given a sufficiently expressive API:

{
  using scope = new AbortController.AbortScope(controller);
  ...
  scope.release(); // release scope without disposing it
}

// or

{
  using scope = controller.abortScope();
  ...
  scope.release();
}

{
  using scope = controller.cleanupScope();
  ...
  scope.release();
}

@rbuckton
Copy link
Author

Also, if we chose to adopt a secondary symbol to "enter" a disposable, as is being discussed in tc39/proposal-explicit-resource-management#195, something like what @bakkot has suggested wouldn't even necessarily require subclassing:

AbortController.abortOnDispose = function() {
  const controller = new AbortController();
  return {
    [Symbol.enter]() { return controller; },
    [Symbol.dispose]() { controller.abort(); }
  };
}

using controller = AbortController.abortOnDispose();
// which is roughly equivalent to:
//   const _a = AbortController.abortOnDispose();
//   const controller = _a[Symbol.enter]();
//   using _b = _a;

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

No branches or pull requests

3 participants