Skip to content

Commit 17a85e3

Browse files
committed
doc: add guidelines for introduction of ERM support
1 parent 9e35ddc commit 17a85e3

File tree

1 file changed

+366
-0
lines changed

1 file changed

+366
-0
lines changed

doc/contributing/erm-guidelines.md

Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
# Explicit Resource Management (`using`) Guidelines
2+
3+
Explicit Resource Management is a capability that was introduced to the JavaScript
4+
language in 2025. It provides a way of marking objects as disposable resources such
5+
that the JavaScript engine will automatically invoke disposal methods when the
6+
object is no longer in scope. For example:
7+
8+
```js
9+
class MyResource {
10+
dispose() {
11+
console.log('Resource disposed');
12+
}
13+
14+
[Symbol.dispose]() {
15+
this.dispose();
16+
}
17+
}
18+
19+
{
20+
using resource = new MyResource();
21+
// When this block exits, the `Symbol.dispose` method will be called
22+
// automatically by the JavaScript engine.
23+
}
24+
```
25+
26+
This document outlines some specific guidelines for using explicit resource
27+
management in the Node.js project -- specifically, guidelines around how to
28+
make objects disposable and how to introduce the new capabilities into existing
29+
APIs.
30+
31+
There is a significant caveat to this document, however. Explicit resource
32+
management is brand new and there is not a body of experience to draw from
33+
when writing these guidelines. The points outlined here are based on the
34+
current understanding of how the mechanism works and how it is expected to
35+
be used. As such, these guidelines may change over time as more experience
36+
is gained with explicit resource management in Node.js and the ecosystem.
37+
It is always a good idea to check the latest version of this document, and
38+
more importantly, to suggest changes to it based on evolving understanding,
39+
needs, and experience.
40+
41+
## Some background
42+
43+
Objects can be made disposable by implementing either, or both, the
44+
`Symbol.dispose` and `Symbol.asyncDispose` methods:
45+
46+
```js
47+
class MySyncResource {
48+
[Symbol.dispose]() {
49+
// Synchronous disposal logic
50+
}
51+
}
52+
53+
class MyAsyncDisposableResource {
54+
async [Symbol.asyncDispose]() {
55+
// Asynchronous disposal logic
56+
}
57+
}
58+
```
59+
60+
An object that implements `Symbol.dispose` can be used with the `using`
61+
statement, which will automatically call the `Symbol.dispose` method when the
62+
object goes out of scope. If an object implements `Symbol.asyncDispose`, it can
63+
be used with the `await using` statement in an asynchronous context. It is
64+
worth noting here that `await using` means the disposal is asynchronous,
65+
not the initialization.
66+
67+
```mjs
68+
{
69+
using resource = new MyResource();
70+
await using asyncResource = new MyResource();
71+
}
72+
```
73+
74+
Importantly, it is necessary to understand that the design of `using` makes it
75+
possible for user code to call the `Symbol.dispose` or `Symbol.asyncDispose`
76+
methods directly, outside of the `using` or `await using` statements. These
77+
can also be called multiple times and by any code that is holding a reference
78+
to the object. That is to say, explicit resource management does not imply
79+
ownership of the object. It is not a form of RAII (Resource Acquisition Is
80+
Initialization) as seen in some other languages and there is no notion of
81+
exclusive ownership of the object. A disposable object can become disposed
82+
at any time.
83+
84+
The `Symbol.dispose` and `Symbol.asyncDispose` methods are called in both
85+
successful and exceptional exits from the scopes in which the using keyword
86+
is used. This means that if an exception is thrown within the scope, the
87+
disposal methods will still be called. However, when the disposal methods are
88+
called they are not aware of the context. These methods will not receive any
89+
information about the exception that was thrown or whether an exception was
90+
thrown at all. This means that it is often safest to assume that the disposal
91+
methods will be called in a context where the object may not be in a valid
92+
state or that an exception may be pending.
93+
94+
## Guidelines for Disposable Objects
95+
96+
So with this is mind, it is necessary to outline some guidelines for disposers:
97+
98+
1. Disposers should be idempotent. Multiple calls to the disposal methods
99+
should not cause any issues or have any additional side effects.
100+
2. Disposers should assume that it is being called in an exception context.
101+
Always assume there is likely a pending exception and that if the object
102+
has not been explicitly closed when the disposal method is called, the
103+
object should be disposed as if an exception had occurred. For instance,
104+
if the object API exposes both a `close()` method and an `abort()` method,
105+
the disposal method should call `abort()` if the object is not already
106+
closed. If there is no difference in disposing in success or exception
107+
contexts, then separate disposal methods are unnecessary.
108+
3. It is recommended to avoid throwing errors within disposers.
109+
If a disposer throws an exception while there is another pending
110+
exception, then both exceptions will be wrapped in a `SupressedError`
111+
that masks both. This makes it difficult to understand the context
112+
in which the exceptions were thrown.
113+
4. Disposable objects should expose explicit disposal methods in addition
114+
to the `Symbol.dispose` and `Symbol.asyncDispose` methods. This allows
115+
user code to explicitly dispose of the object without using the `using`
116+
or `await using` statements. For example, a disposable object might
117+
expose a `close()` method that can be called to dispose of the object.
118+
The `Symbol.dispose` and `Symbol.asyncDispose` methods should delegate to
119+
these explicit disposal methods.
120+
5. Because it is safest to assume that the disposal method will be called
121+
in an exception context, it is generally recommended to prefer use of
122+
`Symbol.dispose` over `Symbol.asyncDispose` when possible. Asynchronous
123+
disposal can lead delaying the handling of exceptions and can make it
124+
difficult to reason about the state of the object while the disposal is
125+
in progress. Disposal in an exception context is preferably synchronous
126+
and immediate. That said, for some types of objects async disposal is not
127+
avoidable.
128+
6. Asynchronous disposers, by definition, are able to yield to other tasks
129+
while waiting for their disposal task(s) to complete. This means that, as a
130+
minimum, a `Symbol.asyncDispose` method must be an `async` function, and
131+
must `await` at least one asynchronous disposal task. If either of these
132+
criteria is not met, then the disposer is actually a synchronous disposer in
133+
disguise, and will block the execution thread until it returns; such a
134+
disposer should instead be declared using `Symbol.dispose`.
135+
7. Avoid, as much as possible, using both `Symbol.dispose` and `Symbl.asyncDispose`
136+
in the same object. This can make it difficult to reason about which method
137+
will be called in a given context and could lead to unexpected behavior or
138+
subtle bugs. This is not a firm rule, however. Sometimes it may make sense
139+
to define both but likely not.
140+
141+
### Example Disposable Object
142+
143+
```js
144+
class MyDisposableResource {
145+
constructor() {
146+
this.closed = false;
147+
}
148+
149+
doSomething() {
150+
if (maybeShouldThrow()) {
151+
throw new Error('Something went wrong');
152+
}
153+
}
154+
155+
close() {
156+
// Gracefully close the resource.
157+
if (this.closed) return;
158+
this.closed = true;
159+
console.log('Resource closed');
160+
}
161+
162+
abort(maybeError) {
163+
// Abort the resource, optionally with an exception. Calling this
164+
// method multiple times should not cause any issues or additional
165+
// side effects.
166+
if (this.closed) return;
167+
this.closed = true;
168+
if (maybeError) {
169+
console.error('Resource aborted due to error:', maybeError);
170+
} else {
171+
console.log('Resource aborted');
172+
}
173+
}
174+
175+
[Symbol.dispose]() {
176+
// Note that when this is called, we cannot pass any pending
177+
// exceptions to the abort method because we do not know if
178+
// there is a pending exception or not.
179+
this.abort();
180+
}
181+
}
182+
```
183+
184+
Then in use:
185+
186+
```js
187+
{
188+
using resource = new MyDisposableResource();
189+
// Do something with the resource that might throw an error
190+
resource.doSomething();
191+
resource.close();
192+
}
193+
```
194+
195+
Here, if an error is thrown in the `doSomething()` method, the `Symbol.dispose`
196+
method will still be called when the block exits, ensuring that the resource is
197+
disposed of properly using the `abort()` method. If no error is thrown, the
198+
`close()` method is called explicitly to gracefully close the resource. When the
199+
block exits, the `Symbol.dispose` method is still called but it will be a non-op
200+
since the resource has already been closed.
201+
202+
To deal with errors that may occur during disposal, it is necessary to wrap
203+
the disposal block in a try-catch:
204+
205+
```js
206+
try {
207+
using resource = new MyDisposableResource();
208+
// Do something with the resource that might throw an error
209+
resource.doSomething();
210+
resource.close();
211+
} catch (error) {
212+
// Error might be the actual error thrown in the block, or might
213+
// be a SuppressedError if an error was thrown during disposal and
214+
// there was a pending exception already.
215+
if (error instanceof SuppressedError) {
216+
console.error('An error occurred during disposal masking pending error:',
217+
error.error, error.suppressed);
218+
} else {
219+
console.error('An error occurred:', error);
220+
}
221+
}
222+
```
223+
224+
## Guidelines for Introducing explicit resource management into Existing APIs
225+
226+
Introducing the ability to use `using` into existing APIs can be tricky.
227+
228+
The best way to understand the issues is to look at a real world example. PR
229+
[58516](https://github.com/nodejs/node/pull/58516) is a good case. This PR
230+
sought to introduce `Symbol.dispose` and `Symbol.asyncDispose` capabilities
231+
into the `fs.mkdtemp` API such that a temporary directory could be created and
232+
be automatically disposed of when the scope in which it was created exited.
233+
However, the existing implementation of the `fs.mkdtemp` API returns a string
234+
value that cannot be made disposable. There are also sync, callback, and
235+
promise-based variations of the existing API that further complicate the
236+
situation.
237+
238+
In the initial proposal, the `fs.mkdtemp` API was changed to return an object
239+
that implements the `Symbol.dispose` method but only if a specific option is
240+
provided. This would mean that the return value of the API would become
241+
polymorphic, returning different types based on how it was called. This adds
242+
a lot of complexity to the API and makes it difficult to reason about the
243+
return value. It also makes it difficult to programmatically detect whether
244+
the version of the API being used supports `using` or not.
245+
`fs.mkdtemp('...', { disposable: true })` would act differently in older versions
246+
of Node.js than in newer versions with no way to detect this at runtime other
247+
than to inspect the return value.
248+
249+
Some APIs that already return objects that can be made disposable do not have
250+
this kind of issue. For example, the `setTimeout()` API in Node.js returns an
251+
object that implements the `Symbol.dispose` method. This change was made without
252+
much fanfare because the return value of the API was already an object.
253+
254+
So, some APIs can be made disposable easily without any issues while others
255+
require more thought and consideration. The following guidelines can help
256+
when introducing these capabilities into existing APIs:
257+
258+
1. Avoid polymorphic return values: If an API already returns a value that
259+
can be made disposable, and it makes sense to make it disposable, do so. Do
260+
not, however, make the return value polymorphic determined by an option
261+
passed into the API.
262+
2. Introduce new API variants that are `using` capable: If an existing API
263+
cannot be made disposable without changing the return type or making it
264+
polymorphic, consider introducing a new API variant. For example,
265+
`fs.mkdtempDisposable` could be introduced to return a disposable object
266+
while the existing `fs.mkdtemp` API continues to return a string. Yes, it
267+
means more APIs to maintain but it avoids the complexity and confusion of
268+
polymorphic return values. If adding a new API variant is not ideal, remember
269+
that changing the return type of an existing API is quite likely a breaking
270+
change.
271+
3. When an existing API signature does not lend itself easily to supporting making
272+
the return value disposable and a new API needs to be introduced, it is worth
273+
considering whether the existing API should be deprecated in favor of the new.
274+
Deprecation is never a decision to be taken lightly, however, as it can have major
275+
ecosystem impact.
276+
277+
## Guidelines for using disposable objects
278+
279+
Because disposable objects can be disposed of at any time, it is important
280+
to be careful when using them. Here are some guidelines for using disposable:
281+
282+
1. Never use `using` or `await using` with disposable objects that you
283+
do not own. For instance, the following code is problematic if you
284+
are not the owner of `someObject`:
285+
286+
```js
287+
function foo(someObject) {
288+
using resource = someObject;
289+
}
290+
```
291+
292+
The reason this is problematic is that the `using` statement will
293+
unconditionally call the `Symbol.dispose` method on `someObject` when the block
294+
exits, but you do not control the lifecycle of `someObject`. If `someObject`
295+
is disposed of, it may lead to unexpected behavior in the rest of the
296+
code that called the `foo` function.
297+
298+
2. When there is a clear difference between disposing of an object in a success
299+
context vs. an exception context, always explicitly dispose of objects the
300+
successful code paths, including early returns. For example:
301+
302+
```js
303+
class MyDisposableResource {
304+
close() {
305+
console.log('Resource closed');
306+
}
307+
308+
abort() {
309+
console.log('Resource aborted');
310+
}
311+
312+
[Symbol.dispose]() {
313+
// Assume the error case here...
314+
this.abort();
315+
}
316+
}
317+
318+
function foo() {
319+
using res = new MyDisposableResource();
320+
if (someCondition) {
321+
// Early return, ensure the resource is disposed of
322+
res.close();
323+
return;
324+
}
325+
// do other stuff
326+
res.close();
327+
}
328+
```
329+
330+
This is because of the fact that, when the disposer is called, it has no way
331+
of knowing if there is a pending exception or not and it is generally safest
332+
to assume that it is being called in an exceptional state.
333+
334+
Many types of disposable objects make no differentiation between success and
335+
exception cases, in which case relying entirely on `using` is just fine (and
336+
preferred). The disposable returned by `setTimeout()` is a good example here.
337+
All that does is call `clearTimeout()` and it does not matter if the block
338+
errored or not.
339+
340+
3. Remember that disposers are invoked in a stack, in the reverse order
341+
in which there were created. For example,
342+
343+
```js
344+
class MyDisposable {
345+
constructor(name) {
346+
this.name = name;
347+
}
348+
[Symbol.dispose]() {
349+
console.log(`Disposing ${this.name}`);
350+
}
351+
}
352+
353+
{
354+
using a = new MyDisposable('A');
355+
using b = new MyDisposable('B');
356+
using c = new MyDisposable('C');
357+
// When this block exits, the disposal methods will be called in the
358+
// reverse order: C, B, A.
359+
}
360+
```
361+
362+
Because of this, it is important to consider the possible relationships
363+
between disposable objects. For example, if one disposable object holds a
364+
reference to another disposable object the cleanup order may be important.
365+
If disposers are properly idempotent, however, this should not cause any
366+
issue, but it still requires careful consideration.

0 commit comments

Comments
 (0)