doc: add guidelines for introduction of ERM support

PR-URL: https://github.com/nodejs/node/pull/58526
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Ethan Arrowood <ethan@arrowood.dev>
Reviewed-By: Gerhard Stöbich <deb2001-github@yahoo.de>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Reviewed-By: LiviaMedeiros <livia@cirno.name>
This commit is contained in:
James M Snell 2025-06-29 14:54:00 +01:00 committed by GitHub
parent a3dfca90d1
commit ebe7dade03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -0,0 +1,509 @@
# Explicit Resource Management (`using`) Guidelines
[Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management)
is a capability that was introduced to the JavaScript language in 2025. It provides a way
of marking objects as disposable resources such that the JavaScript engine will automatically
invoke disposal methods when the object is no longer in scope. For example:
```js
class MyResource {
dispose() {
console.log('Resource disposed');
}
[Symbol.dispose]() {
this.dispose();
}
}
{
using resource = new MyResource();
// When this block exits, the `Symbol.dispose` method will be called
// automatically by the JavaScript engine.
}
```
This document outlines some specific guidelines for using explicit resource
management in the Node.js project -- specifically, guidelines around how to
make objects disposable and how to introduce the new capabilities into existing
APIs.
The caveat to this guidance is that explicit resource management is a brand new
language feature, and there is not an existing body of experience to draw from
when writing these guidelines. The points outlined here are based on the
current understanding of how the mechanism works and how it is expected to
be used. As such, these guidelines may change over time as more experience
is gained with explicit resource management in Node.js and the ecosystem.
It is always a good idea to check the latest version of this document, and
more importantly, to suggest changes to it based on evolving understanding,
needs, and experience.
## Some background
Objects can be made disposable by implementing either, or both, the
`Symbol.dispose` and `Symbol.asyncDispose` methods:
```js
class MySyncResource {
[Symbol.dispose]() {
// Synchronous disposal logic
}
}
class MyAsyncDisposableResource {
async [Symbol.asyncDispose]() {
// Asynchronous disposal logic
}
}
```
An object that implements `Symbol.dispose` can be used with the `using`
statement, which will automatically call the `Symbol.dispose` method when the
object goes out of scope. If an object implements `Symbol.asyncDispose`, it can
be used with the `await using` statement in an asynchronous context. It is
worth noting here that `await using` means the disposal is asynchronous,
not the initialization.
```mjs
{
using resource = new MyResource();
await using asyncResource = new MyResource();
}
```
Importantly, it is necessary to understand that the design of `using` makes it
possible for user code to call the `Symbol.dispose` or `Symbol.asyncDispose`
methods directly, outside of the `using` or `await using` statements. These
can also be called multiple times and by any code that is holding a reference
to the object. That is to say, explicit resource management does not imply
ownership of the object. It is not a form of RAII (Resource Acquisition Is
Initialization) as seen in some other languages and there is no notion of
exclusive ownership of the object. A disposable object can become disposed
at any time.
The `Symbol.dispose` and `Symbol.asyncDispose` methods are called in both
successful and exceptional exits from the scopes in which the `using` keyword
is used. This means that if an exception is thrown within the scope, the
disposal methods will still be called (similar to how `finally { }` blocks work).
However, when the disposal methods are called they are not aware of the context.
These methods will not receive any information about any exception that may have
been thrown. This means that it is often safest to assume that the disposal
methods will be called in a context where the object may not be in a valid
state or that an exception may be pending.
## Guidelines for disposable objects
So with this in mind, it is necessary to outline some guidelines for disposers:
1. Disposers should be idempotent. Multiple calls to the disposal methods
should not cause any issues or have any additional side effects.
2. Disposers should assume that they are being called in an exception context.
Always assume there is likely a pending exception and that if the object
has not been explicitly closed when the disposal method is called, the
object should be disposed as if an exception had occurred. For instance,
if the object API exposes both a `close()` method and an `abort()` method,
the disposal method should call `abort()` if the object is not already
closed. If there is no difference in disposing in success or exception
contexts, then separate disposal methods are unnecessary.
3. It is recommended to avoid throwing errors within disposers.
If a disposer throws an exception while there is another pending
exception, then both exceptions will be wrapped in a `SuppressedError`
that masks both. This makes it difficult to understand the context
in which the exceptions were thrown.
4. Disposable objects should expose named disposal methods in addition
to the `Symbol.dispose` and `Symbol.asyncDispose` methods. This allows
user code to explicitly dispose of the object without using the `using`
or `await using` statements. For example, a disposable object might
expose a `close()` method that can be called to dispose of the object.
The `Symbol.dispose` and `Symbol.asyncDispose` methods should then invoke
these named disposal methods in an idempotent manner.
5. Because it is safest to assume that the disposal method will be called
in an exception context, it is generally recommended to prefer use of
`Symbol.dispose` over `Symbol.asyncDispose` when possible. Asynchronous
disposal can lead to delaying the handling of exceptions and can make it
difficult to reason about the state of the object while the disposal is
in progress. Disposal in an exception context is preferably synchronous
and immediate. That said, for some types of objects async disposal is not
avoidable.
6. Asynchronous disposers, by definition, are able to yield to other tasks
while waiting for their disposal task(s) to complete. This means that, as a
minimum, a `Symbol.asyncDispose` method must be an `async` function, and
must `await` at least one asynchronous disposal task. If either of these
criteria is not met, then the disposer is actually a synchronous disposer in
disguise, and will block the execution thread until it returns; such a
disposer should instead be defined using `Symbol.dispose`.
7. Because the disposal process is strictly ordered, there is an intrinsic
expectation that all tasks performed by a single disposer are fully complete
at the point that the disposer returns. This means, for example, that
"callback-style" APIs must not be invoked within a disposer, unless they are
promisified and awaited. Any Promise created within a disposer must be
awaited, to ensure its resolution prior to the disposer returning.
8. Avoid, as much as possible, using both `Symbol.dispose` and `Symbol.asyncDispose`
in the same object. This can make it difficult to reason about which method
will be called in a given context and could lead to unexpected behavior or
subtle bugs. This is not a firm rule, however; there may be specific cases
where it makes sense to define both, such as where a resource already exposes
both synchronous and asynchronous methods for closing down the resource.
### Example disposable objects
A disposable object can be quite simple:
```js
class MyResource {
#disposed = false;
dispose() {
if (this.#disposed) return;
this.#disposed = true;
console.log('Resource disposed');
}
[Symbol.dispose]() {
this.dispose();
}
}
{ using myDisposable = new MyResource(); }
```
Or even fully anonymous objects:
```js
function getDisposable() {
let disposed = false;
return {
dispose() {
if (disposed) return;
disposed = true;
console.log('Resource disposed');
},
[Symbol.dispose]() {
this.dispose();
},
};
}
{ using myDisposable = getDisposable(); }
```
Some disposable objects, however, may need to differentiate between disposal
in a success context and disposal in an exception context as in the following
example:
```js
class MyDisposableResource {
constructor() {
this.closed = false;
}
doSomething() {
if (maybeShouldThrow()) {
throw new Error('Something went wrong');
}
}
close() {
// Gracefully close the resource.
if (this.closed) return;
this.closed = true;
console.log('Resource closed');
}
abort(maybeError) {
// Abort the resource, optionally with an exception. Calling this
// method multiple times should not cause any issues or additional
// side effects.
if (this.closed) return;
this.closed = true;
if (maybeError) {
console.error('Resource aborted due to error:', maybeError);
} else {
console.log('Resource aborted');
}
}
[Symbol.dispose]() {
// Note that when this is called, we cannot pass any pending
// exceptions to the abort method because we do not know if
// there is a pending exception or not.
this.abort();
}
}
```
Then in use:
```js
{
using resource = new MyDisposableResource();
// Do something with the resource that might throw an error
resource.doSomething();
// Explicitly close the resource if no error was thrown to
// avoid the resource being aborted when the `Symbol.dispose`
// method is called.
resource.close();
}
```
Here, if an error is thrown in the `doSomething()` method, the `Symbol.dispose`
method will still be called when the block exits, ensuring that the resource is
disposed of properly using the `abort()` method. If no error is thrown, the
`close()` method is called explicitly to gracefully close the resource. When the
block exits, the `Symbol.dispose` method is still called but it will be a non-op
since the resource has already been closed.
To deal with errors that may occur during disposal, it is necessary to wrap
the disposal block in a try-catch:
```js
try {
using resource = new MyDisposableResource();
// Do something with the resource that might throw an error
resource.doSomething();
resource.close();
} catch (error) {
// Error might be the actual error thrown in the block, or might
// be a SuppressedError if an error was thrown during disposal and
// there was a pending exception already.
if (error instanceof SuppressedError) {
console.error('An error occurred during disposal masking pending error:',
error.error, error.suppressed);
} else {
console.error('An error occurred:', error);
}
}
```
### Symbol.dispose and Symbol.asyncDispose return values
The `Symbol.dispose` method should return `undefined` and the
`Symbol.asyncDispose` method should return a `Promise` that resolves to
`undefined`.
<!-- eslint-skip -->
```js
[Symbol.dispose]() {
return void this.dispose();
// or
this.dispose();
// or
return;
// or
// no return
}
async [Symbol.asyncDispose]() {
await this.dispose();
// or
return;
// or
// no return
}
```
### Debuggability of disposer methods
To improve debugging experience, The `Symbol.dispose` and `Symbol.asyncDispose`
functions should not be direct aliases of named disposer functions. They should
instead defer to the named disposer. This ensures the stack traces can be more
informative about whether a disposer was called via `using` or was called
directly.
For example:
<!-- eslint-skip -->
```js
// Do something like this:
function dispose() { ... }
return {
dispose,
[Symbol.dispose]() { this.dispose(); }
};
// Rather than this:
function dispose() { ... }
return {
dispose,
[Symbol.dispose]: dispose
};
```
### A Note on documenting disposable objects
When documenting disposable objects, it is important to clearly indicate that
the object is disposable and how it should be disposed of. This includes
documenting the `Symbol.dispose` and `Symbol.asyncDispose` methods, as well as
any named disposal methods that the object exposes.
If the disposable object is anonymous (that is, it is a regular JavaScript
object that implements the `Symbol.dispose` method), it is still important to
document that it is disposable and how it should be disposed of.
Within the documentation, it is possible to document anonymous objects as if
they were classes, using the `Class: ` prefix and otherwise presenting the
object as if you were documenting a regular JavaScript class, even if it is
never actually instantiated as a class. Examples of this pattern can be seen,
for instance, in the documentation of the [Web Crypto API](../api/webcrypto.md).
So, for example, if you have an API that returns an anonymous disposable object,
you might document it like:
```markdown
### Class: `MyDisposableObject`
#### `myDisposableObject.dispose()`
...
#### `myDisposableObject[Symbol.dispose]()`
...
### `foo.getMyDisposableObject()`
* Returns: {MyDisposableObject}
```
## Guidelines for introducing explicit resource management into existing APIs
Introducing the ability to use `using` into existing APIs can be tricky.
The best way to understand the issues is to look at a real world example. PR
[58516](https://github.com/nodejs/node/pull/58516) is a good case. This PR
sought to introduce `Symbol.dispose` and `Symbol.asyncDispose` capabilities
into the `fs.mkdtemp` API such that a temporary directory could be created and
be automatically disposed of when the scope in which it was created exited.
However, the existing implementation of the `fs.mkdtemp` API returns a string
value that cannot be made disposable. There are also sync, callback, and
promise-based variations of the existing API that further complicate the
situation.
In the initial proposal, the `fs.mkdtemp` API was changed to return an object
that implements the `Symbol.dispose` method but only if a specific option is
provided. This would mean that the return value of the API would become
polymorphic, returning different types based on how it was called. This adds
a lot of complexity to the API and makes it difficult to reason about the
return value. It also makes it difficult to programmatically detect whether
the version of the API being used supports `using` or not.
`fs.mkdtemp('...', { disposable: true })` would act differently in older versions
of Node.js than in newer versions with no way to detect this at runtime other
than to inspect the return value.
Some APIs that already return objects that can be made disposable do not have
this kind of issue. For example, the `setImmediate()` API in Node.js returns an
object that implements the `Symbol.dispose` method. This change was made without
much fanfare because the return value of the API was already an object.
So, some APIs can be made disposable easily without any issues while others
require more thought and consideration. The following guidelines can help
when introducing these capabilities into existing APIs:
1. Avoid polymorphic return values: If an API already returns a value that
can be made disposable, and it makes sense to make it disposable, do so. Do
not, however, make the return value polymorphic determined by an option
passed into the API.
2. Introduce new API variants that are `using` capable: If an existing API
cannot be made disposable without changing the return type or making it
polymorphic, consider introducing a new API variant. For example,
`fs.mkdtempDisposable` could be introduced to return a disposable object
while the existing `fs.mkdtemp` API continues to return a string. Yes, it
means more APIs to maintain but it avoids the complexity and confusion of
polymorphic return values. If adding a new API variant is not ideal, remember
that changing the return type of an existing API is quite likely a breaking
change.
3. When an existing API signature does not lend itself easily to supporting making
the return value disposable and a new API needs to be introduced, it is worth
considering whether the existing API should be deprecated in favor of the new.
Deprecation is never a decision to be taken lightly, however, as it can have major
ecosystem impact.
## Guidelines for using disposable objects
Because disposable objects can be disposed of at any time, it is important
to be careful when using them. Here are some guidelines for using disposables:
1. Never use `using` or `await using` with disposable objects that you
do not own. For instance, the following code is problematic if you
are not the owner of `someObject`:
```js
function foo(someObject) {
using resource = someObject;
}
```
The reason this is problematic is that the `using` statement will
unconditionally call the `Symbol.dispose` method on `someObject` when the block
exits, but you do not control the lifecycle of `someObject`. If `someObject`
is disposed of, it may lead to unexpected behavior in the rest of the
code that called the `foo` function.
2. When there is a clear difference between disposing of an object in a success
context vs. an exception context, always explicitly dispose of objects the
successful code paths, including early returns. For example:
```js
class MyDisposableResource {
close() {
console.log('Resource closed');
}
abort() {
console.log('Resource aborted');
}
[Symbol.dispose]() {
// Assume the error case here...
this.abort();
}
}
function foo() {
using res = new MyDisposableResource();
if (someCondition) {
// Early return, ensure the resource is disposed of
res.close();
return;
}
// do other stuff
res.close();
}
```
This is because of the fact that, when the disposer is called, it has no way
of knowing if there is a pending exception or not and it is generally safest
to assume that it is being called in an exceptional state.
Many types of disposable objects make no differentiation between success and
exception cases, in which case relying entirely on `using` is just fine (and
preferred). The disposable returned by `setImmediate()` is a good example here.
All that does is call `clearImmediate()` and it does not matter if the block
errored or not.
3. Remember that disposers are invoked in a stack, in the reverse order
in which they were created. For example,
```js
class MyDisposable {
constructor(name) {
this.name = name;
}
[Symbol.dispose]() {
console.log(`Disposing ${this.name}`);
}
}
{
using a = new MyDisposable('A');
using b = new MyDisposable('B');
using c = new MyDisposable('C');
// When this block exits, the disposal methods will be called in the
// reverse order: C, B, A.
}
```
Because of this, it is important to consider the possible relationships
between disposable objects. For example, if one disposable object holds a
reference to another disposable object the cleanup order may be important.